Skip to content

Commit 4ce7b75

Browse files
committed
Extract rule: template-no-invalid-interactive
1 parent 4629cbb commit 4ce7b75

File tree

4 files changed

+465
-0
lines changed

4 files changed

+465
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ rules in templates can be disabled with eslint directives with mustache or html
188188
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
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 | | | |
191+
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
191192

192193
### Best Practices
193194

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ember/template-no-invalid-interactive
2+
3+
<!-- end auto-generated rule header -->
4+
5+
> Disallow non-interactive elements with interactive handlers
6+
7+
## Rule Details
8+
9+
This rule prevents adding interactive event handlers (like `onclick`, `onkeydown`, etc.) to non-interactive HTML elements without proper ARIA roles.
10+
11+
## Examples
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```gjs
16+
<template>
17+
<div onclick={{this.handleClick}}>Click me</div>
18+
</template>
19+
```
20+
21+
```gjs
22+
<template>
23+
<span onkeydown={{this.handleKey}}>Press key</span>
24+
</template>
25+
```
26+
27+
Examples of **correct** code for this rule:
28+
29+
```gjs
30+
<template>
31+
<button onclick={{this.handleClick}}>Click me</button>
32+
</template>
33+
```
34+
35+
```gjs
36+
<template>
37+
<div role="button" onclick={{this.handleClick}}>Click me</div>
38+
</template>
39+
```
40+
41+
```gjs
42+
<template>
43+
<button {{on "click" this.handleClick}}>Click me</button>
44+
</template>
45+
```
46+
47+
## Options
48+
49+
| Name | Type | Default | Description |
50+
| --------------------------- | ---------- | ------- | ----------------------------------------------------------- |
51+
| `additionalInteractiveTags` | `string[]` | `[]` | Extra tag names to treat as interactive. |
52+
| `ignoredTags` | `string[]` | `[]` | Tag names to skip checking. |
53+
| `ignoreTabindex` | `boolean` | `false` | If `true`, `tabindex` does not make an element interactive. |
54+
| `ignoreUsemap` | `boolean` | `false` | If `true`, `usemap` does not make an element interactive. |
55+
56+
## References
57+
58+
- [WCAG 2.1 - 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG21/Understanding/keyboard.html)
59+
- [eslint-plugin-ember template-no-invalid-interactive](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-invalid-interactive.md)
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
function hasAttr(node, name) {
2+
return node.attributes?.some((a) => a.name === name);
3+
}
4+
5+
function getTextAttr(node, name) {
6+
const attr = node.attributes?.find((a) => a.name === name);
7+
if (attr?.value?.type === 'GlimmerTextNode') {
8+
return attr.value.chars;
9+
}
10+
return undefined;
11+
}
12+
13+
const DISALLOWED_DOM_EVENTS = new Set([
14+
// Mouse events:
15+
'click',
16+
'dblclick',
17+
'mousedown',
18+
'mousemove',
19+
'mouseover',
20+
'mouseout',
21+
'mouseup',
22+
// Keyboard events:
23+
'keydown',
24+
'keypress',
25+
'keyup',
26+
]);
27+
28+
const ELEMENT_ALLOWED_EVENTS = {
29+
form: new Set(['submit', 'reset', 'change']),
30+
img: new Set(['load', 'error']),
31+
};
32+
33+
/** @type {import('eslint').Rule.RuleModule} */
34+
module.exports = {
35+
meta: {
36+
type: 'problem',
37+
docs: {
38+
description: 'disallow non-interactive elements with interactive handlers',
39+
category: 'Accessibility',
40+
strictGjs: true,
41+
strictGts: true,
42+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-interactive.md',
43+
},
44+
schema: [
45+
{
46+
type: 'object',
47+
properties: {
48+
additionalInteractiveTags: { type: 'array', items: { type: 'string' } },
49+
ignoredTags: { type: 'array', items: { type: 'string' } },
50+
ignoreTabindex: { type: 'boolean' },
51+
ignoreUsemap: { type: 'boolean' },
52+
},
53+
additionalProperties: false,
54+
},
55+
],
56+
messages: {
57+
noInvalidInteractive:
58+
'Non-interactive element <{{tagName}}> should not have interactive handler "{{handler}}".',
59+
},
60+
},
61+
62+
create(context) {
63+
const options = context.options[0] || {};
64+
const additionalInteractiveTags = new Set(options.additionalInteractiveTags || []);
65+
const ignoredTags = new Set(options.ignoredTags || []);
66+
const ignoreTabindex = options.ignoreTabindex || false;
67+
const ignoreUsemap = options.ignoreUsemap || false;
68+
69+
const NATIVE_INTERACTIVE_ELEMENTS = new Set([
70+
'a',
71+
'button',
72+
'canvas',
73+
'details',
74+
'embed',
75+
'iframe',
76+
'input',
77+
'label',
78+
'select',
79+
'textarea',
80+
]);
81+
82+
const INTERACTIVE_ROLES = new Set([
83+
'button',
84+
'checkbox',
85+
'link',
86+
'menuitem',
87+
'menuitemcheckbox',
88+
'menuitemradio',
89+
'option',
90+
'radio',
91+
'searchbox',
92+
'slider',
93+
'spinbutton',
94+
'switch',
95+
'tab',
96+
'textbox',
97+
'combobox',
98+
'gridcell',
99+
]);
100+
101+
function isInteractive(node) {
102+
const tag = node.tag?.toLowerCase();
103+
if (!tag) {
104+
return false;
105+
}
106+
107+
if (additionalInteractiveTags.has(tag)) {
108+
return true;
109+
}
110+
if (NATIVE_INTERACTIVE_ELEMENTS.has(tag)) {
111+
// Hidden input is not interactive
112+
if (tag === 'input') {
113+
const type = getTextAttr(node, 'type');
114+
if (type === 'hidden') {
115+
return false;
116+
}
117+
}
118+
return true;
119+
}
120+
121+
// Check role
122+
const role = getTextAttr(node, 'role');
123+
if (role && INTERACTIVE_ROLES.has(role)) {
124+
return true;
125+
}
126+
127+
// Check tabindex
128+
if (!ignoreTabindex && hasAttr(node, 'tabindex')) {
129+
return true;
130+
}
131+
132+
// Check contenteditable
133+
const ce = getTextAttr(node, 'contenteditable');
134+
if (ce && ce !== 'false') {
135+
return true;
136+
}
137+
138+
// Check usemap
139+
if (!ignoreUsemap && hasAttr(node, 'usemap')) {
140+
return true;
141+
}
142+
143+
return false;
144+
}
145+
146+
return {
147+
// eslint-disable-next-line complexity
148+
GlimmerElementNode(node) {
149+
const tag = node.tag?.toLowerCase();
150+
if (!tag) {
151+
return;
152+
}
153+
if (ignoredTags.has(tag)) {
154+
return;
155+
}
156+
157+
// Skip if element is interactive
158+
if (isInteractive(node)) {
159+
return;
160+
}
161+
162+
// Skip components (PascalCase)
163+
if (/^[A-Z]/.test(node.tag)) {
164+
return;
165+
}
166+
167+
const allowedEvents = ELEMENT_ALLOWED_EVENTS[tag];
168+
169+
// Check attributes
170+
for (const attr of node.attributes || []) {
171+
const attrName = attr.name?.toLowerCase();
172+
if (!attrName || attrName.startsWith('@')) {
173+
continue;
174+
}
175+
176+
const isDynamic =
177+
attr.value?.type === 'GlimmerMustacheStatement' ||
178+
attr.value?.type === 'GlimmerConcatStatement';
179+
if (!isDynamic) {
180+
continue;
181+
}
182+
183+
const isOnAttr = attrName.startsWith('on') && attrName.length > 2;
184+
const event = isOnAttr ? attrName.slice(2) : null;
185+
186+
// Allow element-specific events (e.g. submit/reset/change on form, load/error on img)
187+
if (isOnAttr && event && allowedEvents?.has(event)) {
188+
continue;
189+
}
190+
191+
const isActionHelper =
192+
attr.value?.type === 'GlimmerMustacheStatement' &&
193+
attr.value.path?.original === 'action';
194+
195+
// Flag {{action}} helper used in any attribute on a non-interactive element
196+
if (isActionHelper) {
197+
context.report({
198+
node,
199+
messageId: 'noInvalidInteractive',
200+
data: { tagName: tag, handler: attrName },
201+
});
202+
continue;
203+
}
204+
205+
// Flag disallowed DOM events (click, mousedown, keydown, etc.) with dynamic values
206+
if (isOnAttr && DISALLOWED_DOM_EVENTS.has(event)) {
207+
context.report({
208+
node,
209+
messageId: 'noInvalidInteractive',
210+
data: { tagName: tag, handler: attrName },
211+
});
212+
}
213+
}
214+
215+
// Check modifiers
216+
for (const mod of node.modifiers || []) {
217+
const modName = mod.path?.original;
218+
219+
if (modName === 'on') {
220+
const eventParam = mod.params?.[0];
221+
const event =
222+
eventParam?.type === 'GlimmerStringLiteral' ? eventParam.value : undefined;
223+
224+
// Allow element-specific events
225+
if (event && allowedEvents?.has(event)) {
226+
continue;
227+
}
228+
// Allow non-disallowed events (scroll, copy, toggle, pause, etc.)
229+
if (event && !DISALLOWED_DOM_EVENTS.has(event)) {
230+
continue;
231+
}
232+
233+
context.report({
234+
node,
235+
messageId: 'noInvalidInteractive',
236+
data: { tagName: tag, handler: '{{on}}' },
237+
});
238+
} else if (modName === 'action') {
239+
// Determine the event from on= hash param (default: 'click')
240+
let event = 'click';
241+
const onPair = mod.hash?.pairs?.find((p) => p.key === 'on');
242+
if (onPair) {
243+
event = onPair.value?.value || onPair.value?.original || 'click';
244+
}
245+
246+
// Allow element-specific events
247+
if (allowedEvents?.has(event)) {
248+
continue;
249+
}
250+
251+
context.report({
252+
node,
253+
messageId: 'noInvalidInteractive',
254+
data: { tagName: tag, handler: '{{action}}' },
255+
});
256+
}
257+
}
258+
},
259+
};
260+
},
261+
};

0 commit comments

Comments
 (0)