Field array
FieldArray repeats a group of fields, such as invoice lines, contacts or addresses, with add, remove and reorder by drag or keyboard, minimum and maximum rows, an empty state and totals.
View as Markdown for coding assistants, or see llms.txt.
- New
- Group: Inputs
- Added in @azmr/ui 0.10.0
Usage
import { FieldArray } from "@azmr/ui/field-array";<FieldArray
legend="Contacts"
description="Up to five people who get invoices and statements."
className="w-full max-w-xl"
items={contacts}
max={5}
itemLabel={(index) => `Contact ${index + 1}`}
empty="No contacts yet. Invoices go to the account email."
messages={{ add: "Add contact" }}
onAdd={() => setContacts([...contacts, newContact()])}
onRemove={(index) => setContacts(contacts.filter((_, i) => i !== index))}
onMove={(from, to) => {
const next = [...contacts];
const [moved] = next.splice(from, 1);
if (moved) next.splice(to, 0, moved);
setContacts(next);
}}
>
{(contact, row) => (
<div className="grid gap-3 sm:grid-cols-2">
<Field label={`Name, contact ${row.index + 1}`}>
<Input
value={contact.name}
autoComplete="off"
onChange={(event) => update(row.index, { name: event.target.value })}
/>
</Field>
<Field label={`Email, contact ${row.index + 1}`}>
<Input
type="email"
value={contact.email}
autoComplete="off"
onChange={(event) => update(row.index, { email: event.target.value })}
/>
</Field>
</div>
)}
</FieldArray>FieldArray renders one row per item through its children function, which gets the item and a row with its index, key, label and a name() helper: with name="contacts", row.name("email") is "contacts.1.email". It does not own the rows. You pass items and handle onAdd, onRemove and onMove, so it works with plain state, React Hook Form or any other form library.
- Each item needs a stable key: its
id, itskey, or whatevergetKeyreturns. Never the index, or focus and drag follow the wrong row after a change. - The add button appears with
onAdd, a remove button on each row withonRemove, and a drag handle withonMove. mindisables the remove buttons at that many rows, andmaxdisables the add button. Both stay focusable, so people can still find them and learn why.emptyis shown in place of the list when there are no rows.footergets the rows and renders under them: totals, counts or a summary.headershows column headings over the rows, for a table-like layout. Lay them out on the same grid as each row's fields. They are hidden from screen readers, so give every field its own label and hide it visually where the headings take over, as the invoice demo below does from thesmbreakpoint up.itemLabelnames each row in button labels and announcements ("Remove Contact 2"). It defaults to "Row 1", "Row 2", and so on.messagesreplaces any of the text, for translation.
Invoice lines with GST
<Form
form={form}
onSubmit={(values) =>
setSaved(
`Saved ${values.lines.length} lines, ${nzd.format(totals(values.lines).total / 100)} incl. GST.`,
)
}
className="flex w-full max-w-3xl flex-col gap-6"
>
<FormErrorSummary />
<FormFieldArray<InvoiceInput, "lines">
name="lines"
legend="Line items"
description="Prices exclude GST."
itemLabel={(index) => `Line ${index + 1}`}
max={20}
newItem={() => ({ description: "", quantity: 1, unitPrice: 0 })}
messages={{ add: "Add line" }}
className="[&_[data-slot=field-array-row]]:items-start"
header={
<div className={`hidden sm:grid ${COLUMNS}`}>
<span>Description</span>
<span>Qty</span>
<span>Unit price</span>
<span className="text-end">Amount</span>
</div>
}
footer={(lines) => {
const { subtotal, gst, total } = totals(lines);
return (
<dl className="ms-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm tabular-nums">
<dt className="text-muted-foreground">Subtotal</dt>
<dd>{nzd.format(subtotal / 100)}</dd>
<dt className="text-muted-foreground">GST (15%)</dt>
<dd>{nzd.format(gst / 100)}</dd>
<dt className="font-medium">Total</dt>
<dd className="font-medium">{nzd.format(total / 100)}</dd>
</dl>
);
}}
>
{(_, row) => {
// Wide screens show the column headings instead of a label on every field; each field
// keeps its full name ("Line 2 Qty") for screen readers.
const label = (text: string) => (
<>
<span className="sr-only">{row.label} </span>
<span className="sm:sr-only">{text}</span>
</>
);
return (
<div className={`grid ${COLUMNS}`}>
<FormField name={row.name("description")} label={label("Description")}>
<Input autoComplete="off" />
</FormField>
<FormField
name={row.name("quantity")}
label={label("Qty")}
rules={{ setValueAs: numberOrNull }}
>
<NumberField min={0} decimalScale={2} />
</FormField>
<FormField
name={row.name("unitPrice")}
label={label("Unit price")}
rules={{ setValueAs: numberOrNull }}
>
<MoneyField min={0} />
</FormField>
<div className="flex flex-col gap-1.5">
<span aria-hidden="true" className="text-sm font-medium leading-none sm:hidden">
Amount
</span>
<LineAmount index={row.index} label={row.label} />
</div>
</div>
);
}}
</FormFieldArray>
<div className="flex items-center gap-3">
<Button type="submit">Save invoice</Button>
<p role="status" className="text-sm text-muted-foreground">
{saved ?? ""}
</p>
</div>
</Form>With React Hook Form, use FormFieldArray from @azmr/ui/rhf inside a Form. It runs useFieldArray for you: newItem gives a new row's values and shows the add button, and rows can be removed and reordered unless you set removable={false} or sortable={false}. Put a FormField in each row with row.name():
<FormFieldArray<Invoice, "lines">
name="lines"
legend="Line items"
itemLabel={(index) => `Line ${index + 1}`}
newItem={() => ({ description: "", quantity: 1, unitPrice: 0 })}
footer={(lines) => <Totals lines={lines} />}
>
{(_, row) => (
<FormField name={row.name("description")} label="Description">
<Input />
</FormField>
)}
</FormFieldArray>footergets the rows' current values and updates as people type, without re-rendering the rows.- An error for the array as a whole, such as
z.array(line).min(1, "Add at least one line")or therulesprop, shows under the legend. Each field shows its own error. - Focus after adding a row is handled by
FieldArray, souseFieldArray's ownshouldFocusis turned off. row.name()is typed: withFormFieldArray<Invoice, "lines">,row.name("quantity")is`lines.${number}.quantity`and a name that isn't a field of a line fails to compile.- To change the rows from elsewhere in the form, such as an "Add from catalogue" picker that calls
replace(), calluseFieldArrayyourself and pass its result asfieldArray.FormFieldArraythen uses it instead of its own; give any arrayrulesto youruseFieldArray.
const lines = useFieldArray({ control: form.control, name: "lines" });
<CataloguePicker onPick={(item) => lines.append(lineFrom(item))} />
<FormFieldArray<Invoice, "lines"> name="lines" fieldArray={lines} legend="Line items">
{(_, row) => /* ... */}
</FormFieldArray>The demo adds up money in integer cents, so the GST total is exact. Work out the real total again on the server from the saved lines.
Reordering
Each row's drag handle moves it in three ways:
- Drag with a mouse, a finger or a pen. The other rows move aside as you go, and the row drops where you let go. Press Escape to cancel.
- Arrow keys on the focused handle move the row up or down by one, and Home and End move it to the top or bottom. Focus stays on the handle.
- Move buttons: set
moveButtonsto add move up and move down buttons to every row. They are the easiest to find, including for screen reader users in browse mode, where the arrow keys move the reading position instead.
Props
Prop
Type
FormFieldArray takes the same props except items, getKey, onAdd, onRemove, onMove and error, plus name, rules, fieldArray, newItem, removable and sortable.
Accessibility
- The rows sit in a
<fieldset>labelled by its legend, so every field in a row is read with the group's name. - Adding a row moves focus to its first field. Removing a row moves focus to the first field of the row that takes its place, then to the previous row, then to the add button.
- Every add, remove and move is announced in a polite live region, for example "Line 2 removed. 2 rows left." or "Line 3 moved to position 1 of 3."
- The drag handle, remove and move buttons are named after their row ("Move Line 2", "Remove Line 2"). The handle also describes how to use the arrow keys.
- Rows step aside with a transform only, and skip that animation when reduced motion is preferred.
| Key | Action |
|---|---|
| ArrowUp | On a drag handle: moves the row up one place. |
| ArrowDown | On a drag handle: moves the row down one place. |
| Home | On a drag handle: moves the row to the top. |
| End | On a drag handle: moves the row to the bottom. |
| Escape | Cancels a drag in progress. |
| Enter | On the add, remove or move buttons: activates the button. |