Skip to content

Phase 3 audit — wave findings (18 fixtures, 4 must-fix bugs)#28

Closed
johanrd wants to merge 1 commit intoaudit/a11y-parityfrom
audit/phase3/behavior-wave-findings
Closed

Phase 3 audit — wave findings (18 fixtures, 4 must-fix bugs)#28
johanrd wants to merge 1 commit intoaudit/a11y-parityfrom
audit/phase3/behavior-wave-findings

Conversation

@johanrd
Copy link
Copy Markdown
Owner

@johanrd johanrd commented Apr 21, 2026

Phase 3 audit wave — follow-up backlog

18 new tests/audit/<concept>/peer-parity.js fixtures (≈1228 translated test cases) have been produced after PR batch #5#27. This PR adds a summary of findings to docs/audit-a11y-behavior.md; the individual fixture branches listed below are not merged here.

Full summary: Phase 3 section of docs/audit-a11y-behavior.md.

Fixture branches — P0 (ride along with each rule PR)

# Concept PR Cases
B1 click-events-have-key-events #17 59
B2 mouse-events-have-key-events #18 45
B3 no-aria-hidden-on-focusable #19 38
B4 no-interactive-element-to-noninteractive-role #20 184
B5 no-noninteractive-element-to-interactive-role #21 292
B6 no-role-presentation-on-focusable #22 28
B7 anchor-is-valid (invalidHref) #23 37
B8 no-noninteractive-tabindex #24 29

Fixture branches — P1 (child branches off audit/a11y-parity)

Ten audit/phase3/<concept> branches ready to fast-forward into audit/a11y-parity:

  • audit/phase3/aria-props — 93 cases
  • audit/phase3/no-autofocus — 37 cases
  • audit/phase3/no-distracting-elements — 49 cases
  • audit/phase3/no-static-element-interactions — 106 cases
  • audit/phase3/anchor-has-content — 39 cases (documents coverage gap)
  • audit/phase3/interactive-supports-focus — 63 cases (documents coverage gap)
  • audit/phase3/html-has-lang — 38 cases
  • audit/phase3/button-has-type — 31 cases
  • audit/phase3/anchor-ambiguous-text — 52 cases
  • audit/phase3/obj-alt — 16 cases

Must-fix bugs (F1–F4)

Each becomes its own targeted PR.

  • F1 aria-orientation="undefined" FP. lib/rules/template-no-invalid-aria-attributes.js:20-22 short-circuits on value === 'undefined' before checking aria-query's token list, where undefined is a legitimate token value for this attribute. One-line fix. Source: B9Resolved by #2723 (fix(template-no-invalid-aria-attributes): absorb allowundefined handling into validateByType).
  • F2 PascalCase-component collision with native HTML tag names. Several rules do node.tag?.toLowerCase() and compare to a set of native tag names, misclassifying <Article>/<Form>/<Main> components as the native tag. Confirmed FPs: <Article tabindex={{0}}> (B8), <Article role="button"> (B5). Extract isComponentInvocation(node) helper. Sources: B5, B8Resolved by #2724 (refactor: extract isNativeElement util) — lists+scope pattern per Post-merge-review: extend allowlist with svg-tags on template-no-block-params-for-html-elements ember-cli/eslint-plugin-ember#2689, heuristic approach rejected.
  • F3 <option> missing from INHERENTLY_INTERACTIVE_TAGS in template-click-events-have-key-events. Source: B1Resolved architecturally via #37: <option> is not in HTML §3.2.5.2.7 Interactive Content (the html-interactive-content util's authority) but IS an ARIA widget per aria-query (the interactive-roles util's authority). Rules needing it consult the ARIA-widget authority; those not needing it don't. See the authority split PR body for details.
  • F4 Missing non-interactive tags in NON_INTERACTIVE_TAGS (template-no-noninteractive-element-to-interactive-role): section, address, aside, code, del, em, fieldset, hr, html, ins, optgroup, output, strong, sub, sup, tbody, tfoot, thead (~22 tags). Source: B518 of the 22 tags landed via #21's follow-up commit 4cd92691 (union of aria-query elementRoles + axobject-query fallback).

Behavioral gaps (G1–G7) — design discussion

  • G1 Escape-hatch awareness: template-no-invalid-interactive + template-click-events-have-key-events don't treat role="presentation" / role="none" / aria-hidden="true" as opt-outs. (B1, B12)Resolved by #33 (template-no-invalid-interactive escape hatches) and #17 (click-events aria-hidden / role=presentation skip).
  • G2 Reconcile NATIVE_INTERACTIVE_ELEMENTS: option/menuitem/datalist missing; <audio/video controls> treatment inconsistent; <input type="hidden"> excluded where peers include. (B12)Resolved by #37 via the HTML-content-model authority split — html-interactive-content.js cites HTML §3.2.5.2.7 as the sole authority for the util.
  • G3 template-no-autofocus-attribute value-blind: flags autofocus={{false}} / ="false" that jsx-a11y accepts. (B10)Resolved by #32. Value-aware for mustache boolean-literal {{false}} only (verified against Glimmer VM source); autofocus="false" still flagged per HTML boolean-attribute spec.
  • G4 <dialog autofocus> exception missing. (B10)Resolved by #32 — MDN-backed ancestor-walk exemption; matches angular-eslint PR #2851.
  • G5 No descendant recursion in template-no-aria-hidden-on-focusable + template-no-role-presentation-on-focusable. (B3, B6)Resolved per-spec with asymmetric stances. The two attributes cascade differently, so the rules do too: aria-hidden DOES cascade per WAI-ARIA 1.2 ("user agents SHOULD NOT expose… all descendants to assistive technologies"), so #19 recurses via hasFocusableDescendant — matches axe-core's aria-hidden-focus. role="presentation" / "none" does NOT cascade per WAI-ARIA 1.2 §4.6 ("does not cascade to its descendants"), so #22 does not recurse — deliberately diverges from vue-a11y's recursion (documented at the top of the rule source). If a future author-misconception-catcher case emerges (<div role="presentation"><input /></div> as a common mistake), that would be a separate rule (template-no-role-presentation-above-focusable or similar), not an option on the existing rule.
  • G6 template-no-invalid-aria-attributes flags custom elements; angular skips tags with hyphen. (B9)Working as intended. aria-* attribute values are spec-normative regardless of element type: <my-widget aria-expanded="notvalid"> is wrong whether or not the component defines its own semantics, because aria-query's value tables derive from WAI-ARIA and apply to any element carrying the attribute. Angular's "skip-on-hyphen" is defensive over-breadth, not superior accuracy. If a real ecosystem emerges where custom elements legitimately redefine aria-* semantics (none today), we can add an opt-in allowCustomElements: true option — not without a concrete bug report.
  • G7 <embed role="img">, <summary role="img">, <td/th role="img"> in non-grid tables, <datalist role="img"> — axobject-query-derived tag set surfaces FPs. Consider switching to aria-query elementRoles-first. (B4)still open; tracks as epic-scope refactor (see review notes).

Missing-rule ports (M1–M2)

  • M1 template-anchor-has-content — flag <a href><span aria-hidden>…</span></a>. (B13 — 4 cases)Resolved by #35.
  • M2 template-interactive-supports-focus — flag <div role="button" {{on "click" …}}> without tabindex. (B14 — 7 MISSING + 18 DIVERGENCE)Resolved by #36 (role-gated variant — a deliberate divergence from all three peers, see feat: add template-interactive-supports-focus #36 doc).

Recommended sequencing

  1. F1, F3, F4 — small, self-contained, low-risk. Done.
  2. ~~F2 — shared util; touches ~5 rules; high-value.~~ Done.
  3. G3+G4 — bundle as one autofocus PR. Done via fix: template-no-autofocus-attribute — value-aware + <dialog> exception #32.
  4. G5 — needs design call. Resolved.
  5. M1, M2 — separate port PRs. Done.
  6. G6, G7 — remaining open items.

… bugs

Adds a "Phase 3 — PR-wave + P1 follow-up" section to
audit-a11y-behavior.md that consolidates the audit findings from 18 new
peer-parity fixtures created after PR batch #5-#27:

  P0 fixtures (on PR branches #17-#24): click-events, mouse-events,
    aria-hidden-on-focusable, no-interactive-element-to-noninteractive-
    role, no-noninteractive-element-to-interactive-role,
    no-role-presentation-on-focusable, anchor-is-valid/invalidHref,
    no-noninteractive-tabindex.

  P1 fixtures (on audit/phase3/<concept> branches): aria-props,
    no-autofocus, no-distracting-elements, no-static-element-
    interactions, anchor-has-content, interactive-supports-focus,
    html-has-lang+lang, button-has-type, anchor-ambiguous-text, obj-alt.

Documents 4 must-fix bugs (aria-orientation short-circuit; PascalCase-
component collision with native HTML tag names; <option> missing from
click-events; 22 non-interactive tags missing from
noninteractive-to-interactive derivation), 7 behavioral gaps, and 2
missing-rule port candidates (template-anchor-has-content,
template-interactive-supports-focus).

Recommends ~9 fix PRs + 2 port PRs as Phase 3 follow-up.
@github-actions
Copy link
Copy Markdown

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
js small 13.65 ms 13.68 ms +0.2%
🟢 js medium 6.70 ms 6.52 ms -2.6%
🟢 js large 2.64 ms 2.56 ms -2.8%
gjs small 1.10 ms 1.10 ms +0.3%
gjs medium 549.90 µs 549.55 µs -0.1%
gjs large 220.03 µs 217.10 µs -1.3%
gts small 1.09 ms 1.09 ms +0.0%
gts medium 548.85 µs 548.34 µs -0.1%
gts large 217.93 µs 217.27 µs -0.3%

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

Full mitata output
clk: ~2.61 GHz
cpu: AMD EPYC 9V74 80-Core Processor
runtime: node 24.14.1 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
------------------------------------------- -------------------------------
js small (control)            16.29 ms/iter  17.43 ms █ ▂                  
                      (11.21 ms … 31.22 ms)  29.15 ms █▅█ ▇                
                    (  5.67 mb …  10.59 mb)   7.23 mb ███▄█▄▄▇▄▄▁▄▄▁▄▁▁▇▁▁▇

js small (experiment)         14.06 ms/iter  15.13 ms   █                  
                      (11.91 ms … 18.31 ms)  18.29 ms ████▅ █▅  █          
                    (  6.40 mb …   8.03 mb)   6.89 mb █████▁██▁███▁▁███▁▁▁▅

                             ┌                                            ┐
                             ╷┌───────────┬──┐                            ╷
          js small (control) ├┤           │  ├────────────────────────────┤
                             ╵└───────────┴──┘                            ╵
                               ╷ ┌──┬──┐       ╷
       js small (experiment)   ├─┤  │  ├───────┤
                               ╵ └──┴──┘       ╵
                             └                                            ┘
                             11.21 ms           20.18 ms           29.15 ms

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

------------------------------------------- -------------------------------
js medium (control)            7.43 ms/iter   7.74 ms  █                   
                       (6.19 ms … 14.73 ms)  13.37 ms ▂█                   
                    (  2.56 mb …   4.55 mb)   3.53 mb ███▃▄▆▃▁▂▂▃▂▁▁▁▁▂▁▁▂▂

js medium (experiment)         7.22 ms/iter   7.30 ms  █                   
                       (6.06 ms … 14.54 ms)  12.64 ms  █                   
                    (  2.66 mb …   4.34 mb)   3.52 mb ▇█▇▆▂▃▂▃▂▃▂▂▁▁▂▁▂▁▁▂▂

                             ┌                                            ┐
                              ╷ ┌────┬─┐                                  ╷
         js medium (control)  ├─┤    │ ├──────────────────────────────────┤
                              ╵ └────┴─┘                                  ╵
                             ╷ ┌────┬┐                                ╷
      js medium (experiment) ├─┤    │├────────────────────────────────┤
                             ╵ └────┴┘                                ╵
                             └                                            ┘
                             6.06 ms            9.71 ms            13.37 ms

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

------------------------------------------- -------------------------------
js large (control)             3.08 ms/iter   2.96 ms  █                   
                       (2.21 ms … 10.29 ms)   8.43 ms ██▃                  
                    (352.55 kb …   2.92 mb)   1.45 mb ███▃▃▄▂▂▁▁▃▂▂▂▁▁▁▁▁▁▁

js large (experiment)          2.82 ms/iter   2.72 ms  █                   
                        (2.34 ms … 7.64 ms)   5.70 ms ▄█▇                  
                    (224.92 kb …   2.65 mb)   1.43 mb ███▃▃▂▂▂▂▂▂▁▂▂▁▁▁▂▁▁▂

                             ┌                                            ┐
                             ╷┌────┬                                      ╷
          js large (control) ├┤    │──────────────────────────────────────┤
                             ╵└────┴                                      ╵
                              ╷┌─┬                    ╷
       js large (experiment)  ├┤ │────────────────────┤
                              ╵└─┴                    ╵
                             └                                            ┘
                             2.21 ms            5.32 ms             8.43 ms

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

------------------------------------------- -------------------------------
gjs small (control)            1.21 ms/iter   1.12 ms █                    
                        (1.08 ms … 5.64 ms)   5.30 ms █                    
                    (435.45 kb …   1.66 mb)   1.06 mb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs small (experiment)         1.20 ms/iter   1.12 ms █                    
                        (1.07 ms … 5.71 ms)   5.14 ms █                    
                    (196.60 kb …   1.82 mb)   1.06 mb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
         gjs small (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌┬                                         ╷
      gjs small (experiment) ││─────────────────────────────────────────┤
                             └┴                                         ╵
                             └                                            ┘
                             1.07 ms            3.18 ms             5.30 ms

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

------------------------------------------- -------------------------------
gjs medium (control)         598.32 µs/iter 558.51 µs █                    
                      (532.04 µs … 5.25 ms)   2.97 ms █                    
                    ( 72.64 kb …   1.10 mb) 540.93 kb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      598.68 µs/iter 556.11 µs █                    
                      (531.30 µs … 5.76 ms)   1.85 ms █                    
                    ( 84.12 kb … 997.13 kb) 540.66 kb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                           ╷
        gjs medium (control) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             ┌┬                      ╷
     gjs medium (experiment) ││──────────────────────┤
                             └┴                      ╵
                             └                                            ┘
                             531.30 µs           1.75 ms            2.97 ms

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

------------------------------------------- -------------------------------
gjs large (control)          262.00 µs/iter 231.09 µs █                    
                      (212.60 µs … 5.49 ms) 636.81 µs █                    
                    (216.09 kb … 802.68 kb) 217.08 kb ██▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs large (experiment)       237.89 µs/iter 224.65 µs  █                   
                      (210.86 µs … 4.96 ms) 374.89 µs ██▂                  
                    ( 15.19 kb …   1.03 mb) 216.62 kb ███▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌───┬                                       ╷
         gjs large (control) ├┤   │───────────────────────────────────────┤
                             ╵└───┴                                       ╵
                             ┌──┬             ╷
      gjs large (experiment) │  │─────────────┤
                             └──┴             ╵
                             └                                            ┘
                             210.86 µs         423.83 µs          636.81 µs

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

------------------------------------------- -------------------------------
gts small (control)            1.19 ms/iter   1.10 ms █                    
                        (1.07 ms … 6.04 ms)   5.08 ms █                    
                    (197.88 kb …   1.60 mb)   1.06 mb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)         1.19 ms/iter   1.11 ms █                    
                        (1.07 ms … 6.28 ms)   5.31 ms █                    
                    (195.97 kb …   1.60 mb)   1.05 mb █▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬                                         ╷
         gts small (control) ││─────────────────────────────────────────┤
                             └┴                                         ╵
                             ┌┬                                           ╷
      gts small (experiment) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             └                                            ┘
                             1.07 ms            3.19 ms             5.31 ms

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

------------------------------------------- -------------------------------
gts medium (control)         601.02 µs/iter 555.47 µs █                    
                      (532.24 µs … 5.72 ms)   1.36 ms █▂                   
                    ( 98.10 kb …   1.04 mb) 540.93 kb ██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts medium (experiment)      595.37 µs/iter 554.70 µs █                    
                      (530.82 µs … 5.64 ms)   2.68 ms █                    
                    (150.95 kb …   1.28 mb) 541.34 kb █▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌┬               ╷
        gts medium (control) ││───────────────┤
                             └┴               ╵
                             ┌┬                                           ╷
     gts medium (experiment) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             └                                            ┘
                             530.82 µs           1.61 ms            2.68 ms

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

------------------------------------------- -------------------------------
gts large (control)          237.84 µs/iter 224.83 µs  █                   
                      (211.76 µs … 5.15 ms) 285.61 µs  █▇                  
                    ( 17.26 kb … 776.84 kb) 216.64 kb ▅██▄▇▆▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts large (experiment)       237.49 µs/iter 224.26 µs  █                   
                      (211.34 µs … 5.02 ms) 282.52 µs  ██                  
                    (215.70 kb … 901.13 kb) 216.63 kb ▃██▃▅▇▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌─────────────┬                            ╷
         gts large (control) ├─┤             │────────────────────────────┤
                             ╵ └─────────────┴                            ╵
                             ╷ ┌─────────────┬                          ╷
      gts large (experiment) ├─┤             │──────────────────────────┤
                             ╵ └─────────────┴                          ╵
                             └                                            ┘
                             211.34 µs         248.47 µs          285.61 µs

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

johanrd added a commit that referenced this pull request Apr 21, 2026
…events check

Premise: jsx-a11y accepts `aria-hidden={true}` (JSX boolean literal) as a
static opt-out of the click-events-have-key-events check. The HBS analog
is `aria-hidden={{true}}` — a GlimmerMustacheStatement whose path is a
GlimmerBooleanLiteral with value `true`. The previous
`isHiddenFromScreenReader` helper happened to treat this correctly, but
only because it also treated ANY mustache value as truthy (e.g.
`aria-hidden={{this.maybeHidden}}` would silence the rule as if the
element were guaranteed hidden). That is too permissive: a dynamic value
can't be proven hidden at lint time, so the rule should still fire.

Conclusion: rewrite the aria-hidden check to distinguish the two shapes
explicitly.

- `isHiddenFromScreenReader` now only returns true for:
  - GlimmerTextNode value with chars `""` (bare attribute) or `"true"`
    (case-insensitive, trimmed).
  - GlimmerMustacheStatement whose `path` is a GlimmerBooleanLiteral
    with value `true` — i.e. the literal `{{true}}`.
- Other mustache shapes (path references, helper calls) are now treated
  as dynamic/unknown, so the rule fires. Authors who want a static
  escape hatch should write `aria-hidden` or `aria-hidden="true"` or
  `aria-hidden={{true}}`.
- Tests added for the mustache-literal valid case and for the new
  dynamic-mustache invalid cases (`{{false}}`, `{{this.x}}`).
- Audit fixture: adds `aria-hidden={{true}}` as explicit parity with
  jsx-a11y's `aria-hidden={true}`.

Tracks PR #28 item G1 (escape-hatch awareness). Companion to the
master-side `template-no-invalid-interactive` fix.
@johanrd johanrd closed this Apr 22, 2026
johanrd added a commit that referenced this pull request Apr 26, 2026
…nd aria-hidden as escape hatches

Premise: peer a11y plugins (jsx-a11y `hasPresentationRole` + aria-hidden
handling inside `isInteractiveElement`; vuejs-accessibility's equivalent)
treat `role="presentation"` / `role="none"` and `aria-hidden`
(boolean / "true" / `{{true}}`) as explicit opt-outs of the interactivity
contract. An element that has opted out via ARIA does not need an
interactive handler check — the handler is authored acknowledging the
element is decorative or hidden from AT.

Conclusion: wire that opt-out into `template-no-invalid-interactive`
before the native/role-based interactivity probe runs.

- Adds `hasNonInteractiveEscapeHatch(node)` covering:
  - `role="presentation"` / `role="none"` (case-insensitive, trimmed,
    first token of a space-separated role list — matches jsx-a11y).
  - `aria-hidden` as bare boolean attribute, the text value `"true"`,
    or the mustache-literal `{{true}}`. `aria-hidden="false"` does NOT
    qualify.
- Visitor short-circuits on escape-hatch hit before computing interactivity.
- Tests: new valid cases for every escape-hatch shape; new invalid
  cases guarding that `aria-hidden="false"` and other roles still flag.
- Audit fixture `tests/audit/no-static-element-interactions/peer-parity.js`
  added to master as evidence of parity. D1 (role=presentation/none) and
  D2 (aria-hidden) divergences previously documented in the Phase-3 audit
  branch are now parity cases in the valid block.

Tracks PR #28 item G1 (escape-hatch awareness across interactive-handler
rules).
johanrd added a commit that referenced this pull request Apr 26, 2026
…events check

Premise: jsx-a11y accepts `aria-hidden={true}` (JSX boolean literal) as a
static opt-out of the click-events-have-key-events check. The HBS analog
is `aria-hidden={{true}}` — a GlimmerMustacheStatement whose path is a
GlimmerBooleanLiteral with value `true`. The previous
`isHiddenFromScreenReader` helper happened to treat this correctly, but
only because it also treated ANY mustache value as truthy (e.g.
`aria-hidden={{this.maybeHidden}}` would silence the rule as if the
element were guaranteed hidden). That is too permissive: a dynamic value
can't be proven hidden at lint time, so the rule should still fire.

Conclusion: rewrite the aria-hidden check to distinguish the two shapes
explicitly.

- `isHiddenFromScreenReader` now only returns true for:
  - GlimmerTextNode value with chars `""` (bare attribute) or `"true"`
    (case-insensitive, trimmed).
  - GlimmerMustacheStatement whose `path` is a GlimmerBooleanLiteral
    with value `true` — i.e. the literal `{{true}}`.
- Other mustache shapes (path references, helper calls) are now treated
  as dynamic/unknown, so the rule fires. Authors who want a static
  escape hatch should write `aria-hidden` or `aria-hidden="true"` or
  `aria-hidden={{true}}`.
- Tests added for the mustache-literal valid case and for the new
  dynamic-mustache invalid cases (`{{false}}`, `{{this.x}}`).
- Audit fixture: adds `aria-hidden={{true}}` as explicit parity with
  jsx-a11y's `aria-hidden={true}`.

Tracks PR #28 item G1 (escape-hatch awareness). Companion to the
master-side `template-no-invalid-interactive` fix.
johanrd added a commit that referenced this pull request Apr 27, 2026
…events check

Premise: jsx-a11y accepts `aria-hidden={true}` (JSX boolean literal) as a
static opt-out of the click-events-have-key-events check. The HBS analog
is `aria-hidden={{true}}` — a GlimmerMustacheStatement whose path is a
GlimmerBooleanLiteral with value `true`. The previous
`isHiddenFromScreenReader` helper happened to treat this correctly, but
only because it also treated ANY mustache value as truthy (e.g.
`aria-hidden={{this.maybeHidden}}` would silence the rule as if the
element were guaranteed hidden). That is too permissive: a dynamic value
can't be proven hidden at lint time, so the rule should still fire.

Conclusion: rewrite the aria-hidden check to distinguish the two shapes
explicitly.

- `isHiddenFromScreenReader` now only returns true for:
  - GlimmerTextNode value with chars `""` (bare attribute) or `"true"`
    (case-insensitive, trimmed).
  - GlimmerMustacheStatement whose `path` is a GlimmerBooleanLiteral
    with value `true` — i.e. the literal `{{true}}`.
- Other mustache shapes (path references, helper calls) are now treated
  as dynamic/unknown, so the rule fires. Authors who want a static
  escape hatch should write `aria-hidden` or `aria-hidden="true"` or
  `aria-hidden={{true}}`.
- Tests added for the mustache-literal valid case and for the new
  dynamic-mustache invalid cases (`{{false}}`, `{{this.x}}`).
- Audit fixture: adds `aria-hidden={{true}}` as explicit parity with
  jsx-a11y's `aria-hidden={true}`.

Tracks PR #28 item G1 (escape-hatch awareness). Companion to the
master-side `template-no-invalid-interactive` fix.
johanrd added a commit that referenced this pull request Apr 27, 2026
Ports jsx-a11y/interactive-supports-focus, vuejs-accessibility/
interactive-supports-focus, and angular-eslint/interactive-supports-focus
to Ember templates. Flags elements that carry an interactive ARIA role
but have no focus affordance — the classic "<div role='button'>" without
tabindex anti-pattern that keyboard users cannot reach.

An element is flagged when all of the following hold:
- plain HTML tag (present in aria-query's DOM map)
- not a component invocation (PascalCase, @arg, this.x, foo.bar, foo::bar)
- role attribute statically resolves to an interactive role (non-abstract
  roles descending from `widget` in aria-query, plus `toolbar`)
- element is not inherently focusable (button, input[type!=hidden],
  select, textarea, a[href], area[href], summary, iframe, object, embed,
  audio[controls], video[controls])
- no tabindex attribute (any value, static or dynamic)
- no truthy contenteditable

Conservative skips:
- Dynamic role values (role={{x}}) — cannot statically resolve
- Component invocations — opaque rendering target
- Custom elements not in aria-query's DOM map

Interactive-role taxonomy derived from aria-query using the same widget-
descendant predicate as jsx-a11y and vuejs-accessibility, plus the
`toolbar` addition that jsx-a11y has historically kept outside the
auto-derived set.

Relationship to template-no-invalid-interactive: the two rules are
complementary — template-no-invalid-interactive flags handlers on
non-interactive elements; this rule flags interactive roles on
non-focusable hosts. They can both fire on the same element when both
concerns apply, and they target independently authored mistakes.

Not added to template-lint-migration — opt-in.

Closes the M2 finding in the Phase 3 audit for interactive-supports-focus
(audit/phase3/interactive-supports-focus; tracking PR #28).
johanrd added a commit that referenced this pull request Apr 27, 2026
…nd aria-hidden as escape hatches

Premise: peer a11y plugins (jsx-a11y `hasPresentationRole` + aria-hidden
handling inside `isInteractiveElement`; vuejs-accessibility's equivalent)
treat `role="presentation"` / `role="none"` and `aria-hidden`
(boolean / "true" / `{{true}}`) as explicit opt-outs of the interactivity
contract. An element that has opted out via ARIA does not need an
interactive handler check — the handler is authored acknowledging the
element is decorative or hidden from AT.

Conclusion: wire that opt-out into `template-no-invalid-interactive`
before the native/role-based interactivity probe runs.

- Adds `hasNonInteractiveEscapeHatch(node)` covering:
  - `role="presentation"` / `role="none"` (case-insensitive, trimmed,
    first token of a space-separated role list — matches jsx-a11y).
  - `aria-hidden` as bare boolean attribute, the text value `"true"`,
    or the mustache-literal `{{true}}`. `aria-hidden="false"` does NOT
    qualify.
- Visitor short-circuits on escape-hatch hit before computing interactivity.
- Tests: new valid cases for every escape-hatch shape; new invalid
  cases guarding that `aria-hidden="false"` and other roles still flag.
- Audit fixture `tests/audit/no-static-element-interactions/peer-parity.js`
  added to master as evidence of parity. D1 (role=presentation/none) and
  D2 (aria-hidden) divergences previously documented in the Phase-3 audit
  branch are now parity cases in the valid block.

Tracks PR #28 item G1 (escape-hatch awareness across interactive-handler
rules).
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.

1 participant