# Form layout

> Fieldset, FormSection and ErrorSummary structure long forms and help people fix every error after a failed submit.

Source: https://platform.azmara.io/docs/components/form

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

## Usage

```tsx
import { ErrorSummary, Fieldset, FormSection } from "@azmr/ui/form";
```

These three build on [Field](https://platform.azmara.io/docs/components/field.md): `Fieldset` groups related fields, `FormSection` splits a long form into titled parts, and `ErrorSummary` lists every error at the top of the form after a failed submit.

## Fieldset

```tsx
import { Field } from "@azmr/ui/field";
import { Fieldset } from "@azmr/ui/form";
import { Input } from "@azmr/ui/input";

export default function FormFieldset() {
  return (
    <Fieldset
      legend="Postal address"
      description="Where we send printed statements."
      className="w-full max-w-md"
    >
      <Field label="Street address">
        <Input name="street" autoComplete="street-address" />
      </Field>
      <Field label="Suburb">
        <Input name="suburb" autoComplete="address-level3" />
      </Field>
      <div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-4">
        <Field label="Town or city">
          <Input name="city" autoComplete="address-level2" />
        </Field>
        <Field label="Postcode">
          <Input name="postcode" autoComplete="postal-code" inputMode="numeric" />
        </Field>
      </div>
    </Fieldset>
  );
}
```

`Fieldset` renders a real `<fieldset>`, named by its `legend`. Use it when the fields only make sense together, such as the parts of an address or the options in a group of checkboxes or radios. Screen readers read the legend before the label of each control inside it.

`description` and `error` are read with the legend through `aria-describedby`. `error` is for a problem with the group as a whole, such as "Choose a delivery option"; errors that belong to one field go on its `Field`. Like on `Field`, the error sits in a polite live region, and `undefined`, `null`, `false` and `""` all mean no error. `disabled` disables every control inside.

Use `FieldsetRoot` and `FieldsetLegend` for a layout `Fieldset` doesn't cover.

## Error summary

```tsx
"use client";

import { Button } from "@azmr/ui/button";
import { Field } from "@azmr/ui/field";
import { ErrorSummary } from "@azmr/ui/form";
import { Input } from "@azmr/ui/input";
import { type FormEvent, useState } from "react";

type Errors = Partial<Record<"name" | "email", string>>;

function validate(form: HTMLFormElement): Errors {
  const data = new FormData(form);
  const errors: Errors = {};
  if (String(data.get("name")).trim() === "") errors.name = "Enter your full name";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(data.get("email")))) {
    errors.email = "Enter an email address like aroha@example.co.nz";
  }
  return errors;
}

export default function FormErrorSummary() {
  const [errors, setErrors] = useState<Errors>({});
  const [submitCount, setSubmitCount] = useState(0);
  const [sent, setSent] = useState(false);

  function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const found = validate(event.currentTarget);
    setErrors(found);
    setSubmitCount((count) => count + 1);
    setSent(Object.keys(found).length === 0);
  }

  return (
    <form noValidate onSubmit={onSubmit} className="flex w-full max-w-sm flex-col gap-4">
      <ErrorSummary errors={errors} submitCount={submitCount} />
      <Field label="Full name" required error={errors.name}>
        <Input name="name" autoComplete="name" />
      </Field>
      <Field label="Email" required error={errors.email}>
        <Input name="email" type="email" autoComplete="email" />
      </Field>
      <div className="flex items-center gap-3">
        <Button type="submit">Continue</Button>
        <p role="status" className="text-sm text-muted-foreground">
          {sent ? "Saved." : ""}
        </p>
      </div>
    </form>
  );
}
```

`ErrorSummary` follows the [GOV.UK error summary](https://design-system.service.gov.uk/components/error-summary/). Put it at the top of the form. After a failed submit it takes focus, so keyboard and screen reader users start from the list of problems, and each error is a link that moves focus to its field and scrolls its label into view. It renders nothing while there are no errors. Submit the form above while it is empty.

Pass `errors` in any of these shapes:

- an object keyed by field name, such as `{ email: "Enter an email address" }`;
- React Hook Form's `formState.errors`, including nested groups (`address.city`) and field arrays (`lines.1.amount`);
- Base UI Form's `errors`, where each field has a list of messages (the first is shown);
- a list of `{ name, message, targetId? }`, when you need to control the order or link to a control by `id`.

Links find their field by `name` inside the same form. Hidden inputs are skipped, so a radio group or select whose `name` sits on a hidden input still gets focus on its visible control. An error with no message can't be listed, so give every rule a message. A `root` error (React Hook Form's form-level errors, such as `errors.root.server`) is listed as plain text, since there is no field to go to.

Pass `submitCount` to bring focus back to the summary on every failed submit. Without it the summary takes focus only the first time it shows errors. Either way it never takes focus while someone is fixing a field and the errors change under them.

Keep each field's own `error` as well: the summary says what is wrong across the form, and the message beside the field says it again where it is fixed.

### With react-hook-form

```tsx
import { useForm } from "react-hook-form";
import { Button } from "@azmr/ui/button";
import { Field } from "@azmr/ui/field";
import { ErrorSummary } from "@azmr/ui/form";
import { Input } from "@azmr/ui/input";

type Values = { name: string; email: string };

export function ProfileForm({ onSave }: { onSave: (values: Values) => void }) {
  const {
    register,
    handleSubmit,
    formState: { errors, submitCount },
  } = useForm<Values>({ shouldFocusError: false });

  return (
    <form noValidate onSubmit={handleSubmit(onSave)}>
      <ErrorSummary errors={errors} submitCount={submitCount} />
      <Field label="Name" required error={errors.name?.message}>
        <Input {...register("name", { required: "Enter your name" })} />
      </Field>
      <Field label="Email" required error={errors.email?.message}>
        <Input type="email" {...register("email", { required: "Enter your email address" })} />
      </Field>
      <Button type="submit">Save</Button>
    </form>
  );
}
```

`shouldFocusError: false` stops React Hook Form moving focus to the first field, so focus lands on the summary instead. The summary would take focus anyway, but turning it off avoids a second focus move.

## A long settings form

```tsx
"use client";

import { Button } from "@azmr/ui/button";
import { Field } from "@azmr/ui/field";
import { ErrorSummary, Fieldset, FormSection } from "@azmr/ui/form";
import { Input, Textarea } from "@azmr/ui/input";
import { Separator } from "@azmr/ui/layout";
import { type FormEvent, useState } from "react";

type Name = "business" | "gst" | "email" | "phone" | "city" | "postcode" | "reminders";
type Errors = Partial<Record<Name, string>>;

// Validate on submit, in the order the fields appear, so the summary lists them top to bottom.
function validate(data: FormData): Errors {
  const value = (name: string) => String(data.get(name) ?? "").trim();
  const errors: Errors = {};
  if (value("business") === "") errors.business = "Enter your business name";
  if (value("gst") && !/^\d{3}-?\d{3}-?\d{3}$/.test(value("gst"))) {
    errors.gst = "Enter a GST number with 9 digits, like 123-456-789";
  }
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value("email"))) {
    errors.email = "Enter an email address like accounts@example.co.nz";
  }
  if (value("phone") && !/^\+?[\d\s-]{7,}$/.test(value("phone"))) {
    errors.phone = "Enter a phone number, like 09 123 4567";
  }
  if (value("city") === "") errors.city = "Enter a town or city";
  if (!/^\d{4}$/.test(value("postcode"))) errors.postcode = "Enter a 4-digit postcode";
  if (data.getAll("reminders").length === 0) {
    errors.reminders = "Choose at least one way to send reminders";
  }
  return errors;
}

const REMINDERS = [
  { value: "email", label: "Email", checked: true },
  { value: "sms", label: "Text message", checked: false },
  { value: "post", label: "Post", checked: false },
];

export default function FormSettings() {
  const [errors, setErrors] = useState<Errors>({});
  const [submitCount, setSubmitCount] = useState(0);
  const [saved, setSaved] = useState(false);

  function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const found = validate(new FormData(event.currentTarget));
    setErrors(found);
    setSubmitCount((count) => count + 1);
    setSaved(Object.keys(found).length === 0);
  }

  return (
    <form noValidate onSubmit={onSubmit} className="flex w-full flex-col gap-8">
      <ErrorSummary errors={errors} submitCount={submitCount} />

      <FormSection
        title="Business details"
        description="Printed on every quote and invoice."
        layout="aside"
        columns={2}
      >
        <Field label="Business name" required error={errors.business} className="@lg:col-span-2">
          <Input name="business" autoComplete="organization" />
        </Field>
        <Field
          label="GST number"
          description="Leave blank if you aren't registered."
          error={errors.gst}
        >
          <Input name="gst" inputMode="numeric" placeholder="123-456-789" />
        </Field>
        <Field label="NZBN" description="Optional.">
          <Input name="nzbn" inputMode="numeric" />
        </Field>
      </FormSection>

      <Separator />

      <FormSection
        title="Contact"
        description="Customers reply to this address."
        layout="aside"
        columns={2}
      >
        <Field label="Accounts email" required error={errors.email}>
          <Input name="email" type="email" autoComplete="email" />
        </Field>
        <Field label="Phone" error={errors.phone}>
          <Input name="phone" type="tel" autoComplete="tel" />
        </Field>
        <Fieldset legend="Postal address" className="@lg:col-span-2">
          <Field label="Street address">
            <Input name="street" autoComplete="street-address" />
          </Field>
          <div className="grid grid-cols-[minmax(0,2fr)_minmax(0,1fr)] gap-4">
            <Field label="Town or city" required error={errors.city}>
              <Input name="city" autoComplete="address-level2" />
            </Field>
            <Field label="Postcode" required error={errors.postcode}>
              <Input name="postcode" autoComplete="postal-code" inputMode="numeric" />
            </Field>
          </div>
        </Fieldset>
      </FormSection>

      <Separator />

      <FormSection
        title="Payment reminders"
        description="Sent when an invoice is 7 days overdue."
        layout="aside"
      >
        <Fieldset
          legend="Send reminders by"
          description="Choose all that apply."
          error={errors.reminders}
        >
          <div className="flex flex-col gap-2">
            {REMINDERS.map((option) => (
              <label key={option.value} className="flex items-center gap-2 text-sm">
                <input
                  type="checkbox"
                  name="reminders"
                  value={option.value}
                  defaultChecked={option.checked}
                  className="size-4 accent-primary"
                />
                {option.label}
              </label>
            ))}
          </div>
        </Fieldset>
        <Field label="Reminder note" description="Added to the bottom of each reminder.">
          <Textarea name="note" placeholder="Kia ora, a friendly reminder that…" />
        </Field>
      </FormSection>

      <div className="flex items-center gap-3">
        <Button type="submit">Save settings</Button>
        <p role="status" className="text-sm text-muted-foreground">
          {saved ? "Settings saved." : ""}
        </p>
      </div>
    </form>
  );
}
```

`FormSection` gives each part of a long form a heading and an optional description, and lays out its fields:

- `columns={2}` puts the fields in two columns once the section is about 32rem wide. Give a field `className="@lg:col-span-2"` to span both.
- `layout="aside"` puts the heading and description beside the fields once the section is about 48rem wide.

Both respond to the section's own width (a container query), not the viewport, so a section in a narrow card or side panel stays in one column. Each section is a region named by its heading; set `headingLevel` (2, 3 or 4) to fit the page's outline.

## Props

### Fieldset

| Prop          | Type                   | Default | Required | Description                                                                                                                                                           |
| ------------- | ---------------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled`    | `boolean \| undefined` |         | No       |                                                                                                                                                                       |
| `className`   | `string`               |         | No       |                                                                                                                                                                       |
| `legend`      | `ReactNode`            |         | Yes      | The caption for the group, for example "Delivery address".                                                                                                            |
| `description` | `ReactNode`            |         | No       |                                                                                                                                                                       |
| `error`       | `ReactNode`            |         | No       | An error for the group as a whole, such as "Choose a delivery option" on a radio group. Any value other than `undefined`, `null`, `false` or `""` counts as an error. |
| `children`    | `ReactNode`            |         | Yes      | The grouped controls, usually `Field`s, radios or checkboxes.                                                                                                         |

### FormSection

| Prop           | Type                                | Default     | Required | Description                                                                                                                                            |
| -------------- | ----------------------------------- | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `className`    | `string`                            |             | No       |                                                                                                                                                        |
| `title`        | `ReactNode`                         |             | Yes      | The section heading. It also names the section for screen reader landmark lists.                                                                       |
| `description`  | `ReactNode`                         |             | No       |                                                                                                                                                        |
| `columns`      | `1 \| 2 \| undefined`               | `1`         | No       | `2` lays the fields out in two columns once the section is wide enough (about 32rem). Give a field `className="@lg:col-span-2"` to span both.          |
| `layout`       | `"aside" \| "stacked" \| undefined` | `"stacked"` | No       | `"aside"` puts the heading and description beside the fields once the section is wide enough (about 48rem), the usual layout for a long settings page. |
| `headingLevel` | `2 \| 3 \| 4 \| undefined`          | `2`         | No       | The heading level, so the section fits the page's outline.                                                                                             |
| `children`     | `ReactNode`                         |             | Yes      |                                                                                                                                                        |

### ErrorSummary

| Prop          | Type                                      | Default                | Required | Description                                                                                                                                                                              |
| ------------- | ----------------------------------------- | ---------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `className`   | `string`                                  |                        | No       |                                                                                                                                                                                          |
| `errors`      | `ErrorSummaryErrors \| null \| undefined` |                        | Yes      | The errors to list. Nothing renders while there are none.                                                                                                                                |
| `title`       | `ReactNode`                               | `"There is a problem"` | No       |                                                                                                                                                                                          |
| `description` | `ReactNode`                               |                        | No       |                                                                                                                                                                                          |
| `submitCount` | `number`                                  |                        | No       | The number of submit attempts, such as React Hook Form's `formState.submitCount`. The summary takes focus when it first shows errors, and again each time this changes while it has any. |

`toErrorSummaryItems(errors)` turns any of the supported shapes into the list the summary shows, for a custom summary or a count in the page title.

## Accessibility

- `Fieldset` is a native `<fieldset>` named by its legend; its description and group error are read through `aria-describedby`, and group errors are announced through a polite live region.
- `FormSection` is a `<section>` named by its heading, so it shows up in screen readers' landmark and heading lists.
- `ErrorSummary` has `role="alert"`, is named by its heading and takes focus after a failed submit, helping meet WCAG 3.3.1 (Error Identification) and 3.3.3 (Error Suggestion).
- Each summary link moves focus to its field and scrolls the field's label, or a group's legend, into view.
- Errors are never shown by colour alone: each is text, linked from the summary and repeated beside its field.

| Key              | Action                                                                          |
| ---------------- | ------------------------------------------------------------------------------- |
| `Tab`            | From the focused summary, moves through its error links, then on into the form. |
| `Enter`          | On an error link, moves focus to that field.                                    |
| `Shift` or `Tab` | Moves focus to the previous link or control.                                    |
