UX Standards — 27: Form Standards
Governs: All create and edit forms — field layout, validation, submission, error handling, and UX patterns.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md first. See 11-MODALS-AND-DIALOGS.md for form dialogs.
Form Dialog vs Full Page
| Condition | Use |
|---|---|
| ≤ 5 fields | Dialog (Dialog + DialogContent max-w-md) |
| 6–12 fields | Dialog (DialogContent max-w-2xl) or full page |
| 12+ fields | Full page |
| Fields require file uploads | Full page |
| Multi-section grouping needed | Full page |
| Wizard / multi-step | Full page |
Full-Page Form Layout
export default function CreateBookingPage() {
return (
<div className="max-w-3xl space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="header-title">{t('booking.create')}</h1>
<p className="caption-text text-muted-foreground">{t('booking.createDescription')}</p>
</div>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<SectionCard title={t('booking.coreDetails')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<BookingNameField />
<CustomerField />
<StartDateField />
<EndDateField />
</div>
</SectionCard>
<SectionCard title={t('booking.additionalInfo')}>
<div className="grid grid-cols-1 gap-4">
<NotesField />
</div>
</SectionCard>
{/* Form actions — always bottom right */}
<div className="flex justify-end gap-3">
<Button type="button" variant="ghost" onClick={() => router.back()}>
{t('common.cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{isSubmitting ? t('common.saving') : t('common.save')}
</Button>
</div>
</form>
</Form>
</div>
);
}
Rules:
- Max width:
max-w-3xlfor full-page forms - Fields: single column mobile,
md:grid-cols-2on desktop - Actions:
flex justify-end gap-3, Cancel left, Save right - Cancel:
variant="ghost", nevervariant="outline"
Field Layout Standard
Every field uses the shared FormField wrapper from @anshin/shared/components/forms:
// The pattern for every form field
<FormField
control={form.control}
name="customer_name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('booking.customer')} <span className="text-destructive">*</span></FormLabel>
<FormControl>
<Input placeholder={t('booking.customerPlaceholder')} {...field} />
</FormControl>
<FormDescription>{t('booking.customerHelp')}</FormDescription>
<FormMessage /> {/* Renders Zod error in text-destructive text-xs */}
</FormItem>
)}
/>
| Element | Class / Rule |
|---|---|
| Label | FormLabel — text-sm font-medium |
| Required asterisk | <span className="text-destructive">*</span> after label text |
| Input | Full width by default |
| Help text | FormDescription — text-xs text-muted-foreground |
| Error | FormMessage — text-xs text-destructive — appears below field |
| Error placement | ALWAYS below the field, never toast for field errors |
React Hook Form + Zod Setup
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const bookingSchema = z.object({
customer_name: z.string().min(1, t('validation.required')),
start_date: z.date({ required_error: t('validation.required') }),
end_date: z.date().optional(),
notes: z.string().max(500, t('validation.maxLength', { max: 500 })).optional(),
}).refine(
(data) => !data.end_date || data.end_date >= data.start_date,
{ message: t('validation.endDateAfterStart'), path: ['end_date'] }
);
type BookingFormValues = z.infer<typeof bookingSchema>;
const form = useForm<BookingFormValues>({
resolver: zodResolver(bookingSchema),
defaultValues: {
customer_name: '',
notes: '',
},
});
Section Grouping
Group related fields into labeled sections with Card + Separator:
function SectionCard({ title, children, description }: {
title: string;
description?: string;
children: React.ReactNode;
}) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="card-title">{title}</CardTitle>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
Field Type Patterns
Select / Combobox
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger>
<SelectValue placeholder={t('common.selectOption')} />
</SelectTrigger>
<SelectContent>
{options.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
Date Picker
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className={cn(
"w-full justify-start text-left font-normal",
!field.value && "text-muted-foreground"
)}>
<CalendarIcon className="mr-2 h-4 w-4" />
{field.value ? formatDate(field.value) : t('common.pickDate')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={field.value}
onSelect={field.onChange} initialFocus />
</PopoverContent>
</Popover>
Switch / Toggle
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel>{t('booking.isConfirmed')}</FormLabel>
<FormDescription>{t('booking.isConfirmedHelp')}</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
File Upload
<FormItem>
<FormLabel>{t('common.attachment')}</FormLabel>
<FormControl>
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center hover:border-primary/50 transition-colors cursor-pointer"
onClick={() => fileInputRef.current?.click()}>
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t('common.dropOrClick')}</p>
<p className="text-xs text-muted-foreground mt-1">{t('common.maxFileSize', { size: '10MB' })}</p>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileChange} />
</div>
</FormControl>
{uploadedFile && (
<div className="flex items-center gap-2 mt-2 text-sm">
<Paperclip className="h-4 w-4" />
<span>{uploadedFile.name}</span>
<Button variant="ghost" size="icon" className="h-6 w-6 ml-auto"
onClick={() => setUploadedFile(null)}>
<X className="h-3 w-3" />
</Button>
</div>
)}
</FormItem>
Form-Level Error (Non-Field Errors)
When submission fails for a non-field reason (e.g., network error, server error):
{serverError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{serverError}</AlertDescription>
</Alert>
)}
Place this Alert above the form actions (bottom of form), not at the top. Field errors stay inline.
Auto-Save Pattern (Edit Forms)
For edit forms where changes should persist automatically:
const { watch } = form;
const watchedValues = watch();
useEffect(() => {
const timer = setTimeout(async () => {
if (form.formState.isDirty) {
setAutoSaveStatus('saving');
await saveDraft(watchedValues);
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
}
}, 2000); // 2-second debounce
return () => clearTimeout(timer);
}, [watchedValues]);
// Status indicator in page header
{autoSaveStatus === 'saving' && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{t('common.saving')}
</span>
)}
{autoSaveStatus === 'saved' && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Check className="h-3 w-3" />
{t('common.saved')}
</span>
)}
Dirty State Warning (Navigate Away)
const router = useRouter();
// Block navigation when form is dirty
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (form.formState.isDirty) {
e.preventDefault();
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [form.formState.isDirty]);
Submit Button States
| State | Appearance |
|---|---|
| Idle | {t('common.save')} — normal |
| Submitting | disabled + Loader2 animate-spin + {t('common.saving')} |
| Success | Toast fired, form reset or navigate away |
| Error | Alert rendered above actions, button returns to idle |
Rule: Never leave the submit button in a loading state after a response. Always resolve to idle+error or navigate away on success.
Edit Form Pre-fill
// useEffect to populate form with existing record data
useEffect(() => {
if (record) {
form.reset({
customer_name: record.customer_name,
start_date: record.start_date ? new Date(record.start_date) : undefined,
notes: record.notes ?? '',
});
}
}, [record]);
Violation Checklist
- Field errors displayed BELOW the field via
FormMessage— never as toast - Required fields marked with
<span className="text-destructive">*</span> - Submit button shows
Loader2 animate-spin+ disabled state while submitting - Cancel button is
variant="ghost", not "outline" or "secondary" - Actions
flex justify-end gap-3: Cancel left, Save right - Form-level server errors use
Alert variant="destructive"above actions - All field labels/placeholders/errors go through
t()— no hardcoded English - Edit forms use
form.reset(record)inuseEffectto pre-fill - Date fields display via
formatDate()— never raw ISO strings in UI - File upload has drag-and-drop zone (dashed border pattern)
- Forms ≤5 fields use Dialog (max-w-md), 6-12 use Dialog (max-w-2xl), 12+ use full page
- Dirty state
beforeunloadlistener present on edit forms -
useAnnaPageContext({ entityType: 'FormName', entityId: ... })wired