Summary
The form directives cannot produce an accessible field. emits for="email", but emits only name="email" — no id — so the association is broken by construction, and the same is true of @textarea, @select, @checkbox and @radio. Error state adds a CSS class and nothing else: no aria-invalid, no aria-describedby pointing at the @error block. stx's own accessibility checker flags the markup its own directives produce.
Repro (no app needed)
import { processFormInputDirectives } from 'stx/src/forms'
import { checkA11y } from 'stx/src/a11y'
const html = processFormInputDirectives(
`@label('email')Email\n\n\n`,
{ errors: { email: ['Email is required'] } },
)Output:
<label for="email" class="form-label">Email</label>
<input type="email" name="email" value="" class="form-control is-invalid">
<textarea name="bio" class="form-control"></textarea>
<select name="country" class="form-control"><option value="us">US</option></select>await checkA11y(html, 'x.stx') on that output returns three violations, all type: 'input-missing-label', impact: 'serious', one per control.
Note the input also carries is-invalid (the error class) with no aria-invalid="true" and no aria-describedby linking it to the @error('email') block that renders the message.
Impact (real app)
analyticshq hand-writes all ten of its form fields, and none of them are associated:
- 10
<label>elements across five views —register.stx:104,108,112;login.stx:82,87;reset-password.stx:131,135;forgot-password.stx:96;sites/new.stx:95,99 grep -rn "<label[^>]*for=" resources/→ zerogrep -rn "<input[^>]*id=" resources/→ zerogrep -rn "aria-describedby|aria-invalid|role=\"alert\"" resources/→ zero, so the error banners atlogin.stx:78,register.stx:100,reset-password.stx:127and the goal error atdashboard.stx:1576are silent to a screen reader
Clicking "Email" does not focus the field, and a screen reader announces an unlabelled edit box on every auth page — WCAG 1.3.1 / 3.3.2 failures on the entire signup and password-reset flow. Reaching for the framework directives would not have fixed it: as the repro shows, @label + @input produces markup that fails the same check.
Evidence in stx
packages/stx/src/forms.ts:293—@inputemit:<input type="…" name="…" value="…" class="…">. Noid.packages/stx/src/forms.ts:312—@textarea, same.:350—@select, same.:375—@checkbox.:396—@radio.packages/stx/src/forms.ts:460—@labelemit:<label for="${forAttr}" …>, so thefortarget is the field NAME, which no directive ever writes as anid.packages/stx/src/forms.ts:285,305,324,414—hasFieldError(name, context)feeds onlybuildClassString(:124-133); the error state never reaches an ARIA attribute.packages/stx/src/a11y.ts:263-298— check 4,input-missing-label, looks for<label for="X">matching anid="X"or an implicit wrap oraria-label. The directives satisfy none of the three.
Prior art
- Laravel Breeze ships
<x-input-label :for=".."/>+<x-text-input id=".." name=".."/>for exactly this pair, and@error('x')renders into an element the input'saria-describedbypoints at. - Angular Material
<mat-form-field>— auto id,aria-describedbyto<mat-error>,aria-invalidon failure. ChakraFormControl/FormLabel/FormErrorMessage. Nuxt UI<UFormGroup>. Vuetify<v-text-field label=…>.
Ask
@input/@textarea/@select/@checkbox/@radioshould emit anid(defaulting to the field name, overridable via the attributes bag) so@label'sforresolves.- When
hasFieldError(name, context)is true, also emitaria-invalid="true"andaria-describedby="<field>-error", and have@error('field')render its container with that id. - Add a regression test that runs
checkA11yover the output ofprocessFormInputDirectivesand asserts zeroinput-missing-labelviolations — the checker is already in the package, one file away. - Consider a single
@field('email', { label: 'Email', type: 'email' })directive that emits the label, the control and the error region correctly wired, so the association cannot be dropped by an author.
Related: #1677