Phase 3 audit — wave findings (18 fixtures, 4 must-fix bugs)#28
Closed
johanrd wants to merge 1 commit intoaudit/a11y-parityfrom
Closed
Phase 3 audit — wave findings (18 fixtures, 4 must-fix bugs)#28johanrd wants to merge 1 commit intoaudit/a11y-parityfrom
johanrd wants to merge 1 commit intoaudit/a11y-parityfrom
Conversation
… bugs Adds a "Phase 3 — PR-wave + P1 follow-up" section to audit-a11y-behavior.md that consolidates the audit findings from 18 new peer-parity fixtures created after PR batch #5-#27: P0 fixtures (on PR branches #17-#24): click-events, mouse-events, aria-hidden-on-focusable, no-interactive-element-to-noninteractive- role, no-noninteractive-element-to-interactive-role, no-role-presentation-on-focusable, anchor-is-valid/invalidHref, no-noninteractive-tabindex. P1 fixtures (on audit/phase3/<concept> branches): aria-props, no-autofocus, no-distracting-elements, no-static-element- interactions, anchor-has-content, interactive-supports-focus, html-has-lang+lang, button-has-type, anchor-ambiguous-text, obj-alt. Documents 4 must-fix bugs (aria-orientation short-circuit; PascalCase- component collision with native HTML tag names; <option> missing from click-events; 22 non-interactive tags missing from noninteractive-to-interactive derivation), 7 behavioral gaps, and 2 missing-rule port candidates (template-anchor-has-content, template-interactive-supports-focus). Recommends ~9 fix PRs + 2 port PRs as Phase 3 follow-up.
🏎️ Benchmark Comparison
Full mitata output |
This was referenced Apr 21, 2026
BUGFIX: template-no-invalid-aria-attributes — accept "undefined" as valid aria-orientation token
#29
Closed
johanrd
added a commit
that referenced
this pull request
Apr 21, 2026
…events check
Premise: jsx-a11y accepts `aria-hidden={true}` (JSX boolean literal) as a
static opt-out of the click-events-have-key-events check. The HBS analog
is `aria-hidden={{true}}` — a GlimmerMustacheStatement whose path is a
GlimmerBooleanLiteral with value `true`. The previous
`isHiddenFromScreenReader` helper happened to treat this correctly, but
only because it also treated ANY mustache value as truthy (e.g.
`aria-hidden={{this.maybeHidden}}` would silence the rule as if the
element were guaranteed hidden). That is too permissive: a dynamic value
can't be proven hidden at lint time, so the rule should still fire.
Conclusion: rewrite the aria-hidden check to distinguish the two shapes
explicitly.
- `isHiddenFromScreenReader` now only returns true for:
- GlimmerTextNode value with chars `""` (bare attribute) or `"true"`
(case-insensitive, trimmed).
- GlimmerMustacheStatement whose `path` is a GlimmerBooleanLiteral
with value `true` — i.e. the literal `{{true}}`.
- Other mustache shapes (path references, helper calls) are now treated
as dynamic/unknown, so the rule fires. Authors who want a static
escape hatch should write `aria-hidden` or `aria-hidden="true"` or
`aria-hidden={{true}}`.
- Tests added for the mustache-literal valid case and for the new
dynamic-mustache invalid cases (`{{false}}`, `{{this.x}}`).
- Audit fixture: adds `aria-hidden={{true}}` as explicit parity with
jsx-a11y's `aria-hidden={true}`.
Tracks PR #28 item G1 (escape-hatch awareness). Companion to the
master-side `template-no-invalid-interactive` fix.
This was referenced Apr 21, 2026
johanrd
added a commit
that referenced
this pull request
Apr 26, 2026
…nd aria-hidden as escape hatches
Premise: peer a11y plugins (jsx-a11y `hasPresentationRole` + aria-hidden
handling inside `isInteractiveElement`; vuejs-accessibility's equivalent)
treat `role="presentation"` / `role="none"` and `aria-hidden`
(boolean / "true" / `{{true}}`) as explicit opt-outs of the interactivity
contract. An element that has opted out via ARIA does not need an
interactive handler check — the handler is authored acknowledging the
element is decorative or hidden from AT.
Conclusion: wire that opt-out into `template-no-invalid-interactive`
before the native/role-based interactivity probe runs.
- Adds `hasNonInteractiveEscapeHatch(node)` covering:
- `role="presentation"` / `role="none"` (case-insensitive, trimmed,
first token of a space-separated role list — matches jsx-a11y).
- `aria-hidden` as bare boolean attribute, the text value `"true"`,
or the mustache-literal `{{true}}`. `aria-hidden="false"` does NOT
qualify.
- Visitor short-circuits on escape-hatch hit before computing interactivity.
- Tests: new valid cases for every escape-hatch shape; new invalid
cases guarding that `aria-hidden="false"` and other roles still flag.
- Audit fixture `tests/audit/no-static-element-interactions/peer-parity.js`
added to master as evidence of parity. D1 (role=presentation/none) and
D2 (aria-hidden) divergences previously documented in the Phase-3 audit
branch are now parity cases in the valid block.
Tracks PR #28 item G1 (escape-hatch awareness across interactive-handler
rules).
johanrd
added a commit
that referenced
this pull request
Apr 26, 2026
…events check
Premise: jsx-a11y accepts `aria-hidden={true}` (JSX boolean literal) as a
static opt-out of the click-events-have-key-events check. The HBS analog
is `aria-hidden={{true}}` — a GlimmerMustacheStatement whose path is a
GlimmerBooleanLiteral with value `true`. The previous
`isHiddenFromScreenReader` helper happened to treat this correctly, but
only because it also treated ANY mustache value as truthy (e.g.
`aria-hidden={{this.maybeHidden}}` would silence the rule as if the
element were guaranteed hidden). That is too permissive: a dynamic value
can't be proven hidden at lint time, so the rule should still fire.
Conclusion: rewrite the aria-hidden check to distinguish the two shapes
explicitly.
- `isHiddenFromScreenReader` now only returns true for:
- GlimmerTextNode value with chars `""` (bare attribute) or `"true"`
(case-insensitive, trimmed).
- GlimmerMustacheStatement whose `path` is a GlimmerBooleanLiteral
with value `true` — i.e. the literal `{{true}}`.
- Other mustache shapes (path references, helper calls) are now treated
as dynamic/unknown, so the rule fires. Authors who want a static
escape hatch should write `aria-hidden` or `aria-hidden="true"` or
`aria-hidden={{true}}`.
- Tests added for the mustache-literal valid case and for the new
dynamic-mustache invalid cases (`{{false}}`, `{{this.x}}`).
- Audit fixture: adds `aria-hidden={{true}}` as explicit parity with
jsx-a11y's `aria-hidden={true}`.
Tracks PR #28 item G1 (escape-hatch awareness). Companion to the
master-side `template-no-invalid-interactive` fix.
johanrd
added a commit
that referenced
this pull request
Apr 27, 2026
…events check
Premise: jsx-a11y accepts `aria-hidden={true}` (JSX boolean literal) as a
static opt-out of the click-events-have-key-events check. The HBS analog
is `aria-hidden={{true}}` — a GlimmerMustacheStatement whose path is a
GlimmerBooleanLiteral with value `true`. The previous
`isHiddenFromScreenReader` helper happened to treat this correctly, but
only because it also treated ANY mustache value as truthy (e.g.
`aria-hidden={{this.maybeHidden}}` would silence the rule as if the
element were guaranteed hidden). That is too permissive: a dynamic value
can't be proven hidden at lint time, so the rule should still fire.
Conclusion: rewrite the aria-hidden check to distinguish the two shapes
explicitly.
- `isHiddenFromScreenReader` now only returns true for:
- GlimmerTextNode value with chars `""` (bare attribute) or `"true"`
(case-insensitive, trimmed).
- GlimmerMustacheStatement whose `path` is a GlimmerBooleanLiteral
with value `true` — i.e. the literal `{{true}}`.
- Other mustache shapes (path references, helper calls) are now treated
as dynamic/unknown, so the rule fires. Authors who want a static
escape hatch should write `aria-hidden` or `aria-hidden="true"` or
`aria-hidden={{true}}`.
- Tests added for the mustache-literal valid case and for the new
dynamic-mustache invalid cases (`{{false}}`, `{{this.x}}`).
- Audit fixture: adds `aria-hidden={{true}}` as explicit parity with
jsx-a11y's `aria-hidden={true}`.
Tracks PR #28 item G1 (escape-hatch awareness). Companion to the
master-side `template-no-invalid-interactive` fix.
johanrd
added a commit
that referenced
this pull request
Apr 27, 2026
Ports jsx-a11y/interactive-supports-focus, vuejs-accessibility/ interactive-supports-focus, and angular-eslint/interactive-supports-focus to Ember templates. Flags elements that carry an interactive ARIA role but have no focus affordance — the classic "<div role='button'>" without tabindex anti-pattern that keyboard users cannot reach. An element is flagged when all of the following hold: - plain HTML tag (present in aria-query's DOM map) - not a component invocation (PascalCase, @arg, this.x, foo.bar, foo::bar) - role attribute statically resolves to an interactive role (non-abstract roles descending from `widget` in aria-query, plus `toolbar`) - element is not inherently focusable (button, input[type!=hidden], select, textarea, a[href], area[href], summary, iframe, object, embed, audio[controls], video[controls]) - no tabindex attribute (any value, static or dynamic) - no truthy contenteditable Conservative skips: - Dynamic role values (role={{x}}) — cannot statically resolve - Component invocations — opaque rendering target - Custom elements not in aria-query's DOM map Interactive-role taxonomy derived from aria-query using the same widget- descendant predicate as jsx-a11y and vuejs-accessibility, plus the `toolbar` addition that jsx-a11y has historically kept outside the auto-derived set. Relationship to template-no-invalid-interactive: the two rules are complementary — template-no-invalid-interactive flags handlers on non-interactive elements; this rule flags interactive roles on non-focusable hosts. They can both fire on the same element when both concerns apply, and they target independently authored mistakes. Not added to template-lint-migration — opt-in. Closes the M2 finding in the Phase 3 audit for interactive-supports-focus (audit/phase3/interactive-supports-focus; tracking PR #28).
johanrd
added a commit
that referenced
this pull request
Apr 27, 2026
…nd aria-hidden as escape hatches
Premise: peer a11y plugins (jsx-a11y `hasPresentationRole` + aria-hidden
handling inside `isInteractiveElement`; vuejs-accessibility's equivalent)
treat `role="presentation"` / `role="none"` and `aria-hidden`
(boolean / "true" / `{{true}}`) as explicit opt-outs of the interactivity
contract. An element that has opted out via ARIA does not need an
interactive handler check — the handler is authored acknowledging the
element is decorative or hidden from AT.
Conclusion: wire that opt-out into `template-no-invalid-interactive`
before the native/role-based interactivity probe runs.
- Adds `hasNonInteractiveEscapeHatch(node)` covering:
- `role="presentation"` / `role="none"` (case-insensitive, trimmed,
first token of a space-separated role list — matches jsx-a11y).
- `aria-hidden` as bare boolean attribute, the text value `"true"`,
or the mustache-literal `{{true}}`. `aria-hidden="false"` does NOT
qualify.
- Visitor short-circuits on escape-hatch hit before computing interactivity.
- Tests: new valid cases for every escape-hatch shape; new invalid
cases guarding that `aria-hidden="false"` and other roles still flag.
- Audit fixture `tests/audit/no-static-element-interactions/peer-parity.js`
added to master as evidence of parity. D1 (role=presentation/none) and
D2 (aria-hidden) divergences previously documented in the Phase-3 audit
branch are now parity cases in the valid block.
Tracks PR #28 item G1 (escape-hatch awareness across interactive-handler
rules).
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.
Phase 3 audit wave — follow-up backlog
18 new
tests/audit/<concept>/peer-parity.jsfixtures (≈1228 translated test cases) have been produced after PR batch #5–#27. This PR adds a summary of findings todocs/audit-a11y-behavior.md; the individual fixture branches listed below are not merged here.Full summary: Phase 3 section of
docs/audit-a11y-behavior.md.Fixture branches — P0 (ride along with each rule PR)
Fixture branches — P1 (child branches off
audit/a11y-parity)Ten
audit/phase3/<concept>branches ready to fast-forward intoaudit/a11y-parity:audit/phase3/aria-props— 93 casesaudit/phase3/no-autofocus— 37 casesaudit/phase3/no-distracting-elements— 49 casesaudit/phase3/no-static-element-interactions— 106 casesaudit/phase3/anchor-has-content— 39 cases (documents coverage gap)audit/phase3/interactive-supports-focus— 63 cases (documents coverage gap)audit/phase3/html-has-lang— 38 casesaudit/phase3/button-has-type— 31 casesaudit/phase3/anchor-ambiguous-text— 52 casesaudit/phase3/obj-alt— 16 casesMust-fix bugs (F1–F4)
Each becomes its own targeted PR.
aria-orientation="undefined"FP.lib/rules/template-no-invalid-aria-attributes.js:20-22short-circuits onvalue === 'undefined'before checking aria-query's token list, whereundefinedis a legitimate token value for this attribute. One-line fix. Source: B9 — Resolved by #2723 (fix(template-no-invalid-aria-attributes): absorb allowundefined handling into validateByType).node.tag?.toLowerCase()and compare to a set of native tag names, misclassifying<Article>/<Form>/<Main>components as the native tag. Confirmed FPs:<Article tabindex={{0}}>(B8),<Article role="button">(B5). ExtractisComponentInvocation(node)helper. Sources: B5, B8 — Resolved by #2724 (refactor: extract isNativeElement util) — lists+scope pattern per Post-merge-review: extend allowlist with svg-tags on template-no-block-params-for-html-elements ember-cli/eslint-plugin-ember#2689, heuristic approach rejected.<option>missing fromINHERENTLY_INTERACTIVE_TAGSintemplate-click-events-have-key-events. Source: B1 — Resolved architecturally via #37:<option>is not in HTML §3.2.5.2.7 Interactive Content (thehtml-interactive-contentutil's authority) but IS an ARIA widget per aria-query (theinteractive-rolesutil's authority). Rules needing it consult the ARIA-widget authority; those not needing it don't. See the authority split PR body for details.NON_INTERACTIVE_TAGS(template-no-noninteractive-element-to-interactive-role):section,address,aside,code,del,em,fieldset,hr,html,ins,optgroup,output,strong,sub,sup,tbody,tfoot,thead(~22 tags). Source: B5 — 18 of the 22 tags landed via #21's follow-up commit4cd92691(union of aria-queryelementRoles+ axobject-query fallback).Behavioral gaps (G1–G7) — design discussion
template-no-invalid-interactive+template-click-events-have-key-eventsdon't treatrole="presentation"/role="none"/aria-hidden="true"as opt-outs. (B1, B12) — Resolved by #33 (template-no-invalid-interactive escape hatches) and #17 (click-events aria-hidden / role=presentation skip).NATIVE_INTERACTIVE_ELEMENTS:option/menuitem/datalistmissing;<audio/video controls>treatment inconsistent;<input type="hidden">excluded where peers include. (B12) — Resolved by #37 via the HTML-content-model authority split —html-interactive-content.jscites HTML §3.2.5.2.7 as the sole authority for the util.template-no-autofocus-attributevalue-blind: flagsautofocus={{false}}/="false"that jsx-a11y accepts. (B10) — Resolved by #32. Value-aware for mustache boolean-literal{{false}}only (verified against Glimmer VM source);autofocus="false"still flagged per HTML boolean-attribute spec.<dialog autofocus>exception missing. (B10) — Resolved by #32 — MDN-backed ancestor-walk exemption; matches angular-eslint PR #2851.template-no-aria-hidden-on-focusable+template-no-role-presentation-on-focusable. (B3, B6) — Resolved per-spec with asymmetric stances. The two attributes cascade differently, so the rules do too:aria-hiddenDOES cascade per WAI-ARIA 1.2 ("user agents SHOULD NOT expose… all descendants to assistive technologies"), so #19 recurses viahasFocusableDescendant— matches axe-core'saria-hidden-focus.role="presentation"/"none"does NOT cascade per WAI-ARIA 1.2 §4.6 ("does not cascade to its descendants"), so #22 does not recurse — deliberately diverges from vue-a11y's recursion (documented at the top of the rule source). If a future author-misconception-catcher case emerges (<div role="presentation"><input /></div>as a common mistake), that would be a separate rule (template-no-role-presentation-above-focusableor similar), not an option on the existing rule.template-no-invalid-aria-attributesflags custom elements; angular skips tags with hyphen. (B9) — Working as intended. aria-* attribute values are spec-normative regardless of element type:<my-widget aria-expanded="notvalid">is wrong whether or not the component defines its own semantics, because aria-query's value tables derive from WAI-ARIA and apply to any element carrying the attribute. Angular's "skip-on-hyphen" is defensive over-breadth, not superior accuracy. If a real ecosystem emerges where custom elements legitimately redefine aria-* semantics (none today), we can add an opt-inallowCustomElements: trueoption — not without a concrete bug report.<embed role="img">,<summary role="img">,<td/th role="img">in non-grid tables,<datalist role="img">— axobject-query-derived tag set surfaces FPs. Consider switching to aria-queryelementRoles-first. (B4) — still open; tracks as epic-scope refactor (see review notes).Missing-rule ports (M1–M2)
template-anchor-has-content— flag<a href><span aria-hidden>…</span></a>. (B13 — 4 cases) — Resolved by #35.template-interactive-supports-focus— flag<div role="button" {{on "click" …}}>without tabindex. (B14 — 7 MISSING + 18 DIVERGENCE) — Resolved by #36 (role-gated variant — a deliberate divergence from all three peers, see feat: add template-interactive-supports-focus #36 doc).Recommended sequencing
F1, F3, F4 — small, self-contained, low-risk.Done.G3+G4 — bundle as one autofocus PR.Done via fix: template-no-autofocus-attribute — value-aware + <dialog> exception #32.G5 — needs design call.Resolved.M1, M2 — separate port PRs.Done.