Skip to content

Commit be1a15d

Browse files
committed
refactor: reconcile native-interactive-elements via shared util
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.
1 parent f400aca commit be1a15d

6 files changed

Lines changed: 319 additions & 89 deletions

lib/rules/template-no-invalid-interactive.js

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const { isNativeElement } = require('../utils/is-native-element');
2+
const { isNativeInteractive } = require('../utils/native-interactive-elements');
23

34
function hasAttr(node, name) {
45
return node.attributes?.some((a) => a.name === name);
@@ -74,19 +75,6 @@ module.exports = {
7475
const ignoreTabindex = options.ignoreTabindex || false;
7576
const ignoreUsemap = options.ignoreUsemap || false;
7677

77-
const NATIVE_INTERACTIVE_ELEMENTS = new Set([
78-
'button',
79-
'canvas',
80-
'details',
81-
'embed',
82-
'iframe',
83-
'input',
84-
'label',
85-
'select',
86-
'summary',
87-
'textarea',
88-
]);
89-
9078
const INTERACTIVE_ROLES = new Set([
9179
'button',
9280
'checkbox',
@@ -109,7 +97,6 @@ module.exports = {
10997
'gridcell',
11098
]);
11199

112-
// eslint-disable-next-line complexity
113100
function isInteractive(node) {
114101
const tag = node.tag?.toLowerCase();
115102
if (!tag) {
@@ -120,24 +107,10 @@ module.exports = {
120107
return true;
121108
}
122109

123-
// <a> is only interactive when it has href
124-
if (tag === 'a' && hasAttr(node, 'href')) {
125-
return true;
126-
}
127-
128-
// <audio>/<video> with controls attribute is interactive
129-
if ((tag === 'audio' || tag === 'video') && hasAttr(node, 'controls')) {
130-
return true;
131-
}
132-
133-
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
134-
// Hidden input is not interactive
135-
if (tag === 'input') {
136-
const type = getTextAttr(node, 'type');
137-
if (type === 'hidden') {
138-
return false;
139-
}
140-
}
110+
// Native HTML tags whose interactivity is settled by the shared util
111+
// (handles a[href], area[href], audio/video[controls], input-not-hidden,
112+
// excludes label/menuitem, keeps option/datalist/canvas/object interactive).
113+
if (isNativeInteractive(node, getTextAttr)) {
141114
return true;
142115
}
143116

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

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,4 @@
1-
const NATIVE_INTERACTIVE_ELEMENTS = new Set([
2-
'button',
3-
'details',
4-
'embed',
5-
'iframe',
6-
'input',
7-
'label',
8-
'select',
9-
'summary',
10-
'textarea',
11-
]);
1+
const { isNativeInteractive } = require('../utils/native-interactive-elements');
122

133
const INTERACTIVE_ROLES = new Set([
144
'button',
@@ -122,18 +112,10 @@ module.exports = {
122112
return true;
123113
}
124114

125-
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
126-
if (tag === 'input') {
127-
const type = getTextAttr(node, 'type');
128-
if (type === 'hidden') {
129-
return false;
130-
}
131-
}
132-
return true;
133-
}
134-
135-
// <a> with href is interactive (without href, <a> is not interactive)
136-
if (tag === 'a' && hasAttr(node, 'href')) {
115+
// Native HTML tags whose interactivity is settled by the shared util
116+
// (handles a[href], area[href], audio/video[controls], input-not-hidden,
117+
// excludes label/menuitem, keeps option/datalist/canvas/object interactive).
118+
if (isNativeInteractive(node, getTextAttr)) {
137119
return true;
138120
}
139121

@@ -175,10 +157,7 @@ module.exports = {
175157
if (additionalInteractiveTags.has(tag)) {
176158
return false;
177159
}
178-
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
179-
return false;
180-
}
181-
if (tag === 'a' && hasAttr(node, 'href')) {
160+
if (isNativeInteractive(node, getTextAttr)) {
182161
return false;
183162
}
184163
const role = getTextAttr(node, 'role');
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use strict';
2+
3+
/**
4+
* Native-interactive HTML element classification, shared across rules that need to
5+
* ask "does this HTML tag natively expose interactive UI to keyboard / AT users?".
6+
*
7+
* Hand-curated rather than derived directly from axobject-query because
8+
* axobject-query disagrees with browser reality on several rows (notably
9+
* audio/video where axobject-query marks them unconditionally widget, but
10+
* browsers only render keyboard UI when `controls` is set). Decision
11+
* rationale is documented per-tag:
12+
*
13+
* | Element | Behavior | Rationale |
14+
* |------------------------------------------|----------------------|-----------------------------------------------------------------------------------------------|
15+
* | button, select, textarea, embed, summary | Interactive | axobject-query widget; universally accepted. |
16+
* | iframe | Interactive | axobject-query types it `window` (not widget), but iframe IS focusable and delegates focus. |
17+
* | details | Interactive | axobject-query types it `structure`, but <details> is a keyboard-activatable disclosure. |
18+
* | input (except type=hidden) | Interactive | axobject-query widget for every type except `hidden` (which has no entry). |
19+
* | option, datalist | Interactive | axobject-query widget (ListBoxOptionRole / ListBoxRole). |
20+
* | canvas | Interactive | axobject-query widget (CanvasRole); convention + no-false-positive bias. |
21+
* | a[href], area[href] | Interactive (cond.) | HTML-AAM: anchor interactivity requires href. (area has no axobject-query entry — pragmatic.) |
22+
* | audio[controls], video[controls] | Interactive | Stricter than axobject-query (which marks bare audio/video as widget). Browsers only render |
23+
* | | | keyboard-operable UI when `controls` is present. |
24+
* | audio, video (no controls) | NOT interactive | Matches browser behavior; axobject-query would disagree here. |
25+
* | input[type=hidden] | NOT interactive | HTML spec: no UI, no focus, no AT exposure. axobject-query has no entry. |
26+
* | menuitem | NOT interactive | Deprecated HTML; removed from all major browsers despite axobject-query still listing it. |
27+
* | label | NOT interactive | axobject-query LabelRole is structure, not widget. |
28+
* | object | NOT interactive | axobject-query has no entry for <object>; no authoritative source backs "interactive by default." |
29+
*/
30+
31+
// Unconditionally-interactive HTML tags (no attribute dependencies).
32+
const UNCONDITIONAL_INTERACTIVE_TAGS = new Set([
33+
'button',
34+
'select',
35+
'textarea',
36+
'iframe',
37+
'embed',
38+
'summary',
39+
'details',
40+
'option',
41+
'datalist',
42+
'canvas',
43+
]);
44+
45+
/**
46+
* Determine whether a Glimmer element node represents a natively-interactive
47+
* HTML element.
48+
*
49+
* @param {object} node Glimmer `ElementNode` (has a string `tag`).
50+
* @param {Function} getTextAttrValue Helper (node, attrName) -> string | undefined
51+
* that returns the text value of a static
52+
* attribute, or undefined for dynamic / missing.
53+
* @returns {boolean} True if the element is natively interactive.
54+
*/
55+
function isNativeInteractive(node, getTextAttrValue) {
56+
const rawTag = node && node.tag;
57+
if (typeof rawTag !== 'string' || rawTag.length === 0) {
58+
return false;
59+
}
60+
const tag = rawTag.toLowerCase();
61+
62+
// Unconditional interactive tags.
63+
if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
64+
return true;
65+
}
66+
67+
// <input> is interactive unless type="hidden" (HTML spec: no UI/focus/AT exposure).
68+
if (tag === 'input') {
69+
const type = getTextAttrValue(node, 'type');
70+
return type !== 'hidden';
71+
}
72+
73+
// <a> and <area> are interactive only when an href is present (HTML-AAM).
74+
if (tag === 'a' || tag === 'area') {
75+
return hasAttribute(node, 'href');
76+
}
77+
78+
// <audio>/<video> are only interactive when `controls` is present.
79+
if (tag === 'audio' || tag === 'video') {
80+
return hasAttribute(node, 'controls');
81+
}
82+
83+
return false;
84+
}
85+
86+
function hasAttribute(node, name) {
87+
return Boolean(node.attributes && node.attributes.some((a) => a.name === name));
88+
}
89+
90+
module.exports = {
91+
isNativeInteractive,
92+
};

tests/lib/rules/template-no-invalid-interactive.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ ruleTester.run('template-no-invalid-interactive', rule, {
7575
'<template><audio controls onclick={{this.play}}></audio></template>',
7676
'<template><video controls onclick={{this.play}}></video></template>',
7777

78+
// <option> and <datalist> are interactive (aria option/listbox roles)
79+
'<template><option onclick={{this.select}}>Opt</option></template>',
80+
'<template><datalist onclick={{this.show}}></datalist></template>',
81+
82+
// <canvas> is kept as a native-interactive widget (axobject CanvasRole; no-FP bias).
83+
'<template><canvas onclick={{this.draw}}></canvas></template>',
84+
7885
// usemap only makes img/object interactive
7986
'<template><img usemap="#map" onclick={{this.click}}></template>',
8087

@@ -212,5 +219,19 @@ ruleTester.run('template-no-invalid-interactive', rule, {
212219
},
213220
],
214221
},
222+
{
223+
// <label> is NOT native-interactive (structure role, not widget).
224+
// Click-forwarding is a spec behavior of <label>, not widget-ness, so an
225+
// interactive handler on <label> itself is invalid.
226+
filename: 'test.gjs',
227+
code: '<template><label onclick={{this.handleClick}}>Label</label></template>',
228+
output: null,
229+
errors: [
230+
{
231+
messageId: 'noInvalidInteractive',
232+
data: { tagName: 'label', handler: 'onclick' },
233+
},
234+
],
235+
},
215236
],
216237
});

tests/lib/rules/template-no-nested-interactive.js

Lines changed: 28 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ ruleTester.run('template-no-nested-interactive', rule, {
3939
Text
4040
</label>
4141
</template>`,
42+
// <label> is NOT a native-interactive widget (structure role per axobject-query),
43+
// so multiple interactive children inside a <label> do not fire nested-interactive.
44+
// (Outer label-click-forwarding is a spec behavior of <label>, not widget-ness.)
45+
`<template>
46+
<label>
47+
<button>Click</button>
48+
<a href="#">Link</a>
49+
</label>
50+
</template>`,
51+
'<template><label><input><input></label></template>',
52+
// <audio>/<video> without `controls` are NOT interactive (no rendered UI, no focus).
53+
'<template><button><audio></audio></button></template>',
54+
'<template><button><video></video></button></template>',
4255
`<template>
4356
<div role="presentation">
4457
<button>Click</button>
@@ -127,16 +140,6 @@ ruleTester.run('template-no-nested-interactive', rule, {
127140
output: null,
128141
errors: [{ messageId: 'nested' }],
129142
},
130-
{
131-
code: `<template>
132-
<label>
133-
<button>Click</button>
134-
<a href="#">Link</a>
135-
</label>
136-
</template>`,
137-
output: null,
138-
errors: [{ messageId: 'nested' }],
139-
},
140143
{
141144
code: `<template>
142145
<div role="button">
@@ -217,13 +220,14 @@ ruleTester.run('template-no-nested-interactive', rule, {
217220
output: null,
218221
errors: [{ messageId: 'nested' }],
219222
},
223+
// <audio controls> / <video controls> are native-interactive; nesting inside <button> fires.
220224
{
221-
code: '<template><object usemap=""><button></button></object></template>',
225+
code: '<template><button><video controls></video></button></template>',
222226
output: null,
223227
errors: [{ messageId: 'nested' }],
224228
},
225229
{
226-
code: '<template><label><input><input></label></template>',
230+
code: '<template><button><audio controls></audio></button></template>',
227231
output: null,
228232
errors: [{ messageId: 'nested' }],
229233
},
@@ -307,6 +311,15 @@ hbsRuleTester.run('template-no-nested-interactive', rule, {
307311
code: '<button><img usemap=""></button>',
308312
options: [{ ignoreUsemapAttribute: true }],
309313
},
314+
// <label> is NOT a native-interactive widget (structure role per axobject-query).
315+
'<label><input><input></label>',
316+
`<label for="foo">
317+
<div id="foo" tabindex=-1></div>
318+
<input>
319+
</label>`,
320+
// <audio>/<video> without `controls` are NOT interactive.
321+
'<button><audio></audio></button>',
322+
'<button><video></video></button>',
310323
],
311324
invalid: [
312325
{
@@ -379,33 +392,18 @@ hbsRuleTester.run('template-no-nested-interactive', rule, {
379392
output: null,
380393
errors: [{ message: 'Do not nest interactive element <img> inside <button>.' }],
381394
},
382-
{
383-
code: '<object usemap=""><button></button></object>',
384-
output: null,
385-
errors: [{ message: 'Do not nest interactive element <button> inside <object>.' }],
386-
},
387-
{
388-
code: '<label><input><input></label>',
389-
output: null,
390-
errors: [{ message: 'Do not nest interactive element <input> inside <label>.' }],
391-
},
392395
// Config: additionalInteractiveTags
393396
{
394397
code: '<button><my-special-input></my-special-input></button>',
395398
output: null,
396399
options: [{ additionalInteractiveTags: ['my-special-input'] }],
397400
errors: [{ message: 'Do not nest interactive element <my-special-input> inside <button>.' }],
398401
},
399-
// Label with multiple interactive children including tabindex
402+
// <video controls> is native-interactive; nesting inside <button> fires.
400403
{
401-
code: [
402-
'<label for="foo">',
403-
' <div id="foo" tabindex=-1></div>',
404-
' <input>',
405-
'</label>',
406-
].join('\n'),
404+
code: '<button><video controls></video></button>',
407405
output: null,
408-
errors: [{ message: 'Do not nest interactive element <input> inside <label>.' }],
406+
errors: [{ message: 'Do not nest interactive element <video> inside <button>.' }],
409407
},
410408
],
411409
});

0 commit comments

Comments
 (0)