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 ( + +