From 7e293fffac4fa524db81a9dc5ce6db6b4685a500 Mon Sep 17 00:00:00 2001 From: Hunter Johnston <64506580+huntabyte@users.noreply.github.com> Date: Wed, 24 Apr 2024 22:37:43 -0400 Subject: [PATCH] next: Dialog (#510) --- .../dialog/components/dialog-close.svelte | 37 +-- .../dialog/components/dialog-content.svelte | 202 ++++++-------- .../components/dialog-description.svelte | 40 ++- .../dialog/components/dialog-overlay.svelte | 100 +++---- .../dialog/components/dialog-portal.svelte | 28 -- .../dialog/components/dialog-title.svelte | 47 ++-- .../dialog/components/dialog-trigger.svelte | 48 ++-- .../lib/bits/dialog/components/dialog.svelte | 68 +---- packages/bits-ui/src/lib/bits/dialog/ctx.ts | 45 ---- .../src/lib/bits/dialog/dialog.svelte.ts | 248 ++++++++++++++++++ packages/bits-ui/src/lib/bits/dialog/index.ts | 8 +- packages/bits-ui/src/lib/bits/dialog/types.ts | 152 ++++------- .../popover/components/popover-content.svelte | 17 +- .../src/lib/bits/popover/popover.svelte.ts | 12 +- .../bits-ui/src/lib/bits/popover/types.ts | 20 +- .../dismissable-layer.svelte | 10 +- .../bits/utilities/dismissable-layer/types.ts | 20 +- ...velte.ts => useDismissableLayer.svelte.ts} | 5 +- .../escape-layer/escape-layer.svelte | 13 +- .../lib/bits/utilities/escape-layer/types.ts | 11 +- ...yer.svelte.ts => useEscapeLayer.svelte.ts} | 6 +- .../components/floating-layer-anchor.svelte | 2 +- .../components/floating-layer-arrow.svelte | 2 +- .../components/floating-layer-content.svelte | 17 +- .../components/floating-layer.svelte | 2 +- .../floating-layer/components/index.ts | 1 + .../bits/utilities/floating-layer/types.ts | 29 +- ...r.svelte.ts => useFloatingLayer.svelte.ts} | 0 .../utilities/focus-scope/focus-scope.svelte | 6 +- .../lib/bits/utilities/focus-scope/types.ts | 30 ++- ...cope.svelte.ts => useFocusScope.svelte.ts} | 16 +- .../popper-layer/popper-layer.svelte | 10 +- .../lib/bits/utilities/popper-layer/types.ts | 33 ++- .../utilities/portal/use-portal.svelte.ts | 44 ---- .../presence-layer/presence-layer.svelte | 15 +- .../bits/utilities/presence-layer/types.ts | 13 +- .../presence-layer}/usePresence.svelte.ts | 2 +- .../lib/bits/utilities/scroll-lock/index.ts | 8 + .../utilities/scroll-lock/scroll-lock.svelte | 8 + .../text-selection-layer.svelte | 10 +- .../utilities/text-selection-layer/types.ts | 30 ++- ...lte.ts => useTextSelectionLayer.svelte.ts} | 6 +- packages/bits-ui/src/lib/internal/index.ts | 2 +- .../lib/components/demos/dialog-demo.svelte | 9 +- 44 files changed, 714 insertions(+), 718 deletions(-) delete mode 100644 packages/bits-ui/src/lib/bits/dialog/components/dialog-portal.svelte delete mode 100644 packages/bits-ui/src/lib/bits/dialog/ctx.ts rename packages/bits-ui/src/lib/bits/utilities/dismissable-layer/{use-dismissable-layer.svelte.ts => useDismissableLayer.svelte.ts} (98%) rename packages/bits-ui/src/lib/bits/utilities/escape-layer/{use-escape-layer.svelte.ts => useEscapeLayer.svelte.ts} (93%) rename packages/bits-ui/src/lib/bits/utilities/floating-layer/{floating-layer.svelte.ts => useFloatingLayer.svelte.ts} (100%) rename packages/bits-ui/src/lib/bits/utilities/focus-scope/{use-focus-scope.svelte.ts => useFocusScope.svelte.ts} (94%) delete mode 100644 packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts rename packages/bits-ui/src/lib/{internal => bits/utilities/presence-layer}/usePresence.svelte.ts (99%) create mode 100644 packages/bits-ui/src/lib/bits/utilities/scroll-lock/index.ts create mode 100644 packages/bits-ui/src/lib/bits/utilities/scroll-lock/scroll-lock.svelte rename packages/bits-ui/src/lib/bits/utilities/text-selection-layer/{use-text-selection-layer.svelte.ts => useTextSelectionLayer.svelte.ts} (94%) diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog-close.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog-close.svelte index 4f4f62aaf..d3f18848d 100644 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog-close.svelte +++ b/packages/bits-ui/src/lib/bits/dialog/components/dialog-close.svelte @@ -1,38 +1,19 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} - {/if} diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog-content.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog-content.svelte index ee5baeffd..7058c585e 100644 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog-content.svelte +++ b/packages/bits-ui/src/lib/bits/dialog/components/dialog-content.svelte @@ -1,122 +1,92 @@ -{#if asChild && $open} - -{:else if transition && $open} -
- -
-{:else if inTransition && outTransition && $open} -
- -
-{:else if inTransition && $open} -
- -
-{:else if outTransition && $open} -
- -
-{:else if $open} -
- -
-{/if} + + {#snippet presence({ present })} + + { + onDestroyAutoFocus(e); + if (e.defaultPrevented) return; + state.root.triggerNode?.value?.focus(); + }} + > + {#snippet focusScope({ props: focusScopeProps })} + { + onEscapeKeydown(e); + state.root.closeDialog(); + }} + > + { + onInteractOutside(e); + if (e.defaultPrevented) return; + state.root.closeDialog(); + }} + > + + {#if asChild} + {@render child?.({ + props: mergeProps(mergedProps, focusScopeProps, { + hidden: !present.value, + }), + })} + {:else} +
+ {@render children?.()} +
+ {/if} +
+
+
+ {/snippet} +
+ {/snippet} +
diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog-description.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog-description.svelte index cdf62ad4e..a1c873660 100644 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog-description.svelte +++ b/packages/bits-ui/src/lib/bits/dialog/components/dialog-description.svelte @@ -1,32 +1,30 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} -
- +
+ {@render children?.()}
{/if} diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog-overlay.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog-overlay.svelte index f9f401df6..b37083573 100644 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog-overlay.svelte +++ b/packages/bits-ui/src/lib/bits/dialog/components/dialog-overlay.svelte @@ -1,76 +1,36 @@ -{#if asChild && $open} - -{:else if transition && $open} - -
-{:else if inTransition && outTransition && $open} - -
-{:else if inTransition && $open} - -
-{:else if outTransition && $open} - -
-{:else if $open} - -
-{/if} + + {#snippet presence({ present })} + {#if asChild} + {@render child?.({ props: mergeProps(mergedProps, { hidden: !present.value }) })} + {:else} +
+ {@render children?.()} +
+ {/if} + {/snippet} +
diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog-portal.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog-portal.svelte deleted file mode 100644 index 0dbf9b165..000000000 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog-portal.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -{#if asChild} - -{:else} -
- -
-{/if} diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog-title.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog-title.svelte index f6943dbd9..36961db65 100644 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog-title.svelte +++ b/packages/bits-ui/src/lib/bits/dialog/components/dialog-title.svelte @@ -1,35 +1,32 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} - - - +
+ {@render children?.()} +
{/if} diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog-trigger.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog-trigger.svelte index 36a6eda0d..9906a8170 100644 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog-trigger.svelte +++ b/packages/bits-ui/src/lib/bits/dialog/components/dialog-trigger.svelte @@ -1,38 +1,30 @@ {#if asChild} - + {@render child?.({ props: mergedProps })} {:else} - {/if} diff --git a/packages/bits-ui/src/lib/bits/dialog/components/dialog.svelte b/packages/bits-ui/src/lib/bits/dialog/components/dialog.svelte index 50fbeaa3d..3534c5603 100644 --- a/packages/bits-ui/src/lib/bits/dialog/components/dialog.svelte +++ b/packages/bits-ui/src/lib/bits/dialog/components/dialog.svelte @@ -1,61 +1,21 @@ - +{@render children?.()} diff --git a/packages/bits-ui/src/lib/bits/dialog/ctx.ts b/packages/bits-ui/src/lib/bits/dialog/ctx.ts deleted file mode 100644 index 11d661fa7..000000000 --- a/packages/bits-ui/src/lib/bits/dialog/ctx.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { type CreateDialogProps, createDialog } from "@melt-ui/svelte"; -import { getContext, setContext } from "svelte"; -import { createBitAttrs, getOptionUpdater, removeUndefined } from "$lib/internal/index.js"; - -export function getDialogData() { - const NAME = "dialog" as const; - const PARTS = [ - "close", - "content", - "description", - "overlay", - "portal", - "title", - "trigger", - ] as const; - - return { - NAME, - PARTS, - }; -} - -type SetProps = CreateDialogProps; -type GetReturn = Omit, "updateOption">; - -export function setCtx(props: SetProps) { - const { NAME, PARTS } = getDialogData(); - const getAttrs = createBitAttrs(NAME, PARTS); - - const dialog = { - ...createDialog({ ...removeUndefined(props), role: "dialog", forceVisible: true }), - getAttrs, - }; - - setContext(NAME, dialog); - return { - ...dialog, - updateOption: getOptionUpdater(dialog.options), - }; -} - -export function getCtx() { - const { NAME } = getDialogData(); - return getContext(NAME); -} diff --git a/packages/bits-ui/src/lib/bits/dialog/dialog.svelte.ts b/packages/bits-ui/src/lib/bits/dialog/dialog.svelte.ts index e69de29bb..c8b25feb8 100644 --- a/packages/bits-ui/src/lib/bits/dialog/dialog.svelte.ts +++ b/packages/bits-ui/src/lib/bits/dialog/dialog.svelte.ts @@ -0,0 +1,248 @@ +import { getContext, setContext } from "svelte"; +import { getAriaExpanded, getDataOpenClosed } from "$lib/internal/attrs.js"; +import { + type BoxedValues, + type ReadonlyBoxedValues, + boxedState, + readonlyBoxedState, +} from "$lib/internal/box.svelte.js"; +import { useNodeById } from "$lib/internal/useNodeById.svelte.js"; + +const CONTENT_ATTR = "data-dialog-content"; +const TITLE_ATTR = "data-dialog-title"; +const TRIGGER_ATTR = "data-dialog-trigger"; +const OVERLAY_ATTR = "data-dialog-overlay"; +const DESCRIPTION_ATTR = "data-dialog-description"; +const CLOSE_ATTR = "data-dialog-close"; + +type DialogRootStateProps = BoxedValues<{ + open: boolean; +}>; + +class DialogRootState { + open = undefined as unknown as DialogRootStateProps["open"]; + triggerNode = boxedState(null); + titleNode = boxedState(null); + contentNode = boxedState(null); + contentId = $derived(this.contentNode.value ? this.contentNode.value.id : undefined); + titleId = $derived(this.titleNode.value ? this.titleNode.value.id : undefined); + triggerId = $derived(this.triggerNode.value ? this.triggerNode.value.id : undefined); + descriptionNode = boxedState(null); + descriptionId = $derived( + this.descriptionNode.value ? this.descriptionNode.value.id : undefined + ); + + constructor(props: DialogRootStateProps) { + this.open = props.open; + } + + openDialog() { + if (this.open.value) return; + this.open.value = true; + } + + closeDialog() { + if (!this.open.value) return; + this.open.value = false; + } + + createTrigger(props: DialogTriggerStateProps) { + return new DialogTriggerState(props, this); + } + + createTitle(props: DialogTitleStateProps) { + return new DialogTitleState(props, this); + } + + createContent(props: DialogContentStateProps) { + return new DialogContentState(props, this); + } + + createOverlay(props: DialogOverlayStateProps) { + return new DialogOverlayState(props, this); + } + + createDescription(props: DialogDescriptionStateProps) { + return new DialogDescriptionState(props, this); + } + + createClose() { + return new DialogCloseState(this); + } + + sharedProps = $derived({ + "data-state": getDataOpenClosed(this.open.value), + }); +} + +type DialogTriggerStateProps = ReadonlyBoxedValues<{ + id: string; +}>; + +class DialogTriggerState { + #id = undefined as unknown as DialogTriggerStateProps["id"]; + #root = undefined as unknown as DialogRootState; + + constructor(props: DialogTriggerStateProps, root: DialogRootState) { + this.#id = props.id; + this.#root = root; + this.#root.triggerNode = useNodeById(this.#id); + } + + #onclick = () => { + this.#root.openDialog(); + }; + + props = $derived({ + id: this.#id.value, + "aria-haspopup": "dialog", + "aria-expanded": getAriaExpanded(this.#root.open.value), + "aria-controls": this.#root.contentId, + [TRIGGER_ATTR]: "", + onclick: this.#onclick, + ...this.#root.sharedProps, + } as const); +} + +class DialogCloseState { + #root = undefined as unknown as DialogRootState; + + constructor(root: DialogRootState) { + this.#root = root; + } + + #onclick = () => { + this.#root.closeDialog(); + }; + + props = $derived({ + [CLOSE_ATTR]: "", + onclick: this.#onclick, + ...this.#root.sharedProps, + } as const); +} + +type DialogTitleStateProps = ReadonlyBoxedValues<{ + id: string; + level: 1 | 2 | 3 | 4 | 5 | 6; +}>; + +class DialogTitleState { + #id = undefined as unknown as DialogTitleStateProps["id"]; + #root = undefined as unknown as DialogRootState; + #level = undefined as unknown as DialogTitleStateProps["level"]; + + constructor(props: DialogTitleStateProps, root: DialogRootState) { + this.#id = props.id; + this.#root = root; + this.#root.titleNode = useNodeById(this.#id); + this.#level = props.level; + } + + props = $derived({ + id: this.#id.value, + role: "heading", + "aria-level": String(this.#level), + [TITLE_ATTR]: "", + ...this.#root.sharedProps, + } as const); +} + +type DialogDescriptionStateProps = ReadonlyBoxedValues<{ + id: string; +}>; + +class DialogDescriptionState { + #id = undefined as unknown as DialogDescriptionStateProps["id"]; + #root = undefined as unknown as DialogRootState; + + constructor(props: DialogDescriptionStateProps, root: DialogRootState) { + this.#id = props.id; + this.#root = root; + this.#root.descriptionNode = useNodeById(this.#id); + } + + props = $derived({ + id: this.#id.value, + [DESCRIPTION_ATTR]: "", + ...this.#root.sharedProps, + } as const); +} + +type DialogContentStateProps = ReadonlyBoxedValues<{ + id: string; +}>; + +class DialogContentState { + #id = undefined as unknown as DialogContentStateProps["id"]; + root = undefined as unknown as DialogRootState; + + constructor(props: DialogContentStateProps, root: DialogRootState) { + this.#id = props.id; + this.root = root; + this.root.contentNode = useNodeById(this.#id); + } + + props = $derived({ + id: this.#id.value, + role: "dialog", + "aria-describedby": this.root.descriptionId, + "aria-labelledby": this.root.titleId, + [CONTENT_ATTR]: "", + ...this.root.sharedProps, + } as const); +} + +type DialogOverlayStateProps = ReadonlyBoxedValues<{ + id: string; +}>; + +class DialogOverlayState { + #id = undefined as unknown as DialogOverlayStateProps["id"]; + root = undefined as unknown as DialogRootState; + + constructor(props: DialogOverlayStateProps, root: DialogRootState) { + this.#id = props.id; + this.root = root; + } + + props = $derived({ + id: this.#id.value, + [OVERLAY_ATTR]: "", + ...this.root.sharedProps, + } as const); +} + +const DIALOG_ROOT_KEY = Symbol("Dialog.Root"); + +export function setDialogRootState(props: DialogRootStateProps) { + return setContext(DIALOG_ROOT_KEY, new DialogRootState(props)); +} + +export function getDialogRootState(): DialogRootState { + return getContext(DIALOG_ROOT_KEY); +} + +export function setDialogTriggerState(props: DialogTriggerStateProps) { + return getDialogRootState().createTrigger(props); +} + +export function setDialogTitleState(props: DialogTitleStateProps) { + return getDialogRootState().createTitle(props); +} + +export function setDialogContentState(props: DialogContentStateProps) { + return getDialogRootState().createContent(props); +} + +export function setDialogOverlayState(props: DialogOverlayStateProps) { + return getDialogRootState().createOverlay(props); +} + +export function setDialogDescriptionState(props: DialogDescriptionStateProps) { + return getDialogRootState().createDescription(props); +} + +export function setDialogCloseState() { + return getDialogRootState().createClose(); +} diff --git a/packages/bits-ui/src/lib/bits/dialog/index.ts b/packages/bits-ui/src/lib/bits/dialog/index.ts index 12ce1764c..6ae9059b6 100644 --- a/packages/bits-ui/src/lib/bits/dialog/index.ts +++ b/packages/bits-ui/src/lib/bits/dialog/index.ts @@ -1,14 +1,14 @@ export { default as Root } from "./components/dialog.svelte"; export { default as Title } from "./components/dialog-title.svelte"; export { default as Close } from "./components/dialog-close.svelte"; -export { default as Portal } from "./components/dialog-portal.svelte"; +export { default as Portal } from "$lib/bits/utilities/portal/portal.svelte"; export { default as Content } from "./components/dialog-content.svelte"; export { default as Overlay } from "./components/dialog-overlay.svelte"; export { default as Trigger } from "./components/dialog-trigger.svelte"; export { default as Description } from "./components/dialog-description.svelte"; export type { - DialogProps as Props, + DialogRootProps as RootProps, DialogTitleProps as TitleProps, DialogCloseProps as CloseProps, DialogPortalProps as PortalProps, @@ -16,8 +16,4 @@ export type { DialogOverlayProps as OverlayProps, DialogTriggerProps as TriggerProps, DialogDescriptionProps as DescriptionProps, - DialogTriggerEvents as TriggerEvents, - DialogCloseEvents as CloseEvents, - DialogContentEvents as ContentEvents, - DialogOverlayEvents as OverlayEvents, } from "./types.js"; diff --git a/packages/bits-ui/src/lib/bits/dialog/types.ts b/packages/bits-ui/src/lib/bits/dialog/types.ts index 4310ca3f4..dccb4c5df 100644 --- a/packages/bits-ui/src/lib/bits/dialog/types.ts +++ b/packages/bits-ui/src/lib/bits/dialog/types.ts @@ -1,113 +1,69 @@ -import type { HTMLButtonAttributes } from "svelte/elements"; -import type { CreateDialogProps as MeltDialogProps } from "@melt-ui/svelte"; +import type { Snippet } from "svelte"; +import type { EscapeLayerProps } from "../utilities/escape-layer/types.js"; +import type { DismissableLayerProps } from "../utilities/dismissable-layer/types.js"; +import type { PresenceLayerProps } from "../utilities/presence-layer/types.js"; +import type { FocusScopeProps } from "../utilities/focus-scope/types.js"; +import type { TextSelectionLayerProps } from "../utilities/text-selection-layer/types.js"; import type { - DOMElement, - Expand, - HTMLDivAttributes, - HTMLHeadingAttributes, - OmitOpen, OnChangeFn, - SvelteEvent, - Transition, - TransitionProps, -} from "$lib/internal/index.js"; -import type { CustomEventHandler } from "$lib/index.js"; - -import type { FocusProp } from "$lib/shared/index.js"; - -export type DialogPropsWithoutHTML = Expand< - OmitOpen< - Omit - > & { - /** - * The open state of the dialog. - * You can bind this to a boolean value to programmatically control the open state. - * - * @defaultValue false - */ - open?: MeltDialogProps["defaultOpen"] & {}; - - /** - * A callback function called when the open state changes. - */ - onOpenChange?: OnChangeFn; + PrimitiveButtonAttributes, + PrimitiveDivAttributes, + WithAsChild, +} from "$lib/internal/types.js"; +import type { PortalProps } from "$lib/bits/utilities/portal/index.js"; +import type { EventCallback } from "$lib/internal/events.js"; + +export type DialogRootPropsWithoutHTML = { + /** + * The open state of the dialog. + */ + open?: boolean; + + /** + * A callback that is called when the popover's open state changes. + */ + onOpenChange?: OnChangeFn; + + children?: Snippet; +}; - /** - * Override the default autofocus behavior of the dialog when it opens - */ - openFocus?: FocusProp; +export type DialogRootProps = DialogRootPropsWithoutHTML; - /** - * Override the default autofocus behavior of the dialog after close - */ - closeFocus?: FocusProp; - } +export type DialogContentPropsWithoutHTML = WithAsChild< + EscapeLayerProps & + DismissableLayerProps & + PresenceLayerProps & + FocusScopeProps & + TextSelectionLayerProps & { + preventScroll?: boolean; + } >; -export type DialogTriggerPropsWithoutHTML = DOMElement; +export type DialogContentProps = DialogContentPropsWithoutHTML & PrimitiveDivAttributes; -export type DialogClosePropsWithoutHTML = DialogTriggerPropsWithoutHTML; +export type DialogOverlayPropsWithoutHTML = WithAsChild; +export type DialogOverlayProps = DialogOverlayPropsWithoutHTML & PrimitiveDivAttributes; -export type DialogContentPropsWithoutHTML< - T extends Transition = Transition, - In extends Transition = Transition, - Out extends Transition = Transition, -> = Expand & DOMElement>; +export type DialogPortalPropsWithoutHTML = PortalProps; +export type DialogPortalProps = DialogPortalPropsWithoutHTML; -export type DialogDescriptionPropsWithoutHTML = DOMElement; +export type DialogTriggerPropsWithoutHTML = WithAsChild<{ + onclick?: EventCallback; +}>; -export type DialogOverlayPropsWithoutHTML< - T extends Transition = Transition, - In extends Transition = Transition, - Out extends Transition = Transition, -> = Expand & DOMElement>; +export type DialogTriggerProps = DialogTriggerPropsWithoutHTML & PrimitiveButtonAttributes; -export type DialogPortalPropsWithoutHTML = DOMElement; +export type DialogTitlePropsWithoutHTML = WithAsChild<{ + /** + * The heading level of the dialog title. + */ + level?: 1 | 2 | 3 | 4 | 5 | 6; +}>; -export type DialogTitlePropsWithoutHTML = Expand< - { - level?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; - } & DOMElement ->; - -export type DialogProps = DialogPropsWithoutHTML; +export type DialogTitleProps = DialogTitlePropsWithoutHTML & PrimitiveDivAttributes; -export type DialogTriggerProps = DialogTriggerPropsWithoutHTML & HTMLButtonAttributes; +export type DialogClosePropsWithoutHTML = DialogTriggerPropsWithoutHTML; export type DialogCloseProps = DialogTriggerProps; -export type DialogContentProps< - T extends Transition = Transition, - In extends Transition = Transition, - Out extends Transition = Transition, -> = DialogContentPropsWithoutHTML & HTMLDivAttributes; - -export type DialogDescriptionProps = DialogDescriptionPropsWithoutHTML & HTMLDivAttributes; - -export type DialogOverlayProps< - T extends Transition = Transition, - In extends Transition = Transition, - Out extends Transition = Transition, -> = DialogOverlayPropsWithoutHTML & HTMLDivAttributes; - -export type DialogPortalProps = DialogPortalPropsWithoutHTML & HTMLDivAttributes; -export type DialogTitleProps = DialogTitlePropsWithoutHTML & HTMLHeadingAttributes; - -export type DialogOverlayEvents = { - mouseup: SvelteEvent; -}; - -export type DialogContentEvents = { - pointerdown: SvelteEvent; - pointerup: SvelteEvent; - pointermove: SvelteEvent; - touchend: SvelteEvent; - touchstart: SvelteEvent; - touchcancel: SvelteEvent; - touchmove: SvelteEvent; -}; - -export type DialogTriggerEvents = { - click: CustomEventHandler; - keydown: CustomEventHandler; -}; -export type DialogCloseEvents = DialogTriggerEvents; +export type DialogDescriptionPropsWithoutHTML = WithAsChild<{}>; +export type DialogDescriptionProps = DialogDescriptionPropsWithoutHTML & PrimitiveDivAttributes; diff --git a/packages/bits-ui/src/lib/bits/popover/components/popover-content.svelte b/packages/bits-ui/src/lib/bits/popover/components/popover-content.svelte index 119f168f2..7c694f59a 100644 --- a/packages/bits-ui/src/lib/bits/popover/components/popover-content.svelte +++ b/packages/bits-ui/src/lib/bits/popover/components/popover-content.svelte @@ -12,6 +12,9 @@ id = useId(), forceMount = false, onDestroyAutoFocus = noop, + onEscapeKeydown = noop, + onInteractOutside = noop, + loop = true, ...restProps }: ContentProps = $props(); @@ -24,8 +27,16 @@ {...restProps} present={state.root.open.value || forceMount} {id} - onInteractOutside={state.root.close} - onEscape={state.root.close} + onInteractOutside={(e) => { + onInteractOutside(e); + if (e.defaultPrevented) return; + state.root.close(); + }} + onEscapeKeydown={(e) => { + // TODO: users should be able to cancel this + onEscapeKeydown(e); + state.root.close(); + }} onDestroyAutoFocus={(e) => { onDestroyAutoFocus(e); if (e.defaultPrevented) return; @@ -33,7 +44,7 @@ state.root.triggerNode?.value?.focus(); }} trapped - loop + {loop} > {#snippet popper({ props })} {@const mergedProps = mergeProps(restProps, state.props, props)} 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 e2885ab84..39a1154c2 100644 --- a/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts +++ b/packages/bits-ui/src/lib/bits/popover/popover.svelte.ts @@ -8,7 +8,7 @@ import { } from "$lib/internal/box.svelte.js"; import { useNodeById } from "$lib/internal/useNodeById.svelte.js"; import { kbd } from "$lib/internal/kbd.js"; -import { getAriaExpanded, getDataOpenClosed, getHiddenAttr } from "$lib/internal/attrs.js"; +import { getAriaExpanded, getDataOpenClosed } from "$lib/internal/attrs.js"; import { verifyContextDeps } from "$lib/internal/context.js"; type PopoverRootStateProps = BoxedValues<{ @@ -96,12 +96,10 @@ type PopoverContentStateProps = ReadonlyBoxedValues<{ class PopoverContentState { #id = undefined as unknown as PopoverContentStateProps["id"]; - #node = undefined as unknown as Box; root = undefined as unknown as PopoverRootState; constructor(props: PopoverContentStateProps, root: PopoverRootState) { this.#id = props.id; - this.#node = useNodeById(this.#id); this.root = root; } @@ -114,20 +112,20 @@ class PopoverContentState { } class PopoverCloseState { - root = undefined as unknown as PopoverRootState; + #root = undefined as unknown as PopoverRootState; constructor(root: PopoverRootState) { - this.root = root; + this.#root = root; } #onclick = () => { - this.root.close(); + this.#root.close(); }; #onkeydown = (e: KeyboardEvent) => { if (!(e.key === kbd.ENTER || e.key === kbd.SPACE)) return; e.preventDefault(); - this.root.close(); + this.#root.close(); }; props = $derived({ diff --git a/packages/bits-ui/src/lib/bits/popover/types.ts b/packages/bits-ui/src/lib/bits/popover/types.ts index bae972d88..42111a4db 100644 --- a/packages/bits-ui/src/lib/bits/popover/types.ts +++ b/packages/bits-ui/src/lib/bits/popover/types.ts @@ -1,5 +1,6 @@ import type { Snippet } from "svelte"; import type { ArrowProps, ArrowPropsWithoutHTML } from "../utilities/arrow/types.js"; +import type { PopperLayerProps } from "../utilities/popper-layer/types.js"; import type { EventCallback, OnChangeFn, @@ -8,7 +9,6 @@ import type { WithAsChild, } from "$lib/internal/index.js"; import type { CustomEventHandler } from "$lib/index.js"; -import type { FloatingLayer } from "$lib/bits/utilities/floating-layer/index.js"; export type PopoverRootPropsWithoutHTML = { /** @@ -27,23 +27,9 @@ export type PopoverRootPropsWithoutHTML = { children?: Snippet; }; -export type PopoverRootProps = PopoverRootPropsWithoutHTML & PrimitiveDivAttributes; +export type PopoverRootProps = PopoverRootPropsWithoutHTML; -export type PopoverContentPropsWithoutHTML = WithAsChild< - Partial> & { - forceMount?: boolean; - } & { - onMountAutoFocus?: EventCallback; - onDestroyAutoFocus?: EventCallback; - } & { - /** - * Whether to prevent scrolling the body when the popover is open or not. - * - * @defaultValue true - */ - preventScroll?: boolean; - } ->; +export type PopoverContentPropsWithoutHTML = WithAsChild; export type PopoverContentProps = PopoverContentPropsWithoutHTML & PrimitiveDivAttributes; diff --git a/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/dismissable-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/dismissable-layer.svelte index 7394982a4..3d615c9b1 100644 --- a/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/dismissable-layer.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/dismissable-layer/dismissable-layer.svelte @@ -1,20 +1,20 @@ diff --git a/packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts index f0dd2e5b4..39090c132 100644 --- a/packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts +++ b/packages/bits-ui/src/lib/bits/utilities/escape-layer/types.ts @@ -7,12 +7,10 @@ export type EscapeBehaviorType = | "ignore"; export type EscapeLayerProps = { - children?: Snippet; - /** * Callback fired when escape is pressed. */ - onEscape?: (e: KeyboardEvent) => void; + onEscapeKeydown?: (e: KeyboardEvent) => void; /** * Escape behavior type. @@ -24,10 +22,15 @@ export type EscapeLayerProps = { * @defaultValue `close` */ behaviorType?: EscapeBehaviorType; +}; +// internal props not exposed to the user but used in the implementation +export type EscapeLayerImplProps = { /** * Whether the layer is enabled. Currently, we determine this with the * `presence` returned from the `presence` layer. */ present: boolean; -}; + + children?: Snippet; +} & EscapeLayerProps; diff --git a/packages/bits-ui/src/lib/bits/utilities/escape-layer/use-escape-layer.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/escape-layer/useEscapeLayer.svelte.ts similarity index 93% rename from packages/bits-ui/src/lib/bits/utilities/escape-layer/use-escape-layer.svelte.ts rename to packages/bits-ui/src/lib/bits/utilities/escape-layer/useEscapeLayer.svelte.ts index 4c2414d8b..71395df82 100644 --- a/packages/bits-ui/src/lib/bits/utilities/escape-layer/use-escape-layer.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/escape-layer/useEscapeLayer.svelte.ts @@ -1,5 +1,5 @@ import { untrack } from "svelte"; -import type { EscapeBehaviorType, EscapeLayerProps } from "./types.js"; +import type { EscapeBehaviorType, EscapeLayerImplProps } from "./types.js"; import type { ReadonlyBox, ReadonlyBoxedValues } from "$lib/internal/box.svelte.js"; import { type EventCallback, addEventListener } from "$lib/internal/events.js"; import { kbd } from "$lib/internal/kbd.js"; @@ -7,7 +7,7 @@ import { noop } from "$lib/internal/callbacks.js"; const layers = new Map>(); -type EscapeLayerStateProps = ReadonlyBoxedValues>>; +type EscapeLayerStateProps = ReadonlyBoxedValues>>; export class EscapeLayerState { #onEscapeProp: ReadonlyBox>; @@ -16,7 +16,7 @@ export class EscapeLayerState { constructor(props: EscapeLayerStateProps) { this.#behaviorType = props.behaviorType; - this.#onEscapeProp = props.onEscape; + this.#onEscapeProp = props.onEscapeKeydown; this.#present = props.present; let unsubEvents = noop; diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte index 20c732302..e2734c8f1 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer-anchor.svelte @@ -1,5 +1,5 @@
diff --git a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer.svelte index cdd61abdb..388f7b2ab 100644 --- a/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/floating-layer/components/floating-layer.svelte @@ -1,6 +1,6 @@ @@ -21,7 +21,11 @@ {@render popper?.({ - props: mergeProps(props, focusScopeProps), + props: mergeProps(props, focusScopeProps, { + style: { + pointerEvents: "auto", + }, + }), })} diff --git a/packages/bits-ui/src/lib/bits/utilities/popper-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/popper-layer/types.ts index d91730df2..15b68c356 100644 --- a/packages/bits-ui/src/lib/bits/utilities/popper-layer/types.ts +++ b/packages/bits-ui/src/lib/bits/utilities/popper-layer/types.ts @@ -1,15 +1,32 @@ import type { Snippet } from "svelte"; -import type { EscapeLayerProps } from "../escape-layer/types.js"; -import type { DismissableLayerProps } from "../dismissable-layer/types.js"; -import type { FloatingLayerContentProps } from "../floating-layer/types.js"; -import type { TextSelectionLayerProps } from "../text-selection-layer/types.js"; -import type { PresenceLayerProps } from "../presence-layer/types.js"; -import type { FocusScopeProps } from "../focus-scope/types.js"; +import type { EscapeLayerImplProps, EscapeLayerProps } from "../escape-layer/types.js"; +import type { + DismissableLayerImplProps, + DismissableLayerProps, +} from "../dismissable-layer/types.js"; +import type { + FloatingLayerContentImplProps, + FloatingLayerContentProps, +} from "../floating-layer/types.js"; +import type { + TextSelectionLayerImplProps, + TextSelectionLayerProps, +} from "../text-selection-layer/types.js"; +import type { PresenceLayerImplProps, PresenceLayerProps } from "../presence-layer/types.js"; +import type { FocusScopeImplProps, FocusScopeProps } from "../focus-scope/types.js"; export type PopperLayerProps = EscapeLayerProps & DismissableLayerProps & FloatingLayerContentProps & PresenceLayerProps & - TextSelectionLayerProps & { + TextSelectionLayerProps & + FocusScopeProps; + +export type PopperLayerImplProps = EscapeLayerImplProps & + DismissableLayerImplProps & + FloatingLayerContentImplProps & + PresenceLayerImplProps & + TextSelectionLayerImplProps & + FocusScopeImplProps & { popper: Snippet<[{ props: Record }]>; - } & FocusScopeProps; + }; 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 deleted file mode 100644 index 0dc8bd156..000000000 --- a/packages/bits-ui/src/lib/bits/utilities/portal/use-portal.svelte.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { untrack } from "svelte"; -import type { ReadonlyBox } from "$lib/internal/box.svelte.js"; -import { useNodeById } from "$lib/internal/useNodeById.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/bits/utilities/presence-layer/presence-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence-layer.svelte index 31307e2a0..dc39a2be4 100644 --- a/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence-layer.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/presence-layer/presence-layer.svelte @@ -1,16 +1,9 @@ -{#if forceMount.value || present || isPresent.value} +{#if forceMount || present || isPresent.value} {@render presence?.({ present: isPresent })} {/if} diff --git a/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts b/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts index 18ee8b8c3..8bd5041d6 100644 --- a/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts +++ b/packages/bits-ui/src/lib/bits/utilities/presence-layer/types.ts @@ -2,16 +2,17 @@ import type { Snippet } from "svelte"; export type PresenceLayerProps = { /** - * The presence status. + * Whether to force mount the component. */ - present: boolean; + forceMount?: boolean; +}; +export type PresenceLayerImplProps = PresenceLayerProps & { + id: string; /** - * Whether to force mount the component. + * The presence status. */ - forceMount?: boolean; + present: boolean; presence?: Snippet<[{ present: { value: boolean } }]>; - - id: string; }; diff --git a/packages/bits-ui/src/lib/internal/usePresence.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/presence-layer/usePresence.svelte.ts similarity index 99% rename from packages/bits-ui/src/lib/internal/usePresence.svelte.ts rename to packages/bits-ui/src/lib/bits/utilities/presence-layer/usePresence.svelte.ts index b64dc6cda..0ded3a091 100644 --- a/packages/bits-ui/src/lib/internal/usePresence.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/presence-layer/usePresence.svelte.ts @@ -1,5 +1,5 @@ import { onDestroy } from "svelte"; -import { type Box, type ReadonlyBox, boxedState, watch } from "./box.svelte.js"; +import { type Box, type ReadonlyBox, boxedState, watch } from "../../../internal/box.svelte.js"; import { afterTick, useStateMachine } from "$lib/internal/index.js"; export function usePresence(present: ReadonlyBox, id: ReadonlyBox) { diff --git a/packages/bits-ui/src/lib/bits/utilities/scroll-lock/index.ts b/packages/bits-ui/src/lib/bits/utilities/scroll-lock/index.ts new file mode 100644 index 000000000..173b605d9 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/scroll-lock/index.ts @@ -0,0 +1,8 @@ +export type ScrollLockProps = { + /** + * Whether to prevent body scrolling when the content is open. + * + * @defaultValue true + */ + preventScroll?: boolean; +}; diff --git a/packages/bits-ui/src/lib/bits/utilities/scroll-lock/scroll-lock.svelte b/packages/bits-ui/src/lib/bits/utilities/scroll-lock/scroll-lock.svelte new file mode 100644 index 000000000..a52a62b40 --- /dev/null +++ b/packages/bits-ui/src/lib/bits/utilities/scroll-lock/scroll-lock.svelte @@ -0,0 +1,8 @@ + diff --git a/packages/bits-ui/src/lib/bits/utilities/text-selection-layer/text-selection-layer.svelte b/packages/bits-ui/src/lib/bits/utilities/text-selection-layer/text-selection-layer.svelte index c876e2b36..881bfebea 100644 --- a/packages/bits-ui/src/lib/bits/utilities/text-selection-layer/text-selection-layer.svelte +++ b/packages/bits-ui/src/lib/bits/utilities/text-selection-layer/text-selection-layer.svelte @@ -1,20 +1,20 @@ @@ -15,13 +13,10 @@