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.

What not to do
<!-- 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.

ControlHow
Input, Textarea, Combobox.Input, PinInputthe native required attribute, on a real input
Checkbox, Switch, RadioGroup, RatingGrouparia-required, which Bits UI sets for you
Slideraria-required on the thumb — the container is not the slider
Select, Toggle, DatePickercannot 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.

A failed submit
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

PropTypeDefault
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.booleanfalse
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.