diff --git a/packages/bits-ui/src/lib/bits/index.ts b/packages/bits-ui/src/lib/bits/index.ts index aef73a2ea..078b9d859 100644 --- a/packages/bits-ui/src/lib/bits/index.ts +++ b/packages/bits-ui/src/lib/bits/index.ts @@ -36,3 +36,4 @@ export * as Tooltip from "./tooltip/index.js"; export * as EscapeLayer from "./utilities/escape-layer/index.js"; export * as PreventTextSelectionOverflowLayer from "./utilities/prevent-text-selection-overflow-layer/index.js"; export * as DismissableLayer from "./utilities/dismissable-layer/index.js"; +export * as Portal from "./utilities/portal/index.js"; diff --git a/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte b/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte index e69de29bb..0d0c8b29a 100644 --- a/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte +++ b/packages/bits-ui/src/lib/bits/popover/components/popover-close.svelte @@ -0,0 +1,37 @@ + + +{#if asChild} + {@render child?.({ props: mergedProps })} +{:else} + +{/if} diff --git a/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts b/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts index eac37c7cb..f8e17fa1b 100644 --- a/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts +++ b/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts @@ -41,6 +41,10 @@ class PopoverRootState { createContent(props: PopoverContentStateProps) { return new PopoverContentState(props, this); } + + createClose(props: PopoverCloseStateProps) { + return new PopoverCloseState(props, this); + } } type PopoverTriggerStateProps = ReadonlyBoxedValues<{ @@ -66,6 +70,7 @@ class PopoverTriggerState { this.root.open.value && this.root.contentId.value ? this.root.contentId.value : undefined, + "data-popover-trigger": "", // onclick: this.#composedClick, onkeydown: this.#composedKeydown, @@ -105,6 +110,7 @@ class PopoverContentState { tabindex: -1, hidden: !this.root.open.value ? true : undefined, "data-state": getDataOpenClosed(this.root.open.value), + "data-popover-content": "", }); constructor(props: PopoverContentStateProps, root: PopoverRootState) { @@ -114,6 +120,39 @@ class PopoverContentState { } } +type PopoverCloseStateProps = ReadonlyBoxedValues<{ + onclick: EventCallback; + onkeydown: EventCallback; +}>; + +class PopoverCloseState { + root = undefined as unknown as PopoverRootState; + #composedClick = undefined as unknown as EventCallback; + #composedKeydown = undefined as unknown as EventCallback; + props = $derived({ + onclick: this.#composedClick, + onkeydown: this.#composedKeydown, + type: "button", + "data-popover-close": "", + } as const); + + constructor(props: PopoverCloseStateProps, root: PopoverRootState) { + this.root = root; + this.#composedClick = composeHandlers(props.onclick, this.#onclick); + this.#composedKeydown = composeHandlers(props.onkeydown, this.#onkeydown); + } + + #onclick = () => { + this.root.close(); + }; + + #onkeydown = (e: KeyboardEvent) => { + if (!(e.key === kbd.ENTER || e.key === kbd.SPACE)) return; + e.preventDefault(); + this.root.close(); + }; +} + // // CONTEXT METHODS // @@ -136,3 +175,7 @@ export function setPopoverTriggerState(props: PopoverTriggerStateProps) { export function setPopoverContentState(props: PopoverContentStateProps) { return getPopoverRootState().createContent(props); } + +export function setPopoverCloseState(props: PopoverCloseStateProps) { + return getPopoverRootState().createClose(props); +} diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte index 1597dc258..de983cfaf 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-content.svelte @@ -22,6 +22,7 @@ dir = "ltr", style = {}, present, + wrapperId, }: ContentProps = $props(); const state = setFloatingContentState({ @@ -42,6 +43,7 @@ dir: readonlyBox(() => dir), style: readonlyBox(() => style), present: readonlyBox(() => present), + wrapperId: readonlyBox(() => wrapperId), }); diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts index 9c0b555d5..4f282d659 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/floating-layer.svelte.ts @@ -16,9 +16,7 @@ import { type Box, type ReadonlyBox, type ReadonlyBoxedValues, - afterTick, boxedState, - generateId, styleToString, useNodeById, } from "$lib/internal/index.js"; @@ -43,15 +41,11 @@ export type Align = (typeof ALIGN_OPTIONS)[number]; export type Boundary = Element | null; class FloatingRootState { - wrapperId = boxedState(generateId()); - wrapperNode = undefined as unknown as Box; + wrapperId = undefined as unknown as ReadonlyBox; contentNode = undefined as unknown as Box; anchorNode = undefined as unknown as Box; arrowNode = boxedState(null); - - constructor() { - this.wrapperNode = useNodeById(this.wrapperId); - } + wrapperNode = undefined as unknown as Box; createAnchor(props: FloatingAnchorStateProps) { return new FloatingAnchorState(props, this); @@ -68,6 +62,7 @@ class FloatingRootState { export type FloatingContentStateProps = ReadonlyBoxedValues<{ id: string; + wrapperId: string; side: Side; sideOffset: number; align: Align; @@ -227,6 +222,8 @@ class FloatingContentState { this.root = root; this.present = props.present; this.arrowSize = useSize(this.root.arrowNode); + this.root.wrapperId = props.wrapperId; + this.root.wrapperNode = useNodeById(this.root.wrapperId); this.root.contentNode = useNodeById(this.id); this.floating = useFloating({ strategy: () => this.strategy.value, diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts index ec964d05f..0a2242840 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/types.ts @@ -103,6 +103,11 @@ export type FloatingLayerContentProps = { * Whether the floating layer is present. */ present: boolean; + + /** + * The ID of the content wrapper element. + */ + wrapperId?: string; }; export type FloatingLayerArrowProps = { diff --git a/packages/bits-ui/src/lib/bits/utilities/index.ts b/packages/bits-ui/src/lib/bits/utilities/index.ts index 93f9e009c..c422cfa2f 100644 --- a/packages/bits-ui/src/lib/bits/utilities/index.ts +++ b/packages/bits-ui/src/lib/bits/utilities/index.ts @@ -6,3 +6,4 @@ export * as DismissableLayer from "./dismissable-layer/index.js"; export * as PreventTextSelectionOverflowLayer from "./prevent-text-selection-overflow-layer/index.js"; export * as PresenceLayer from "./presence-layer/index.js"; export * as PopperLayer from "./popper-layer/index.js"; +export * as Portal from "./portal/index.js"; diff --git a/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte index 741a3cc3c..a099fa9c3 100644 --- a/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/popper-layer/popper-layer.svelte @@ -4,6 +4,7 @@ DismissableLayer, EscapeLayer, FloatingLayer, + Portal, PresenceLayer, PreventTextSelectionOverflowLayer, } from "$lib/bits/utilities/index.js"; @@ -11,23 +12,34 @@ let { popper, ...restProps }: Props = $props(); - - {#snippet presence({ present })} - - {#snippet content({ props })} - - - - {@render popper?.({ - props: { ...props, hidden: present.value ? undefined : true }, - })} - - - + + {#snippet portal({ portalProps })} + + {#snippet presence({ present })} + + {#snippet content({ props })} + + + + {@render popper?.({ + props: { + ...props, + hidden: present.value ? undefined : true, + }, + })} + + + + {/snippet} + {/snippet} - + {/snippet} - + diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/index.ts b/packages/bits-ui/src/lib/bits/utilities/portal/index.ts new file mode 100644 index 000000000..730a09d44 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/portal/index.ts @@ -0,0 +1,3 @@ +export { default as Root } from "./portal.svelte"; + +export type { PortalProps } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte b/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte new file mode 100644 index 000000000..16e6f21de --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte @@ -0,0 +1,17 @@ + + +{#if forceMount} + {@render portal?.({ portalProps: state.props })} +{/if} diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/types.ts b/packages/bits-ui/src/lib/bits/utilities/portal/types.ts new file mode 100644 index 000000000..8ec21e63d --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/portal/types.ts @@ -0,0 +1,33 @@ +import type { Snippet } from "svelte"; + +export type PortalProps = { + /** + * Where to portal the content to. + * + * @defaultValue document.body + */ + to?: HTMLElement | string; + + /** + * Disable portaling and render the component inline + * + * @defaultValue false + */ + disabled?: boolean; + + /** + * Whether to force mount the portal content for more + * advanced animation control + * + * @defaultValue false + * + */ + forceMount?: boolean; + + /** + * The id of the portal content + */ + id?: string; + + portal?: Snippet<[{ portalProps: { id: string; "data-portal": string } }]>; +}; diff --git a/packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts new file mode 100644 index 000000000..ab69df34a --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts @@ -0,0 +1,44 @@ +import { untrack } from "svelte"; +import type { ReadonlyBox } from "$lib/internal/box.svelte.js"; +import { useNodeById } from "$lib/internal/use-node-by-id.svelte.js"; + +export function usePortal(id: ReadonlyBox, to: ReadonlyBox) { + const node = useNodeById(id); + + const props = $derived({ + id: id.value, + "data-portal": "", + }); + + $effect.pre(() => { + if (!node.value) return; + let target: HTMLElement | null = null; + if (typeof to.value === "string") { + target = document.querySelector(to.value); + if (!target) { + throw new Error(`Could not find target element with selector: ${to.value}`); + } + target.appendChild(node.value); + } else if (to.value instanceof HTMLElement) { + to.value.appendChild(node.value); + } else { + throw new TypeError( + `Unknown portal target type: ${ + to.value === null ? "null" : typeof to.value + }. Allowed types: string (CSS selector) or HTMLElement.` + ); + } + }); + + $effect(() => { + return () => { + untrack(() => node.value?.remove()); + }; + }); + + return { + get props() { + return props; + }, + }; +} diff --git a/packages/bits-ui/src/lib/internal/use-presence.svelte.ts b/packages/bits-ui/src/lib/internal/use-presence.svelte.ts index fa39c07c0..fdbde4381 100644 --- a/packages/bits-ui/src/lib/internal/use-presence.svelte.ts +++ b/packages/bits-ui/src/lib/internal/use-presence.svelte.ts @@ -1,6 +1,6 @@ -import { onDestroy, untrack } from "svelte"; +import { onDestroy } from "svelte"; import { type Box, type ReadonlyBox, boxedState, watch } from "./box.svelte.js"; -import { afterTick, useNodeById, useStateMachine } from "$lib/internal/index.js"; +import { useNodeById, useStateMachine } from "$lib/internal/index.js"; export function usePresence(present: ReadonlyBox, id: ReadonlyBox) { const styles = boxedState({}) as unknown as Box; diff --git a/packages/bits-ui/src/lib/internal/use-size.svelte.ts b/packages/bits-ui/src/lib/internal/use-size.svelte.ts index b48452359..8e8cd720f 100644 --- a/packages/bits-ui/src/lib/internal/use-size.svelte.ts +++ b/packages/bits-ui/src/lib/internal/use-size.svelte.ts @@ -1,6 +1,6 @@ /// -import { tick, untrack } from "svelte"; +import { untrack } from "svelte"; import type { Box } from "./box.svelte.js"; import { afterTick } from "./after-tick.js"; diff --git a/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts b/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts index 686f38890..dfd3351ec 100644 --- a/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts +++ b/packages/bits-ui/src/lib/internal/use-state-machine.svelte.ts @@ -1,4 +1,4 @@ -import { box, boxedState } from "./box.svelte.js"; +import { boxedState } from "./box.svelte.js"; interface Machine { [k: string]: { [k: string]: S };