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