Skip to content

Commit e05562e

Browse files
author
Ethan Cohen
committed
[new] - Implement role-requires-aria rule.
Fixes #12 Enforce that elements with ARIA roles have all required attributes for that role.
1 parent d6b234d commit e05562e

File tree

6 files changed

+188
-7
lines changed

6 files changed

+188
-7
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Then configure the rules you want to use under the rules section.
7777
- [no-invalid-aria](docs/rules/no-invalid-aria.md): Enforce all aria-* properties are valid.
7878
- [valid-aria-role](docs/rules/valid-aria-role.md): Enforce that elements with ARIA roles must use a valid, non-abstract ARIA role.
7979
- [valid-aria-proptype](docs/rules/valid-aria-proptype.md): Enforce ARIA state and property values are valid.
80+
- [role-requires-aria](docs/rules/role-requires-aria.md): Enforce that elements with ARIA roles must have all required attributes for that role.
8081

8182
## Contributing
8283
Feel free to contribute! I am currently using [Google Chrome's Audit Rules](https://github.com/GoogleChrome/accessibility-developer-tools/wiki/Audit-Rules) to map out as rules for this plugin.

docs/rules/role-requires-aria.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# role-requires-aria
2+
3+
Elements with ARIA roles must have all required attributes for that role.
4+
5+
#### References
6+
1. [Spec](https://www.w3.org/TR/wai-aria/roles)
7+
2. [AX_ARIA_03](https://github.com/GoogleChrome/accessibility-developer-tools/wiki/Audit-Rules#ax_aria_03)
8+
9+
## Rule details
10+
11+
This rule takes no arguments.
12+
13+
### Succeed
14+
```jsx
15+
<!-- Good: the checkbox role requires the aria-checked state -->
16+
<span role="checkbox" aria-checked="false" aria-labelledby="foo" tabindex="0"></span>
17+
```
18+
19+
### Fail
20+
21+
```jsx
22+
<!-- Bad: the checkbox role requires the aria-checked state -->
23+
<span role="checkbox" aria-labelledby="foo" tabindex="0"></span>
24+
```

src/index.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ module.exports = {
1212
'no-hash-href': require('./rules/no-hash-href'),
1313
'valid-aria-role': require('./rules/valid-aria-role'),
1414
'valid-aria-proptypes': require('./rules/valid-aria-proptypes'),
15-
'no-invalid-aria': require('./rules/no-invalid-aria')
15+
'no-invalid-aria': require('./rules/no-invalid-aria'),
16+
'role-requires-aria': require('./rules/role-requires-aria')
1617
},
1718
configs: {
1819
recommended: {
@@ -32,7 +33,8 @@ module.exports = {
3233
"jsx-a11y/no-hash-href": 2,
3334
"jsx-a11y/valid-aria-role": 2,
3435
"jsx-a11y/valid-aria-proptypes": 2,
35-
"jsx-a11y/no-invalid-aria": 2
36+
"jsx-a11y/no-invalid-aria": 2,
37+
"jsx-a11y/role-requires-aria": 2
3638
}
3739
}
3840
}

src/rules/role-requires-aria.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @fileoverview Enforce that elements with ARIA roles must have all required attributes for that role.
3+
* @author Ethan Cohen
4+
*/
5+
'use strict';
6+
7+
// ----------------------------------------------------------------------------
8+
// Rule Definition
9+
// ----------------------------------------------------------------------------
10+
11+
import validRoleTypes from '../util/attributes/role';
12+
import { getLiteralAttributeValue } from '../util/getAttributeValue';
13+
import hasAttribute from '../util/hasAttribute';
14+
15+
const errorMessage = (role, requiredProps) =>
16+
`Elements with the ARIA role "${role}" must have the following ` +
17+
`attributes defined: ${requiredProps.toString().toLowerCase()}`;
18+
19+
module.exports = context => ({
20+
JSXAttribute: attribute => {
21+
const normalizedName = attribute.name.name.toUpperCase();
22+
if (normalizedName !== 'ROLE') {
23+
return;
24+
}
25+
26+
const value = getLiteralAttributeValue(attribute);
27+
28+
// If value is undefined, then the role attribute will be dropped in the DOM.
29+
// If value is null, then getLiteralAttributeValue is telling us that the value isn't in the form of a literal.
30+
if (value === undefined || value === null) {
31+
return;
32+
}
33+
34+
const normalizedValues = `${value}`.toUpperCase().split(" ");
35+
const validRoles = normalizedValues.filter(value => Object.keys(validRoleTypes).indexOf(value) > -1);
36+
37+
validRoles.forEach(role => {
38+
const { requiredProps } = validRoleTypes[role];
39+
40+
if (requiredProps.length > 0) {
41+
const hasRequiredProps = requiredProps.every(prop => hasAttribute(attribute.parent.attributes, prop));
42+
43+
if (hasRequiredProps === false) {
44+
context.report({
45+
node: attribute,
46+
message: errorMessage(role.toLowerCase(), requiredProps)
47+
});
48+
}
49+
}
50+
});
51+
52+
}
53+
});
54+
55+
module.exports.schema = [
56+
{ type: 'object' }
57+
];

src/util/attributes/role.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
"requiredProps": []
1919
},
2020
"CHECKBOX": {
21-
"requiredProps": []
21+
"requiredProps": [ "ARIA-CHECKED" ]
2222
},
2323
"COLUMNHEADER": {
2424
"requiredProps": []
2525
},
2626
"COMBOBOX": {
27-
"requiredProps": []
27+
"requiredProps": [ "ARIA-EXPANDED" ]
2828
},
2929
"COMPLEMENTARY": {
3030
"requiredProps": []
@@ -135,7 +135,7 @@
135135
"requiredProps": []
136136
},
137137
"SCROLLBAR": {
138-
"requiredProps": []
138+
"requiredProps": [ "ARIA-CONTROLS", "ARIA-ORIENTATION", "ARIA-VALUEMAX", "ARIA-VALUEMIN", "ARIA-VALUENOW" ]
139139
},
140140
"SEARCH": {
141141
"requiredProps": []
@@ -144,10 +144,10 @@
144144
"requiredProps": []
145145
},
146146
"SLIDER": {
147-
"requiredProps": []
147+
"requiredProps": [ "ARIA-VALUEMAX", "ARIA-VALUEMIN", "ARIA-VALUENOW" ]
148148
},
149149
"SPINBUTTON": {
150-
"requiredProps": []
150+
"requiredProps": [ "ARIA-VALUEMAX", "ARIA-VALUEMIN", "ARIA-VALUENOW" ]
151151
},
152152
"STATUS": {
153153
"requiredProps": []

tests/src/rules/role-requires-aria.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* @fileoverview Enforce that elements with ARIA roles must have all required attributes for that role.
3+
* @author Ethan Cohen
4+
*/
5+
6+
'use strict';
7+
8+
// -----------------------------------------------------------------------------
9+
// Requirements
10+
// -----------------------------------------------------------------------------
11+
12+
import rule from '../../../src/rules/role-requires-aria';
13+
import { RuleTester } from 'eslint';
14+
15+
const parserOptions = {
16+
ecmaVersion: 6,
17+
ecmaFeatures: {
18+
jsx: true
19+
}
20+
};
21+
22+
// -----------------------------------------------------------------------------
23+
// Tests
24+
// -----------------------------------------------------------------------------
25+
26+
const ruleTester = new RuleTester();
27+
28+
import validRoleTypes from '../../../src/util/attributes/role';
29+
30+
const errorMessage = role => ({
31+
message: `Elements with the ARIA role "${role}" must have the following ` +
32+
`attributes defined: ${validRoleTypes[role.toUpperCase()].requiredProps.toString().toLowerCase()}`,
33+
type: 'JSXAttribute'
34+
});
35+
36+
37+
// Create basic test cases using all valid role types.
38+
const basicValidityTests = Object.keys(validRoleTypes).map(role => {
39+
const { requiredProps } = validRoleTypes[role];
40+
const propChain = requiredProps.join(' ').toLowerCase();
41+
42+
return {
43+
code: `<div role="${role.toLowerCase()}" ${propChain} />`,
44+
parserOptions
45+
};
46+
});
47+
48+
ruleTester.run('role-requires-aria', rule, {
49+
valid: [
50+
// Variables should pass, as we are only testing literals.
51+
{ code: '<div />', parserOptions },
52+
{ code: '<div></div>', parserOptions },
53+
{ code: '<div role={role} />', parserOptions },
54+
{ code: '<div role={role || "button"} />', parserOptions },
55+
{ code: '<div role={role || "foobar"} />', parserOptions },
56+
{ code: '<div role="tabpanel row" />', parserOptions },
57+
{ code: '<span role="checkbox" aria-checked="false" aria-labelledby="foo" tabindex="0"></span>', parserOptions },
58+
{ code: '<Bar baz />', parserOptions }
59+
].concat(basicValidityTests),
60+
invalid: [
61+
// SLIDER
62+
{ code: '<div role="slider" />', errors: [ errorMessage('slider') ], parserOptions },
63+
{ code: '<div role="slider" aria-valuemax />', errors: [ errorMessage('slider') ], parserOptions },
64+
{ code: '<div role="slider" aria-valuemax aria-valuemin />', errors: [ errorMessage('slider') ], parserOptions },
65+
{ code: '<div role="slider" aria-valuemax aria-valuenow />', errors: [ errorMessage('slider') ], parserOptions },
66+
{ code: '<div role="slider" aria-valuemin aria-valuenow />', errors: [ errorMessage('slider') ], parserOptions },
67+
68+
// SPINBUTTON
69+
{ code: '<div role="spinbutton" />', errors: [ errorMessage('spinbutton') ], parserOptions },
70+
{ code: '<div role="spinbutton" aria-valuemax />', errors: [ errorMessage('spinbutton') ], parserOptions },
71+
{ code: '<div role="spinbutton" aria-valuemax aria-valuemin />', errors: [ errorMessage('spinbutton') ], parserOptions },
72+
{ code: '<div role="spinbutton" aria-valuemax aria-valuenow />', errors: [ errorMessage('spinbutton') ], parserOptions },
73+
{ code: '<div role="spinbutton" aria-valuemin aria-valuenow />', errors: [ errorMessage('spinbutton') ], parserOptions },
74+
75+
// CHECKBOX
76+
{ code: '<div role="checkbox" />', errors: [ errorMessage('checkbox') ], parserOptions },
77+
{ code: '<div role="checkbox" checked />', errors: [ errorMessage('checkbox') ], parserOptions },
78+
{ code: '<div role="checkbox" aria-chcked />', errors: [ errorMessage('checkbox') ], parserOptions },
79+
{
80+
code: '<span role="checkbox" aria-labelledby="foo" tabindex="0"></span>',
81+
errors: [ errorMessage('checkbox') ],
82+
parserOptions
83+
},
84+
85+
// COMBOBOX
86+
{ code: '<div role="combobox" />', errors: [ errorMessage('combobox') ], parserOptions },
87+
{ code: '<div role="combobox" expanded />', errors: [ errorMessage('combobox') ], parserOptions },
88+
{ code: '<div role="combobox" aria-expandd />', errors: [ errorMessage('combobox') ], parserOptions },
89+
90+
// SCROLLBAR
91+
{ code: '<div role="scrollbar" />', errors: [ errorMessage('scrollbar') ], parserOptions },
92+
{ code: '<div role="scrollbar" aria-valuemax />', errors: [ errorMessage('scrollbar') ], parserOptions },
93+
{ code: '<div role="scrollbar" aria-valuemax aria-valuemin />', errors: [ errorMessage('scrollbar') ], parserOptions },
94+
{ code: '<div role="scrollbar" aria-valuemax aria-valuenow />', errors: [ errorMessage('scrollbar') ], parserOptions },
95+
{ code: '<div role="scrollbar" aria-valuemin aria-valuenow />', errors: [ errorMessage('scrollbar') ], parserOptions }
96+
]
97+
});

0 commit comments

Comments
 (0)