From 6dd06986ccaea20b3ae2c55760827e3c592ccb09 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 20:39:28 +0700 Subject: [PATCH] Migrate overlay components to Zod props --- .../src/components/drawer/drawer-root.tsx | 14 ++------ .../src/components/drawer/drawer.props.ts | 16 +++++++++ packages/core/src/components/drawer/index.ts | 24 ++----------- .../components/hover-card/hover-card-root.tsx | 15 ++------ .../components/hover-card/hover-card.props.ts | 16 +++++++++ .../core/src/components/hover-card/index.ts | 1 + packages/core/src/components/popover/index.ts | 2 ++ .../src/components/popover/popover-root.tsx | 21 ++--------- .../src/components/popover/popover.props.ts | 15 ++++++++ packages/core/src/components/toast/index.ts | 1 + .../src/components/toast/toast-region.tsx | 9 ++--- .../core/src/components/toast/toast.props.ts | 16 +++++++++ packages/core/src/components/tooltip/index.ts | 2 ++ .../src/components/tooltip/tooltip-root.tsx | 16 ++------- .../src/components/tooltip/tooltip.props.ts | 17 +++++++++ .../schemas/collection-components.test.ts | 35 +++++++++++++------ .../tests/schemas/overlay-components.test.ts | 28 +++++++++++++++ 17 files changed, 152 insertions(+), 96 deletions(-) create mode 100644 packages/core/src/components/drawer/drawer.props.ts create mode 100644 packages/core/src/components/hover-card/hover-card.props.ts create mode 100644 packages/core/src/components/popover/popover.props.ts create mode 100644 packages/core/src/components/toast/toast.props.ts create mode 100644 packages/core/src/components/tooltip/tooltip.props.ts create mode 100644 packages/core/tests/schemas/overlay-components.test.ts diff --git a/packages/core/src/components/drawer/drawer-root.tsx b/packages/core/src/components/drawer/drawer-root.tsx index 2cf5dfe..1514978 100644 --- a/packages/core/src/components/drawer/drawer-root.tsx +++ b/packages/core/src/components/drawer/drawer-root.tsx @@ -12,18 +12,8 @@ import { type InternalDrawerContextValue, } from "./drawer-context"; -/** Props for the Drawer root component. */ -export interface DrawerRootProps { - /** Controls open state externally. */ - open?: boolean; - /** Initial open state when uncontrolled. */ - defaultOpen?: boolean; - /** Called when open state changes. */ - onOpenChange?: (open: boolean) => void; - /** Which edge the drawer slides from. @default "right" */ - side?: DrawerSide; - children: JSX.Element; -} +import type { DrawerRootProps } from "./drawer.props"; +export type { DrawerRootProps } from "./drawer.props"; /** * Root component for Drawer. Manages open state and provides context. diff --git a/packages/core/src/components/drawer/drawer.props.ts b/packages/core/src/components/drawer/drawer.props.ts new file mode 100644 index 0000000..d977d47 --- /dev/null +++ b/packages/core/src/components/drawer/drawer.props.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const DrawerRootPropsSchema = z.object({ + open: z.boolean().optional(), + defaultOpen: z.boolean().optional(), + modal: z.boolean().optional(), + side: z.enum(["left", "right", "top", "bottom"]).optional(), +}); +export interface DrawerRootProps extends z.infer { onOpenChange?: (open: boolean) => void; children: JSX.Element; } +export const DrawerMeta: ComponentMeta = { + name: "Drawer", + description: "Panel that slides in from the edge of the screen, used for navigation or secondary content", + 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/drawer/index.ts b/packages/core/src/components/drawer/index.ts index 08dc390..5f8c49b 100644 --- a/packages/core/src/components/drawer/index.ts +++ b/packages/core/src/components/drawer/index.ts @@ -7,25 +7,7 @@ import { DrawerPortal } from "./drawer-portal"; import { DrawerRoot } from "./drawer-root"; import { DrawerTitle } from "./drawer-title"; import { DrawerTrigger } from "./drawer-trigger"; - -/** Compound Drawer component with sub-components attached as properties. */ -export const Drawer = Object.assign(DrawerRoot, { - Content: DrawerContent, - Title: DrawerTitle, - Description: DrawerDescription, - Trigger: DrawerTrigger, - Close: DrawerClose, - Portal: DrawerPortal, - Overlay: DrawerOverlay, - useContext: useDrawerContext, -}); - -export type { DrawerRootProps } from "./drawer-root"; -export type { DrawerContentProps } from "./drawer-content"; -export type { DrawerTitleProps } from "./drawer-title"; -export type { DrawerDescriptionProps } from "./drawer-description"; -export type { DrawerTriggerProps } from "./drawer-trigger"; -export type { DrawerCloseProps } from "./drawer-close"; -export type { DrawerPortalProps } from "./drawer-portal"; -export type { DrawerOverlayProps } from "./drawer-overlay"; +export const Drawer = Object.assign(DrawerRoot, { Content: DrawerContent, Title: DrawerTitle, Description: DrawerDescription, Trigger: DrawerTrigger, Close: DrawerClose, Portal: DrawerPortal, Overlay: DrawerOverlay, useContext: useDrawerContext }); +export { DrawerRootPropsSchema, DrawerMeta } from "./drawer.props"; +export type { DrawerRootProps } from "./drawer.props"; export type { DrawerContextValue, DrawerSide } from "./drawer-context"; diff --git a/packages/core/src/components/hover-card/hover-card-root.tsx b/packages/core/src/components/hover-card/hover-card-root.tsx index 345c75f..8b4a736 100644 --- a/packages/core/src/components/hover-card/hover-card-root.tsx +++ b/packages/core/src/components/hover-card/hover-card-root.tsx @@ -13,19 +13,8 @@ import { import type { InternalHoverCardContextValue } from "./hover-card-context"; import { HoverCardTrigger } from "./hover-card-trigger"; -export interface HoverCardRootProps { - /** Controlled open state. */ - open?: boolean | undefined; - /** Initial open state (uncontrolled). */ - defaultOpen?: boolean | undefined; - /** Called when open state changes. */ - onOpenChange?: ((open: boolean) => void) | undefined; - /** Delay in ms before opening. @default 700 */ - openDelay?: number | undefined; - /** Delay in ms before closing. @default 300 */ - closeDelay?: number | undefined; - children: JSX.Element; -} +import type { HoverCardRootProps } from "./hover-card.props"; +export type { HoverCardRootProps } from "./hover-card.props"; /** * Root component for HoverCard. Manages open state, floating diff --git a/packages/core/src/components/hover-card/hover-card.props.ts b/packages/core/src/components/hover-card/hover-card.props.ts new file mode 100644 index 0000000..5c723ff --- /dev/null +++ b/packages/core/src/components/hover-card/hover-card.props.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const HoverCardRootPropsSchema = z.object({ + open: z.boolean().optional(), + defaultOpen: z.boolean().optional(), + openDelay: z.number().optional(), + closeDelay: z.number().optional(), +}); +export interface HoverCardRootProps extends z.infer { onOpenChange?: (open: boolean) => void; children: JSX.Element; } +export const HoverCardMeta: ComponentMeta = { + name: "HoverCard", + description: "Card that appears on hover to preview linked content without navigating", + parts: ["Root", "Trigger", "Portal", "Content", "Arrow"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; diff --git a/packages/core/src/components/hover-card/index.ts b/packages/core/src/components/hover-card/index.ts index 37158b3..9bc5abc 100644 --- a/packages/core/src/components/hover-card/index.ts +++ b/packages/core/src/components/hover-card/index.ts @@ -7,3 +7,4 @@ export type { HoverCardRootProps } from "./hover-card-root"; export type { HoverCardTriggerProps } from "./hover-card-trigger"; export type { HoverCardContentProps } from "./hover-card-content"; export type { HoverCardContextValue } from "./hover-card-context"; +export { HoverCardRootPropsSchema, HoverCardMeta } from "./hover-card.props"; diff --git a/packages/core/src/components/popover/index.ts b/packages/core/src/components/popover/index.ts index 9d79d6a..9d5b8c6 100644 --- a/packages/core/src/components/popover/index.ts +++ b/packages/core/src/components/popover/index.ts @@ -10,3 +10,5 @@ export type { PopoverTriggerProps } from "./popover-trigger"; export type { PopoverCloseProps } from "./popover-close"; export type { PopoverPortalProps } from "./popover-portal"; export const Popover = Object.assign(PopoverRoot, { Content: PopoverContent, Trigger: PopoverTrigger, Close: PopoverClose, Portal: PopoverPortal, useContext: usePopoverContext }); +export { PopoverRootPropsSchema, PopoverMeta } from "./popover.props"; +export type { PopoverRootProps } from "./popover.props"; diff --git a/packages/core/src/components/popover/popover-root.tsx b/packages/core/src/components/popover/popover-root.tsx index 0aff840..fccd3f1 100644 --- a/packages/core/src/components/popover/popover-root.tsx +++ b/packages/core/src/components/popover/popover-root.tsx @@ -14,25 +14,8 @@ import { type InternalPopoverContextValue, } from "./popover-context"; -export interface PopoverRootProps { - /** Controlled open state. */ - open?: boolean | undefined; - /** Default open state (uncontrolled). */ - defaultOpen?: boolean | undefined; - /** Called when open state should change. */ - onOpenChange?: ((open: boolean) => void) | undefined; - /** - * Whether the popover blocks outside interaction and traps focus. - * @default false - */ - modal?: boolean | undefined; - /** - * Floating UI placement. - * @default "bottom" - */ - placement?: Placement | undefined; - children: JSX.Element; -} +import type { PopoverRootProps } from "./popover.props"; +export type { PopoverRootProps } from "./popover.props"; /** * Root component for Popover. Manages open state, floating positioning, diff --git a/packages/core/src/components/popover/popover.props.ts b/packages/core/src/components/popover/popover.props.ts new file mode 100644 index 0000000..c509ffb --- /dev/null +++ b/packages/core/src/components/popover/popover.props.ts @@ -0,0 +1,15 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const PopoverRootPropsSchema = z.object({ + open: z.boolean().optional(), + defaultOpen: z.boolean().optional(), + modal: z.boolean().optional(), +}); +export interface PopoverRootProps extends z.infer { onOpenChange?: (open: boolean) => void; children: JSX.Element; } +export const PopoverMeta: ComponentMeta = { + name: "Popover", + description: "Floating content panel anchored to a trigger element, for interactive content", + parts: ["Root", "Trigger", "Portal", "Content", "Arrow", "Close", "Title", "Description"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; diff --git a/packages/core/src/components/toast/index.ts b/packages/core/src/components/toast/index.ts index 614b00e..c698f60 100644 --- a/packages/core/src/components/toast/index.ts +++ b/packages/core/src/components/toast/index.ts @@ -10,3 +10,4 @@ export const Toast = { export type { ToastRegionProps } from "./toast-region"; export type { ToastData, ToastType } from "./toast-store"; +export { ToastRegionPropsSchema, ToastMeta } from "./toast.props"; diff --git a/packages/core/src/components/toast/toast-region.tsx b/packages/core/src/components/toast/toast-region.tsx index 80572cb..ce942bb 100644 --- a/packages/core/src/components/toast/toast-region.tsx +++ b/packages/core/src/components/toast/toast-region.tsx @@ -2,13 +2,8 @@ import type { JSX } from "solid-js"; import { For, splitProps } from "solid-js"; import { useToastStore } from "./toast-store"; -export interface ToastRegionProps extends JSX.HTMLAttributes { - /** Maximum visible toasts. @default 5 */ - limit?: number | undefined; - /** ARIA label for the region. @default "Notifications" */ - label?: string | undefined; - children?: JSX.Element | undefined; -} +import type { ToastRegionProps } from "./toast.props"; +export type { ToastRegionProps } from "./toast.props"; /** Region where toasts are rendered. Place once in your app root. */ export function ToastRegion(props: ToastRegionProps): JSX.Element { diff --git a/packages/core/src/components/toast/toast.props.ts b/packages/core/src/components/toast/toast.props.ts new file mode 100644 index 0000000..1e4843d --- /dev/null +++ b/packages/core/src/components/toast/toast.props.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const ToastRegionPropsSchema = z.object({ + placement: z.enum(["top-start", "top-center", "top-end", "bottom-start", "bottom-center", "bottom-end"]).optional(), + duration: z.number().optional(), + limit: z.number().optional(), + swipeDirection: z.enum(["left", "right", "up", "down"]).optional(), +}); +export interface ToastRegionProps extends z.infer, JSX.HTMLAttributes { label?: string; children?: JSX.Element; } +export const ToastMeta: ComponentMeta = { + name: "Toast", + description: "Temporary notification that auto-dismisses, with imperative toast.add() API for programmatic creation", + parts: ["Region", "List", "Root", "Title", "Description", "Close", "Action"] as const, + requiredParts: ["Region", "List"] as const, +} as const; diff --git a/packages/core/src/components/tooltip/index.ts b/packages/core/src/components/tooltip/index.ts index 9b7b9ab..8bd06a4 100644 --- a/packages/core/src/components/tooltip/index.ts +++ b/packages/core/src/components/tooltip/index.ts @@ -2,3 +2,5 @@ export { Tooltip, TooltipRoot } from "./tooltip-root"; export { TooltipTrigger } from "./tooltip-trigger"; export { TooltipContent } from "./tooltip-content"; export { useTooltipContext } from "./tooltip-context"; +export { TooltipRootPropsSchema, TooltipMeta } from "./tooltip.props"; +export type { TooltipRootProps } from "./tooltip.props"; diff --git a/packages/core/src/components/tooltip/tooltip-root.tsx b/packages/core/src/components/tooltip/tooltip-root.tsx index cc2556b..55f393e 100644 --- a/packages/core/src/components/tooltip/tooltip-root.tsx +++ b/packages/core/src/components/tooltip/tooltip-root.tsx @@ -12,20 +12,8 @@ import { } from "./tooltip-context"; import type { InternalTooltipContextValue } from "./tooltip-context"; import { TooltipTrigger } from "./tooltip-trigger"; - -export interface TooltipRootProps { - /** Controlled open state. */ - open?: boolean | undefined; - /** Initial open state (uncontrolled). */ - defaultOpen?: boolean | undefined; - /** Called when open state changes. */ - onOpenChange?: ((open: boolean) => void) | undefined; - /** Delay in ms before the tooltip opens on hover. @default 700 */ - openDelay?: number | undefined; - /** Delay in ms before the tooltip closes after leaving. @default 300 */ - closeDelay?: number | undefined; - children: JSX.Element; -} +import type { TooltipRootProps } from "./tooltip.props"; +export type { TooltipRootProps } from "./tooltip.props"; /** * Root component for Tooltip. Manages open state, delay timers, diff --git a/packages/core/src/components/tooltip/tooltip.props.ts b/packages/core/src/components/tooltip/tooltip.props.ts new file mode 100644 index 0000000..bfd63b0 --- /dev/null +++ b/packages/core/src/components/tooltip/tooltip.props.ts @@ -0,0 +1,17 @@ +import { z } from "zod/v4"; +import type { JSX } from "solid-js"; +import type { ComponentMeta } from "../../meta"; +export const TooltipRootPropsSchema = z.object({ + open: z.boolean().optional(), + defaultOpen: z.boolean().optional(), + disabled: z.boolean().optional(), + openDelay: z.number().optional(), + closeDelay: z.number().optional(), +}); +export interface TooltipRootProps extends z.infer { onOpenChange?: (open: boolean) => void; children: JSX.Element; } +export const TooltipMeta: ComponentMeta = { + name: "Tooltip", + description: "Floating label that appears on hover/focus to describe an element", + parts: ["Root", "Trigger", "Portal", "Content", "Arrow"] as const, + requiredParts: ["Root", "Trigger", "Content"] as const, +} as const; diff --git a/packages/core/tests/schemas/collection-components.test.ts b/packages/core/tests/schemas/collection-components.test.ts index 2abd416..c70b3d2 100644 --- a/packages/core/tests/schemas/collection-components.test.ts +++ b/packages/core/tests/schemas/collection-components.test.ts @@ -5,27 +5,42 @@ import { DropdownMenuRootPropsSchema, DropdownMenuMeta } from "../../src/compone import { ListboxRootPropsSchema, ListboxMeta } from "../../src/components/listbox/listbox.props"; import { SeparatorPropsSchema, SeparatorMeta } from "../../src/components/separator/separator.props"; import { PaginationRootPropsSchema, PaginationMeta } from "../../src/components/pagination/pagination.props"; + describe("Collection component schemas", () => { it("Select validates", () => { - expect(SelectRootPropsSchema.safeParse({ value: "opt1", placeholder: "Choose..." }).success).toBe(true); + const result = SelectRootPropsSchema.safeParse({ value: "opt1", placeholder: "Choose..." }); + expect(result.success).toBe(true); }); + it("Combobox validates", () => { - expect(ComboboxRootPropsSchema.safeParse({ inputValue: "search", allowCustomValue: true }).success).toBe(true); + const result = ComboboxRootPropsSchema.safeParse({ inputValue: "search", allowCustomValue: true }); + expect(result.success).toBe(true); }); + it("DropdownMenu validates", () => { - expect(DropdownMenuRootPropsSchema.safeParse({ open: true }).success).toBe(true); + const result = DropdownMenuRootPropsSchema.safeParse({ open: true }); + expect(result.success).toBe(true); }); - it("Listbox validates", () => { - expect(ListboxRootPropsSchema.safeParse({ value: "a" }).success).toBe(true); - expect(ListboxRootPropsSchema.safeParse({ value: ["a", "b"], multiple: true }).success).toBe(true); + + it("Listbox validates single and multiple", () => { + const single = ListboxRootPropsSchema.safeParse({ value: "a" }); + const multi = ListboxRootPropsSchema.safeParse({ value: ["a", "b"], multiple: true }); + expect(single.success).toBe(true); + expect(multi.success).toBe(true); }); - it("Separator validates", () => { - expect(SeparatorPropsSchema.safeParse({ orientation: "vertical" }).success).toBe(true); - expect(SeparatorPropsSchema.safeParse({ orientation: "angled" }).success).toBe(false); + + it("Separator validates orientation enum", () => { + const valid = SeparatorPropsSchema.safeParse({ orientation: "vertical" }); + const invalid = SeparatorPropsSchema.safeParse({ orientation: "angled" }); + expect(valid.success).toBe(true); + expect(invalid.success).toBe(false); }); + it("Pagination validates", () => { - expect(PaginationRootPropsSchema.safeParse({ page: 3, count: 10, siblingCount: 2 }).success).toBe(true); + const result = PaginationRootPropsSchema.safeParse({ page: 3, count: 10, siblingCount: 2 }); + expect(result.success).toBe(true); }); + it("all Meta objects valid", () => { const metas = [SelectMeta, ComboboxMeta, DropdownMenuMeta, ListboxMeta, SeparatorMeta, PaginationMeta]; for (const meta of metas) { diff --git a/packages/core/tests/schemas/overlay-components.test.ts b/packages/core/tests/schemas/overlay-components.test.ts new file mode 100644 index 0000000..3e3a6c2 --- /dev/null +++ b/packages/core/tests/schemas/overlay-components.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { TooltipRootPropsSchema, TooltipMeta } from "../../src/components/tooltip/tooltip.props"; +import { PopoverRootPropsSchema, PopoverMeta } from "../../src/components/popover/popover.props"; +import { HoverCardRootPropsSchema, HoverCardMeta } from "../../src/components/hover-card/hover-card.props"; +import { DrawerRootPropsSchema, DrawerMeta } from "../../src/components/drawer/drawer.props"; +import { ToastRegionPropsSchema, ToastMeta } from "../../src/components/toast/toast.props"; +const ok = (schema: { safeParse: (v: unknown) => { success: boolean } }, v: unknown) => schema.safeParse(v).success; +describe("Overlay component schemas", () => { + it("Tooltip validates delays", () => { expect(ok(TooltipRootPropsSchema, { openDelay: 500, closeDelay: 200 })).toBe(true); }); + it("Popover validates modal", () => { expect(ok(PopoverRootPropsSchema, { open: true, modal: true })).toBe(true); }); + it("HoverCard validates delays", () => { expect(ok(HoverCardRootPropsSchema, { openDelay: 300 })).toBe(true); }); + it("Drawer validates side enum", () => { + expect(ok(DrawerRootPropsSchema, { side: "left" })).toBe(true); + expect(ok(DrawerRootPropsSchema, { side: "center" })).toBe(false); + }); + it("Toast validates placement", () => { + expect(ok(ToastRegionPropsSchema, { placement: "top-center", duration: 3000, limit: 5 })).toBe(true); + expect(ok(ToastRegionPropsSchema, { placement: "middle" })).toBe(false); + }); + it("all Meta objects valid", () => { + for (const meta of [TooltipMeta, PopoverMeta, HoverCardMeta, DrawerMeta, ToastMeta]) { + expect(meta.name).toBeTruthy(); + expect(meta.description).toBeTruthy(); + expect(meta.parts.length).toBeGreaterThan(0); + expect(meta.requiredParts.length).toBeGreaterThan(0); + } + }); +});