UNPKG

@loke/design-system

Version:

A design system with individually importable components

419 lines (344 loc) 14.4 kB
--- 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