Skip to main content

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

ConditionUse
≤ 5 fieldsDialog (Dialog + DialogContent max-w-md)
6–12 fieldsDialog (DialogContent max-w-2xl) or full page
12+ fieldsFull page
Fields require file uploadsFull page
Multi-section grouping neededFull page
Wizard / multi-stepFull 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-3xl for full-page forms
  • Fields: single column mobile, md:grid-cols-2 on desktop
  • Actions: flex justify-end gap-3, Cancel left, Save right
  • Cancel: variant="ghost", never variant="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>
)}
/>
ElementClass / Rule
LabelFormLabeltext-sm font-medium
Required asterisk<span className="text-destructive">*</span> after label text
InputFull width by default
Help textFormDescriptiontext-xs text-muted-foreground
ErrorFormMessagetext-xs text-destructive — appears below field
Error placementALWAYS 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

StateAppearance
Idle{t('common.save')} — normal
Submittingdisabled + Loader2 animate-spin + {t('common.saving')}
SuccessToast fired, form reset or navigate away
ErrorAlert 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) in useEffect to 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 beforeunload listener present on edit forms
  • useAnnaPageContext({ entityType: 'FormName', entityId: ... }) wired