Field
A field gives a form control its label, description and error message, and
links them with ids and ARIA, so a screen reader reads the label, the
description, the error and the required and invalid states when the control
gets focus. In a Form, it also binds the control’s value to the
form by its name. Field renders no control: its control part goes on
yours.
Every UniAria form component already has Field inside, with its own label,
description and error parts: a TextField, a RadioGroup or a Combobox
needs no Field around it (see In UniAria components).
Use Field itself for a control UniAria doesn’t have:
- A native control UniAria has no component for: a
<select>, or a date, color or file<input>. - A third-party widget, such as a date picker or a rich text editor (see A control that isn’t native).
- A control of your own. Compose Field into its behavior, and it gets the labelling and the form binding (see A field of your own).
Field shows validation state. In a form, the form checks the field and gives
Field its errors; without one, your code decides when a field is invalid and
passes isInvalid. See Forms for the whole picture.
There is no WAI-ARIA pattern for a field. Field follows the APG practice on names and descriptions.
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;
justify-items: start;
max-inline-size: 20rem;
}
form > uni-field {
justify-self: stretch;
}
</style><form id="order" novalidate>
<uni-field id="country-field" required>
<div data-uni="field">
<label data-element="Label"
>Country <span aria-hidden="true">*</span></label
>
<select data-element="Control" id="country" name="country">
<option value="">Choose a country</option>
<option value="de">Germany</option>
<option value="fr">France</option>
<option value="jp">Japan</option>
</select>
<p data-element="Description">We ship your order there.</p>
<p data-element="Error" hidden>Choose a country.</p>
</div>
</uni-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" hidden>Accept the terms to continue.</p>
</div>
</uni-checkbox>
<uni-button type="submit">
<button data-uni="button" data-variant="primary">Order</button>
</uni-button>
</form>
<output id="log">Submit the form empty to see the errors.</output>
<script src="/demo/field.js"></script><script>// Field shows validation state; the form decides it. This form checks on
// submit, then again on every change.
const form = document.getElementById('order');
const countryField = document.getElementById('country-field');
const country = document.getElementById('country');
const terms = document.getElementById('terms');
const log = document.getElementById('log');
let wasSubmitted = false;
function validate() {
countryField.toggleAttribute('invalid', country.value === '');
terms.toggleAttribute('invalid', !form.elements.terms.checked);
return (
!countryField.hasAttribute('invalid') && !terms.hasAttribute('invalid')
);
}
form.addEventListener('submit', (event) => {
event.preventDefault();
wasSubmitted = true;
log.textContent = validate()
? `Ordered for ${country.selectedOptions[0].textContent}.`
: 'Fix the marked fields.';
});
const revalidate = () => {
if (wasSubmitted) validate();
};
country.addEventListener('change', revalidate);
terms.addEventListener('selectedchange', revalidate);
</script>React
import { field } from '@uniaria/core/field';
import { useBehavior } from '@uniaria/react';
export function CountryField(props: { error?: string }) {
const country = useBehavior(field, {
name: 'country',
isRequired: true,
isInvalid: props.error !== undefined,
});
return (
<div {...country.parts.root}>
<label {...country.parts.label}>
Country <span aria-hidden="true">*</span>
</label>
<select {...country.parts.control}>
<option value="">Choose a country</option>
<option value="de">Germany</option>
<option value="fr">France</option>
</select>
<p {...country.parts.description}>We ship your order there.</p>
<p {...country.parts.error}>{props.error}</p>
</div>
);
}Vue
<script setup lang="ts">
import { field } from '@uniaria/core/field';
import { useBehavior } from '@uniaria/vue';
const props = defineProps<{ error?: string }>();
const country = useBehavior(field, () => ({
name: 'country',
isRequired: true,
isInvalid: props.error !== undefined,
}));
</script>
<template>
<div v-bind="country.parts.root">
<label v-bind="country.parts.label">
Country <span aria-hidden="true">*</span>
</label>
<select v-bind="country.parts.control">
<option value="">Choose a country</option>
<option value="de">Germany</option>
<option value="fr">France</option>
</select>
<p v-bind="country.parts.description">We ship your order there.</p>
<p v-bind="country.parts.error">{{ error }}</p>
</div>
</template>HTML
<uni-field id="country-field" required>
<label data-element="Label">Country <span aria-hidden="true">*</span></label>
<select data-element="Control" id="country" name="country">
<option value="">Choose a country</option>
<option value="de">Germany</option>
<option value="fr">France</option>
</select>
<p data-element="Description">We ship your order there.</p>
<p data-element="Error" hidden>Choose a country.</p>
</uni-field>
<script type="module">
import '@uniaria/elements/field';
const country = document.querySelector('#country');
country.addEventListener('change', () => {
document
.querySelector('#country-field')
.toggleAttribute('invalid', country.value === '');
});
</script>parts.control holds what Field writes on your control: its id (unless it
has one), aria-labelledby, aria-describedby, aria-invalid,
aria-required and name, and listeners that follow its value and focus.
The rest of the control is yours: its options, type, multiple,
disabled.
Give the name to Field, not to the control. Field writes it on the control,
so a native form post sends it. In React and Vue, Field doesn’t read the
control’s attributes: a name written only on the <select> leaves the field
without one, and in a Form it doesn’t bind. The custom element
is the exception: <uni-field> reads the name from its control’s name
attribute.
Render the error part every time: Field hides it with hidden while the
field is valid.
When isInvalid turns on, Field shows the error, links it to the control and
announces its text, as role="alert" would. While the error stays shown, new
text in it is announced too, so one error part can say “Enter your email” and
then “Enter a valid email”. The timing is yours: validate on submit, on blur,
or as the user types.
An error already shown when the field starts isn’t announced: the screen reader reads it when the control gets focus. Nothing is announced while the field is disabled, or for an error part that is missing or empty.
In a Form, leave isInvalid unset: the form checks the field
(isRequired, the native constraints on the control, then validate) and
gives Field its errors. Field follows the control’s value, and an errorText
part inside the error shows the first error:
const country = useBehavior(field, { name: 'country', isRequired: true });
<p {...country.parts.error}>
<span {...country.parts.errorText} />
</p>;The field binds to the form around it (through OwnerProvider in React), or
to the one in its form prop. While the form controls the value, Field writes
it back to the control, a <select multiple>’s options included. See
Field props for the form.
A widget that isn’t a native control (a date picker from another library, a rich text editor) needs two things from you:
- The label as another element. Only a native control can be labelled by
a
<label>. Render the label as a<span>and pass its tag aslabelElementType. The label then gets nofor, and names the control througharia-labelledbyonly. A click on it doesn’t focus the control. - The value. Field can’t read a widget’s value. Report each change with
Field’s
setValueaction, and render the value from Field’s state: the form’s while bound.
React
import { field } from '@uniaria/core/field';
import { useBehavior, useBehaviorState } from '@uniaria/react';
export function DueDateField() {
const due = useBehavior(field, {
name: 'due',
isRequired: true,
labelElementType: 'span',
});
const value = useBehaviorState(due, (state) => state.value as Date | null);
return (
<div {...due.parts.root}>
<span {...due.parts.label}>Due date</span>
{/* It must pass the props to its focusable element. */}
<DatePicker
{...due.parts.control}
value={value}
onChange={(date) => due.actions.setValue(date)}
/>
<p {...due.parts.error}>
<span {...due.parts.errorText} />
</p>
</div>
);
}Vue
<script setup lang="ts">
import { field } from '@uniaria/core/field';
import { useBehavior, useBehaviorState } from '@uniaria/vue';
const due = useBehavior(field, {
name: 'due',
isRequired: true,
labelElementType: 'span',
});
const value = useBehaviorState(due, (state) => state.value as Date | null);
</script>
<template>
<div v-bind="due.parts.root">
<span v-bind="due.parts.label">Due date</span>
<!-- It must pass the attributes to its focusable element. -->
<DatePicker
v-bind="due.parts.control"
:model-value="value"
@update:model-value="(date) => due.actions.setValue(date)"
/>
<p v-bind="due.parts.error">
<span v-bind="due.parts.errorText" />
</p>
</div>
</template>HTML
<uni-field id="due-field" required>
<span data-element="Label">Due date</span>
<date-picker data-element="Control"></date-picker>
<p data-element="Error"><span data-element="ErrorText"></span></p>
</uni-field>
<script type="module">
import '@uniaria/elements/field';
const due = document.querySelector('#due-field');
due.querySelector('date-picker').addEventListener('change', (event) => {
due.setValue(event.target.value);
});
</script>The element reads the label’s tag, so there is no attribute for it. Its
setValue() method is the action, and its value property the value.
For radios and checkboxes, use RadioGroup and CheckboxGroup, which have Field built in.
labelledBy and describedBy add ids of other elements after Field’s own,
for a hint that sits elsewhere on the page or a unit next to the control.
Don’t write aria-labelledby or aria-describedby on the control yourself:
Field owns them and replaces them.
<p id="password-rules">At least 12 characters.</p>
<uni-field described-by="password-rules">…</uni-field>Every UniAria form component has Field built in: its label, description and error are parts of the component itself, with the same props, the same linking, the same error announcements and the same form binding. Render them on the component, with no Field around it:
const plan = useBehavior(radioGroup, { name: 'plan', isRequired: true });
<div {...plan.parts.root}>
<span {...plan.parts.label}>Plan</span>
<OwnerProvider owner={plan}>{/* the radios */}</OwnerProvider>
<p {...plan.parts.description}>You can change it later.</p>
<p {...plan.parts.error}>
<span {...plan.parts.errorText} />
</p>
</div>;
const fruit = useBehavior(combobox, { items: fruits, name: 'fruit' });
<div {...fruit.parts.root}>
<label {...fruit.parts.label}>Fruit</label>
<div {...fruit.parts.anchor}>{/* the input and its button */}</div>
<p {...fruit.parts.description}>We deliver it fresh.</p>
<p {...fruit.parts.error}>
<span {...fruit.parts.errorText} />
</p>
{/* the popover with the list */}
</div>;Field’s control is the element the component’s label names:
| Component | Field’s control | Label element by default | Notes |
|---|---|---|---|
| TextField | input |
label |
An <input> or a <textarea>. |
| NumberField | input |
label |
The label also names the stepper buttons. |
| Checkbox | input |
label |
Inside a <label> wrapper or root, the label part is a <span>. In a group, each checkbox also links the group’s error while shown. |
| Switch | input |
label |
As Checkbox. |
| RadioGroup | root |
span |
The radiogroup. Each radio links its own label and description, and the group’s error while shown. |
| CheckboxGroup | root |
span |
The group. |
| Listbox | list |
span |
|
| Select | trigger |
span |
|
| Combobox | input |
label |
The component’s markers and state attributes go on those parts, so style
them through the component ([data-uni='text-field']), not through field. Where
you can name the control’s id with a prop, the prop is named after the part:
inputId, triggerId, listId. An id you write on the part itself wins.
Use Field itself only for native and third-party controls. Don’t wrap a
UniAria component in <uni-field>: the field doesn’t look inside another
UniAria element, so its label would link to nothing, and in development it
warns. To build your own component on Field, see
A field of your own.
| Part | Element | Notes |
|---|---|---|
root |
any; usually div |
Optional. The outermost element, around the other parts, for styling. No role. |
label |
label by default; any element through labelElementType |
You own the content. UniAria writes its id (unless it has one), and for when it is a label. |
control |
any control: input, textarea, select, or a custom widget |
UniAria writes id, aria-labelledby, aria-describedby, aria-invalid and aria-required; name from the name prop, and form when it sits outside its form’s <form>; a native control’s value while a form or value controls it. You own the rest: type, disabled, readonly, a custom widget’s role. |
description |
any | UniAria writes its id (unless it has one). |
error |
any | UniAria writes its id (unless it has one), and hidden while the error isn’t shown. You own the content. |
errorText |
any, inside error |
Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself. |
Every part is optional. Every part keeps an id (unless it has one) you give it, and the links
below use it; UniAria writes an id (unless it has one) only on a part that has none. Write it
on the element (after the part spread in React, after v-bind in Vue), or
name the control’s with
controlId. Without either, UniAria generates one (see
Ids).
The parts link like this:
- Label. The label gets an
id(unless it has one), andforpointing at the control when it is a<label>, so a click on it focuses the control and a native control is named before JavaScript runs. The control getsaria-labelledbywith the label’s id, thenlabelledBy. While the label is rendered, it names the control, so anaria-labelon the control is ignored. - Description and error. The control’s
aria-describedbylists the description, then the error while it is shown, thendescribedBy. A hidden error is left out, since screen readers read hidden content a reference points at. - Only rendered parts. An id goes into a reference only once its part is
rendered, so no reference points at nothing. In React that happens right
after the first commit, in Vue right after mount, both before paint: server HTML has every
idand the label’sfor, and gains the references on hydration. The custom element links on upgrade, and again whenever a part is added or removed.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
isInvalid |
invalid |
boolean |
— | aria-invalid="true" on the control, removed when false (never "false"). Shows, links and announces the error. Unset in a Form, the form’s errors decide it. |
isRequired |
required |
boolean |
false |
aria-required="true" on the control. Never the native required, which would start the browser’s own validation. |
isDisabled |
disabled |
boolean |
false |
data-disabled on every part, and hides the error. It doesn’t disable the control: disable a native control yourself. |
controlId |
— | string |
generated | The control’s id while the control has none of its own. Server HTML from React or Vue has it, so the label’s for is right before hydration. |
labelElementType |
— (read from the label’s tag) | string |
'label' |
The label’s tag. for goes on a label only. |
labelledBy |
labelled-by |
string |
— | More ids for the control’s aria-labelledby, space-separated, after the label’s. |
describedBy |
described-by |
string |
— | More ids for the control’s aria-describedby, after the description and the error: a hint elsewhere on the page, for example. |
value |
— (property only) | unknown |
— | Bindable: what a native control holds, or what setValue gave a custom one. Seeded by defaultValue, reported as valueChange. Ignored while bound to a form. |
Field also takes the props every field takes for a form: name, written on
the control even outside a form, form, validate, validationTrigger,
validationDelay, validationMessages, dependsOn, isEqual and
shouldPreserve. See Field props for the
form.
Field reports valueChange for the user’s changes and handles no keys: the
control keeps its own.
| Field | Type | Meaning |
|---|---|---|
isInvalid |
boolean |
The isInvalid prop, else in a form whether it holds errors. |
isRequired |
boolean |
The isRequired prop. |
isDisabled |
boolean |
The isDisabled prop, or the form’s. |
isErrorShown |
boolean |
The error is shown: invalid and not disabled. |
value |
unknown |
The value: the control’s, or the form’s under its name while bound. |
errors |
unknown[] |
The form’s errors for it; [] outside a form. |
status |
'unvalidated' | 'validating' | 'valid' | 'invalid' |
Its last check in the form. |
isTouched |
boolean |
The user changed or left it, in a form. |
isDirty |
boolean |
Its value differs from its default, in a form. |
isBound |
boolean |
It is bound to a form. |
Field has one action, setValue(value): a user’s change from a custom
control, which a form takes as typing in a native one. Around a native
input, select or textarea, Field follows the control’s value itself.
You rarely need it: the state attributes below cover styling. In React or
Vue, select a value with useBehaviorState (Vue gives a ref), and the
component renders again only when it changes:
const isErrorShown = useBehaviorState(email, (state) => state.isErrorShown);On the custom element, read element.instance.getState(). See
State.
| Attribute | On | When |
|---|---|---|
data-uni="field" |
root | Always. |
data-element |
every other part | Always. |
data-invalid |
every part | isInvalid is set. |
data-required |
every part | isRequired is set. |
data-disabled |
every part | isDisabled is set. |
hidden |
error |
The error isn’t shown. |
The state attributes go on every part, so you can style the label and the messages from the field’s state without reading it in JavaScript. A required mark, an “(optional)” note and a success state are yours to draw.
The optional @uniaria/styles package styles the
label, the description and the error, as in the demo above, whose CSS tab holds
only the page’s layout. A native text control (an input you type in, a
textarea or a select) becomes the input box, with the focus ring and the
invalid border; any other control, such as a group, keeps its own look.
- Spoken output. For a label “Email”, a description “We never share it” and an error “Enter a valid email”, a screen reader reads the control as “textbox, Email, We never share it, required” while valid, and “textbox, Email, We never share it Enter a valid email, invalid, required” while invalid. A checkbox control reads “checkbox, Accept terms, You must accept, not checked, invalid, required”.
- The required mark. Hide a
*in the label witharia-hidden="true": the control already announces “required”. - Announcements. Field announces a new error through UniAria’s live
announcer, a visually hidden region it adds to the page. Don’t add
role="alert"oraria-liveto the error part, or it is read twice. - Disabled.
isDisabledhides the error, since a disabled control can’t be fixed. A native control you disable reads “disabled, invalid” without the error text. - Groups. A
role="group"control announces neither invalid nor required; ARIA supports both onradiogroupbut not ongroup. There, the error text inaria-describedbyis all a screen reader user hears. - Owned attributes. Pass more ids through
labelledByanddescribedByinstead of writingaria-labelledbyoraria-describedby. Ids are yours to set on any part. React and Vue warn in development when you pass an attribute UniAria writes.
- React Aria links errors the same way, through
aria-describedby, but announces nothing when one appears. Field announces it, as therole="alert"messages of many design systems do. Lion makes the error a live region instead. - Validation is the form’s. In a Form, a field checks
isRequired, the native constraints on its control andvalidate, as React Aria’svalidateand native validation do. Outside one, passisInvalidfrom your form library or your own checks. - No
aria-errormessage. VoiceOver and NVDA don’t read it, so the error goes inaria-describedby, as React Aria, Lion and Spectrum Web Components do. The APG’s spin button example usesaria-errormessage. - No native
required. Field writesaria-requiredonly, so the browser shows no validation bubbles of its own.