UNPKG

alouette

Version:

A modern, customizable design system built on top of NativeWind v5 with configurable defaults

300 lines (233 loc) 9.84 kB
--- name: alouette-forms-composition description: > Build validated forms on react-hook-form: Form owns the form instance and exposes submit() via a render prop (no control passing); FormField wires a Controller to FormItem's label/error/required layout and renders any input via render; FormFieldArray wraps useFieldArray for repeatable item lists with add/remove buttons; FormSubmitButton (built on ActionButton) drives loading/success/failed from the submit promise; SimpleVForm is the vertical-stack shortcut. errorToMessage is required (i18n); FormValidationError distinguishes invalid fields from an onSubmit failure. Load when building a form with validation. type: core library: alouette library_version: "20.4.0" requires: - alouette-forms - alouette-actions - alouette-theming sources: - "christophehurpeau/alouette:packages/alouette/src/ui/forms/Form.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/FormField.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/FormItem.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/FormFieldArray.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/FormSubmitButton.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/SimpleVForm.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/FormField.stories.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/SimpleVForm.stories.tsx" - "christophehurpeau/alouette:packages/alouette/src/ui/forms/FormFieldArray.stories.tsx" --- This skill builds on alouette-forms (inputs) and alouette-actions. Read them for the input components and the ActionButton lifecycle. # alouette — Form composition alouette forms wrap [react-hook-form](https://react-hook-form.com). `Form` owns the form instance and hands `submit()` to its subtree; `FormField` binds one field to a labelled, error-aware row and renders any alouette input; the submit button reuses the async-action lifecycle. You do not touch `control` — it flows through context. ## Setup `SimpleVForm` is the common case: a vertical stack of fields plus a trailing submit button. ```tsx import { SimpleVForm, FormField, FormValidationError } from "alouette"; import { InputText } from "alouette"; interface Values { name: string; email: string } function submitErrorToMessage(error: unknown): string { if (error instanceof FormValidationError) return "Please fix the errors above."; return error instanceof Error ? error.message : "Something went wrong."; } <SimpleVForm<Values> defaultValues={{ name: "", email: "" }} submitLabel="Submit" submitErrorToMessage={submitErrorToMessage} onSubmit={async (values) => saveToServer(values)} render={() => ( <> <FormField<Values> name="name" label="Name" required="Name is required." render={({ field, labelId }) => ( <InputText ref={field.ref} value={field.value} aria-labelledby={labelId} onChangeText={field.onChange} onBlur={field.onBlur} /> )} /> {/* more FormFields */} </> )} />; ``` ## Core Patterns ### FormField wiring `FormField` renders any input through `render` — it is not tied to `InputText`. The rendered input must spread the three `field` bindings and the label: - `ref={field.ref}` — lets pressing the label focus the input (via react-hook-form `setFocus`). - `value={field.value}` / `onChangeText={field.onChange}` / `onBlur={field.onBlur}`. - `aria-labelledby={labelId}` — ties the input to `FormItem`'s generated label. `required` doubles as the empty-field message: `true` shows the marker with no message; any other `ReactNode` is the message shown once the field is left empty. `validate` takes a react-hook-form validator (returns an error string or `undefined`). For rich/non-string error content, use `renderError`. ```tsx <FormField<Values> name="email" label="Email" validate={(v) => (/^[^@]+@[^@]+$/.test(v) ? undefined : "Enter a valid email.")} render={({ field, labelId }) => ( <InputText ref={field.ref} mode="email" value={field.value} aria-labelledby={labelId} onChangeText={field.onChange} onBlur={field.onBlur} /> )} /> ``` ### Custom layout with Form When the layout isn't a plain vertical stack, use `Form` directly and place a `FormSubmitButton` (or call `submit` yourself). `render` receives `{ submit }`. ```tsx import { Form, FormSubmitButton } from "alouette"; <Form<Values> defaultValues={{ name: "", email: "" }} onSubmit={async (values) => saveToServer(values)} render={({ submit }) => ( <> {/* fields */} <FormSubmitButton label="Save" errorToMessage={submitErrorToMessage} onPress={submit} /> </> )} />; ``` ### Repeatable item lists with FormFieldArray `FormFieldArray` wraps react-hook-form's `useFieldArray` and owns the array label, add/remove buttons, and padding to a minimum count. It is agnostic about item shape — `render` gets the item's path prefix (e.g. `"guests.0"`) and composes its own `FormField`(s) from it, bound directly for a raw value or via `${name}.fieldName` for an object item: ```tsx <FormFieldArray<Values> name="guests" label="Guests" emptyValue={{ value: "" }} minSize={1} addLabel="Add guest" render={({ name, label }) => ( <FormField<Values> name={`${name}.value` as `guests.${number}.value`} label={label} required="Guest name is required." render={({ field, labelId }) => ( <InputText ref={field.ref} value={field.value as string} aria-labelledby={labelId} onChangeText={field.onChange} onBlur={field.onBlur} /> )} /> )} /> ``` The leading `minSize` items are padded in on mount and cannot be removed. Each row tints to the danger accent on hover over its remove button via `StableAccentScope` (see alouette-theming) — that's built in, not something callers wire up themselves. ### Submit lifecycle `FormSubmitButton` is built on `ActionButton`, so submitting shows the spinner / success / failed overlay and an inline error automatically. `submit()` rejects with `FormValidationError` when fields are invalid (nothing was submitted), so `errorToMessage` can render "fix the errors above" distinctly from a real `onSubmit` failure. `mode` defaults to `"onTouched"`. By default an exception thrown from `onSubmit` propagates (consumed by `FormSubmitButton`); pass `onSubmitError` to `Form` only if you need to observe it elsewhere. ## Common Mistakes ### HIGH Passing control / register to FormField Wrong: ```tsx <FormField control={control} name="name" ... /> ``` Correct: ```tsx <Form render={() => <FormField name="name" ... />} /> ``` `Form` provides the react-hook-form instance through context; `FormField` reads `control` and `setFocus` itself via `useFormContext`. There is no `control` prop — just render `FormField` inside `Form`. Source: packages/alouette/src/ui/forms/FormField.tsx; ui/forms/Form.tsx ### HIGH Naming FormField's input function `children` instead of `render` Wrong: ```tsx <FormField name="name" label="Name">{({ field }) => <InputText ... />}</FormField> ``` Correct: ```tsx <FormField name="name" label="Name" render={({ field, labelId }) => <InputText ... />} /> ``` The input is supplied through the `render` prop, not children — a function child is ignored. Source: packages/alouette/src/ui/forms/FormField.tsx ### HIGH Forgetting field.ref / aria-labelledby on the input Wrong: ```tsx render={({ field }) => <InputText value={field.value} onChangeText={field.onChange} />} ``` Correct: ```tsx render={({ field, labelId }) => ( <InputText ref={field.ref} value={field.value} aria-labelledby={labelId} onChangeText={field.onChange} onBlur={field.onBlur} /> )} ``` Without `field.ref`, pressing the label can't focus the input and react-hook-form's `setFocus` no-ops; without `aria-labelledby={labelId}` the input has no accessible name. Source: packages/alouette/src/ui/forms/FormField.tsx; ui/forms/FormItem.tsx ### MEDIUM Omitting errorToMessage or hardcoding an English default Wrong: ```tsx <FormSubmitButton label="Save" onPress={submit} /> {/* errorToMessage missing */} ``` Correct: ```tsx <FormSubmitButton label="Save" onPress={submit} errorToMessage={submitErrorToMessage} /> ``` `errorToMessage` is required (not defaulted) because a library default could only be a hardcoded, untranslatable string. Handle `FormValidationError` inside it to distinguish invalid fields from a genuine submit failure. Source: packages/alouette/src/ui/forms/FormSubmitButton.tsx; ui/forms/Form.tsx ### MEDIUM Reimplementing the submit button's loading state Wrong: a raw `Button` with manual `isSubmitting` + `disabled` around `submit`. Correct: `FormSubmitButton` (or `SimpleVForm`, which adds it for you) — it derives loading/success/failed from the submit promise like any `ActionButton`. Source: packages/alouette/src/ui/forms/FormSubmitButton.tsx ### MEDIUM Mapping a data array of fields instead of FormFieldArray Wrong: ```tsx {fields.map((f, i) => <FormField key={i} name={`guests.${i}.value`} ... />)} <Button text="Add" onPress={() => setFields([...fields, {}])} /> ``` Correct: ```tsx <FormFieldArray<Values> name="guests" label="Guests" emptyValue={{ value: "" }} render={({ name, label }) => <FormField name={`${name}.value`} label={label} ... />} /> ``` Hand-rolled array state drifts from react-hook-form's own field array (stale indices on remove, no stable `key`, no built-in add/remove affordances). `FormFieldArray` wraps `useFieldArray` and supplies the label, indentation, and add/remove buttons; `render` only supplies each item's own fields. Source: packages/alouette/src/ui/forms/FormFieldArray.tsx