Skip to content

Commit 1c9a858

Browse files
Merge pull request #2418 from NullVoxPopuli/nvp/template-lint-extract-rule-template-no-down-event-binding
Extract rule: template-no-down-event-binding
2 parents 472dbb0 + eff76dc commit 1c9a858

4 files changed

Lines changed: 295 additions & 16 deletions

File tree

README.md

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -178,22 +178,23 @@ rules in templates can be disabled with eslint directives with mustache or html
178178

179179
### Accessibility
180180

181-
| Name                                  | Description | 💼 | 🔧 | 💡 |
182-
| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
183-
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
184-
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
185-
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
186-
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187-
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
188-
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
189-
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
190-
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
191-
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
192-
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
193-
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
194-
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
195-
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
196-
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
181+
| Name                                   | Description | 💼 | 🔧 | 💡 |
182+
| :--------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
183+
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
184+
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
185+
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
186+
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
187+
| [template-no-aria-unsupported-elements](docs/rules/template-no-aria-unsupported-elements.md) | disallow ARIA roles, states, and properties on elements that do not support them | | | |
188+
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
189+
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
190+
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
191+
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
192+
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
193+
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
194+
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
195+
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
196+
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
197+
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
197198

198199
### Best Practices
199200

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# ember/template-no-pointer-down-event-binding
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows pointer down event bindings (`mousedown`, `pointerdown`).
6+
7+
Pointer down events fire before the user releases the pointer, which can cause accessibility issues — actions triggered on down events don't allow users to cancel by moving the pointer away before releasing. Bind to the corresponding pointer up event instead.
8+
9+
## Rule Details
10+
11+
This rule disallows the use of `mousedown`, `onmousedown`, `pointerdown`, and `onpointerdown` events in templates, whether via `{{on}}`, `{{action on=...}}`, or HTML attributes.
12+
13+
## Examples
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```gjs
18+
<template>
19+
<button {{on "mousedown" this.handleMouseDown}}>Click</button>
20+
</template>
21+
```
22+
23+
```gjs
24+
<template>
25+
<div {{on "pointerdown" this.handlePointerDown}}>Content</div>
26+
</template>
27+
```
28+
29+
```gjs
30+
<template>
31+
<div onmousedown={{this.handleMouseDown}}>Content</div>
32+
</template>
33+
```
34+
35+
```gjs
36+
<template>
37+
<div {{action this.handler on="mousedown"}}></div>
38+
</template>
39+
```
40+
41+
Examples of **correct** code for this rule:
42+
43+
```gjs
44+
<template>
45+
<button {{on "mouseup" this.handleMouseUp}}>Click</button>
46+
</template>
47+
```
48+
49+
```gjs
50+
<template>
51+
<div {{on "pointerup" this.handlePointerUp}}>Content</div>
52+
</template>
53+
```
54+
55+
```gjs
56+
<template>
57+
<button {{on "click" this.handleClick}}>Click</button>
58+
</template>
59+
```
60+
61+
## Migration
62+
63+
Replace:
64+
65+
```gjs
66+
<button {{on "mousedown" this.action}}>
67+
```
68+
69+
With:
70+
71+
```gjs
72+
<button {{on "mouseup" this.action}}>
73+
```
74+
75+
Or use the more modern pointer event:
76+
77+
```gjs
78+
<button {{on "pointerup" this.action}}>
79+
```
80+
81+
## References
82+
83+
- [ember-template-lint no-pointer-down-event-binding](https://github.com/ember-template-lint/ember-template-lint/blob/master/docs/rule/no-pointer-down-event-binding.md)
84+
- [MDN - Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)
85+
- [MDN - mousedown event](https://developer.mozilla.org/en-US/docs/Web/API/Element/mousedown_event)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/** @type {import('eslint').Rule.RuleModule} */
2+
module.exports = {
3+
meta: {
4+
type: 'problem',
5+
docs: {
6+
description: 'disallow pointer down event bindings',
7+
category: 'Accessibility',
8+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-pointer-down-event-binding.md',
9+
templateMode: 'both',
10+
},
11+
fixable: null,
12+
schema: [],
13+
messages: {
14+
unexpected: 'Avoid binding to a pointer `down` event; bind to a pointer `up` event instead.',
15+
},
16+
},
17+
18+
create(context) {
19+
const DOWN_EVENTS = new Set(['mousedown', 'onmousedown', 'pointerdown', 'onpointerdown']);
20+
21+
function isDownEvent(name) {
22+
return DOWN_EVENTS.has(name.toLowerCase());
23+
}
24+
25+
return {
26+
GlimmerElementNode(node) {
27+
// Check for onmousedown/onpointerdown HTML attributes
28+
if (node.attributes) {
29+
for (const attr of node.attributes) {
30+
if (attr.name && attr.name.startsWith('on') && isDownEvent(attr.name)) {
31+
context.report({ node: attr, messageId: 'unexpected' });
32+
}
33+
}
34+
}
35+
36+
// Check modifiers: {{on "mousedown"}} and {{action ... on="mousedown"}}
37+
if (node.modifiers) {
38+
for (const modifier of node.modifiers) {
39+
if (modifier.path?.type !== 'GlimmerPathExpression') {
40+
continue;
41+
}
42+
43+
if (modifier.path.original === 'on' && modifier.params?.length > 0) {
44+
const eventParam = modifier.params[0];
45+
if (eventParam.type === 'GlimmerStringLiteral' && isDownEvent(eventParam.value)) {
46+
context.report({ node: modifier, messageId: 'unexpected' });
47+
}
48+
}
49+
50+
if (modifier.path.original === 'action') {
51+
const onPair = modifier.hash?.pairs?.find((p) => p.key === 'on');
52+
if (
53+
onPair &&
54+
onPair.value?.type === 'GlimmerStringLiteral' &&
55+
isDownEvent(onPair.value.value)
56+
) {
57+
context.report({ node: modifier, messageId: 'unexpected' });
58+
}
59+
}
60+
}
61+
}
62+
},
63+
};
64+
},
65+
};
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//------------------------------------------------------------------------------
2+
// Requirements
3+
//------------------------------------------------------------------------------
4+
5+
const rule = require('../../../lib/rules/template-no-pointer-down-event-binding');
6+
const RuleTester = require('eslint').RuleTester;
7+
8+
//------------------------------------------------------------------------------
9+
// Tests
10+
//------------------------------------------------------------------------------
11+
12+
const ruleTester = new RuleTester({
13+
parser: require.resolve('ember-eslint-parser'),
14+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
15+
});
16+
17+
ruleTester.run('template-no-pointer-down-event-binding', rule, {
18+
valid: [
19+
'<template><button {{on "click" this.handleClick}}>Click</button></template>',
20+
'<template><button {{on "keydown" this.handleKeyDown}}>Press</button></template>',
21+
'<template><div {{on "mouseup" this.handleMouseUp}}>Content</div></template>',
22+
'<template><div {{on "pointerup" this.handlePointerUp}}>Content</div></template>',
23+
'<template><div {{action this.handler on="click"}}></div></template>',
24+
'<template><div {{action this.handler on="mouseup"}}></div></template>',
25+
// Case-insensitive: MOUSEUP is fine
26+
'<template><div {{on "MOUSEUP" this.handler}}>Content</div></template>',
27+
// onmouseup attribute is fine
28+
'<template><input type="text" onmouseup="myFunction()"></template>',
29+
// Component arguments are not flagged (could be any prop name)
30+
'<template><MyComponent @mouseDown={{this.doSomething}} /></template>',
31+
],
32+
33+
invalid: [
34+
{
35+
code: '<template><button {{on "mousedown" this.handleMouseDown}}>Click</button></template>',
36+
output: null,
37+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
38+
},
39+
{
40+
code: '<template><div {{on "pointerdown" this.handlePointerDown}}>Content</div></template>',
41+
output: null,
42+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
43+
},
44+
// Case-insensitive
45+
{
46+
code: '<template><div {{on "MouseDown" this.handler}}>Content</div></template>',
47+
output: null,
48+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
49+
},
50+
// HTML attributes
51+
{
52+
code: '<template><div onmousedown={{this.handleMouseDown}}>Content</div></template>',
53+
output: null,
54+
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
55+
},
56+
{
57+
code: '<template><input type="text" onmousedown="myFunction()"></template>',
58+
output: null,
59+
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
60+
},
61+
{
62+
code: '<template><div onpointerdown={{this.handlePointerDown}}>Content</div></template>',
63+
output: null,
64+
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
65+
},
66+
// {{action}} modifier with on= hash pair
67+
{
68+
code: '<template><div {{action this.handler on="mousedown"}}></div></template>',
69+
output: null,
70+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
71+
},
72+
{
73+
code: '<template><div {{action this.handler on="pointerdown"}}></div></template>',
74+
output: null,
75+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
76+
},
77+
// on= is not the first hash pair
78+
{
79+
code: '<template><div {{action this.handler preventDefault=true on="mousedown"}}></div></template>',
80+
output: null,
81+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
82+
},
83+
],
84+
});
85+
86+
const hbsRuleTester = new RuleTester({
87+
parser: require.resolve('ember-eslint-parser/hbs'),
88+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
89+
});
90+
91+
hbsRuleTester.run('template-no-pointer-down-event-binding', rule, {
92+
valid: [
93+
'<div {{on "mouseup" this.doSomething}}></div>',
94+
'<div {{action this.doSomething on="mouseup"}}></div>',
95+
'<input type="text" onmouseup="myFunction()">',
96+
// Component arguments are not flagged
97+
'{{my-component mouseDown=this.doSomething}}',
98+
'<MyComponent @mouseDown={{this.doSomething}} />',
99+
],
100+
invalid: [
101+
{
102+
code: '<div {{on "mousedown" this.doSomething}}></div>',
103+
output: null,
104+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
105+
},
106+
{
107+
code: '<div {{action this.doSomething on="mousedown"}}></div>',
108+
output: null,
109+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
110+
},
111+
// on= is not the first hash pair
112+
{
113+
code: '<div {{action this.doSomething preventDefault=true on="mousedown"}}></div>',
114+
output: null,
115+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
116+
},
117+
{
118+
code: '<input type="text" onmousedown="myFunction()">',
119+
output: null,
120+
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
121+
},
122+
{
123+
code: '<div {{on "pointerdown" this.doSomething}}></div>',
124+
output: null,
125+
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
126+
},
127+
],
128+
});

0 commit comments

Comments
 (0)