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.
<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>import { Field, Input } from '@caioalfonso/kanso-react';import { useState } from 'react';
export function FieldBasic() { const [email, setEmail] = useState(''); const [invalid, setInvalid] = useState(false);
return ( <Field label="Email" description="We only use this to sign you in." errorText="Enter an email address." invalid={invalid} required > <Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} onBlur={() => setInvalid(email === '')} /> </Field> );}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
Section titled “Import”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.
Anatomy
Section titled “Anatomy”| Part | Element | Notes |
|---|---|---|
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
label | node / slot | — | Rendered as a real <label>. |
description | node / slot | — | Help text. Shown and associated while no error is showing. |
errorText | node / slot | — | Shown, and associated, only while invalid. |
invalid | boolean | false | Sets aria-invalid and reveals the error. |
disabled | boolean | false | Forwarded to the control. |
readOnly | boolean | false | Forwarded to the control. |
required | boolean | false | The native attribute. |
id | string | auto | Lands 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.
Keyboard
Section titled “Keyboard”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.
Why the content is a prop and not a child
Section titled “Why the content is a prop and not a child”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.
aria-describedby, composed
Section titled “aria-describedby, composed”Four cases, and all four are tested:
| Description | Error, invalid | Result |
|---|---|---|
| — | — | attribute absent — not "" |
| yes | — | the description id |
| — | yes | the error id |
| yes | yes | the 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.
One message, not two
Section titled “One message, not two”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 message
Section titled “The error message”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 is native
Section titled “required is native”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.
Styling an invalid field
Section titled “Styling an invalid field”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.
Accessibility
Section titled “Accessibility”- 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"andaria-atomic, so the message is read whole rather than word by word. - One control per field. A
Fieldwith no control inside it logs a development warning, because the label’sforwould 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.