Skip to content

Commit b7274bd

Browse files
committed
feat(portal): add Portal component
Closes #2280
1 parent 2fbf1c2 commit b7274bd

File tree

10 files changed

+335
-2
lines changed

10 files changed

+335
-2
lines changed

COMPONENT_INDEX.md

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

3-
> 168 components exported from [email protected].
3+
> 169 components exported from [email protected].
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,24 @@ None.
28902891
| :------------ | :--------- | :------------------------------------ | :---------- |
28912892
| click:outside | dispatched | <code>{ target: HTMLElement; }</code> | -- |
28922893

2894+
## `Portal`
2895+
2896+
### Props
2897+
2898+
| Prop name | Required | Kind | Reactive | Type | Default value | Description |
2899+
| :-------- | :------- | :--------------- | :------- | ---------------------------------------- | ------------------ | --------------------- |
2900+
| tag | No | <code>let</code> | No | <code>keyof HTMLElementTagNameMap</code> | <code>"div"</code> | Specify the tag name. |
2901+
2902+
### Slots
2903+
2904+
| Slot name | Default | Props | Fallback |
2905+
| :-------- | :------ | :---------------------------------- | :------- |
2906+
| -- | Yes | <code>Record<string, never> </code> | -- |
2907+
2908+
### Events
2909+
2910+
None.
2911+
28932912
## `ProgressBar`
28942913

28952914
### Props

docs/src/COMPONENT_API.json

Lines changed: 35 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,40 @@
1165011650
},
1165111651
"contexts": []
1165211652
},
11653+
{
11654+
"moduleName": "Portal",
11655+
"filePath": "src/Portal/Portal.svelte",
11656+
"props": [
11657+
{
11658+
"name": "tag",
11659+
"kind": "let",
11660+
"description": "Specify the tag name.",
11661+
"type": "keyof HTMLElementTagNameMap",
11662+
"value": "\"div\"",
11663+
"isFunction": false,
11664+
"isFunctionDeclaration": false,
11665+
"isRequired": false,
11666+
"constant": false,
11667+
"reactive": false
11668+
}
11669+
],
11670+
"moduleExports": [],
11671+
"slots": [
11672+
{
11673+
"name": null,
11674+
"default": true,
11675+
"slot_props": "Record<string, never>"
11676+
}
11677+
],
11678+
"events": [],
11679+
"typedefs": [],
11680+
"generics": null,
11681+
"rest_props": {
11682+
"type": "Element",
11683+
"name": "svelte:element"
11684+
},
11685+
"contexts": []
11686+
},
1165311687
{
1165411688
"moduleName": "ProgressBar",
1165511689
"filePath": "src/ProgressBar/ProgressBar.svelte",

src/Portal/Portal.svelte

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<script>
2+
/**
3+
* Specify the tag name.
4+
* @type {keyof HTMLElementTagNameMap}
5+
*/
6+
export let tag = "div";
7+
8+
import { onMount } from "svelte";
9+
10+
/** @type {null | HTMLElement} */
11+
let portal = null;
12+
let mounted = false;
13+
14+
onMount(() => {
15+
mounted = true;
16+
17+
return () => {
18+
mounted = false;
19+
20+
if (portal?.parentNode) {
21+
portal.parentNode.removeChild(portal);
22+
}
23+
};
24+
});
25+
26+
$: if (mounted && portal) {
27+
if (
28+
typeof document !== "undefined" &&
29+
portal.parentNode !== document.body
30+
) {
31+
document.body.appendChild(portal);
32+
}
33+
}
34+
</script>
35+
36+
<svelte:element this={tag} bind:this={portal} data-portal {...$$restProps}>
37+
<slot />
38+
</svelte:element>

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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script lang="ts">
2+
import { Portal } from "carbon-components-svelte";
3+
4+
export let showPortal = true;
5+
export let portalContent = "Portal content";
6+
export let tag: keyof HTMLElementTagNameMap = "div";
7+
</script>
8+
9+
{#if showPortal}
10+
<Portal {tag} {...$$restProps}>
11+
{portalContent}
12+
</Portal>
13+
{/if}
14+

tests/Portal/Portal.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
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 existingPortals = document.querySelectorAll("[data-portal]");
9+
for (const portal of existingPortals) {
10+
portal.remove();
11+
}
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+
const portalElement = portalContent.closest("[data-portal]");
21+
assert(portalElement instanceof HTMLElement);
22+
expect(portalElement.parentElement).toBe(document.body);
23+
expect(portalElement.tagName).toBe("DIV");
24+
});
25+
26+
it("multiple portals each have their own instance", async () => {
27+
render(PortalMultipleTest);
28+
29+
const portalContent1 = await screen.findByText("Portal content 1");
30+
const portalContent2 = await screen.findByText("Portal content 2");
31+
const portalContent3 = await screen.findByText("Portal content 3");
32+
33+
expect(portalContent1).toBeInTheDocument();
34+
expect(portalContent2).toBeInTheDocument();
35+
expect(portalContent3).toBeInTheDocument();
36+
37+
const portalElement1 = portalContent1.closest("[data-portal]");
38+
const portalElement2 = portalContent2.closest("[data-portal]");
39+
const portalElement3 = portalContent3.closest("[data-portal]");
40+
41+
expect(portalElement1).not.toBe(portalElement2);
42+
expect(portalElement2).not.toBe(portalElement3);
43+
expect(portalElement1).not.toBe(portalElement3);
44+
expect(portalElement1).toBeInTheDocument();
45+
expect(portalElement2).toBeInTheDocument();
46+
expect(portalElement3).toBeInTheDocument();
47+
});
48+
49+
it("removes portal element when instance is unmounted", async () => {
50+
const { unmount } = render(PortalTest);
51+
52+
const portalContent = await screen.findByText("Portal content");
53+
const portalElement = portalContent.closest("[data-portal]");
54+
assert(portalElement instanceof HTMLElement);
55+
56+
unmount();
57+
58+
const remainingPortal = document.querySelector("[data-portal]");
59+
expect(remainingPortal).not.toBeInTheDocument();
60+
});
61+
62+
it("each portal instance is independent", async () => {
63+
const { unmount: unmount1 } = render(PortalTest, {
64+
props: { portalContent: "Portal 1" },
65+
});
66+
67+
const portalContent1 = await screen.findByText("Portal 1");
68+
const portalElement1 = portalContent1.closest("[data-portal]");
69+
assert(portalElement1 instanceof HTMLElement);
70+
71+
const { unmount: unmount2 } = render(PortalTest, {
72+
props: { portalContent: "Portal 2" },
73+
});
74+
75+
const portalContent2 = await screen.findByText("Portal 2");
76+
const portalElement2 = portalContent2.closest("[data-portal]");
77+
assert(portalElement2 instanceof HTMLElement);
78+
79+
expect(portalElement1).not.toBe(portalElement2);
80+
81+
unmount1();
82+
83+
const remainingPortals = document.querySelectorAll("[data-portal]");
84+
expect(remainingPortals).toHaveLength(1);
85+
expect(await screen.findByText("Portal 2")).toBeInTheDocument();
86+
87+
unmount2();
88+
89+
const finalPortals = document.querySelectorAll("[data-portal]");
90+
expect(finalPortals).toHaveLength(0);
91+
});
92+
93+
it("renders slot content correctly", async () => {
94+
render(PortalTest, {
95+
props: { portalContent: "Custom portal content" },
96+
});
97+
98+
const portalContent = await screen.findByText("Custom portal content");
99+
expect(portalContent).toBeInTheDocument();
100+
});
101+
102+
it("handles conditional rendering", async () => {
103+
const { component } = render(PortalTest, {
104+
props: { showPortal: false },
105+
});
106+
107+
let portalContent = screen.queryByText("Portal content");
108+
expect(portalContent).not.toBeInTheDocument();
109+
110+
let portalElement = document.querySelector("[data-portal]");
111+
expect(portalElement).not.toBeInTheDocument();
112+
113+
component.$set({ showPortal: true });
114+
115+
portalContent = await screen.findByText("Portal content");
116+
expect(portalContent).toBeInTheDocument();
117+
118+
portalElement = portalContent.closest("[data-portal]");
119+
expect(portalElement).toBeInTheDocument();
120+
121+
component.$set({ showPortal: false });
122+
await tick();
123+
124+
portalContent = screen.queryByText("Portal content");
125+
expect(portalContent).not.toBeInTheDocument();
126+
127+
portalElement = document.querySelector("[data-portal]");
128+
expect(portalElement).not.toBeInTheDocument();
129+
});
130+
131+
it("uses custom tag when tag prop is specified", async () => {
132+
render(PortalTest, {
133+
props: { tag: "section" },
134+
});
135+
136+
const portalContent = await screen.findByText("Portal content");
137+
const portalElement = portalContent.closest("[data-portal]");
138+
assert(portalElement instanceof HTMLElement);
139+
140+
expect(portalElement.tagName).toBe("SECTION");
141+
});
142+
143+
it("supports different custom tags", async () => {
144+
const { unmount: unmount1 } = render(PortalTest, {
145+
props: { portalContent: "Article portal", tag: "article" },
146+
});
147+
148+
const portalContent1 = await screen.findByText("Article portal");
149+
const portalElement1 = portalContent1.closest("[data-portal]");
150+
assert(portalElement1 instanceof HTMLElement);
151+
expect(portalElement1.tagName).toBe("ARTICLE");
152+
153+
const { unmount: unmount2 } = render(PortalTest, {
154+
props: { portalContent: "Section portal", tag: "section" },
155+
});
156+
157+
const portalContent2 = await screen.findByText("Section portal");
158+
const portalElement2 = portalContent2.closest("[data-portal]");
159+
assert(portalElement2 instanceof HTMLElement);
160+
expect(portalElement2.tagName).toBe("SECTION");
161+
162+
unmount1();
163+
unmount2();
164+
});
165+
166+
it("forwards rest props to the portal element", async () => {
167+
render(PortalTest, {
168+
props: {
169+
class: "custom-portal-class",
170+
id: "test-portal-id",
171+
"data-testid": "portal-test",
172+
"aria-label": "Test portal",
173+
style: "background-color: red;",
174+
},
175+
});
176+
177+
const portalContent = await screen.findByText("Portal content");
178+
const portalElement = portalContent.closest("[data-portal]");
179+
assert(portalElement instanceof HTMLElement);
180+
181+
expect(portalElement).toHaveClass("custom-portal-class");
182+
expect(portalElement).toHaveAttribute("id", "test-portal-id");
183+
expect(portalElement).toHaveAttribute("data-testid", "portal-test");
184+
expect(portalElement).toHaveAttribute("aria-label", "Test portal");
185+
expect(portalElement.getAttribute("style")).toContain("background-color");
186+
});
187+
});

types/Portal/Portal.svelte.d.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { SvelteComponentTyped } from "svelte";
2+
import type { SvelteHTMLElements } from "svelte/elements";
3+
4+
type $RestProps = SvelteHTMLElements["svelte:element"];
5+
6+
type $Props = {
7+
/**
8+
* Specify the tag name.
9+
* @default "div"
10+
*/
11+
tag?: keyof HTMLElementTagNameMap;
12+
13+
[key: `data-${string}`]: any;
14+
};
15+
16+
export type PortalProps = Omit<$RestProps, keyof $Props> & $Props;
17+
18+
export default class Portal extends SvelteComponentTyped<
19+
PortalProps,
20+
Record<string, any>,
21+
{ default: Record<string, never> }
22+
> {}

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)