feat: add template-no-interactive-element-to-noninteractive-role#20
feat: add template-no-interactive-element-to-noninteractive-role#20
Conversation
🏎️ Benchmark Comparison
Full mitata output |
…ractive-role Translates jsx-a11y's no-interactive-element-to-noninteractive-role test suite (the only peer source — vuejs-accessibility and lit-a11y do not ship this rule) into GTS + HBS RuleTester cases under tests/audit/. Pins cases where we agree with jsx-a11y plus each deliberate divergence: - Canvas is NOT treated as interactive (PR #20 carve-out) — matches jsx-a11y :recommended, diverges from :strict. - <audio>/<video> without `controls` are non-interactive (PR #20 carve-out). - <video controls role="presentation"> is FLAGGED by us, VALID in jsx-a11y. - <tr role="presentation"> FLAGGED by us — matches jsx-a11y :strict, diverges from :recommended. - <input type="hidden" role="img"> VALID for us (hidden guard), INVALID in jsx-a11y — captured in a dedicated sub-run. - <embed>, <summary>, <td>, <th>, <datalist> with a non-interactive role FLAGGED by us via aria-query/axobject-query mappings; jsx-a11y treats these as static. Potential false positives worth revisiting. 184 tests, all passing. Not part of main CI — lives under tests/audit/.
There was a problem hiding this comment.
Pull request overview
Adds a new Ember template accessibility rule that flags native interactive HTML elements being assigned non-interactive ARIA roles (e.g. <button role="heading">), using a layered aria-query + axobject-query derivation similar to eslint-plugin-jsx-a11y.
Changes:
- Introduces
template-no-interactive-element-to-noninteractive-rolerule with layered interactive-element detection plus pragmatic carve-outs (canvasexcluded;audio/videorequirecontrols). - Adds shared utilities for interactive-role derivation, HTML interactive-content classification, and component-invocation detection.
- Adds comprehensive unit tests plus an audit/parity fixture, and updates docs/README and dependencies (
axobject-query).
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| lib/rules/template-no-interactive-element-to-noninteractive-role.js | New rule implementation using aria-query + axobject-query layered detection and role-fallback parsing. |
| lib/utils/interactive-roles.js | Shared INTERACTIVE_ROLES derivation from aria-query’s taxonomy (+ toolbar). |
| lib/utils/is-component-invocation.js | Helper to skip Glimmer component-invocation tag forms (PascalCase, @, this., dot-path, ::). |
| lib/utils/html-interactive-content.js | Spec-based HTML “interactive content” classifier (+ ignoreUsemap option). |
| tests/lib/rules/template-no-interactive-element-to-noninteractive-role.js | RuleTester coverage for both gts (<template>) and hbs parser modes, including carve-outs. |
| tests/lib/utils/is-component-invocation-test.js | Unit tests for component-invocation tag classification. |
| tests/lib/utils/html-interactive-content-test.js | Unit tests pinning HTML interactive-content classification behavior and edge cases. |
| tests/audit/no-interactive-element-to-noninteractive-role/peer-parity.js | Large audit fixture to track parity/divergences vs jsx-a11y behavior. |
| docs/rules/template-no-interactive-element-to-noninteractive-role.md | New rule documentation with rationale, examples, and references. |
| README.md | Adds the new rule to the auto-generated rules list table. |
| package.json | Adds axobject-query dependency. |
| pnpm-lock.yaml | Locks [email protected]. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 12 changed files in this pull request and generated 5 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 3 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ractive-role Translates jsx-a11y's no-interactive-element-to-noninteractive-role test suite (the only peer source — vuejs-accessibility and lit-a11y do not ship this rule) into GTS + HBS RuleTester cases under tests/audit/. Pins cases where we agree with jsx-a11y plus each deliberate divergence: - Canvas is NOT treated as interactive (PR #20 carve-out) — matches jsx-a11y :recommended, diverges from :strict. - <audio>/<video> without `controls` are non-interactive (PR #20 carve-out). - <video controls role="presentation"> is FLAGGED by us, VALID in jsx-a11y. - <tr role="presentation"> FLAGGED by us — matches jsx-a11y :strict, diverges from :recommended. - <input type="hidden" role="img"> VALID for us (hidden guard), INVALID in jsx-a11y — captured in a dedicated sub-run. - <embed>, <summary>, <td>, <th>, <datalist> with a non-interactive role FLAGGED by us via aria-query/axobject-query mappings; jsx-a11y treats these as static. Potential false positives worth revisiting. 184 tests, all passing. Not part of main CI — lives under tests/audit/.
6b35b8c to
c0cb08d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…ractive-role Translates jsx-a11y's no-interactive-element-to-noninteractive-role test suite (the only peer source — vuejs-accessibility and lit-a11y do not ship this rule) into GTS + HBS RuleTester cases under tests/audit/. Pins cases where we agree with jsx-a11y plus each deliberate divergence: - Canvas is NOT treated as interactive (PR #20 carve-out) — matches jsx-a11y :recommended, diverges from :strict. - <audio>/<video> without `controls` are non-interactive (PR #20 carve-out). - <video controls role="presentation"> is FLAGGED by us, VALID in jsx-a11y. - <tr role="presentation"> FLAGGED by us — matches jsx-a11y :strict, diverges from :recommended. - <input type="hidden" role="img"> VALID for us (hidden guard), INVALID in jsx-a11y — captured in a dedicated sub-run. - <embed>, <summary>, <td>, <th>, <datalist> with a non-interactive role FLAGGED by us via aria-query/axobject-query mappings; jsx-a11y treats these as static. Potential false positives worth revisiting. 184 tests, all passing. Not part of main CI — lives under tests/audit/.
78266b8 to
03d35f7
Compare
03d35f7 to
3c36f55
Compare
…pt-in requireExplicit
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Returns true if the Glimmer element node is a component invocation | ||
| * rather than a native HTML element. Excludes: | ||
| * - PascalCase tags (<Button>, <MyWidget>) | ||
| * - Named-arg invocations (<@heading>, <@tag.foo>) | ||
| * - This-path invocations (<this.myComponent>, <this.comp.sub>) | ||
| * - Dot-path invocations (<foo.bar>) | ||
| * - Named-block syntax (<foo::bar>) | ||
| */ |
There was a problem hiding this comment.
The doc comment says “Excludes:” but the listed cases are exactly the ones that return true (i.e., are treated as component invocations). Update wording to “Includes:” (or rephrase) so the documentation matches the implemented behavior.
| @@ -0,0 +1,276 @@ | |||
| const { roles, elementRoles } = require('aria-query'); | |||
There was a problem hiding this comment.
This module is missing the 'use strict'; directive used across the other newly added CommonJS files in this PR. Adding it improves consistency and avoids subtle differences in strict-mode behavior across modules.
…-require-input-type feat: add template-require-input-type
Prepare Release v13.2.0
Captures empirically-verified Glimmer rendering behavior for HTML
attributes with mustache values, so rule authors classifying
GlimmerBooleanLiteral / GlimmerStringLiteral / GlimmerConcatStatement
have a ground-truth reference instead of intuition.
Notable findings the doc pins down:
- attr={{"false"}} (bare string "false") renders as attr="false" — TRUTHY,
not falsy as the literal suggests.
- attr="{{false}}" (concat) sets the IDL property to true regardless of
the literal value inside, even when HTML serialization shows nothing.
Verified against <video muted="{{false}}"> → videoEl.muted === true.
- Non-reflecting boolean attrs (muted, autoplay) and reflecting ones
(disabled, hidden) diverge in HTML serialization but agree at the IDL
property layer.
Includes a copy-pasteable reproduction template so future readers can
re-verify if Glimmer behavior changes.
Adds a pointer in README's "Creating a New Rule" section.
Replaces the prior tables (which mixed verified data with extrapolations
marked "(assumed)") with strictly-verified per-attribute tables. Every cell
populated from rendering and IDL inspection in ember-source 6.12.
Structure:
- One "Reference table" section, five per-attribute sub-tables
(muted, disabled, aria-hidden, tabindex, autocomplete)
- One "To reproduce the reference table" section with the exact template
and JS console snippet, inline (no separate fixture file)
- Cross-attribute observations summarizing the rules the data reveals
Findings the new tables make explicit:
- Bare-mustache falsy set is {{false}}/{{null}}/{{undefined}}/{{0}} for
boolean-coerced attrs (boolean HTML, ARIA, numeric); {{""}} is kept as
attr="".
- Bare-mustache string literals never coerce — attr={{"false"}} renders
as attr="false".
- Concat-mustache for boolean HTML attrs sets the IDL property to true
regardless of the literal value inside (verified for both reflecting
and non-reflecting attrs).
- Concat-mustache for ARIA/string attrs renders the stringified value
literally — no boolean coercion. aria-hidden="{{false}}" is visible.
- Plain string attrs (autocomplete) skip Glimmer's boolean coercion
entirely; autocomplete={{false}} renders as autocomplete="false".
The video.muted snapshot reads IDL muted=false for static attribute forms
(m1-m4, m7-m8, m11) because the test runs before media load — the doc
explains how defaultMuted reflects to muted at load time, so the rule's
"At play time" column is the lint-truth column rule authors should use.
…les" guide Adds a practical-implementation section between the reference table and the reproduction template. It maps each AST shape (GlimmerTextNode / GlimmerMustacheStatement with each path type / GlimmerConcatStatement) to a verdict, citing the row IDs from the reference table so rule authors can implement classification correctly without re-deriving the model. Includes: - AST-shape verdict table — direct mapping rule authors can copy from - Six common mistakes section, each tied to specific row IDs - Pointer to the (forthcoming) lib/utils/glimmer-attr-presence.js utility that will encode the verdict table once and let rules consume the resolved kind + value rather than re-walking the AST The audit of master rules and the open feature PRs found 18 REAL_BUG findings (12 in PRs, 6 in master) — all classifiable into the bullet-1 through bullet-4 footguns this guide enumerates.
…ng model
Adds lib/utils/glimmer-attr-presence.js exporting:
- classifyAttribute(attrNode, options?) → { presence, value }
Maps every AST shape (valueless / GlimmerTextNode / GlimmerMustacheStatement
with each path type / GlimmerConcatStatement) to a (presence, value) pair
per the verified model in docs/glimmer-attribute-behavior.md. Each branch
cites the relevant doc row IDs (m1–m19, h1–h15, d1–d10, t1–t7, i1–i5).
- inferAttrKind(name) → 'boolean' | 'aria' | 'numeric' | 'plain-string'
Used when classifyAttribute callers don't pass options.kind explicitly.
- BOOLEAN_HTML_ATTRS, NUMERIC_ATTRS — exported sets, useful for callers
that want to extend the kind model.
Key empirical asymmetries this util encodes correctly (and that audit
findings show several rules currently misclassify):
- Bare {{false}} / {{null}} / {{undefined}} on falsy-coerced kinds
(boolean / aria / numeric) → presence='absent' (Glimmer omits attribute).
Same forms on plain-string → presence='present', value='false' / etc.
- Bare {{"false"}} (StringLiteral) is JS-truthy, never coerced — renders
the literal value across all attribute kinds.
- aria-hidden={{true}} renders aria-hidden="" (h5, contested), not
aria-hidden="true" — the util surfaces value='' here so callers
comparing value === 'true' don't false-match.
- Concat is never falsy: any concat form is presence='present'; the
resolved value comes from the existing getStaticAttrValue helper.
Tests: 35 unit tests covering every doc row + the kind-override option.
Updates docs/glimmer-attribute-behavior.md to reference the actual file
and replaces the "(forthcoming)" sketch with a working example.
…ation / html-void-elements Correctness fixes from PR ember-cli#2769 review: - Boolean concat now returns canonical `value: 'true'` instead of leaking the inner literal. Per doc rows m13-m19, d7-d10 the IDL is set true regardless of inner value, so callers checking `value === 'false'` to detect "off" no longer get a wrong answer for `<video muted="{{false}}">`. - {{true}} on numeric / plain-string now returns `unknown` (untested in doc; was previously claiming `value: 'true'` by extrapolation). - `inferAttrKind` is now case-insensitive (`Disabled`, `ARIA-Hidden`, etc.). Drop hand-rolled spec lists in favor of upstream packages: - `property-information` for boolean / overloadedBoolean / number attribute classification, replacing the 24-entry BOOLEAN_HTML_ATTRS and 3-entry NUMERIC_ATTRS Sets. `colspan` is added as a small NUMERIC_OVERRIDES Set to compensate for an upstream gap in property-information 7.1.0 (rowspan and cols are marked `number: true`, colspan isn't). - `html-void-elements` in template-block-indentation.js and template-self-closing-void-elements.js, deduplicating two parallel 16-entry VOID_TAGS Sets. Internal API change: BOOLEAN_HTML_ATTRS and NUMERIC_ATTRS are no longer exported from glimmer-attr-presence. The util's public surface is now `classifyAttribute` and `inferAttrKind`. Callers wanting the underlying classification can use property-information directly.
…oss-attr m6/d3 analog)
`controls` on `<audio>`/`<video>` is an HTML boolean attribute, so per the
cross-attribute observation in docs/glimmer-attribute-behavior.md, bare
`{{false}}` / `{{null}}` / `{{undefined}}` cause Glimmer to omit the
attribute at runtime. The helper's previous AST-presence check
(hasAttribute) treated `<video controls={{false}}>` as still having
controls — a false positive that propagated to every rule using
isHtmlInteractiveContent: nested-interactive, no-noninteractive-tabindex,
interactive-supports-focus, click-events-have-key-events, etc.
After:
- `controls` flows through classifyAttribute. Bare-mustache falsy literals
now correctly classify as 'absent' → element is NOT interactive at
runtime.
- `href` and `usemap` continue to use AST-presence — those are plain
string attributes that don't falsy-coerce (i4 analog: bare `{{false}}`
on a plain string renders as the literal `"false"`, attribute kept).
Concat forms (`controls="{{X}}"`) still classify as 'present' because
concat is never falsy at runtime — so the existing pass-through behavior
for concat is preserved.
Tests:
- New: `<video controls={{false}}>` → not interactive (regression-locking).
- New: `<audio controls={{null}}>` → not interactive.
- New: `<video controls="{{false}}">` → IS interactive (concat sets IDL
true regardless of inner literal).
- All 9555 existing tests pass — no rule was relying on the buggy
behavior, so this fix lands cleanly across all consumers.
…cludes→Includes, add 'use strict'
0b5fb88 to
0ecb3da
Compare
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.heading,article,presentation, …) to a native interactive element (<button>,<a href>,<input>, …) strips those semantics.<button role="heading">becomes a visible, un-operable "button".Fix: add
template-no-interactive-element-to-noninteractive-role.Interactive-element set mirrors jsx-a11y's layered derivation:
aria-query'selementRoles(with attribute constraints — e.g.<a href>,<input type="…">,<select multiple>) is primary; jsx-a11y has an intermediate short-circuit on non-interactiveelementRolesmatches; we then consultaxobject-query(AX-tree data) as a fallback for tags with no interactiveelementRolesentry. Interactive-role set imported from the sharedlib/utils/interactive-roles.jshelper (see #27) —aria-query's widget-ancestor derivation +toolbar.Two deviations from the unconstrained AX fallback, driven by common real-world patterns (pragmatic carve-outs; see diff for details):
<canvas>excluded — axobject-query markscanvasas widget (via CanvasRole); aria-query assigns no inherent role. Pragmatic carve-out becauserole="img"/role="presentation"on canvas is a legitimate pattern.<audio>/<video>gated oncontrols— stricter than axobject-query (which marks bare<audio>/<video>as widget unconditionally); matches the HTML spec'scontrolssemantics.Neither carve-out is spec-cited; both are pragmatic.
Prior art
no-interactive-element-to-noninteractive-rolesrc/rules/).packages/eslint-plugin-template/src/rules/).lib/rules/).