Skip to content

refactor: extract html-interactive-content util (HTML §3.2.5.2.7 authority)#37

Closed
johanrd wants to merge 9 commits intomasterfrom
fix/native-interactive-elements-util
Closed

refactor: extract html-interactive-content util (HTML §3.2.5.2.7 authority)#37
johanrd wants to merge 9 commits intomasterfrom
fix/native-interactive-elements-util

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.

Summary

  • Premise 1: Two rules in this plugin (template-no-invalid-interactive, template-no-nested-interactive) each hand-maintain a "native-interactive HTML tag" list. Those lists have drifted against each other and — more importantly — against any single authoritative source: rows for label, object, canvas, details, summary, option, datalist were adjudicated case-by-case without a consistent citation.
  • Premise 2: The drift is a symptom of authority conflation. "Native interactive" was answering two questions at once: (a) HTML Living Standard §3.2.5.2.7 Interactive content — a content-model authority — and (b) aria-query widget taxonomy — an AT-tree authority. The two overlap but disagree on those exact rows (<label>: HTML says interactive, ARIA says structure role; <canvas>: HTML doesn't list it, ARIA says widget; etc.). Picking one citation consistently resolves each row.
  • Conclusion: Extract lib/utils/html-interactive-content.js, scoped to HTML §3.2.5.2.7 as its sole authority. The rules keep their existing INTERACTIVE_ROLES set for the ARIA-widget-role authority (separate concern, separate citation). Rows that don't cleanly belong to either authority (<canvas>, <object usemap>) become explicit rule-level defensive additions with documented justification.

Fix

New util lib/utils/html-interactive-content.js exports isHtmlInteractiveContent(node, getTextAttrValue, options?):

  • Unconditional interactive (per §3.2.5.2.7): button, details, embed, iframe, label, select, textarea. Plus summary as a rule-level addition: it is not in the §3.2.5.2.7 list, but §4.11.2 defines its activation behavior (toggling its parent details), so it acts interactive in practice.
  • Conditional: <a href>, <input> unless type=hidden, <img usemap>, <audio controls>, <video controls>.
  • options.ignoreUsemap — exempts <img usemap> when rules pass this flag (preserves the existing ignoreUsemap config option surface).

Old lib/utils/native-interactive-elements.js is deleted.

Rule migrations (template-no-invalid-interactive, template-no-nested-interactive):

  • Import swap: isNativeInteractiveisHtmlInteractiveContent.
  • <canvas> added as a rule-level defensive check (not in §3.2.5.2.7, but upstream ember-template-lint has it — canvas is commonly wired for drawing/game UI where event handlers are expected).
  • <object usemap> stays as a rule-level special case in template-no-nested-interactive (not in §3.2.5.2.7 — but upstream flags it and browsers treat image-mapped <object> as clickable). Moved from the util to rule scope for honest citation.
  • template-no-nested-interactive's existing label-multi-child detection (lines 180–189) now activates correctly because <label> is interactive again. <label><input><input></label> flags as "multiple interactive elements inside a single <label>" — matches upstream.

Prior art

All four a11y-ESLint peers consult either aria-query, axobject-query, or a mix for interactivity classification. None cleanly separate HTML-content-model from ARIA-widget-tree authority the way this PR does, but several have sub-utilities for one or the other.

Plugin Approach File
jsx-a11y aria-query elementRoles (primary) + axobject-query elementAXObjects (fallback). Unified util covering both authorities. src/util/isInteractiveElement.js
vuejs-accessibility aria-query elementRoles-based set. src/utils/isInteractiveElement.ts
@angular-eslint/template aria-query elementRoles + axobject-query elementAXObjects. src/utils/is-interactive-element/index.ts
lit-a11y aria-query + axobject-query with progressbar carve-out. lib/utils/isInteractiveElement.js
html-validate Dedicated per-element interactive metadata field (boolean or callback). Closest spec-aligned external source. src/elements/html5.ts

Behavior change vs master

  • <label> is HTML interactive content again (was dropped in the prior draft). <label><input><input></label> flags per upstream parity.
  • <object usemap> still flags in template-no-nested-interactive (preserved via rule-level special case; previously via util inclusion).
  • <canvas> still interactive in both rules (preserved via rule-level defensive; previously via util inclusion).
  • <option>, <datalist> are no longer interactive in either rule (were the prior draft's defensive additions; dropped as they're not in §3.2.5.2.7). Rules wanting them should consult an ARIA-widget-role util separately.
  • <area[href]> no longer interactive via this util (not in §3.2.5.2.7). Rules that need <area> coverage (e.g. template-no-role-presentation-on-focusable) now add rule-level defensive handling with an HTML-focus-navigation citation.

Notes

@github-actions
Copy link
Copy Markdown

github-actions Bot commented Apr 21, 2026

🏎️ Benchmark Comparison

Benchmark Control (p50) Experiment (p50) Δ
🟠 js small 13.67 ms 14.05 ms +2.8%
🟢 js medium 6.92 ms 6.73 ms -2.9%
js large 2.75 ms 2.71 ms -1.3%
gjs small 1.23 ms 1.22 ms -1.3%
gjs medium 614.93 µs 609.58 µs -0.9%
gjs large 245.75 µs 241.97 µs -1.5%
gts small 1.23 ms 1.22 ms -1.3%
gts medium 615.57 µs 615.99 µs +0.1%
gts large 244.08 µs 244.79 µs +0.3%

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

Full mitata output
clk: ~3.09 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.51 ms/iter  17.78 ms █                    
                      (12.06 ms … 30.22 ms)  27.17 ms █▆█                  
                    (  5.70 mb …  10.42 mb)   7.25 mb ███▆▆▁▆▄▄▆▁▁▁▄▁▁▁▆▄▄▆

js small (experiment)         14.47 ms/iter  15.37 ms    █                 
                      (12.72 ms … 17.77 ms)  17.49 ms ▂ ▅█  ▂▂   ▂▂        
                    (  6.37 mb …   8.14 mb)   6.88 mb █▇██▇▇██▄▄▁██▄▁▇▁▄▄▄▄

                             ┌                                            ┐
                             ╷┌───────────┬───┐                           ╷
          js small (control) ├┤           │   ├───────────────────────────┤
                             ╵└───────────┴───┘                           ╵
                               ╷ ┌──┬──┐     ╷
       js small (experiment)   ├─┤  │  ├─────┤
                               ╵ └──┴──┘     ╵
                             └                                            ┘
                             12.06 ms           19.62 ms           27.17 ms

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

------------------------------------------- -------------------------------
js medium (control)            7.61 ms/iter   7.88 ms  █                   
                       (6.50 ms … 13.52 ms)  13.48 ms ▂█                   
                    (  2.37 mb …   4.69 mb)   3.53 mb ███▂▄▃▄▁▂▂▂▁▂▁▁▂▂▁▂▁▂

js medium (experiment)         7.50 ms/iter   7.84 ms  █                   
                       (6.34 ms … 13.83 ms)  13.26 ms  █                   
                    (  2.72 mb …   4.20 mb)   3.51 mb ▇█▇▂▃▄▅▂▁▂▂▁▂▁▁▁▂▁▁▂▂

                             ┌                                            ┐
                              ╷ ┌────┬─┐                                  ╷
         js medium (control)  ├─┤    │ ├──────────────────────────────────┤
                              ╵ └────┴─┘                                  ╵
                             ╷ ┌────┬─┐                                  ╷
      js medium (experiment) ├─┤    │ ├──────────────────────────────────┤
                             ╵ └────┴─┘                                  ╵
                             └                                            ┘
                             6.34 ms            9.91 ms            13.48 ms

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

------------------------------------------- -------------------------------
js large (control)             3.14 ms/iter   2.90 ms  █                   
                        (2.38 ms … 9.17 ms)   7.86 ms ▃█                   
                    (352.37 kb …   2.52 mb)   1.44 mb ███▃▄▂▂▂▁▂▂▂▂▂▂▁▁▁▁▁▁

js large (experiment)          2.92 ms/iter   2.81 ms  █                   
                        (2.50 ms … 7.26 ms)   5.92 ms ▄█                   
                    (318.65 kb …   2.55 mb)   1.43 mb ███▃▂▂▂▁▂▂▂▁▁▂▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷ ┌───┬                                      ╷
          js large (control) ├─┤   │──────────────────────────────────────┤
                             ╵ └───┴                                      ╵
                              ╷┌─┬                        ╷
       js large (experiment)  ├┤ │────────────────────────┤
                              ╵└─┴                        ╵
                             └                                            ┘
                             2.38 ms            5.12 ms             7.86 ms

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

------------------------------------------- -------------------------------
gjs small (control)            1.35 ms/iter   1.30 ms █                    
                        (1.18 ms … 5.79 ms)   4.89 ms █▂                   
                    (291.30 kb …   1.63 mb)   1.06 mb ██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs small (experiment)         1.33 ms/iter   1.25 ms █                    
                        (1.18 ms … 5.60 ms)   4.83 ms █                    
                    (212.70 kb …   1.56 mb)   1.06 mb █▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌─┬                                          ╷
         gjs small (control) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             ┌─┬                                         ╷
      gjs small (experiment) │ │─────────────────────────────────────────┤
                             └─┴                                         ╵
                             └                                            ┘
                             1.18 ms            3.04 ms             4.89 ms

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

------------------------------------------- -------------------------------
gjs medium (control)         663.63 µs/iter 632.30 µs █                    
                      (586.37 µs … 4.96 ms)   2.06 ms █▄                   
                    (464.00  b …   1.08 mb) 541.39 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gjs medium (experiment)      656.58 µs/iter 625.27 µs █                    
                      (580.00 µs … 5.42 ms)   3.09 ms █                    
                    ( 74.84 kb …   1.25 mb) 541.01 kb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌─┬                       ╷
        gjs medium (control) │ │───────────────────────┤
                             └─┴                       ╵
                             ┌┬                                           ╷
     gjs medium (experiment) ││───────────────────────────────────────────┤
                             └┴                                           ╵
                             └                                            ┘
                             580.00 µs           1.83 ms            3.09 ms

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

------------------------------------------- -------------------------------
gjs large (control)          266.94 µs/iter 261.41 µs  █▅                  
                      (234.89 µs … 4.56 ms) 358.85 µs ▂██                  
                    (181.87 kb … 961.39 kb) 217.48 kb ███▄▇▇▇▃▂▂▁▁▁▁▁▁▁▁▁▁▁

gjs large (experiment)       263.25 µs/iter 258.64 µs  █                   
                      (232.89 µs … 4.40 ms) 337.15 µs ▂██                  
                    (127.30 kb … 724.83 kb) 216.61 kb ███▅▃▇▇▅▂▂▁▂▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                              ╷┌─────────┬                                ╷
         gjs large (control)  ├┤         │────────────────────────────────┤
                              ╵└─────────┴                                ╵
                             ╷┌─────────┬                         ╷
      gjs large (experiment) ├┤         │─────────────────────────┤
                             ╵└─────────┴                         ╵
                             └                                            ┘
                             232.89 µs         295.87 µs          358.85 µs

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

------------------------------------------- -------------------------------
gts small (control)            1.33 ms/iter   1.26 ms █                    
                        (1.20 ms … 6.05 ms)   5.00 ms █                    
                    (182.05 kb …   1.73 mb)   1.06 mb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts small (experiment)         1.32 ms/iter   1.25 ms █                    
                        (1.19 ms … 5.35 ms)   5.09 ms █                    
                    (636.52 kb …   2.15 mb)   1.06 mb █▃▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ┌─┬                                         ╷
         gts small (control) │ │─────────────────────────────────────────┤
                             └─┴                                         ╵
                             ┌─┬                                          ╷
      gts small (experiment) │ │──────────────────────────────────────────┤
                             └─┴                                          ╵
                             └                                            ┘
                             1.19 ms            3.14 ms             5.09 ms

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

------------------------------------------- -------------------------------
gts medium (control)         679.88 µs/iter 633.37 µs  █                   
                      (585.54 µs … 4.69 ms)   1.61 ms ▆█                   
                    (239.98 kb …   1.25 mb) 542.32 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

gts medium (experiment)      658.35 µs/iter 631.03 µs  █                   
                      (586.55 µs … 4.93 ms)   1.61 ms ▆█                   
                    ( 41.54 kb …   1.10 mb) 540.47 kb ██▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁

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

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

------------------------------------------- -------------------------------
gts large (control)          267.28 µs/iter 260.40 µs  █                   
                      (234.15 µs … 4.43 ms) 336.97 µs  ██                  
                    (170.56 kb … 834.36 kb) 216.90 kb ████▂▇▆▆▃▂▁▂▁▁▁▁▁▁▁▁▁

gts large (experiment)       266.61 µs/iter 260.61 µs  █▄                  
                      (234.49 µs … 4.40 ms) 362.00 µs ███ ▂                
                    (170.17 kb … 808.53 kb) 216.50 kb ███▄██▆▂▂▂▁▁▁▁▁▁▁▁▁▁▁

                             ┌                                            ┐
                             ╷┌──────────┬                       ╷
         gts large (control) ├┤          │───────────────────────┤
                             ╵└──────────┴                       ╵
                             ╷ ┌────────┬                                 ╷
      gts large (experiment) ├─┤        │─────────────────────────────────┤
                             ╵ └────────┴                                 ╵
                             └                                            ┘
                             234.15 µs         298.08 µs          362.00 µs

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

johanrd added a commit that referenced this pull request Apr 21, 2026
Adopts the shared utils from PRs #31 and #37. Bit-identical copies so
both PRs merge cleanly regardless of order. Rule behavior unchanged.
johanrd added a commit that referenced this pull request Apr 21, 2026
Migrate template-no-aria-hidden-on-focusable to the shared utility helpers
introduced by PR #31 (isComponentInvocation) and PR #37
(isNativeInteractive):

- isFocusable() now delegates the native-focusable-tag check to
  isNativeInteractive(node, getTextAttrValue). The local
  INHERENTLY_FOCUSABLE_TAGS set and the inline a[href] branch are
  removed.
- hasFocusableDescendant()'s opaque-tag skip (added in G5.1) now uses
  isComponentInvocation(child) in place of the inline isOpaqueTag
  predicate; the local isOpaqueTag helper is removed.

Behavior delta (spec-correct FN fix):

- Previously <video controls> and <audio controls> were absent from the
  local INHERENTLY_FOCUSABLE_TAGS, so
  <div aria-hidden="true"><video controls></video></div> was VALID.
- isNativeInteractive returns true for audio[controls] / video[controls]
  (browsers only render focusable media UI when controls is present).
  Such patterns are now FLAGGED under noAriaHiddenOnAncestorOfFocusable,
  and the element directly (<video controls aria-hidden="true">) is
  FLAGGED under noAriaHiddenOnFocusable.
- Audio/video without controls remain VALID (no native focusable UI).

Tests: new invalid cases for audio/video with controls directly
aria-hidden and as descendants of aria-hidden wrappers, in both gts and
hbs suites. New valid cases for audio/video without controls to pin the
conditional behavior.
johanrd added a commit that referenced this pull request Apr 21, 2026
Replace the rule's inline INHERENTLY_FOCUSABLE_TAGS set and ad-hoc tag
checks with the two shared utils:

- lib/utils/is-component-invocation.js (from PR #31)
- lib/utils/native-interactive-elements.js (from PR #37)

Both files (and their tests) are copied bit-identically from their source
branches so parity is preserved while those PRs remain open.

Behavior deltas introduced by the util swap
-------------------------------------------
The prior inline set was {button, details, embed, iframe, input, select,
summary, textarea}. The shared util covers the same set plus several
additional native-interactive tags that were previously false negatives:

- option, datalist, object, canvas — now recognized as native-interactive
- area[href]                       — now recognized (symmetric with a[href])
- audio[controls], video[controls] — now recognized (per HTML-AAM / browser
  reality; keyboard-operable transport controls)

Net effect: `role="presentation"` / `role="none"` on any of the above is
now flagged where it wasn't before. All of these are spec-correct FN fixes
(WAI-ARIA 1.2 §4.6 conflict resolution applies the same way once the
element is acknowledged as focusable).

Tests added for representative new cases:
- <video controls role="presentation"> — flags (gts + hbs)
- <audio controls role="none">         — flags (gts)
- <area href="/x" role="presentation"> — flags (gts)
- <video role="presentation"> (no controls) — valid (still not focusable)

No deltas for <label>: it was not in the prior INHERENTLY_FOCUSABLE_TAGS
set and it is not in the shared util either, so behavior is unchanged.

Component-invocation handling is now an explicit early-return via
isComponentInvocation(node), which also excludes named-arg (<@slot>),
this-path (<this.widget>), and dot-path (<foo.bar>) invocations that were
previously only excluded incidentally by the tag-lowercase lookup.
@johanrd johanrd force-pushed the fix/native-interactive-elements-util branch 2 times, most recently from f5ee868 to 9ba18c6 Compare April 21, 2026 22:31
johanrd added a commit that referenced this pull request Apr 22, 2026
Same alignment story as the is-native-element swap in the previous
commit: copy #37's lib/utils/native-interactive-elements.js + test
byte-for-byte so the two PRs can land in either order without conflict.

Behavior change: <object> is no longer classified as interactive.
axobject-query has no entry for <object>, and no authoritative source
backs "interactive by default" for it — prior inclusion was based on
a misattributed axobject-query citation. The <object tabindex="0">
valid-test is removed accordingly (it would now flag, which is the
intended new behavior; invalid-case coverage for <object tabindex>
belongs in a dedicated test once we decide how to frame the
guidance).
johanrd added a commit that referenced this pull request Apr 22, 2026
…uthority

Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js. The new util is strictly scoped
to HTML Living Standard §3.2.5.2.7 Interactive Content (plus <summary>
per §4.11.2):

    button, details, embed, iframe, label, select, summary, textarea
    + a[href], input[!type=hidden], img[usemap], audio[controls], video[controls]

The previous util mixed HTML interactive-content semantics with
axobject-query widget-taxonomy semantics, giving it no single spec
authority to cite. Edge cases (label, object+usemap, canvas, option,
datalist) were adjudicated via hand-waving ("no-false-positive bias")
because neither authority alone justified the list.

This commit commits to HTML §3.2.5.2.7 as the sole authority for the
util. ARIA-widget-role concerns remain in each rule's hardcoded
INTERACTIVE_ROLES set (separate authority, separate concern).

Behavior changes:

- <label> is interactive again (upstream ember-template-lint parity
  restored). <label><input><input></label> flags multi-labelable-child
  via the existing rule-level label-child-counting logic.
- <object usemap> is interactive via rule-level special case (not in
  §3.2.5.2.7 but upstream-parity).
- <canvas> is interactive via rule-level defensive addition (not in
  §3.2.5.2.7 but authors commonly wire for drawing/game UI).
- <option>, <datalist> are no longer interactive (they were #37 prior
  defensive additions, not in HTML §3.2.5.2.7; rules wanting them can
  consult aria-query widgets separately).
- <area[href]> is no longer interactive via this util (not in §3.2.5.2.7;
  rules wanting it should use the ARIA widget-role authority).

Test updates mirror these changes — restored label-multi-child and
object-usemap INVALID cases; removed option/datalist/canvas-defensive
valid cases.

Supersedes the "decision table" framing of the previous PR body — see
updated PR description for the authority-split rationale.
johanrd added a commit that referenced this pull request Apr 22, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 22, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 22, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 22, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 22, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
@johanrd johanrd changed the title refactor: reconcile native-interactive-elements via shared util refactor: extract html-interactive-content util (HTML §3.2.5.2.7 authority) Apr 22, 2026
johanrd added a commit that referenced this pull request Apr 22, 2026
Import INTERACTIVE_ROLES and COMPOSITE_WIDGET_CHILDREN from the shared
lib/utils/interactive-roles.js util (introduced in #27 — byte-identical
copy here so either PR can land first without conflict). Drop the
hardcoded 19-role set previously duplicated inline in each rule.

Behavior changes:

- ARIA widget role set expands from 19 to 35 roles — picks up
  menubar, menu, listbox, tree, tablist, grid, treegrid, radiogroup,
  alertdialog, progressbar, and other widget-descended roles in
  aria-query's taxonomy that the hardcoded list missed.
- tooltip is no longer treated as interactive. Per WAI-ARIA 1.2 §5.3.3,
  tooltip is a document-structure role, not a widget. #27's util
  reflects this (tooltip explicitly excluded). Old <div role="tooltip"
  onclick> test moves from valid to invalid — spec-correct.
- Composite-widget nesting exception expanded via COMPOSITE_WIDGET_CHILDREN.
  Canonical APG patterns (<ul role="menubar"><li role="menuitem">...,
  <ul role="listbox"><li role="option">..., grid/row/gridcell, treegrid,
  radiogroup/radio) no longer flag as nested-interactive. Previously the
  rule only handled menuitem-in-menuitem explicitly.

Pairs with #37's HTML-content-model authority split to complete the
two-authority architecture: HTML §3.2.5.2.7 via html-interactive-content,
ARIA widget taxonomy via interactive-roles. Each util cites one authority
honestly; rules compose both.
@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

This PR refactors interactive-element classification by separating HTML “interactive content” (per HTML Living Standard §3.2.5.2.7) from ARIA widget-role interactivity (per aria-query taxonomy), then migrates template-no-invalid-interactive and template-no-nested-interactive to use the new shared utilities.

Changes:

  • Added lib/utils/html-interactive-content.js and migrated rules to consult it for spec-aligned HTML interactivity (plus <summary>).
  • Added lib/utils/interactive-roles.js deriving INTERACTIVE_ROLES (and composite-widget nesting allowances) from aria-query, with tests that pin expected role-set behavior.
  • Updated rule test suites to match the new authority split and defensive special cases (<canvas>, <object usemap>).

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
lib/utils/html-interactive-content.js New spec-aligned HTML interactive-content classifier with ignoreUsemap support.
lib/utils/interactive-roles.js New derived ARIA widget-role set and composite-widget child map used by rules.
lib/rules/template-no-nested-interactive.js Migrates tag/role logic to new utils; adds composite-widget exception handling.
lib/rules/template-no-invalid-interactive.js Migrates tag/role logic to new utils; preserves <canvas> behavior.
tests/lib/utils/html-interactive-content-test.js Adds unit coverage for HTML interactive-content classification edge cases.
tests/lib/utils/interactive-roles-test.js Adds unit coverage for derived interactive-role set and composite-widget mapping.
tests/lib/rules/template-no-nested-interactive.js Adjusts rule fixtures for audio/video controls + label behavior + object/usemap special case.
tests/lib/rules/template-no-invalid-interactive.js Adjusts rule fixtures for tooltip role handling and <canvas> defensive behavior.
Comments suppressed due to low confidence (1)

lib/rules/template-no-nested-interactive.js:176

  • isInteractiveOnlyFromTabindex doesn’t account for the rule-level <canvas> interactive carve-out. A <canvas tabindex="0"> will be classified as “interactive only from tabindex” and won’t be pushed onto the interactive-parent stack, which can suppress nested-interactive reports inside it. Add a tag === 'canvas' exclusion here to keep behavior consistent with isInteractive().
      if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
        return false;
      }
      const role = getTextAttr(node, 'role');
      if (role && INTERACTIVE_ROLES.has(role)) {
        return false;

💡 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-nested-interactive.js Outdated
Comment thread lib/utils/html-interactive-content.js 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

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

lib/rules/template-no-nested-interactive.js:192

  • isInteractiveOnlyFromTabindex treats any usemap attribute (img/object) as making the element non-tabindex-only, even when ignoreUsemap is enabled, and it also doesn't account for the rule-level <canvas> interactive carve-out. This can misclassify elements as “interactive for more than tabindex” and push them onto the interactive stack, potentially creating incorrect nested-interactive reports. Consider (a) honoring ignoreUsemap for both img and object here, and (b) returning false for tag === 'canvas' so canvas+tabindex isn't treated as tabindex-only.
      if (hasAttr(node, 'contenteditable')) {
        const ce = getTextAttr(node, 'contenteditable');
        if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
          return false;
        }
      }
      if ((tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
        return false;
      }
      return hasAttr(node, 'tabindex');

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

Comment thread lib/rules/template-no-nested-interactive.js Outdated
Comment thread lib/rules/template-no-nested-interactive.js Outdated
Comment thread lib/utils/interactive-roles.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

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

Comments suppressed due to low confidence (1)

lib/rules/template-no-nested-interactive.js:200

  • isInteractiveOnlyFromTabindex treats any usemap attribute as a non-tabindex source of interactivity even when ignoreUsemap/ignoreUsemapAttribute is enabled. This breaks the option’s intent: e.g. with ignoreUsemap=true, an <img usemap ... tabindex="0"> should be considered tabindex-only (and not pushed onto interactiveStack), but the current check returns false due solely to usemap being present. Gate this usemap exclusion on !ignoreUsemap (and align with the object+usemap special case above).
      if (hasAttr(node, 'contenteditable')) {
        const ce = getTextAttr(node, 'contenteditable');
        if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
          return false;
        }
      }
      if ((tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
        return false;
      }
      return hasAttr(node, 'tabindex');

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

Comment thread lib/utils/is-native-element.js Outdated
Comment thread lib/utils/is-native-element.js
johanrd added 6 commits April 26, 2026 10:05
Import INTERACTIVE_ROLES and COMPOSITE_WIDGET_CHILDREN from the shared
lib/utils/interactive-roles.js util (introduced in #27 — byte-identical
copy here so either PR can land first without conflict). Drop the
hardcoded 19-role set previously duplicated inline in each rule.

Behavior changes:

- ARIA widget role set expands from 19 to 35 roles — picks up
  menubar, menu, listbox, tree, tablist, grid, treegrid, radiogroup,
  alertdialog, progressbar, and other widget-descended roles in
  aria-query's taxonomy that the hardcoded list missed.
- tooltip is no longer treated as interactive. Per WAI-ARIA 1.2 §5.3.3,
  tooltip is a document-structure role, not a widget. #27's util
  reflects this (tooltip explicitly excluded). Old <div role="tooltip"
  onclick> test moves from valid to invalid — spec-correct.
- Composite-widget nesting exception expanded via COMPOSITE_WIDGET_CHILDREN.
  Canonical APG patterns (<ul role="menubar"><li role="menuitem">...,
  <ul role="listbox"><li role="option">..., grid/row/gridcell, treegrid,
  radiogroup/radio) no longer flag as nested-interactive. Previously the
  rule only handled menuitem-in-menuitem explicitly.

Pairs with #37's HTML-content-model authority split to complete the
two-authority architecture: HTML §3.2.5.2.7 via html-interactive-content,
ARIA widget taxonomy via interactive-roles. Each util cites one authority
honestly; rules compose both.
@johanrd johanrd force-pushed the fix/native-interactive-elements-util branch from e2aa02a to aecd9ed Compare April 26, 2026 08:08
johanrd added a commit that referenced this pull request Apr 26, 2026
Migrate template-no-aria-hidden-on-focusable to the shared utility helpers
introduced by PR #31 (isComponentInvocation) and PR #37
(isNativeInteractive):

- isFocusable() now delegates the native-focusable-tag check to
  isNativeInteractive(node, getTextAttrValue). The local
  INHERENTLY_FOCUSABLE_TAGS set and the inline a[href] branch are
  removed.
- hasFocusableDescendant()'s opaque-tag skip (added in G5.1) now uses
  isComponentInvocation(child) in place of the inline isOpaqueTag
  predicate; the local isOpaqueTag helper is removed.

Behavior delta (spec-correct FN fix):

- Previously <video controls> and <audio controls> were absent from the
  local INHERENTLY_FOCUSABLE_TAGS, so
  <div aria-hidden="true"><video controls></video></div> was VALID.
- isNativeInteractive returns true for audio[controls] / video[controls]
  (browsers only render focusable media UI when controls is present).
  Such patterns are now FLAGGED under noAriaHiddenOnAncestorOfFocusable,
  and the element directly (<video controls aria-hidden="true">) is
  FLAGGED under noAriaHiddenOnFocusable.
- Audio/video without controls remain VALID (no native focusable UI).

Tests: new invalid cases for audio/video with controls directly
aria-hidden and as descendants of aria-hidden wrappers, in both gts and
hbs suites. New valid cases for audio/video without controls to pin the
conditional behavior.
johanrd added a commit that referenced this pull request Apr 26, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 26, 2026
Adopts the shared utils from PRs #31 and #37. Bit-identical copies so
both PRs merge cleanly regardless of order. Rule behavior unchanged.
johanrd added a commit that referenced this pull request Apr 26, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 26, 2026
Replace the rule's inline INHERENTLY_FOCUSABLE_TAGS set and ad-hoc tag
checks with the two shared utils:

- lib/utils/is-component-invocation.js (from PR #31)
- lib/utils/native-interactive-elements.js (from PR #37)

Both files (and their tests) are copied bit-identically from their source
branches so parity is preserved while those PRs remain open.

Behavior deltas introduced by the util swap
-------------------------------------------
The prior inline set was {button, details, embed, iframe, input, select,
summary, textarea}. The shared util covers the same set plus several
additional native-interactive tags that were previously false negatives:

- option, datalist, object, canvas — now recognized as native-interactive
- area[href]                       — now recognized (symmetric with a[href])
- audio[controls], video[controls] — now recognized (per HTML-AAM / browser
  reality; keyboard-operable transport controls)

Net effect: `role="presentation"` / `role="none"` on any of the above is
now flagged where it wasn't before. All of these are spec-correct FN fixes
(WAI-ARIA 1.2 §4.6 conflict resolution applies the same way once the
element is acknowledged as focusable).

Tests added for representative new cases:
- <video controls role="presentation"> — flags (gts + hbs)
- <audio controls role="none">         — flags (gts)
- <area href="/x" role="presentation"> — flags (gts)
- <video role="presentation"> (no controls) — valid (still not focusable)

No deltas for <label>: it was not in the prior INHERENTLY_FOCUSABLE_TAGS
set and it is not in the shared util either, so behavior is unchanged.

Component-invocation handling is now an explicit early-return via
isComponentInvocation(node), which also excludes named-arg (<@slot>),
this-path (<this.widget>), and dot-path (<foo.bar>) invocations that were
previously only excluded incidentally by the tag-lowercase lookup.
johanrd added a commit that referenced this pull request Apr 26, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 26, 2026
Same alignment story as the is-native-element swap in the previous
commit: copy #37's lib/utils/native-interactive-elements.js + test
byte-for-byte so the two PRs can land in either order without conflict.

Behavior change: <object> is no longer classified as interactive.
axobject-query has no entry for <object>, and no authoritative source
backs "interactive by default" for it — prior inclusion was based on
a misattributed axobject-query citation. The <object tabindex="0">
valid-test is removed accordingly (it would now flag, which is the
intended new behavior; invalid-case coverage for <object tabindex>
belongs in a dedicated test once we decide how to frame the
guidance).
johanrd added a commit that referenced this pull request Apr 26, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 26, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
@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.

…yFromTabindex; update docs

canvas is unconditionally interactive (added by PR #37 for drawing/game-UI
parity with upstream ember-template-lint). isInteractiveOnlyFromTabindex
was missing a canvas guard, so <canvas tabindex="0"> was incorrectly
treated as tabindex-only and never pushed onto the interactive stack,
meaning nested interactive content inside a canvas+tabindex was silently
allowed.

Also updates docs to list audio[controls], video[controls], and canvas as
interactive, and expands the Special Cases section to document the ARIA
composite-widget hierarchy exception introduced alongside this refactor.
@johanrd johanrd closed this Apr 26, 2026
johanrd added a commit that referenced this pull request Apr 27, 2026
Adopts the shared utils from PRs #31 and #37. Bit-identical copies so
both PRs merge cleanly regardless of order. Rule behavior unchanged.
johanrd added a commit that referenced this pull request Apr 27, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 27, 2026
Migrate template-no-aria-hidden-on-focusable to the shared utility helpers
introduced by PR #31 (isComponentInvocation) and PR #37
(isNativeInteractive):

- isFocusable() now delegates the native-focusable-tag check to
  isNativeInteractive(node, getTextAttrValue). The local
  INHERENTLY_FOCUSABLE_TAGS set and the inline a[href] branch are
  removed.
- hasFocusableDescendant()'s opaque-tag skip (added in G5.1) now uses
  isComponentInvocation(child) in place of the inline isOpaqueTag
  predicate; the local isOpaqueTag helper is removed.

Behavior delta (spec-correct FN fix):

- Previously <video controls> and <audio controls> were absent from the
  local INHERENTLY_FOCUSABLE_TAGS, so
  <div aria-hidden="true"><video controls></video></div> was VALID.
- isNativeInteractive returns true for audio[controls] / video[controls]
  (browsers only render focusable media UI when controls is present).
  Such patterns are now FLAGGED under noAriaHiddenOnAncestorOfFocusable,
  and the element directly (<video controls aria-hidden="true">) is
  FLAGGED under noAriaHiddenOnFocusable.
- Audio/video without controls remain VALID (no native focusable UI).

Tests: new invalid cases for audio/video with controls directly
aria-hidden and as descendants of aria-hidden wrappers, in both gts and
hbs suites. New valid cases for audio/video without controls to pin the
conditional behavior.
johanrd added a commit that referenced this pull request Apr 27, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 27, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 27, 2026
Replace the rule's inline INHERENTLY_FOCUSABLE_TAGS set and ad-hoc tag
checks with the two shared utils:

- lib/utils/is-component-invocation.js (from PR #31)
- lib/utils/native-interactive-elements.js (from PR #37)

Both files (and their tests) are copied bit-identically from their source
branches so parity is preserved while those PRs remain open.

Behavior deltas introduced by the util swap
-------------------------------------------
The prior inline set was {button, details, embed, iframe, input, select,
summary, textarea}. The shared util covers the same set plus several
additional native-interactive tags that were previously false negatives:

- option, datalist, object, canvas — now recognized as native-interactive
- area[href]                       — now recognized (symmetric with a[href])
- audio[controls], video[controls] — now recognized (per HTML-AAM / browser
  reality; keyboard-operable transport controls)

Net effect: `role="presentation"` / `role="none"` on any of the above is
now flagged where it wasn't before. All of these are spec-correct FN fixes
(WAI-ARIA 1.2 §4.6 conflict resolution applies the same way once the
element is acknowledged as focusable).

Tests added for representative new cases:
- <video controls role="presentation"> — flags (gts + hbs)
- <audio controls role="none">         — flags (gts)
- <area href="/x" role="presentation"> — flags (gts)
- <video role="presentation"> (no controls) — valid (still not focusable)

No deltas for <label>: it was not in the prior INHERENTLY_FOCUSABLE_TAGS
set and it is not in the shared util either, so behavior is unchanged.

Component-invocation handling is now an explicit early-return via
isComponentInvocation(node), which also excludes named-arg (<@slot>),
this-path (<this.widget>), and dot-path (<foo.bar>) invocations that were
previously only excluded incidentally by the tag-lowercase lookup.
johanrd added a commit that referenced this pull request Apr 27, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
johanrd added a commit that referenced this pull request Apr 27, 2026
Same alignment story as the is-native-element swap in the previous
commit: copy #37's lib/utils/native-interactive-elements.js + test
byte-for-byte so the two PRs can land in either order without conflict.

Behavior change: <object> is no longer classified as interactive.
axobject-query has no entry for <object>, and no authoritative source
backs "interactive by default" for it — prior inclusion was based on
a misattributed axobject-query citation. The <object tabindex="0">
valid-test is removed accordingly (it would now flag, which is the
intended new behavior; invalid-case coverage for <object tabindex>
belongs in a dedicated test once we decide how to frame the
guidance).
johanrd added a commit that referenced this pull request Apr 27, 2026
Replace lib/utils/native-interactive-elements.js with
lib/utils/html-interactive-content.js to match the canonical util
introduced in #37. The new util cites HTML Living Standard §3.2.5.2.7
Interactive Content as its sole authority, resolving the previous
mixed-authority approach that cited axobject-query's widget taxonomy
for some rows and HTML spec for others.

Byte-identical copy of #37's util + test across worktrees so the two
PRs can land in either order without conflict.
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