Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

next: Portal #489

Merged
merged 4 commits into from
Apr 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bits-ui/src/lib/bits/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script lang="ts">
import type { CloseProps } from "../index.js";
import { setPopoverCloseState } from "../popover.svelte.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
import { noop } from "$lib/internal/callbacks.js";
import { styleToString } from "$lib/internal/style.js";

let {
asChild,
child,
children,
el = $bindable(),
onclick = noop,
onkeydown = noop,
style = {},
...restProps
}: CloseProps = $props();

const state = setPopoverCloseState({
onclick: readonlyBox(() => onclick),
onkeydown: readonlyBox(() => onkeydown),
});

const mergedProps = $derived({
...restProps,
...state.props,
style: styleToString(style),
});
</script>

{#if asChild}
{@render child?.({ props: mergedProps })}
{:else}
<button {...mergedProps} bind:this={el}>
{@render children?.()}
</button>
{/if}
43 changes: 43 additions & 0 deletions packages/bits-ui/src/lib/bits/popover/popover.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -114,6 +120,39 @@ class PopoverContentState {
}
}

type PopoverCloseStateProps = ReadonlyBoxedValues<{
onclick: EventCallback<MouseEvent>;
onkeydown: EventCallback<KeyboardEvent>;
}>;

class PopoverCloseState {
root = undefined as unknown as PopoverRootState;
#composedClick = undefined as unknown as EventCallback<MouseEvent>;
#composedKeydown = undefined as unknown as EventCallback<KeyboardEvent>;
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
//
Expand All @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
dir = "ltr",
style = {},
present,
wrapperId,
}: ContentProps = $props();

const state = setFloatingContentState({
Expand All @@ -42,6 +43,7 @@
dir: readonlyBox(() => dir),
style: readonlyBox(() => style),
present: readonlyBox(() => present),
wrapperId: readonlyBox(() => wrapperId),
});
</script>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ import {
type Box,
type ReadonlyBox,
type ReadonlyBoxedValues,
afterTick,
boxedState,
generateId,
styleToString,
useNodeById,
} from "$lib/internal/index.js";
Expand All @@ -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<HTMLElement | null>;
wrapperId = undefined as unknown as ReadonlyBox<string>;
contentNode = undefined as unknown as Box<HTMLElement | null>;
anchorNode = undefined as unknown as Box<HTMLElement | null>;
arrowNode = boxedState<HTMLElement | null>(null);

constructor() {
this.wrapperNode = useNodeById(this.wrapperId);
}
wrapperNode = undefined as unknown as Box<HTMLElement | null>;

createAnchor(props: FloatingAnchorStateProps) {
return new FloatingAnchorState(props, this);
Expand All @@ -68,6 +62,7 @@ class FloatingRootState {

export type FloatingContentStateProps = ReadonlyBoxedValues<{
id: string;
wrapperId: string;
side: Side;
sideOffset: number;
align: Align;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions packages/bits-ui/src/lib/bits/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,42 @@
DismissableLayer,
EscapeLayer,
FloatingLayer,
Portal,
PresenceLayer,
PreventTextSelectionOverflowLayer,
} from "$lib/bits/utilities/index.js";

let { popper, ...restProps }: Props = $props();
</script>

<PresenceLayer.Root {...restProps}>
{#snippet presence({ present })}
<FloatingLayer.Content {...restProps} present={present.value}>
{#snippet content({ props })}
<EscapeLayer.Root {...restProps} present={present.value}>
<DismissableLayer.Root {...restProps} present={present.value}>
<PreventTextSelectionOverflowLayer.Root
{...restProps}
present={present.value}
>
{@render popper?.({
props: { ...props, hidden: present.value ? undefined : true },
})}
</PreventTextSelectionOverflowLayer.Root>
</DismissableLayer.Root>
</EscapeLayer.Root>
<Portal.Root forceMount={true}>
{#snippet portal({ portalProps })}
<PresenceLayer.Root {...restProps}>
{#snippet presence({ present })}
<FloatingLayer.Content
{...restProps}
wrapperId={portalProps.id}
present={present.value}
>
{#snippet content({ props })}
<EscapeLayer.Root {...restProps} present={present.value}>
<DismissableLayer.Root {...restProps} present={present.value}>
<PreventTextSelectionOverflowLayer.Root
{...restProps}
present={present.value}
>
{@render popper?.({
props: {
...props,
hidden: present.value ? undefined : true,
},
})}
</PreventTextSelectionOverflowLayer.Root>
</DismissableLayer.Root>
</EscapeLayer.Root>
{/snippet}
</FloatingLayer.Content>
{/snippet}
</FloatingLayer.Content>
</PresenceLayer.Root>
{/snippet}
</PresenceLayer.Root>
</Portal.Root>
3 changes: 3 additions & 0 deletions packages/bits-ui/src/lib/bits/utilities/portal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as Root } from "./portal.svelte";

export type { PortalProps } from "./types.js";
17 changes: 17 additions & 0 deletions packages/bits-ui/src/lib/bits/utilities/portal/portal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import { usePortal } from "./use-portal.svelte.js";
import type { PortalProps } from "./types.js";
import { readonlyBox } from "$lib/internal/box.svelte.js";
import { generateId } from "$lib/internal/id.js";

let { id = generateId(), to = "body", forceMount, portal }: PortalProps = $props();

const state = usePortal(
readonlyBox(() => id),
readonlyBox(() => to)
);
</script>

{#if forceMount}
{@render portal?.({ portalProps: state.props })}
{/if}
33 changes: 33 additions & 0 deletions packages/bits-ui/src/lib/bits/utilities/portal/types.ts
Original file line number Diff line number Diff line change
@@ -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 } }]>;
};
Original file line number Diff line number Diff line change
@@ -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<string>, to: ReadonlyBox<HTMLElement | string>) {
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;
},
};
}
4 changes: 2 additions & 2 deletions packages/bits-ui/src/lib/internal/use-presence.svelte.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>, id: ReadonlyBox<string>) {
const styles = boxedState({}) as unknown as Box<CSSStyleDeclaration>;
Expand Down
2 changes: 1 addition & 1 deletion packages/bits-ui/src/lib/internal/use-size.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="resize-observer-browser" />

import { tick, untrack } from "svelte";
import { untrack } from "svelte";
import type { Box } from "./box.svelte.js";
import { afterTick } from "./after-tick.js";

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { box, boxedState } from "./box.svelte.js";
import { boxedState } from "./box.svelte.js";

interface Machine<S> {
[k: string]: { [k: string]: S };
Expand Down
Loading