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 abe798512..dccb4c5df 100644 --- a/packages/bits-ui/src/lib/bits/dialog/types.ts +++ b/packages/bits-ui/src/lib/bits/dialog/types.ts @@ -1,5 +1,17 @@ import type { Snippet } from "svelte"; -import type { OnChangeFn, WithAsChild } from "$lib/internal/types.js"; +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 { + 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 = { /** @@ -14,3 +26,44 @@ export type DialogRootPropsWithoutHTML = { children?: Snippet; }; + +export type DialogRootProps = DialogRootPropsWithoutHTML; + +export type DialogContentPropsWithoutHTML = WithAsChild< + EscapeLayerProps & + DismissableLayerProps & + PresenceLayerProps & + FocusScopeProps & + TextSelectionLayerProps & { + preventScroll?: boolean; + } +>; + +export type DialogContentProps = DialogContentPropsWithoutHTML & PrimitiveDivAttributes; + +export type DialogOverlayPropsWithoutHTML = WithAsChild; +export type DialogOverlayProps = DialogOverlayPropsWithoutHTML & PrimitiveDivAttributes; + +export type DialogPortalPropsWithoutHTML = PortalProps; +export type DialogPortalProps = DialogPortalPropsWithoutHTML; + +export type DialogTriggerPropsWithoutHTML = WithAsChild<{ + onclick?: EventCallback; +}>; + +export type DialogTriggerProps = DialogTriggerPropsWithoutHTML & PrimitiveButtonAttributes; + +export type DialogTitlePropsWithoutHTML = WithAsChild<{ + /** + * The heading level of the dialog title. + */ + level?: 1 | 2 | 3 | 4 | 5 | 6; +}>; + +export type DialogTitleProps = DialogTitlePropsWithoutHTML & PrimitiveDivAttributes; + +export type DialogClosePropsWithoutHTML = DialogTriggerPropsWithoutHTML; +export type DialogCloseProps = DialogTriggerProps; + +export type DialogDescriptionPropsWithoutHTML = WithAsChild<{}>; +export type DialogDescriptionProps = DialogDescriptionPropsWithoutHTML & PrimitiveDivAttributes; 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/utilities/focus-scope/useFocusScope.svelte.ts b/packages/bits-ui/src/lib/bits/utilities/focus-scope/useFocusScope.svelte.ts index 0702d3ea3..f3ba1b416 100644 --- a/packages/bits-ui/src/lib/bits/utilities/focus-scope/useFocusScope.svelte.ts +++ b/packages/bits-ui/src/lib/bits/utilities/focus-scope/useFocusScope.svelte.ts @@ -19,6 +19,7 @@ import { useNodeById } from "$lib/internal/useNodeById.svelte.js"; import { isHTMLElement } from "$lib/internal/is.js"; import { executeCallbacks } from "$lib/internal/callbacks.js"; import { kbd } from "$lib/internal/kbd.js"; +import { afterTick } from "$lib/internal/after-tick.js"; type UseFocusScopeProps = ReadonlyBoxedValues<{ /** @@ -72,6 +73,7 @@ export function useFocusScope({ $effect(() => { const container = node.value; + if (!container) return; if (!trapped.value) return; function handleFocusIn(event: FocusEvent) { @@ -154,11 +156,13 @@ export function useFocusScope({ container.dispatchEvent(mountEvent); if (!mountEvent.defaultPrevented) { - focusFirst(removeLinks(getTabbableCandidates(container)), { select: true }); + afterTick(() => { + focusFirst(removeLinks(getTabbableCandidates(container)), { select: true }); - if (document.activeElement === previouslyFocusedElement) { - focus(container); - } + if (document.activeElement === previouslyFocusedElement) { + focus(container); + } + }); } } 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/sites/docs/src/lib/components/demos/dialog-demo.svelte b/sites/docs/src/lib/components/demos/dialog-demo.svelte index 2b6a014f4..5337e7ae1 100644 --- a/sites/docs/src/lib/components/demos/dialog-demo.svelte +++ b/sites/docs/src/lib/components/demos/dialog-demo.svelte @@ -1,8 +1,6 @@ @@ -15,13 +13,10 @@