From 799ab09fee0107f9b7864d02ff63ea189651540a Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sat, 29 Nov 2025 11:18:32 -0800 Subject: [PATCH 1/3] chore(deps): add `@floating-ui/dom` dependency --- bun.lock | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/bun.lock b/bun.lock index d260ce8ba3..badd9b5191 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "carbon-components-svelte", "dependencies": { + "@floating-ui/dom": "^1.7.4", "@ibm/telemetry-js": "^1.5.0", "flatpickr": "4.6.9", }, @@ -135,6 +136,12 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.8", "", { "os": "win32", "cpu": "x64" }, "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@hutson/parse-repository-url": ["@hutson/parse-repository-url@3.0.2", "", {}, "sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q=="], "@ibm/telemetry-js": ["@ibm/telemetry-js@1.10.2", "", { "bin": { "ibmtelemetry": "dist/collect.js" } }, "sha512-F8+/NNUwtm8BuFz18O9KPvIFTFDo8GUSoyhPxPjEpk7nEyEzWGfhIiEPhL00B2NdHRLDSljh3AiCfSnL/tutiQ=="], diff --git a/package.json b/package.json index 4ba373ec5b..6b839ad01a 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "release": "standard-version && bun build:docs" }, "dependencies": { + "@floating-ui/dom": "^1.7.4", "@ibm/telemetry-js": "^1.5.0", "flatpickr": "4.6.9" }, From 65ecd2067d4520fbff8881f3b4c22a0d8acebd25 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sat, 29 Nov 2025 11:19:12 -0800 Subject: [PATCH 2/3] feat(portal): add `FloatingPortal` component Closes #2280 --- COMPONENT_INDEX.md | 24 +++- docs/src/COMPONENT_API.json | 68 ++++++++++- src/Portal/FloatingPortal.svelte | 83 ++++++++++++++ src/Portal/index.js | 1 + src/index.js | 1 + tests/Portal/FloatingPortal.test.svelte | 31 +++++ tests/Portal/FloatingPortal.test.ts | 146 ++++++++++++++++++++++++ types/Portal/FloatingPortal.svelte.d.ts | 45 ++++++++ types/index.d.ts | 1 + 9 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 src/Portal/FloatingPortal.svelte create mode 100644 tests/Portal/FloatingPortal.test.svelte create mode 100644 tests/Portal/FloatingPortal.test.ts create mode 100644 types/Portal/FloatingPortal.svelte.d.ts diff --git a/COMPONENT_INDEX.md b/COMPONENT_INDEX.md index 78932fc562..10a636d1cf 100644 --- a/COMPONENT_INDEX.md +++ b/COMPONENT_INDEX.md @@ -1,6 +1,6 @@ # Component Index -> 169 components exported from carbon-components-svelte@0.94.0. +> 170 components exported from carbon-components-svelte@0.94.0. ## Components @@ -45,6 +45,7 @@ - [`FileUploaderItem`](#fileuploaderitem) - [`FileUploaderSkeleton`](#fileuploaderskeleton) - [`Filename`](#filename) +- [`FloatingPortal`](#floatingportal) - [`FluidForm`](#fluidform) - [`Form`](#form) - [`FormGroup`](#formgroup) @@ -1441,6 +1442,27 @@ None. | click | forwarded | -- | -- | | keydown | forwarded | -- | -- | +## `FloatingPortal` + +### Props + +| Prop name | Required | Kind | Reactive | Type | Default value | Description | +| :--------- | :------- | :--------------- | :------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------ | +| reference | No | let | No | HTMLElement | null | null | Reference element to position the portal relative to | +| placement | No | let | No | "top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end" | "bottom" | Placement of the floating portal relative to the reference element | +| offset | No | let | No | number | 0 | Offset in pixels from the reference element | +| autoUpdate | No | let | No | boolean | true | Set to `true` to enable auto-update positioning on scroll/resize | + +### Slots + +| Slot name | Default | Props | Fallback | +| :-------- | :------ | :---------------------------------- | :------- | +| -- | Yes | Record | -- | + +### Events + +None. + ## `FluidForm` ### Props diff --git a/docs/src/COMPONENT_API.json b/docs/src/COMPONENT_API.json index 75e806f54a..03b28c739d 100644 --- a/docs/src/COMPONENT_API.json +++ b/docs/src/COMPONENT_API.json @@ -1,5 +1,5 @@ { - "total": 169, + "total": 170, "components": [ { "moduleName": "Accordion", @@ -5663,6 +5663,72 @@ }, "contexts": [] }, + { + "moduleName": "FloatingPortal", + "filePath": "src/Portal/FloatingPortal.svelte", + "props": [ + { + "name": "reference", + "kind": "let", + "description": "Reference element to position the portal relative to", + "type": "HTMLElement | null", + "value": "null", + "isFunction": false, + "isFunctionDeclaration": false, + "isRequired": false, + "constant": false, + "reactive": false + }, + { + "name": "placement", + "kind": "let", + "description": "Placement of the floating portal relative to the reference element", + "type": "\"top\" | \"top-start\" | \"top-end\" | \"right\" | \"right-start\" | \"right-end\" | \"bottom\" | \"bottom-start\" | \"bottom-end\" | \"left\" | \"left-start\" | \"left-end\"", + "value": "\"bottom\"", + "isFunction": false, + "isFunctionDeclaration": false, + "isRequired": false, + "constant": false, + "reactive": false + }, + { + "name": "offset", + "kind": "let", + "description": "Offset in pixels from the reference element", + "type": "number", + "value": "0", + "isFunction": false, + "isFunctionDeclaration": false, + "isRequired": false, + "constant": false, + "reactive": false + }, + { + "name": "autoUpdate", + "kind": "let", + "description": "Set to `true` to enable auto-update positioning on scroll/resize", + "type": "boolean", + "value": "true", + "isFunction": false, + "isFunctionDeclaration": false, + "isRequired": false, + "constant": false, + "reactive": false + } + ], + "moduleExports": [], + "slots": [ + { + "name": null, + "default": true, + "slot_props": "Record" + } + ], + "events": [], + "typedefs": [], + "generics": null, + "contexts": [] + }, { "moduleName": "FluidForm", "filePath": "src/FluidForm/FluidForm.svelte", diff --git a/src/Portal/FloatingPortal.svelte b/src/Portal/FloatingPortal.svelte new file mode 100644 index 0000000000..0469501408 --- /dev/null +++ b/src/Portal/FloatingPortal.svelte @@ -0,0 +1,83 @@ + + + +
+ +
+
diff --git a/src/Portal/index.js b/src/Portal/index.js index b15448b4f5..5f640ef9f7 100644 --- a/src/Portal/index.js +++ b/src/Portal/index.js @@ -1 +1,2 @@ +export { default as FloatingPortal } from "./FloatingPortal.svelte"; export { default as Portal } from "./Portal.svelte"; diff --git a/src/index.js b/src/index.js index f1758cb788..fde3b450bd 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 FloatingPortal } from "./Portal/FloatingPortal.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"; diff --git a/tests/Portal/FloatingPortal.test.svelte b/tests/Portal/FloatingPortal.test.svelte new file mode 100644 index 0000000000..759ec4fe6c --- /dev/null +++ b/tests/Portal/FloatingPortal.test.svelte @@ -0,0 +1,31 @@ + + + + +{#if showPortal && reference} + + {portalContent} + +{/if} + diff --git a/tests/Portal/FloatingPortal.test.ts b/tests/Portal/FloatingPortal.test.ts new file mode 100644 index 0000000000..be794b51ff --- /dev/null +++ b/tests/Portal/FloatingPortal.test.ts @@ -0,0 +1,146 @@ +import { render, screen } from "@testing-library/svelte"; +import { tick } from "svelte"; +import FloatingPortalTest from "./FloatingPortal.test.svelte"; + +describe("FloatingPortal", () => { + afterEach(() => { + const existingContainer = document.querySelector( + "[data-portal]", + ) as HTMLElement; + existingContainer?.remove(); + }); + + it("renders floating portal content", async () => { + render(FloatingPortalTest); + + const referenceButton = await screen.findByText("Reference button"); + expect(referenceButton).toBeInTheDocument(); + + const portalContent = await screen.findByText("Floating portal content"); + expect(portalContent).toBeInTheDocument(); + }); + + it("positions portal relative to reference element", async () => { + render(FloatingPortalTest); + + const referenceButton = await screen.findByText("Reference button"); + const portalContent = await screen.findByText("Floating portal content"); + + expect(referenceButton).toBeInTheDocument(); + expect(portalContent).toBeInTheDocument(); + + const portalContainer = portalContent.closest("[data-portal]"); + expect(portalContainer).toBeInTheDocument(); + expect(portalContainer?.parentElement).toBe(document.body); + }); + + it("supports different placement options", async () => { + const testPlacement = async ( + placement: + | "top" + | "right" + | "bottom" + | "left" + | "top-start" + | "bottom-end", + ) => { + const { unmount } = render(FloatingPortalTest, { + props: { placement }, + }); + + const portalContent = await screen.findByText("Floating portal content"); + expect(portalContent).toBeInTheDocument(); + + const floatingElement = portalContent.parentElement; + assert(floatingElement instanceof HTMLElement); + const styles = window.getComputedStyle(floatingElement); + if (styles.position) { + expect(styles.position).toBe("absolute"); + } + + unmount(); + }; + + await testPlacement("top"); + await testPlacement("right"); + await testPlacement("bottom"); + await testPlacement("left"); + await testPlacement("top-start"); + await testPlacement("bottom-end"); + }); + + it("handles conditional rendering", async () => { + const { component } = render(FloatingPortalTest, { + props: { showPortal: false }, + }); + + let portalContent = screen.queryByText("Floating portal content"); + expect(portalContent).not.toBeInTheDocument(); + + component.$set({ showPortal: true }); + + portalContent = await screen.findByText("Floating portal content"); + expect(portalContent).toBeInTheDocument(); + + component.$set({ showPortal: false }); + await tick(); + + portalContent = screen.queryByText("Floating portal content"); + expect(portalContent).not.toBeInTheDocument(); + }); + + it("updates position when offset changes", async () => { + const { component } = render(FloatingPortalTest); + + const portalContent = await screen.findByText("Floating portal content"); + expect(portalContent).toBeInTheDocument(); + + const floatingElement = portalContent.parentElement; + assert(floatingElement instanceof HTMLElement); + + component.$set({ offset: 20 }); + await tick(); + + const styles = window.getComputedStyle(floatingElement); + if (styles.position) { + expect(styles.position).toBe("absolute"); + } + }); + + it("renders custom slot content", async () => { + render(FloatingPortalTest, { + props: { portalContent: "Custom floating content" }, + }); + + const portalContent = await screen.findByText("Custom floating content"); + expect(portalContent).toBeInTheDocument(); + }); + + it("cleans up auto-update on unmount", async () => { + const { unmount } = render(FloatingPortalTest); + + const portalContent = await screen.findByText("Floating portal content"); + expect(portalContent).toBeInTheDocument(); + + unmount(); + + const remainingContainer = document.querySelector("[data-portal]"); + expect(remainingContainer).not.toBeInTheDocument(); + }); + + it("works with autoUpdate disabled", async () => { + render(FloatingPortalTest, { + props: { autoUpdate: false }, + }); + + const portalContent = await screen.findByText("Floating portal content"); + expect(portalContent).toBeInTheDocument(); + + const floatingElement = portalContent.parentElement; + assert(floatingElement instanceof HTMLElement); + const styles = window.getComputedStyle(floatingElement); + if (styles.position) { + expect(styles.position).toBe("absolute"); + } + }); +}); diff --git a/types/Portal/FloatingPortal.svelte.d.ts b/types/Portal/FloatingPortal.svelte.d.ts new file mode 100644 index 0000000000..81a2dfcf6f --- /dev/null +++ b/types/Portal/FloatingPortal.svelte.d.ts @@ -0,0 +1,45 @@ +import { SvelteComponentTyped } from "svelte"; + +export type FloatingPortalProps = { + /** + * Reference element to position the portal relative to + * @default null + */ + reference?: HTMLElement | null; + + /** + * Placement of the floating portal relative to the reference element + * @default "bottom" + */ + placement?: + | "top" + | "top-start" + | "top-end" + | "right" + | "right-start" + | "right-end" + | "bottom" + | "bottom-start" + | "bottom-end" + | "left" + | "left-start" + | "left-end"; + + /** + * Offset in pixels from the reference element + * @default 0 + */ + offset?: number; + + /** + * Set to `true` to enable auto-update positioning on scroll/resize + * @default true + */ + autoUpdate?: boolean; +}; + +export default class FloatingPortal extends SvelteComponentTyped< + FloatingPortalProps, + Record, + { default: Record } +> {} diff --git a/types/index.d.ts b/types/index.d.ts index f1758cb788..fde3b450bd 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 FloatingPortal } from "./Portal/FloatingPortal.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"; From 6de743b7839344e1594fbcdbe66dc7425a2ac518 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Sat, 29 Nov 2025 11:19:39 -0800 Subject: [PATCH 3/3] docs(portal): add floating portal examples --- docs/src/pages/components/Portal.svx | 12 +++++ .../pages/framed/Portal/FloatingPortal.svelte | 22 ++++++++ .../Portal/FloatingPortalPlacement.svelte | 53 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 docs/src/pages/framed/Portal/FloatingPortal.svelte create mode 100644 docs/src/pages/framed/Portal/FloatingPortalPlacement.svelte diff --git a/docs/src/pages/components/Portal.svx b/docs/src/pages/components/Portal.svx index ce808af29b..4934933ed8 100644 --- a/docs/src/pages/components/Portal.svx +++ b/docs/src/pages/components/Portal.svx @@ -28,3 +28,15 @@ Use the `tag` prop to specify a custom HTML element. By default, Portal uses a ` 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. + +## Floating portal + +The `FloatingPortal` component automatically positions content relative to a reference element using [Floating UI](https://floating-ui.com/). This is useful for dropdowns, popovers, and tooltips. + + + +## Floating portal placement + +Control the placement of the floating portal using the `placement` prop. + + diff --git a/docs/src/pages/framed/Portal/FloatingPortal.svelte b/docs/src/pages/framed/Portal/FloatingPortal.svelte new file mode 100644 index 0000000000..353f7048f7 --- /dev/null +++ b/docs/src/pages/framed/Portal/FloatingPortal.svelte @@ -0,0 +1,22 @@ + + + + +{#if showPortal && buttonRef} + +
+

Floating portal content

+

+ This content is positioned relative to the button above. +

+
+
+{/if} + diff --git a/docs/src/pages/framed/Portal/FloatingPortalPlacement.svelte b/docs/src/pages/framed/Portal/FloatingPortalPlacement.svelte new file mode 100644 index 0000000000..437eff43a7 --- /dev/null +++ b/docs/src/pages/framed/Portal/FloatingPortalPlacement.svelte @@ -0,0 +1,53 @@ + + +
+ {#each placements as placement} +
+ + {#if openPlacements[placement] && buttonRefs[placement]} + +
+ {placement} +
+
+ {/if} +
+ {/each} +
+