Skip to content

Commit a484593

Browse files
committed
feat: add template-no-noninteractive-tabindex
Flags tabindex on non-interactive elements. tabindex="0" on a <div> puts it in the tab order without any keyboard semantics — users reach it but have no way to operate it. Not flagged (interactive): - native interactive tags (button, a[href], input, select, textarea, summary, details, iframe, embed) - non-interactive tags with an interactive ARIA role (role="button", role="checkbox", role="tab", etc.) Dynamic role values (mustache) are conservatively skipped — we can't statically determine whether they resolve to an interactive role. Interactive-role taxonomy derived from aria-query (widget/command/ composite/input/range ancestors) plus toolbar (per jsx-a11y). Shape modelled on eslint-plugin-jsx-a11y/no-noninteractive-tabindex. Not added to template-lint-migration — opt-in.
1 parent 24882a3 commit a484593

3 files changed

Lines changed: 285 additions & 0 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# ember/template-no-noninteractive-tabindex
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallow `tabindex` on non-interactive elements.
6+
7+
Adding `tabindex="0"` to a `<div>`, `<section>`, etc. puts it in the keyboard tab order without supplying any keyboard semantics — users reach the element but have no way to operate it, and screen readers announce the tag with no hint of interactivity.
8+
9+
If the element is meant to be interactive, give it an explicit ARIA role (`button`, `checkbox`, …) **and** wire up the appropriate keyboard event handlers. If it isn't meant to be interactive, remove the tabindex.
10+
11+
## Examples
12+
13+
This rule **forbids** the following:
14+
15+
```gjs
16+
<template>
17+
<div tabindex="0"></div>
18+
<span tabindex="-1">text</span>
19+
<article tabindex="0">Story</article>
20+
<div role="article" tabindex="0"></div>
21+
<a tabindex="0">Not a link (missing href)</a>
22+
</template>
23+
```
24+
25+
This rule **allows** the following:
26+
27+
```gjs
28+
<template>
29+
{{! Interactive native elements }}
30+
<button tabindex="0">Click</button>
31+
<a href="/x" tabindex="0">Link</a>
32+
<input tabindex="-1" />
33+
34+
{{! Non-interactive element with an interactive ARIA role }}
35+
<div role="button" tabindex="0"></div>
36+
<div role="checkbox" tabindex="0" aria-checked="false"></div>
37+
38+
{{! Dynamic role — conservatively skipped }}
39+
<div role={{this.role}} tabindex="0"></div>
40+
</template>
41+
```
42+
43+
## References
44+
45+
- [WAI-ARIA Authoring Practices — Keyboard Interaction](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)
46+
- [MDN — tabindex](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/tabindex)
47+
- [`no-noninteractive-tabindex` — eslint-plugin-jsx-a11y](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/no-noninteractive-tabindex.md)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
const { dom, roles } = require('aria-query');
2+
3+
// Native elements with default interactive semantics — tabindex here is fine.
4+
const INHERENTLY_INTERACTIVE_TAGS = new Set([
5+
'button',
6+
'details',
7+
'embed',
8+
'iframe',
9+
'input',
10+
'select',
11+
'summary',
12+
'textarea',
13+
]);
14+
15+
// Interactive ARIA roles (widget/command/composite/input/range subtypes) —
16+
// tabindex is required for widget keyboard access, so allow it when present.
17+
const INTERACTIVE_ROLES = buildInteractiveRoleSet();
18+
19+
function buildInteractiveRoleSet() {
20+
const result = new Set();
21+
for (const [role, def] of roles) {
22+
if (def.abstract) {
23+
continue;
24+
}
25+
const ancestors = new Set();
26+
for (const chain of def.superClass || []) {
27+
for (const cls of chain) {
28+
ancestors.add(cls);
29+
}
30+
}
31+
if (
32+
ancestors.has('widget') ||
33+
ancestors.has('command') ||
34+
ancestors.has('composite') ||
35+
ancestors.has('input') ||
36+
ancestors.has('range')
37+
) {
38+
result.add(role);
39+
}
40+
}
41+
// toolbar is practically widget-like — see jsx-a11y's note.
42+
result.add('toolbar');
43+
return result;
44+
}
45+
46+
function findAttr(node, name) {
47+
return node.attributes?.find((a) => a.name === name);
48+
}
49+
50+
function getTextAttrValue(attr) {
51+
if (attr?.value?.type === 'GlimmerTextNode') {
52+
return attr.value.chars;
53+
}
54+
return undefined;
55+
}
56+
57+
function isInteractiveElement(node) {
58+
const tag = node.tag?.toLowerCase();
59+
if (INHERENTLY_INTERACTIVE_TAGS.has(tag)) {
60+
if (tag === 'input') {
61+
const type = getTextAttrValue(findAttr(node, 'type'));
62+
if (type === 'hidden') {
63+
return false;
64+
}
65+
}
66+
return true;
67+
}
68+
if (tag === 'a' && findAttr(node, 'href')) {
69+
return true;
70+
}
71+
return false;
72+
}
73+
74+
// Returns "interactive", "non-interactive", or "unknown" (dynamic value).
75+
function roleStatus(node) {
76+
const attr = findAttr(node, 'role');
77+
if (!attr) {
78+
return 'non-interactive';
79+
}
80+
if (attr.value?.type !== 'GlimmerTextNode') {
81+
// Dynamic role — can't statically tell. Be conservative: skip flagging.
82+
return 'unknown';
83+
}
84+
const tokens = attr.value.chars.trim().toLowerCase().split(/\s+/u);
85+
return tokens.some((t) => INTERACTIVE_ROLES.has(t)) ? 'interactive' : 'non-interactive';
86+
}
87+
88+
/** @type {import('eslint').Rule.RuleModule} */
89+
module.exports = {
90+
meta: {
91+
type: 'problem',
92+
docs: {
93+
description:
94+
'disallow tabindex on non-interactive elements (elements without interactive native semantics or interactive ARIA role)',
95+
category: 'Accessibility',
96+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-noninteractive-tabindex.md',
97+
templateMode: 'both',
98+
},
99+
fixable: null,
100+
schema: [],
101+
messages: {
102+
noNonInteractiveTabindex:
103+
'tabindex on non-interactive element <{{tag}}> — tabindex should only be used on interactive elements or non-interactive elements with an explicit interactive role.',
104+
},
105+
},
106+
107+
create(context) {
108+
return {
109+
GlimmerElementNode(node) {
110+
const tabindex = findAttr(node, 'tabindex');
111+
if (!tabindex) {
112+
return;
113+
}
114+
115+
const tag = node.tag?.toLowerCase();
116+
if (!tag) {
117+
return;
118+
}
119+
120+
// Skip components and custom elements (not in aria-query's dom map).
121+
if (!dom.has(tag)) {
122+
return;
123+
}
124+
125+
if (isInteractiveElement(node)) {
126+
return;
127+
}
128+
129+
const status = roleStatus(node);
130+
if (status === 'interactive' || status === 'unknown') {
131+
return;
132+
}
133+
134+
context.report({
135+
node: tabindex,
136+
messageId: 'noNonInteractiveTabindex',
137+
data: { tag: node.tag },
138+
});
139+
},
140+
};
141+
},
142+
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
'use strict';
2+
3+
const rule = require('../../../lib/rules/template-no-noninteractive-tabindex');
4+
const RuleTester = require('eslint').RuleTester;
5+
6+
const ruleTester = new RuleTester({
7+
parser: require.resolve('ember-eslint-parser'),
8+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
9+
});
10+
11+
ruleTester.run('template-no-noninteractive-tabindex', rule, {
12+
valid: [
13+
// No tabindex → rule doesn't fire.
14+
'<template><div></div></template>',
15+
'<template><article></article></template>',
16+
17+
// Interactive native elements.
18+
'<template><button tabindex="0">Click</button></template>',
19+
'<template><a href="/x" tabindex="0">Link</a></template>',
20+
'<template><input tabindex="-1" /></template>',
21+
'<template><select tabindex="0"></select></template>',
22+
23+
// Non-interactive element made interactive via role.
24+
'<template><div role="button" tabindex="0"></div></template>',
25+
'<template><div role="checkbox" tabindex="0" aria-checked="false"></div></template>',
26+
'<template><div role="tab" tabindex="0"></div></template>',
27+
'<template><div role="menuitem" tabindex="-1"></div></template>',
28+
29+
// Components and custom elements — rule skips.
30+
'<template><CustomWidget tabindex="0" /></template>',
31+
'<template><my-widget tabindex="0"></my-widget></template>',
32+
33+
// Dynamic role — rule conservatively skips.
34+
'<template><div role={{this.role}} tabindex="0"></div></template>',
35+
],
36+
invalid: [
37+
{
38+
code: '<template><div tabindex="0"></div></template>',
39+
output: null,
40+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
41+
},
42+
{
43+
code: '<template><span tabindex="-1">text</span></template>',
44+
output: null,
45+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
46+
},
47+
{
48+
code: '<template><article tabindex="0">Story</article></template>',
49+
output: null,
50+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
51+
},
52+
// Non-interactive role doesn't save it.
53+
{
54+
code: '<template><div role="article" tabindex="0"></div></template>',
55+
output: null,
56+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
57+
},
58+
{
59+
code: '<template><div role="heading" tabindex="0"></div></template>',
60+
output: null,
61+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
62+
},
63+
// <a> without href isn't interactive.
64+
{
65+
code: '<template><a tabindex="0">Not a link</a></template>',
66+
output: null,
67+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
68+
},
69+
],
70+
});
71+
72+
const hbsRuleTester = new RuleTester({
73+
parser: require.resolve('ember-eslint-parser/hbs'),
74+
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
75+
});
76+
77+
hbsRuleTester.run('template-no-noninteractive-tabindex', rule, {
78+
valid: [
79+
'<div></div>',
80+
'<button tabindex="0">Click</button>',
81+
'<div role="button" tabindex="0"></div>',
82+
'<CustomWidget tabindex="0" />',
83+
],
84+
invalid: [
85+
{
86+
code: '<div tabindex="0"></div>',
87+
output: null,
88+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
89+
},
90+
{
91+
code: '<article tabindex="0"></article>',
92+
output: null,
93+
errors: [{ messageId: 'noNonInteractiveTabindex' }],
94+
},
95+
],
96+
});

0 commit comments

Comments
 (0)