Skip to content

feat: add template-no-aria-hidden-on-focusable#19

Closed
johanrd wants to merge 1 commit intomasterfrom
feat/template-no-aria-hidden-on-focusable
Closed

feat: add template-no-aria-hidden-on-focusable#19
johanrd wants to merge 1 commit intomasterfrom
feat/template-no-aria-hidden-on-focusable

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: A focusable element with aria-hidden="true" creates a keyboard trap — reachable via Tab, hidden from assistive technology. This is the anti-pattern flagged by axe's aria-hidden-focus rule and by jsx-a11y's no-aria-hidden-on-focusable. The WAI-ARIA 1.2 spec itself only says authors MAY "with caution" use aria-hidden; the specific "keyboard-reachable + AT-hidden = trap" framing is community/axe guidance, not a normative MUST-NOT.
  • Problem: No equivalent rule exists in our plugin today.

Fix: add template-no-aria-hidden-on-focusable.

Four ecosystem positions on valueless aria-hidden

The question "what does <el 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". Catches valueless, empty, "TRUE", "anything". Non-spec shortcut.
3 axe-core / W3C ACT Rules Valueless/empty → INCOMPLETE, needs author review axe-core PR #3635: empty ARIA values reported as incomplete, "There is no real difference between an empty ARIA attribute, a null attribute, and not having the attribute at all." W3C ACT Rule 6a7281 explicitly scopes out empty values as inapplicable.
4 WAI-ARIA 1.2 spec Valueless/empty → default undefined → not hidden §aria-hidden value table: value type is true/false/undefined (default). Missing/empty resolves to the default. aria-hidden is NOT an HTML boolean attribute — the HTML spec never designates it as such.

Browser testing shows disagreement even on the explicit aria-hidden="true" case (see Steve Faulkner's post and Mozilla bug 948540); no documented browser testing on valueless specifically.

Design choice for this rule

We lean toward fewer false positives. For this rule that means: only flag when aria-hidden is unambiguously "true" — the author has clearly asserted the element is hidden but also made it keyboard-focusable. When the signal is ambiguous (valueless, empty, "false"), we don't report a keyboard trap.

Flag (explicit hide + focusable = trap):

  • aria-hidden="true" / "TRUE" / "True" (ASCII case-insensitive)
  • aria-hidden={{true}}, {{"true"}} / case-variants

Don't flag:

  • <button aria-hidden> (valueless — ambiguous)
  • <button aria-hidden=""> (empty — ambiguous)
  • <button aria-hidden={{false}}> / "false" — explicit opt-out

Flags

<button aria-hidden="true">Trapped</button>
<a href="/x" aria-hidden="true">Link</a>
<input type="text" aria-hidden="true" />
<div tabindex="0" aria-hidden="true"></div>
<button aria-hidden={{true}}></button>
<button aria-hidden="TRUE"></button>
<div aria-hidden="true"><button>Close</button></div>   {{! descendant focusable under aria-hidden ancestor }}

Allows

<button aria-hidden="false">Click me</button>
<button aria-hidden>Click me</button>       {{! valueless — ambiguous, don't flag }}
<button aria-hidden="">Click me</button>    {{! empty — same }}
<CustomBtn aria-hidden="true" />            {{! opaque component }}
<div aria-hidden="true"><span>Just text</span></div>   {{! no focusable descendant }}

Prior art

Plugin Rule Behavior on <button aria-hidden>
jsx-a11y no-aria-hidden-on-focusable Flags (JSX coerces valueless → boolean true).
vuejs-accessibility no-aria-hidden-on-focusable Does NOT flag (rule's if (hasAriaHidden) short-circuits on null).
lit-a11y No equivalent rule.
@angular-eslint/template No equivalent rule.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 21, 2026

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
🟢 js small 14.71 ms 14.30 ms -2.8%
🟢 js medium 7.29 ms 7.02 ms -3.7%
🟢 js large 2.89 ms 2.83 ms -2.2%
🟢 gjs small 2.14 ms 1.27 ms -40.7%
gjs medium 621.58 µs 619.43 µs -0.3%
gjs large 247.97 µs 248.93 µs +0.4%
gts small 1.24 ms 1.25 ms +0.6%
gts medium 617.63 µs 623.58 µs +1.0%
🟢 gts large 254.88 µs 248.91 µs -2.3%

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

Full mitata output
clk: ~3.10 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.83 ms/iter  19.17 ms █                    
                      (12.12 ms … 31.78 ms)  28.70 ms █ █                  
                    (  5.68 mb …  10.39 mb)   7.30 mb ███▆▄█▆▄▁█▁▁▄▁▄▁▄▁▄▄▄

js small (experiment)         14.77 ms/iter  15.47 ms   █                  
                      (12.95 ms … 18.72 ms)  18.67 ms  ▅█▅  ▂▂             
                    (  6.47 mb …   8.10 mb)   6.88 mb ▇███▇▇██▇▇▇▄▁▄▇▁▇▁▁▁▄

                             ┌                                            ┐
                             ╷ ┌──────────┬─────┐                         ╷
          js small (control) ├─┤          │     ├─────────────────────────┤
                             ╵ └──────────┴─────┘                         ╵
                               ╷ ┌──┬─┐        ╷
       js small (experiment)   ├─┤  │ ├────────┤
                               ╵ └──┴─┘        ╵
                             └                                            ┘
                             12.12 ms           20.41 ms           28.70 ms

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

------------------------------------------- -------------------------------
js medium (control)            7.94 ms/iter   8.29 ms █                    
                       (6.80 ms … 14.12 ms)  14.08 ms ██                   
                    (  1.97 mb …   5.13 mb)   3.54 mb ███▅▅▅▄▃▄▂▁▂▂▁▁▂▁▂▁▁▂

js medium (experiment)         7.58 ms/iter   7.79 ms ▅█                   
                       (6.74 ms … 14.20 ms)  13.14 ms ██                   
                    (  2.67 mb …   4.19 mb)   3.52 mb ██▅▄▃▄▅▃▁▂▁▁▃▁▂▁▁▁▁▁▂

                             ┌                                            ┐
                             ╷┌─────┬──┐                                  ╷
         js medium (control) ├┤     │  ├──────────────────────────────────┤
                             ╵└─────┴──┘                                  ╵
                             ╷┌───┬┐                                ╷
      js medium (experiment) ├┤   │├────────────────────────────────┤
                             ╵└───┴┘                                ╵
                             └                                            ┘
                             6.74 ms           10.41 ms            14.08 ms

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

------------------------------------------- -------------------------------
js large (control)             3.39 ms/iter   3.27 ms  █                   
                       (2.57 ms … 10.23 ms)   8.33 ms  █                   
                    (  7.24 kb …   3.21 mb)   1.44 mb ▄█▅▂▃▁▂▂▁▂▁▁▂▁▁▁▁▁▁▁▁

js large (experiment)          3.08 ms/iter   2.94 ms  █▃                  
                        (2.57 ms … 7.21 ms)   5.91 ms  ██                  
                    (567.94 kb …   2.45 mb)   1.43 mb ▄██▂▃▂▂▂▂▂▂▂▁▁▁▁▂▁▂▁▂

                             ┌                                            ┐
                             ╷ ┌───┬                                      ╷
          js large (control) ├─┤   │──────────────────────────────────────┤
                             ╵ └───┴                                      ╵
                             ╷┌──┬                     ╷
       js large (experiment) ├┤  │─────────────────────┤
                             ╵└──┴                     ╵
                             └                                            ┘
                             2.57 ms            5.45 ms             8.33 ms

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

------------------------------------------- -------------------------------
gjs small (control)            2.20 ms/iter   2.24 ms     █                
                        (1.24 ms … 8.21 ms)   5.35 ms     ██               
                    (505.13 kb …   1.82 mb)   1.06 mb ▃▅▂▄██▂▁▁▁▁▁▁▁▁▁▁▁▂▁▁

gjs small (experiment)         1.39 ms/iter   1.32 ms █                    
                        (1.20 ms … 6.59 ms)   5.19 ms █                    
                    (350.89 kb …   1.78 mb)   1.06 mb ██▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷         ┌┬                                 ╷
         gjs small (control) ├─────────┤│─────────────────────────────────┤
                             ╵         └┴                                 ╵
                             ┌─┬                                        ╷
      gjs small (experiment) │ │────────────────────────────────────────┤
                             └─┴                                        ╵
                             └                                            ┘
                             1.20 ms            3.28 ms             5.35 ms

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

------------------------------------------- -------------------------------
gjs medium (control)         668.79 µs/iter 639.91 µs █                    
                      (589.34 µs … 5.09 ms)   1.97 ms ██                   
                    ( 29.84 kb …   1.04 mb) 540.66 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      665.27 µs/iter 638.39 µs █▃                   
                      (589.34 µs … 4.99 ms)   1.85 ms ██                   
                    ( 47.80 kb …   1.26 mb) 540.62 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌─┬                                         ╷
        gjs medium (control) ├┤ │─────────────────────────────────────────┤
                             ╵└─┴                                         ╵
                             ╷┌┬                                      ╷
     gjs medium (experiment) ├┤│──────────────────────────────────────┤
                             ╵└┴                                      ╵
                             └                                            ┘
                             589.34 µs           1.28 ms            1.97 ms

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

------------------------------------------- -------------------------------
gjs large (control)          278.50 µs/iter 262.35 µs ██                   
                      (234.20 µs … 4.80 ms) 574.54 µs ██▆                  
                    (183.05 kb … 809.21 kb) 217.28 kb ███▃▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs large (experiment)       270.96 µs/iter 262.12 µs  █▃                  
                      (236.84 µs … 4.73 ms) 358.89 µs  ██                  
                    (143.49 kb … 868.38 kb) 216.61 kb ▇██▆█▅▆▃▂▂▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌────┬                                      ╷
         gjs large (control) ├┤    │──────────────────────────────────────┤
                             ╵└────┴                                      ╵
                             ╷┌───┬          ╷
      gjs large (experiment) ├┤   │──────────┤
                             ╵└───┴          ╵
                             └                                            ┘
                             234.20 µs         404.37 µs          574.54 µs

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

------------------------------------------- -------------------------------
gts small (control)            1.33 ms/iter   1.26 ms █                    
                        (1.20 ms … 5.72 ms)   5.01 ms █                    
                    (313.35 kb …   1.82 mb)   1.06 mb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)         1.34 ms/iter   1.27 ms █                    
                        (1.20 ms … 6.26 ms)   5.20 ms █                    
                    (533.09 kb …   1.60 mb)   1.05 mb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                         ╷
         gts small (control) ││─────────────────────────────────────────┤
                             └┴                                         ╵
                             ┌─┬                                          ╷
      gts small (experiment) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             └                                            ┘
                             1.20 ms            3.20 ms             5.20 ms

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

------------------------------------------- -------------------------------
gts medium (control)         660.63 µs/iter 632.94 µs  █                   
                      (588.34 µs … 5.02 ms)   1.17 ms ▂█▂                  
                    (115.63 kb … 985.96 kb) 541.58 kb ███▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts medium (experiment)      668.42 µs/iter 640.04 µs █▃                   
                      (593.42 µs … 5.01 ms)   1.83 ms ██                   
                    (112.68 kb …   0.98 mb) 540.62 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌─┬                 ╷
        gts medium (control) ├┤ │─────────────────┤
                             ╵└─┴                 ╵
                             ╷┌─┬                                         ╷
     gts medium (experiment) ├┤ │─────────────────────────────────────────┤
                             ╵└─┴                                         ╵
                             └                                            ┘
                             588.34 µs           1.21 ms            1.83 ms

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

------------------------------------------- -------------------------------
gts large (control)          332.12 µs/iter 296.96 µs ▅█                   
                      (234.61 µs … 5.25 ms) 613.96 µs ██▄                  
                    (181.38 kb … 808.86 kb) 216.83 kb ███▅▂▂▂▁▁▂▂▁▁▂▂▁▂▄▃▄▂

gts large (experiment)       270.73 µs/iter 262.76 µs  █                   
                      (236.13 µs … 4.99 ms) 329.74 µs  █▂█                 
                    (162.19 kb …   1.37 mb) 216.72 kb ▃███▅▄█▄▆▃▂▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌──────────┬                                ╷
         gts large (control) ├┤          │────────────────────────────────┤
                             ╵└──────────┴                                ╵
                             ╷┌──┬      ╷
      gts large (experiment) ├┤  │──────┤
                             ╵└──┴      ╵
                             └                                            ┘
                             234.61 µs         424.29 µs          613.96 µs

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

johanrd added a commit that referenced this pull request Apr 21, 2026
…ec + correct peer-plugin claims

Two corrections to the previous revision:

1. Valueless / empty-string `aria-hidden` is no longer treated as a
   non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
   value table, a missing or empty-string value resolves to the default
   `undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
   (ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
   This matches ember-cli#2717 / #19's spec-first resolution.

2. Code comment corrections. jsx-a11y's util is named
   `isPresentationRole`, not `hasPresentationRole`. The comment also
   claimed jsx-a11y's `isPresentationRole` does "first token of a
   space-separated role list" — it does not (jsx-a11y does plain
   `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
   first-token behavior is a deliberate superset, not parity.

Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
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 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

Adds a new template accessibility rule to the plugin: ember/template-no-aria-hidden-on-focusable, intended to prevent the “aria-hidden + focusable = keyboard trap” anti-pattern in Ember templates (HBS and GJS/GTS).

Changes:

  • Add template-no-aria-hidden-on-focusable rule with direct + descendant-focusable detection.
  • Introduce shared utilities for component-invocation detection and HTML “interactive content” classification.
  • Add rule docs, README rule-table entry, and a comprehensive set of tests (plus a peer-parity audit fixture).

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/lib/utils/is-component-invocation-test.js Unit tests for new isComponentInvocation utility.
tests/lib/utils/html-interactive-content-test.js Unit tests for new HTML interactive content utility.
tests/lib/rules/template-no-aria-hidden-on-focusable.js Main rule test suite for both GJS/GTS and HBS modes.
tests/audit/no-aria-hidden-on-focusable/peer-parity.js Peer-plugin parity audit fixture for translated upstream cases.
lib/utils/is-component-invocation.js New helper to treat PascalCase / path-based tags as component invocations (opaque).
lib/utils/html-interactive-content.js New helper classifying tags per HTML interactive content model (+ <summary>).
lib/rules/template-no-aria-hidden-on-focusable.js New rule implementation: detects aria-hidden="true" on focusables and ancestors of focusables.
docs/rules/template-no-aria-hidden-on-focusable.md Documentation for the new rule, including examples and caveats.
README.md Adds the new rule to the published rule list.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-no-aria-hidden-on-focusable.js Outdated
Comment thread lib/utils/is-component-invocation.js Outdated
Comment thread lib/rules/template-no-aria-hidden-on-focusable.js Outdated
Comment thread lib/rules/template-no-aria-hidden-on-focusable.js Outdated
Comment thread tests/audit/no-aria-hidden-on-focusable/peer-parity.js Outdated
@johanrd johanrd force-pushed the feat/template-no-aria-hidden-on-focusable branch from 822a3ab to 88ba3ca Compare April 22, 2026 12:21
@johanrd johanrd requested a review from Copilot April 22, 2026 12:58
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 9 out of 9 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread docs/rules/template-no-aria-hidden-on-focusable.md
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 9 out of 9 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-no-aria-hidden-on-focusable.js
Comment thread lib/rules/template-no-aria-hidden-on-focusable.js
Comment thread docs/rules/template-no-aria-hidden-on-focusable.md
Comment thread lib/rules/template-no-aria-hidden-on-focusable.js
Comment thread tests/lib/rules/template-no-aria-hidden-on-focusable.js
Comment thread tests/lib/utils/is-native-element-test.js
johanrd added a commit that referenced this pull request Apr 24, 2026
…view)

Per HTML §6.6.3 'Sequential focus navigation', none of <details>,
<option>, or <datalist> are focusable by default:
- <details>: the focusable control is its <summary> child
- <option>: not in default tab order; <select> is focused and arrow keys
  navigate options within it
- <datalist>: no user-facing UI; the paired <input list> is focused

Including them in UNCONDITIONAL_FOCUSABLE_TAGS was over-aggressive and
caused false positives on this rule. Tests updated to pin the
now-allowed cases.
@johanrd johanrd requested a review from Copilot April 24, 2026 13:40
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 9 out of 9 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-no-aria-hidden-on-focusable.js
Comment thread lib/rules/template-no-aria-hidden-on-focusable.js
Comment thread lib/rules/template-no-aria-hidden-on-focusable.js
Comment thread docs/rules/template-no-aria-hidden-on-focusable.md Outdated
johanrd added a commit that referenced this pull request Apr 24, 2026
… via shared helper (Copilot review)

Extract a new `getStaticAttrValue` util that resolves literal-valued
mustaches (`{{"foo"}}`, `{{true}}`, `{{-1}}`) and single-part concat
statements (`"{{true}}"`) to their static string value. `isAriaHiddenTruthy`
now delegates to the helper and compares the resolved string to `'true'`
(case-insensitive, whitespace-trimmed).

Behavior change: valueless `<h1 aria-hidden>`, `aria-hidden=""`, and the
mustache-empty-string equivalents (`aria-hidden={{""}}`, `aria-hidden="{{""}}"`,
`aria-hidden={{" "}}`) are no longer treated as hidden. Per WAI-ARIA 1.2
§6.6 value table, those shapes resolve to the default `undefined` — NOT
`true` — so the empty-content check still applies. Drops the previous
"fewer false positives" deviation rationale in favour of spec-literal
consistency with sibling rules (#19, #35, #41) that share the same
aria-hidden resolution.

Byte-identical carrier of lib/utils/static-attr-value.js across all PRs
that land it.
@johanrd johanrd requested a review from Copilot April 24, 2026 17:48
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
…ec + correct peer-plugin claims

Two corrections to the previous revision:

1. Valueless / empty-string `aria-hidden` is no longer treated as a
   non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
   value table, a missing or empty-string value resolves to the default
   `undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
   (ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
   This matches ember-cli#2717 / #19's spec-first resolution.

2. Code comment corrections. jsx-a11y's util is named
   `isPresentationRole`, not `hasPresentationRole`. The comment also
   claimed jsx-a11y's `isPresentationRole` does "first token of a
   space-separated role list" — it does not (jsx-a11y does plain
   `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
   first-token behavior is a deliberate superset, not parity.

Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
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 force-pushed the feat/template-no-aria-hidden-on-focusable branch from 932d431 to 1d3c863 Compare April 26, 2026 08:10
@johanrd johanrd requested a review from Copilot April 26, 2026 08:41
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 7 out of 7 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/rules/template-no-aria-hidden-on-focusable.js
@johanrd johanrd force-pushed the feat/template-no-aria-hidden-on-focusable branch from b629330 to 921767d Compare April 27, 2026 14:01
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
…ec + correct peer-plugin claims

Two corrections to the previous revision:

1. Valueless / empty-string `aria-hidden` is no longer treated as a
   non-interactive escape hatch. Per WAI-ARIA 1.2 §6.6 + aria-hidden
   value table, a missing or empty-string value resolves to the default
   `undefined` — NOT `true`. Only an explicit `aria-hidden="true"`
   (ASCII case-insensitive) or mustache-literal `{{true}}` opts out.
   This matches ember-cli#2717 / #19's spec-first resolution.

2. Code comment corrections. jsx-a11y's util is named
   `isPresentationRole`, not `hasPresentationRole`. The comment also
   claimed jsx-a11y's `isPresentationRole` does "first token of a
   space-separated role list" — it does not (jsx-a11y does plain
   `presentationRoles.has(rawValue)`, no trim/lowercase/split). Our
   first-token behavior is a deliberate superset, not parity.

Moved `<div aria-hidden onclick>` and `<div aria-hidden="" onclick>` from
the valid section to invalid. Added `<div aria-hidden="TRUE">` as
additional valid coverage for the case-insensitive path.
@johanrd johanrd force-pushed the feat/template-no-aria-hidden-on-focusable branch from 921767d to 5f0a5da Compare April 27, 2026 19:26
@johanrd johanrd requested a review from Copilot April 27, 2026 19:33
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 6 out of 6 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +12 to +78
valid: [
// aria-hidden on non-focusable elements — fine.
'<template><div aria-hidden="true"></div></template>',
'<template><span aria-hidden="true">decorative</span></template>',
'<template><img src="/x.png" alt="" aria-hidden="true" /></template>',

// Focusable elements without aria-hidden — fine.
'<template><button>Click me</button></template>',
'<template><a href="/x">Link</a></template>',
'<template><input type="text" /></template>',

// aria-hidden="false" — explicit opt-out. Not flagged.
'<template><button aria-hidden="false">Click me</button></template>',

// Valueless / empty aria-hidden resolves to default `undefined` per
// WAI-ARIA 1.2 §6.6 — not hidden, not flagged even on focusable hosts.
'<template><button aria-hidden>Click me</button></template>',
'<template><button aria-hidden="">Click me</button></template>',
'<template><button aria-hidden={{false}}>Click me</button></template>',

// <input type="hidden"> isn't focusable, so aria-hidden on it is fine.
'<template><input type="hidden" aria-hidden="true" /></template>',
// Mustache string literal — statically resolvable to "hidden".
'<template><input type={{"hidden"}} aria-hidden="true" /></template>',

// <a> without href isn't focusable by default.
'<template><a aria-hidden="true">Not a link</a></template>',

// <label> is HTML interactive content but NOT keyboard-focusable by default
// (clicks forward to the associated control; the label itself isn't in the
// tab order). So aria-hidden on it is fine.
'<template><label aria-hidden="true">Name</label></template>',

// Disabled form controls are removed from the tab order (HTML §4.10.18.5),
// so they're not keyboard-focusable and aria-hidden on them isn't a trap.
'<template><button disabled aria-hidden="true">Click me</button></template>',
'<template><input disabled aria-hidden="true" /></template>',

// Components — we don't know if they render a focusable element.
'<template><CustomBtn aria-hidden="true" /></template>',

// <audio> / <video> without `controls` are not interactive — no focusable UI.
'<template><video aria-hidden="true"></video></template>',
'<template><audio aria-hidden="true"></audio></template>',
'<template><div aria-hidden="true"><video></video></div></template>',
'<template><div aria-hidden="true"><audio></audio></div></template>',

// Descendant-focusable check — valid cases.
// No focusable descendant.
'<template><div aria-hidden="true"><span>Just text</span></div></template>',
// Component descendants are opaque — conservatively not flagged.
'<template><div aria-hidden="true"><Button>X</Button></div></template>',
// No focusable descendants (alt-less img is decorative, not focusable).
'<template><div aria-hidden="true"><img alt="static" /></div></template>',
// <input type="hidden"> is non-focusable per isFocusable.
'<template><div aria-hidden="true"><input type="hidden" /></div></template>',
// Event modifiers (`{{on "click" ...}}`) do not make an element focusable —
// only tabindex / inherent native focusability does.
'<template><div {{on "click" this.handler}} aria-hidden="true"></div></template>',

// Dynamic mustache descendants are not inspected.
'<template><div aria-hidden="true">{{this.label}}</div></template>',
// `@arg`-prefixed tag is opaque.
'<template><div aria-hidden="true"><@thing /></div></template>',
// `this.`-prefixed tag is opaque.
'<template><div aria-hidden="true"><this.Item /></div></template>',
],
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

Add regression tests for the static-true cases that currently won’t be detected if aria-hidden is expressed as a quoted mustache/concat (e.g. aria-hidden="{{true}}" / aria-hidden="{{'true'}}"). These are common template forms and help ensure isAriaHiddenTrue handles GlimmerConcatStatement correctly.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +222
// Disabled form controls are removed from the tab order (HTML §4.10.18.5),
// so they're not keyboard-focusable and aria-hidden on them isn't a trap.
'<template><button disabled aria-hidden="true">Click me</button></template>',
'<template><input disabled aria-hidden="true" /></template>',

// Components — we don't know if they render a focusable element.
'<template><CustomBtn aria-hidden="true" /></template>',

// <audio> / <video> without `controls` are not interactive — no focusable UI.
'<template><video aria-hidden="true"></video></template>',
'<template><audio aria-hidden="true"></audio></template>',
'<template><div aria-hidden="true"><video></video></div></template>',
'<template><div aria-hidden="true"><audio></audio></div></template>',

// Descendant-focusable check — valid cases.
// No focusable descendant.
'<template><div aria-hidden="true"><span>Just text</span></div></template>',
// Component descendants are opaque — conservatively not flagged.
'<template><div aria-hidden="true"><Button>X</Button></div></template>',
// No focusable descendants (alt-less img is decorative, not focusable).
'<template><div aria-hidden="true"><img alt="static" /></div></template>',
// <input type="hidden"> is non-focusable per isFocusable.
'<template><div aria-hidden="true"><input type="hidden" /></div></template>',
// Event modifiers (`{{on "click" ...}}`) do not make an element focusable —
// only tabindex / inherent native focusability does.
'<template><div {{on "click" this.handler}} aria-hidden="true"></div></template>',

// Dynamic mustache descendants are not inspected.
'<template><div aria-hidden="true">{{this.label}}</div></template>',
// `@arg`-prefixed tag is opaque.
'<template><div aria-hidden="true"><@thing /></div></template>',
// `this.`-prefixed tag is opaque.
'<template><div aria-hidden="true"><this.Item /></div></template>',
],
invalid: [
// Native interactive elements.
{
code: '<template><button aria-hidden="true">Trapped</button></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
code: '<template><a href="/x" aria-hidden="true">Link</a></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
code: '<template><input type="text" aria-hidden="true" /></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
code: '<template><select aria-hidden="true"><option /></select></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
code: '<template><textarea aria-hidden="true"></textarea></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},

// Non-interactive element made focusable via tabindex.
{
code: '<template><div tabindex="0" aria-hidden="true"></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
// tabindex="-1" still makes it programmatically focusable — still flag.
code: '<template><div tabindex="-1" aria-hidden="true"></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
// DIVERGENCE from jsx-a11y + vue-a11y: both accept button[tabindex="-1"][aria-hidden="true"]
// reasoning that tabindex="-1" removes the element from the tab order.
// Our rule treats tabindex="-1" as still programmatically focusable (reachable via
// .focus() and click), so aria-hidden on it still creates an AT-invisibility mismatch.
{
code: '<template><button aria-hidden="true" tabindex="-1"></button></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},

// Mustache-boolean + case-variant aria-hidden = true — truthy per spec.
{
code: '<template><button aria-hidden={{true}}></button></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
code: '<template><button aria-hidden="TRUE"></button></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
// Whitespace-padded "true" is still a truthy aria-hidden per enumerated-
// attribute normalization (trim + case-insensitive).
code: '<template><button aria-hidden=" true "></button></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},

// Descendant-focusable check. Per WAI-ARIA 1.2 §aria-hidden
// "may receive focus" — focusable descendants are keyboard-reachable
// under an aria-hidden ancestor, creating a keyboard trap.
{
// Classic modal-backdrop trap.
code: '<template><div aria-hidden="true"><button>Close</button></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
{
// Deeper descendant.
code: '<template><div aria-hidden="true"><span><button>Deep</button></span></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
{
code: '<template><div aria-hidden="true"><a href="/x">Link</a></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
{
code: '<template><div aria-hidden="true"><input /></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
{
// Depth check — focusable descendant two levels deep.
code: '<template><section aria-hidden="true"><div><textarea></textarea></div></section></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
{
// tabindex on a descendant makes it focusable.
code: '<template><div aria-hidden="true"><span tabindex="0">x</span></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
// DIVERGENCE from vue-a11y: it considers tabindex="-1" on a descendant as "escaped from
// tab order = not focusable". Our isFocusable treats any tabindex (including "-1") as
// programmatically focusable, so the ancestor aria-hidden still creates a trap.
{
code: '<template><div aria-hidden="true"><button tabindex="-1">Trapped</button></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
{
code: '<template><div aria-hidden="true"><a href="#" tabindex="-1">Link</a></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},

// <audio controls> / <video controls> expose focusable UI; flag directly.
{
code: '<template><video controls aria-hidden="true"></video></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
{
code: '<template><audio controls aria-hidden="true"></audio></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnFocusable' }],
},
// <audio controls> / <video controls> as focusable descendants of an
// aria-hidden ancestor.
{
code: '<template><div aria-hidden="true"><video controls></video></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
{
code: '<template><div aria-hidden="true"><audio controls></audio></div></template>',
output: null,
errors: [{ messageId: 'noAriaHiddenOnAncestorOfFocusable' }],
},
],
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

Consider adding a test case for disabled={{false}} combined with aria-hidden="true" (and the analogous descendant case) to lock in the intended behavior that literal-false boolean attributes render as absent in Glimmer. Right now the rule would treat disabled={{false}} as disabled due to attribute presence checks.

Copilot uses AI. Check for mistakes.
Comment on lines +82 to +92
describe('<img>', () => {
it('is interactive when usemap is present', () => {
expect(isHtmlInteractiveContent(makeNode('img', { usemap: '#m' }), getTextAttrValue)).toBe(
true
);
});

it('is NOT interactive without usemap', () => {
expect(isHtmlInteractiveContent(makeNode('img'), getTextAttrValue)).toBe(false);
});
});
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

isHtmlInteractiveContent has an ignoreUsemap option but the test suite doesn’t currently exercise it. Add a test asserting <img usemap> is treated as non-interactive when { ignoreUsemap: true } to prevent regressions in callers that rely on this opt-out.

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +56
Component invocations, argument/`this`/path-based tags, and namespace-pathed
tags are "opaque" — we can't statically know what they render. The descendant
check skips these branches to avoid false positives. If a component renders a
focusable element beneath an `aria-hidden` ancestor, the keyboard trap still
exists at runtime; this rule can't detect it.
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The “Caveats” section lists several kinds of opaque tags but doesn’t mention custom elements (e.g. <my-widget>). Since the rule uses isNativeElement, custom elements are also skipped, which can surprise readers expecting tabindex-based focusability on custom elements to be reported. Consider explicitly mentioning custom elements in this caveat list to align docs with behavior.

Suggested change
Component invocations, argument/`this`/path-based tags, and namespace-pathed
tags are "opaque" — we can't statically know what they render. The descendant
check skips these branches to avoid false positives. If a component renders a
focusable element beneath an `aria-hidden` ancestor, the keyboard trap still
exists at runtime; this rule can't detect it.
Component invocations, argument/`this`/path-based tags, namespace-pathed tags,
and custom elements (for example, `<my-widget>`) are "opaque" — we can't
statically know what they render. The descendant check skips these branches to
avoid false positives. If one of these renders a focusable element beneath an
`aria-hidden` ancestor, the keyboard trap still exists at runtime; this rule
can't detect it.

Copilot uses AI. Check for mistakes.
@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