alouette
Version:
A modern, customizable design system built on top of NativeWind v5 with configurable defaults
483 lines (366 loc) • 18.2 kB
Markdown
---
name: alouette-forms
description: >
Inputs: InputText (mode: password/email/number/tel/url/search), TextArea, Switch
(checked + onValueChange; use disabled not editable). Single-select groups
(value/defaultValue/onValueChange + accent/disabled): RadioGroup + Radio
(circle-dot list), RadioButtonGroup + RadioButton (segmented pill bar),
RadioCardGroup + RadioCard (icon/label/description cards). Validation on
react-hook-form: Form hands { control, submit } to its render prop, and every field
takes that control — so the form type is written once on Form and each field's value
type comes from its name. FormField wires Controller to
FormItem label/error/required; FormFieldArray wraps useFieldArray; FormSubmitButton
drives loading/success/failed; SimpleVForm is the vertical-stack shortcut;
FormEditableItem edits a row in a modal with its own Form. errorToMessage is required
(i18n); FormValidationError separates 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.6.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/inputs/RadioCardGroup.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/RadioCard.tsx"
- "christophehurpeau/alouette:packages/alouette/src/ui/inputs/RadioCardGroup.stories.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 `{ control, 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.
`control` is how the types flow, exactly as in react-hook-form's own
`Controller`: write the form type **once** on `<Form<Values>>`, then pass its
`control` to each field. The field infers the form type from the control and its
own value type from `name`, so `field.value` is that one field's type — never a
union of every field, and never a per-field type argument.
```tsx
<Form<Values> … render={({ control }) => <FormField control={control} name="age" … />} />
// field.value is Values["age"], inferred
```
## 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} />
```
### Single-select groups
Three families share one API: the group owns the value (`value` / `defaultValue`
+ `onValueChange`, plus `accent` and `disabled`), children are composed rather
than passed as an options array, and a child's own `disabled` affects only that
option. Label the group via `aria-labelledby`.
- `RadioGroup` + `Radio` — circle-dot list, for longer or self-evident options.
- `RadioButtonGroup` + `RadioButton` — segmented pill bar: a lowered 44px track,
each pressable filling the tap target around a shorter visible chip. It is a
form input; for the same material used to move between destinations or switch
views use `NavBar` / `Tabs` (alouette-navigation/SKILL.md).
- `RadioCardGroup` + `RadioCard` — cards with `icon`, `label`, `description` and
a radio indicator, for options that need explaining. The selected card is
`PressableBox`'s `contained` fill, the rest its `outlined` surface. Group
`variant` is `"list"` (default, one per row) or `"stack"` (cards wrap and share
a row from a 240px basis).
```tsx
<RadioGroup defaultValue="week" onValueChange={setRange} aria-labelledby={labelId}>
<Radio value="day" label="Day" />
<Radio value="month" label="Month" disabled />
</RadioGroup>
<RadioButtonGroup defaultValue="week" accent="brand">
<RadioButton value="day" label="Day" />
<RadioButton value="week" label="Week" />
</RadioButtonGroup>
<RadioCardGroup variant="stack" defaultValue="public" onValueChange={setVisibility}>
<RadioCard value="public" icon={<GlobeRegularIcon />} label="Public"
description="Anyone with the link" />
<RadioCard value="private" icon={<LockRegularIcon />} label="Private" />
</RadioCardGroup>
```
`label` is each option's accessible name — required even when a description
carries the detail. Inside a form, render any of the three from a `FormField`
`render` prop: `value={field.value}` + `onValueChange={field.onChange}` +
`aria-labelledby={labelId}` (no `ref`, they are not focusable text). Since
`field.value` is typed from `name`, a `"day" | "week"` union field arrives as
that union, not as a widened `string`.
## 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={({ control }) => (
<FormField
control={control}
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} />
)}
/>
)}
/>;
```
The type argument goes on `SimpleVForm` / `Form` only — `defaultValues` is a
`DeepPartial`, which infers poorly, so that one stays explicit. Nothing below it
needs one.
### 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:
- `control={control}` — from the enclosing `Form`'s `render` params; types `name`
and `field.value`.
- `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
control={control}
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} />
)}
/>
```
`validate`'s `v` is that field's value type, so a `number` field's validator takes
a number without a cast.
### 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
`{ control, submit }`.
```tsx
import { Form, FormSubmitButton } from "alouette";
<Form<Values>
defaultValues={{ name: "", email: "" }}
onSubmit={async (values) => saveToServer(values)}
render={({ control, submit }) => (
<>
{/* fields */}
<FormSubmitButton label="Save" errorToMessage={submitErrorToMessage} onPress={submit} />
</>
)}
/>;
```
To split the fields into their own component, give it a
`control: Control<Values>` prop rather than reaching for `useFormContext`. The
form instance is still in context — `setFocus` (to move focus between fields)
only lives there — but `control` is what carries the types.
### Repeatable item lists and edit-in-a-modal rows
`FormFieldArray` wraps `useFieldArray` and owns the array label, add/remove
buttons and `minSize` padding; `render` gets the item's path prefix (typed
`` `${name}.${number}` ``) and composes its own `FormField`s from it.
`FormEditableItem` is an `EditableItem` row whose editor is a modal owning **its
own** `Form`, mounted per open — so cancelling is an unmount, not a restore. Both
are detailed in
[references/field-arrays-and-editable-rows.md](references/field-arrays-and-editable-rows.md).
### 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 Omitting control, or writing the form type on every FormField
Wrong:
```tsx
<Form<Values> render={() => <FormField<Values> name="name" ... />} />
```
Correct:
```tsx
<Form<Values> render={({ control }) => <FormField control={control} name="name" ... />} />
```
`control` is required and is the only inference site: it types `name` against the
form and makes `field.value` that one field's type. Writing `FormField<Values>`
instead is an arity error (`TName` has no default) — deliberately, because an
explicit type argument would block `TName` from ever being inferred from `name`
and degrade `field.value` to a union of every field in the form. Reaching for
`useFormContext` to avoid threading `control` gets you the same union.
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 control={control} name="name" label="Name">{({ field }) => <InputText ... />}</FormField>
```
Correct:
```tsx
<FormField control={control} 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} control={control} 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}
render={({ control }) => <DietFields control={control} />} />` (see above) — the
fields bind to the modal Form's own `control`, not to the screen's.
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 Picking the wrong single-select family
`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).
`RadioCardGroup` + `RadioCard`: cards, for a few options that each need an icon
and a line of explanation. None replaces `Select` — use `Select` for large option
lists.
The three share one context, so a child must sit inside its own group: a
`RadioCard` under a `RadioGroup` renders, but a `Radio` outside any of the three
throws "Radio, RadioButton and RadioCard must be rendered inside a RadioGroup,
RadioButtonGroup or RadioCardGroup."
Source: packages/alouette/src/ui/inputs/RadioGroup.tsx; ui/inputs/RadioButtonGroup.tsx; ui/inputs/RadioCardGroup.tsx