Skip to content

Commit 5d6de8d

Browse files
committed
feat(portal): add FloatingPortal component
Closes #2280
1 parent 799ab09 commit 5d6de8d

File tree

6 files changed

+307
-0
lines changed

6 files changed

+307
-0
lines changed

src/Portal/FloatingPortal.svelte

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<script>
2+
import {
3+
computePosition,
4+
autoUpdate as floatingAutoUpdate,
5+
offset as offsetMiddleware,
6+
} from "@floating-ui/dom";
7+
import { onMount, tick } from "svelte";
8+
import Portal from "./Portal.svelte";
9+
10+
/**
11+
* Reference element to position the portal relative to
12+
* @type {HTMLElement | null}
13+
*/
14+
export let reference = null;
15+
16+
/**
17+
* Placement of the floating portal relative to the reference element
18+
* @type {"top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end"}
19+
*/
20+
export let placement = "bottom";
21+
22+
/**
23+
* Offset in pixels from the reference element
24+
* @type {number}
25+
*/
26+
export let offset = 0;
27+
28+
/**
29+
* Set to `true` to enable auto-update positioning on scroll/resize
30+
* @type {boolean}
31+
*/
32+
export let autoUpdate = true;
33+
34+
/** @type {null | HTMLElement} */
35+
let floatingElement = null;
36+
37+
/** @type {null | ReturnType<typeof floatingAutoUpdate>} */
38+
let cleanup = null;
39+
40+
async function updatePosition() {
41+
if (!reference || !floatingElement) return;
42+
43+
await tick();
44+
45+
computePosition(reference, floatingElement, {
46+
placement,
47+
middleware: [offsetMiddleware(offset)],
48+
}).then(({ x, y }) => {
49+
if (!floatingElement) return;
50+
Object.assign(floatingElement.style, {
51+
position: "absolute",
52+
left: `${x}px`,
53+
top: `${y}px`,
54+
});
55+
});
56+
}
57+
58+
$: if (reference && floatingElement) {
59+
updatePosition();
60+
61+
// Re-setup auto-update if enabled
62+
if (autoUpdate) {
63+
cleanup?.();
64+
cleanup = floatingAutoUpdate(reference, floatingElement, updatePosition);
65+
}
66+
}
67+
68+
onMount(() => {
69+
if (autoUpdate && reference && floatingElement) {
70+
cleanup = floatingAutoUpdate(reference, floatingElement, updatePosition);
71+
}
72+
73+
return () => {
74+
cleanup?.();
75+
};
76+
});
77+
</script>
78+
79+
<Portal>
80+
<div bind:this={floatingElement}>
81+
<slot />
82+
</div>
83+
</Portal>

src/Portal/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export { default as FloatingPortal } from "./FloatingPortal.svelte";
12
export { default as Portal } from "./Portal.svelte";

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export { default as Pagination } from "./Pagination/Pagination.svelte";
9292
export { default as PaginationSkeleton } from "./Pagination/PaginationSkeleton.svelte";
9393
export { default as PaginationNav } from "./PaginationNav/PaginationNav.svelte";
9494
export { default as Popover } from "./Popover/Popover.svelte";
95+
export { default as FloatingPortal } from "./Portal/FloatingPortal.svelte";
9596
export { default as Portal } from "./Portal/Portal.svelte";
9697
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte";
9798
export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script lang="ts">
2+
import { FloatingPortal } from "carbon-components-svelte";
3+
4+
export let showPortal = true;
5+
export let reference: HTMLElement | null = null;
6+
export let placement:
7+
| "top"
8+
| "top-start"
9+
| "top-end"
10+
| "right"
11+
| "right-start"
12+
| "right-end"
13+
| "bottom"
14+
| "bottom-start"
15+
| "bottom-end"
16+
| "left"
17+
| "left-start"
18+
| "left-end" = "bottom";
19+
export let offset = 0;
20+
export let autoUpdate = true;
21+
export let portalContent = "Floating portal content";
22+
</script>
23+
24+
<button bind:this={reference} type="button">Reference button</button>
25+
26+
{#if showPortal && reference}
27+
<FloatingPortal {reference} {placement} {offset} {autoUpdate}>
28+
{portalContent}
29+
</FloatingPortal>
30+
{/if}
31+
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { render, screen } from "@testing-library/svelte";
2+
import { tick } from "svelte";
3+
import FloatingPortalTest from "./FloatingPortal.test.svelte";
4+
5+
describe("FloatingPortal", () => {
6+
afterEach(() => {
7+
const existingContainer = document.querySelector(
8+
"[data-portal]",
9+
) as HTMLElement;
10+
existingContainer?.remove();
11+
});
12+
13+
it("renders floating portal content", async () => {
14+
render(FloatingPortalTest);
15+
16+
const referenceButton = await screen.findByText("Reference button");
17+
expect(referenceButton).toBeInTheDocument();
18+
19+
const portalContent = await screen.findByText("Floating portal content");
20+
expect(portalContent).toBeInTheDocument();
21+
});
22+
23+
it("positions portal relative to reference element", async () => {
24+
render(FloatingPortalTest);
25+
26+
const referenceButton = await screen.findByText("Reference button");
27+
const portalContent = await screen.findByText("Floating portal content");
28+
29+
expect(referenceButton).toBeInTheDocument();
30+
expect(portalContent).toBeInTheDocument();
31+
32+
const portalContainer = portalContent.closest("[data-portal]");
33+
expect(portalContainer).toBeInTheDocument();
34+
expect(portalContainer?.parentElement).toBe(document.body);
35+
});
36+
37+
it("supports different placement options", async () => {
38+
const testPlacement = async (
39+
placement:
40+
| "top"
41+
| "right"
42+
| "bottom"
43+
| "left"
44+
| "top-start"
45+
| "bottom-end",
46+
) => {
47+
const { unmount } = render(FloatingPortalTest, {
48+
props: { placement },
49+
});
50+
51+
const portalContent = await screen.findByText("Floating portal content");
52+
expect(portalContent).toBeInTheDocument();
53+
54+
const floatingElement = portalContent.parentElement;
55+
assert(floatingElement instanceof HTMLElement);
56+
const styles = window.getComputedStyle(floatingElement);
57+
if (styles.position) {
58+
expect(styles.position).toBe("absolute");
59+
}
60+
61+
unmount();
62+
};
63+
64+
await testPlacement("top");
65+
await testPlacement("right");
66+
await testPlacement("bottom");
67+
await testPlacement("left");
68+
await testPlacement("top-start");
69+
await testPlacement("bottom-end");
70+
});
71+
72+
it("handles conditional rendering", async () => {
73+
const { component } = render(FloatingPortalTest, {
74+
props: { showPortal: false },
75+
});
76+
77+
let portalContent = screen.queryByText("Floating portal content");
78+
expect(portalContent).not.toBeInTheDocument();
79+
80+
component.$set({ showPortal: true });
81+
82+
portalContent = await screen.findByText("Floating portal content");
83+
expect(portalContent).toBeInTheDocument();
84+
85+
component.$set({ showPortal: false });
86+
await tick();
87+
88+
portalContent = screen.queryByText("Floating portal content");
89+
expect(portalContent).not.toBeInTheDocument();
90+
});
91+
92+
it("updates position when offset changes", async () => {
93+
const { component } = render(FloatingPortalTest);
94+
95+
const portalContent = await screen.findByText("Floating portal content");
96+
expect(portalContent).toBeInTheDocument();
97+
98+
const floatingElement = portalContent.parentElement;
99+
assert(floatingElement instanceof HTMLElement);
100+
101+
component.$set({ offset: 20 });
102+
await tick();
103+
104+
const styles = window.getComputedStyle(floatingElement);
105+
if (styles.position) {
106+
expect(styles.position).toBe("absolute");
107+
}
108+
});
109+
110+
it("renders custom slot content", async () => {
111+
render(FloatingPortalTest, {
112+
props: { portalContent: "Custom floating content" },
113+
});
114+
115+
const portalContent = await screen.findByText("Custom floating content");
116+
expect(portalContent).toBeInTheDocument();
117+
});
118+
119+
it("cleans up auto-update on unmount", async () => {
120+
const { unmount } = render(FloatingPortalTest);
121+
122+
const portalContent = await screen.findByText("Floating portal content");
123+
expect(portalContent).toBeInTheDocument();
124+
125+
unmount();
126+
127+
const remainingContainer = document.querySelector("[data-portal]");
128+
expect(remainingContainer).not.toBeInTheDocument();
129+
});
130+
131+
it("works with autoUpdate disabled", async () => {
132+
render(FloatingPortalTest, {
133+
props: { autoUpdate: false },
134+
});
135+
136+
const portalContent = await screen.findByText("Floating portal content");
137+
expect(portalContent).toBeInTheDocument();
138+
139+
const floatingElement = portalContent.parentElement;
140+
assert(floatingElement instanceof HTMLElement);
141+
const styles = window.getComputedStyle(floatingElement);
142+
if (styles.position) {
143+
expect(styles.position).toBe("absolute");
144+
}
145+
});
146+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { SvelteComponentTyped } from "svelte";
2+
3+
export type FloatingPortalProps = {
4+
/**
5+
* Reference element to position the portal relative to
6+
* @default null
7+
*/
8+
reference?: HTMLElement | null;
9+
10+
/**
11+
* Placement of the floating portal relative to the reference element
12+
* @default "bottom"
13+
*/
14+
placement?:
15+
| "top"
16+
| "top-start"
17+
| "top-end"
18+
| "right"
19+
| "right-start"
20+
| "right-end"
21+
| "bottom"
22+
| "bottom-start"
23+
| "bottom-end"
24+
| "left"
25+
| "left-start"
26+
| "left-end";
27+
28+
/**
29+
* Offset in pixels from the reference element
30+
* @default 0
31+
*/
32+
offset?: number;
33+
34+
/**
35+
* Set to `true` to enable auto-update positioning on scroll/resize
36+
* @default true
37+
*/
38+
autoUpdate?: boolean;
39+
};
40+
41+
export default class FloatingPortal extends SvelteComponentTyped<
42+
FloatingPortalProps,
43+
Record<string, any>,
44+
{ default: Record<string, never> }
45+
> {}

0 commit comments

Comments
 (0)