Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
52 changes: 39 additions & 13 deletions packages/ui/src/components/form/form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Primary />

Expand All @@ -15,13 +15,20 @@ 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
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Expand All @@ -31,25 +38,44 @@ import {
## Usage

```tsx
<Form invalid>
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" />
</FormControl>
<FormDescription>Use your work email address.</FormDescription>
<FormMessage>Please enter a valid email.</FormMessage>
</FormItem>
const schema = z.object({
email: z.string().email(),
})

<Form
defaultValues={{ email: '' }}
onValidSubmit={async (values, form) => {
await save(values)
form.reset(values)
}}
schema={schema}
>
{(form) => (
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" {...field} />
</FormControl>
<FormDescription>Use your work email address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</Form>
```

<Canvas of={Stories.Default} sourceState="shown" />

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

<Canvas of={Stories.Invalid} sourceState="shown" />
<Canvas of={Stories.ServerError} sourceState="shown" />

## API Reference

Expand Down
130 changes: 97 additions & 33 deletions packages/ui/src/components/form/form.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof profileSchema>;

type ProfileFormExampleProps = {
serverError?: boolean;
};

function ProfileFormExample({
serverError = false,
}: ProfileFormExampleProps) {
const [submitted, setSubmitted] = React.useState<ProfileValues | null>(null);

return (
<Form<ProfileValues>
className="w-full max-w-md rounded-lg border border-border bg-card p-6"
defaultValues={{ email: "", name: "" }}
onValidSubmit={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) => (
<>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" type="email" {...field} />
</FormControl>
<FormDescription>
Use your work email address for notifications.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Ada Lovelace" {...field} />
</FormControl>
<FormDescription>
We will use this name in collaborator mentions.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex items-center gap-3">
<Button disabled={form.formState.isSubmitting} type="submit">
{form.formState.isSubmitting ? "Saving…" : "Submit"}
</Button>
{submitted ? (
<p className="text-sm text-muted-foreground">
Submitted for {submitted.name}.
</p>
) : null}
</div>
</>
)}
</Form>
);
}

const meta = {
component: Form,
component: ProfileFormExample,
title: "Core/Form",
} satisfies Meta<typeof Form>;
} satisfies Meta<typeof ProfileFormExample>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
render: () => (
<div className="w-full max-w-sm">
<Form>
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="name@company.com" type="email" />
</FormControl>
<FormDescription>Use your work email address.</FormDescription>
<FormMessage>We will never share your email.</FormMessage>
</FormItem>
</Form>
</div>
),
};
export const Default: Story = {};

export const Invalid: Story = {
render: () => (
<div className="w-full max-w-sm">
<Form invalid required>
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="name@company.com" type="email" />
</FormControl>
<FormDescription>This email will be used for account recovery.</FormDescription>
<FormMessage>Please enter a valid email address.</FormMessage>
</FormItem>
</Form>
</div>
),
export const ServerError: Story = {
args: {
serverError: true,
},
};
Loading