@loke/design-system
Version:
A design system with individually importable components
419 lines (344 loc) • 14.4 kB
Markdown
---
name: forms
description: >
Build forms with react-hook-form + Zod using design system controls.
Form (FormProvider), FormField (Controller + context), FormItem (ID
generation), FormControl (Slot with aria bindings), FormLabel, FormDescription,
FormMessage, useFormField. Wiring patterns: Input/Textarea spread {...field},
Select uses value/onValueChange, Checkbox/Switch use checked/onCheckedChange,
RadioGroup uses value/onValueChange. Popover+Command combobox for searchable
selection. Reusable field component extraction. useFieldArray for dynamic lists.
Activate when building forms with validation or wiring form controls.
type: composition
library: '@loke/design-system'
library_version: '2.0.0-rc.6'
requires:
- getting-started
- interactive-components
sources:
- 'LOKE/merchant-frontends:packages/design-system/src/components/form'
- 'LOKE/merchant-frontends:packages/design-system/src/components/select'
- 'LOKE/merchant-frontends:packages/design-system/src/components/checkbox'
- 'LOKE/merchant-frontends:packages/design-system/src/components/switch'
- 'LOKE/merchant-frontends:packages/design-system/src/components/radio-group'
- 'LOKE/merchant-frontends:apps/office/src/components/design-system/form.tsx'
- 'LOKE/merchant-frontends:apps/office/src/components/form/timezone-combobox.tsx'
- 'LOKE/merchant-frontends:apps/office/src/components/locations/location-settings/switch-field.tsx'
---
# Forms
This skill builds on **getting-started** and **interactive-components**. Read them first.
## Setup
A complete minimal form with Zod validation:
```tsx
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@loke/design-system/button";
import { Input } from "@loke/design-system/input";
import {
Form, FormControl, FormDescription, FormField,
FormItem, FormLabel, FormMessage,
} from "@loke/design-system/form";
const schema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
});
type FormValues = z.infer<typeof schema>;
function ExampleForm({ onSubmit }: { onSubmit: (data: FormValues) => void }) {
const form = useForm<FormValues>({
defaultValues: { name: "", email: "" },
resolver: zodResolver(schema),
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Jane Doe" {...field} />
</FormControl>
<FormDescription>Your display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</div>
</form>
</Form>
);
}
```
- `Form` is a re-export of `FormProvider`. It provides form context to all children.
- `form.handleSubmit(onSubmit)` goes on the native `<form>` element, not on `<Form>`.
- Layout spacing is your responsibility (e.g. `grid gap-6`).
## Core Patterns
### Basic form structure
Every form field follows this nesting:
```
Form (FormProvider)
form (native HTML)
FormField (Controller + context)
FormItem (generates unique IDs via useId)
FormLabel (wired to field via htmlFor)
FormControl (Slot -- passes aria-* and id to child)
FormDescription (optional help text)
FormMessage (validation error display)
```
`FormField` wraps react-hook-form's `Controller`. Its `render` callback receives `{ field }` with `value`, `onChange`, `onBlur`, `name`, and `ref`. `FormItem` generates a unique ID. `FormControl` uses that ID plus `useFormField()` to inject `id`, `aria-invalid`, and `aria-describedby` onto its single child via `Slot`.
### Wiring per control type
Each DS control type connects to `field` differently. This is the most important pattern.
#### Input / Textarea -- spread `{...field}`
```tsx
<FormControl>
<Textarea placeholder="Tell us about yourself" {...field} />
</FormControl>
```
Input and Textarea accept standard `value`, `onChange`, `onBlur`, `name`, `ref` -- spreading `{...field}` works directly.
#### Select -- value/onValueChange
```tsx
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@loke/design-system/select";
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="member">Member</SelectItem>
</SelectContent>
</Select>
```
`Select` is a Radix primitive -- no native `onChange`. Wire `value` and `onValueChange` explicitly. `FormControl` wraps `SelectTrigger` (not `Select`) so aria bindings land on the focusable element.
#### Checkbox -- checked/onCheckedChange
```tsx
import { Checkbox } from "@loke/design-system/checkbox";
<FormItem className="flex items-start gap-3">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<div className="grid gap-1.5 leading-none">
<FormLabel>Accept terms</FormLabel>
<FormDescription>You agree to our Terms of Service.</FormDescription>
</div>
</FormItem>
```
#### Switch -- checked/onCheckedChange
```tsx
import { Switch } from "@loke/design-system/switch";
<FormItem className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<FormLabel>Enable notifications</FormLabel>
<FormDescription>Receive alerts for important updates.</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
```
#### RadioGroup -- value/onValueChange
```tsx
import { RadioGroup, RadioGroupItem } from "@loke/design-system/radio-group";
import { Label } from "@loke/design-system/label";
<FormControl>
<RadioGroup onValueChange={field.onChange} value={field.value}>
<div className="flex items-center gap-2">
<RadioGroupItem id="free" value="free" />
<Label htmlFor="free">Free</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem id="pro" value="pro" />
<Label htmlFor="pro">Pro</Label>
</div>
</RadioGroup>
</FormControl>
```
### Wiring summary table
| Control | Props from `field` |
|------------|----------------------------------------------------------|
| Input | `{...field}` (spread all) |
| Textarea | `{...field}` (spread all) |
| Select | `value={field.value} onValueChange={field.onChange}` |
| Checkbox | `checked={field.value} onCheckedChange={field.onChange}` |
| Switch | `checked={field.value} onCheckedChange={field.onChange}` |
| RadioGroup | `value={field.value} onValueChange={field.onChange}` |
### Popover + Command combobox
When you need a searchable/filterable dropdown (Select does not support filtering), use Popover + Command. The combobox accepts `value`/`onChange` like Select, managing its own `open` state:
```tsx
import { Button } from "@loke/design-system/button";
import { cn } from "@loke/design-system/cn";
import {
Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,
} from "@loke/design-system/command";
import { Popover, PopoverContent, PopoverTrigger } from "@loke/design-system/popover";
import { Check, ChevronsUpDown } from "@loke/icons";
import { useState } from "react";
function Combobox({ options, value, onChange, placeholder = "Select..." }: {
onChange: (value: string | null) => void;
options: { label: string; value: string }[];
placeholder?: string;
value: string | null;
}) {
const [open, setOpen] = useState(false);
const selectedLabel = options.find((o) => o.value === value)?.label;
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
className={cn("w-full justify-between font-normal", !value && "text-muted-foreground")}
role="combobox"
type="button"
variant="outline"
>
<span className="truncate">{selectedLabel ?? placeholder}</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="p-0" matchTriggerWidth>
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
onSelect={() => { onChange(option.value === value ? null : option.value); setOpen(false); }}
value={option.value}
>
<span className="flex-1">{option.label}</span>
<Check className={cn("h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
```
Wire into a form field like Select:
```tsx
<FormField
control={form.control}
name="timezone"
render={({ field }) => (
<FormItem>
<FormLabel>Timezone</FormLabel>
<FormControl>
<Combobox onChange={field.onChange} options={tzOptions} value={field.value} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
```
### Reusable field components
Extract repeated form field patterns into dedicated components. Generic `TFieldValues` preserves type safety:
```tsx
import { Switch } from "@loke/design-system/switch";
import type { Control, FieldValues, Path } from "react-hook-form";
import {
FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@loke/design-system/form";
function SwitchField<TFieldValues extends FieldValues>({ control, description, label, name }: {
control: Control<TFieldValues>;
description: string;
label: string;
name: Path<TFieldValues>;
}) {
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<FormLabel>{label}</FormLabel>
<FormDescription>{description}</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
```
Apply the same pattern for `SelectField`, `ComboboxField`, etc.
### useFieldArray for dynamic lists
Use `useFieldArray` from react-hook-form for add/remove/reorder item lists:
```tsx
import { useFieldArray } from "react-hook-form";
const { append, fields, remove } = useFieldArray({
control: form.control,
name: "links",
});
// In JSX:
{fields.map((item, index) => (
<div key={item.id}> {/* Always use item.id as key, not index */}
<FormField
control={form.control}
name={`links.${index}.url`} {/* Template literal path */}
render={({ field }) => (
<FormItem>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button onClick={() => remove(index)} type="button" variant="outline">Remove</Button>
</div>
))}
<Button onClick={() => append({ url: "" })} type="button" variant="outline">Add link</Button>
```
Available methods: `append`, `prepend`, `insert`, `remove`, `move`, `swap`, `update`, `replace`.
## Common Mistakes
### 1. CRITICAL: Spreading `{...field}` identically into all controls
```tsx
// WRONG
<Select {...field}>
<Checkbox {...field} />
// CORRECT -- each control type has its own wiring
<Select onValueChange={field.onChange} value={field.value}>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
```
Only Input and Textarea accept `{...field}`. See the wiring summary table.
### 2. CRITICAL: Using FormControl outside FormField context
`FormControl` calls `useFormField()` which reads `FormFieldContext`. That context is only provided by `FormField`. Using `FormControl` without a `FormField` ancestor causes a runtime error.
### 3. HIGH: Using Select for searchable/async selection
Select is a Radix dropdown -- it does not support text filtering. Use the Popover + Command combobox pattern for filterable selection.
### 4. HIGH: Using native HTML form elements instead of DS components
```tsx
// WRONG // CORRECT
<input type="text" /> <Input />
<select>...</select> <Select>...</Select>
<textarea /> <Textarea />
```
DS components include focus rings, `aria-invalid` error states, dark mode, and consistent sizing.
### 5. HIGH: Missing FormItem wrapper
`FormItem` creates `FormItemContext` with a unique `id` via `useId()`. `FormControl` reads this for `id`, `aria-describedby`, and `aria-invalid`. `FormLabel` reads it for `htmlFor`. Replacing `FormItem` with a plain `<div>` silently breaks all accessibility bindings.
### 6. MEDIUM: Monolithic form components
Extract reusable field components (like `SwitchField` above) instead of repeating the full `FormField` > `FormItem` > `FormControl` nesting inline for every field. This keeps form components under 100 lines.
### 7. MEDIUM: Manual array state instead of useFieldArray
```tsx
// WRONG
const [items, setItems] = useState([{ name: "" }]);
// CORRECT
const { append, fields, remove } = useFieldArray({ control: form.control, name: "items" });
```
`useFieldArray` integrates with react-hook-form's validation, dirty tracking, and error state. Manual `useState` arrays require manual syncing.
## See also
- **interactive-components/SKILL.md** -- Button, Input, Textarea components
- **overlay-composition/SKILL.md** -- Popover + Command for combobox