From 69068fbee925d47e7c0b02772f22a119516af543 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 05:47:48 +0700 Subject: [PATCH] Dialog component Implements DialogRoot, Content, Title, Description, Trigger, Close, Portal, and Overlay parts with full context wiring, focus trap, scroll lock, and dismiss. Also fixes pre-existing TS6 exactOptionalPropertyTypes errors in create-disclosure-state and presence, and silences TS6.0 deprecation warnings via ignoreDeprecations. --- .../src/components/dialog/dialog-close.tsx | 28 +++++++ .../src/components/dialog/dialog-content.tsx | 76 +++++++++++++++++++ .../components/dialog/dialog-description.tsx | 24 ++++++ .../src/components/dialog/dialog-overlay.tsx | 20 +++++ .../src/components/dialog/dialog-portal.tsx | 20 +++++ .../src/components/dialog/dialog-root.tsx | 72 ++++++++++++++++++ .../src/components/dialog/dialog-title.tsx | 24 ++++++ .../src/components/dialog/dialog-trigger.tsx | 42 ++++++++++ packages/core/src/components/dialog/index.ts | 30 ++++++++ .../src/primitives/create-disclosure-state.ts | 2 +- .../core/src/utilities/presence/presence.tsx | 11 +-- .../dialog/dialog-rendering.test.tsx | 65 ++++++++++++++++ tsconfig.base.json | 3 +- 13 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/components/dialog/dialog-close.tsx create mode 100644 packages/core/src/components/dialog/dialog-content.tsx create mode 100644 packages/core/src/components/dialog/dialog-description.tsx create mode 100644 packages/core/src/components/dialog/dialog-overlay.tsx create mode 100644 packages/core/src/components/dialog/dialog-portal.tsx create mode 100644 packages/core/src/components/dialog/dialog-root.tsx create mode 100644 packages/core/src/components/dialog/dialog-title.tsx create mode 100644 packages/core/src/components/dialog/dialog-trigger.tsx create mode 100644 packages/core/src/components/dialog/index.ts create mode 100644 packages/core/tests/components/dialog/dialog-rendering.test.tsx diff --git a/packages/core/src/components/dialog/dialog-close.tsx b/packages/core/src/components/dialog/dialog-close.tsx new file mode 100644 index 0000000..9a3fd10 --- /dev/null +++ b/packages/core/src/components/dialog/dialog-close.tsx @@ -0,0 +1,28 @@ +// packages/core/src/components/dialog/dialog-close.tsx +import type { Component, JSX } from "solid-js"; +import { splitProps } from "solid-js"; +import { Dynamic } from "solid-js/web"; +import { useInternalDialogContext } from "./dialog-context"; + +export interface DialogCloseProps extends JSX.HTMLAttributes { + as?: string | Component; + children?: JSX.Element; +} + +/** Closes the dialog when clicked. Supports polymorphic rendering via `as`. */ +export function DialogClose(props: DialogCloseProps): JSX.Element { + const [local, rest] = splitProps(props, ["as", "children", "onClick"]); + const ctx = useInternalDialogContext(); + + const handleClick: JSX.EventHandler = (e) => { + if (typeof local.onClick === "function") + local.onClick(e as MouseEvent & { currentTarget: HTMLButtonElement; target: Element }); + ctx.setOpen(false); + }; + + return ( + + {local.children} + + ); +} diff --git a/packages/core/src/components/dialog/dialog-content.tsx b/packages/core/src/components/dialog/dialog-content.tsx new file mode 100644 index 0000000..8767854 --- /dev/null +++ b/packages/core/src/components/dialog/dialog-content.tsx @@ -0,0 +1,76 @@ +// packages/core/src/components/dialog/dialog-content.tsx +import type { JSX } from "solid-js"; +import { Show, onCleanup, onMount, splitProps } from "solid-js"; +import { createDismiss } from "../../utilities/dismiss/create-dismiss"; +import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap"; +import { Portal } from "../../utilities/portal/portal"; +import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock"; +import { useInternalDialogContext } from "./dialog-context"; + +export interface DialogContentProps extends JSX.DialogHtmlAttributes { + /** + * Called when auto-focus fires on open. Call event.preventDefault() to prevent default. + */ + onOpenAutoFocus?: (event: Event) => void; + /** + * Called when auto-focus fires on close. Call event.preventDefault() to prevent default. + */ + onCloseAutoFocus?: (event: Event) => void; + /** Keep mounted even when closed (for animation control). */ + forceMount?: boolean; + children?: JSX.Element; +} + +/** + * Dialog content panel. Portals to body, manages focus trap, scroll lock, and dismiss. + * Only renders when open (unless forceMount is set). + */ +export function DialogContent(props: DialogContentProps): JSX.Element { + const [local, rest] = splitProps(props, [ + "children", + "onOpenAutoFocus", + "onCloseAutoFocus", + "forceMount", + ]); + const ctx = useInternalDialogContext(); + let contentRef: HTMLDialogElement | undefined; + + const focusTrap = createFocusTrap(() => contentRef ?? null); + const scrollLock = createScrollLock(); + const dismiss = createDismiss({ + getContainer: () => contentRef ?? null, + onDismiss: () => ctx.setOpen(false), + }); + + onMount(() => { + if (ctx.isOpen() && ctx.modal()) { + focusTrap.activate(); + scrollLock.lock(); + dismiss.attach(); + } + }); + + onCleanup(() => { + focusTrap.deactivate(); + scrollLock.unlock(); + dismiss.detach(); + }); + + return ( + + + + {local.children} + + + + ); +} diff --git a/packages/core/src/components/dialog/dialog-description.tsx b/packages/core/src/components/dialog/dialog-description.tsx new file mode 100644 index 0000000..06eab78 --- /dev/null +++ b/packages/core/src/components/dialog/dialog-description.tsx @@ -0,0 +1,24 @@ +// packages/core/src/components/dialog/dialog-description.tsx +import type { JSX } from "solid-js"; +import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js"; +import { useInternalDialogContext } from "./dialog-context"; + +export interface DialogDescriptionProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +/** Renders as p and registers its ID for aria-describedby on Dialog.Content. */ +export function DialogDescription(props: DialogDescriptionProps): JSX.Element { + const [local, rest] = splitProps(props, ["children"]); + const ctx = useInternalDialogContext(); + const id = createUniqueId(); + + onMount(() => ctx.setDescriptionId(id)); + onCleanup(() => ctx.setDescriptionId(undefined)); + + return ( +

+ {local.children} +

+ ); +} diff --git a/packages/core/src/components/dialog/dialog-overlay.tsx b/packages/core/src/components/dialog/dialog-overlay.tsx new file mode 100644 index 0000000..2fcc991 --- /dev/null +++ b/packages/core/src/components/dialog/dialog-overlay.tsx @@ -0,0 +1,20 @@ +// packages/core/src/components/dialog/dialog-overlay.tsx +import type { JSX } from "solid-js"; +import { Show, splitProps } from "solid-js"; +import { useInternalDialogContext } from "./dialog-context"; + +export interface DialogOverlayProps extends JSX.HTMLAttributes { + forceMount?: boolean; +} + +/** Semi-transparent overlay rendered behind dialog content. */ +export function DialogOverlay(props: DialogOverlayProps): JSX.Element { + const [local, rest] = splitProps(props, ["forceMount"]); + const ctx = useInternalDialogContext(); + + return ( + +