diff --git a/COMPONENT_INDEX.md b/COMPONENT_INDEX.md index 7bd6ce2b6b..78932fc562 100644 --- a/COMPONENT_INDEX.md +++ b/COMPONENT_INDEX.md @@ -1,6 +1,6 @@ # Component Index -> 168 components exported from carbon-components-svelte@0.94.0. +> 169 components exported from carbon-components-svelte@0.94.0. ## Components @@ -96,6 +96,7 @@ - [`PaginationSkeleton`](#paginationskeleton) - [`PasswordInput`](#passwordinput) - [`Popover`](#popover) +- [`Portal`](#portal) - [`ProgressBar`](#progressbar) - [`ProgressIndicator`](#progressindicator) - [`ProgressIndicatorSkeleton`](#progressindicatorskeleton) @@ -2890,6 +2891,24 @@ None. | :------------ | :--------- | :------------------------------------ | :---------- | | click:outside | dispatched | { target: HTMLElement; } | -- | +## `Portal` + +### Props + +| Prop name | Required | Kind | Reactive | Type | Default value | Description | +| :-------- | :------- | :--------------- | :------- | ---------------------------------------- | ------------------ | --------------------- | +| tag | No | let | No | keyof HTMLElementTagNameMap | "div" | Specify the tag name. | + +### Slots + +| Slot name | Default | Props | Fallback | +| :-------- | :------ | :---------------------------------- | :------- | +| -- | Yes | Record | -- | + +### Events + +None. + ## `ProgressBar` ### Props diff --git a/docs/src/COMPONENT_API.json b/docs/src/COMPONENT_API.json index 3a14f5ddf7..75e806f54a 100644 --- a/docs/src/COMPONENT_API.json +++ b/docs/src/COMPONENT_API.json @@ -1,5 +1,5 @@ { - "total": 168, + "total": 169, "components": [ { "moduleName": "Accordion", @@ -11650,6 +11650,40 @@ }, "contexts": [] }, + { + "moduleName": "Portal", + "filePath": "src/Portal/Portal.svelte", + "props": [ + { + "name": "tag", + "kind": "let", + "description": "Specify the tag name.", + "type": "keyof HTMLElementTagNameMap", + "value": "\"div\"", + "isFunction": false, + "isFunctionDeclaration": false, + "isRequired": false, + "constant": false, + "reactive": false + } + ], + "moduleExports": [], + "slots": [ + { + "name": null, + "default": true, + "slot_props": "Record" + } + ], + "events": [], + "typedefs": [], + "generics": null, + "rest_props": { + "type": "Element", + "name": "svelte:element" + }, + "contexts": [] + }, { "moduleName": "ProgressBar", "filePath": "src/ProgressBar/ProgressBar.svelte", diff --git a/docs/src/pages/components/ComposedModal.svx b/docs/src/pages/components/ComposedModal.svx index 188b0035d0..a0149cbe96 100644 --- a/docs/src/pages/components/ComposedModal.svx +++ b/docs/src/pages/components/ComposedModal.svx @@ -18,6 +18,12 @@ Create a modal with a header, body, and footer. Each section can be customized i +## With Portal + +Wrap `ComposedModal` in a `Portal` to ensure it renders above all z-index stacking contexts and parent overflow constraints, preventing visual clipping and layering issues. + + + ## Prevent default close behavior The modal dispatches a cancelable `close` event, allowing you to prevent the modal from closing using `e.preventDefault()`. The event includes a `trigger` property indicating what triggered the close attempt: `"escape-key"`, `"outside-click"`, or `"close-button"`. diff --git a/docs/src/pages/components/Modal.svx b/docs/src/pages/components/Modal.svx index 281e3001a3..fc247f7190 100644 --- a/docs/src/pages/components/Modal.svx +++ b/docs/src/pages/components/Modal.svx @@ -16,6 +16,12 @@ Create a basic modal dialog with primary and secondary actions. This variant is +## With Portal + +Wrap `Modal` in a `Portal` to escape parent containers with `overflow: hidden` or z-index stacking contexts. This ensures the modal appears above all content and isn't clipped by parent boundaries. + + + ## Custom focus Control which element receives focus when the modal opens. Use `selectorPrimaryFocus` to specify the target element using CSS selectors. diff --git a/docs/src/pages/components/Portal.svx b/docs/src/pages/components/Portal.svx new file mode 100644 index 0000000000..ce808af29b --- /dev/null +++ b/docs/src/pages/components/Portal.svx @@ -0,0 +1,30 @@ + + +The `Portal` component renders its content directly into `document.body`, allowing you to escape parent overflow constraints and z-index stacking contexts. + +## Default Portal + +Render content in a portal. This is useful for modals, tooltips, and menus that need to escape parent containers. + + + +## Multiple Portals + +Each portal instance is independent and creates its own element in `document.body`. Each portal is automatically cleaned up when it is removed. + + + +## Custom Tag + +Use the `tag` prop to specify a custom HTML element. By default, Portal uses a `div` element. + + + +## Modal with Portal + +Wrap `Modal` in a `Portal` to escape parent containers with `overflow: hidden` or z-index stacking contexts. This ensures the modal appears above all content and isn't clipped by parent boundaries. + + diff --git a/docs/src/pages/framed/Portal/BasicPortal.svelte b/docs/src/pages/framed/Portal/BasicPortal.svelte new file mode 100644 index 0000000000..53ba22462f --- /dev/null +++ b/docs/src/pages/framed/Portal/BasicPortal.svelte @@ -0,0 +1,9 @@ + + +
+
This is rendered inside the div
+
+ This is rendered outside of the div +
diff --git a/docs/src/pages/framed/Portal/ComposedModalPortal.svelte b/docs/src/pages/framed/Portal/ComposedModalPortal.svelte new file mode 100644 index 0000000000..be3978fcc0 --- /dev/null +++ b/docs/src/pages/framed/Portal/ComposedModalPortal.svelte @@ -0,0 +1,39 @@ + + + +

+ This container hides overflowing content. Without a portal, the modal would + be clipped. +

+
+ +
+ + + + +

+ This composed modal is rendered in a portal, ensuring it appears above + all z-index stacking contexts and parent overflow constraints. +

+
+ (open = false)} + /> +
+
+
diff --git a/docs/src/pages/framed/Portal/CustomTagPortal.svelte b/docs/src/pages/framed/Portal/CustomTagPortal.svelte new file mode 100644 index 0000000000..427ca95d6a --- /dev/null +++ b/docs/src/pages/framed/Portal/CustomTagPortal.svelte @@ -0,0 +1,7 @@ + + + + This portal uses a section tag. + diff --git a/docs/src/pages/framed/Portal/ModalPortal.svelte b/docs/src/pages/framed/Portal/ModalPortal.svelte new file mode 100644 index 0000000000..6000b6d4ee --- /dev/null +++ b/docs/src/pages/framed/Portal/ModalPortal.svelte @@ -0,0 +1,29 @@ + + + +

+ This container hides overflowing content. Without a portal, the modal would + be clipped. +

+
+ +
+ + (open = false)} + > +

+ This modal is rendered in a portal, escaping the parent container's + overflow constraints and ensuring it appears above all other content. +

+
+
+
diff --git a/docs/src/pages/framed/Portal/MultiplePortals.svelte b/docs/src/pages/framed/Portal/MultiplePortals.svelte new file mode 100644 index 0000000000..9a1142782b --- /dev/null +++ b/docs/src/pages/framed/Portal/MultiplePortals.svelte @@ -0,0 +1,27 @@ + + + + + + + +{#if showPortal1} + + Portal content 1 + +{/if} + +{#if showPortal2} + + Portal content 2 + +{/if} diff --git a/src/Portal/Portal.svelte b/src/Portal/Portal.svelte new file mode 100644 index 0000000000..9fe9470d38 --- /dev/null +++ b/src/Portal/Portal.svelte @@ -0,0 +1,38 @@ + + + + + diff --git a/src/Portal/index.js b/src/Portal/index.js new file mode 100644 index 0000000000..b15448b4f5 --- /dev/null +++ b/src/Portal/index.js @@ -0,0 +1 @@ +export { default as Portal } from "./Portal.svelte"; diff --git a/src/index.js b/src/index.js index 16bd663455..f1758cb788 100644 --- a/src/index.js +++ b/src/index.js @@ -92,6 +92,7 @@ export { default as Pagination } from "./Pagination/Pagination.svelte"; export { default as PaginationSkeleton } from "./Pagination/PaginationSkeleton.svelte"; export { default as PaginationNav } from "./PaginationNav/PaginationNav.svelte"; export { default as Popover } from "./Popover/Popover.svelte"; +export { default as Portal } from "./Portal/Portal.svelte"; export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"; export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte"; export { default as ProgressIndicatorSkeleton } from "./ProgressIndicator/ProgressIndicatorSkeleton.svelte"; diff --git a/tests/Portal/Portal.multiple.test.svelte b/tests/Portal/Portal.multiple.test.svelte new file mode 100644 index 0000000000..c5297ca82c --- /dev/null +++ b/tests/Portal/Portal.multiple.test.svelte @@ -0,0 +1,16 @@ + + + + Portal content 1 + + + + Portal content 2 + + + + Portal content 3 + + diff --git a/tests/Portal/Portal.test.svelte b/tests/Portal/Portal.test.svelte new file mode 100644 index 0000000000..c75204c699 --- /dev/null +++ b/tests/Portal/Portal.test.svelte @@ -0,0 +1,14 @@ + + +{#if showPortal} + + {portalContent} + +{/if} + diff --git a/tests/Portal/Portal.test.ts b/tests/Portal/Portal.test.ts new file mode 100644 index 0000000000..752692e1f7 --- /dev/null +++ b/tests/Portal/Portal.test.ts @@ -0,0 +1,187 @@ +import { render, screen } from "@testing-library/svelte"; +import { tick } from "svelte"; +import PortalMultipleTest from "./Portal.multiple.test.svelte"; +import PortalTest from "./Portal.test.svelte"; + +describe("Portal", () => { + afterEach(() => { + const existingPortals = document.querySelectorAll("[data-portal]"); + for (const portal of existingPortals) { + portal.remove(); + } + }); + + it("renders portal content", async () => { + render(PortalTest); + + const portalContent = await screen.findByText("Portal content"); + expect(portalContent).toBeInTheDocument(); + + const portalElement = portalContent.closest("[data-portal]"); + assert(portalElement instanceof HTMLElement); + expect(portalElement.parentElement).toBe(document.body); + expect(portalElement.tagName).toBe("DIV"); + }); + + it("multiple portals each have their own instance", async () => { + render(PortalMultipleTest); + + const portalContent1 = await screen.findByText("Portal content 1"); + const portalContent2 = await screen.findByText("Portal content 2"); + const portalContent3 = await screen.findByText("Portal content 3"); + + expect(portalContent1).toBeInTheDocument(); + expect(portalContent2).toBeInTheDocument(); + expect(portalContent3).toBeInTheDocument(); + + const portalElement1 = portalContent1.closest("[data-portal]"); + const portalElement2 = portalContent2.closest("[data-portal]"); + const portalElement3 = portalContent3.closest("[data-portal]"); + + expect(portalElement1).not.toBe(portalElement2); + expect(portalElement2).not.toBe(portalElement3); + expect(portalElement1).not.toBe(portalElement3); + expect(portalElement1).toBeInTheDocument(); + expect(portalElement2).toBeInTheDocument(); + expect(portalElement3).toBeInTheDocument(); + }); + + it("removes portal element when instance is unmounted", async () => { + const { unmount } = render(PortalTest); + + const portalContent = await screen.findByText("Portal content"); + const portalElement = portalContent.closest("[data-portal]"); + assert(portalElement instanceof HTMLElement); + + unmount(); + + const remainingPortal = document.querySelector("[data-portal]"); + expect(remainingPortal).not.toBeInTheDocument(); + }); + + it("each portal instance is independent", async () => { + const { unmount: unmount1 } = render(PortalTest, { + props: { portalContent: "Portal 1" }, + }); + + const portalContent1 = await screen.findByText("Portal 1"); + const portalElement1 = portalContent1.closest("[data-portal]"); + assert(portalElement1 instanceof HTMLElement); + + const { unmount: unmount2 } = render(PortalTest, { + props: { portalContent: "Portal 2" }, + }); + + const portalContent2 = await screen.findByText("Portal 2"); + const portalElement2 = portalContent2.closest("[data-portal]"); + assert(portalElement2 instanceof HTMLElement); + + expect(portalElement1).not.toBe(portalElement2); + + unmount1(); + + const remainingPortals = document.querySelectorAll("[data-portal]"); + expect(remainingPortals).toHaveLength(1); + expect(await screen.findByText("Portal 2")).toBeInTheDocument(); + + unmount2(); + + const finalPortals = document.querySelectorAll("[data-portal]"); + expect(finalPortals).toHaveLength(0); + }); + + it("renders slot content correctly", async () => { + render(PortalTest, { + props: { portalContent: "Custom portal content" }, + }); + + const portalContent = await screen.findByText("Custom portal content"); + expect(portalContent).toBeInTheDocument(); + }); + + it("handles conditional rendering", async () => { + const { component } = render(PortalTest, { + props: { showPortal: false }, + }); + + let portalContent = screen.queryByText("Portal content"); + expect(portalContent).not.toBeInTheDocument(); + + let portalElement = document.querySelector("[data-portal]"); + expect(portalElement).not.toBeInTheDocument(); + + component.$set({ showPortal: true }); + + portalContent = await screen.findByText("Portal content"); + expect(portalContent).toBeInTheDocument(); + + portalElement = portalContent.closest("[data-portal]"); + expect(portalElement).toBeInTheDocument(); + + component.$set({ showPortal: false }); + await tick(); + + portalContent = screen.queryByText("Portal content"); + expect(portalContent).not.toBeInTheDocument(); + + portalElement = document.querySelector("[data-portal]"); + expect(portalElement).not.toBeInTheDocument(); + }); + + it("uses custom tag when tag prop is specified", async () => { + render(PortalTest, { + props: { tag: "section" }, + }); + + const portalContent = await screen.findByText("Portal content"); + const portalElement = portalContent.closest("[data-portal]"); + assert(portalElement instanceof HTMLElement); + + expect(portalElement.tagName).toBe("SECTION"); + }); + + it("supports different custom tags", async () => { + const { unmount: unmount1 } = render(PortalTest, { + props: { portalContent: "Article portal", tag: "article" }, + }); + + const portalContent1 = await screen.findByText("Article portal"); + const portalElement1 = portalContent1.closest("[data-portal]"); + assert(portalElement1 instanceof HTMLElement); + expect(portalElement1.tagName).toBe("ARTICLE"); + + const { unmount: unmount2 } = render(PortalTest, { + props: { portalContent: "Section portal", tag: "section" }, + }); + + const portalContent2 = await screen.findByText("Section portal"); + const portalElement2 = portalContent2.closest("[data-portal]"); + assert(portalElement2 instanceof HTMLElement); + expect(portalElement2.tagName).toBe("SECTION"); + + unmount1(); + unmount2(); + }); + + it("forwards rest props to the portal element", async () => { + render(PortalTest, { + props: { + class: "custom-portal-class", + id: "test-portal-id", + "data-testid": "portal-test", + "aria-label": "Test portal", + style: "background-color: red;", + }, + }); + + const portalContent = await screen.findByText("Portal content"); + const portalElement = portalContent.closest("[data-portal]"); + assert(portalElement instanceof HTMLElement); + + expect(portalElement).toHaveClass("custom-portal-class"); + expect(portalElement).toHaveAttribute("id", "test-portal-id"); + expect(portalElement).toHaveAttribute("data-testid", "portal-test"); + expect(portalElement).toHaveAttribute("aria-label", "Test portal"); + expect(portalElement.getAttribute("style")).toContain("background-color"); + }); +}); diff --git a/types/Portal/Portal.svelte.d.ts b/types/Portal/Portal.svelte.d.ts new file mode 100644 index 0000000000..64f783d888 --- /dev/null +++ b/types/Portal/Portal.svelte.d.ts @@ -0,0 +1,22 @@ +import type { SvelteComponentTyped } from "svelte"; +import type { SvelteHTMLElements } from "svelte/elements"; + +type $RestProps = SvelteHTMLElements["svelte:element"]; + +type $Props = { + /** + * Specify the tag name. + * @default "div" + */ + tag?: keyof HTMLElementTagNameMap; + + [key: `data-${string}`]: any; +}; + +export type PortalProps = Omit<$RestProps, keyof $Props> & $Props; + +export default class Portal extends SvelteComponentTyped< + PortalProps, + Record, + { default: Record } +> {} diff --git a/types/index.d.ts b/types/index.d.ts index 16bd663455..f1758cb788 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -92,6 +92,7 @@ export { default as Pagination } from "./Pagination/Pagination.svelte"; export { default as PaginationSkeleton } from "./Pagination/PaginationSkeleton.svelte"; export { default as PaginationNav } from "./PaginationNav/PaginationNav.svelte"; export { default as Popover } from "./Popover/Popover.svelte"; +export { default as Portal } from "./Portal/Portal.svelte"; export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"; export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte"; export { default as ProgressIndicatorSkeleton } from "./ProgressIndicator/ProgressIndicatorSkeleton.svelte";