Skip to content

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

Merged
NullVoxPopuli merged 9 commits intoember-cli:masterfrom
johanrd:fix/native-interactive-elements-util
Apr 27, 2026
Merged

refactor: extract html-interactive-content util (HTML §3.2.5.2.7 authority)#2748
NullVoxPopuli merged 9 commits intoember-cli:masterfrom
johanrd:fix/native-interactive-elements-util

Conversation

@johanrd
Copy link
Copy Markdown
Contributor

@johanrd johanrd commented Apr 26, 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

johanrd added 9 commits April 26, 2026 10:05
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.
…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 changed the title Fix/native interactive elements util refactor: extract html-interactive-content util (HTML §3.2.5.2.7 authority) Apr 26, 2026
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is there a library that handles stuff like this? I wouldn't want us to be an expert on how this stuff is done

Copy link
Copy Markdown
Contributor Author

@johanrd johanrd Apr 26, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Created an upstream issue here: A11yance/aria-query#601

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants