test: add advisory axe-core accessibility sweep for every component - #736
Open
johnleider wants to merge 3 commits into
Open
test: add advisory axe-core accessibility sweep for every component#736johnleider wants to merge 3 commits into
johnleider wants to merge 3 commits into
Conversation
…732) 35 of 171 test files assert `aria-*` by hand. That is real coverage and it is not the gap — every one of those assertions is an attribute someone thought to check. Nothing audits the ones nobody thought of, which is how the open a11y batch (#608, #611-#616) survived a suite those 35 files already passed. Adds a sweep that mounts each of the 40 shipped components in the shape its own `@example` JSDoc documents, in the `v0:browser` project's real Chromium, and hands it to axe-core. Advisory: violations are collected and reported, never asserted. Gating on day one buys either a red master or suppressions written to deadline, and a suppression written to get CI green is a permanently invisible violation. First pass: 10 violations, 13 elements, 7 components, 8 rules. Three reproduce open issues (#608 via Progress, #613 via Selection and Select). The other five are new. Full breakdown in the PR body; not fixed here. Uses axe-core directly rather than `vitest-axe`, which issue #732 proposed and the accessibility guide recommends to users. `vitest-axe` reaches for `node:module` at import time and fails to load in Vitest browser mode at both 0.1.0 and 1.0.0-pre.5 — it is a jsdom/happy-dom tool. axe-core is the engine underneath it either way. The guide now says so. The manifest check derives its expected set from maturity.json rather than from this file, so promoting a component out of `draft` without writing it a fixture fails the sweep. CI runs it as a separate `a11y` job with `continue-on-error: true`, mirroring the existing `vapor` job's non-blocking-signal pattern, and tees the breakdown into the GitHub step summary. Flipping to a gate is deleting that one line.
Contributor
|
The canonical sweep audits each component in the shape its own `@example`
documents, and a documented example is written by someone thinking about the
example. The failure mode that ships is the omission — the optional
sub-component nobody mounted, the optional prop nobody passed.
Adds `fixtures/degenerate/`: 11 fixtures, each the canonical shape with one
documented-optional part removed and nothing else changed. Membership rule is
stated at the map so it does not drift into a grab bag; components whose
canonical fixture already omits its optional name (NumberField, Progress,
Rating, Select, Slider) are absent because they are already in the first-pass
count.
Counted and reported separately from the canonical number, which is unchanged
at 10 violations / 13 elements / 7 components / 8 rules. No CI change — both
sections sit inside the existing report fence.
The result the pass exists for: `aria-dialog-name` on Dialog and AlertDialog,
2 elements. Both emit `aria-labelledby={titleId}` whether or not the Title is
mounted, so omitting it leaves a `<dialog>` with a dangling IDREF and no
accessible name. That is #608's actual failure mode and the two-thirds of it
#638 does not fix — invisible from the documented shape, which mounts a Title.
The other 8 are consumer omissions that degrade correctly to merely-unnamed
widgets (button-name x6, image-alt x2, label x1), which is what a headless
library should do and is the useful negative result.
`sweep()` extracted so both passes share the mount/audit body; the render
assertion moves to the callers rather than being hidden behind a helper.
This was referenced Jul 28, 2026
Open
Make the advisory baseline trustworthy enough to burn down against: mount real Snackbar surface, drop attrs double-fire in Select/Combobox fixtures, open disclosures with focus-for-input / click-for-button, dedupe open-state HTML churn, and tag unverified opens in the report. Also force Treeview open-all, Carousel LiveRegion, Breadcrumbs overflow, and correct the region / gate-comment wording from review.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #732.
What lands
axe-corein the workspace catalog +packages/0devDependencies.packages/0/src/components/a11y.browser.test.ts— a sweep over all 40 shipped components, running in thev0:browserproject's real Chromium.packages/0/src/components/fixtures/— one.vueper component holding the usage its own@exampleJSDoc documents, plus the audit/report helper.a11yCI job,continue-on-error: true, that tees the breakdown into the GitHub step summary.guide/features/accessibility.md— see "Not vitest-axe" below.No violations are fixed here. The point of the first pass is the number.
The number
aria-required-parentaria-allowed-attrbutton-namelabelaria-input-field-namearia-progressbar-namenested-interactiveregionThree of these are the open batch, found independently:
aria-progressbar-nameon Progress is [Bug] a11y: unconditional aria-labelledby/describedby emit dangling IDREFs (Dialog, AlertDialog, Progress) #608.ProgressRootemitsaria-labelledby="v-0-label"whether or notProgress.Labelis mounted; the dangling IDREF leaves the progressbar with no accessible name. PR fix(Progress): only emit aria-labelledby when Progress.Label is mounted #638 is the fix.aria-allowed-attron Selection is [Bug] Provider Items ship a partial ARIA contract: aria-selected without a role, no keyboard path #613 —<button aria-selected="false">, an attributebuttondoes not allow without an appropriate role. PR fix(Group,Selection,Single,Step): add missing ARIA role alongside aria-selected #633 is the fix.button-nameon Select is theSelectActivatorhalf of [Bug] Provider Items ship a partial ARIA contract: aria-selected without a role, no keyboard path #613. PR feat(Select): add aria-label prop and printable-char typeahead to SelectActivator #635 is the fix.Five are new:
aria-required-parenton Select — threerole="option"elements whose nearest ancestor is not alistbox/group.aria-controlspoints at the listbox id, but the options are not inside it.nested-interactive+aria-input-field-nameon Rating —RatingRootrendersrole="slider"andRating.Item as="button"puts focusable buttons inside it. The slider also has no accessible name.aria-input-field-nameon Slider —role="slider"witharia-valuetextbut noaria-label/aria-labelledby.labelon NumberField — the spinbutton<input>has no associated label. Adjacent to fix(NumberField): add labelledBy prop for aria-labelledby on spinbutton #640, which addslabelledByfor thearia-labelledbycase; this is the plain<label>case.regionon Portal — content teleported to<body>sits outside any landmark. Arguably inherent to teleporting and arguably a documentation note rather than a fix; flagging, not asserting.Follow-ups are filed:
aria-required-parent— Selectnested-interactive+aria-input-field-name— Ratingaria-input-field-name— Sliderlabel— NumberFieldregion— Portal#738 turned out not to be the finding it looked like.
Select.Contentdoes renderrole="listbox"and the items are nested inside it — butSelectItembinds itsattrsonto its own non-renderlessAtomwhileSelect.Item's@examplespreads that sameattrsonto a child<div>, so every option renders twice and the inner one's nearest role ancestor is anotheroption. That is also theattrsdouble-fire hazard.claude/rules/components.mdalready documents, so one correction to the example fixes both.This was a floor, not a ceiling — and the second commit raises it. See "Degenerate shapes" below.
Degenerate shapes
The fixtures above are each component's documented usage, and a documented example is written by someone thinking about the example. The failure mode that ships is the omission: the optional sub-component nobody mounted, the optional prop nobody passed. #608 is exactly that shape, and the canonical sweep caught only its Progress third — by the accident that Progress's own example happens not to render a
Progress.Label.packages/0/src/components/fixtures/degenerate/is the other half of #732's open question: 11 fixtures, each the canonical shape with one documented-optional part removed and nothing else changed.button-namearia-dialog-nameimage-altlabelTwo of these are ours. Eight are not, and that is the useful result.
aria-dialog-nameon Dialog and AlertDialog is the finding this pass exists for:No
Dialog.Titleis mounted, sov-0-titleresolves to nothing. The dialog has a dangling IDREF and no accessible name. That is #608's actual failure mode and the two-thirds of it #638 does not fix — #638 is Progress-scoped. It is invisible from the documented shape, which mounts a Title, which is the whole argument for this pass.The remaining eight are consumer omissions. A headless library cannot stop a consumer from failing to name a widget, and it should not try. The question the sweep is actually asking is narrower and is v0's: does the component degrade to a merely-unnamed widget, or to a broken one — a dangling IDREF, a role stripped of a property it requires? Everywhere except the two Dialogs, v0 degrades correctly.
button-name,image-altandlabelfiring on a nameless control is axe working as intended, not v0 misbehaving.Design notes:
a11yjob'sawklifts them together with no workflow change.[aria-expanded="false"]. Combobox's listbox does not open from that click, so its options are never in the DOM to audit — andCombobox.Itemis structurally identical toSelect.Item, down to the same<div v-bind="attrs">in its own example. Read Combobox's clean line in the report as unverified, not clean. Noted in [Bug] Select.Item renders role="option" twice — nested options have no required listbox parent #738.Not
vitest-axe#732 proposed
vitest-axe, on the reasoning that the docs already recommend it. It does not work here:vitest-axe@0.1.0callsmodule.createRequireat import time.1.0.0-pre.5drops that but pullschalk, which fails to resolvetinyrainbowin the browser environment. Both are Node packages;vitest-axeis a jsdom/happy-dom tool, which is why reka-ui's use of it comes bundled with agetComputedStylemonkeypatch.Running the auditor where layout is simulated is the one thing #732 argues against, so the wrapper goes and the engine stays —
vitest-axewrapsaxe-core, andaxe-coreis a browser library that needs no wrapper to run in a browser.guide/features/accessibility.mdnow documents both forms and says which to reach for.Design decisions
Sweep, not per-component assertions. #732 left this open, leaning sweep. A dedicated file guarantees no component is silently unaudited and keeps axe out of the hot path of the 25 existing
*.browser.test.tsfiles. The manifest check derives its expected set frommaturity.json— the registry that already drives the docs badges — so promoting a component out ofdraftwithout writing a fixture fails the sweep. Enforcement lives outside this file on purpose.Explicit fixture list, not
import.meta.glob. Matches the conventionsrc/surface.test.tssets and states outright: a committed array so a surface change is a reviewable diff rather than a silent side effect.A
<main>harness instead of disablingregion. Mounted into a bare<div>, 25 of 40 components reportedregion— content not contained by a landmark, true of the harness and not of the component. reka-ui's answer is a globalconfigureAxedisabling the rule. Wrapping the mount point in<main>fixes the actual defect instead, andregionstill fires for Portal, correctly. No axe rule is disabled and norunOnlyis passed — an auditor configured down to the rules you already pass is not an auditor.Separate job, not a step on
browser. Mirrors the existingvaporjob, which is this repo's own pattern for non-blocking signal. Keeps the breakdown in its own step summary rather than buried under the coverage upload, leaves thebrowserjob's Codecov behavior untouched, and makes the flip to blocking a one-line deletion. Same Playwright cache key, so Chromium restores rather than re-downloads.Verification
All run at this branch:
Sweep itself: 53 tests passed, ~4s, zero
[Vue warn]output (the repo's zero-stderr policy). The CI extraction step was verified locally by running the sameawkover the run log. Re-run after the degenerate commit:pnpm lintclean,pnpm typecheckDone,pnpm exec vitest run --project v0:browser34 files, 1768 passed | 2 skipped.Related
Stacked conceptually on #735 (coverage globs) but textually independent — different hunks of
pr-checks.yml, no conflict either merge order. The fixtures land under**/fixtures/**, already excluded by the root coverage config, so they do not enter coverage under either PR's include set.