Skip to content

Field

The accessibility wiring around a form control: a real <label>, optional help text, an error message that announces itself, and an aria-describedby that is composed from whichever of those exist.

That composition is the reason this component exists. Overwriting the attribute instead of composing it is the most common form-accessibility bug there is, and nothing in an automated scan reports it.

We only use this to sign you in.
Props
<script setup lang="ts">
import { Field, Input } from '@caioalfonso/kanso-vue';
import { ref } from 'vue';
const email = ref('');
const invalid = ref(false);
</script>
<template>
<Field :invalid="invalid" required>
<template #label>Email</template>
<template #description>We only use this to sign you in.</template>
<template #error-text>Enter an email address.</template>
<Input
v-model="email"
type="email"
@blur="invalid = email === ''"
/>
</Field>
</template>

The two panels above are different libraries rendering the same core. Every attribute — the for, the aria-describedby, the aria-invalid — comes from the same @caioalfonso/kanso-core code; only the rendering differs.

import { Field, Input, Textarea } from '@caioalfonso/kanso-react/field';
import '@caioalfonso/kanso-styles/tokens';
import '@caioalfonso/kanso-styles/base';
import '@caioalfonso/kanso-styles/field';

The stylesheet is optional. See Installation for package setup, the Vue entry points, and why tokens and base come along.

PartElementNotes
root<div>Carries data-kanso and data-scope=“field”.
label<label>A native for pointing at the control — not aria-label.
control<input> / <textarea>Rendered by you, wired by the field.
description<div>Included in aria-describedby whenever present.
error-text<div>aria-live=“polite”. Present from first render; filled while invalid.

The list above comes from fieldAnatomy, which the library exports: {fieldAnatomy.join(', ')}.

All of these go on Field.

PropTypeDefaultNotes
labelnode / slotRendered as a real <label>.
descriptionnode / slotHelp text. Shown and associated while no error is showing.
errorTextnode / slotShown, and associated, only while invalid.
invalidbooleanfalseSets aria-invalid and reveals the error.
disabledbooleanfalseForwarded to the control.
readOnlybooleanfalseForwarded to the control.
requiredbooleanfalseThe native attribute.
idstringautoLands on the root verbatim; other ids derive from it.

Input and Textarea take every attribute their element takes. The id is owned by the field.

In Vue the three content props are slots#label, #description and #error-text — which is the same information spelled the way Vue spells content.

Input and Textarea support v-model in Vue and the ordinary value / onChange pair in React. The value is never touched by core: the binding is reactivity, which is the adapter’s job.

None of its own. Input and Textarea are native elements, so every key behaves exactly as the browser and the platform intend — which is the point of using them rather than building a text field out of a <div>.

The one keyboard behaviour the component does add is not a key at all: clicking the label moves focus to the control, which the native for/id pair provides and no ARIA attribute can.

Field.Description as a compound child would be the fashionable shape. It is rejected, and by a measurement rather than a preference.

A child can only tell its parent it exists after it mounts. For a dialog that is harmless — a closed dialog is not in the server HTML at all, so there is nothing to be late for. A field is always rendered. With a registration, the server would send a control with no aria-describedby and the association would appear only once JavaScript arrived: a form that works without JavaScript would ship without its description.

So presence is known during render, which means it comes from a prop or a slot, and Field renders those parts itself. The cost is that the order is fixed — label, control, description, error — which is the order they should be in.

The SSR tests in both adapters assert the attribute is in the HTML string itself, not merely present after hydration.

Four cases, and all four are tested:

DescriptionError, invalidResult
attribute absent — not ""
yesthe description id
yesthe error id
yesyesthe error id alone — see below

And a fifth that libraries usually get wrong — your own:

<Field label="Bio" description="Keep it short.">
<Textarea aria-describedby="counter" />
</Field>
<span id="counter">140 characters left</span>

The control ends up described by both. Every adapter in this library applies core’s props last so that core wins, which means an aria-describedby written by hand would otherwise be dropped in silence.

A field shows one region of text below its control. When it goes invalid, the error replaces the description rather than stacking under it — two messages competing for the same spot push the first one away from the control it describes, and the pair is twice as tall as either.

The description is removed from the document while an error is showing, so aria-describedby names the error alone. That is not a second decision: both the markup and the attribute come from one function in core, fieldMessage(state), which returns which part is currently the message. If they were decided separately they would drift, and the control would end up describing itself with an element that is not there — a broken reference only a screen-reader user would ever notice.

The error element is the one that stays mounted, because it is the live region (below). The description is the one that yields.

The tradeoff, stated plainly: a description often carries the very rule the error is complaining about — “8 characters or more” — and it disappears exactly when the user is trying to satisfy it. If that matters for your form, put the constraint in the error message itself.

The error element is in the document from the first render — empty until the field is invalid. That is what makes aria-live work: a live region announces changes to a region that was already there, and mounting the region together with its first message produces an announcement in some screen readers and not others.

It is not hidden with display: none either. The stylesheet is optional, and an announcement must never depend on CSS the consumer may not have installed.

required, not aria-required. The native attribute maps to the same accessibility property, and shipping both is a redundancy that can only drift.

It comes with constraint validation attached: the browser will block submission and show its own message. If you do not want that, put noValidate on the form — the same escape hatch you would use for any native control.

Use data-invalid, never :invalid.

[data-scope='field'] [data-part='control'][data-invalid] {
border-color: var(--kanso-danger);
border-width: 2px;
}

required on an empty control matches :invalid the moment the page loads, so a stylesheet written against the pseudo-class marks every required field as broken before the user has typed anything. data-invalid follows the invalid prop, which follows your validation.

The shipped stylesheet also thickens the border and puts a triangle before the message, so the state is never carried by colour alone.

  • The label is associated with for/id, so clicking it focuses the control — a thing no ARIA attribute can do.
  • aria-invalid="true" only while invalid; absent otherwise.
  • The error region is aria-live="polite" and aria-atomic, so the message is read whole rather than word by word.
  • One control per field. A Field with no control inside it logs a development warning, because the label’s for would otherwise point at nothing.
  • The placeholder is styled with --kanso-fg-muted, not the faint token: placeholder text is small text and needs 4.5:1.