Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,22 +178,23 @@ rules in templates can be disabled with eslint directives with mustache or html

### Accessibility

| Name                                  | Description | 💼 | 🔧 | 💡 |
| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
| [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 | | | |
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
| Name                                   | Description | 💼 | 🔧 | 💡 |
| :--------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- |
| [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | | | |
| [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | | | |
| [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | | 🔧 | |
| [template-no-aria-hidden-body](docs/rules/template-no-aria-hidden-body.md) | disallow aria-hidden on body element | | 🔧 | |
| [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 | | | |
| [template-no-autofocus-attribute](docs/rules/template-no-autofocus-attribute.md) | disallow autofocus attribute | | 🔧 | |
| [template-no-empty-headings](docs/rules/template-no-empty-headings.md) | disallow empty heading elements | | | |
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |

### Best Practices

Expand Down
85 changes: 85 additions & 0 deletions docs/rules/template-no-pointer-down-event-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# ember/template-no-pointer-down-event-binding

<!-- end auto-generated rule header -->

Disallows pointer down event bindings (`mousedown`, `pointerdown`).

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.

## Rule Details

This rule disallows the use of `mousedown`, `onmousedown`, `pointerdown`, and `onpointerdown` events in templates, whether via `{{on}}`, `{{action on=...}}`, or HTML attributes.

## Examples

Examples of **incorrect** code for this rule:

```gjs
<template>
<button {{on "mousedown" this.handleMouseDown}}>Click</button>
</template>
```

```gjs
<template>
<div {{on "pointerdown" this.handlePointerDown}}>Content</div>
</template>
```

```gjs
<template>
<div onmousedown={{this.handleMouseDown}}>Content</div>
</template>
```

```gjs
<template>
<div {{action this.handler on="mousedown"}}></div>
</template>
```

Examples of **correct** code for this rule:

```gjs
<template>
<button {{on "mouseup" this.handleMouseUp}}>Click</button>
</template>
```

```gjs
<template>
<div {{on "pointerup" this.handlePointerUp}}>Content</div>
</template>
```

```gjs
<template>
<button {{on "click" this.handleClick}}>Click</button>
</template>
```

## Migration

Replace:

```gjs
<button {{on "mousedown" this.action}}>
```

With:

```gjs
<button {{on "mouseup" this.action}}>
```

Or use the more modern pointer event:

```gjs
<button {{on "pointerup" this.action}}>
```

## References

- [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)
- [MDN - Pointer events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events)
- [MDN - mousedown event](https://developer.mozilla.org/en-US/docs/Web/API/Element/mousedown_event)
65 changes: 65 additions & 0 deletions lib/rules/template-no-pointer-down-event-binding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow pointer down event bindings',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-pointer-down-event-binding.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
unexpected: 'Avoid binding to a pointer `down` event; bind to a pointer `up` event instead.',
},
},

create(context) {
const DOWN_EVENTS = new Set(['mousedown', 'onmousedown', 'pointerdown', 'onpointerdown']);

function isDownEvent(name) {
return DOWN_EVENTS.has(name.toLowerCase());
}

return {
GlimmerElementNode(node) {
// Check for onmousedown/onpointerdown HTML attributes
if (node.attributes) {
for (const attr of node.attributes) {
if (attr.name && attr.name.startsWith('on') && isDownEvent(attr.name)) {
context.report({ node: attr, messageId: 'unexpected' });
}
}
}

// Check modifiers: {{on "mousedown"}} and {{action ... on="mousedown"}}
if (node.modifiers) {
for (const modifier of node.modifiers) {
if (modifier.path?.type !== 'GlimmerPathExpression') {
continue;
}

if (modifier.path.original === 'on' && modifier.params?.length > 0) {
const eventParam = modifier.params[0];
if (eventParam.type === 'GlimmerStringLiteral' && isDownEvent(eventParam.value)) {
context.report({ node: modifier, messageId: 'unexpected' });
}
}

if (modifier.path.original === 'action') {
const onPair = modifier.hash?.pairs?.find((p) => p.key === 'on');
if (
onPair &&
onPair.value?.type === 'GlimmerStringLiteral' &&
isDownEvent(onPair.value.value)
) {
context.report({ node: modifier, messageId: 'unexpected' });
}
}
}
}
},
};
},
};
128 changes: 128 additions & 0 deletions tests/lib/rules/template-no-pointer-down-event-binding.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require('../../../lib/rules/template-no-pointer-down-event-binding');
const RuleTester = require('eslint').RuleTester;

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

ruleTester.run('template-no-pointer-down-event-binding', rule, {
valid: [
'<template><button {{on "click" this.handleClick}}>Click</button></template>',
'<template><button {{on "keydown" this.handleKeyDown}}>Press</button></template>',
'<template><div {{on "mouseup" this.handleMouseUp}}>Content</div></template>',
'<template><div {{on "pointerup" this.handlePointerUp}}>Content</div></template>',
'<template><div {{action this.handler on="click"}}></div></template>',
'<template><div {{action this.handler on="mouseup"}}></div></template>',
// Case-insensitive: MOUSEUP is fine
'<template><div {{on "MOUSEUP" this.handler}}>Content</div></template>',
// onmouseup attribute is fine
'<template><input type="text" onmouseup="myFunction()"></template>',
// Component arguments are not flagged (could be any prop name)
'<template><MyComponent @mouseDown={{this.doSomething}} /></template>',
],

invalid: [
{
code: '<template><button {{on "mousedown" this.handleMouseDown}}>Click</button></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
{
code: '<template><div {{on "pointerdown" this.handlePointerDown}}>Content</div></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
// Case-insensitive
{
code: '<template><div {{on "MouseDown" this.handler}}>Content</div></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
// HTML attributes
{
code: '<template><div onmousedown={{this.handleMouseDown}}>Content</div></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
},
{
code: '<template><input type="text" onmousedown="myFunction()"></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
},
{
code: '<template><div onpointerdown={{this.handlePointerDown}}>Content</div></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
},
// {{action}} modifier with on= hash pair
{
code: '<template><div {{action this.handler on="mousedown"}}></div></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
{
code: '<template><div {{action this.handler on="pointerdown"}}></div></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
// on= is not the first hash pair
{
code: '<template><div {{action this.handler preventDefault=true on="mousedown"}}></div></template>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
],
});

const hbsRuleTester = new RuleTester({
parser: require.resolve('ember-eslint-parser/hbs'),
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
});

hbsRuleTester.run('template-no-pointer-down-event-binding', rule, {
valid: [
'<div {{on "mouseup" this.doSomething}}></div>',
'<div {{action this.doSomething on="mouseup"}}></div>',
'<input type="text" onmouseup="myFunction()">',
// Component arguments are not flagged
'{{my-component mouseDown=this.doSomething}}',
'<MyComponent @mouseDown={{this.doSomething}} />',
],
invalid: [
{
code: '<div {{on "mousedown" this.doSomething}}></div>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
{
code: '<div {{action this.doSomething on="mousedown"}}></div>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
// on= is not the first hash pair
{
code: '<div {{action this.doSomething preventDefault=true on="mousedown"}}></div>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
{
code: '<input type="text" onmousedown="myFunction()">',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerAttrNode' }],
},
{
code: '<div {{on "pointerdown" this.doSomething}}></div>',
output: null,
errors: [{ messageId: 'unexpected', type: 'GlimmerElementModifierStatement' }],
},
],
});
Loading