UNPKG

alouette

Version:

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

499 lines (381 loc) 18.2 kB
--- name: alouette-forms description: > Inputs: InputText (mode: password/email/number/tel/url/search), TextArea, Switch (checked + onValueChange; use disabled not editable). Single-select groups, controlled or not via value/defaultValue/onValueChange + accent/disabled: RadioGroup + Radio (circle-dot list), RadioButtonGroup + RadioButton (segmented pill bar), RadioCardGroup + RadioCard (icon/label/description cards, list|stack). 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 edits a row in a modal owning its own Form (mounted per open, so cancel is an unmount). 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.4.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 `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} /> ``` ### 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). ## 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, and each row tints to the danger accent on hover over its remove button via `StableAccentScope` (alouette-theming) — built in, not caller-wired. ### 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 Picking the wrong single-select family `RadioGroup` + `Radio`: vertical circle-dot list (longer option lists, labeled form fields). `RadioButtonGroup` + `RadioButton`: horizontal segmented pill bar (24 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