Form
A form holds the values of the fields inside it, by their name, checks
them, shows each field’s errors through the field, submits and resets. It
goes on your own <form> element. Every UniAria field binds to it: TextField,
NumberField, Checkbox, Switch, CheckboxGroup, RadioGroup, Select, Combobox
and Field around a control of your own.
Typing in a field never renders your component: the form’s store changes,
and UniAria writes the field’s value, aria-invalid and error text to the
page. A component renders only for the form state it selects.
There is no WAI-ARIA pattern for a form. A <form> with a name is a form
landmark: name it by a visible heading.
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The fields' look is @uniaria/styles' default. */
:root {
font:
15px/1.5 system-ui,
sans-serif;
}
body {
display: grid;
gap: 1rem;
margin: 1.5rem;
background: var(--uni-surface-color);
color: var(--uni-text-color);
}
form {
display: grid;
gap: 1rem;
max-inline-size: 20rem;
}
h2 {
margin: 0;
font-size: 1.25rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.submit-error {
margin: 0;
color: var(--uni-danger-text-color);
}
</style><uni-form id="signup">
<form data-uni="form" aria-labelledby="signup-title" novalidate>
<h2 id="signup-title">Sign up</h2>
<uni-text-field id="email" required>
<div data-uni="text-field">
<label data-element="Label"
>Email <span aria-hidden="true">*</span></label
>
<input
data-element="Input"
name="email"
type="email"
autocomplete="email"
/>
<p data-element="Description">Try taken@example.com.</p>
<p data-element="Error"><span data-element="ErrorText"></span></p>
</div>
</uni-text-field>
<uni-text-field id="password" required>
<div data-uni="text-field">
<label data-element="Label"
>Password <span aria-hidden="true">*</span></label
>
<input
data-element="Input"
name="password"
type="password"
minlength="8"
autocomplete="new-password"
/>
<p data-element="Error"><span data-element="ErrorText"></span></p>
</div>
</uni-text-field>
<uni-checkbox id="terms" required>
<div data-uni="checkbox">
<label data-element="Wrapper">
<input data-element="Input" type="checkbox" name="terms" />
<span data-element="Indicator"></span>
<span data-element="Label">I accept the terms</span>
</label>
<p data-element="Error"><span data-element="ErrorText"></span></p>
</div>
</uni-checkbox>
<p data-element="Error" class="submit-error"></p>
<div class="actions">
<uni-button type="submit">
<button data-uni="button" data-variant="primary">Sign up</button>
</uni-button>
<uni-button type="reset">
<button data-uni="button">Reset</button>
</uni-button>
</div>
</form>
</uni-form>
<output id="log">Leave a field, or submit the form empty.</output>
<script src="/demo/form.js"></script><script>// The form holds the values and checks them; the fields show the errors.
const signup = document.getElementById('signup');
const log = document.getElementById('log');
document.getElementById('email').validationMessages = {
valueMissing: 'Enter your email.',
typeMismatch: 'Enter an email like jane@example.com.',
};
document.getElementById('password').validationMessages = {
valueMissing: 'Choose a password.',
tooShort: ({ min }) => `Use at least ${min} characters.`,
};
document.getElementById('terms').validationMessages = {
valueMissing: 'Accept the terms to continue.',
};
// A server that knows one address already. An error it returns for a field
// shows on that field; one for the whole form shows in the form's error.
signup.handleSubmit = async (values, { signal }) => {
log.textContent = 'Signing up…';
await new Promise((resolve) => setTimeout(resolve, 600));
if (signal.aborted) return;
if (values.email === 'taken@example.com') {
signup.setFieldErrors('email', ['This email already has an account.']);
signup.focusInvalid();
log.textContent = 'Fix the marked field.';
return;
}
log.textContent = `Signed up as ${values.email}.`;
};
signup.addEventListener('submitfailed', (event) => {
if (event.detail.status === 'invalid') {
log.textContent = 'Fix the marked fields.';
}
});
signup.addEventListener('valueschange', (event) => {
if (event.detail.kind === 'reset')
log.textContent = 'The form is empty again.';
});
</script>React
import { form } from '@uniaria/core/form';
import { OwnerProvider, useBehavior, useBehaviorState } from '@uniaria/react';
export function SignUp() {
const signup = useBehavior(form, {
defaultValues: { email: '', plan: null },
handleSubmit: (values, { signal }) => api.signUp(values, signal),
});
const error = useBehaviorState(signup, (state) => state.submitError);
return (
<OwnerProvider owner={signup}>
<form {...signup.parts.root} aria-labelledby="signup-title">
<h2 id="signup-title">Sign up</h2>
<EmailField name="email" isRequired validate={checkEmail} />
<PlanSelect name="plan" isRequired />
<p {...signup.parts.error}>{String(error ?? '')}</p>
<button type="submit">Sign up</button>
</form>
</OwnerProvider>
);
}Vue
<script setup lang="ts">
import { form } from '@uniaria/core/form';
import { useBehavior, useBehaviorState } from '@uniaria/vue';
const signup = useBehavior(form, () => ({
defaultValues: { email: '', plan: null },
handleSubmit: (values, { signal }) => api.signUp(values, signal),
}));
const error = useBehaviorState(signup, (state) => state.submitError);
</script>
<template>
<form v-bind="signup.parts.root" aria-labelledby="signup-title">
<h2 id="signup-title">Sign up</h2>
<EmailField name="email" is-required :validate="checkEmail" />
<PlanSelect name="plan" is-required />
<p v-bind="signup.parts.error">{{ error ?? '' }}</p>
<button type="submit">Sign up</button>
</form>
</template>HTML
<!-- Checks, then posts natively to /signup. -->
<uni-form>
<form data-uni="form" action="/signup" method="post" novalidate>
<uni-text-field required>
<label data-element="Label">Email</label>
<input data-element="Input" name="email" type="email" />
<p data-element="Error"><span data-element="ErrorText"></span></p>
</uni-text-field>
<button type="submit">Sign up</button>
</form>
</uni-form>
<script type="module">
import '@uniaria/elements/form';
import '@uniaria/elements/text-field';
</script>Write novalidate on the <form>, in server markup too: the form checks the
fields itself, and without it the browser stops a submit with its own bubbles
before the form sees it. UniAria writes it as well once the form starts.
A field binds to the form while it has a name and finds a form: the
nearest one around it (through OwnerProvider in React, the component tree
in Vue, the page on custom elements), or the one you pass in its form
prop. On custom elements, form takes the id of the <form> element, as
HTML’s form attribute does, and a field reads its name from its native
control’s name attribute. form: null (an empty form attribute on
custom elements) leaves a field inside a form unbound.
A bound field’s value is the form’s value under its name:
| Field | Form value | Checks by default when |
|---|---|---|
| TextField | string |
focus leaves it |
| NumberField | number | null |
focus leaves it |
| Checkbox | boolean |
it changes |
| Switch | boolean |
it changes |
| CheckboxGroup | string[] |
it changes |
| RadioGroup | string | null |
it changes |
| Select | string | null, or string[] with selectionMode: 'multiple' |
it changes |
| Combobox | string | null: the key, or the custom text |
focus leaves it |
| Field | its control’s value, or what its setValue action gives |
focus leaves it |
A Checkbox in a CheckboxGroup and a Radio in a RadioGroup belong to the
group, which binds. A name is one key ('email'), or a path for nested data
(['users', 0, 'email']); a field writes it on its native control as one
key, users.0.email, so a native post sends flat names.
Values live in the form, not in the fields: they exist before their fields
mount and stay after they unmount, out of the payload. A field keeps
reporting its own change event (valueChange, selectedChange) for the
user’s changes; changes that start in the form come out as the form’s
valuesChange. A value you pass to a bound field is ignored.
While bound, a field takes the form’s isDisabled and isReadOnly on top
of its own, and shows the form’s errors unless you set its isInvalid.
A bound field checks, in order, stopping at the first that fails:
- Bad input: text the browser can’t read in a native control.
- Required: with
isRequired, an empty value (null,undefined,'',false,[]) fails. - Native constraints you write on a text input:
pattern,min,max,step,type="email",type="url",minlengthandmaxlength. NumberField checks its ownminValue,maxValueandstep. validate, for every value, empty included.
const email = useBehavior(textField, {
name: 'email',
isRequired: true,
validate: async (value, { signal }) =>
(await api.isTaken(value, signal)) ? 'This email has an account' : null,
});validate returns null, undefined, '' or true when the value is
fine; else an error or a list of them, or a promise of either. A thrown
error counts as one. Its signal aborts once the result can no longer count
(the value changed, the form reset), and a late result is dropped.
A field checks on its trigger: when focus leaves it, or when it changes
(the table above). Set validationTrigger ('change', 'blur' or
'submit') on a field, or on the form for every field. While a field shows
errors, every change checks it again, so an error goes as soon as it is
fixed; a change that doesn’t check clears the field’s last result.
validationDelay waits that many milliseconds first. dependsOn lists
names whose changes check a field again once it has been checked. Disabled
and read-only fields aren’t checked.
A built-in check’s message is the field’s validationMessages, else the
form’s, else the browser’s:
form.validationMessages = {
valueMissing: 'Fill this in',
tooShort: ({ min }) => `Use at least ${min} characters`,
};The keys are valueMissing, badInput, patternMismatch, typeMismatch,
rangeUnderflow, rangeOverflow, stepMismatch, tooShort and tooLong.
A browser’s messages are in the browser’s language and differ between
engines, so set them for a translated app. For tooShort and tooLong the
browser gives none, and the English defaults are “Use at least min
characters” and “Use at most max characters”, with the control’s
minlength or maxlength.
A field shows its errors as soon as it holds them, through its
Field: aria-invalid on the control, data-invalid on every
part, and the error part shown and linked. Put an errorText part inside the
error part and the field writes the first error’s text there:
<p data-element="Error"><span data-element="ErrorText"></span></p>To show something richer, leave errorText out and render errors[0] from
the field’s state.
On submit, the form checks every active field, waiting for async checks, then:
- with
handleSubmit, calls it with the values and waits for it. A throw or a rejection becomessubmitError, which the form’serrorpart shows. - without it, posts the
<form>natively, with itsaction,methodandtarget.
If a check fails, it reports submitFailed, then in the next frame scrolls
to the first invalid field and focuses it. A submit button with
formnovalidate skips the checks. A value that changes while the checks run
makes the submit stale: nothing is sent.
handleSubmit gets the values of the active names, those with a field
mounted now; submitValues: 'all' sends every value the form holds. Its
second argument is { signal, submitter, include, formData }, where
formData is the <form>’s own data, for controls no field binds (a CSRF
token).
signup.actions.setFieldErrors('email', ['This email has an account']);
signup.actions.focusInvalid();A server error shows until the field is checked again, and one set for a
name with no field yet shows when its field mounts. setSubmitError() sets
the form’s error.
A reset button, or the reset() action, puts every value back to its
default, clears errors and touched state, and aborts a running submit. A
reset listener that calls preventDefault() cancels it. reset({ values }) sets new defaults first, for starting a new record.
A name’s default is defaultValues[name], else its field’s own default
(defaultValue, defaultSelected). A name nobody changed follows its
default when defaultValues changes later, or through setDefaultValues().
| Part | Element | Notes |
|---|---|---|
root |
form |
Required: the form is the element. UniAria writes id (unless it has one), novalidate and the state attributes. action, method and the rest are yours. |
error |
any | Optional. The submit error: hidden while there is none. Render its content from submitError. |
On custom elements, <uni-form> goes around your <form>, which carries
data-uni="form".
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
defaultValues |
— (property only) | Record<string, unknown> |
{} |
What names start with and reset goes back to. |
handleSubmit |
— (property only) | (values, context) => unknown |
— | Called with the values once the checks pass; the form waits for a promise. Without it, a valid form posts natively. |
submitValues |
submit-values |
'active' | 'all' |
'active' |
Which values a submit sends. |
validationTrigger |
validation-trigger |
'change' | 'blur' | 'submit' |
per field | When the fields that set none check. |
validationDelay |
validation-delay |
number |
0 |
Milliseconds a change or a blur waits before checking. |
validationMessages |
— (property only) | Partial<Record<MessageKey, Message>> |
— | Messages for the built-in checks. |
isDisabled |
disabled |
boolean |
false |
Disables every bound field. |
isReadOnly |
readonly |
boolean |
false |
Makes every bound field read-only. |
Every field takes these, Field included.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
name |
name; read from the native control |
string | (string | number)[] |
— | The name in the form, written on the native control it submits through. |
form |
form (the <form>’s id) |
a Form’s instance or handle, or null |
nearest form | The form to bind to. |
validate |
— (property only) | (value, { name, values, signal }) => unknown |
— | The field’s own check. |
validationTrigger |
validation-trigger |
'change' | 'blur' | 'submit' |
the form’s | When it checks. |
validationDelay |
validation-delay |
number |
the form’s | Milliseconds it waits before checking. |
validationMessages |
— (property only) | Partial<Record<MessageKey, Message>> |
the form’s | Its messages for the built-in checks. |
dependsOn |
depends-on (JSON) |
Name[] |
[] |
Names whose changes check it again. |
isEqual |
— (property only) | (a, b) => boolean |
shallow | What counts as a change for isDirty. |
shouldPreserve |
preserve |
boolean |
true |
Keeps the value when the field unmounts; false drops it. |
isInvalid |
invalid |
boolean |
— | Unset, the field shows the form’s errors; true or false wins over them. |
| Event | Callback prop | Detail | When |
|---|---|---|---|
submitted |
onSubmitted |
{ values } |
handleSubmit resolved, or the native post went out. |
submitfailed |
onSubmitFailed |
{ status: 'invalid', errors } or { status: 'failed', error } |
The checks failed, or handleSubmit threw. Before focus moves. |
valueschange |
onValuesChange |
{ values, names, source: 'user' | 'program', kind: 'set' | 'reset' | 'defaults' } |
Once per batch of the user’s changes, a reset, or a program write that asked. |
| Field | Type | Meaning |
|---|---|---|
values |
Record<string, unknown> |
Every value the form holds. |
activeValues |
Record<string, unknown> |
The values of names with a field mounted: what a submit sends. |
defaultValues |
Record<string, unknown> |
What a reset goes back to. |
fields |
Record<string, FieldState> |
One entry per name, keyed by its key: { value, defaultValue, errors, status, isTouched, isDirty, isActive }. |
isDirty |
boolean |
Some value differs from its default. |
isTouched |
boolean |
The user changed or left some field. |
isValid |
boolean |
Every active, checked field passed. false with no fields. |
isInvalid |
boolean |
Some active field is invalid. |
isValidating |
boolean |
A check waits or runs. |
isSubmitting |
boolean |
From a submit until handleSubmit settles or the post goes out. |
submitError |
unknown |
What handleSubmit threw, or what setSubmitError() set. |
canReset |
boolean |
A reset would change something. false while submitting. |
isDisabled |
boolean |
The prop. |
isReadOnly |
boolean |
The prop. |
A field’s status is 'unvalidated', 'validating', 'valid' or
'invalid'. values, fields and each field’s entry keep their identity
until they change, so selecting one renders only for it:
const email = useBehaviorState(signup, (state) => state.fields.email);A field’s own state gains value, errors, status, isTouched,
isDirty and isBound.
On custom elements they are methods of <uni-form>: signup.submit().
| Action | Does |
|---|---|
submit({ submitter, include, shouldFocus }) |
Submits as a submit button does. Resolves to { status }: 'submitted', 'invalid' (with errors), 'failed' (with error), 'stale' or 'ignored'. shouldFocus: false moves no focus. |
cancelSubmit() |
Aborts a running submit, which settles 'stale'. |
reset({ values }) |
Resets as a reset button does; values become the defaults first. |
validate(names?) |
Checks those names, or every active one, now. Resolves to { isValid, isStale, errors }. |
focusInvalid() |
Focuses the first invalid field, as a failed submit does. |
setValue(name, value, options) |
Writes a value. options: validation ('auto', 'always', 'never'), source, shouldTouch, shouldNotify. |
setValues(values, options) |
Several at once. |
setDefaultValues(values, { replaceValues }) |
New defaults. replaceValues: 'untouched' (the default), 'clean' or 'all'. |
touch(name, isTouched) |
Marks a name touched, or not. |
setFieldErrors(name, errors) |
Shows errors from the server. |
clearFieldErrors(name?) |
Clears them, for one name or all. |
setSubmitError(error), clearSubmitError() |
The form’s error. |
batch(fn) |
Runs fn’s actions as one change. |
Field gains one action, setValue(value), for a custom control’s change.
| Attribute | On | When |
|---|---|---|
data-uni="form" |
root | Always. |
data-element="Error" |
error | Always. |
data-submitting |
root | From a submit until it settles. |
data-invalid |
root | Some active field is invalid. |
data-dirty |
root | Some value differs from its default. |
data-validating |
root; a bound field’s parts | A check waits or runs. |
data-disabled, data-readonly |
root | The props. |
hidden |
error | There is no submit error. |
A bound field keeps its own attributes: data-invalid on every part while
it shows errors.
Form handles no keys. Enter submits natively: in a text field it presses the form’s first submit button, so a disabled first submit button turns it off. NumberField commits its number and Combobox settles its text first.
- Focus after a failed submit. Focus moves to the first invalid field, in
page order, with the focus ring shown, and the field reads its label, its
error, “invalid” and “required”. A field that can’t take focus (in a
closed
<details>or a hidden tab) is skipped. - Announcements. An error shown when focus leaves a field is announced with the field’s label, since focus is on the next field already. Errors a submit shows aren’t announced one by one: focus reads the first. When focus can’t move (Enter in the field that is wrong), the form announces the first error with its label. A changing error isn’t announced again while its control has focus.
- The submit error in the
errorpart is announced when it shows. Don’t addrole="alert"to it. - A press on Submit right after typing in a field that checks on leaving it submits: the field’s check waits for the press, so its error doesn’t move the button from under the pointer.
- Name the form by a visible heading (
aria-labelledby), so it is a form landmark.
Form follows Cube UI Kit’s modern form, with these differences:
- Focus moves to the first invalid field after a failed submit, as React Aria’s native validation does; UI Kit moves none.
- Native constraints count: a
patternortype="email"you write on an input is checked. UI Kit ignores them. - Without
handleSubmit, a valid form posts natively. UI Kit posts anactionform without checking it. - Disabled and read-only fields aren’t checked, as in HTML and React Aria.
formnovalidateon a submit button skips the checks.- A form with no fields submits.
- Server errors set before a field mounts show when it does.
- The required message is the browser’s unless you set one; UI Kit’s is “Required”.
- Rules are
isRequired, native constraints andvalidate; UI Kit’s rule objects compile intovalidate.
Compared with React Aria, the form works as its validationBehavior: 'aria'
(no browser bubbles, errors shown as soon as they exist) and also reads the
native constraints and focuses the first invalid field. Server errors come
through setFieldErrors, not a validationErrors prop.