Skip to content

Commit 2c709c8

Browse files
Merge pull request ember-cli#2748 from johanrd/fix/native-interactive-elements-util
refactor: extract `html-interactive-content` util (HTML §3.2.5.2.7 authority)
2 parents e20d9cd + 2bbcee0 commit 2c709c8

9 files changed

Lines changed: 762 additions & 130 deletions

docs/rules/template-no-nested-interactive.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ This rule disallows nesting interactive elements inside other interactive elemen
1515
Interactive elements include:
1616

1717
- `<a>` (only when it has an `href` attribute)
18+
- `<audio>` (only when it has a `controls` attribute)
1819
- `<button>`
20+
- `<canvas>` (drawing/game-UI convention; not in the HTML spec's interactive-content category)
1921
- `<details>`
2022
- `<embed>`
2123
- `<iframe>`
@@ -24,6 +26,7 @@ Interactive elements include:
2426
- `<select>`
2527
- `<summary>`
2628
- `<textarea>`
29+
- `<video>` (only when it has a `controls` attribute)
2730
- Elements with interactive ARIA roles (e.g., `role="button"`, `role="link"`)
2831
- Elements with `tabindex` (unless `ignoreTabindex` is enabled)
2932
- Elements with `contenteditable` (except `contenteditable="false"`)
@@ -32,8 +35,9 @@ Interactive elements include:
3235
Special cases:
3336

3437
- `<label>` may contain **one** interactive child (e.g., `<label><input /></label>` is fine, but `<label><input /><button>x</button></label>` is not)
35-
- `<summary>` as the first child of `<details>` is allowed
36-
- Nested `role="menuitem"` elements are allowed (menu/sub-menu pattern)
38+
- `<summary>` as the first child of `<details>` is allowed; other interactive content after `<summary>` (in the disclosed panel) is also allowed
39+
- Canonical ARIA composite-widget hierarchies are allowed (e.g., `role="option"` inside `role="listbox"`, `role="tab"` inside `role="tablist"`, `role="row"` inside `role="grid"`, `role="radio"` inside `role="radiogroup"`). Derived from the ARIA `requiredOwnedElements` relationship.
40+
- Nested `role="menuitem"` / `role="menuitemcheckbox"` / `role="menuitemradio"` elements are allowed (menu/sub-menu pattern)
3741

3842
## Examples
3943

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

Lines changed: 21 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1+
'use strict';
2+
13
const { isNativeElement } = require('../utils/is-native-element');
4+
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
5+
const { INTERACTIVE_ROLES } = require('../utils/interactive-roles');
26

37
function hasAttr(node, name) {
48
return node.attributes?.some((a) => a.name === name);
@@ -74,42 +78,6 @@ module.exports = {
7478
const ignoreTabindex = options.ignoreTabindex || false;
7579
const ignoreUsemap = options.ignoreUsemap || false;
7680

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-
90-
const INTERACTIVE_ROLES = new Set([
91-
'button',
92-
'checkbox',
93-
'link',
94-
'menuitem',
95-
'menuitemcheckbox',
96-
'menuitemradio',
97-
'option',
98-
'radio',
99-
'scrollbar',
100-
'searchbox',
101-
'slider',
102-
'spinbutton',
103-
'switch',
104-
'tab',
105-
'textbox',
106-
'tooltip',
107-
'treeitem',
108-
'combobox',
109-
'gridcell',
110-
]);
111-
112-
// eslint-disable-next-line complexity
11381
function isInteractive(node) {
11482
const tag = node.tag?.toLowerCase();
11583
if (!tag) {
@@ -120,24 +88,17 @@ module.exports = {
12088
return true;
12189
}
12290

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')) {
91+
// HTML §3.2.5.2.7 interactive content (authoritative for content-model;
92+
// handles label/button/etc. + conditional a[href], input[!hidden],
93+
// img[usemap], audio/video[controls]).
94+
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
13095
return true;
13196
}
13297

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-
}
98+
// <canvas> — not in §3.2.5.2.7 but upstream ember-template-lint treats
99+
// it as interactive (canvas is commonly wired for drawing/game UI where
100+
// event handlers are expected). Preserved for upstream parity.
101+
if (tag === 'canvas') {
141102
return true;
142103
}
143104

@@ -152,10 +113,15 @@ module.exports = {
152113
return true;
153114
}
154115

155-
// Check contenteditable
156-
const ce = getTextAttr(node, 'contenteditable');
157-
if (ce && ce !== 'false') {
158-
return true;
116+
// Check contenteditable. Per HTML spec, valid keywords are "true",
117+
// "false", "plaintext-only", and the empty string (which is the default
118+
// state, equivalent to "true"). So the attribute enables editing unless
119+
// its value is "false".
120+
if (hasAttr(node, 'contenteditable')) {
121+
const ce = getTextAttr(node, 'contenteditable');
122+
if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
123+
return true;
124+
}
159125
}
160126

161127
// Check usemap (only on img/object)

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

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,7 @@
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+
'use strict';
122

13-
const INTERACTIVE_ROLES = new Set([
14-
'button',
15-
'checkbox',
16-
'link',
17-
'searchbox',
18-
'spinbutton',
19-
'switch',
20-
'textbox',
21-
'radio',
22-
'slider',
23-
'tab',
24-
'menuitem',
25-
'menuitemcheckbox',
26-
'menuitemradio',
27-
'option',
28-
'combobox',
29-
'gridcell',
30-
]);
3+
const { isHtmlInteractiveContent } = require('../utils/html-interactive-content');
4+
const { INTERACTIVE_ROLES, COMPOSITE_WIDGET_CHILDREN } = require('../utils/interactive-roles');
315

326
function hasAttr(node, name) {
337
return node.attributes?.some((a) => a.name === name);
@@ -41,8 +15,38 @@ function getTextAttr(node, name) {
4115
return undefined;
4216
}
4317

18+
function getRole(node) {
19+
return getTextAttr(node, 'role');
20+
}
21+
22+
// Menu submenu pattern — per WAI-ARIA APG, a `menuitem` with `aria-haspopup`
23+
// may own a nested `menu`. aria-query's `requiredOwnedElements` does not
24+
// express this "menu-inside-menuitem" direction, so it is handled explicitly.
25+
const MENUITEM_ROLES = new Set(['menuitem', 'menuitemcheckbox', 'menuitemradio']);
26+
27+
function isCompositeWidgetPattern(parentRole, childRole) {
28+
if (!parentRole || !childRole) {
29+
return false;
30+
}
31+
const allowedChildren = COMPOSITE_WIDGET_CHILDREN.get(parentRole);
32+
if (allowedChildren && allowedChildren.has(childRole)) {
33+
return true;
34+
}
35+
// Submenu: <… role="menuitem"><… role="menu"> …
36+
if (MENUITEM_ROLES.has(parentRole) && childRole === 'menu') {
37+
return true;
38+
}
39+
return false;
40+
}
41+
4442
function isMenuItemNode(node) {
45-
return getTextAttr(node, 'role') === 'menuitem';
43+
// Match all three menu-item role variants per ARIA taxonomy. `menuitem`,
44+
// `menuitemcheckbox`, and `menuitemradio` are all "menu items" — they can
45+
// carry submenus (via MENUITEM_ROLES in isCompositeWidgetPattern) and nest
46+
// each other (via the nested-menuitem compat exception). Keeping both
47+
// predicates symmetric avoids false positives on APG Menu Button /
48+
// Menubar patterns that mix the variants.
49+
return MENUITEM_ROLES.has(getTextAttr(node, 'role'));
4650
}
4751

4852
function isAllowedDetailsChild(childNode, parentEntry) {
@@ -122,22 +126,23 @@ module.exports = {
122126
return true;
123127
}
124128

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-
}
129+
// HTML §3.2.5.2.7 interactive content (authoritative for content-model
130+
// nesting — handles a[href], audio/video[controls], input[!hidden],
131+
// img[usemap], plus label/button/select/textarea/iframe/etc. unconditional).
132+
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
132133
return true;
133134
}
134135

135-
// <a> with href is interactive (without href, <a> is not interactive)
136-
if (tag === 'a' && hasAttr(node, 'href')) {
136+
// <canvas> — not in §3.2.5.2.7 but upstream ember-template-lint treats
137+
// it as interactive. Preserved for parity.
138+
if (tag === 'canvas') {
137139
return true;
138140
}
139141

140-
// Check role
142+
// ARIA widget roles (author-declared interactivity — separate authority
143+
// from HTML content-model: `html-interactive-content.js` speaks to the
144+
// HTML §3.2.5.2.7 content model, while `interactive-roles.js` speaks to
145+
// the WAI-ARIA 1.2 widget taxonomy).
141146
const role = getTextAttr(node, 'role');
142147
if (role && INTERACTIVE_ROLES.has(role)) {
143148
return true;
@@ -148,14 +153,21 @@ module.exports = {
148153
return true;
149154
}
150155

151-
// Check contenteditable
152-
const ce = getTextAttr(node, 'contenteditable');
153-
if (ce && ce !== 'false') {
154-
return true;
156+
// Check contenteditable. Per HTML spec, valid keywords are "true",
157+
// "false", "plaintext-only", and the empty string (which is the default
158+
// state, equivalent to "true"). So the attribute enables editing unless
159+
// its value is "false".
160+
if (hasAttr(node, 'contenteditable')) {
161+
const ce = getTextAttr(node, 'contenteditable');
162+
if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
163+
return true;
164+
}
155165
}
156166

157-
// Check usemap (only on img and object elements)
158-
if (!ignoreUsemap && (tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
167+
// <object usemap> — not in HTML §3.2.5.2.7 but upstream ember-template-lint
168+
// treats object+usemap as interactive (image-map behavior). Rule-level
169+
// special case for upstream parity; revisit if/when HTML-AAM clarifies.
170+
if (!ignoreUsemap && tag === 'object' && hasAttr(node, 'usemap')) {
159171
return true;
160172
}
161173

@@ -175,19 +187,21 @@ module.exports = {
175187
if (additionalInteractiveTags.has(tag)) {
176188
return false;
177189
}
178-
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
190+
if (tag === 'canvas') {
179191
return false;
180192
}
181-
if (tag === 'a' && hasAttr(node, 'href')) {
193+
if (isHtmlInteractiveContent(node, getTextAttr, { ignoreUsemap })) {
182194
return false;
183195
}
184196
const role = getTextAttr(node, 'role');
185197
if (role && INTERACTIVE_ROLES.has(role)) {
186198
return false;
187199
}
188-
const ce = getTextAttr(node, 'contenteditable');
189-
if (ce && ce !== 'false') {
190-
return false;
200+
if (hasAttr(node, 'contenteditable')) {
201+
const ce = getTextAttr(node, 'contenteditable');
202+
if (ce === undefined || ce === null || ce.trim().toLowerCase() !== 'false') {
203+
return false;
204+
}
191205
}
192206
if ((tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
193207
return false;
@@ -214,8 +228,18 @@ module.exports = {
214228
parentEntry.interactiveChildCount++;
215229
} else if (isAllowedDetailsChild(node, parentEntry)) {
216230
// flow content in the disclosed panel, or <summary> as first child
231+
} else if (isCompositeWidgetPattern(getRole(parentEntry.node), getRole(node))) {
232+
// Canonical ARIA composite-widget hierarchies — e.g. option inside
233+
// listbox, tab inside tablist, treeitem inside tree, row inside
234+
// grid/treegrid, gridcell/columnheader/rowheader inside row,
235+
// radio inside radiogroup, menu inside menuitem (submenu).
236+
// Derived from aria-query's requiredOwnedElements with superClass
237+
// inheritance — see lib/utils/interactive-roles.js.
217238
} else if (isMenuItemNode(parentEntry.node) && isMenuItemNode(node)) {
218-
// Nested menuitem nodes are valid (menu/sub-menu pattern)
239+
// Nested menu-item nodes (any combination of menuitem /
240+
// menuitemcheckbox / menuitemradio) are valid — menu/sub-menu
241+
// pattern per WAI-ARIA APG. Kept for historical compat since
242+
// aria-query doesn't encode this via requiredOwnedElements.
219243
} else {
220244
context.report({
221245
node,

0 commit comments

Comments
 (0)