Migrate overlay components to Zod props

This commit is contained in:
Mats Bosson 2026-03-29 20:39:28 +07:00
parent c4547473a4
commit 6dd06986cc
17 changed files with 152 additions and 96 deletions

View File

@ -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.

View File

@ -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<typeof DrawerRootPropsSchema> { 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;

View File

@ -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";

View File

@ -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

View File

@ -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<typeof HoverCardRootPropsSchema> { 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;

View File

@ -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";

View File

@ -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";

View File

@ -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,

View File

@ -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<typeof PopoverRootPropsSchema> { 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;

View File

@ -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";

View File

@ -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<HTMLDivElement> {
/** 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 {

View File

@ -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<typeof ToastRegionPropsSchema>, JSX.HTMLAttributes<HTMLDivElement> { 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;

View File

@ -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";

View File

@ -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,

View File

@ -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<typeof TooltipRootPropsSchema> { 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;

View File

@ -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) {

View File

@ -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);
}
});
});