A record being created or edited as the page's whole purpose — sections, field anatomy, and a footer that gates the save.
Page-class laws·docs/form.md·102 lines
Kit src/components/form-page/Living example · Form
How every form page — a record being created or edited as the page's whole purpose — is built. The kit is src/components/form-page/ (+ @/components/ui/field for field anatomy); the living example is /labs/layout/form. Same contract as data-view.md: the kit enforces what it can with defaults, this file records the rest.
Scope: whole-page create/edit flows. A filter control in a toolbar, a search box, an inline-editable table cell are NOT this class — they belong to their own page's law. A settings page is a sibling class with its own future law (grouped navigation, per-section apply semantics); don't stretch this one over it.
01
A form is an interview, and an interview happens in one column. Reading a form is a top-to-bottom activity: every column added is a chance to submit half an interview. So the form is ONE feature in ONE standard Card on a NARROW measure (max-w-2xl), sections stacked with dashed seams, the action footer last — and the only sanctioned multi-column is FormRow, two SHORT, RELATED fields sharing a line (first / last name), which still stacks on phones.
02
const schema = z.object({ … }); // schema first
type Values = z.infer<typeof schema>; // the type derives
const form = useForm<Values>({ resolver: zodResolver(schema), defaultValues });
useUnsavedGuard(form.formState.isDirty);
<FormShell onSubmit={form.handleSubmit(onSubmit)}>
<FormSection title="Identity" description="…">
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="name">Name</FieldLabel>
<Input id="name" {...form.register("name")} aria-invalid={!!errors.name} />
<FieldDescription>…</FieldDescription>
<FieldError errors={[errors.name]} />
</Field>
<FormRow>…two related short fields…</FormRow>
</FormSection>
<FormSection title="…">…</FormSection>
<FormFooter dirty={form.formState.isDirty}>
<Button variant="outline" type="button" disabled={!isDirty} onClick={() => form.reset()}>Reset</Button>
<Button type="submit" disabled={!isDirty || isSubmitting}>Save</Button>
</FormFooter>
</FormShell>| Region | Component | Rule |
|---|---|---|
| Shell | FormShell | <form noValidate> wrapping the one Card at max-w-2xl. Validation is zod's job; the browser's bubbles never appear. |
| Section | FormSection | Heading = what this group of fields decides, optional one-line description, then fields at a fixed gap-5 rhythm. Dashed bottom seam. |
| Field | ui/field primitives | Field → FieldLabel → control → FieldDescription → FieldError, in that order. Mark invalid twice: data-invalid on the Field (paints the group) and aria-invalid on the control (tells the reader). |
| Paired row | FormRow | Two short, related fields; stacks on phones. Never two unrelated questions. |
| Footer | FormFooter | The card's last region: dirty readout left; actions right with the submit right-most. Submit and Reset both earn their enabled state from isDirty. |
03
z.infer), and there is no hand-written validation beside it (repo-wide rule, enforced here).reset(values) so the clean state is re-baselined, not faked. useUnsavedGuard covers tab-close — and because the App Router can't intercept in-app links, the footer's readout is the real guard.FieldDescription — the one-line cost of not making the user guess.max-w-2xl the footer is never far; pinning it would cover fields on small screens for no gain. If a form grows long enough to argue for one, it has outgrown one page — split the sections.04
useForm + zodResolver; wire useUnsavedGuard(isDirty).FormShell → FormSections → ui/field anatomy per field; register inputs, Controller for Select / Radio / Switch.isDirty; submit toasts and reset(values) on success.05
max-w-2xl is the law).