Skip to content

Commit ad391dc

Browse files
committed
refactor: split native-interactive-elements into HTML-content-model authority
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.
1 parent be1a15d commit ad391dc

8 files changed

Lines changed: 356 additions & 310 deletions

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

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

44
function hasAttr(node, name) {
55
return node.attributes?.some((a) => a.name === name);
@@ -107,10 +107,17 @@ module.exports = {
107107
return true;
108108
}
109109

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)) {
110+
// HTML §3.2.5.2.7 interactive content (authoritative for content-model;
111+
// handles label/button/etc. + conditional a[href], input[!hidden],
112+
// img[usemap], audio/video[controls]).
113+
if (isHtmlInteractiveContent(node, getTextAttr)) {
114+
return true;
115+
}
116+
117+
// <canvas> — not in §3.2.5.2.7 but upstream ember-template-lint treats
118+
// it as interactive (canvas is commonly wired for drawing/game UI where
119+
// event handlers are expected). Preserved for upstream parity.
120+
if (tag === 'canvas') {
114121
return true;
115122
}
116123

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const { isNativeInteractive } = require('../utils/native-interactive-elements');
1+
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
22

33
const INTERACTIVE_ROLES = new Set([
44
'button',
@@ -112,14 +112,21 @@ module.exports = {
112112
return true;
113113
}
114114

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)) {
115+
// HTML §3.2.5.2.7 interactive content (authoritative for content-model
116+
// nesting — handles a[href], audio/video[controls], input[!hidden],
117+
// img[usemap], plus label/button/select/textarea/iframe/etc. unconditional).
118+
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
119119
return true;
120120
}
121121

122-
// Check role
122+
// <canvas> — not in §3.2.5.2.7 but upstream ember-template-lint treats
123+
// it as interactive. Preserved for parity.
124+
if (tag === 'canvas') {
125+
return true;
126+
}
127+
128+
// ARIA widget roles (author-declared interactivity — separate authority
129+
// from HTML content-model; see `docs/architectural/interactive.md`).
123130
const role = getTextAttr(node, 'role');
124131
if (role && INTERACTIVE_ROLES.has(role)) {
125132
return true;
@@ -136,8 +143,10 @@ module.exports = {
136143
return true;
137144
}
138145

139-
// Check usemap (only on img and object elements)
140-
if (!ignoreUsemap && (tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
146+
// <object usemap> — not in HTML §3.2.5.2.7 but upstream ember-template-lint
147+
// treats object+usemap as interactive (image-map behavior). Rule-level
148+
// special case for upstream parity; revisit if/when HTML-AAM clarifies.
149+
if (!ignoreUsemap && tag === 'object' && hasAttr(node, 'usemap')) {
141150
return true;
142151
}
143152

@@ -157,7 +166,7 @@ module.exports = {
157166
if (additionalInteractiveTags.has(tag)) {
158167
return false;
159168
}
160-
if (isNativeInteractive(node, getTextAttr)) {
169+
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
161170
return false;
162171
}
163172
const role = getTextAttr(node, 'role');
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
'use strict';
2+
3+
/**
4+
* HTML "interactive content" classification, authoritative per
5+
* [HTML Living Standard §3.2.5.2.7 Interactive content]
6+
* (https://html.spec.whatwg.org/multipage/dom.html#interactive-content):
7+
*
8+
* a (if the href attribute is present), audio (if the controls attribute
9+
* is present), button, details, embed, iframe, img (if the usemap
10+
* attribute is present), input (if the type attribute is not in the
11+
* Hidden state), label, select, textarea, video (if the controls
12+
* attribute is present).
13+
*
14+
* Plus <summary>, which is not in §3.2.5.2.7 but is keyboard-activatable per
15+
* [§4.11.2 The summary element](https://html.spec.whatwg.org/multipage/interactive-elements.html#the-summary-element).
16+
*
17+
* This is the HTML-content-model authority — it answers "does the HTML spec
18+
* prohibit nesting this inside an interactive parent?" It does NOT answer
19+
* "is this an ARIA widget for AT semantics?" (see `interactive-roles.js`
20+
* for that). The two questions diverge on rows like <label> (HTML: yes;
21+
* ARIA: structure role), <canvas> (HTML: no; ARIA: widget per axobject),
22+
* and <option>/<datalist> (HTML: no; ARIA: widgets). Rules that need
23+
* "interactive for any reason" should compose both authorities.
24+
*/
25+
26+
const UNCONDITIONAL_INTERACTIVE_TAGS = new Set([
27+
'button',
28+
'details',
29+
'embed',
30+
'iframe',
31+
'label',
32+
'select',
33+
'summary',
34+
'textarea',
35+
]);
36+
37+
/**
38+
* Determine whether a Glimmer element node is HTML-interactive content per
39+
* §3.2.5.2.7 (+ summary).
40+
*
41+
* @param {object} node Glimmer ElementNode (has a string `tag`).
42+
* @param {Function} getTextAttrValue Helper (node, attrName) -> string | undefined
43+
* returning the static text value of an
44+
* attribute, or undefined for dynamic / missing.
45+
* @param {object} [options]
46+
* @param {boolean} [options.ignoreUsemap=false] Treat `<img usemap>` as NOT interactive.
47+
* Consumed by rules with an `ignoreUsemap`
48+
* config option that lets authors opt out
49+
* of image-map-based interactivity.
50+
* @returns {boolean}
51+
*/
52+
function isHtmlInteractiveContent(node, getTextAttrValue, options = {}) {
53+
const rawTag = node && node.tag;
54+
if (typeof rawTag !== 'string' || rawTag.length === 0) {
55+
return false;
56+
}
57+
const tag = rawTag.toLowerCase();
58+
59+
if (UNCONDITIONAL_INTERACTIVE_TAGS.has(tag)) {
60+
return true;
61+
}
62+
63+
// input — interactive unless type="hidden"
64+
if (tag === 'input') {
65+
const type = getTextAttrValue(node, 'type');
66+
return type !== 'hidden';
67+
}
68+
69+
// a — interactive only when href is present
70+
if (tag === 'a') {
71+
return hasAttribute(node, 'href');
72+
}
73+
74+
// img — interactive only when usemap is present (image map)
75+
if (tag === 'img') {
76+
if (options.ignoreUsemap) {
77+
return false;
78+
}
79+
return hasAttribute(node, 'usemap');
80+
}
81+
82+
// audio / video — interactive only when controls is present
83+
if (tag === 'audio' || tag === 'video') {
84+
return hasAttribute(node, 'controls');
85+
}
86+
87+
return false;
88+
}
89+
90+
function hasAttribute(node, name) {
91+
return Boolean(node.attributes && node.attributes.some((a) => a.name === name));
92+
}
93+
94+
module.exports = { isHtmlInteractiveContent };

lib/utils/native-interactive-elements.js

Lines changed: 0 additions & 92 deletions
This file was deleted.

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

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,8 @@ 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).
78+
// <canvas> — not in HTML §3.2.5.2.7, but upstream ember-template-lint
79+
// treats it as interactive (drawing/game-UI convention); preserved for parity.
8380
'<template><canvas onclick={{this.draw}}></canvas></template>',
8481

8582
// usemap only makes img/object interactive
@@ -219,19 +216,5 @@ ruleTester.run('template-no-invalid-interactive', rule, {
219216
},
220217
],
221218
},
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-
},
236219
],
237220
});

0 commit comments

Comments
 (0)