fix: template-no-invalid-interactive — honor role=presentation/none and aria-hidden#33
fix: template-no-invalid-interactive — honor role=presentation/none and aria-hidden#33
Conversation
🏎️ Benchmark Comparison
Full mitata output |
…ML boolean semantics
Per HTML Living Standard on boolean attributes, the presence of `autofocus`
indicates TRUE regardless of value — `autofocus="false"` and
`autofocus="autofocus"` are equally truthy. jsx-a11y's `no-autofocus`
treats the literal string `"false"` as an opt-out (via `getPropValue`),
but that's a peer-plugin convention that diverges from HTML semantics;
vue-a11y and lit-a11y are presence-based, consistent with the spec.
Narrow opt-out to the only case that is spec-consistent:
- `autofocus={{false}}` in angle-bracket syntax — renders no attribute.
- `{{input autofocus=false}}` in mustache hash-pair syntax — no attribute.
Revert peer-parity opt-outs for `autofocus="false"`, `autofocus={{"false"}}`,
and `{{input autofocus="false"}}` — these are now flagged per HTML spec
semantics. Moved from valid → invalid in the test suite.
Dialog exemption unchanged — keeps MDN-backed behavior for autofocus on
and within <dialog>.
Follows the spec-first direction established in ember-cli#2717 (aria-hidden),
#19, #33.
…I-ARIA spec Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string aria-hidden resolves to the default `undefined` — NOT `true`. So <span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the span; its content still contributes to the anchor's accessible name. The prior behavior inherited jsx-a11y's JSX-coercion convention and vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin conventions that diverge from normative ARIA. Matches the spec-first resolution of ember-cli#2717, #19, and #33. Moved valueless / empty aria-hidden cases from invalid → valid. Kept the explicit aria-hidden="true" and {{true}} cases as invalid.
Add a prominent banner explaining that this rule flags <div role="button"> regardless of handler presence, while all three peer plugins (jsx-a11y, vuejs-accessibility, @angular-eslint/template) gate on handler presence. Direct users who want peer-parity handler- gated behavior to template-no-invalid-interactive + #33.
Re-examined the original #33 review concern ("role=presentation escape hatch contradicts axe-core's presentation-role-conflict"). Tracing through actual cases shows the "narrowing" proposal was a no-op — isInteractive already catches focusable hosts before the escape hatch matters. The rule's behavior is correct; the docs weren't documenting the scope clearly. Added: - Explicit "Escape hatches" section naming the two escapes (aria-hidden truthy, role=presentation/none) with rationale. - Valueless-aria-hidden contested-semantics note pointing at Scott O'Hara's hidden-vs-none post for context. - "Related checks outside this rule's scope" subsection naming axe-core's presentation-role-conflict (distinct concern — role conflict on focusable) and click-events-have-key-events (distinct concern — handler without keyboard equivalent) so users know to layer those on top when appropriate. No rule-behavior changes.
There was a problem hiding this comment.
Pull request overview
Updates ember/template-no-invalid-interactive to avoid false positives by honoring explicit non-interactive opt-outs (role="presentation"/"none" and aria-hidden) before evaluating whether an element is interactive.
Changes:
- Add
hasNonInteractiveEscapeHatch(node)and short-circuit the rule whenrole="presentation"/"none"oraria-hiddenindicates an opt-out. - Expand unit tests with new valid/invalid cases for
roleandaria-hidden(including valueless/empty and literal mustache forms). - Add an audit fixture to document peer-plugin parity expectations, and document the new escape-hatch behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| lib/rules/template-no-invalid-interactive.js | Implements the escape-hatch helper and early-return behavior in the rule. |
| tests/lib/rules/template-no-invalid-interactive.js | Adds coverage for presentation/none roles and multiple aria-hidden shapes, plus explicit false non-opt-outs. |
| tests/audit/no-static-element-interactions/peer-parity.js | Adds a non-CI audit fixture capturing parity/divergences vs peer a11y plugins. |
| docs/rules/template-no-invalid-interactive.md | Documents the new escape hatches and related out-of-scope checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Updates ember/template-no-invalid-interactive to avoid false positives by honoring common ARIA “opt-out” signals (matching peer a11y plugins), and adds coverage to lock in the new behavior.
Changes:
- Add an early escape-hatch check for
role="presentation"/"none"andaria-hiddenbefore interactivity probing. - Expand rule unit tests with new valid/invalid cases around these escape hatches.
- Add a Vitest-running audit suite that documents current peer-parity behavior and divergences; update rule docs accordingly.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| lib/rules/template-no-invalid-interactive.js | Implements hasNonInteractiveEscapeHatch(node) and short-circuits rule reporting when present. |
| tests/lib/rules/template-no-invalid-interactive.js | Adds regression tests for presentation/none and aria-hidden (including false cases). |
| tests/audit/no-static-element-interactions/peer-parity.js | Introduces a peer-parity audit fixture running in the default Vitest suite. |
| docs/rules/template-no-invalid-interactive.md | Documents the new escape hatches and clarifies related/out-of-scope checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Updates template-no-invalid-interactive to match peer accessibility plugins by honoring explicit “non-interactive opt-out” signals (role="presentation|none" and aria-hidden) so the rule doesn’t report handlers on elements that authors have intentionally made decorative/hidden.
Changes:
- Add
hasNonInteractiveEscapeHatch(node)and short-circuit the rule whenrole="presentation|none"oraria-hiddenindicates the element is intentionally non-interactive/hidden. - Expand unit tests with new valid/invalid cases for these escape hatches.
- Add an audit fixture translating peer-plugin cases into assertions against this rule, and document the new escape-hatch behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| lib/rules/template-no-invalid-interactive.js | Adds escape-hatch detection and early return before interactivity probing. |
| tests/lib/rules/template-no-invalid-interactive.js | Adds coverage for presentation/none + aria-hidden forms (and confirms aria-hidden=false still flags). |
| tests/audit/no-static-element-interactions/peer-parity.js | Introduces a peer-parity audit suite for shared test shapes and documented divergences. |
| docs/rules/template-no-invalid-interactive.md | Documents escape hatches and their rationale/relationship to complementary checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…e; clarify escape-hatch scope)
There was a problem hiding this comment.
Pull request overview
This PR updates the template-no-invalid-interactive accessibility rule to reduce false positives by honoring explicit “opt-out” ARIA signals (role="presentation"/"none" and aria-hidden) before evaluating whether an element is interactive.
Changes:
- Add
hasNonInteractiveEscapeHatch(node)and short-circuit reporting whenrole="presentation"/"none"oraria-hiddenindicates the element should be treated as decorative/hidden. - Expand unit tests for valid/invalid scenarios around these escape hatches (including
aria-hidden="false"remaining invalid). - Add a large “peer parity” audit fixture to pin current behavior vs. jsx-a11y/vue-a11y, and document the escape-hatch behavior in rule docs.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| lib/rules/template-no-invalid-interactive.js | Implements the escape-hatch helper and early return in the rule visitor. |
| tests/lib/rules/template-no-invalid-interactive.js | Adds new valid/invalid test cases covering role and aria-hidden escape hatches. |
| tests/audit/no-static-element-interactions/peer-parity.js | New audit fixture translating peer-plugin cases into RuleTester assertions for regression/parity tracking. |
| docs/rules/template-no-invalid-interactive.md | Documents escape hatches, rationale, and related out-of-scope checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Re-examined the original #33 review concern ("role=presentation escape hatch contradicts axe-core's presentation-role-conflict"). Tracing through actual cases shows the "narrowing" proposal was a no-op — isInteractive already catches focusable hosts before the escape hatch matters. The rule's behavior is correct; the docs weren't documenting the scope clearly. Added: - Explicit "Escape hatches" section naming the two escapes (aria-hidden truthy, role=presentation/none) with rationale. - Valueless-aria-hidden contested-semantics note pointing at Scott O'Hara's hidden-vs-none post for context. - "Related checks outside this rule's scope" subsection naming axe-core's presentation-role-conflict (distinct concern — role conflict on focusable) and click-events-have-key-events (distinct concern — handler without keyboard equivalent) so users know to layer those on top when appropriate. No rule-behavior changes.
e0dfa50 to
c2fce7e
Compare
…I-ARIA spec Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string aria-hidden resolves to the default `undefined` — NOT `true`. So <span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the span; its content still contributes to the anchor's accessible name. The prior behavior inherited jsx-a11y's JSX-coercion convention and vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin conventions that diverge from normative ARIA. Matches the spec-first resolution of ember-cli#2717, #19, and #33. Moved valueless / empty aria-hidden cases from invalid → valid. Kept the explicit aria-hidden="true" and {{true}} cases as invalid.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…I-ARIA spec Per WAI-ARIA 1.2 §6.6 + aria-hidden value table, a missing or empty-string aria-hidden resolves to the default `undefined` — NOT `true`. So <span aria-hidden>X</span> as a child of <a href="/x"> does NOT hide the span; its content still contributes to the anchor's accessible name. The prior behavior inherited jsx-a11y's JSX-coercion convention and vue-a11y's "anything-not-literal-false" shortcut. Both are peer-plugin conventions that diverge from normative ARIA. Matches the spec-first resolution of ember-cli#2717, #19, and #33. Moved valueless / empty aria-hidden cases from invalid → valid. Kept the explicit aria-hidden="true" and {{true}} cases as invalid.
Add a prominent banner explaining that this rule flags <div role="button"> regardless of handler presence, while all three peer plugins (jsx-a11y, vuejs-accessibility, @angular-eslint/template) gate on handler presence. Direct users who want peer-parity handler- gated behavior to template-no-invalid-interactive + #33.
Re-examined the original #33 review concern ("role=presentation escape hatch contradicts axe-core's presentation-role-conflict"). Tracing through actual cases shows the "narrowing" proposal was a no-op — isInteractive already catches focusable hosts before the escape hatch matters. The rule's behavior is correct; the docs weren't documenting the scope clearly. Added: - Explicit "Escape hatches" section naming the two escapes (aria-hidden truthy, role=presentation/none) with rationale. - Valueless-aria-hidden contested-semantics note pointing at Scott O'Hara's hidden-vs-none post for context. - "Related checks outside this rule's scope" subsection naming axe-core's presentation-role-conflict (distinct concern — role conflict on focusable) and click-events-have-key-events (distinct concern — handler without keyboard equivalent) so users know to layer those on top when appropriate. No rule-behavior changes.
f4abbaf to
00a7ea0
Compare
e9c46dd to
e17c1b9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const tokens = roleAttr.value.chars.trim().toLowerCase().split(/\s+/u); | ||
| // WAI-ARIA first-recognized-token: skip unknown tokens, use the first | ||
| // one that aria-query recognizes as a valid role. | ||
| const firstRecognized = tokens.find((t) => roles.get(t) !== undefined); |
| // aria-hidden="false" is opt-in to exposure — rule still flags non-interactive + handler. | ||
| code: '<template><div aria-hidden="false" onclick={{this.h}}></div></template>', | ||
| output: null, | ||
| errors: [ | ||
| { | ||
| messageId: 'noInvalidInteractive', | ||
| data: { tagName: 'div', handler: 'onclick' }, | ||
| }, | ||
| ], | ||
| errors: [{ messageId: 'noInvalidInteractive' }], | ||
| }, | ||
| { | ||
| code: '<template><div aria-hidden={{false}} onclick={{this.h}}></div></template>', | ||
| output: null, | ||
| errors: [{ messageId: 'noInvalidInteractive' }], | ||
| }, |
|
|
||
| An element opts out of this rule's handler-on-non-interactive check in two cases: | ||
|
|
||
| - **`aria-hidden="true"`** (including valueless / empty / `{{true}}` / `{{"true"}}` forms) — the author has explicitly removed the element from the accessibility tree, so a "non-interactive element with handler" is moot; AT users won't encounter it either way. Explicit `aria-hidden="false"` / `{{false}}` still flags. |
| An element opts out of this rule's handler-on-non-interactive check in two cases: | ||
|
|
||
| - **`aria-hidden="true"`** (including valueless / empty / `{{true}}` / `{{"true"}}` forms) — the author has explicitly removed the element from the accessibility tree, so a "non-interactive element with handler" is moot; AT users won't encounter it either way. Explicit `aria-hidden="false"` / `{{false}}` still flags. | ||
| - **`role="presentation"` / `role="none"`** — the author asserts the element is decorative. We accept `role="presentation"` and `role="none"` (case-insensitive, first recognized token of a space-separated role list per WAI-ARIA first-recognized-token rule) — a deliberate superset of [jsx-a11y's exact-match `hasPresentationRole`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/hasPresentationRole.js) for consistency with [WAI-ARIA 1.2 §4.1](https://www.w3.org/TR/wai-aria-1.2/#host_general_role) role-fallback semantics. |
Note
This is part of a series where Claude has audited
eslint-plugin-emberagainst jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate,ember-template-lint, and the HTML and WCAG specs.Premise
Peer accessibility plugins honor two ARIA signals as opt-outs of the "interactive handler on non-interactive element" check:
isPresentationRole()short-circuitsno-static-element-interactionsandno-noninteractive-element-interactions. Separately,isHiddenFromScreenReader()handlesaria-hidden.no-static-element-interactionsacceptsrole='presentation'andaria-hidden='true'on clickable elements as VALID.Flagging these is a false positive.
Fix
template-no-invalid-interactivenow short-circuits on an explicit opt-out before the native/role-based interactivity probe runs.Helper —
hasNonInteractiveEscapeHatch(node)Returns true iff the element carries any of:
role="presentation"orrole="none"— case-insensitive, trimmed, first token of a space-separated role list. (Note: jsx-a11y'sisPresentationRoledoes plain exact-match — our first-token handling is a deliberate superset, matching ARIA 1.2 §5.4 role-fallback semantics.)aria-hiddenin any plausibly-"hide" form — valueless, empty,"true"(case-insensitive),{{true}},{{"true"}}.Explicit
aria-hidden="false"/{{false}}still flags — it's the unambiguous opt-in.Four ecosystem positions on valueless aria-hidden
The question "what does
<div aria-hidden>(bare),aria-hidden=""(empty), oraria-hidden={{false}}mean?" has no single authoritative answer. Four defensible positions exist:jsx-ast-utilscoercing valueless JSX attrs to booleantrue, combined with rule checkariaHidden === true. Quirk: stringaria-hidden="true"is NOT recognized because"true" !== true. Not a deliberate ARIA interpretation."false"→ hiddenisHiddenFromScreenReader.ts:(value || "").toString() !== "false". Non-spec shortcut.undefined→ not hiddentrue/false/undefined (default). Missing/empty resolves to default.Design choice for this rule
We lean toward fewer false positives. For this rule, that means treating valueless / empty aria-hidden as an escape hatch — flagging a click handler on an author-decorated element creates friction more often than it catches a real bug. The author wrote
aria-hiddenfor a reason; trust the signal.Prior art
no-static-element-interactions/no-noninteractive-element-interactionsisPresentationRole()andisHiddenFromScreenReader()before the static-element check.no-static-element-interactionsrole="presentation"andaria-hidden="true"on clickable elements as VALID.click-events-have-key-eventsisPresentationRole(node)andisHiddenFromScreenReader(node)before the click-handler check. No dedicatedno-static-element-interactions-style rule.click-events-have-key-events.jsdoes not honorrole="presentation"oraria-hidden(verified: noisPresentationRole/isHiddenFromScreenReaderimport).Tests
16 new valid cases (presentation / none + every plausibly-"hide" aria-hidden form); 3 new invalid cases confirming
aria-hidden="false"/{{false}}are NOT opt-outs.