refactor: extract html-interactive-content util (HTML §3.2.5.2.7 authority)#2748
Conversation
Adds `lib/utils/native-interactive-elements.js` exporting `isNativeInteractive(node, getTextAttrValue)` — the canonical "is this HTML tag natively interactive?" classifier. Migrates `template-no-invalid-interactive` and `template-no-nested-interactive` to use it. The set is hand-curated because axobject-query disagrees with browser reality on several rows (notably audio/video unconditional-widget; <menuitem> deprecated-but-still-listed). Each row is documented inline with spec/browser rationale. See the JSDoc in `lib/utils/native-interactive-elements.js` for the full table. Interactive set: - `button`, `select`, `textarea`, `iframe`, `embed`, `summary`, `details` — universally accepted widgets (iframe/details deviate from axobject-query's type classification but are focusable in practice). - `input` — except `type=hidden`. - `option`, `datalist` — axobject-query widget (ListBoxOptionRole / ListBoxRole). - `canvas` — axobject-query widget (CanvasRole); convention + no-false-positive bias. - `a[href]`, `area[href]` — HTML-AAM: anchor interactivity requires href. - `audio[controls]`, `video[controls]` — stricter than axobject-query (which marks bare audio/video as widget unconditionally); aligns with browser reality. Not in the interactive set: - `input[type=hidden]`, `menuitem`, `label` — documented per-row. - `<object>` — excluded. Earlier revision included it based on a misattributed axobject-query EmbeddedObjectRole citation; verification showed that role maps only to `<embed>`, not `<object>`. With no authoritative source backing inclusion, default to non-interactive. - `template-no-invalid-interactive` — replaces inline native-interactive set. - `template-no-nested-interactive` — same. Both rules' behavior is preserved for every case except `<object>` (no longer classified as interactive). Tests updated accordingly — `<object usemap=""><button>` no longer flagged as nested-interactive.
…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.
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.
…e empty-string (Copilot review)
… menu-item role variants (Copilot review)
…-invalid-interactive
…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.
html-interactive-content util (HTML §3.2.5.2.7 authority)
There was a problem hiding this comment.
is there a library that handles stuff like this? I wouldn't want us to be an expert on how this stuff is done
There was a problem hiding this comment.
I did look for this myself, and the answer is "halfway yes": the current ecosystem authority is aria-query's role taxonomy, where INTERACTIVE_ROLES is derived by taking every concrete role whose superClass chain includes widget. How jsx-a11y does it: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveRole.js.
There's no library that wraps this into a ready-made "is role X a valid owned child of role Y" function — aria-query exposes the raw data but not the derived query. Ideally this derivation could live in aria-query itself. For now, we're single-sourcing from aria-query (so any ARIA spec bump that aria-query absorbs flows through automatically) rather than hand-maintaining a static map.
There was a problem hiding this comment.
Created an upstream issue here: A11yance/aria-query#601
Note
This is part of a series where Claude has audited
eslint-plugin-emberagainst jsx-a11y, vuejs-accessibility, angular-eslint, lit-a11y and html-validate,ember-template-lint, and the HTML and WCAG specs.Summary
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 forlabel,object,canvas,details,summary,option,datalistwere adjudicated case-by-case without a consistent citation.<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.lib/utils/html-interactive-content.js, scoped to HTML §3.2.5.2.7 as its sole authority. The rules keep their existingINTERACTIVE_ROLESset 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.jsexportsisHtmlInteractiveContent(node, getTextAttrValue, options?):button,details,embed,iframe,label,select,textarea. Plussummaryas 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 parentdetails), so it acts interactive in practice.<a href>,<input>unlesstype=hidden,<img usemap>,<audio controls>,<video controls>.options.ignoreUsemap— exempts<img usemap>when rules pass this flag (preserves the existingignoreUsemapconfig option surface).Old
lib/utils/native-interactive-elements.jsis deleted.Rule migrations (
template-no-invalid-interactive,template-no-nested-interactive):isNativeInteractive→isHtmlInteractiveContent.<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 intemplate-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.
elementRoles(primary) + axobject-queryelementAXObjects(fallback). Unified util covering both authorities.src/util/isInteractiveElement.jselementRoles-based set.src/utils/isInteractiveElement.tselementRoles+ axobject-queryelementAXObjects.src/utils/is-interactive-element/index.tsprogressbarcarve-out.lib/utils/isInteractiveElement.jsinteractivemetadata field (boolean or callback). Closest spec-aligned external source.src/elements/html5.ts