Skip to content

fix(FormField): announce the blocks that rendered, not the props that were set - #549

Merged
IgorShevchik merged 3 commits into
mainfrom
claude/repo-rules-35um4e
Sep 8, 2026
Merged

fix(FormField): announce the blocks that rendered, not the props that were set#549
IgorShevchik merged 3 commits into
mainfrom
claude/repo-rules-35um4e

Conversation

@IgorShevchik

Copy link
Copy Markdown
Collaborator

Linked issue

Closes #497

Type of change

  • Documentation (updates to the documentation or readme)
  • Bug fix (a non-breaking change that fixes an issue)
  • Enhancement (improving an existing functionality)
  • New feature (a non-breaking change that adds functionality)
  • Chore (updates to the build process or auxiliary tools and libraries)
  • Revert (undoing a merged change — retitle this PR revert(Scope): ...)
  • Breaking change (fix or feature that would cause existing functionality to change)

Description

FormField decided what to render from the prop or the slot, and what to announce from the props alone. Slots are not in the injected context at all, so the two conditions disagreed in both directions.

what you pass block on screen named in aria-describedby
#hint / #description / #help slot, no prop yes no text nobody is told about
hint prop, no label no yes dangling reference
error + help error only both dangling reference
error: true, no message no yes dangling reference

The last row is the one #497 did not list, and nothing covered it. error?: boolean | string; true marks the field invalid without a message, and the error block needs a string, so it never draws while the attribute named it anyway.

The fix

FormField.vue is the only scope where props and slots are both visible, so the ids are computed there and passed through the injected context as describedBy. The template binds the same hasLabel / hasError computeds that feed it, so the two cannot drift apart again — that pair is the point of the change, not the list itself.

useFormField prefers describedBy and keeps the props-derived list as a fallback: formFieldInjectionKey and FormFieldInjectedOptions are both published, so a custom control may provide a context without it. Adding an optional field rather than replacing the three keeps that non-breaking.

aria-invalid is deliberately unchanged. It reports the field's error state, and the #error slot's v-if is satisfied by the slot merely existing — so keying invalidity off the rendered block would mark every field invalid for anyone who supplies the slot as markup. An #error slot with no error value now has its block described, since it drew; it is still not reported as invalid, and the docs still recommend binding error instead.

Upstream

Checked at v4 HEAD (970025f), not just at our sync cursor: useFormField.ts's ariaAttrs and FormField.vue's five conditions are still line-for-line identical, modulo uib24ui. Upstream has not fixed this, so the bug is live for every nuxt/ui consumer and a faithful port of any commit touching those two files would silently revert us.

Recorded as a §2 invariant in .sync/PORTING.md so that cannot happen quietly.

Tests

The six characterization tests #496 added to pin the broken behaviour did exactly what they were written to do — 66 failures per project on the first run, every one of them a test that existed to say "this is wrong on purpose". They are rewritten to assert the fixed behaviour, and a seventh covers error: true.

Each of the four conditions in describedBy was mutated separately, because a guard checked at one value is not a guard checked:

mutation goes red
drop the hasLabel term 1 test
drop the !hasError term on help 2 tests
ignore describedBy in useFormField all 7

Why nothing caught this

Two measurements worth recording, because both are limits rather than oversights.

axe does not check that an idref resolves. This spec already runs axe over a field with label, description, help, hint and error set at once — precisely the configuration where …-help dangled. Measured with the fix reverted: aria-describedby named v-0-0-0-help, which is provably absent from the DOM, and axe reported aria-valid-attr-value among its passes, zero violations.

A snapshot guard would have been vacuous. Scanning all 218 snapshot files for an aria-describedby id that no id= in the same file defines returns zero hits — and returned zero before the fix too, since no .snap changed here. The corpus simply never rendered the offending combination. So no corpus guard was added; the seven unit cases are the coverage.

Docs

Both places that documented the old behaviour in detail are rewritten:

  • docs/content/docs/2.components/form-field.md — the section Slots do not replace their props becomes What is announced is what was drawn, with both in-page anchors updated, and the #error table's second row corrected from …-error …-help to …-error
  • skills/b24-ui-nuxt/references/guidelines/forms.md — same rewrite, and the note that this is now a divergence rather than upstream behaviour

Checks

  • pnpm lint, pnpm typecheck green
  • Full suite green on three consecutive runs (347 files, 7881 passed, 6 skipped). One earlier run reported a single failure and I cannot name it — that run's log was captured as a tail and the detail was lost. Three clean runs since, and CI is the fourth data point; flagging it rather than calling it flaky.

Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

🤖 Generated with Claude Code

https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc


Generated by Claude Code

… were set

The template renders each descriptive block from `props.x || !!slots.x`, while
`useFormField` built `aria-describedby` from the props handed down through
`provide`. Slots were not in that context at all, so the two conditions
disagreed in both directions: a slot with no prop was text on screen that
nothing announced, and a prop whose block never drew left the attribute naming
an id that was not in the document — `hint` without a label to live in, `help`
behind a rendered error, and `error: true`, which marks the field invalid
without a message the error block could show.

`FormField.vue` is the only scope where props and slots are both visible, so
the ids are computed there and passed through the injected context as
`describedBy`. The template binds the same `hasLabel` / `hasError` computeds
that feed it, so the two cannot drift apart again. `useFormField` prefers that
list and keeps the props-derived one as a fallback, since
`formFieldInjectionKey` and `FormFieldInjectedOptions` are both published and a
custom control may provide a context without it.

`aria-invalid` is deliberately unchanged. It reports the field's error state,
and the `#error` slot's `v-if` is satisfied by the slot merely existing — so
keying invalidity off the rendered block would mark every field invalid for
anyone who supplies the slot as markup.

The six characterization tests added in #496 to pin this behaviour fail as
intended and are rewritten; a seventh covers the `error: true` direction, which
nothing had. Each of the four conditions in `describedBy` was mutated
separately to confirm it is checked.

Upstream still carries the defect at v4 HEAD, so this is a deliberate
divergence: recorded as a §2 invariant in `.sync/PORTING.md`, with the docs
page and the `skills/` forms guideline rewritten to match.

Closes #497

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MsMuj8Fic9tjWVjyEyrxc
@IgorShevchik
IgorShevchik merged commit 40b0d48 into main Sep 8, 2026
2 checks passed
@IgorShevchik
IgorShevchik deleted the claude/repo-rules-35um4e branch September 8, 2026 05:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

a11y(FormField): aria-describedby is built from props while the blocks render from props-or-slots

2 participants