AZMARA PLATFORM
Components

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.

Usage

import { FieldArray } from "@azmr/ui/field-array";
Preview theme
Preview density
Contacts

Up to five people who get invoices and statements.

<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>
Open as text

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, its key, or whatever getKey returns. 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 with onRemove, and a drag handle with onMove.
  • min disables the remove buttons at that many rows, and max disables the add button. Both stay focusable, so people can still find them and learn why.
  • empty is shown in place of the list when there are no rows.
  • footer gets the rows and renders under them: totals, counts or a summary.
  • header shows 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 the sm breakpoint up.
  • itemLabel names each row in button labels and announcements ("Remove Contact 2"). It defaults to "Row 1", "Row 2", and so on.
  • messages replaces any of the text, for translation.

Invoice lines with GST

Preview theme
Preview density
Line items

Prices exclude GST.

  • $95.00
  • $220.00
Subtotal
$315.00
GST (15%)
$47.25
Total
$362.25

<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>
Open as text

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>
  • footer gets 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 the rules prop, shows under the legend. Each field shows its own error.
  • Focus after adding a row is handled by FieldArray, so useFieldArray's own shouldFocus is turned off.
  • row.name() is typed: with FormFieldArray<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(), call useFieldArray yourself and pass its result as fieldArray. FormFieldArray then uses it instead of its own; give any array rules to your useFieldArray.
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 moveButtons to 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.
Keyboard interactions
KeyAction
ArrowUpOn a drag handle: moves the row up one place.
ArrowDownOn a drag handle: moves the row down one place.
HomeOn a drag handle: moves the row to the top.
EndOn a drag handle: moves the row to the bottom.
EscapeCancels a drag in progress.
EnterOn the add, remove or move buttons: activates the button.