Skip to content

Commit 70ebc51

Browse files
committed
feat(portal): add Portal and FloatingPortal components
Closes #2280
1 parent 5a27fbe commit 70ebc51

14 files changed

+709
-2
lines changed

COMPONENT_INDEX.md

Lines changed: 40 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+
> 170 components exported from carbon-components-svelte@0.94.0.
44
55
## Components
66

@@ -45,6 +45,7 @@
4545
- [`FileUploaderItem`](#fileuploaderitem)
4646
- [`FileUploaderSkeleton`](#fileuploaderskeleton)
4747
- [`Filename`](#filename)
48+
- [`FloatingPortal`](#floatingportal)
4849
- [`FluidForm`](#fluidform)
4950
- [`Form`](#form)
5051
- [`FormGroup`](#formgroup)
@@ -96,6 +97,7 @@
9697
- [`PaginationSkeleton`](#paginationskeleton)
9798
- [`PasswordInput`](#passwordinput)
9899
- [`Popover`](#popover)
100+
- [`Portal`](#portal)
99101
- [`ProgressBar`](#progressbar)
100102
- [`ProgressIndicator`](#progressindicator)
101103
- [`ProgressIndicatorSkeleton`](#progressindicatorskeleton)
@@ -1440,6 +1442,27 @@ None.
14401442
| click | forwarded | -- | -- |
14411443
| keydown | forwarded | -- | -- |
14421444

1445+
## `FloatingPortal`
1446+
1447+
### Props
1448+
1449+
| Prop name | Required | Kind | Reactive | Type | Default value | Description |
1450+
| :--------- | :------- | :--------------- | :------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------------------------------------------------------------------ |
1451+
| reference | No | <code>let</code> | No | <code>HTMLElement &#124; null</code> | <code>null</code> | Reference element to position the portal relative to |
1452+
| placement | No | <code>let</code> | No | <code>"top" &#124; "top-start" &#124; "top-end" &#124; "right" &#124; "right-start" &#124; "right-end" &#124; "bottom" &#124; "bottom-start" &#124; "bottom-end" &#124; "left" &#124; "left-start" &#124; "left-end"</code> | <code>"bottom"</code> | Placement of the floating portal relative to the reference element |
1453+
| offset | No | <code>let</code> | No | <code>number</code> | <code>0</code> | Offset in pixels from the reference element |
1454+
| autoUpdate | No | <code>let</code> | No | <code>boolean</code> | <code>true</code> | Set to `true` to enable auto-update positioning on scroll/resize |
1455+
1456+
### Slots
1457+
1458+
| Slot name | Default | Props | Fallback |
1459+
| :-------- | :------ | :---------------------------------- | :------- |
1460+
| -- | Yes | <code>Record<string, never> </code> | -- |
1461+
1462+
### Events
1463+
1464+
None.
1465+
14431466
## `FluidForm`
14441467

14451468
### Props
@@ -2890,6 +2913,22 @@ None.
28902913
| :------------ | :--------- | :------------------------------------ | :---------- |
28912914
| click:outside | dispatched | <code>{ target: HTMLElement; }</code> | -- |
28922915

2916+
## `Portal`
2917+
2918+
### Props
2919+
2920+
None.
2921+
2922+
### Slots
2923+
2924+
| Slot name | Default | Props | Fallback |
2925+
| :-------- | :------ | :---------------------------------- | :------- |
2926+
| -- | Yes | <code>Record<string, never> </code> | -- |
2927+
2928+
### Events
2929+
2930+
None.
2931+
28932932
## `ProgressBar`
28942933

28952934
### Props

docs/src/COMPONENT_API.json

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"total": 168,
2+
"total": 170,
33
"components": [
44
{
55
"moduleName": "Accordion",
@@ -5663,6 +5663,72 @@
56635663
},
56645664
"contexts": []
56655665
},
5666+
{
5667+
"moduleName": "FloatingPortal",
5668+
"filePath": "src/Portal/FloatingPortal.svelte",
5669+
"props": [
5670+
{
5671+
"name": "reference",
5672+
"kind": "let",
5673+
"description": "Reference element to position the portal relative to",
5674+
"type": "HTMLElement | null",
5675+
"value": "null",
5676+
"isFunction": false,
5677+
"isFunctionDeclaration": false,
5678+
"isRequired": false,
5679+
"constant": false,
5680+
"reactive": false
5681+
},
5682+
{
5683+
"name": "placement",
5684+
"kind": "let",
5685+
"description": "Placement of the floating portal relative to the reference element",
5686+
"type": "\"top\" | \"top-start\" | \"top-end\" | \"right\" | \"right-start\" | \"right-end\" | \"bottom\" | \"bottom-start\" | \"bottom-end\" | \"left\" | \"left-start\" | \"left-end\"",
5687+
"value": "\"bottom\"",
5688+
"isFunction": false,
5689+
"isFunctionDeclaration": false,
5690+
"isRequired": false,
5691+
"constant": false,
5692+
"reactive": false
5693+
},
5694+
{
5695+
"name": "offset",
5696+
"kind": "let",
5697+
"description": "Offset in pixels from the reference element",
5698+
"type": "number",
5699+
"value": "0",
5700+
"isFunction": false,
5701+
"isFunctionDeclaration": false,
5702+
"isRequired": false,
5703+
"constant": false,
5704+
"reactive": false
5705+
},
5706+
{
5707+
"name": "autoUpdate",
5708+
"kind": "let",
5709+
"description": "Set to `true` to enable auto-update positioning on scroll/resize",
5710+
"type": "boolean",
5711+
"value": "true",
5712+
"isFunction": false,
5713+
"isFunctionDeclaration": false,
5714+
"isRequired": false,
5715+
"constant": false,
5716+
"reactive": false
5717+
}
5718+
],
5719+
"moduleExports": [],
5720+
"slots": [
5721+
{
5722+
"name": null,
5723+
"default": true,
5724+
"slot_props": "Record<string, never>"
5725+
}
5726+
],
5727+
"events": [],
5728+
"typedefs": [],
5729+
"generics": null,
5730+
"contexts": []
5731+
},
56665732
{
56675733
"moduleName": "FluidForm",
56685734
"filePath": "src/FluidForm/FluidForm.svelte",
@@ -11650,6 +11716,23 @@
1165011716
},
1165111717
"contexts": []
1165211718
},
11719+
{
11720+
"moduleName": "Portal",
11721+
"filePath": "src/Portal/Portal.svelte",
11722+
"props": [],
11723+
"moduleExports": [],
11724+
"slots": [
11725+
{
11726+
"name": null,
11727+
"default": true,
11728+
"slot_props": "Record<string, never>"
11729+
}
11730+
],
11731+
"events": [],
11732+
"typedefs": [],
11733+
"generics": null,
11734+
"contexts": []
11735+
},
1165311736
{
1165411737
"moduleName": "ProgressBar",
1165511738
"filePath": "src/ProgressBar/ProgressBar.svelte",

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/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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as FloatingPortal } from "./FloatingPortal.svelte";
2+
export { default as Portal } from "./Portal.svelte";

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ 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";
96+
export { default as Portal } from "./Portal/Portal.svelte";
9597
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte";
9698
export { default as ProgressIndicator } from "./ProgressIndicator/ProgressIndicator.svelte";
9799
export { default as ProgressIndicatorSkeleton } from "./ProgressIndicator/ProgressIndicatorSkeleton.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+

0 commit comments

Comments
 (0)