alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
498 lines (376 loc) • 17.3 kB
Markdown
---
name: alouette-forms
description: >
Forms end to end. Inputs: InputText (mode: password/email/number/tel/url/search),
TextArea, Switch (checked + onValueChange; use disabled not editable). Single-select:
RadioGroup + Radio (circle-dot list) and RadioButtonGroup + RadioButton (segmented
pill bar); controlled/uncontrolled via value/defaultValue/onValueChange + accent/disabled.
Validation on react-hook-form: Form owns the instance and exposes submit() via render
prop (no control passing); FormField wires Controller to FormItem label/error/required;
FormFieldArray wraps useFieldArray with add/remove; FormSubmitButton drives
loading/success/failed; SimpleVForm is the vertical-stack shortcut;
FormEditableItem is an EditableItem row whose editor is a modal owning its own
Form (mounted per open, so cancel is an unmount — no snapshot/restore).
errorToMessage required (i18n); FormValidationError distinguishes invalid fields
from onSubmit failures. Load when building text fields, toggles, radio groups, a
validated form, or an edit-in-a-modal row.
type: core
library: alouette
library_version: "22.0.0"
requires:
- alouette-theming
- alouette-actions
sources:
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/InputText.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/TextArea.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/Switch.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/RadioGroup.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/Radio.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/RadioButtonGroup.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/RadioButton.tsx"
- "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/FormEditableItem.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"
- "christophehurpeau/alouette:packages/alouette/src/ui/forms/FormEditableItem.stories.tsx"
---
This skill builds on alouette-theming (token model) and alouette-actions (the
ActionButton lifecycle the submit button reuses). Read them first.
# alouette — Forms
Two layers. **Inputs** are the raw controls: `InputText` wraps react-native
`TextInput` with alouette styling and a `mode` prop, `TextArea` is a multiline
`InputText`, and `Switch` wraps the native switch with themed colors.
**Composition** wraps [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 input, and the submit
button reuses the async-action lifecycle. You do not touch `control` — it flows
through context.
## Inputs
```tsx
import { InputText } from "alouette";
<InputText mode="email" value={email} onChangeText={setEmail} placeholder="you@example.com" />;
```
### Input modes
`mode` bundles the right keyboard, input mode, autocomplete and secure entry:
`"password" | "email" | "number" | "tel" | "url" | "search" | "webSearch"`.
```tsx
<InputText mode="password" value={pw} onChangeText={setPw} />
<InputText mode="number" value={qty} onChangeText={setQty} />
```
### TextArea, Switch, disabled
```tsx
<TextArea value={notes} onChangeText={setNotes} placeholder="Notes" />
<Switch checked={on} onValueChange={setOn} /> {/* controlled */}
<Switch onValueChange={(v) => log(v)} /> {/* uncontrolled */}
<InputText disabled value={value} />
<Switch disabled checked={on} />
```
### RadioGroup — circle-dot single-select list
`RadioGroup` owns the selected value; `Radio` children read it via context.
Controlled/uncontrolled via `value` / `defaultValue` + `onValueChange`. `accent`
and `disabled` propagate to all options; `disabled` on an individual `Radio`
disables only that option. Label the group via `aria-labelledby`.
```tsx
import { RadioGroup, Radio } from "alouette";
<RadioGroup
defaultValue="week"
onValueChange={setRange}
aria-labelledby={labelId}
>
<Radio value="day" label="Day" />
<Radio value="week" label="Week" />
<Radio value="month" label="Month" disabled />
</RadioGroup>
```
### RadioButtonGroup — segmented pill bar
Same API as `RadioGroup` / `Radio`, but rendered as a pill-style segmented
control. The track is a `Surface variant="lowered"` with no vertical padding
(44 px); each `RadioButton` pressable fills the full 44 px tap target while
centering a shorter visible chip.
It is a form input: it answers a question and submits a value. For the same
material used to move between destinations or switch views, use `NavBar` or
`Tabs` — see alouette-navigation/SKILL.md.
```tsx
import { RadioButtonGroup, RadioButton } from "alouette";
<RadioButtonGroup defaultValue="week" accent="brand">
<RadioButton value="day" label="Day" />
<RadioButton value="week" label="Week" />
<RadioButton value="month" label="Month" />
</RadioButtonGroup>
```
Inside a form, render it from a `FormField` `render` prop for label/error
wiring — `value={field.value}` + `onValueChange={field.onChange}` +
`aria-labelledby={labelId}` (no `ref`, it is not focusable text).
## Validated forms
`SimpleVForm` is the common case: a vertical stack of fields plus a trailing
submit button.
```tsx
import { SimpleVForm, FormField, InputText, FormValidationError } 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} />
)}
/>
)}
/>;
```
### 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={...}
/>
)}
/>
```
`render` on the inner `FormField` is an `InputText` wired exactly as above.
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.
### Edit-in-a-modal rows with FormEditableItem
`FormEditableItem` is an `EditableItem` row (see alouette-data/SKILL.md) whose
editor is a modal owning **its own** `Form`. The `Form` is mounted only while
editing, so it reseeds from `defaultValues` on every open and cancelling is a
plain unmount — the screen's state is never touched by an abandoned edit.
```tsx
<FormEditableItem<Values>
label="Display name"
summary={<Badge accent="brand">{displayName}</Badge>}
editAriaLabel="Edit display name"
cancelLabel="Cancel"
submitLabel="Save"
submitErrorToMessage={submitErrorToMessage}
defaultValues={{ displayName }}
onSubmit={async (values) => saveToServer(values)}
>
{/* the fields — an InputText FormField, wired as above */}
<FormField<Values> name="displayName" label="Name" required="A name is required."
render={...} />
</FormEditableItem>
```
`children` are the fields (the modal body); the Cancel / Save footer is built
for you. It takes the row props (`label`, `summary`, `details`, `editAriaLabel`,
`editIcon`, `variant`, `accent`, `disabled` — see alouette-data/SKILL.md) plus
`Form`'s `defaultValues` / `mode` / `onSubmit`, and the modal's `title`
(defaults to `label`), `size` and `closeButtonAriaLabel`.
The modal closes only once `onSubmit` resolves: a rejection (or a
`FormValidationError` from invalid fields) keeps it open with the error on the
submit button.
### 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
### MEDIUM Disabling an input with editable={false}
Wrong:
```tsx
<InputText editable={false} />
```
Correct:
```tsx
<InputText disabled />
```
`InputText` exposes a `disabled` prop that sets `editable`, `aria-disabled` and
the disabled styling together; passing `editable` directly skips the disabled
visual state and a11y.
Source: packages/alouette/src/ui/inputs/InputText.tsx
### MEDIUM Setting keyboardType / secureTextEntry by hand
Wrong:
```tsx
<InputText secureTextEntry autoComplete="current-password" />
```
Correct:
```tsx
<InputText mode="password" />
```
The `mode` prop bundles `inputMode` + `keyboardType` + `autoComplete` +
`secureTextEntry` consistently across platforms; setting them piecemeal is
error-prone.
Source: packages/alouette/src/ui/inputs/InputText.tsx (MODE_PROPS)
### MEDIUM Wiring Switch like a web checkbox
Wrong:
```tsx
<Switch value={on} onChange={(e) => setOn(e.target.checked)} />
```
Correct:
```tsx
<Switch checked={on} onValueChange={setOn} />
```
`Switch` is controlled via `checked` and reports through
`onValueChange(value: boolean)`. `value`/`onChange` with `e.target` do nothing
in React Native.
Source: packages/alouette/src/ui/inputs/Switch.tsx
### 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: `FormFieldArray` (see above).
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
### HIGH Editing a field of the screen's form inside the modal, then restoring on cancel
Wrong:
```tsx
const { field } = useController<Values, "diet">({ name: "diet" });
const [editedFrom, setEditedFrom] = useState(field.value);
<IconButton onPress={() => { setEditedFrom(field.value); setEditing(true); }} … />
<Modal visible={editing} onClose={() => { field.onChange(editedFrom); setEditing(false); }}>
<DietFields /> {/* FormFields of the *screen's* form */}
</Modal>
```
Correct: `<FormEditableItem defaultValues={{ diet }} onSubmit={save}>` with
`<DietFields />` as children (see above).
Binding the modal's fields to the surrounding form mutates shared state on every
keystroke, which is why the value then has to be snapshotted on open and restored
on cancel. `FormEditableItem` mounts a **separate** `Form` per open, seeded from
`defaultValues`: cancel is an unmount, and the row keeps showing the last saved
value with no restore logic.
Source: packages/alouette/src/ui/forms/FormEditableItem.tsx
### MEDIUM Confusing RadioGroup with RadioButtonGroup
`RadioGroup` + `Radio`: vertical circle-dot list (longer option lists, labeled
form fields). `RadioButtonGroup` + `RadioButton`: horizontal segmented pill bar
(2–4 compact, equal-weight choices like view mode or time range). Neither
replaces `Select` — use `Select` for large option lists.
Source: packages/alouette/src/ui/inputs/RadioGroup.tsx; ui/inputs/RadioButtonGroup.tsx