Skip to content

Commit abd4ca4

Browse files
jessebeachbeefancohen
authored andcommitted
Add aria-activedescendant-has-tabindex rule (#135)
* Add aria-activedescendant-has-tabindex rule * Fix lint and test failures in aria-activedescendant-has-tabindex rule * Responding to review comments * Forgot to add a change to the previous commit.
1 parent c8a0148 commit abd4ca4

File tree

5 files changed

+223
-0
lines changed

5 files changed

+223
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ You can also enable all the recommended rules at once. Add `plugin:jsx-a11y/reco
8484

8585
- [accessible-emoji](docs/rules/accessible-emoji): Enforce emojis are wrapped in <span> and provide screenreader access.
8686
- [anchor-has-content](docs/rules/anchor-has-content.md): Enforce all anchors to contain accessible content.
87+
- [aria-activedescendant-has-tabindex](docs/rules/aria-activedescendant-has-tabindex.md): Enforce elements with aria-activedescendant are tabbable.
8788
- [aria-props](docs/rules/aria-props.md): Enforce all `aria-*` props are valid.
8889
- [aria-proptypes](docs/rules/aria-proptypes.md): Enforce ARIA state and property values are valid.
8990
- [aria-role](docs/rules/aria-role.md): Enforce that elements with ARIA roles must use a valid, non-abstract ARIA role.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @fileoverview Enforce elements with aria-activedescendant are tabbable.
3+
* @author Jesse Beach <@jessebeach>
4+
*/
5+
6+
// -----------------------------------------------------------------------------
7+
// Requirements
8+
// -----------------------------------------------------------------------------
9+
10+
import { RuleTester } from 'eslint';
11+
import rule from '../../../src/rules/aria-activedescendant-has-tabindex';
12+
13+
const parserOptions = {
14+
ecmaVersion: 6,
15+
ecmaFeatures: {
16+
jsx: true,
17+
},
18+
};
19+
20+
// -----------------------------------------------------------------------------
21+
// Tests
22+
// -----------------------------------------------------------------------------
23+
24+
const ruleTester = new RuleTester();
25+
26+
const expectedError = {
27+
message: 'An element that manages focus with `aria-activedescendant` ' +
28+
'must be tabbable',
29+
type: 'JSXOpeningElement',
30+
};
31+
32+
ruleTester.run('aria-activedescendant-has-tabindex', rule, {
33+
valid: [
34+
{
35+
code: '<CustomComponent />;',
36+
parserOptions,
37+
},
38+
{
39+
code: '<CustomComponent aria-activedescendant={someID} />;',
40+
parserOptions,
41+
},
42+
{
43+
code: '<CustomComponent aria-activedescendant={someID} tabIndex={0} />;',
44+
parserOptions,
45+
},
46+
{
47+
code: '<CustomComponent aria-activedescendant={someID} tabIndex={-1} />;',
48+
parserOptions,
49+
},
50+
{
51+
code: '<div />;',
52+
parserOptions,
53+
},
54+
{
55+
code: '<input />;',
56+
parserOptions,
57+
},
58+
{
59+
code: '<div tabIndex={0} />;',
60+
parserOptions,
61+
},
62+
{
63+
code: '<div aria-activedescendant={someID} tabIndex={0} />;',
64+
parserOptions,
65+
},
66+
{
67+
code: '<div aria-activedescendant={someID} tabIndex="0" />;',
68+
parserOptions,
69+
},
70+
{
71+
code: '<div aria-activedescendant={someID} tabIndex={1} />;',
72+
parserOptions,
73+
},
74+
{
75+
code: '<input aria-activedescendant={someID} />;',
76+
parserOptions,
77+
},
78+
{
79+
code: '<input aria-activedescendant={someID} tabIndex={0} />;',
80+
parserOptions,
81+
},
82+
],
83+
invalid: [
84+
{
85+
code: '<div aria-activedescendant={someID} />;',
86+
errors: [expectedError],
87+
parserOptions,
88+
},
89+
{
90+
code: '<div aria-activedescendant={someID} tabIndex={-1} />;',
91+
errors: [expectedError],
92+
parserOptions,
93+
},
94+
{
95+
code: '<div aria-activedescendant={someID} tabIndex="-1" />;',
96+
errors: [expectedError],
97+
parserOptions,
98+
},
99+
{
100+
code: '<input aria-activedescendant={someID} tabIndex={-1} />;',
101+
errors: [expectedError],
102+
parserOptions,
103+
},
104+
],
105+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# aria-activedescendant-has-tabindex
2+
3+
`aria-activedescendant` is used to manage focus within a [composite widget](https://www.w3.org/TR/wai-aria/roles#composite_header).
4+
The element with the attribute `aria-activedescendant` retains the active document
5+
focus; it indicates which of its child elements has secondary focus by assigning
6+
the ID of that element to the value of `aria-activedescendant`. This pattern is
7+
used to build a widget like a search typeahead select list. The search input box
8+
retains document focus so that the user can type in the input. If the down arrow
9+
key is pressed and a search suggestion is highlighted, the ID of the suggestion
10+
element will be applied as the value of `aria-activedescendant` on the input
11+
element.
12+
13+
Because an element with `aria-activedescendant` must be tabbable, it must either
14+
have an inherent `tabIndex` of zero or declare a `tabIndex` of zero with the `tabIndex`
15+
attribute.
16+
17+
#### References
18+
1. [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_aria-activedescendant_attribute)
19+
20+
## Rule details
21+
22+
This rule takes no arguments.
23+
24+
### Succeed
25+
```jsx
26+
<CustomComponent />
27+
<CustomComponent aria-activedescendant={someID} />
28+
<CustomComponent aria-activedescendant={someID} tabIndex={0} />
29+
<CustomComponent aria-activedescendant={someID} tabIndex={-1} />
30+
<div />
31+
<input />
32+
<div tabIndex={0} />
33+
<div aria-activedescendant={someID} tabIndex={0} />
34+
<div aria-activedescendant={someID} tabIndex="0" />
35+
<div aria-activedescendant={someID} tabIndex={1} />
36+
<input aria-activedescendant={someID} />
37+
<input aria-activedescendant={someID} tabIndex={0} />
38+
```
39+
40+
### Fail
41+
```jsx
42+
<div aria-activedescendant={someID} />
43+
<div aria-activedescendant={someID} tabIndex={-1} />
44+
<div aria-activedescendant={someID} tabIndex="-1" />
45+
<input aria-activedescendant={someID} tabIndex={-1} />
46+
```

src/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ module.exports = {
44
rules: {
55
'accessible-emoji': require('./rules/accessible-emoji'),
66
'anchor-has-content': require('./rules/anchor-has-content'),
7+
'aria-activedescendant-has-tabindex': require('./rules/aria-activedescendant-has-tabindex'),
78
'aria-props': require('./rules/aria-props'),
89
'aria-proptypes': require('./rules/aria-proptypes'),
910
'aria-role': require('./rules/aria-role'),
@@ -39,6 +40,7 @@ module.exports = {
3940
rules: {
4041
'jsx-a11y/accessible-emoji': 'error',
4142
'jsx-a11y/anchor-has-content': 'error',
43+
'jsx-a11y/aria-activedescendant-has-tabindex': 'error',
4244
'jsx-a11y/aria-props': 'error',
4345
'jsx-a11y/aria-proptypes': 'error',
4446
'jsx-a11y/aria-role': 'error',
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* @fileoverview Enforce elements with aria-activedescendant are tabbable.
3+
* @author Jesse Beach <@jessebeach>
4+
*/
5+
6+
import { getProp, elementType } from 'jsx-ast-utils';
7+
import { generateObjSchema } from '../util/schemas';
8+
import DOMElements from '../util/attributes/DOM.json';
9+
import getTabIndex from '../util/getTabIndex';
10+
import isInteractiveElement from '../util/isInteractiveElement';
11+
12+
// ----------------------------------------------------------------------------
13+
// Rule Definition
14+
// ----------------------------------------------------------------------------
15+
16+
const errorMessage =
17+
'An element that manages focus with `aria-activedescendant` must be tabbable';
18+
19+
const schema = generateObjSchema();
20+
21+
const DOMElementKeys = Object.keys(DOMElements);
22+
23+
module.exports = {
24+
meta: {
25+
docs: {},
26+
schema: [schema],
27+
},
28+
29+
create: context => ({
30+
JSXOpeningElement: (node) => {
31+
const { attributes } = node;
32+
33+
if (getProp(attributes, 'aria-activedescendant') === undefined) {
34+
return;
35+
}
36+
37+
const type = elementType(node);
38+
// Do not test higher level JSX components, as we do not know what
39+
// low-level DOM element this maps to.
40+
if (DOMElementKeys.indexOf(type) === -1) {
41+
return;
42+
}
43+
const tabIndex = getTabIndex(getProp(attributes, 'tabIndex'));
44+
45+
// If this is an interactive element, tabIndex must be either left
46+
// unspecified allowing the inherent tabIndex to obtain or it must be
47+
// zero (allowing for positive, even though that is not ideal). It cannot
48+
// be given a negative value.
49+
if (
50+
isInteractiveElement(type, attributes)
51+
&& (
52+
tabIndex === undefined
53+
|| tabIndex >= 0
54+
)
55+
) {
56+
return;
57+
}
58+
59+
if (tabIndex >= 0) {
60+
return;
61+
}
62+
63+
context.report({
64+
node,
65+
message: errorMessage,
66+
});
67+
},
68+
}),
69+
};

0 commit comments

Comments
 (0)