From 14b8cacad15003ca76dd1c872bd17a2597959504 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Sat, 25 Apr 2026 03:20:52 +0200 Subject: [PATCH 1/7] feat(ui): upgrade form with validation context --- packages/ui/package.json | 10 +- packages/ui/src/components/form/form.mdx | 52 ++- .../ui/src/components/form/form.stories.tsx | 130 +++++-- packages/ui/src/components/form/form.test.tsx | 275 +++++++------- packages/ui/src/components/form/form.tsx | 349 ++++++++++-------- .../components/form/form.visual.fixture.tsx | 59 +++ .../ui/src/components/form/form.visual.tsx | 27 +- packages/ui/src/components/form/index.ts | 2 + packages/ui/src/components/index.ts | 2 + pnpm-lock.yaml | 129 +++++-- 10 files changed, 629 insertions(+), 406 deletions(-) create mode 100644 packages/ui/src/components/form/form.visual.fixture.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index 3eaedd2..33adf72 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -90,11 +90,14 @@ "check:stories": "tsx scripts/check-story-coverage.ts" }, "peerDependencies": { + "@hookform/resolvers": ">=5.0.0", "next": ">=14.0.0", "next-themes": ">=0.4.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", - "tailwindcss": ">=3.0.0" + "react-hook-form": ">=7.55.0", + "tailwindcss": ">=3.0.0", + "zod": ">=4.0.0" }, "peerDependenciesMeta": { "next": { @@ -171,6 +174,7 @@ "jsdom": "^26.1.0", "playwright": "^1.57.0", "postcss": "^8.5.6", + "react-hook-form": "^7.73.1", "remark-gfm": "^4.0.1", "storybook": "^10.2.17", "tailwindcss": "^3.4.17", @@ -178,6 +182,8 @@ "tsup": "^8.5.0", "tsx": "^4.21.0", "typescript": "^5.9.3", - "vitest": "^4.0.16" + "vitest": "^4.0.16", + "zod": "^4.3.6", + "@hookform/resolvers": "^5.2.2" } } diff --git a/packages/ui/src/components/form/form.mdx b/packages/ui/src/components/form/form.mdx index d4203e1..f3250a6 100644 --- a/packages/ui/src/components/form/form.mdx +++ b/packages/ui/src/components/form/form.mdx @@ -5,7 +5,7 @@ import * as Stories from './form.stories' # Form -A lightweight validation wrapper for composing labels, descriptions, controls, and messages with consistent ARIA wiring. +A react-hook-form powered validation wrapper that keeps labels, descriptions, controls, and field errors in sync with accessible ARIA wiring. @@ -15,6 +15,12 @@ A lightweight validation wrapper for composing labels, descriptions, controls, a pnpm dlx shadcn@latest add https://ui.vllnt.com/r/form.json ``` +## Dependencies + +```bash +pnpm add react-hook-form @hookform/resolvers zod +``` + ## Import ```tsx @@ -22,6 +28,7 @@ import { Form, FormControl, FormDescription, + FormField, FormItem, FormLabel, FormMessage, @@ -31,25 +38,44 @@ import { ## Usage ```tsx -
- - Email - - - - Use your work email address. - Please enter a valid email. - +const schema = z.object({ + email: z.string().email(), +}) + + { + await save(values) + form.reset(values) + }} + schema={schema} +> + {(form) => ( + ( + + Email + + + + Use your work email address. + + + )} + /> + )} ``` -## Validation state +## Server-side errors -Set `invalid` on `Form` to add `aria-invalid`, append the message id to `aria-describedby`, and expose the message as an alert. +Call `form.setError()` in `onSubmit` when the API rejects a field. `FormMessage` automatically renders the latest field error. - + ## API Reference diff --git a/packages/ui/src/components/form/form.stories.tsx b/packages/ui/src/components/form/form.stories.tsx index ee6e306..3c0d703 100644 --- a/packages/ui/src/components/form/form.stories.tsx +++ b/packages/ui/src/components/form/form.stories.tsx @@ -1,53 +1,117 @@ +import * as React from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; +import { z } from "zod"; +import { Button } from "../button"; import { Input } from "../input"; import { Form, FormControl, FormDescription, + FormField, FormItem, FormLabel, FormMessage, } from "./form"; +const profileSchema = z.object({ + email: z.string().email("Enter a valid email address."), + name: z.string().min(2, "Enter at least 2 characters."), +}); + +type ProfileValues = z.infer; + +type ProfileFormExampleProps = { + serverError?: boolean; +}; + +function ProfileFormExample({ + serverError = false, +}: ProfileFormExampleProps) { + const [submitted, setSubmitted] = React.useState(null); + + return ( + + className="w-full max-w-md rounded-lg border border-border bg-card p-6" + defaultValues={{ email: "", name: "" }} + onSubmit={async (values, form) => { + setSubmitted(null); + await Promise.resolve(); + + if (serverError) { + form.setError("email", { + message: "This email is already in use.", + type: "server", + }); + return; + } + + setSubmitted(values); + }} + schema={profileSchema} + > + {(form) => ( + <> + ( + + Email + + + + + Use your work email address for notifications. + + + + )} + /> + ( + + Name + + + + + We will use this name in collaborator mentions. + + + + )} + /> +
+ + {submitted ? ( +

+ Submitted for {submitted.name}. +

+ ) : null} +
+ + )} + + ); +} + const meta = { - component: Form, + component: ProfileFormExample, title: "Core/Form", -} satisfies Meta; +} satisfies Meta; export default meta; type Story = StoryObj; -export const Default: Story = { - render: () => ( -
-
- - Email - - - - Use your work email address. - We will never share your email. - -
-
- ), -}; +export const Default: Story = {}; -export const Invalid: Story = { - render: () => ( -
-
- - Email - - - - This email will be used for account recovery. - Please enter a valid email address. - -
-
- ), +export const ServerError: Story = { + args: { + serverError: true, + }, }; diff --git a/packages/ui/src/components/form/form.test.tsx b/packages/ui/src/components/form/form.test.tsx index 4b887c1..bc0e38f 100644 --- a/packages/ui/src/components/form/form.test.tsx +++ b/packages/ui/src/components/form/form.test.tsx @@ -1,200 +1,175 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { Button } from "../button"; import { Input } from "../input"; import { Form, FormControl, FormDescription, + FormField, FormItem, FormLabel, FormMessage, } from "./form"; +const emailSchema = z.object({ + email: z.email("Enter a valid email address."), +}); + +type EmailValues = z.infer; + describe("Form", () => { - it("wires the label to the generated control id", () => { + it("wires labels, descriptions, and validation errors through form context", async () => { + const handleSubmit = vi.fn(); + render( -
- - Email - - - - + + defaultValues={{ email: "" }} + onSubmit={handleSubmit} + schema={emailSchema} + > + {(form) => ( + <> + ( + + Email + + + + + Use your work email address. + + + + )} + /> + + + )} , ); - const input = screen.getByRole("textbox"); + const input = screen.getByRole("textbox", { name: "Email" }); const label = screen.getByText("Email"); + const description = screen.getByText("Use your work email address."); expect(label).toHaveAttribute("for", input.id); - }); + expect(input).toHaveAttribute("aria-describedby", description.id); - it("applies invalid aria wiring to the control and message", () => { - render( -
- - Email - - - - Use your work email address. - Please enter a valid email. - -
, - ); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); - const input = screen.getByRole("textbox"); - const description = screen.getByText("Use your work email address."); - const message = screen.getByRole("alert"); + const message = await screen.findByRole("alert"); + expect(message).toHaveTextContent("Enter a valid email address."); expect(input).toHaveAttribute("aria-invalid", "true"); expect(input).toHaveAttribute( "aria-describedby", `${description.id} ${message.id}`, ); - expect(message).toHaveTextContent("Please enter a valid email."); - }); - - it("propagates disabled and required state to native controls", () => { - render( -
- - Email - - - - -
, - ); - - const input = screen.getByRole("textbox"); - - expect(input).toBeDisabled(); - expect(input).toBeRequired(); - expect(input).toHaveAttribute("aria-disabled", "true"); - expect(input).toHaveAttribute("aria-required", "true"); + expect(handleSubmit).not.toHaveBeenCalled(); }); - it("keeps helper text in aria-describedby without linking a valid message", () => { + it("supports server-side field errors via setError", async () => { render( -
- - Email - - - - - We only use this for account updates. - - Looks good. - + + defaultValues={{ email: "person@example.com" }} + onSubmit={async (_values, form) => { + form.setError("email", { + message: "This email is already in use.", + type: "server", + }); + }} + schema={emailSchema} + > + {(form) => ( + <> + ( + + Email + + + + + We will send invitations here. + + + + )} + /> + + + )} , ); - const input = screen.getByRole("textbox"); - const description = screen.getByText( - "We only use this for account updates.", - ); - const message = screen.getByText("Looks good."); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); - expect(input).toHaveAttribute( - "aria-describedby", - `external-help ${description.id}`, + expect(await screen.findByRole("alert")).toHaveTextContent( + "This email is already in use.", ); - expect(input).not.toHaveAttribute("aria-invalid", "true"); - expect(message).not.toHaveAttribute("role", "alert"); }); - it("creates unique aria wiring for each form item", () => { - render( -
- - First name - - - - Given name - Required - - - Last name - - - - Family name - Required - -
, - ); - - const firstInput = screen.getByRole("textbox", { name: "First name" }); - const lastInput = screen.getByRole("textbox", { name: "Last name" }); - const firstDescription = screen.getByText("Given name"); - const lastDescription = screen.getByText("Family name"); - const [firstMessage, secondMessage] = screen.getAllByText("Required"); - - expect(firstMessage).toBeDefined(); - expect(secondMessage).toBeDefined(); - - if (!firstMessage || !secondMessage) { - throw new Error("Expected both required messages to be rendered."); - } - - expect(firstInput.id).not.toBe(lastInput.id); - expect(firstDescription.id).not.toBe(lastDescription.id); - expect(firstMessage.id).not.toBe(secondMessage.id); - expect(firstInput).toHaveAttribute("aria-describedby", firstDescription.id); - expect(lastInput).toHaveAttribute("aria-describedby", lastDescription.id); - }); + it("exposes submitting state to children and disables controls while pending", async () => { + let resolveSubmit: (() => void) | undefined; + const handlePendingSubmit = () => + new Promise((resolve) => { + resolveSubmit = resolve; + }); - it("keeps root-level custom ids unique across form items", () => { render( -
+ defaultValues={{ email: "person@example.com" }} + onSubmit={handlePendingSubmit} + schema={emailSchema} > - - Primary email - - - - Primary contact - Required - - - Backup email - - - - Secondary contact - Required - + {(form) => ( + <> + ( + + Email + + + + + + )} + /> + + + )} , ); - const primaryInput = screen.getByRole("textbox", { name: "Primary email" }); - const backupInput = screen.getByRole("textbox", { name: "Backup email" }); - const primaryDescription = screen.getByText("Primary contact"); - const backupDescription = screen.getByText("Secondary contact"); - const [primaryMessage, backupMessage] = screen.getAllByText("Required"); + fireEvent.click(screen.getByRole("button", { name: "Submit" })); - expect(primaryMessage).toBeDefined(); - expect(backupMessage).toBeDefined(); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Saving…" })).toBeDisabled(); + expect(screen.getByRole("textbox", { name: "Email" })).toBeDisabled(); + }); - if (!primaryMessage || !backupMessage) { - throw new Error("Expected both required messages to be rendered."); + if (resolveSubmit === undefined) { + throw new Error("Expected submit promise resolver to be captured."); } - expect(primaryInput.id).toMatch(/^field-/); - expect(backupInput.id).toMatch(/^field-/); - expect(primaryInput.id).not.toBe(backupInput.id); - expect(primaryDescription.id).toMatch(/^field-description-/); - expect(backupDescription.id).toMatch(/^field-description-/); - expect(primaryMessage.id).toMatch(/^field-message-/); - expect(backupMessage.id).toMatch(/^field-message-/); - expect(primaryMessage.id).not.toBe(backupMessage.id); + resolveSubmit(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled(); + expect(screen.getByRole("textbox", { name: "Email" })).toBeEnabled(); + }); }); }); diff --git a/packages/ui/src/components/form/form.tsx b/packages/ui/src/components/form/form.tsx index 8c852fe..0819c9b 100644 --- a/packages/ui/src/components/form/form.tsx +++ b/packages/ui/src/components/form/form.tsx @@ -2,30 +2,69 @@ import * as React from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + type ControllerProps, + type DefaultValues, + type FieldPath, + type FieldValues, + FormProvider, + type Resolver, + type SubmitErrorHandler, + useForm, + useFormContext, + type UseFormReturn, +} from "react-hook-form"; + +type FormInstance = UseFormReturn< + TFieldValues, + unknown, + TFieldValues +>; import { cn } from "../../lib/utils"; import { Label } from "../label"; -type FormRootContextValue = { - controlId?: string; - descriptionId?: string; - disabled: boolean; - invalid: boolean; - messageId?: string; - required: boolean; +type FormRenderChildren = + | ((form: FormInstance) => React.ReactNode) + | React.ReactNode; + +type FormSubmitHandler = ( + values: TFieldValues, + form: FormInstance, +) => Promise | void; + +type FormErrorHandler = ( + errors: Parameters>[0], + form: FormInstance, +) => Promise | void; + +export type FormProps = Omit< + React.ComponentPropsWithoutRef<"form">, + "children" | "onSubmit" +> & { + children: FormRenderChildren; + defaultValues?: DefaultValues; + disabled?: boolean; + form?: FormInstance; + onError?: FormErrorHandler; + onSubmit: FormSubmitHandler; + resolver?: Resolver; + schema?: Parameters[0]; + values?: TFieldValues; +}; + +type FormFieldContextValue = { + name: string; }; type FormItemContextValue = { - controlId: string; - descriptionId: string; - disabled: boolean; - invalid: boolean; - messageId: string; - required: boolean; + id: string; }; -const FormRootContext = React.createContext( +const FormFieldContext = React.createContext( undefined, ); @@ -33,24 +72,24 @@ const FormItemContext = React.createContext( undefined, ); -function useFormRootContext(componentName: string) { - const context = React.useContext(FormRootContext); +function useFormFieldContext(componentName: string) { + const fieldContext = React.useContext(FormFieldContext); - if (context === undefined) { - throw new Error(`${componentName} must be used within Form.`); + if (fieldContext === undefined) { + throw new Error(`${componentName} must be used within FormField.`); } - return context; + return fieldContext; } function useFormItemContext(componentName: string) { - const context = React.useContext(FormItemContext); + const itemContext = React.useContext(FormItemContext); - if (context === undefined) { + if (itemContext === undefined) { throw new Error(`${componentName} must be used within FormItem.`); } - return context; + return itemContext; } function composeIds(...ids: (string | undefined)[]) { @@ -59,108 +98,103 @@ function composeIds(...ids: (string | undefined)[]) { return value.length > 0 ? value : undefined; } -function resolveItemId( - baseId: string | undefined, - generatedId: string, - suffix: string, -) { - if (baseId === undefined) { - return `${generatedId}-${suffix}`; - } +function Form({ + children, + className, + defaultValues, + disabled = false, + form: providedForm, + onError, + onSubmit, + resolver, + schema, + values, + ...props +}: FormProps) { + const internalForm = useForm({ + defaultValues, + resolver: + schema === undefined + ? resolver + : (zodResolver(schema) as Resolver), + values, + }); + const form: FormInstance = providedForm ?? internalForm; + const submitting = disabled || form.formState.isSubmitting; + const content = typeof children === "function" ? children(form) : children; - return `${baseId}-${generatedId}`; + return ( + +
{ + await onSubmit(values, form); + }, + async (errors) => { + if (onError !== undefined) { + await onError(errors, form); + } + }, + )} + {...props} + > + {content} +
+
+ ); } -export type FormProps = React.ComponentPropsWithoutRef<"div"> & { - controlId?: string; - descriptionId?: string; - disabled?: boolean; - invalid?: boolean; - messageId?: string; - required?: boolean; -}; +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ ...props }: ControllerProps) { + const fieldContextValue = React.useMemo( + () => ({ name: props.name }), + [props.name], + ); -const Form = React.forwardRef( - ( - { - className, - controlId, - descriptionId, - disabled = false, - invalid = false, - messageId, - required = false, - ...props - }, - ref, - ) => { - const value = React.useMemo( - () => ({ - controlId, - descriptionId, - disabled, - invalid, - messageId, - required, - }), - [controlId, descriptionId, disabled, invalid, messageId, required], - ); + return ( + + + + ); +} - return ( - -
- - ); - }, -); -Form.displayName = "Form"; +function useFormField() { + const fieldContext = useFormFieldContext("useFormField"); + const itemContext = useFormItemContext("useFormField"); + const { formState, getFieldState } = useFormContext(); + const fieldState = getFieldState(fieldContext.name, formState); + const formItemId = `${itemContext.id}-form-item`; + const formDescriptionId = `${itemContext.id}-form-item-description`; + const formMessageId = `${itemContext.id}-form-item-message`; + + return { + error: fieldState.error, + formDescriptionId, + formItemId, + formMessageId, + id: itemContext.id, + invalid: fieldState.invalid, + isDirty: fieldState.isDirty, + isTouched: fieldState.isTouched, + isValidating: fieldState.isValidating, + name: fieldContext.name, + }; +} const FormItem = React.forwardRef< HTMLDivElement, React.ComponentPropsWithoutRef<"div"> >(({ className, ...props }, ref) => { - const { - controlId: controlIdBase, - descriptionId: descriptionIdBase, - disabled, - invalid, - messageId: messageIdBase, - required, - } = useFormRootContext("FormItem"); - const generatedId = React.useId(); - - const value = React.useMemo( - () => ({ - controlId: resolveItemId(controlIdBase, generatedId, "control"), - descriptionId: resolveItemId( - descriptionIdBase, - generatedId, - "description", - ), - disabled, - invalid, - messageId: resolveItemId(messageIdBase, generatedId, "message"), - required, - }), - [ - controlIdBase, - descriptionIdBase, - disabled, - generatedId, - invalid, - messageIdBase, - required, - ], - ); + const id = React.useId(); + const itemContextValue = React.useMemo(() => ({ id }), [id]); return ( - +
); @@ -170,14 +204,14 @@ FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef ->(({ className, htmlFor, ...props }, ref) => { - const { controlId, invalid } = useFormItemContext("FormLabel"); +>(({ className, ...props }, ref) => { + const { formItemId, invalid } = useFormField(); return (