# 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.

Source: https://platform.azmara.io/docs/components/field-array

Group: Inputs · Status: new · Added in @azmr/ui 0.10.0

## Usage

```tsx
import { FieldArray } from "@azmr/ui/field-array";
```

```tsx
"use client";

import { Field } from "@azmr/ui/field";
import { FieldArray } from "@azmr/ui/field-array";
import { Input } from "@azmr/ui/input";
import { useState } from "react";

type Contact = { id: string; name: string; email: string };

let nextId = 3;
const newContact = (): Contact => ({ id: `contact-${nextId++}`, name: "", email: "" });

export default function FieldArrayBasic() {
  const [contacts, setContacts] = useState<Contact[]>([
    { id: "contact-1", name: "Aroha Ngata", email: "aroha@northline.co.nz" },
    { id: "contact-2", name: "Tama Wiremu", email: "accounts@northline.co.nz" },
  ]);
  const update = (index: number, change: Partial<Contact>) =>
    setContacts(contacts.map((contact, i) => (i === index ? { ...contact, ...change } : contact)));

  return (
    <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`, 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

```tsx
"use client";

import { Button } from "@azmr/ui/button";
import { Input } from "@azmr/ui/input";
import { MoneyField, NumberField, numberOrNull } from "@azmr/ui/number-field";
import { Form, FormErrorSummary, FormField, FormFieldArray } from "@azmr/ui/rhf";
import { zodResolver } from "@azmr/ui/zod";
import { useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import { z } from "zod";

const GST_RATE = 0.15;
const COLUMNS = "gap-3 sm:grid-cols-[minmax(0,1fr)_6rem_8rem_6rem]";
const nzd = new Intl.NumberFormat("en-NZ", { style: "currency", currency: "NZD" });

const line = z.object({
  description: z.string().trim().min(1, "Enter a description"),
  quantity: z.number("Enter a quantity").positive("Enter a quantity above 0"),
  unitPrice: z.number("Enter a unit price").min(0, "Enter a price of $0 or more"),
});

const schema = z.object({
  lines: z.array(line).min(1, "Add at least one line").max(20, "Use 20 lines or fewer"),
});

type InvoiceInput = z.input<typeof schema>;
type InvoiceOutput = z.output<typeof schema>;
type LineInput = InvoiceInput["lines"][number];

/** A line's amount in cents, so totals add up exactly. */
const lineCents = (value: Partial<LineInput> | undefined) =>
  Math.round((value?.quantity ?? 0) * (value?.unitPrice ?? 0) * 100);

function totals(lines: Partial<LineInput>[]) {
  const subtotal = lines.reduce((sum, value) => sum + lineCents(value), 0);
  const gst = Math.round(subtotal * GST_RATE);
  return { subtotal, gst, total: subtotal + gst };
}

function LineAmount({ index, label }: { index: number; label: string }) {
  const value = useWatch<InvoiceInput, `lines.${number}`>({ name: `lines.${index}` });
  return (
    <output
      aria-label={`${label} amount`}
      className="flex h-9 items-center justify-end tabular-nums"
    >
      {nzd.format(lineCents(value) / 100)}
    </output>
  );
}

export default function FieldArrayInvoice() {
  const [saved, setSaved] = useState<string>();
  const form = useForm<InvoiceInput, unknown, InvoiceOutput>({
    resolver: zodResolver(schema),
    defaultValues: {
      lines: [
        { description: "Callout fee", quantity: 1, unitPrice: 95 },
        { description: "Labour (hours)", quantity: 2.5, unitPrice: 88 },
      ],
    },
    shouldFocusError: false,
  });

  return (
    <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`](https://platform.azmara.io/docs/guide/forms.md). 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()`:

```tsx
<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`.

```tsx
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                                                            | Default | Required | Description                                                                                                                                                                                                                                                  |
| ------------- | --------------------------------------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `legend`      | `ReactNode`                                                     |         | Yes      | The group's caption, such as "Line items".                                                                                                                                                                                                                   |
| `description` | `ReactNode`                                                     |         | No       |                                                                                                                                                                                                                                                              |
| `error`       | `ReactNode`                                                     |         | No       | An error for the array as a whole, such as "Add at least one line".                                                                                                                                                                                          |
| `items`       | `readonly unknown[]`                                            |         | Yes      | The rows. Each needs a stable key (see `getKey`) so focus and drag follow the right row.                                                                                                                                                                     |
| `getKey`      | `((item: unknown, index: number) => RowKey) \| undefined`       |         | No       | A row's stable key. Default: the item's `id`, then its `key`. Never the index.                                                                                                                                                                               |
| `name`        | `string`                                                        |         | No       | The array's path in the form values, used by `row.name()`.                                                                                                                                                                                                   |
| `children`    | `(item: unknown, row: FieldArrayRow) => React.ReactNode`        |         | Yes      | Renders one row's fields.                                                                                                                                                                                                                                    |
| `onAdd`       | `(() => void) \| undefined`                                     |         | No       | Adds a row at the end. Shows the add button; focus moves to the new row's first field.                                                                                                                                                                       |
| `onRemove`    | `((index: number) => void) \| undefined`                        |         | No       | Removes the row at `index`. Shows a remove button on each row.                                                                                                                                                                                               |
| `onMove`      | `((from: number, to: number) => void) \| undefined`             |         | No       | Moves a row from one position to another. Shows a drag handle on each row.                                                                                                                                                                                   |
| `min`         | `number`                                                        |         | No       | Fewest rows: remove buttons are disabled at this count. Default 0.                                                                                                                                                                                           |
| `max`         | `number`                                                        |         | No       | Most rows: the add button is disabled at this count.                                                                                                                                                                                                         |
| `empty`       | `ReactNode`                                                     |         | No       | Shown instead of the list when there are no rows.                                                                                                                                                                                                            |
| `header`      | `ReactNode`                                                     |         | No       | Column headings over the rows, for a table-like layout: lay them out on the same grid as each row's fields. Hidden from screen readers, so every field still needs its own label (make it visually hidden with `sr-only`). Not shown when there are no rows. |
| `footer`      | `((items: readonly unknown[]) => React.ReactNode) \| undefined` |         | No       | Totals and other values computed from the rows, shown under them.                                                                                                                                                                                            |
| `itemLabel`   | `((index: number, item: unknown) => string) \| undefined`       |         | No       | A row's name in announcements and button labels. Default "Row 1", "Row 2", ...                                                                                                                                                                               |
| `moveButtons` | `boolean \| undefined`                                          |         | No       | Also show move up and move down buttons on each row. The drag handle's arrow keys already move rows; buttons are easier to find, including for screen reader users in browse mode.                                                                           |
| `disabled`    | `boolean \| undefined`                                          |         | No       |                                                                                                                                                                                                                                                              |
| `messages`    | `Partial<object>`                                               |         | No       |                                                                                                                                                                                                                                                              |
| `className`   | `string`                                                        |         | No       |                                                                                                                                                                                                                                                              |

`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. |
