Skip to content

Commit a36d8b0

Browse files
authored
Merge pull request #210 from jessebeach/no-noninteractive-tabindex
No noninteractive tabindex
2 parents 7ca7805 + 1b063ce commit a36d8b0

File tree

6 files changed

+268
-2
lines changed

6 files changed

+268
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ You can also enable all the recommended rules at once. Add `plugin:jsx-a11y/reco
116116
- [no-interactive-element-to-noninteractive-role](docs/rules/no-interactive-element-to-noninteractive-role.md): Interactive elements should not be assigned non-interactive roles.
117117
- [no-noninteractive-element-interactions](docs/rules/no-noninteractive-element-interactions.md): Non-interactive elements should not be assigned mouse or keyboard event listeners.
118118
- [no-noninteractive-element-to-interactive-role](docs/rules/no-noninteractive-element-to-interactive-role.md): Non-interactive elements should not be assigned interactive roles.
119+
- [no-noninteractive-tabindex](docs/rules/no-noninteractive-tabindex.md): `tabIndex` should only be declared on interactive elements.
119120
- [no-onchange](docs/rules/no-onchange.md): Enforce usage of `onBlur` over `onChange` on select menus for accessibility.
120121
- [no-redundant-roles](docs/rules/no-redundant-roles.md): Enforce explicit role property is not the same as implicit/default role property on element.
121122
- [no-static-element-interactions](docs/rules/no-static-element-interactions.md): Enforce that non-interactive, visible elements (such as `<div>`) that have click handlers use the role attribute.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/* eslint-env jest */
2+
/**
3+
* @fileoverview Disallow tabindex on static and noninteractive elements
4+
* @author jessebeach
5+
*/
6+
7+
// -----------------------------------------------------------------------------
8+
// Requirements
9+
// -----------------------------------------------------------------------------
10+
11+
import { RuleTester } from 'eslint';
12+
import { configs } from '../../../src/index';
13+
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
14+
import rule from '../../../src/rules/no-noninteractive-tabindex';
15+
import ruleOptionsMapperFactory from '../../__util__/ruleOptionsMapperFactory';
16+
17+
// -----------------------------------------------------------------------------
18+
// Tests
19+
// -----------------------------------------------------------------------------
20+
21+
const ruleTester = new RuleTester();
22+
23+
const ruleName = 'no-noninteractive-tabindex';
24+
25+
const expectedError = {
26+
message: '`tabIndex` should only be declared on interactive elements.',
27+
type: 'JSXAttribute',
28+
};
29+
30+
const alwaysValid = [
31+
{ code: '<MyButton tabIndex={0} />' },
32+
{ code: '<button />' },
33+
{ code: '<button tabIndex="0" />' },
34+
{ code: '<button tabIndex={0} />' },
35+
{ code: '<div />' },
36+
{ code: '<div tabIndex="-1" />' },
37+
{ code: '<div role="button" tabIndex="0" />' },
38+
{ code: '<div role="article" tabIndex="-1" />' },
39+
{ code: '<article tabIndex="-1" />' },
40+
];
41+
42+
const neverValid = [
43+
{ code: '<div tabIndex="0" />', errors: [expectedError] },
44+
{ code: '<div role="article" tabIndex="0" />', errors: [expectedError] },
45+
{ code: '<article tabIndex="0" />', errors: [expectedError] },
46+
{ code: '<article tabIndex={0} />', errors: [expectedError] },
47+
];
48+
49+
const recommendedOptions = (
50+
configs.recommended.rules[`jsx-a11y/${ruleName}`][1] || {}
51+
);
52+
53+
ruleTester.run(`${ruleName}:recommended`, rule, {
54+
valid: [
55+
...alwaysValid,
56+
{ code: '<div role="tabpanel" tabIndex="0" />' },
57+
]
58+
.map(ruleOptionsMapperFactory(recommendedOptions))
59+
.map(parserOptionsMapper),
60+
invalid: [
61+
...neverValid,
62+
]
63+
.map(ruleOptionsMapperFactory(recommendedOptions))
64+
.map(parserOptionsMapper),
65+
});
66+
67+
ruleTester.run(`${ruleName}:strict`, rule, {
68+
valid: [
69+
...alwaysValid,
70+
].map(parserOptionsMapper),
71+
invalid: [
72+
...neverValid,
73+
{ code: '<div role="tabpanel" tabIndex="0" />', errors: [expectedError] },
74+
].map(parserOptionsMapper),
75+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# no-noninteractive-tabindex
2+
3+
Tab key navigation should be limited to elements on the page that can be interacted with. Thus it is not necessary to add a tabindex to items in an unordered list, for example, to make them navigable through assistive technology. These applications already afford page traversal mechanisms based on the HTML of the page. Generally, we should try to reduce the size of the page's tab ring rather than increasing it.
4+
5+
## How do I resolve this error?
6+
7+
### Case: I am using an `<a>` tag. Isn't that interactive?
8+
9+
The `<a>` tag is tricky. Consider the following:
10+
11+
```
12+
<a>Edit</a>
13+
<a href="#">Edit</a>
14+
<a role="button">Edit</a>
15+
```
16+
17+
The bare `<a>` tag is an _anchor_. It has no semantic AX API mapping in either ARIA or the AXObject model. It's as meaningful as `<div>`, which is to say it has no meaning. An `<a>` tag with an `href` attribute has an inherent role of `link`. An `<a>` tag with an explicit role obtains the designated role. In the example above, this role is `button`.
18+
19+
### Case: I am using "semantic" HTML. Isn't that interactive?
20+
21+
If we take a step back into the field of linguistics for a moment, let's consider what it means for something to be "semantic". Nothing, in and of itself, has meaning. Meaning is constructed through dialogue. A speaker intends a meaning and a listener/observer interprets a meaning. Each participant constructs their own meaning through dialogue. There is no intrinsic or isolated meaning outside of interaction. Thus, we must ask, given that we have a "speaker" who communicates via "semantic" HTML, who is listening/observing?
22+
23+
In our case, the observer is the Accessibility (AX) API. Browsers interpret HTML (inflected at times by ARIA) to construct a meaning (AX Tree) of the page. Whatever the semantic HTML intends has only the force of suggestion to the AX API. Therefore, we have inconsistencies. For example, there is not yet an ARIA role for `text` or `label` and thus no way to change a `<label>` into plain text or a `<span>` into a label via ARIA. '<div>' has an AXObject correpondant `DivRole`, but no such object maps to `<span>`.
24+
25+
What this lint rule endeavors to do is apply the AX API understanding of the semantics of an HTML document back onto your code. The concept of interactivity boils down to whether a user can do something with the indicated or focused component.
26+
27+
Common interactive roles include:
28+
29+
1. `button`
30+
1. `link`
31+
1. `checkbox`
32+
1. `menuitem`
33+
1. `menuitemcheckbox`
34+
1. `menuitemradio`
35+
1. `option`
36+
1. `radio`
37+
1. `searchbox`
38+
1. `switch`
39+
1. `textbox`
40+
41+
Endeavor to limit tabbable elements to those that a user can act upon.
42+
43+
### Case: Shouldn't I add a tabindex so that users can navigate to this item?
44+
45+
It is not necessary to put a tabindex on an `<article>`, for instance or on `<li>` items; assistive technologies provide affordances to users to find and traverse these containers. Most elements that require a tabindex -- `<a href>`, `<button>`, `<input>`, `<textarea>` -- have it already.
46+
47+
Your application might require an exception to this rule in the case of an element that captures incoming tab traversal for a composite widget. In that case, turn off this rule on a per instance basis. This is an uncommon case.
48+
49+
### References
50+
51+
1. [Fundamental Keyboard Navigation Conventions](https://www.w3.org/TR/wai-aria-practices-1.1/#kbd_generalnav)
52+
53+
## Rule details
54+
55+
The recommended options for this rule allow `tabIndex` on elements with the noninteractive `tabpanel` role. Adding `tabIndex` to a tabpanel is a recommended practice in some instances.
56+
57+
```javascript
58+
'jsx-a11y/no-noninteractive-tabindex': [
59+
'error',
60+
{
61+
tags: [],
62+
roles: ['tabpanel'],
63+
},
64+
]
65+
```
66+
67+
### Succeed
68+
```jsx
69+
<div />
70+
<MyButton tabIndex={0} />
71+
<button />
72+
<button tabIndex="0" />
73+
<button tabIndex={0} />
74+
<div />
75+
<div tabIndex="-1" />
76+
<div role="button" tabIndex="0" />
77+
<div role="article" tabIndex="-1" />
78+
<article tabIndex="-1" />
79+
```
80+
81+
### Fail
82+
```jsx
83+
<div tabIndex="0" />
84+
<div role="article" tabIndex="0" />
85+
<article tabIndex="0" />
86+
<article tabIndex={0} />
87+
```

src/index.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ module.exports = {
2727
'no-interactive-element-to-noninteractive-role': require('./rules/no-interactive-element-to-noninteractive-role'),
2828
'no-noninteractive-element-interactions': require('./rules/no-noninteractive-element-interactions'),
2929
'no-noninteractive-element-to-interactive-role': require('./rules/no-noninteractive-element-to-interactive-role'),
30+
'no-noninteractive-tabindex': require('./rules/no-noninteractive-tabindex'),
3031
'no-onchange': require('./rules/no-onchange'),
3132
'no-redundant-roles': require('./rules/no-redundant-roles'),
3233
'no-static-element-interactions': require('./rules/no-static-element-interactions'),
@@ -100,7 +101,13 @@ module.exports = {
100101
td: ['gridcell'],
101102
},
102103
],
103-
104+
'jsx-a11y/no-noninteractive-tabindex': [
105+
'error',
106+
{
107+
tags: [],
108+
roles: ['tabpanel'],
109+
},
110+
],
104111
'jsx-a11y/no-onchange': 'error',
105112
'jsx-a11y/no-redundant-roles': 'error',
106113
'jsx-a11y/no-static-element-interactions': 'warn',
@@ -141,6 +148,7 @@ module.exports = {
141148
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'error',
142149
'jsx-a11y/no-noninteractive-element-interactions': 'error',
143150
'jsx-a11y/no-noninteractive-element-to-interactive-role': 'error',
151+
'jsx-a11y/no-noninteractive-tabindex': 'error',
144152
'jsx-a11y/no-onchange': 'error',
145153
'jsx-a11y/no-redundant-roles': 'error',
146154
'jsx-a11y/no-static-element-interactions': 'warn',
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* @fileoverview Disallow tabindex on static and noninteractive elements
3+
* @author jessebeach
4+
* @flow
5+
*/
6+
7+
// ----------------------------------------------------------------------------
8+
// Rule Definition
9+
// ----------------------------------------------------------------------------
10+
11+
import {
12+
dom,
13+
} from 'aria-query';
14+
import {
15+
elementType,
16+
getProp,
17+
getLiteralPropValue,
18+
} from 'jsx-ast-utils';
19+
import isInteractiveElement from '../util/isInteractiveElement';
20+
import isInteractiveRole from '../util/isInteractiveRole';
21+
import { generateObjSchema, arraySchema } from '../util/schemas';
22+
import getTabIndex from '../util/getTabIndex';
23+
24+
const errorMessage =
25+
'`tabIndex` should only be declared on interactive elements.';
26+
27+
const schema = generateObjSchema({
28+
roles: {
29+
...arraySchema,
30+
description: 'An array of ARIA roles',
31+
},
32+
tags: {
33+
...arraySchema,
34+
description: 'An array of HTML tag names',
35+
},
36+
});
37+
38+
module.exports = {
39+
meta: {
40+
docs: {},
41+
schema: [schema],
42+
},
43+
44+
create: (context: ESLintContext) => {
45+
const options = context.options;
46+
return {
47+
JSXOpeningElement: (
48+
node: JSXOpeningElement,
49+
) => {
50+
const type = elementType(node);
51+
const attributes = node.attributes;
52+
const tabIndexProp = getProp(attributes, 'tabIndex');
53+
const tabIndex = getTabIndex(tabIndexProp);
54+
// Early return;
55+
if (typeof tabIndex === 'undefined') {
56+
return;
57+
}
58+
const role = getLiteralPropValue(
59+
getProp(node.attributes, 'role'),
60+
);
61+
62+
63+
if (!dom.has(type)) {
64+
// Do not test higher level JSX components, as we do not know what
65+
// low-level DOM element this maps to.
66+
return;
67+
}
68+
// Allow for configuration overrides.
69+
const {
70+
tags,
71+
roles,
72+
} = (options[0] || {});
73+
if (
74+
(tags && tags.includes(type))
75+
|| (roles && roles.includes(role))
76+
) {
77+
return;
78+
}
79+
if (
80+
isInteractiveElement(type, attributes)
81+
|| isInteractiveRole(type, attributes)
82+
) {
83+
return;
84+
}
85+
if (
86+
tabIndex >= 0
87+
) {
88+
context.report({
89+
node: tabIndexProp,
90+
message: errorMessage,
91+
});
92+
}
93+
},
94+
};
95+
},
96+
};

src/util/schemas.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export const arraySchema = {
66
items: {
77
type: 'string',
88
},
9-
minItems: 1,
109
uniqueItems: true,
1110
additionalItems: false,
1211
};

0 commit comments

Comments
 (0)