Skip to content

Commit 0b6e8cc

Browse files
committed
fix: exclude canvas, and audio/video without controls, from interactive tags
Tighten the interactive-element derivation to mirror jsx-a11y's layered approach: aria-query's elementRoles (with attribute constraints) is the primary signal, axobject-query is only a fallback for tags with no interactive elementRoles entry. Deviations for common real-world false positives: - canvas is excluded from the AX fallback — its AXObject is widget-typed but aria-query assigns it no inherent ARIA role, and authors legitimately use role="img" / role="presentation" on it. - audio / video are only treated as interactive when the controls attribute is present, matching user-facing-widget reality. Adds tests for: canvas with role=img/presentation/none (valid); audio and video without controls + non-interactive role (valid); audio and video with controls + role=presentation (invalid). Existing tr/td/th/ button/input/a[href] tests unchanged.
1 parent ecd0812 commit 0b6e8cc

3 files changed

Lines changed: 178 additions & 24 deletions

File tree

docs/rules/template-no-interactive-element-to-noninteractive-role.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44

55
Disallow native interactive elements from being assigned non-interactive ARIA roles.
66

7-
Assigning a non-interactive role to a native interactive element (e.g. `<button role="heading">`) strips the element's built-in keyboard, focus, and activation semantics — leaving users with a broken widget. The [first rule of ARIA use](https://www.w3.org/TR/html-aria/#rule1) says don't use ARIA if native semantics already cover the job; this rule enforces the related corollary.
7+
Assigning a non-interactive role to a native interactive element (e.g. `<button role="heading">`) strips the element's built-in keyboard, focus, and activation semantics — leaving users with a broken widget. The [first rule of ARIA use](https://www.w3.org/TR/using-aria/#rule1) says don't use ARIA if native semantics already cover the job; this rule enforces the related corollary.
8+
9+
The interactive-element set is derived in layers, mirroring [jsx-a11y's `isInteractiveElement`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isInteractiveElement.js): aria-query's `elementRoles` (with its attribute constraints, e.g. `<a href>`, `<input type="…">`, `<select multiple>`) is the primary signal; axobject-query's AX-tree mapping is consulted only as a fallback for tags that have no interactive `elementRoles` entry.
10+
11+
Two deviations for real-world false-positive patterns:
12+
13+
- `<canvas>` is **not** treated as inherently interactive. Its AXObject is widget-typed but aria-query assigns it no inherent role; authors legitimately set `role="img"` or `role="presentation"` on canvases.
14+
- `<audio>` and `<video>` are only interactive when the `controls` attribute is present. Without it they render no user-operable UI (e.g. background / decorative media).
815

916
## Examples
1017

@@ -32,5 +39,5 @@ This rule **allows** the following:
3239
## References
3340

3441
- [WAI-ARIA 1.2 — Role taxonomy](https://www.w3.org/TR/wai-aria-1.2/#roles_categorization)
35-
- [HTML-AAM — Rule 1 of ARIA](https://www.w3.org/TR/html-aria/#rule1)
42+
- [Using ARIA — Rule 1](https://www.w3.org/TR/using-aria/#rule1)
3643
- [`no-interactive-element-to-noninteractive-role` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-interactive-element-to-noninteractive-role.md)

lib/rules/template-no-interactive-element-to-noninteractive-role.js

Lines changed: 137 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,154 @@
1-
const { roles } = require('aria-query');
1+
const { roles, elementRoles } = require('aria-query');
22
const { AXObjects, elementAXObjects } = require('axobject-query');
33
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
44

5-
// Native elements with inherent interactive semantics per axobject-query —
6-
// elements whose AXObjects include a widget type. This is the same data source
7-
// jsx-a11y, @angular-eslint/template, and lit-a11y use. `<a>` is handled
8-
// separately because it's only interactive when `href` is present.
9-
const INHERENTLY_INTERACTIVE_TAGS = buildInteractiveTagSet();
10-
11-
function buildInteractiveTagSet() {
12-
const interactiveAXObjects = new Set(
13-
[...AXObjects.keys()].filter((name) => AXObjects.get(name).type === 'widget')
14-
);
15-
const tags = new Set();
16-
for (const [schema, axObjectsArr] of elementAXObjects) {
17-
// Only consider elements with no attribute constraints — unconditionally
18-
// interactive by element type (separately handles <a href>).
5+
// Interactive-element derivation. Mirrors jsx-a11y's layered approach:
6+
// 1. Primary signal — aria-query's `elementRoles`: an element is inherently
7+
// interactive if one of its mapped roles is in INTERACTIVE_ROLES AND the
8+
// schema's attribute constraints match the node. (Handles <button>, <a
9+
// href>, <input type=…>, <select multiple>, <td>, <th scope=…>, <tr>,
10+
// <textarea>, <datalist>, <option>.)
11+
// 2. AX-tree fallback — axobject-query's `elementAXObjects`: consulted only
12+
// for tag names that have NO interactive `elementRoles` entry. These are
13+
// elements whose AXObject is a widget but aria-query lists no inherent
14+
// ARIA role (e.g. <summary>, <menuitem>, <embed>).
15+
//
16+
// Deviations from jsx-a11y, driven by real-world false-positive patterns:
17+
// - `<canvas>` is NOT treated as inherently interactive. Its AXObject is
18+
// `CanvasRole` (widget), but per aria-query `<canvas>` has no inherent
19+
// ARIA role — authors commonly set `role="img"` or `role="presentation"`
20+
// as the accessibility surface, and that is legitimate.
21+
// - `<audio>` and `<video>` are only treated as interactive when the
22+
// `controls` attribute is present. Without `controls` they render no
23+
// user-operable UI (background / decorative media is a common real
24+
// pattern). axobject-query does not encode this constraint; we add it
25+
// here explicitly.
26+
27+
const interactiveRoleSet = INTERACTIVE_ROLES;
28+
29+
const elementRoleEntries = [...elementRoles];
30+
31+
// Schemas (element name + attribute constraints) whose role list contains at
32+
// least one interactive role.
33+
const interactiveElementRoleSchemas = elementRoleEntries
34+
.filter(([, rolesArr]) => [...rolesArr].some((r) => interactiveRoleSet.has(r)))
35+
.map(([schema]) => schema);
36+
37+
const tagsWithInteractiveElementRoleEntry = new Set(
38+
interactiveElementRoleSchemas.map((schema) => schema.name)
39+
);
40+
41+
// AX-fallback tag set — tags whose AXObject list is entirely widget AND which
42+
// have no interactive `elementRoles` entry. Excludes `canvas` per rationale
43+
// above.
44+
const EXCLUDED_AX_FALLBACK_TAGS = new Set(['canvas']);
45+
46+
// Tags where we require explicit attribute constraints before treating as
47+
// interactive — overrides the unconstrained AX fallback.
48+
const CONTROLS_GATED_TAGS = new Set(['audio', 'video']);
49+
50+
const interactiveAXObjects = new Set(
51+
[...AXObjects.keys()].filter((name) => AXObjects.get(name).type === 'widget')
52+
);
53+
54+
const AX_FALLBACK_TAGS = (() => {
55+
const result = new Set();
56+
for (const [schema, axArr] of elementAXObjects) {
1957
if (schema.attributes && schema.attributes.length > 0) {
2058
continue;
2159
}
22-
if ([...axObjectsArr].some((o) => interactiveAXObjects.has(o))) {
23-
tags.add(schema.name);
60+
const name = schema.name;
61+
if (tagsWithInteractiveElementRoleEntry.has(name)) {
62+
continue;
63+
}
64+
if (EXCLUDED_AX_FALLBACK_TAGS.has(name)) {
65+
continue;
66+
}
67+
if (CONTROLS_GATED_TAGS.has(name)) {
68+
continue; // handled via explicit controls check
69+
}
70+
if ([...axArr].every((o) => interactiveAXObjects.has(o))) {
71+
result.add(name);
2472
}
2573
}
26-
return tags;
27-
}
74+
return result;
75+
})();
2876

2977
function findAttr(node, name) {
3078
return node.attributes?.find((a) => a.name === name);
3179
}
3280

81+
function hasAttr(node, name) {
82+
return Boolean(findAttr(node, name));
83+
}
84+
3385
function getTextAttrValue(attr) {
3486
if (attr?.value?.type === 'GlimmerTextNode') {
3587
return attr.value.chars;
3688
}
3789
return undefined;
3890
}
3991

92+
// Verify a single aria-query attribute-constraint entry matches the node.
93+
function attrConstraintMatches(baseAttr, node) {
94+
const attr = findAttr(node, baseAttr.name);
95+
const constraints = baseAttr.constraints || [];
96+
97+
if (constraints.includes('set')) {
98+
return Boolean(attr);
99+
}
100+
if (constraints.includes('undefined')) {
101+
if (!attr) {
102+
return true;
103+
}
104+
// For boolean-present attributes (no value) we treat the presence as "set".
105+
// Otherwise check value against any literal value constraint below.
106+
}
107+
108+
if (baseAttr.value !== undefined) {
109+
const value = getTextAttrValue(attr);
110+
if (value === undefined) {
111+
return false;
112+
}
113+
return value === baseAttr.value;
114+
}
115+
116+
// Attribute named without a value or constraint — match means present.
117+
return Boolean(attr);
118+
}
119+
120+
function attributesMatchSchema(schema, node) {
121+
const baseAttrs = schema.attributes || [];
122+
if (baseAttrs.length === 0) {
123+
return true;
124+
}
125+
return baseAttrs.every((baseAttr) => attrConstraintMatches(baseAttr, node));
126+
}
127+
40128
function isInteractiveElement(node) {
41129
const tag = node.tag?.toLowerCase();
42130
if (!tag) {
43131
return false;
44132
}
45-
if (INHERENTLY_INTERACTIVE_TAGS.has(tag)) {
133+
134+
// Tag not in DOM per aria-query — treat as component (conservatively skip).
135+
// aria-query's `dom` map covers HTML element names; unknown tag = not ours.
136+
// (We don't import `dom` separately — `elementRoles` / AX maps cover the
137+
// element-name surface for the rule's purpose, and unknown tags simply fail
138+
// every schema match below.)
139+
140+
// Primary signal: elementRoles with at least one interactive role + schema
141+
// constraints match the node's attributes.
142+
for (const schema of interactiveElementRoleSchemas) {
143+
if (schema.name !== tag) {
144+
continue;
145+
}
146+
if (!attributesMatchSchema(schema, node)) {
147+
continue;
148+
}
149+
// Special case: <input type="hidden"> is never user-facing. aria-query's
150+
// textbox entry would not match (it requires type=text/email/url/…), so
151+
// normally we'd be fine — but keep the explicit guard for clarity.
46152
if (tag === 'input') {
47153
const type = getTextAttrValue(findAttr(node, 'type'));
48154
if (type === 'hidden') {
@@ -51,9 +157,18 @@ function isInteractiveElement(node) {
51157
}
52158
return true;
53159
}
54-
if (tag === 'a' && findAttr(node, 'href')) {
160+
161+
// AX-tree fallback for tags with no interactive elementRoles entry.
162+
if (AX_FALLBACK_TAGS.has(tag)) {
55163
return true;
56164
}
165+
166+
// Controls-gated fallback for <audio>/<video>: only interactive when the
167+
// `controls` attribute is present (matches user-facing-widget reality).
168+
if (CONTROLS_GATED_TAGS.has(tag) && hasAttr(node, 'controls')) {
169+
return true;
170+
}
171+
57172
return false;
58173
}
59174

@@ -99,7 +214,7 @@ module.exports = {
99214
return;
100215
}
101216

102-
// Pick the first token that's a known role (matching ARIA 1.2 §5.4
217+
// Pick the first token that's a known role (matching ARIA 1.2 §5.3
103218
// role-fallback behavior — UAs use the first recognised role).
104219
for (const token of tokens) {
105220
if (token === 'presentation' || token === 'none') {

tests/lib/rules/template-no-interactive-element-to-noninteractive-role.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ ruleTester.run('template-no-interactive-element-to-noninteractive-role', rule, {
3838

3939
// <a> without href is not interactive.
4040
'<template><a role="heading">Not a link</a></template>',
41+
42+
// <canvas> is not treated as inherently interactive — authors commonly
43+
// use it as an accessibility surface with role="img" or role="presentation".
44+
'<template><canvas role="img">Chart</canvas></template>',
45+
'<template><canvas role="presentation"></canvas></template>',
46+
'<template><canvas role="none"></canvas></template>',
47+
48+
// <video>/<audio> without `controls` render no user-operable UI — treat
49+
// as non-interactive. Decorative background media is a common pattern.
50+
'<template><video role="presentation" src="/x.mp4" /></template>',
51+
'<template><audio role="presentation" src="/x.mp3" /></template>',
52+
'<template><video role="img" src="/x.mp4" /></template>',
53+
'<template><audio role="img" src="/x.mp3" /></template>',
4154
],
4255
invalid: [
4356
{
@@ -78,6 +91,18 @@ ruleTester.run('template-no-interactive-element-to-noninteractive-role', rule, {
7891
output: null,
7992
errors: [{ messageId: 'mismatch' }],
8093
},
94+
// <video controls> / <audio controls> exposes user-operable playback UI —
95+
// stripping interactive semantics with a non-interactive role is wrong.
96+
{
97+
code: '<template><video controls role="presentation" src="/x.mp4" /></template>',
98+
output: null,
99+
errors: [{ messageId: 'mismatch' }],
100+
},
101+
{
102+
code: '<template><audio controls role="presentation" src="/x.mp3" /></template>',
103+
output: null,
104+
errors: [{ messageId: 'mismatch' }],
105+
},
81106
],
82107
});
83108

@@ -92,6 +117,8 @@ hbsRuleTester.run('template-no-interactive-element-to-noninteractive-role', rule
92117
'<div role="article">Story</div>',
93118
'<button>Click</button>',
94119
'<CustomBtn role="article" />',
120+
'<canvas role="img">Chart</canvas>',
121+
'<video role="presentation" src="/x.mp4" />',
95122
],
96123
invalid: [
97124
{
@@ -104,5 +131,10 @@ hbsRuleTester.run('template-no-interactive-element-to-noninteractive-role', rule
104131
output: null,
105132
errors: [{ messageId: 'mismatch' }],
106133
},
134+
{
135+
code: '<video controls role="presentation" src="/x.mp4" />',
136+
output: null,
137+
errors: [{ messageId: 'mismatch' }],
138+
},
107139
],
108140
});

0 commit comments

Comments
 (0)