Skip to content

Form

Form InfrastructureSince 0.2.0
import { Form } from '@absoluteui/components/form';
import '@absoluteui/components/form/style.css';

Using Angular? See Form for Angular.

State manager and validation orchestrator for a native <form>. Form discovers the AbsoluteUI controls inside the form (including native controls wrapped by Form Field), validates them on submit, runs cross-field rules and an optional async validator (cancellable, with a timeout), shows an Error Summary that links to each invalid field, serializes values, applies server errors, tracks dirty/touched/submitting state, and resets every control consistently. The same entry point also exports the validation engine (checkText, checkNumber, messageFor) and the FormControl contract.

  • Any form built from AbsoluteUI inputs that needs one consistent submit, validation, and error-navigation flow.
  • Rules that span several fields, such as password confirmation or conditional requirements.
  • Server-side or async checks (uniqueness, availability) whose errors must appear on the right fields.
  • Forms that need dirty/touched state for unsaved-changes prompts.
  • A single standalone control with no submission: use the control on its own.
  • Showing a list of errors you already have without a form: use Error Summary directly.
  • Multi-step wizards with per-step persistence: Form manages one <form>; compose one Form per step.
import { Form, type FormSubmitDetail } from '@absoluteui/components/form';
import { TextInput } from '@absoluteui/components/text-input';
import { Button } from '@absoluteui/components/button';
const el = document.createElement('form');
document.body.append(el);
const field = (): HTMLDivElement => {
const host = document.createElement('div');
el.append(host);
return host;
};
TextInput.create(field(), { label: 'Email', name: 'email', type: 'email', required: true, autocomplete: 'email' });
TextInput.create(field(), { label: 'Password', name: 'password', type: 'password', autocomplete: 'new-password', required: true, minLength: 12 });
TextInput.create(field(), { label: 'Confirm password', name: 'confirm', type: 'password', autocomplete: 'new-password', required: true });
const submit = document.createElement('button');
submit.type = 'submit';
submit.textContent = 'Create account';
Button.enhance(submit, { variant: 'primary' });
el.append(submit);
Form.create(el, {
rules: [{ fields: ['password', 'confirm'], validate: (values) => (values.password === values.confirm ? null : 'Passwords do not match.') }],
});
el.addEventListener('aui-submit', (event) => {
const { values } = (event as CustomEvent<FormSubmitDetail>).detail;
el.dataset.account = typeof values.email === 'string' ? values.email : '';
});

Async validation with AbortSignal, then server errors with setErrors

Section titled “Async validation with AbortSignal, then server errors with setErrors”
import { Form, type FormErrors, type FormSubmitDetail } from '@absoluteui/components/form';
import { TextInput } from '@absoluteui/components/text-input';
import { Button } from '@absoluteui/components/button';
const el = document.createElement('form');
document.body.append(el);
const host = document.createElement('div');
el.append(host);
TextInput.create(host, { label: 'Username', name: 'username', required: true, autocomplete: 'username' });
const submit = document.createElement('button');
submit.type = 'submit';
submit.textContent = 'Register';
Button.enhance(submit, { variant: 'primary' });
el.append(submit);
function isFormErrors(value: unknown): value is FormErrors {
return typeof value === 'object' && value !== null && Object.values(value).every((m) => typeof m === 'string');
}
const post = (url: string, body: unknown, signal?: AbortSignal): Promise<Response> =>
fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), ...(signal ? { signal } : {}) });
const form = Form.create(el, {
asyncTimeoutMs: 5000,
// Runs after constraints and cross-field rules pass. Rejecting or timing out reports reason 'async-failed'.
validate: async (values, signal) => {
const response = await post('/api/username-check', values, signal);
if (!response.ok) throw new Error(`Username check failed: ${response.status}`);
const body: unknown = await response.json();
if (!isFormErrors(body)) throw new Error('Unexpected response');
return body;
},
});
// Form prevents double submission only while the async validator runs; guard the save yourself.
el.addEventListener('aui-submit', (event) => {
const { values } = (event as CustomEvent<FormSubmitDetail>).detail;
submit.disabled = true;
post('/api/register', values)
.then(async (response) => {
if (response.status !== 422) return;
const body: unknown = await response.json();
if (isFormErrors(body)) form.setErrors(body);
})
.catch(() => {
// A name that matches no field is listed in the summary without a link.
form.setErrors({ _form: 'Your account could not be created. Try again.' });
})
.finally(() => {
submit.disabled = false;
});
});

Native submission with Form Field-wrapped and plain native controls

Section titled “Native submission with Form Field-wrapped and plain native controls”
import { Form } from '@absoluteui/components/form';
import { FormField } from '@absoluteui/components/form-field';
import { Button } from '@absoluteui/components/button';
const el = document.createElement('form');
el.method = 'post';
el.action = '/contact';
document.body.append(el);
const zip = document.createElement('input');
zip.name = 'zip';
zip.inputMode = 'numeric';
zip.pattern = '[0-9]{5}';
const zipHost = document.createElement('div');
el.append(zipHost);
FormField.create(zipHost, { label: 'ZIP code', control: zip, required: true, description: 'Five digits' });
// Plain named native elements are serialized and reset too, but not validated.
const source = document.createElement('input');
source.type = 'hidden';
source.name = 'source';
source.value = 'footer';
el.append(source);
const submit = document.createElement('button');
submit.type = 'submit';
submit.name = 'intent';
submit.value = 'send';
submit.textContent = 'Send';
Button.enhance(submit, { variant: 'primary' });
el.append(submit);
// After validation passes and aui-submit is not prevented, the form is submitted natively (submitter name/value included).
Form.create(el, { submitMode: 'native', focusOnInvalid: 'first-field' });
import { Form, type FormState } from '@absoluteui/components/form';
import { TextInput } from '@absoluteui/components/text-input';
import { Button } from '@absoluteui/components/button';
const el = document.createElement('form');
document.body.append(el);
const host = document.createElement('div');
el.append(host);
TextInput.create(host, { label: 'Display name', name: 'displayName', value: 'Ada' });
const status = document.createElement('p');
const reset = document.createElement('button');
reset.type = 'reset';
reset.textContent = 'Discard changes';
Button.enhance(reset);
el.append(status, reset);
const form = Form.create(el);
// Emitted only when dirty, touched, submitting, submitCount, or valid actually changes.
el.addEventListener('aui-state-change', (event) => {
const state = (event as CustomEvent<FormState>).detail;
status.textContent = state.dirty ? 'You have unsaved changes.' : '';
});
// The native reset button is intercepted and calls form.reset().
el.addEventListener('aui-reset', () => {
status.textContent = '';
});
window.addEventListener('beforeunload', (event) => {
if (form.state.dirty) event.preventDefault();
});

Reuse the validation engine in a cross-field rule

Section titled “Reuse the validation engine in a cross-field rule”
import { checkText, Form, messageFor, type FormValues } from '@absoluteui/components/form';
import { TextInput } from '@absoluteui/components/text-input';
const el = document.createElement('form');
document.body.append(el);
for (const [label, name] of [['Contact method', 'method'], ['Contact email', 'contactEmail']] as const) {
const host = document.createElement('div');
el.append(host);
TextInput.create(host, { label, name });
}
// Email is required only when the contact method is "email"; messages stay localized.
const emailRule = (values: FormValues): string | null => {
const email = typeof values.contactEmail === 'string' ? values.contactEmail : '';
const outcome = checkText(email, { type: 'email', required: values.method === 'email' });
return outcome.error === null ? null : messageFor(outcome, { locale: 'en-US' });
};
Form.create(el, { rules: [{ fields: ['method', 'contactEmail'], validate: emailRule, target: 'contactEmail' }] });

Focus the first invalid field and shorten the async timeout (Mode B)

Section titled “Focus the first invalid field and shorten the async timeout (Mode B)”
import { configureAbsoluteUI } from '@absoluteui/core';
import '@absoluteui/components/form';
configureAbsoluteUI({ components: { form: { focusOnInvalid: 'first-field', asyncTimeoutMs: 5000 } } });

Custom control joining a Form through registerFormControl

Section titled “Custom control joining a Form through registerFormControl”
import { emit } from '@absoluteui/core';
import { Form, registerFormControl, type ControlValidity, type FormControl } from '@absoluteui/components/form';
import { Button } from '@absoluteui/components/button';
type Priority = 'low' | 'normal' | 'high';
/** A required toggle-button group. It implements the FormControl contract so Form can validate, serialize, reset, and focus it. */
class PriorityPicker implements FormControl {
readonly name = 'priority';
readonly label = 'Priority';
readonly disabled = false;
touched = false;
#value: Priority | null = null;
#customError: string | null = null;
#showErrors = false;
readonly #buttons: HTMLButtonElement[];
readonly #message: HTMLParagraphElement;
readonly #unregister: () => void;
constructor(readonly host: HTMLElement) {
const doc = host.ownerDocument;
const legend = doc.createElement('p');
legend.id = 'priority-label';
legend.textContent = this.label;
this.#message = doc.createElement('p');
this.#message.id = 'priority-message';
this.#buttons = (['low', 'normal', 'high'] as const).map((value) => {
const button = doc.createElement('button');
button.type = 'button';
button.textContent = value;
button.setAttribute('aria-pressed', 'false');
button.setAttribute('aria-describedby', this.#message.id);
button.addEventListener('click', () => {
this.#value = value;
this.#customError = null;
this.#render();
emit(host, 'aui-change', { value });
});
Button.enhance(button, { size: 'sm' });
return button;
});
host.setAttribute('role', 'group');
host.setAttribute('aria-labelledby', legend.id);
host.replaceChildren(legend, ...this.#buttons, this.#message);
host.addEventListener('focusout', () => {
this.touched = true;
});
this.#render();
// Registration makes the host discoverable by any Form that contains it.
this.#unregister = registerFormControl(this);
}
get validity(): ControlValidity {
if (this.#value === null) return { valid: false, message: 'Select a priority.' };
if (this.#customError !== null) return { valid: false, message: this.#customError };
return { valid: true, message: '' };
}
get dirty(): boolean {
return this.#value !== null;
}
getFormValue(): Priority | null {
return this.#value;
}
reportValidity(): boolean {
this.#showErrors = true;
this.#render();
return this.validity.valid;
}
setCustomError(message: string | null): void {
this.#customError = message === '' ? null : message;
this.#render();
}
focus(): void {
this.#buttons[0]?.focus();
}
reset(): void {
this.#value = null;
this.#customError = null;
this.#showErrors = false;
this.touched = false;
this.#render();
}
destroy(): void {
this.#unregister();
this.host.replaceChildren();
}
#render(): void {
for (const button of this.#buttons) button.setAttribute('aria-pressed', String(button.textContent === this.#value));
this.#message.textContent = this.#showErrors ? this.validity.message : '';
}
}
const el = document.createElement('form');
const host = document.createElement('div');
const submit = document.createElement('button');
submit.type = 'submit';
submit.textContent = 'Create ticket';
Button.enhance(submit, { variant: 'primary' });
el.append(host, submit);
document.body.append(el);
new PriorityPicker(host);
Form.create(el);
import { Form, type FormErrors, type FormSubmitDetail } from '@absoluteui/components/form';
import '@absoluteui/components/form/style.css';
const el = document.querySelector<HTMLFormElement>('#signup');
if (el) {
const form = Form.create(el, {
validate: async (values, signal) => {
const response = await fetch('/api/signup/check', { method: 'POST', body: JSON.stringify(values), signal });
if (response.status === 409) return { email: 'This email is already registered.' } satisfies FormErrors;
if (!response.ok) throw new Error(`Check failed: ${response.status}`);
return {};
},
});
el.addEventListener('aui-submit', (event) => {
const { values } = (event as CustomEvent<FormSubmitDetail>).detail;
void fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) }).then((response) => {
if (!response.ok) form.setErrors({ _form: 'Sign-up failed. Try again.' });
});
});
}
import { Form, type FormSubmitDetail } from '@absoluteui/components/form';
import { TextInput } from '@absoluteui/components/text-input';
const el = document.createElement('form');
document.body.append(el);
for (const [label, name] of [['Password', 'password'], ['Confirm password', 'confirm']] as const) {
const host = document.createElement('div');
el.append(host);
TextInput.create(host, { label, name, type: 'password', autocomplete: 'new-password', required: true });
}
const submit = document.createElement('button');
submit.type = 'submit';
submit.textContent = 'Save';
el.append(submit);
Form.create(el, { rules: [{ fields: ['password', 'confirm'], validate: (v) => (v.password === v.confirm ? null : 'Passwords do not match.') }] });
el.addEventListener('aui-submit', (event) => {
const { values } = (event as CustomEvent<FormSubmitDetail>).detail;
el.dataset.saved = String(Object.keys(values).length);
});
<form class="aui-form" novalidate [aria-busy="true"]> (consumer element)
<div class="aui-error-summary" role="region" tabindex="-1"> (inserted on first failed submit, or summaryHost)
<div class="aui-field aui-text-input" data-aui-control> (AbsoluteUI controls, discovered)
<div class="aui-field aui-form-field" data-aui-control> (Form Field-wrapped native controls)
<input name="..."> (plain named native elements: serialized and reset)
<div class="aui-form__actions"><button type="submit"> (optional consumer wrapper)
</form>
Form.create(element: HTMLFormElement, options?: FormOptions): Form

Global configuration key: form (see Configuration).

Option Type Default Description
rules readonly CrossFieldRule[] [] Cross-field rules { fields, validate(values), target? }. Run on validate/submit and live on every edit after the first submit. The first failing rule per target wins.
validate AsyncFormValidator (values, signal) => Promise<FormErrors>. Runs on submit after constraints and rules pass. Rejection or timeout reports reason ‘async-failed’.
summaryTitle string Error summary heading. Default: localized “There is a problem”. Applied when the summary is created, on update(), and on global configuration changes.
summaryHost HTMLElement Element that hosts the error summary. Read only when the summary is first created (on the first failed submission); changing it afterwards has no effect. Default: a <div> inserted as the form’s first child on the first failed submit (removed on destroy).
focusOnInvalid 'summary' | 'first-field' "summary" Where focus moves after a failed submission. Without a summary, focus always goes to the first invalid field. Globally configurable.
errorSummary boolean true Render an error summary on failed submission. When false, messages that belong to no field are announced assertively. Globally configurable.
submitMode 'event' | 'native' "event" ‘event’: emit aui-submit only. ‘native’: then call HTMLFormElement.submit() unless aui-submit was prevented (the submitter’s name/value is included). Globally configurable.
asyncTimeoutMs number 10000 Async validator timeout in ms (integer 100..120000). On timeout the signal aborts with a TimeoutError. Globally configurable.
locale string "en-US" Locale of the summary heading and form-level messages. Globally configurable. Inherited from common configuration.
Property Type Description
element HTMLFormElement The managed native form.
controls FormControl[] Registered controls inside the form, in document order (computed on access).
state FormState Frozen { dirty, touched, submitting, submitCount, valid }; valid ignores disabled controls.
errorSummary ErrorSummary | null The summary, created lazily on the first failed submission; null until then or when errorSummary is false.
Method Description
getValues(): FormValues Frozen values by name: enabled named controls first, then plain named native elements outside controls. Unchecked checkboxes/radios, buttons, files, and :disabled elements (including disabled-fieldset descendants) are skipped; repeated names become arrays.
validate(): boolean Apply rules, show every control’s messages and the summary, and return validity. Does not run the async validator or emit events.
submit(): void requestSubmit(): runs the full submit pipeline.
setErrors(errors: FormErrors): void Apply external (server) errors by field name, show them, and move focus. Unknown names and disabled controls are listed in the summary as plain text without a link. Replaces earlier external errors; each clears when the user edits the field or on the next submit.
clearErrors(): void Clear every rule and external error this form set, and hide the summary.
reset(): void Abort pending async validation, reset every control and plain native element to its initial value, clear state and the summary, emit aui-reset.
update(options: Partial<FormOptions>): void Merge and validate options; summaryTitle and locale are applied to an existing summary, and rules re-run (skipping unrendered targets) if the form was already submitted. Invalid options leave the previous options in place.
registerFormControl(control: FormControl): () => void Standalone export (not a Form method): register a custom control implementing FormControl so any Form containing control.host discovers it. Marks the host with data-aui-control; call the returned function on destroy to unregister.
destroy(): void Abort async validation, remove listeners and subscriptions, destroy the summary (and remove its host if Form created it), and restore the form’s original noValidate value.
Event Detail Bubbles Cancelable Description
aui-submit { values: FormValues } No Yes All validation passed. Prevent it to stop a native submission (submitMode ‘native’).
aui-invalid { errors: readonly FormErrorEntry[]; reason: 'constraints' | 'async' | 'async-failed' } No No Submission stopped: constraint or rule errors, async validator errors, or async validator rejection/timeout.
aui-state-change FormState No No dirty, touched, submitting, submitCount, or valid changed (deduplicated).
aui-reset null No No The form was reset (reset() or a native reset).
Area Description
controls Consumer-owned children: AbsoluteUI controls, Form Field hosts, plain native elements, and buttons.

All rules live in the aui.components cascade layer, so application styles override them without specificity battles.

Class Purpose
aui-form Added to the <form>: single-column grid with a space-4 gap.
aui-form[aria-busy='true'] Async validation in progress (progress cursor).
aui-form__actions Optional consumer wrapper for buttons: wrapping flex row.

Tokens this stylesheet reads (verified in CI). Override them in a theme or with the Theme Designer.

--aui-color-text | --aui-font-family-sans | --aui-space-2 | --aui-space-4

type FocusOnInvalid = 'summary' | 'first-field';
type SubmitMode = 'event' | 'native';
type FormValues = Readonly<Record<string, unknown>>;
type FormErrors = Readonly<Record<string, string>>;
interface CrossFieldRule { readonly fields: readonly string[]; readonly validate: (values: FormValues) => string | null; readonly target?: string }
type AsyncFormValidator = (values: FormValues, signal: AbortSignal) => Promise<FormErrors>;
interface FormState { readonly dirty: boolean; readonly touched: boolean; readonly submitting: boolean; readonly submitCount: number; readonly valid: boolean }
interface FormErrorEntry { readonly name: string | null; readonly label: string; readonly message: string }
interface FormSubmitDetail { values: FormValues }
interface FormInvalidDetail { errors: readonly FormErrorEntry[]; reason: 'constraints' | 'async' | 'async-failed' }

Also exported: registerFormControl(control: FormControl): () => void, FormControl, ControlValidity, ValidationTiming, FieldValidity, ValidationErrorCode, TextConstraints, NumberConstraints, Outcome, MessageContext, and the functions getFormControl, findFormControls, checkText, checkNumber, messageFor.

Pattern: Native <form> with the GOV.UK error summary pattern: validate on submit, then move focus to a summary of links to each invalid field.

Key Context Action
Enter Single-line input focused Native implicit submission: runs the Form submit pipeline.
Tab / Shift+Tab Error summary focused Moves through the summary links, then on into the form.
Enter Summary link focused Moves focus to the invalid control and scrolls it into view.
Enter / Space Reset button focused Native reset, intercepted: every control returns to its initial value.
Element Role
form form (native; exposed as a landmark only when the consumer gives it an accessible name)
error summary region labelled by its heading (see Error Summary)
Attribute Element Value
aria-busy form “true” while the async validator runs; removed when it settles, times out, or is aborted
aria-invalid / aria-describedby controls Set by each control when its message is shown (submit marks every control as submitted)

After a failed submission focus moves to the error summary (default) or, with focusOnInvalid ‘first-field’ or errorSummary false, to the first invalid control. setErrors moves focus the same way. A successful submission does not move focus.

Criterion Level Note
1.3.1 Info and Relationships A
2.1.1 Keyboard A
2.4.3 Focus Order A
3.3.1 Error Identification A
3.3.3 Error Suggestion AA
4.1.2 Name, Role, Value A
4.1.3 Status Messages AA

Automated test results: Accessibility and compliance.

  • The form sets noValidate so native validation bubbles never compete with AbsoluteUI messages; destroy() restores the original value.
  • Focusing the summary makes screen readers read its heading and list once; no live region is used.
  • Messages that belong to no field (unknown setErrors names, async failure) appear in the summary; without a summary they are announced through an assertive live region.
  • Blur/pointer deferral: when focus leaves a field because the user pressed the pointer elsewhere (for example on Submit), the field’s message renders after the pointer is released, so the layout shift does not make the click miss its target.
  • aria-busy is set during async validation; consider a visible status for long checks.
  • Summary focus and announcement have not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • Form does not announce a successful submission; announce the outcome from the aui-submit handler.

pristine, dirty, touched, validating (submitting, aria-busy), submitted (submitCount > 0), invalid (summary shown), valid.

From Trigger To
pristine user edits a control dirty; aui-state-change
any submit with invalid controls or failing rules invalid; summary shown; aui-invalid (constraints)
valid constraints submit with validate submitting (aria-busy)
submitting validator resolves {} aui-submit; native submit if submitMode: 'native'
submitting validator returns errors invalid; aui-invalid (async)
submitting rejection or timeout invalid; form-level message; aui-invalid (async-failed)
submitting reset() or destroy() validator aborted; result discarded
submitted user edits any field rules re-run live
any reset() or native reset pristine, untouched, submitCount 0; aui-reset
  • noValidate is set so native bubbles never compete with AbsoluteUI messages (the original value is restored on destroy()).

  • Focus moves to the summary after a failed submit, so screen readers read the heading and list once, without a live region. focusOnInvalid: 'first-field' focuses the first invalid control instead.

  • Messages that belong to no field appear in the summary; with errorSummary: false they are announced through an assertive live region.

  • Blur/pointer deferral (markTouched): when focus leaves a field because the user pressed the pointer elsewhere, for example on Submit, the field’s message renders after the pointer is released. Rendering on pointerdown shifts the layout and makes the pending click miss the button (covered by a regression test).

  • A successful submission is not announced; announce the outcome in your aui-submit handler.

  • Manual screen-reader verification is pending (see Known limitations).

  • Error summary links name their field: each item is the localized message form.summaryItem ('{label}: {message}'), for example “Email: This field is required.” Override it per locale with registerMessages('form', locale, { summaryItem: ... }). Messages set with setErrors for unknown or disabled fields are listed as given.

Single-column grid; .aui-form__actions wraps. Consumer layout rules override both (layered styles).

No direction-specific layout. The summary and controls use logical properties.

Uses only text color, font, and spacing tokens. The summary and controls provide their own themed states.

configureAbsoluteUI({ components: { form: { focusOnInvalid: 'first-field', asyncTimeoutMs: 5000 } } });

Values are read at each submission. A global locale change updates an existing summary.

Instance options override global and theme defaults. rules, validate, summaryTitle, locale, and the global options are dynamic through update(). summaryHost is read only when the summary is first created (on the first failed submission); set it before then.

Not applicable. Date controls serialize their own ISO strings.

None on the form. The summary uses the Error Summary default icon (alert-circle).

Summary items and announcements use textContent. setErrors messages are shown as text; do not echo raw user input into them. Form performs no network requests; the async validator is consumer code and should honour its AbortSignal. CSP-safe.

Values stay in memory. Nothing is persisted or sent unless submitMode: 'native' submits the form or your handler sends values. Disabled controls and :disabled elements (including those inside a disabled <fieldset>) are never serialized.

Five listeners on the form (submit, reset, aui-input, aui-change, focusout) and one configuration subscription. controls is a querySelectorAll on access; validation and serialization are linear in the number of controls. After the first submit, rules re-run on every edit. Budget: validate() over 200 invalid Text Inputs completes in under 150 ms in headless Chromium, including rendering 200 messages and moving focus. About 25 ms is script; the rest is the browser layout for the new messages.

200 controls in the performance test; unit tests cover forms with up to six controls plus plain elements.

  • A second submit while the async validator runs is ignored; submitCount does not change.
  • A validator that ignores its signal still settles on timeout: the form stops waiting and reports async-failed.
  • A rule returning '' counts as valid. When several rules target one field, the first failing rule’s message wins.
  • A rule targeting a field that is not a named control throws FORM_RULE_TARGET from validate() and submit; live re-checks after the first submit skip it.
  • Errors returned for a disabled control are listed in the summary as plain text, without a link.
  • setErrors with only unknown names shows a summary with plain-text items and no links.
Code Category Cause
FORM_HOST configuration element is not an HTMLFormElement.
FORM_OPTIONS configuration Rule without non-empty string fields or a validate function, non-function validate, or non-element summaryHost.
FORM_RULE_TARGET configuration On validate() or submit, a failing rule’s target (or last field) is not a named control in the form.
CFG_INVALID_VALUE configuration Invalid value for a global or instance config option (for example asyncTimeoutMs outside 100..120000).
aui-invalid reason validation constraints, async, or async-failed.

A failed construction leaves the form untouched (noValidate unchanged, no listeners).

  • Double submission is blocked only while the async validator runs.
  • Plain native elements are serialized and reset but not validated.
  • File inputs are not serialized by getValues().
  • Summary focus and announcements have not yet been manually verified with NVDA, JAWS, and VoiceOver.
  • Nested forms or several Form instances on one element.
  • Native browser validation bubbles.
  • HTML in error messages.