Skip to content

fix: template-no-invalid-interactive — honor role=presentation/none and aria-hidden#33

Closed
johanrd wants to merge 1 commit intomasterfrom
fix/escape-hatch-in-invalid-interactive
Closed

fix: template-no-invalid-interactive — honor role=presentation/none and aria-hidden#33
johanrd wants to merge 1 commit intomasterfrom
fix/escape-hatch-in-invalid-interactive

Conversation

@johanrd
Copy link
Copy Markdown
Owner

@johanrd johanrd commented Apr 21, 2026

Note

This is part of a series where Claude has audited eslint-plugin-ember against 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:

Flagging these is a false positive.

Fix

template-no-invalid-interactive now 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" or role="none" — case-insensitive, trimmed, first token of a space-separated role list. (Note: jsx-a11y's isPresentationRole does plain exact-match — our first-token handling is a deliberate superset, matching ARIA 1.2 §5.4 role-fallback semantics.)
  • aria-hidden in 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), or aria-hidden={{false}} mean?" has no single authoritative answer. Four defensible positions exist:

# Source Interpretation Evidence
1 jsx-a11y Valueless → hidden Side effect of jsx-ast-utils coercing valueless JSX attrs to boolean true, combined with rule check ariaHidden === true. Quirk: string aria-hidden="true" is NOT recognized because "true" !== true. Not a deliberate ARIA interpretation.
2 vue-a11y Anything not literal "false" → hidden isHiddenFromScreenReader.ts: (value || "").toString() !== "false". Non-spec shortcut.
3 axe-core / W3C ACT Rules Valueless/empty → INCOMPLETE, needs author review axe-core PR #3635; W3C ACT Rule 6a7281 scopes out empty values as inapplicable.
4 WAI-ARIA 1.2 spec Valueless/empty → default undefined → not hidden §aria-hidden value table: value type true/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-hidden for a reason; trust the signal.

Prior art

Plugin Rule Verified behavior
jsx-a11y no-static-element-interactions / no-noninteractive-element-interactions Short-circuits on isPresentationRole() and isHiddenFromScreenReader() before the static-element check.
vuejs-accessibility no-static-element-interactions Accepts role="presentation" and aria-hidden="true" on clickable elements as VALID.
@angular-eslint/template click-events-have-key-events Short-circuits on isPresentationRole(node) and isHiddenFromScreenReader(node) before the click-handler check. No dedicated no-static-element-interactions-style rule.
lit-a11y No equivalent rule; click-events-have-key-events.js does not honor role="presentation" or aria-hidden (verified: no isPresentationRole / isHiddenFromScreenReader import).

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.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 21, 2026

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
🟢 js small 14.80 ms 14.06 ms -5.0%
🟢 js medium 7.11 ms 6.93 ms -2.5%
🟢 js large 2.88 ms 2.76 ms -4.1%
gjs small 1.27 ms 1.26 ms -0.4%
gjs medium 630.62 µs 633.78 µs +0.5%
gjs large 251.01 µs 250.85 µs -0.1%
gts small 1.26 ms 1.27 ms +0.7%
gts medium 637.66 µs 631.92 µs -0.9%
gts large 249.53 µs 246.80 µs -1.1%

🟢 faster · 🔴 slower · 🟠 slightly slower · ⚪ within 2%

Full mitata output
clk: ~3.08 GHz
cpu: AMD EPYC 7763 64-Core Processor
runtime: node 24.15.0 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
------------------------------------------- -------------------------------
js small (control)            16.69 ms/iter  17.50 ms █                    
                      (12.21 ms … 31.54 ms)  29.66 ms █▂ ▂                 
                    (  5.67 mb …  10.13 mb)   7.25 mb █████▆▆▃▁▁▁▃▃▃▃▁▁▆▁▁▃

js small (experiment)         15.74 ms/iter  15.85 ms  █                   
                      (13.08 ms … 25.98 ms)  25.01 ms  █                   
                    (  6.56 mb …   7.85 mb)   6.85 mb ▇█▆▄▇▄▄▃▁▃▁▁▁▁▃▁▃▁▁▁▄

                             ┌                                            ┐
                             ╷┌──────────┬─┐                              ╷
          js small (control) ├┤          │ ├──────────────────────────────┤
                             ╵└──────────┴─┘                              ╵
                               ╷ ┌────┬                       ╷
       js small (experiment)   ├─┤    │───────────────────────┤
                               ╵ └────┴                       ╵
                             └                                            ┘
                             12.21 ms           20.93 ms           29.66 ms

summary
  js small (experiment)
   1.06x faster than js small (control)

------------------------------------------- -------------------------------
js medium (control)            7.85 ms/iter   8.05 ms  █                   
                       (6.64 ms … 14.46 ms)  13.40 ms ▅█▅                  
                    (  2.70 mb …   4.55 mb)   3.54 mb ███▃▃▄▂▂▃▁▃▃▃▁▂▂▁▂▂▁▂

js medium (experiment)         7.47 ms/iter   7.71 ms  █                   
                       (6.49 ms … 14.00 ms)  12.77 ms ▂█▃                  
                    (  2.53 mb …   4.45 mb)   3.53 mb ███▅▃▅▅▂▁▂▂▂▂▁▁▁▁▁▂▁▂

                             ┌                                            ┐
                              ╷┌──────┬┐                                  ╷
         js medium (control)  ├┤      │├──────────────────────────────────┤
                              ╵└──────┴┘                                  ╵
                             ╷┌────┬─┐                                ╷
      js medium (experiment) ├┤    │ ├────────────────────────────────┤
                             ╵└────┴─┘                                ╵
                             └                                            ┘
                             6.49 ms            9.94 ms            13.40 ms

summary
  js medium (experiment)
   1.05x faster than js medium (control)

------------------------------------------- -------------------------------
js large (control)             3.39 ms/iter   3.18 ms  █                   
                        (2.51 ms … 9.95 ms)   9.14 ms  █                   
                    (219.12 kb …   3.30 mb)   1.44 mb ▃█▃▃▂▂▁▂▁▁▂▂▁▁▁▁▁▁▁▁▁

js large (experiment)          3.05 ms/iter   2.89 ms  █                   
                        (2.56 ms … 7.90 ms)   5.95 ms  █                   
                    (212.37 kb …   2.68 mb)   1.43 mb ▄█▅▁▃▂▂▁▂▂▁▂▁▁▁▁▂▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌───┬                                      ╷
          js large (control) ├─┤   │──────────────────────────────────────┤
                             ╵ └───┴                                      ╵
                             ╷┌──┬                  ╷
       js large (experiment) ├┤  │──────────────────┤
                             ╵└──┴                  ╵
                             └                                            ┘
                             2.51 ms            5.82 ms             9.14 ms

summary
  js large (experiment)
   1.11x faster than js large (control)

------------------------------------------- -------------------------------
gjs small (control)            1.40 ms/iter   1.35 ms █                    
                        (1.22 ms … 6.11 ms)   5.22 ms █▃                   
                    (220.47 kb …   1.67 mb)   1.06 mb ██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs small (experiment)         1.39 ms/iter   1.30 ms █                    
                        (1.22 ms … 6.18 ms)   5.14 ms █                    
                    (215.70 kb …   1.91 mb)   1.06 mb █▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌─┬                                          ╷
         gjs small (control) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             ┌─┬                                         ╷
      gjs small (experiment) │ │─────────────────────────────────────────┤
                             └─┴                                         ╵
                             └                                            ┘
                             1.22 ms            3.22 ms             5.22 ms

summary
  gjs small (experiment)
   1.01x faster than gjs small (control)

------------------------------------------- -------------------------------
gjs medium (control)         691.47 µs/iter 649.39 µs █                    
                      (599.40 µs … 5.31 ms)   3.72 ms █                    
                    (  8.99 kb …   1.06 mb) 541.72 kb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      681.24 µs/iter 647.46 µs █▅                   
                      (602.64 µs … 5.13 ms)   1.86 ms ██                   
                    (258.41 kb …   1.68 mb) 541.26 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
        gjs medium (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌┬                ╷
     gjs medium (experiment) ││────────────────┤
                             └┴                ╵
                             └                                            ┘
                             599.40 µs           2.16 ms            3.72 ms

summary
  gjs medium (experiment)
   1.02x faster than gjs medium (control)

------------------------------------------- -------------------------------
gjs large (control)          275.24 µs/iter 268.33 µs  █                   
                      (240.36 µs … 4.81 ms) 390.48 µs ▃█                   
                    (170.92 kb … 953.66 kb) 217.55 kb ███▄█▆▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁

gjs large (experiment)       276.34 µs/iter 266.72 µs  █                   
                      (241.37 µs … 5.03 ms) 431.05 µs ██ ▂                 
                    (215.70 kb … 868.02 kb) 216.70 kb ██▄█▅▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌──────┬                          ╷
         gjs large (control) ├┤      │──────────────────────────┤
                             ╵└──────┴                          ╵
                             ╷┌──────┬                                    ╷
      gjs large (experiment) ├┤      │────────────────────────────────────┤
                             ╵└──────┴                                    ╵
                             └                                            ┘
                             240.36 µs         335.71 µs          431.05 µs

summary
  gjs large (control)
   1x faster than gjs large (experiment)

------------------------------------------- -------------------------------
gts small (control)            1.36 ms/iter   1.28 ms █                    
                        (1.23 ms … 5.89 ms)   5.37 ms █                    
                    (648.87 kb …   1.57 mb)   1.06 mb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)         1.38 ms/iter   1.30 ms █                    
                        (1.23 ms … 6.91 ms)   5.67 ms █                    
                    (220.22 kb …   1.91 mb)   1.05 mb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                        ╷
         gts small (control) ││────────────────────────────────────────┤
                             └┴                                        ╵
                             ┌─┬                                          ╷
      gts small (experiment) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             └                                            ┘
                             1.23 ms            3.45 ms             5.67 ms

summary
  gts small (control)
   1.01x faster than gts small (experiment)

------------------------------------------- -------------------------------
gts medium (control)         747.82 µs/iter 665.88 µs  █                   
                      (594.42 µs … 5.49 ms)   1.42 ms  █                   
                    (  8.00  b …   1.23 mb) 541.53 kb ██▅▂▁▁▁▁▁▁▁▁▂▂▂▁▁▁▁▂▁

gts medium (experiment)      685.47 µs/iter 645.36 µs ▅█                   
                      (598.62 µs … 5.47 ms)   1.81 ms ██                   
                    (106.48 kb …   1.29 mb) 540.76 kb ██▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌────┬                        ╷
        gts medium (control) ├┤    │────────────────────────┤
                             ╵└────┴                        ╵
                             ╷┌─┬                                         ╷
     gts medium (experiment) ├┤ │─────────────────────────────────────────┤
                             ╵└─┴                                         ╵
                             └                                            ┘
                             594.42 µs           1.20 ms            1.81 ms

summary
  gts medium (experiment)
   1.09x faster than gts medium (control)

------------------------------------------- -------------------------------
gts large (control)          276.27 µs/iter 267.48 µs  █                   
                      (239.91 µs … 5.32 ms) 349.09 µs  ██                  
                    (216.09 kb … 965.49 kb) 217.10 kb ▅██▄▃▇█▃▂▂▂▂▁▁▁▁▁▁▁▁▁

gts large (experiment)       271.99 µs/iter 264.83 µs  █                   
                      (237.50 µs … 4.77 ms) 349.43 µs  █▅                  
                    ( 23.33 kb … 692.84 kb) 216.53 kb ▄██▃▂▇▆▃▂▂▂▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                              ╷ ┌────────────┬                            ╷
         gts large (control)  ├─┤            │────────────────────────────┤
                              ╵ └────────────┴                            ╵
                             ╷ ┌───────────┬                              ╷
      gts large (experiment) ├─┤           │──────────────────────────────┤
                             ╵ └───────────┴                              ╵
                             └                                            ┘
                             237.50 µs         293.47 µs          349.43 µs

summary
  gts large (experiment)
   1.02x faster than gts large (control)

johanrd added a commit that referenced this pull request Apr 21, 2026
…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.
johanrd added a commit that referenced this pull request Apr 21, 2026
…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.
johanrd added a commit that referenced this pull request Apr 21, 2026
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.
johanrd added a commit that referenced this pull request Apr 22, 2026
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.
@johanrd johanrd requested a review from Copilot April 22, 2026 10:28
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 when role="presentation"/"none" or aria-hidden indicates an opt-out.
  • Expand unit tests with new valid/invalid cases for role and aria-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.

Comment thread docs/rules/template-no-invalid-interactive.md Outdated
Comment thread tests/lib/rules/template-no-invalid-interactive.js Outdated
Comment thread tests/audit/no-static-element-interactions/peer-parity.js Outdated
Comment thread docs/rules/template-no-invalid-interactive.md Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" and aria-hidden before 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.

Comment thread docs/rules/template-no-invalid-interactive.md Outdated
Comment thread lib/rules/template-no-invalid-interactive.js
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 when role="presentation|none" or aria-hidden indicates 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.

Comment thread docs/rules/template-no-invalid-interactive.md Outdated
Comment thread docs/rules/template-no-invalid-interactive.md
johanrd added a commit that referenced this pull request Apr 24, 2026
@johanrd johanrd requested a review from Copilot April 24, 2026 13:39
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 when role="presentation"/"none" or aria-hidden indicates 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.

Comment thread lib/rules/template-no-invalid-interactive.js
Comment thread tests/lib/rules/template-no-invalid-interactive.js
Comment thread docs/rules/template-no-invalid-interactive.md Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

johanrd added a commit that referenced this pull request Apr 26, 2026
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.
@johanrd johanrd force-pushed the fix/escape-hatch-in-invalid-interactive branch from e0dfa50 to c2fce7e Compare April 26, 2026 08:01
johanrd added a commit that referenced this pull request Apr 26, 2026
…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.
@johanrd johanrd requested a review from Copilot April 26, 2026 08:42
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/rules/template-no-invalid-interactive.js
Comment thread lib/rules/template-no-invalid-interactive.js Outdated
Comment thread docs/rules/template-no-invalid-interactive.md Outdated
johanrd added a commit that referenced this pull request Apr 27, 2026
…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.
johanrd added a commit that referenced this pull request Apr 27, 2026
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.
johanrd added a commit that referenced this pull request Apr 27, 2026
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.
@johanrd johanrd force-pushed the fix/escape-hatch-in-invalid-interactive branch from f4abbaf to 00a7ea0 Compare April 27, 2026 14:22
@johanrd johanrd force-pushed the fix/escape-hatch-in-invalid-interactive branch from e9c46dd to e17c1b9 Compare April 27, 2026 19:28
@johanrd johanrd requested a review from Copilot April 27, 2026 19:34
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Comment on lines +256 to +265
// 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.
@johanrd johanrd closed this Apr 28, 2026
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.

2 participants