AZMARA PLATFORM
Components

Form layout

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

View as Markdown for coding assistants, or see llms.txt.

Usage

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

These three build on Field: 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

Preview theme
Preview density
Postal address

Where we send printed statements.

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

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

Preview theme
Preview density

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

ErrorSummary follows the GOV.UK 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

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

Preview theme
Preview density

Business details

Printed on every quote and invoice.

Leave blank if you aren't registered.

Optional.


Contact

Customers reply to this address.

Postal address

Payment reminders

Sent when an invoice is 7 days overdue.

Send reminders by

Choose all that apply.

Added to the bottom of each reminder.

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

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

FormSection

Prop

Type

ErrorSummary

Prop

Type

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.
Keyboard interactions
KeyAction
TabFrom the focused summary, moves through its error links, then on into the form.
EnterOn an error link, moves focus to that field.
ShiftorTabMoves focus to the previous link or control.