@fillament/a11y
v0.2.0
Published
Form-flow accessibility for Fillament: focus the first error on a failed submit, announce validation state via an ARIA live region, and render a focusable error summary with jump-to-field links. The form-level a11y the dumb primitives make you hand-wire.
Maintainers
Readme
@fillament/a11y
Form-flow accessibility for Fillament. React Aria and friends own input primitives — labels, aria-invalid, per-field error wiring (Fillament's Field does this already). What nobody hands you is the form-level flow: where does focus go when a submit fails, how do screen-reader users hear that it failed, and how do they jump to the offending fields. This package is that layer.
pnpm add @fillament/a11yTree-shakeable, side-effect-free. Drives entirely off the form's submitCount + validation state — no changes to @fillament/core or @fillament/react, and it resolves fields through the id / data-fillament-field attributes the default Field renderer already emits, so it works with zero wiring.
Three drop-ins
import { Form, Field, useForm } from "@fillament/react";
import { FocusFirstError, FormErrorAnnouncer, FormErrorSummary } from "@fillament/a11y";
function CheckoutForm() {
const form = useForm({ schema, defaultValues });
return (
<Form form={form} onSubmit={save}>
<FormErrorSummary form={form} /> {/* focusable summary with jump links */}
<FormErrorAnnouncer form={form} /> {/* visually-hidden ARIA live region */}
<FocusFirstError form={form} /> {/* focus first bad field on failed submit */}
<Field name="email" label="Email" type="email" required />
<Field name="card.number" label="Card number" required />
<button type="submit">Pay</button>
</Form>
);
}| Component | What it does | ARIA |
| --- | --- | --- |
| <FocusFirstError form /> | On every failed submit, moves focus to the first invalid field in DOM order. | — (focus only) |
| <FormErrorAnnouncer form /> | Announces validation state on submit so screen-reader users hear what sighted users see. | visually-hidden aria-live region (role="alert" / "status") |
| <FormErrorSummary form /> | A focusable summary box (the GOV.UK pattern): a heading + a list of errors as links that jump focus to each field; takes focus itself on a failed submit. | role="alert", tabIndex={-1}, aria-labelledby |
Pick a focus strategy.
<FocusFirstError>sends focus straight to the field;<FormErrorSummary>(withfocusOnSubmit, the default) sends focus to the summary so the user can review all errors first. They're alternatives — using both means they compete for focus. Most teams pick the summary for long forms, focus-first for short ones.
How it knows when a submit fails
submitCount is bumped at submit start, so these components wait for the submit to settle (validation done, isSubmitting back to false) before acting — once per completed submit, never on a bare form.validate(). That logic is exported as onSubmitSettled(form, handler) if you want to build your own.
How it finds the field
The default Field renderer emits id={name} on the control and data-fillament-field={name} on the wrapper. Resolution order:
- an element whose
idis the field path (text / select / textarea / checkbox); - the first focusable inside
[data-fillament-field="<path>"](radio groups, custom components).
Custom markup that keeps either convention works. If your layout has no <form> element, pass getContainer to scope lookups.
Options
<FocusFirstError> / useFocusFirstError(form, options)
| Option | Default | Description |
| --- | --- | --- |
| disabled | false | Turn the behavior off. |
| scroll | true | Scroll the field into view (true / false / ScrollIntoViewOptions). |
| getContainer | nearest <form> | Override the DOM root to search. |
The hook returns a ref to attach to an element inside the form (used only to locate the surrounding <form>); the component attaches a hidden marker for you.
<FormErrorAnnouncer>
| Option | Default | Description |
| --- | --- | --- |
| politeness | "assertive" | "assertive" (role="alert") interrupts; "polite" (role="status") waits. |
| getMessage | "N errors found. <first>." | Build the failed-submit message from { errors }. |
| successMessage | — | Announced on a successful submit (silent by default). |
<FormErrorSummary>
| Option | Default | Description |
| --- | --- | --- |
| heading | "There is a problem" | Summary heading content. |
| headingLevel | 2 | 2 | 3 | 4. |
| getLabel | <label> text → humanized path | Resolve the link label for a field path. |
| focusOnSubmit | true | Move focus to the summary on failed submit. |
| scroll | true | Scroll behavior when a link is followed. |
| className, getContainer | — | Style hook; DOM-root override. |
The summary renders nothing while the form is valid. It carries no built-in styling beyond layout-neutral semantics — bring your own className.
Also exported
| Export | Purpose |
| --- | --- |
| useFocusFirstError(form, options) | Hook behind <FocusFirstError>. |
| useFormErrorList(form) | Live { path, message }[] of current errors — build your own summary UI. |
| useFormStateSnapshot(form) | Re-render on form-state change; returns the current FormState. |
| onSubmitSettled(form, handler) | Subscribe to completed submits (valid or invalid). |
| controlForField(root, path), firstInvalidControl(root, errorPaths), wrapperForField(root, path) | The DOM resolvers. |
| visuallyHidden | The screen-reader-only style object. |
Pairs naturally with @fillament/agent: humans get focus + announcements, agents get the contract and structured errors — one validation engine underneath.
License
MIT © headlessButSmart
