From c0019d57e7036d480a9416f00d0f3d73e26da92b Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 20:41:10 +0700 Subject: [PATCH] Migrate navigation components to Zod props --- .../components/accordion/accordion.props.ts | 17 ++++++++ .../core/src/components/accordion/index.ts | 14 ++----- .../alert-dialog/alert-dialog.props.ts | 14 +++++++ .../core/src/components/alert-dialog/index.ts | 27 ++---------- .../breadcrumbs/breadcrumbs.props.ts | 13 ++++++ .../core/src/components/breadcrumbs/index.ts | 16 ++------ .../collapsible/collapsible.props.ts | 15 +++++++ .../core/src/components/collapsible/index.ts | 11 ++--- packages/core/src/components/tabs/index.ts | 17 ++------ .../core/src/components/tabs/tabs.props.ts | 17 ++++++++ .../schemas/disclosure-components.test.ts | 28 +++++++++++++ .../tests/schemas/form-components.test.ts | 41 +++++++++++++++++++ 12 files changed, 163 insertions(+), 67 deletions(-) create mode 100644 packages/core/src/components/accordion/accordion.props.ts create mode 100644 packages/core/src/components/alert-dialog/alert-dialog.props.ts create mode 100644 packages/core/src/components/breadcrumbs/breadcrumbs.props.ts create mode 100644 packages/core/src/components/collapsible/collapsible.props.ts create mode 100644 packages/core/src/components/tabs/tabs.props.ts create mode 100644 packages/core/tests/schemas/disclosure-components.test.ts create mode 100644 packages/core/tests/schemas/form-components.test.ts diff --git a/packages/core/src/components/accordion/accordion.props.ts b/packages/core/src/components/accordion/accordion.props.ts new file mode 100644 index 0000000..a49e988 --- /dev/null +++ b/packages/core/src/components/accordion/accordion.props.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const AccordionRootPropsSchema = z.object({ + value: z.union([z.string(), z.array(z.string())]).optional().describe("Controlled expanded item value(s)"), + defaultValue: z.union([z.string(), z.array(z.string())]).optional().describe("Initial expanded item value(s) when uncontrolled"), + multiple: z.boolean().optional().describe("Allow multiple items to be expanded simultaneously"), + collapsible: z.boolean().optional().describe("Allow the currently open item to be closed by clicking it again"), + disabled: z.boolean().optional().describe("Disable all accordion items"), +}); +export interface AccordionRootProps extends z.infer, Omit, keyof z.infer> { onValueChange?: (value: string | string[]) => void; children: JSX.Element; } +export const AccordionMeta: ComponentMeta = { + name: "Accordion", + description: "Vertically stacked sections that expand/collapse to show content one at a time or multiple", + parts: ["Root", "Item", "Header", "Trigger", "Content"] as const, + requiredParts: ["Root", "Item", "Trigger", "Content"] as const, +} as const; diff --git a/packages/core/src/components/accordion/index.ts b/packages/core/src/components/accordion/index.ts index ea51fcd..4902f78 100644 --- a/packages/core/src/components/accordion/index.ts +++ b/packages/core/src/components/accordion/index.ts @@ -4,18 +4,10 @@ import { AccordionHeader } from "./accordion-header"; import { AccordionItem } from "./accordion-item"; import { AccordionRoot } from "./accordion-root"; import { AccordionTrigger } from "./accordion-trigger"; - -export const Accordion = Object.assign(AccordionRoot, { - Item: AccordionItem, - Header: AccordionHeader, - Trigger: AccordionTrigger, - Content: AccordionContent, - useContext: useAccordionRootContext, -}); - -export type { AccordionRootProps } from "./accordion-root"; -export type { AccordionItemProps } from "./accordion-item"; +export { AccordionRootPropsSchema, AccordionItemPropsSchema, AccordionMeta } from "./accordion.props"; +export type { AccordionRootProps, AccordionItemProps } from "./accordion.props"; export type { AccordionHeaderProps } from "./accordion-header"; export type { AccordionTriggerProps } from "./accordion-trigger"; export type { AccordionContentProps } from "./accordion-content"; export type { AccordionRootContextValue, AccordionItemContextValue } from "./accordion-context"; +export const Accordion = Object.assign(AccordionRoot, { Item: AccordionItem, Header: AccordionHeader, Trigger: AccordionTrigger, Content: AccordionContent, useContext: useAccordionRootContext }); diff --git a/packages/core/src/components/alert-dialog/alert-dialog.props.ts b/packages/core/src/components/alert-dialog/alert-dialog.props.ts new file mode 100644 index 0000000..5937622 --- /dev/null +++ b/packages/core/src/components/alert-dialog/alert-dialog.props.ts @@ -0,0 +1,14 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const AlertDialogRootPropsSchema = z.object({ + open: z.boolean().optional().describe("Controlled open state"), + defaultOpen: z.boolean().optional().describe("Initial open state when uncontrolled"), +}); +export interface AlertDialogRootProps extends z.infer { onOpenChange?: (open: boolean) => void; children: JSX.Element; } +export const AlertDialogMeta: ComponentMeta = { + name: "AlertDialog", + description: "Modal dialog for critical confirmations that requires explicit user action to dismiss", + parts: ["Root", "Trigger", "Portal", "Overlay", "Content", "Title", "Description", "Cancel", "Action"] as const, + requiredParts: ["Root", "Content", "Title", "Action"] as const, +} as const; diff --git a/packages/core/src/components/alert-dialog/index.ts b/packages/core/src/components/alert-dialog/index.ts index f9507e7..0791062 100644 --- a/packages/core/src/components/alert-dialog/index.ts +++ b/packages/core/src/components/alert-dialog/index.ts @@ -8,26 +8,7 @@ import { AlertDialogPortal } from "./alert-dialog-portal"; import { AlertDialogRoot } from "./alert-dialog-root"; import { AlertDialogTitle } from "./alert-dialog-title"; import { AlertDialogTrigger } from "./alert-dialog-trigger"; - -export const AlertDialog = Object.assign(AlertDialogRoot, { - Content: AlertDialogContent, - Title: AlertDialogTitle, - Description: AlertDialogDescription, - Trigger: AlertDialogTrigger, - Cancel: AlertDialogCancel, - Action: AlertDialogAction, - Portal: AlertDialogPortal, - Overlay: AlertDialogOverlay, - useContext: useAlertDialogContext, -}); - -export type { AlertDialogRootProps } from "./alert-dialog-root"; -export type { AlertDialogContentProps } from "./alert-dialog-content"; -export type { AlertDialogTitleProps } from "./alert-dialog-title"; -export type { AlertDialogDescriptionProps } from "./alert-dialog-description"; -export type { AlertDialogTriggerProps } from "./alert-dialog-trigger"; -export type { AlertDialogCancelProps } from "./alert-dialog-cancel"; -export type { AlertDialogActionProps } from "./alert-dialog-action"; -export type { AlertDialogPortalProps } from "./alert-dialog-portal"; -export type { AlertDialogOverlayProps } from "./alert-dialog-overlay"; -export type { AlertDialogContextValue } from "./alert-dialog-context"; +export { AlertDialogRootPropsSchema, AlertDialogMeta } from "./alert-dialog.props"; +export type { AlertDialogRootProps } from "./alert-dialog.props"; +export type { AlertDialogContentProps, AlertDialogTitleProps, AlertDialogDescriptionProps, AlertDialogTriggerProps, AlertDialogCancelProps, AlertDialogActionProps, AlertDialogPortalProps, AlertDialogOverlayProps, AlertDialogContextValue } from "./alert-dialog-content"; +export const AlertDialog = Object.assign(AlertDialogRoot, { Content: AlertDialogContent, Title: AlertDialogTitle, Description: AlertDialogDescription, Trigger: AlertDialogTrigger, Cancel: AlertDialogCancel, Action: AlertDialogAction, Portal: AlertDialogPortal, Overlay: AlertDialogOverlay, useContext: useAlertDialogContext }); diff --git a/packages/core/src/components/breadcrumbs/breadcrumbs.props.ts b/packages/core/src/components/breadcrumbs/breadcrumbs.props.ts new file mode 100644 index 0000000..f59801b --- /dev/null +++ b/packages/core/src/components/breadcrumbs/breadcrumbs.props.ts @@ -0,0 +1,13 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const BreadcrumbsRootPropsSchema = z.object({ + separator: z.string().optional().describe("Custom separator string rendered between breadcrumb items"), +}); +export interface BreadcrumbsRootProps extends z.infer, Omit, keyof z.infer> { children: JSX.Element; } +export const BreadcrumbsMeta: ComponentMeta = { + name: "Breadcrumbs", + description: "Navigation trail showing the current page location within a hierarchy", + parts: ["Root", "Item", "Link", "Separator"] as const, + requiredParts: ["Root", "Item"] as const, +} as const; diff --git a/packages/core/src/components/breadcrumbs/index.ts b/packages/core/src/components/breadcrumbs/index.ts index e934eb7..29ac029 100644 --- a/packages/core/src/components/breadcrumbs/index.ts +++ b/packages/core/src/components/breadcrumbs/index.ts @@ -2,15 +2,7 @@ import { BreadcrumbsItem } from "./breadcrumbs-item"; import { BreadcrumbsLink } from "./breadcrumbs-link"; import { BreadcrumbsRoot } from "./breadcrumbs-root"; import { BreadcrumbsSeparator } from "./breadcrumbs-separator"; - -/** Compound breadcrumbs component with Item, Link, and Separator sub-components. */ -export const Breadcrumbs = Object.assign(BreadcrumbsRoot, { - Item: BreadcrumbsItem, - Link: BreadcrumbsLink, - Separator: BreadcrumbsSeparator, -}); - -export type { BreadcrumbsRootProps } from "./breadcrumbs-root"; -export type { BreadcrumbsItemProps } from "./breadcrumbs-item"; -export type { BreadcrumbsLinkProps } from "./breadcrumbs-link"; -export type { BreadcrumbsSeparatorProps } from "./breadcrumbs-separator"; +export { BreadcrumbsRootPropsSchema, BreadcrumbsMeta } from "./breadcrumbs.props"; +export type { BreadcrumbsRootProps } from "./breadcrumbs.props"; +export type { BreadcrumbsItemProps, BreadcrumbsLinkProps, BreadcrumbsSeparatorProps } from "./breadcrumbs-item"; +export const Breadcrumbs = Object.assign(BreadcrumbsRoot, { Item: BreadcrumbsItem, Link: BreadcrumbsLink, Separator: BreadcrumbsSeparator }); diff --git a/packages/core/src/components/collapsible/collapsible.props.ts b/packages/core/src/components/collapsible/collapsible.props.ts new file mode 100644 index 0000000..8c014ac --- /dev/null +++ b/packages/core/src/components/collapsible/collapsible.props.ts @@ -0,0 +1,15 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const CollapsibleRootPropsSchema = z.object({ + open: z.boolean().optional().describe("Controlled open state"), + defaultOpen: z.boolean().optional().describe("Initial open state when uncontrolled"), + disabled: z.boolean().optional().describe("Prevent the collapsible from being toggled"), +}); +export interface CollapsibleRootProps extends z.infer, Omit, keyof z.infer> { onOpenChange?: (open: boolean) => void; children: JSX.Element; } +export const CollapsibleMeta: ComponentMeta = { + name: "Collapsible", + description: "Content section that can be expanded or collapsed with a trigger", + parts: ["Root", "Trigger", "Content"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; diff --git a/packages/core/src/components/collapsible/index.ts b/packages/core/src/components/collapsible/index.ts index 8ea6c55..31116e2 100644 --- a/packages/core/src/components/collapsible/index.ts +++ b/packages/core/src/components/collapsible/index.ts @@ -2,14 +2,9 @@ import { CollapsibleContent } from "./collapsible-content"; import { useCollapsibleContext } from "./collapsible-context"; import { CollapsibleRoot } from "./collapsible-root"; import { CollapsibleTrigger } from "./collapsible-trigger"; - -export const Collapsible = Object.assign(CollapsibleRoot, { - Trigger: CollapsibleTrigger, - Content: CollapsibleContent, - useContext: useCollapsibleContext, -}); - -export type { CollapsibleRootProps } from "./collapsible-root"; +export { CollapsibleRootPropsSchema, CollapsibleMeta } from "./collapsible.props"; +export type { CollapsibleRootProps } from "./collapsible.props"; export type { CollapsibleTriggerProps } from "./collapsible-trigger"; export type { CollapsibleContentProps } from "./collapsible-content"; export type { CollapsibleContextValue } from "./collapsible-context"; +export const Collapsible = Object.assign(CollapsibleRoot, { Trigger: CollapsibleTrigger, Content: CollapsibleContent, useContext: useCollapsibleContext }); diff --git a/packages/core/src/components/tabs/index.ts b/packages/core/src/components/tabs/index.ts index 0c8c895..db17d09 100644 --- a/packages/core/src/components/tabs/index.ts +++ b/packages/core/src/components/tabs/index.ts @@ -3,16 +3,7 @@ import { TabsList } from "./tabs-list"; import { TabsPanel } from "./tabs-panel"; import { TabsRoot } from "./tabs-root"; import { TabsTab } from "./tabs-tab"; - -export const Tabs = Object.assign(TabsRoot, { - List: TabsList, - Tab: TabsTab, - Panel: TabsPanel, - useContext: useTabsContext, -}); - -export type { TabsRootProps } from "./tabs-root"; -export type { TabsListProps } from "./tabs-list"; -export type { TabsTabProps } from "./tabs-tab"; -export type { TabsPanelProps } from "./tabs-panel"; -export type { TabsContextValue } from "./tabs-context"; +export { TabsRootPropsSchema, TabsMeta } from "./tabs.props"; +export type { TabsRootProps } from "./tabs.props"; +export type { TabsListProps, TabsTabProps, TabsPanelProps, TabsContextValue } from "./tabs-list"; +export const Tabs = Object.assign(TabsRoot, { List: TabsList, Tab: TabsTab, Panel: TabsPanel, useContext: useTabsContext }); diff --git a/packages/core/src/components/tabs/tabs.props.ts b/packages/core/src/components/tabs/tabs.props.ts new file mode 100644 index 0000000..297afae --- /dev/null +++ b/packages/core/src/components/tabs/tabs.props.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const TabsRootPropsSchema = z.object({ + value: z.string().optional().describe("Controlled active tab value"), + defaultValue: z.string().optional().describe("Initial active tab value when uncontrolled"), + orientation: z.enum(["horizontal", "vertical"]).optional().describe("Layout orientation of the tab list. Defaults to horizontal"), + activationMode: z.enum(["automatic", "manual"]).optional().describe("automatic: activates on focus; manual: activates on click or Enter/Space. Defaults to automatic"), + disabled: z.boolean().optional().describe("Disable all tabs"), +}); +export interface TabsRootProps extends z.infer, Omit, keyof z.infer> { onValueChange?: (value: string) => void; children: JSX.Element; } +export const TabsMeta: ComponentMeta = { + name: "Tabs", + description: "Tabbed interface for switching between different views or sections of content", + parts: ["Root", "List", "Trigger", "Content"] as const, + requiredParts: ["Root", "List", "Trigger", "Content"] as const, +} as const; diff --git a/packages/core/tests/schemas/disclosure-components.test.ts b/packages/core/tests/schemas/disclosure-components.test.ts new file mode 100644 index 0000000..a019f15 --- /dev/null +++ b/packages/core/tests/schemas/disclosure-components.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { AccordionRootPropsSchema, AccordionMeta } from "../../src/components/accordion/accordion.props"; +import { CollapsibleRootPropsSchema, CollapsibleMeta } from "../../src/components/collapsible/collapsible.props"; +import { AlertDialogRootPropsSchema, AlertDialogMeta } from "../../src/components/alert-dialog/alert-dialog.props"; +import { BreadcrumbsRootPropsSchema, BreadcrumbsMeta } from "../../src/components/breadcrumbs/breadcrumbs.props"; +import { TabsRootPropsSchema, TabsMeta } from "../../src/components/tabs/tabs.props"; +describe("Disclosure component schemas", () => { + it("Accordion validates", () => { + expect(AccordionRootPropsSchema.safeParse({ value: "item-1" }).success).toBe(true); + expect(AccordionRootPropsSchema.safeParse({ value: ["item-1", "item-2"], multiple: true }).success).toBe(true); + }); + it("Collapsible validates", () => { expect(CollapsibleRootPropsSchema.safeParse({ open: true, disabled: false }).success).toBe(true); }); + it("AlertDialog validates", () => { expect(AlertDialogRootPropsSchema.safeParse({ open: false }).success).toBe(true); }); + it("Breadcrumbs validates", () => { expect(BreadcrumbsRootPropsSchema.safeParse({ separator: ">" }).success).toBe(true); }); + it("Tabs validates", () => { + expect(TabsRootPropsSchema.safeParse({ value: "tab1", orientation: "vertical", activationMode: "manual" }).success).toBe(true); + expect(TabsRootPropsSchema.safeParse({ orientation: "diagonal" }).success).toBe(false); + }); + it("all Meta objects valid", () => { + const metas = [AccordionMeta, CollapsibleMeta, AlertDialogMeta, BreadcrumbsMeta, TabsMeta]; + for (const meta of metas) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + expect(meta.requiredParts.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/core/tests/schemas/form-components.test.ts b/packages/core/tests/schemas/form-components.test.ts new file mode 100644 index 0000000..fb2d901 --- /dev/null +++ b/packages/core/tests/schemas/form-components.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { TextFieldRootPropsSchema, TextFieldMeta } from "../../src/components/text-field/text-field.props"; +import { CheckboxRootPropsSchema, CheckboxMeta } from "../../src/components/checkbox/checkbox.props"; +import { SwitchRootPropsSchema, SwitchMeta } from "../../src/components/switch/switch.props"; +import { RadioGroupRootPropsSchema, RadioGroupMeta } from "../../src/components/radio-group/radio-group.props"; +import { SliderRootPropsSchema, SliderMeta } from "../../src/components/slider/slider.props"; +import { NumberFieldRootPropsSchema, NumberFieldMeta } from "../../src/components/number-field/number-field.props"; +import { ToggleGroupRootPropsSchema, ToggleGroupMeta } from "../../src/components/toggle-group/toggle-group.props"; +describe("Form component schemas", () => { + it("TextField validates", () => { + expect(TextFieldRootPropsSchema.safeParse({ value: "hello", disabled: false }).success).toBe(true); + }); + it("Checkbox validates", () => { + expect(CheckboxRootPropsSchema.safeParse({ checked: true, name: "agree" }).success).toBe(true); + }); + it("Switch validates", () => { + expect(SwitchRootPropsSchema.safeParse({ checked: false }).success).toBe(true); + }); + it("RadioGroup validates", () => { + expect(RadioGroupRootPropsSchema.safeParse({ value: "opt1", orientation: "horizontal" }).success).toBe(true); + expect(RadioGroupRootPropsSchema.safeParse({ orientation: "invalid" }).success).toBe(false); + }); + it("Slider validates", () => { + expect(SliderRootPropsSchema.safeParse({ value: [25, 75], min: 0, max: 100, step: 5 }).success).toBe(true); + }); + it("NumberField validates", () => { + expect(NumberFieldRootPropsSchema.safeParse({ value: 42, min: 0, max: 100 }).success).toBe(true); + }); + it("ToggleGroup validates", () => { + expect(ToggleGroupRootPropsSchema.safeParse({ value: "a" }).success).toBe(true); + expect(ToggleGroupRootPropsSchema.safeParse({ value: ["a", "b"], multiple: true }).success).toBe(true); + }); + it("all Meta objects valid", () => { + const metas = [TextFieldMeta, CheckboxMeta, SwitchMeta, RadioGroupMeta, SliderMeta, NumberFieldMeta, ToggleGroupMeta]; + for (const meta of metas) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + } + }); +});