diff --git a/packages/ui/package.json b/packages/ui/package.json
index 3eaedd2..a7015ef 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -105,6 +105,7 @@
}
},
"dependencies": {
+ "@hookform/resolvers": "^5.2.2",
"@mdx-js/mdx": "^3.1.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
@@ -139,12 +140,14 @@
"input-otp": "^1.4.2",
"lucide-react": "^0.468.0",
"react-day-picker": "^9.13.0",
+ "react-hook-form": "^7.73.1",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.3.3",
"react-syntax-highlighter": "^16.1.1",
"sonner": "^1.7.4",
"tailwind-merge": "^2.5.5",
- "vaul": "^1.1.2"
+ "vaul": "^1.1.2",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
diff --git a/packages/ui/src/components/form/form.mdx b/packages/ui/src/components/form/form.mdx
index d4203e1..70e51e2 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
-
```
-## 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 `onValidSubmit` 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..64e8291 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 (
+
+ );
+}
+
const meta = {
- component: Form,
+ component: ProfileFormExample,
title: "Core/Form",
-} satisfies Meta;
+} satisfies Meta;
export default meta;
type Story = StoryObj;
-export const Default: Story = {
- render: () => (
-
-
-
- ),
-};
+export const Default: Story = {};
-export const Invalid: Story = {
- render: () => (
-
-
-
- ),
+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 210fa8c..bf11535 100644
--- a/packages/ui/src/components/form/form.test.tsx
+++ b/packages/ui/src/components/form/form.test.tsx
@@ -1,22 +1,36 @@
import * as React from "react";
-import { fireEvent, render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
+import { useForm } from "react-hook-form";
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;
+
+type NativeSubmitEvent = Parameters<
+ NonNullable["onSubmit"]>
+>[0];
+
describe("Form", () => {
- it("renders a native form element and forwards props, ref, and submit", () => {
+ it("renders a native form element and forwards props, ref, and submit", async () => {
const handleSubmit = vi.fn();
const ref = React.createRef();
const { container } = render(
@@ -24,10 +38,7 @@ describe("Form", () => {
className="custom-form"
data-testid="login-form"
name="login"
- onSubmit={(event) => {
- event.preventDefault();
- handleSubmit(event.currentTarget);
- }}
+ onSubmit={handleSubmit}
ref={ref}
>
@@ -53,12 +64,61 @@ describe("Form", () => {
}
fireEvent.submit(form);
- expect(handleSubmit).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(handleSubmit).toHaveBeenCalledTimes(1);
+ });
+ const submittedEvent = handleSubmit.mock.calls[0]?.[0] as
+ | NativeSubmitEvent
+ | undefined;
+ expect(submittedEvent).toBeTruthy();
+ expect(submittedEvent?.target).toBe(form);
+ });
+
+ it("skips validated submission when the native submit handler prevents default", async () => {
+ const nativeSubmit = vi.fn((event: NativeSubmitEvent) => {
+ event.preventDefault();
+ });
+ const validSubmit = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Submit" }));
+
+ await waitFor(() => {
+ expect(nativeSubmit).toHaveBeenCalledTimes(1);
+ });
+ expect(validSubmit).not.toHaveBeenCalled();
});
it("omits aria-describedby when no description or message is rendered", () => {
render(
-