ReviewOS

stacks/stx

fix(forms): `@input`/`@textarea`/`@select` emit no `id`, so `@label`'s `for=` points at nothing — stx's own a11y checker flags the result

#1861
Closed glennmichael123 opened this 11 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
11 days ago

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/ → zero
  • grep -rn "<input[^>]*id=" resources/ → zero
  • grep -rn "aria-describedby|aria-invalid|role=\"alert\"" resources/ → zero, so the error banners at login.stx:78, register.stx:100, reset-password.stx:127 and the goal error at dashboard.stx:1576 are 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 — @input emit: <input type="…" name="…" value="…" class="…">. No id.
  • packages/stx/src/forms.ts:312 — @textarea, same. :350 — @select, same. :375 — @checkbox. :396 — @radio.
  • packages/stx/src/forms.ts:460 — @label emit: <label for="${forAttr}" …>, so the for target is the field NAME, which no directive ever writes as an id.
  • packages/stx/src/forms.ts:285,305,324,414 — hasFieldError(name, context) feeds only buildClassString (: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 an id="X" or an implicit wrap or aria-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's aria-describedby points at.
  • Angular Material <mat-form-field> — auto id, aria-describedby to <mat-error>, aria-invalid on failure. Chakra FormControl/FormLabel/FormErrorMessage. Nuxt UI <UFormGroup>. Vuetify <v-text-field label=…>.

Ask

  1. @input / @textarea / @select / @checkbox / @radio should emit an id (defaulting to the field name, overridable via the attributes bag) so @label's for resolves.
  2. When hasFieldError(name, context) is true, also emit aria-invalid="true" and aria-describedby="<field>-error", and have @error('field') render its container with that id.
  3. Add a regression test that runs checkA11y over the output of processFormInputDirectives and asserts zero input-missing-label violations — the checker is already in the package, one file away.
  4. 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

11 days ago

Fixed in 1ecf9b9.

Confirmed: grep -c 'id=' packages/stx/src/forms.ts returned 0. Every @label for= pointed at nothing.

It is six directives, not the three named. @checkbox, @radio and @file emit no id either, so they were affected identically. All six now derive one.

Three decisions worth flagging for review:

  1. An author-supplied id always wins. The generated id is a default for the common case, not a policy.
  2. name is left exactly as written — it is what the server parses — while the id is normalised. user[email] keeps its name and gets id="user-email". @label's for= goes through the same normalisation, otherwise field names that aren't legal bare ids would still fail to pair, which is the same bug wearing a different hat.
  3. Grouped controls need distinct ids or the markup is invalid and every label in the group selects the same control. @radio always takes a value so it always suffixes (color-red); @checkbox suffixes only when given one, so a lone keeps the bare id @label('terms') points at.

One behaviour change to be aware of: @label('a&b') emitted for="a&amp;b" and now emits for="a-b", since every character outside [\w.:-] is collapsed before escaping. The existing test asserting the escaping is updated. Nothing is left for escapeAttr to escape there, so this is strictly narrower, not less safe.

11 new tests, 9 of which fail without the change. The pairing is asserted by parsing the output and resolving each for= against the ids the same output defines — checking for="email" and id="email" as separate string matches would pass even if the two were generated from different normalisations and never actually met.

Sign in to comment on this issue.