Skip to content

Commit fbd4203

Browse files
committed
Extract rule: template-no-nested-interactive
1 parent 75b1904 commit fbd4203

4 files changed

Lines changed: 768 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ rules in templates can be disabled with eslint directives with mustache or html
189189
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
190190
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
191191
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
192+
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
192193
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
193194

194195
### Best Practices
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# ember/template-no-nested-interactive
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows nested interactive elements in templates.
6+
7+
Nested interactive elements (like a button inside a link) are not accessible to keyboard and screen reader users. This creates confusion about which element is actually interactive and can cause unexpected behavior.
8+
9+
## Rule Details
10+
11+
This rule disallows nesting interactive elements inside other interactive elements.
12+
13+
Interactive elements include:
14+
15+
- `<a>` (only when it has an `href` attribute)
16+
- `<button>`
17+
- `<details>`
18+
- `<embed>`
19+
- `<iframe>`
20+
- `<input>` (except `type="hidden"`)
21+
- `<label>`
22+
- `<select>`
23+
- `<summary>`
24+
- `<textarea>`
25+
- Elements with interactive ARIA roles (e.g., `role="button"`, `role="link"`)
26+
- Elements with `tabindex` (unless `ignoreTabindex` is enabled)
27+
- Elements with `contenteditable` (except `contenteditable="false"`)
28+
- Elements with `usemap` (`<img>`, `<object>` only, unless `ignoreUsemap` is enabled)
29+
30+
Special cases:
31+
32+
- `<label>` may contain **one** interactive child (e.g., `<label><input /></label>` is fine, but `<label><input /><button>x</button></label>` is not)
33+
- `<summary>` as the first child of `<details>` is allowed
34+
- Nested `role="menuitem"` elements are allowed (menu/sub-menu pattern)
35+
36+
## Examples
37+
38+
Examples of **incorrect** code for this rule:
39+
40+
```gjs
41+
<template>
42+
<button>
43+
<a href="#">Link inside button</a>
44+
</button>
45+
</template>
46+
```
47+
48+
```gjs
49+
<template>
50+
<a href="#">
51+
<button>Button inside link</button>
52+
</a>
53+
</template>
54+
```
55+
56+
```gjs
57+
<template>
58+
<label>
59+
<input type="text" />
60+
<button>Submit</button>
61+
</label>
62+
</template>
63+
```
64+
65+
Examples of **correct** code for this rule:
66+
67+
```gjs
68+
<template>
69+
<div>
70+
<button>Button</button>
71+
<a href="#">Link</a>
72+
</div>
73+
</template>
74+
```
75+
76+
```gjs
77+
<template>
78+
<label>
79+
<input type="text" />
80+
Label text
81+
</label>
82+
</template>
83+
```
84+
85+
```gjs
86+
<template>
87+
<details>
88+
<summary>Toggle</summary>
89+
Content here
90+
</details>
91+
</template>
92+
```
93+
94+
```gjs
95+
<template>
96+
<a>Not interactive without href</a>
97+
</template>
98+
```
99+
100+
## Options
101+
102+
| Name | Type | Default | Description |
103+
| --------------------------- | ---------- | ------- | ----------------------------------------------------------- |
104+
| `additionalInteractiveTags` | `string[]` | `[]` | Extra tag names to consider interactive. |
105+
| `ignoredTags` | `string[]` | `[]` | Tag names to skip checking. |
106+
| `ignoreTabindex` | `boolean` | `false` | If `true`, `tabindex` does not make an element interactive. |
107+
| `ignoreUsemap` | `boolean` | `false` | If `true`, `usemap` does not make an element interactive. |
108+
109+
## References
110+
111+
- [eslint-plugin-ember template-no-nested-interactive](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-nested-interactive.md)
112+
- [WCAG 2.1 - Interactive controls must not be nested](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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+
]);
12+
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+
]);
31+
32+
function hasAttr(node, name) {
33+
return node.attributes?.some((a) => a.name === name);
34+
}
35+
36+
function getTextAttr(node, name) {
37+
const attr = node.attributes?.find((a) => a.name === name);
38+
if (attr?.value?.type === 'GlimmerTextNode') {
39+
return attr.value.chars;
40+
}
41+
return undefined;
42+
}
43+
44+
function isMenuItemNode(node) {
45+
return getTextAttr(node, 'role') === 'menuitem';
46+
}
47+
48+
function isSummaryFirstChildOfDetails(summaryNode, parentEntry) {
49+
if (summaryNode.tag !== 'summary' || parentEntry.tag !== 'details') {
50+
return false;
51+
}
52+
const parentNode = parentEntry.node;
53+
const children = parentNode.children || [];
54+
const firstNonWhitespace = children.find((child) => {
55+
if (child.type === 'GlimmerTextNode') {
56+
return child.chars.trim().length > 0;
57+
}
58+
return true;
59+
});
60+
return firstNonWhitespace === summaryNode;
61+
}
62+
63+
/** @type {import('eslint').Rule.RuleModule} */
64+
module.exports = {
65+
meta: {
66+
type: 'problem',
67+
docs: {
68+
description: 'disallow nested interactive elements',
69+
category: 'Accessibility',
70+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-nested-interactive.md',
71+
templateMode: 'both',
72+
},
73+
fixable: null,
74+
schema: [
75+
{
76+
type: 'object',
77+
properties: {
78+
additionalInteractiveTags: { type: 'array', items: { type: 'string' } },
79+
ignoredTags: { type: 'array', items: { type: 'string' } },
80+
ignoreTabindex: { type: 'boolean' },
81+
ignoreUsemap: { type: 'boolean' },
82+
},
83+
additionalProperties: false,
84+
},
85+
],
86+
messages: {
87+
nested: 'Do not nest interactive element <{{child}}> inside <{{parent}}>.',
88+
},
89+
originallyFrom: {
90+
name: 'ember-template-lint',
91+
rule: 'lib/rules/no-nested-interactive.js',
92+
docs: 'docs/rule/no-nested-interactive.md',
93+
tests: 'test/unit/rules/no-nested-interactive-test.js',
94+
},
95+
},
96+
97+
create(context) {
98+
const options = context.options[0] || {};
99+
const additionalInteractiveTags = new Set(options.additionalInteractiveTags || []);
100+
const ignoredTags = new Set(options.ignoredTags || []);
101+
const ignoreTabindex = options.ignoreTabindex || false;
102+
const ignoreUsemap = options.ignoreUsemap || false;
103+
104+
const interactiveStack = [];
105+
// Stack for saving/restoring label interactiveChildCount across GlimmerBlock boundaries
106+
const blockCountStack = [];
107+
108+
function isInteractive(node) {
109+
const tag = node.tag?.toLowerCase();
110+
if (!tag) {
111+
return false;
112+
}
113+
if (ignoredTags.has(tag)) {
114+
return false;
115+
}
116+
if (additionalInteractiveTags.has(tag)) {
117+
return true;
118+
}
119+
120+
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
121+
if (tag === 'input') {
122+
const type = getTextAttr(node, 'type');
123+
if (type === 'hidden') {
124+
return false;
125+
}
126+
}
127+
return true;
128+
}
129+
130+
// <a> with href is interactive (without href, <a> is not interactive)
131+
if (tag === 'a' && hasAttr(node, 'href')) {
132+
return true;
133+
}
134+
135+
// Check role
136+
const role = getTextAttr(node, 'role');
137+
if (role && INTERACTIVE_ROLES.has(role)) {
138+
return true;
139+
}
140+
141+
// Check tabindex
142+
if (!ignoreTabindex && hasAttr(node, 'tabindex')) {
143+
return true;
144+
}
145+
146+
// Check contenteditable
147+
const ce = getTextAttr(node, 'contenteditable');
148+
if (ce && ce !== 'false') {
149+
return true;
150+
}
151+
152+
// Check usemap (only on img and object elements)
153+
if (!ignoreUsemap && (tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
154+
return true;
155+
}
156+
157+
return false;
158+
}
159+
160+
/**
161+
* Returns true if the element is interactive ONLY because of tabindex
162+
* (not because of tag name, role, contenteditable, usemap, etc.)
163+
* Called only after isInteractive() already returned true.
164+
*/
165+
function isInteractiveOnlyFromTabindex(node) {
166+
const tag = node.tag?.toLowerCase();
167+
if (!tag) {
168+
return false;
169+
}
170+
if (additionalInteractiveTags.has(tag)) {
171+
return false;
172+
}
173+
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
174+
return false;
175+
}
176+
if (tag === 'a' && hasAttr(node, 'href')) {
177+
return false;
178+
}
179+
const role = getTextAttr(node, 'role');
180+
if (role && INTERACTIVE_ROLES.has(role)) {
181+
return false;
182+
}
183+
const ce = getTextAttr(node, 'contenteditable');
184+
if (ce && ce !== 'false') {
185+
return false;
186+
}
187+
if ((tag === 'img' || tag === 'object') && hasAttr(node, 'usemap')) {
188+
return false;
189+
}
190+
return hasAttr(node, 'tabindex');
191+
}
192+
193+
return {
194+
GlimmerElementNode(node) {
195+
const currentIsInteractive = isInteractive(node);
196+
197+
if (currentIsInteractive && interactiveStack.length > 0) {
198+
const parentEntry = interactiveStack.at(-1);
199+
200+
if (parentEntry.tag === 'label') {
201+
// Label can contain ONE interactive child — track and flag additional ones
202+
if (parentEntry.interactiveChildCount >= 1) {
203+
context.report({
204+
node,
205+
messageId: 'nested',
206+
data: { parent: parentEntry.tag, child: node.tag },
207+
});
208+
}
209+
parentEntry.interactiveChildCount++;
210+
} else if (isSummaryFirstChildOfDetails(node, parentEntry)) {
211+
// <summary> as first non-whitespace child of <details> is allowed
212+
} else if (isMenuItemNode(parentEntry.node) && isMenuItemNode(node)) {
213+
// Nested menuitem nodes are valid (menu/sub-menu pattern)
214+
} else {
215+
context.report({
216+
node,
217+
messageId: 'nested',
218+
data: { parent: parentEntry.tag, child: node.tag },
219+
});
220+
}
221+
}
222+
223+
// Push interactive elements to the stack, but tabindex-only elements
224+
// should not become parent interactive nodes
225+
if (currentIsInteractive && !isInteractiveOnlyFromTabindex(node)) {
226+
interactiveStack.push({ tag: node.tag, node, interactiveChildCount: 0 });
227+
}
228+
},
229+
230+
'GlimmerElementNode:exit'(node) {
231+
if (interactiveStack.length > 0 && interactiveStack.at(-1).node === node) {
232+
interactiveStack.pop();
233+
}
234+
},
235+
236+
// Save/restore label interactive child count at block boundaries
237+
// so that conditional branches ({{#if}}/{{else}}) are tracked independently
238+
GlimmerBlock() {
239+
const labelEntry = interactiveStack.length > 0 ? interactiveStack.at(-1) : null;
240+
if (labelEntry && labelEntry.tag === 'label') {
241+
blockCountStack.push(labelEntry.interactiveChildCount);
242+
} else {
243+
blockCountStack.push(null);
244+
}
245+
},
246+
247+
'GlimmerBlock:exit'() {
248+
const saved = blockCountStack.pop();
249+
if (saved !== null && saved !== undefined) {
250+
const labelEntry = interactiveStack.length > 0 ? interactiveStack.at(-1) : null;
251+
if (labelEntry && labelEntry.tag === 'label') {
252+
labelEntry.interactiveChildCount = saved;
253+
}
254+
}
255+
},
256+
};
257+
},
258+
};

0 commit comments

Comments
 (0)