Skip to content

Commit 38a6b74

Browse files
committed
Extract rule: template-no-positive-tabindex
1 parent 87cbd47 commit 38a6b74

4 files changed

Lines changed: 420 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
197197
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
198198
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
199199
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
200+
| [template-no-positive-tabindex](docs/rules/template-no-positive-tabindex.md) | disallow positive tabindex values | | | |
200201
| [template-no-redundant-role](docs/rules/template-no-redundant-role.md) | disallow redundant role attributes | | 🔧 | |
201202
| [template-no-unsupported-role-attributes](docs/rules/template-no-unsupported-role-attributes.md) | disallow ARIA attributes that are not supported by the element role | | 🔧 | |
202203
| [template-no-whitespace-within-word](docs/rules/template-no-whitespace-within-word.md) | disallow excess whitespace within words (e.g. "W e l c o m e") | | | |
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ember/template-no-positive-tabindex
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows positive `tabindex` values.
6+
7+
Positive `tabindex` values disrupt the natural tab order of the page, making keyboard navigation confusing for users. This is especially problematic for users who rely on keyboard navigation, such as those with motor disabilities.
8+
9+
## Rule Details
10+
11+
This rule disallows positive integer values for the `tabindex` attribute. Only `0` (for naturally focusable elements) and `-1` (for programmatically focusable elements) are allowed.
12+
13+
## `<* tabindex>`
14+
15+
[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) explains the motivation of this rule nicely:
16+
17+
> Avoid using tabindex values greater than 0. Doing so makes it difficult for people who rely on assistive technology to navigate and operate page content. Instead, write the document with the elements in a logical sequence.
18+
19+
This rule prevents usage of any `tabindex` values other than `0` and `-1`. It does allow for dynamic values (choosing which value to show based on some condition / helper / etc), but only if that inline `if` condition has static `0`/`-1` as the value.
20+
21+
This rule takes no arguments.
22+
23+
## Examples
24+
25+
Examples of **incorrect** code for this rule:
26+
27+
```gjs
28+
<template>
29+
<div tabindex="1">Content</div>
30+
</template>
31+
```
32+
33+
```gjs
34+
<template>
35+
<button tabindex="2">Click</button>
36+
</template>
37+
```
38+
39+
Examples of **correct** code for this rule:
40+
41+
```gjs
42+
<template>
43+
<div tabindex="0">Content</div>
44+
</template>
45+
```
46+
47+
```gjs
48+
<template>
49+
<div tabindex="-1">Content</div>
50+
</template>
51+
```
52+
53+
```gjs
54+
<template>
55+
<button>Click</button>
56+
</template>
57+
```
58+
59+
## When Not To Use It
60+
61+
This rule should generally always be enabled for accessibility. However, if you have a specific use case where positive tabindex values are necessary and well-tested, you may disable it.
62+
63+
## References
64+
65+
- [eslint-plugin-ember template-no-positive-tabindex](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-positive-tabindex.md)
66+
- [MDN tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex)
67+
- [WebAIM: Keyboard Accessibility](https://webaim.org/techniques/keyboard/tabindex)
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Check if a tabindex value is statically verifiable as safe (0 or negative).
3+
* Returns true only if we can confirm the value is not positive.
4+
* Dynamic, non-numeric, or boolean values are considered unsafe.
5+
*/
6+
function isTabindexSafe(attrValue) {
7+
if (!attrValue) {
8+
return true;
9+
}
10+
11+
// Handle simple text values like tabindex="0" or tabindex="-1"
12+
if (attrValue.type === 'GlimmerTextNode') {
13+
const value = Number.parseInt(attrValue.chars, 10);
14+
return !Number.isNaN(value) && value <= 0;
15+
}
16+
17+
// Handle mustache statements like tabindex={{-1}} or tabindex={{someProperty}}
18+
if (attrValue.type === 'GlimmerMustacheStatement') {
19+
const path = attrValue.path;
20+
21+
if (path.type === 'GlimmerNumberLiteral') {
22+
return Number.parseInt(path.original, 10) <= 0;
23+
}
24+
if (path.type === 'GlimmerStringLiteral') {
25+
const value = Number.parseInt(path.original, 10);
26+
return !Number.isNaN(value) && value <= 0;
27+
}
28+
29+
// Handle conditional expressions like {{if this.show -1 0}}
30+
if (
31+
path.type === 'GlimmerPathExpression' &&
32+
(path.original === 'if' || path.original === 'unless')
33+
) {
34+
return isConditionalTabindexSafe(attrValue.params);
35+
}
36+
37+
// Any other dynamic value (variable, boolean, etc.) is not verifiably safe
38+
return false;
39+
}
40+
41+
// Handle concat statements like tabindex="{{-1}}" or tabindex="{{false}}"
42+
if (attrValue.type === 'GlimmerConcatStatement') {
43+
const parts = attrValue.parts || [];
44+
if (parts.length > 0 && parts[0].type === 'GlimmerMustacheStatement') {
45+
return isTabindexSafe(parts[0]);
46+
}
47+
return false;
48+
}
49+
50+
return false;
51+
}
52+
53+
/**
54+
* Check that all branches of a conditional (if/unless) expression are safe.
55+
*/
56+
function isConditionalTabindexSafe(params) {
57+
if (!params) {
58+
return false;
59+
}
60+
61+
// Check the value branches (params[1] and optionally params[2])
62+
for (let i = 1; i < params.length && i < 3; i++) {
63+
const param = params[i];
64+
if (param.type === 'GlimmerNumberLiteral') {
65+
if (Number.parseInt(param.original, 10) > 0) {
66+
return false;
67+
}
68+
} else if (param.type === 'GlimmerStringLiteral') {
69+
const val = Number.parseInt(param.original, 10);
70+
if (Number.isNaN(val) || val > 0) {
71+
return false;
72+
}
73+
} else {
74+
// Dynamic value in branch — not verifiably safe
75+
return false;
76+
}
77+
}
78+
79+
return true;
80+
}
81+
82+
/** @type {import('eslint').Rule.RuleModule} */
83+
module.exports = {
84+
meta: {
85+
type: 'suggestion',
86+
docs: {
87+
description: 'disallow positive tabindex values',
88+
category: 'Accessibility',
89+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-positive-tabindex.md',
90+
templateMode: 'both',
91+
},
92+
fixable: null,
93+
schema: [],
94+
messages: {
95+
positive: 'Avoid positive integer values for tabindex.',
96+
},
97+
originallyFrom: {
98+
name: 'ember-template-lint',
99+
rule: 'lib/rules/no-positive-tabindex.js',
100+
docs: 'docs/rule/no-positive-tabindex.md',
101+
tests: 'test/unit/rules/no-positive-tabindex-test.js',
102+
},
103+
},
104+
105+
create(context) {
106+
return {
107+
GlimmerElementNode(node) {
108+
const tabindexAttr = node.attributes?.find((attr) => attr.name === 'tabindex');
109+
110+
if (!tabindexAttr || !tabindexAttr.value) {
111+
return;
112+
}
113+
114+
if (!isTabindexSafe(tabindexAttr.value)) {
115+
context.report({
116+
node: tabindexAttr,
117+
messageId: 'positive',
118+
});
119+
}
120+
},
121+
};
122+
},
123+
};

0 commit comments

Comments
 (0)