Skip to content

Commit 7d03e62

Browse files
committed
feat: allow tabindex="-1" on non-interactive elements
tabindex="-1" marks an element as programmatically focusable but skipped by the Tab key — the canonical pattern for scroll-to-focus targets, focus restoration, and composite-widget children with aria-activedescendant. Matches jsx-a11y's same exemption and aligns with template-require-aria-activedescendant-tabindex, which already accepts both 0 and -1.
1 parent 9dfa4c3 commit 7d03e62

3 files changed

Lines changed: 44 additions & 6 deletions

File tree

docs/rules/template-no-noninteractive-tabindex.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@ Adding `tabindex="0"` to a `<div>`, `<section>`, etc. puts it in the keyboard ta
88

99
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.
1010

11+
`tabindex="-1"` is exempt — it marks an element as programmatically focusable but skipped by the Tab key, the canonical pattern for scroll-to-focus targets, focus restoration, and composite-widget children. See [`template-require-aria-activedescendant-tabindex`](./template-require-aria-activedescendant-tabindex.md).
12+
1113
## Examples
1214

1315
This rule **forbids** the following:
1416

1517
```gjs
1618
<template>
1719
<div tabindex="0"></div>
18-
<span tabindex="-1">text</span>
1920
<article tabindex="0">Story</article>
2021
<div role="article" tabindex="0"></div>
2122
<a tabindex="0">Not a link (missing href)</a>
@@ -35,6 +36,10 @@ This rule **allows** the following:
3536
<div role="button" tabindex="0"></div>
3637
<div role="checkbox" tabindex="0" aria-checked="false"></div>
3738
39+
{{! tabindex="-1" — focusable but not in tab order }}
40+
<div tabindex="-1"></div>
41+
<section tabindex="-1">scroll target</section>
42+
3843
{{! Dynamic role — conservatively skipped }}
3944
<div role={{this.role}} tabindex="0"></div>
4045
</template>

lib/rules/template-no-noninteractive-tabindex.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,27 @@ function findAttr(node, name) {
4747
return node.attributes?.find((a) => a.name === name);
4848
}
4949

50+
function getStaticTabindexValue(attr) {
51+
const value = attr?.value;
52+
if (!value) {
53+
return undefined;
54+
}
55+
if (value.type === 'GlimmerTextNode') {
56+
const parsed = Number.parseInt(value.chars, 10);
57+
return Number.isFinite(parsed) ? parsed : undefined;
58+
}
59+
if (value.type === 'GlimmerMustacheStatement' && value.path) {
60+
if (value.path.type === 'GlimmerNumberLiteral') {
61+
return value.path.value;
62+
}
63+
if (value.path.type === 'GlimmerStringLiteral') {
64+
const parsed = Number.parseInt(value.path.value, 10);
65+
return Number.isFinite(parsed) ? parsed : undefined;
66+
}
67+
}
68+
return undefined;
69+
}
70+
5071
function getTextAttrValue(attr) {
5172
if (attr?.value?.type === 'GlimmerTextNode') {
5273
return attr.value.chars;
@@ -131,6 +152,14 @@ module.exports = {
131152
return;
132153
}
133154

155+
// `tabindex="-1"` is the canonical "focusable but not in tab order"
156+
// pattern (scroll-to-focus targets, focus restoration, composite-widget
157+
// children). Matches jsx-a11y's same exemption and is consistent with
158+
// `template-require-aria-activedescendant-tabindex`.
159+
if (getStaticTabindexValue(tabindex) === -1) {
160+
return;
161+
}
162+
134163
context.report({
135164
node: tabindex,
136165
messageId: 'noNonInteractiveTabindex',

tests/lib/rules/template-no-noninteractive-tabindex.js

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,22 @@ ruleTester.run('template-no-noninteractive-tabindex', rule, {
3232

3333
// Dynamic role — rule conservatively skips.
3434
'<template><div role={{this.role}} tabindex="0"></div></template>',
35+
36+
// tabindex="-1" is the canonical "focusable but not in tab order" pattern
37+
// (scroll-to-focus targets, focus restoration, composite-widget children).
38+
// Matches jsx-a11y's exemption and is consistent with
39+
// template-require-aria-activedescendant-tabindex.
40+
'<template><div tabindex="-1"></div></template>',
41+
'<template><span tabindex="-1">text</span></template>',
42+
'<template><section tabindex="-1">scroll target</section></template>',
43+
'<template><div tabindex={{-1}}></div></template>',
3544
],
3645
invalid: [
3746
{
3847
code: '<template><div tabindex="0"></div></template>',
3948
output: null,
4049
errors: [{ messageId: 'noNonInteractiveTabindex' }],
4150
},
42-
{
43-
code: '<template><span tabindex="-1">text</span></template>',
44-
output: null,
45-
errors: [{ messageId: 'noNonInteractiveTabindex' }],
46-
},
4751
{
4852
code: '<template><article tabindex="0">Story</article></template>',
4953
output: null,

0 commit comments

Comments
 (0)