Field
Label, help text and validation, wired to one control.
Usage
One control, its label, its help text and its validation message — with ids that actually match.
We never share it.
Type something without an @ to see the error wire itself up.
Why it exists
Before this component, aria-describedby appeared in zero of this library's components. There was no accessible way to attach help text
or a validation message to any control: you generated the ids yourself and wired them by hand, on
every field.
That is the same shape of gap as Button not being able to be a link. A library
that does not let you do the correct thing will get you writing the incorrect thing, and then it
looks like your bug.
The ids come from $props.id(), so they are identical on the server and
the client. Two fields on one page never collide.
Why a snippet, not sibling parts
Every other composite here is parts — Table.Row, Sidebar.Item. This one takes the control through a snippet and its
text as props, and that is a deliberate break.
aria-describedby may only name ids that exist. A reference to
a missing id is invalid, axe flags it, and nothing is announced for it — a silent loss rather
than a visible bug. That has to be true in the server-rendered HTML, before any
JavaScript runs.
A sibling <Field.Description> could only register itself after the control had already rendered, so the first paint would either miss the reference
or point at an id that was not there yet. Taking the text as props means the value is exact at render
time. There are tests that render this on a real server and assert every referenced id resolves.
The one way to misuse it is to forget the spread. Nothing breaks visibly — the label just
points at nothing — so Field reports it to the console instead of letting
it pass.
<!-- WRONG: the props are ignored, so the label, the description and
the error are attached to nothing. Field reports this to the console. -->
<Field label="Email" description="We never share it.">
{#snippet control(props)}
<Input type="email" bind:value={email} />
{/snippet}
</Field>Error implies invalid
There is no invalid prop. Passing an error is
what marks the field invalid, so the red border and aria-invalid cannot disagree with the message the user is reading.
Pass undefined when the field is valid, not an empty string — an empty string
is still a message, so it would render an empty error node and mark the field invalid.
When both are present, aria-describedby names the error first. When a field is wrong, that is what the user needs to hear
before the standing help text.
Keep description short. It is announced every single time the control takes
focus.
What required can announce
required always marks the visible label. Whether the control is announced as required depends on what it renders, and the answer is not the
same for all of them.
| Control | How |
|---|---|
Input, Textarea, Combobox.Input, PinInput | the native required attribute, on a real input |
Checkbox, Switch, RadioGroup, RatingGroup | aria-required, which Bits UI sets for you |
Slider | aria-required on the thumb — the container is not the slider |
Select, Toggle, DatePicker | cannot be — see below |
Those last three render a <button>. A native required attribute does nothing there, and aria-required is worse — axe reports it as an aria-allowed-attr violation. So the components swallow it rather than emit
dead markup, and there is a test asserting that axe still rejects it.
aria-invalid behaves differently: axe accepts that one on a button.
The two rules are not symmetric, which is why each was checked separately rather than generalised
from the other.
For those controls the signal a user gets is the required marker on the visible label plus the
error on submit — which is what Field is for. Do not rely on required alone to communicate it on any control: validate, and say what
is wrong.
Errors that appear after submit
The error is not a live region. If it appears while focus is somewhere else — a failed submit, a server response — nobody is told.
That is on purpose. An error that is both a live region and referenced by aria-describedby gets announced twice on the common path: once when it appears,
again when focus reaches the control.
So do what the WCAG technique actually says — move focus to the first invalid control — and
use focusFirstInvalidField to do it. Focusing reads the label, the error and
the description in one announcement, and it puts the user where the work is.
import { focusFirstInvalidField } from 'sve-ui';
let formEl;
let errors = $state({});
async function submit() {
errors = await validate(values);
if (Object.keys(errors).length > 0) {
// Awaits tick() itself, so the errors are in the DOM before it looks.
await focusFirstInvalidField({ root: formEl });
return;
}
await save(values);
}It awaits Svelte's tick() internally, because you call it right after the
state change that produced the errors and the DOM would not carry them yet. That is the one thing
easiest to get wrong here, so it is handled rather than documented.
It returns false when nothing was focused, so you can fall back when a submit
failed for a reason no single field owns. If it finds an invalid field whose control was never wired,
or one that cannot take focus, it says so in the console rather than doing nothing quietly.
Pass root to scope it to one form. Without it, the whole document is searched,
which is wrong on a page with two forms.
Any control
The snippet just hands you attributes, so this works with a plain <input>, a Select, a Slider, or something from another library entirely. Field never has to
know what your control is.
Pick anything.
Props
| Prop | Type | Default |
|---|---|---|
label required The control's accessible name. Rendered as a `<label for>`. | string | Snippet | — |
description Standing help text. Read out with the control, so keep it short — it is announced every time the control takes focus. | string | Snippet | — |
error The validation message. Its presence is what makes the field invalid: there is no separate `invalid` prop, so the styling and `aria-invalid` cannot disagree with what the user is reading. Pass `undefined` (not an empty string) when the field is valid. | string | Snippet | — |
required Marks the control required and shows the marker on the label. | boolean | false |
class Extra classes merged onto the wrapper. | string | — |
control required The control. Spread the props it is given, or nothing is wired. | Snippet<[FieldControlProps]> | — |
label, description and error each take a string, or a snippet when the content needs markup — a
link in the help text, say.
The control snippet receives id, aria-describedby, aria-invalid and required. Spread all of them.