Skip to content

Commit d73570d

Browse files
committed
feat(portal): add Portal component
Closes #2280
1 parent e06340c commit d73570d

File tree

10 files changed

+313
-2
lines changed

10 files changed

+313
-2
lines changed

COMPONENT_INDEX.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Component Index
22

3-
> 168 components exported from carbon-components-svelte@0.94.0.
3+
> 169 components exported from carbon-components-svelte@0.94.0.
44
55
## Components
66

@@ -96,6 +96,7 @@
9696
- [`PaginationSkeleton`](#paginationskeleton)
9797
- [`PasswordInput`](#passwordinput)
9898
- [`Popover`](#popover)
99+
- [`Portal`](#portal)
99100
- [`ProgressBar`](#progressbar)
100101
- [`ProgressIndicator`](#progressindicator)
101102
- [`ProgressIndicatorSkeleton`](#progressindicatorskeleton)
@@ -2890,6 +2891,22 @@ None.
28902891
| :------------ | :--------- | :------------------------------------ | :---------- |
28912892
| click:outside | dispatched | <code>{ target: HTMLElement; }</code> | -- |
28922893

2894+
## `Portal`
2895+
2896+
### Props
2897+
2898+
None.
2899+
2900+
### Slots
2901+
2902+
| Slot name | Default | Props | Fallback |
2903+
| :-------- | :------ | :---------------------------------- | :------- |
2904+
| -- | Yes | <code>Record<string, never> </code> | -- |
2905+
2906+
### Events
2907+
2908+
None.
2909+
28932910
## `ProgressBar`
28942911

28952912
### Props

docs/src/COMPONENT_API.json

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"total": 168,
2+
"total": 169,
33
"components": [
44
{
55
"moduleName": "Accordion",
@@ -11650,6 +11650,23 @@
1165011650
},
1165111651
"contexts": []
1165211652
},
11653+
{
11654+
"moduleName": "Portal",
11655+
"filePath": "src/Portal/Portal.svelte",
11656+
"props": [],
11657+
"moduleExports": [],
11658+
"slots": [
11659+
{
11660+
"name": null,
11661+
"default": true,
11662+
"slot_props": "Record<string, never>"
11663+
}
11664+
],
11665+
"events": [],
11666+
"typedefs": [],
11667+
"generics": null,
11668+
"contexts": []
11669+
},
1165311670
{
1165411671
"moduleName": "ProgressBar",
1165511672
"filePath": "src/ProgressBar/ProgressBar.svelte",

src/Portal/Portal.svelte

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<script context="module">
2+
/** @type {HTMLDivElement | null} */
3+
let portalContainer = null;
4+
5+
let instances = 0;
6+
7+
/**
8+
* Creates or returns the shared portal container
9+
* @returns {HTMLDivElement}
10+
*/
11+
function getPortalContainer() {
12+
// Check if container exists and is still in the DOM.
13+
if (portalContainer && portalContainer.parentNode === document.body) {
14+
return portalContainer;
15+
}
16+
17+
// Create new container if it doesn't exist or was removed.
18+
if (typeof document !== "undefined") {
19+
portalContainer = document.createElement("div");
20+
portalContainer.setAttribute("data-portal", "");
21+
portalContainer.className = "bx--portal-container";
22+
document.body.appendChild(portalContainer);
23+
}
24+
25+
return portalContainer;
26+
}
27+
</script>
28+
29+
<script>
30+
import { onMount } from "svelte";
31+
32+
/** @type {null | HTMLDivElement} */
33+
let portal = null;
34+
let mounted = false;
35+
36+
onMount(() => {
37+
mounted = true;
38+
return () => {
39+
mounted = false;
40+
instances--;
41+
42+
if (portal?.parentNode) {
43+
portal.parentNode.removeChild(portal);
44+
}
45+
46+
if (instances === 0 && portalContainer?.parentNode) {
47+
portalContainer.parentNode.removeChild(portalContainer);
48+
portalContainer = null;
49+
}
50+
};
51+
});
52+
53+
$: if (mounted && portal) {
54+
const container = getPortalContainer();
55+
if (container && portal.parentNode !== container) {
56+
instances++;
57+
container.appendChild(portal);
58+
}
59+
}
60+
</script>
61+
62+
<div bind:this={portal}>
63+
<slot />
64+
</div>
65+
66+
<style>
67+
:global(.bx--portal-container) {
68+
position: fixed;
69+
pointer-events: none;
70+
}
71+
72+
:global(.bx--portal-container > *) {
73+
pointer-events: auto;
74+
}
75+
</style>

src/Portal/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
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 Portal } from "./Portal/Portal.svelte";
9596
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte";
9697
export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte";
9798
export { default as ProgressIndicatorSkeleton } from "./ProgressIndicator/ProgressIndicatorSkeleton.svelte";
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<script lang="ts">
2+
import { Portal } from "carbon-components-svelte";
3+
</script>
4+
5+
<Portal>
6+
Portal content 1
7+
</Portal>
8+
9+
<Portal>
10+
Portal content 2
11+
</Portal>
12+
13+
<Portal>
14+
Portal content 3
15+
</Portal>
16+

tests/Portal/Portal.test.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<script lang="ts">
2+
import { Portal } from "carbon-components-svelte";
3+
4+
export let showPortal = true;
5+
export let portalContent = "Portal content";
6+
</script>
7+
8+
{#if showPortal}
9+
<Portal>
10+
{portalContent}
11+
</Portal>
12+
{/if}
13+

tests/Portal/Portal.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { render, screen } from "@testing-library/svelte";
2+
import { tick } from "svelte";
3+
import PortalMultipleTest from "./Portal.multiple.test.svelte";
4+
import PortalTest from "./Portal.test.svelte";
5+
6+
describe("Portal", () => {
7+
afterEach(() => {
8+
const existingContainer = document.querySelector(
9+
"[data-portal]",
10+
) as HTMLElement;
11+
existingContainer?.remove();
12+
});
13+
14+
it("renders portal content", async () => {
15+
render(PortalTest);
16+
17+
const portalContent = await screen.findByText("Portal content");
18+
expect(portalContent).toBeInTheDocument();
19+
});
20+
21+
it("creates portal container in document.body", async () => {
22+
render(PortalTest);
23+
24+
const portalContent = await screen.findByText("Portal content");
25+
expect(portalContent).toBeInTheDocument();
26+
27+
const portalContainer = portalContent.closest("[data-portal]");
28+
expect(portalContainer).toBeInTheDocument();
29+
expect(portalContainer?.parentElement).toBe(document.body);
30+
});
31+
32+
it("portal container has correct attributes and classes", async () => {
33+
render(PortalTest);
34+
35+
const portalContent = await screen.findByText("Portal content");
36+
const portalContainer = portalContent.closest("[data-portal]");
37+
assert(portalContainer instanceof HTMLElement);
38+
39+
expect(portalContainer).toHaveAttribute("data-portal");
40+
expect(portalContainer).toHaveClass("bx--portal-container");
41+
});
42+
43+
it("multiple portals share the same container", async () => {
44+
render(PortalMultipleTest);
45+
46+
const portalContent1 = await screen.findByText("Portal content 1");
47+
const portalContent2 = await screen.findByText("Portal content 2");
48+
const portalContent3 = await screen.findByText("Portal content 3");
49+
50+
expect(portalContent1).toBeInTheDocument();
51+
expect(portalContent2).toBeInTheDocument();
52+
expect(portalContent3).toBeInTheDocument();
53+
54+
const container1 = portalContent1.closest("[data-portal]");
55+
const container2 = portalContent2.closest("[data-portal]");
56+
const container3 = portalContent3.closest("[data-portal]");
57+
58+
expect(container1).toBe(container2);
59+
expect(container2).toBe(container3);
60+
expect(container1).toBeInTheDocument();
61+
});
62+
63+
it("removes portal container when all instances are unmounted", async () => {
64+
const { unmount } = render(PortalTest);
65+
66+
const portalContent = await screen.findByText("Portal content");
67+
const portalContainer = portalContent.closest("[data-portal]");
68+
assert(portalContainer instanceof HTMLElement);
69+
70+
unmount();
71+
72+
const remainingContainer = document.querySelector("[data-portal]");
73+
expect(remainingContainer).not.toBeInTheDocument();
74+
});
75+
76+
it("removes portal container only when last instance is unmounted", async () => {
77+
const { unmount: unmount1 } = render(PortalTest, {
78+
props: { portalContent: "Portal 1" },
79+
});
80+
81+
const portalContent1 = await screen.findByText("Portal 1");
82+
let portalContainer = portalContent1.closest("[data-portal]");
83+
assert(portalContainer instanceof HTMLElement);
84+
85+
const { unmount: unmount2 } = render(PortalTest, {
86+
props: { portalContent: "Portal 2" },
87+
});
88+
89+
const portalContent2 = await screen.findByText("Portal 2");
90+
portalContainer = portalContent2.closest("[data-portal]");
91+
assert(portalContainer instanceof HTMLElement);
92+
93+
unmount1();
94+
95+
portalContainer = document.querySelector("[data-portal]");
96+
expect(portalContainer).toBeInTheDocument();
97+
expect(await screen.findByText("Portal 2")).toBeInTheDocument();
98+
99+
unmount2();
100+
101+
portalContainer = document.querySelector("[data-portal]");
102+
expect(portalContainer).not.toBeInTheDocument();
103+
});
104+
105+
it("renders slot content correctly", async () => {
106+
render(PortalTest, {
107+
props: { portalContent: "Custom portal content" },
108+
});
109+
110+
const portalContent = await screen.findByText("Custom portal content");
111+
expect(portalContent).toBeInTheDocument();
112+
});
113+
114+
it("handles conditional rendering", async () => {
115+
const { component } = render(PortalTest, {
116+
props: { showPortal: false },
117+
});
118+
119+
let portalContent = screen.queryByText("Portal content");
120+
expect(portalContent).not.toBeInTheDocument();
121+
122+
let portalContainer = document.querySelector("[data-portal]");
123+
expect(portalContainer).not.toBeInTheDocument();
124+
125+
component.$set({ showPortal: true });
126+
127+
portalContent = await screen.findByText("Portal content");
128+
expect(portalContent).toBeInTheDocument();
129+
130+
portalContainer = portalContent.closest("[data-portal]");
131+
expect(portalContainer).toBeInTheDocument();
132+
133+
component.$set({ showPortal: false });
134+
await tick();
135+
136+
portalContent = screen.queryByText("Portal content");
137+
expect(portalContent).not.toBeInTheDocument();
138+
139+
portalContainer = document.querySelector("[data-portal]");
140+
expect(portalContainer).not.toBeInTheDocument();
141+
});
142+
143+
it("portal container has pointer-events: none", async () => {
144+
render(PortalTest);
145+
146+
const portalContent = await screen.findByText("Portal content");
147+
const portalContainer = portalContent.closest("[data-portal]");
148+
assert(portalContainer instanceof HTMLElement);
149+
150+
expect(portalContainer).toHaveClass("bx--portal-container");
151+
});
152+
153+
it("portal content has pointer-events: auto", async () => {
154+
render(PortalTest);
155+
156+
const portalContent = await screen.findByText("Portal content");
157+
expect(portalContent).toBeInTheDocument();
158+
const styles = window.getComputedStyle(portalContent);
159+
expect(styles.pointerEvents).toBe("auto");
160+
});
161+
});

types/Portal/Portal.svelte.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { SvelteComponentTyped } from "svelte";
2+
3+
export type PortalProps = Record<string, never>;
4+
5+
export default class Portal extends SvelteComponentTyped<
6+
PortalProps,
7+
Record<string, any>,
8+
{ default: Record<string, never> }
9+
> {}

types/index.d.ts

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 Portal } from "./Portal/Portal.svelte";
9596
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte";
9697
export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte";
9798
export { default as ProgressIndicatorSkeleton } from "./ProgressIndicator/ProgressIndicatorSkeleton.svelte";

0 commit comments

Comments
 (0)