From 38ef3b0934f23e170fc45de27b9e3ed1782be9f9 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 20:36:18 +0700 Subject: [PATCH] Migrate Dialog to Zod props Centralises all Dialog prop interfaces and schemas in dialog.props.ts. Adds DialogRootPropsSchema, DialogContentPropsSchema, and DialogMeta for AI/MCP discovery. Sub-components now import types from the shared file. --- .../src/components/dialog/dialog-close.tsx | 8 +-- .../src/components/dialog/dialog-content.tsx | 15 +---- .../components/dialog/dialog-description.tsx | 5 +- .../src/components/dialog/dialog-overlay.tsx | 5 +- .../src/components/dialog/dialog-portal.tsx | 7 +-- .../src/components/dialog/dialog-root.tsx | 18 +----- .../src/components/dialog/dialog-title.tsx | 5 +- .../src/components/dialog/dialog-trigger.tsx | 10 +-- .../src/components/dialog/dialog.props.ts | 62 +++++++++++++++++++ packages/core/src/components/dialog/index.ts | 24 +------ .../components/dialog/dialog-props.test.ts | 44 +++++++++++++ 11 files changed, 128 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/components/dialog/dialog.props.ts create mode 100644 packages/core/tests/components/dialog/dialog-props.test.ts diff --git a/packages/core/src/components/dialog/dialog-close.tsx b/packages/core/src/components/dialog/dialog-close.tsx index 846132b..06d40bb 100644 --- a/packages/core/src/components/dialog/dialog-close.tsx +++ b/packages/core/src/components/dialog/dialog-close.tsx @@ -1,13 +1,11 @@ // packages/core/src/components/dialog/dialog-close.tsx -import type { Component, JSX } from "solid-js"; +import type { JSX } from "solid-js"; import { mergeProps, splitProps } from "solid-js"; import { Dynamic } from "solid-js/web"; import { useInternalDialogContext } from "./dialog-context"; +import type { DialogCloseProps } from "./dialog.props"; -export interface DialogCloseProps extends JSX.HTMLAttributes { - as?: string | Component; - children?: JSX.Element; -} +export type { DialogCloseProps }; /** Closes the dialog when clicked. Supports polymorphic rendering via `as`. */ export function DialogClose(props: DialogCloseProps): JSX.Element { diff --git a/packages/core/src/components/dialog/dialog-content.tsx b/packages/core/src/components/dialog/dialog-content.tsx index f39a157..905caed 100644 --- a/packages/core/src/components/dialog/dialog-content.tsx +++ b/packages/core/src/components/dialog/dialog-content.tsx @@ -5,20 +5,9 @@ import { createDismiss } from "../../utilities/dismiss/create-dismiss"; import { createFocusTrap } from "../../utilities/focus-trap/create-focus-trap"; import { createScrollLock } from "../../utilities/scroll-lock/create-scroll-lock"; import { useInternalDialogContext } from "./dialog-context"; +import type { DialogContentProps } from "./dialog.props"; -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; -} +export type { DialogContentProps }; /** * Dialog content panel. Manages focus trap, scroll lock, and dismiss. diff --git a/packages/core/src/components/dialog/dialog-description.tsx b/packages/core/src/components/dialog/dialog-description.tsx index 06eab78..c6fe5cc 100644 --- a/packages/core/src/components/dialog/dialog-description.tsx +++ b/packages/core/src/components/dialog/dialog-description.tsx @@ -2,10 +2,9 @@ import type { JSX } from "solid-js"; import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js"; import { useInternalDialogContext } from "./dialog-context"; +import type { DialogDescriptionProps } from "./dialog.props"; -export interface DialogDescriptionProps extends JSX.HTMLAttributes { - children?: JSX.Element; -} +export type { DialogDescriptionProps }; /** Renders as p and registers its ID for aria-describedby on Dialog.Content. */ export function DialogDescription(props: DialogDescriptionProps): JSX.Element { diff --git a/packages/core/src/components/dialog/dialog-overlay.tsx b/packages/core/src/components/dialog/dialog-overlay.tsx index 2fcc991..2bc81a6 100644 --- a/packages/core/src/components/dialog/dialog-overlay.tsx +++ b/packages/core/src/components/dialog/dialog-overlay.tsx @@ -2,10 +2,9 @@ import type { JSX } from "solid-js"; import { Show, splitProps } from "solid-js"; import { useInternalDialogContext } from "./dialog-context"; +import type { DialogOverlayProps } from "./dialog.props"; -export interface DialogOverlayProps extends JSX.HTMLAttributes { - forceMount?: boolean; -} +export type { DialogOverlayProps }; /** Semi-transparent overlay rendered behind dialog content. */ export function DialogOverlay(props: DialogOverlayProps): JSX.Element { diff --git a/packages/core/src/components/dialog/dialog-portal.tsx b/packages/core/src/components/dialog/dialog-portal.tsx index 1a17be5..48d42b2 100644 --- a/packages/core/src/components/dialog/dialog-portal.tsx +++ b/packages/core/src/components/dialog/dialog-portal.tsx @@ -1,12 +1,9 @@ // packages/core/src/components/dialog/dialog-portal.tsx import type { JSX } from "solid-js"; import { Portal } from "../../utilities/portal/portal"; +import type { DialogPortalProps } from "./dialog.props"; -export interface DialogPortalProps { - /** Override the portal target container. */ - target?: Element | null; - children: JSX.Element; -} +export type { DialogPortalProps }; /** Renders children into a portal (defaults to document.body). */ export function DialogPortal(props: DialogPortalProps): JSX.Element { diff --git a/packages/core/src/components/dialog/dialog-root.tsx b/packages/core/src/components/dialog/dialog-root.tsx index 9a446f0..6b3640c 100644 --- a/packages/core/src/components/dialog/dialog-root.tsx +++ b/packages/core/src/components/dialog/dialog-root.tsx @@ -1,6 +1,6 @@ // packages/core/src/components/dialog/dialog-root.tsx -import type { JSX } from "solid-js"; import { createUniqueId } from "solid-js"; +import type { JSX } from "solid-js"; import { type CreateDisclosureStateOptions, createDisclosureState, @@ -11,21 +11,9 @@ import { InternalDialogContextProvider, type InternalDialogContextValue, } from "./dialog-context"; +import type { DialogRootProps } from "./dialog.props"; -export interface DialogRootProps { - /** Controlled open state. */ - open?: boolean; - /** Default open state (uncontrolled). */ - defaultOpen?: boolean; - /** Called when open state should change. */ - onOpenChange?: (open: boolean) => void; - /** - * Whether the dialog blocks outside interaction and traps focus. - * @default true - */ - modal?: boolean; - children: JSX.Element; -} +export type { DialogRootProps }; /** * Root component. Manages open state, provides context to all Dialog parts. diff --git a/packages/core/src/components/dialog/dialog-title.tsx b/packages/core/src/components/dialog/dialog-title.tsx index 4eeafa3..2d6f3fd 100644 --- a/packages/core/src/components/dialog/dialog-title.tsx +++ b/packages/core/src/components/dialog/dialog-title.tsx @@ -2,10 +2,9 @@ import type { JSX } from "solid-js"; import { createUniqueId, onCleanup, onMount, splitProps } from "solid-js"; import { useInternalDialogContext } from "./dialog-context"; +import type { DialogTitleProps } from "./dialog.props"; -export interface DialogTitleProps extends JSX.HTMLAttributes { - children?: JSX.Element; -} +export type { DialogTitleProps }; /** Renders as h2 and registers its ID for aria-labelledby on Dialog.Content. */ export function DialogTitle(props: DialogTitleProps): JSX.Element { diff --git a/packages/core/src/components/dialog/dialog-trigger.tsx b/packages/core/src/components/dialog/dialog-trigger.tsx index 24e6e22..9134f65 100644 --- a/packages/core/src/components/dialog/dialog-trigger.tsx +++ b/packages/core/src/components/dialog/dialog-trigger.tsx @@ -1,15 +1,11 @@ // packages/core/src/components/dialog/dialog-trigger.tsx -import type { Component, JSX } from "solid-js"; +import type { JSX } from "solid-js"; import { mergeProps, splitProps } from "solid-js"; import { Dynamic } from "solid-js/web"; import { useInternalDialogContext } from "./dialog-context"; +import type { DialogTriggerProps } from "./dialog.props"; -export interface DialogTriggerProps - extends Omit, "children"> { - /** Render as a different element or component. */ - as?: string | Component; - children?: JSX.Element | ((props: JSX.HTMLAttributes) => JSX.Element); -} +export type { DialogTriggerProps }; /** Opens the dialog when clicked. Supports polymorphic rendering via `as` and children-as-function. */ export function DialogTrigger(props: DialogTriggerProps): JSX.Element { diff --git a/packages/core/src/components/dialog/dialog.props.ts b/packages/core/src/components/dialog/dialog.props.ts new file mode 100644 index 0000000..8b6ae01 --- /dev/null +++ b/packages/core/src/components/dialog/dialog.props.ts @@ -0,0 +1,62 @@ +// packages/core/src/components/dialog/dialog.props.ts +import { z } from "zod/v4"; +import type { Component, JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; + +export const DialogRootPropsSchema = z.object({ + open: z.boolean().optional().describe("Controlled open state"), + defaultOpen: z.boolean().optional().describe("Initial open state (uncontrolled)"), + modal: z.boolean().optional().describe("Whether to trap focus and add backdrop. Defaults to true"), +}); + +export interface DialogRootProps extends z.infer { + onOpenChange?: (open: boolean) => void; + children: JSX.Element; +} + +export const DialogContentPropsSchema = z.object({ + forceMount: z.boolean().optional().describe("Force mount content even when closed, useful for animations"), +}); + +export interface DialogContentProps + extends z.infer, + Omit, keyof z.infer> { + onOpenAutoFocus?: (event: Event) => void; + onCloseAutoFocus?: (event: Event) => void; + children?: JSX.Element; +} + +export interface DialogTriggerProps extends Omit, "children"> { + as?: string | Component; + children?: JSX.Element | ((props: JSX.HTMLAttributes) => JSX.Element); +} + +export interface DialogTitleProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface DialogDescriptionProps extends JSX.HTMLAttributes { + children?: JSX.Element; +} + +export interface DialogCloseProps extends JSX.HTMLAttributes { + as?: string | Component; + children?: JSX.Element; +} + +export interface DialogOverlayProps extends JSX.HTMLAttributes { + forceMount?: boolean; +} + +export interface DialogPortalProps { + /** Override the portal target container. */ + target?: Element | null; + children: JSX.Element; +} + +export const DialogMeta: ComponentMeta = { + name: "Dialog", + description: "Modal overlay that interrupts the user with important content requiring acknowledgment", + parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Close"] as const, + requiredParts: ["Root", "Content", "Title"] as const, +} as const; diff --git a/packages/core/src/components/dialog/index.ts b/packages/core/src/components/dialog/index.ts index 610b12d..f121510 100644 --- a/packages/core/src/components/dialog/index.ts +++ b/packages/core/src/components/dialog/index.ts @@ -1,4 +1,3 @@ -// packages/core/src/components/dialog/index.ts import { DialogClose } from "./dialog-close"; import { DialogContent } from "./dialog-content"; import { useDialogContext } from "./dialog-context"; @@ -8,23 +7,6 @@ import { DialogPortal } from "./dialog-portal"; import { DialogRoot } from "./dialog-root"; import { DialogTitle } from "./dialog-title"; import { DialogTrigger } from "./dialog-trigger"; - -export const Dialog = Object.assign(DialogRoot, { - Content: DialogContent, - Title: DialogTitle, - Description: DialogDescription, - Trigger: DialogTrigger, - Close: DialogClose, - Portal: DialogPortal, - Overlay: DialogOverlay, - useContext: useDialogContext, -}); - -export type { DialogRootProps } from "./dialog-root"; -export type { DialogContentProps } from "./dialog-content"; -export type { DialogTitleProps } from "./dialog-title"; -export type { DialogDescriptionProps } from "./dialog-description"; -export type { DialogTriggerProps } from "./dialog-trigger"; -export type { DialogCloseProps } from "./dialog-close"; -export type { DialogPortalProps } from "./dialog-portal"; -export type { DialogOverlayProps } from "./dialog-overlay"; +export { DialogRootPropsSchema, DialogContentPropsSchema, DialogMeta } from "./dialog.props"; +export type { DialogRootProps, DialogContentProps, DialogTitleProps, DialogDescriptionProps, DialogTriggerProps, DialogCloseProps, DialogPortalProps, DialogOverlayProps } from "./dialog.props"; +export const Dialog = Object.assign(DialogRoot, { Content: DialogContent, Title: DialogTitle, Description: DialogDescription, Trigger: DialogTrigger, Close: DialogClose, Portal: DialogPortal, Overlay: DialogOverlay, useContext: useDialogContext }); diff --git a/packages/core/tests/components/dialog/dialog-props.test.ts b/packages/core/tests/components/dialog/dialog-props.test.ts new file mode 100644 index 0000000..57ba3ff --- /dev/null +++ b/packages/core/tests/components/dialog/dialog-props.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + DialogRootPropsSchema, + DialogContentPropsSchema, + DialogMeta, +} from "../../../src/components/dialog/dialog.props"; + +describe("Dialog Zod schemas", () => { + it("validates correct root props", () => { + const result = DialogRootPropsSchema.safeParse({ open: true, modal: false }); + expect(result.success).toBe(true); + }); + + it("validates empty root props (all optional)", () => { + const result = DialogRootPropsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("rejects invalid root props", () => { + const result = DialogRootPropsSchema.safeParse({ open: "yes" }); + expect(result.success).toBe(false); + }); + + it("validates content props", () => { + const result = DialogContentPropsSchema.safeParse({ forceMount: true }); + expect(result.success).toBe(true); + }); + + it("exposes meta with required parts", () => { + expect(DialogMeta.name).toBe("Dialog"); + expect(DialogMeta.parts).toContain("Root"); + expect(DialogMeta.parts).toContain("Content"); + expect(DialogMeta.parts).toContain("Title"); + expect(DialogMeta.requiredParts).toContain("Root"); + expect(DialogMeta.requiredParts).toContain("Content"); + expect(DialogMeta.requiredParts).toContain("Title"); + }); + + it("schema has descriptions on all fields", () => { + const shape = DialogRootPropsSchema.shape; + expect(shape.open.description).toBeDefined(); + expect(shape.modal.description).toBeDefined(); + }); +});