Skip to content

Commit 122789e

Browse files
author
Ethan Cohen
committed
More complete implementation of onClick uses role attribute rule.
1 parent 2beaf83 commit 122789e

File tree

8 files changed

+172
-15
lines changed

8 files changed

+172
-15
lines changed

.eslintrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@
149149
"wrap-regex": 0,
150150
// Legacy
151151
"max-depth": 0,
152-
"max-len": [2, 120],
152+
"max-len": [2, 125],
153153
"max-params": 0,
154154
"max-statements": 0,
155155
"no-plusplus": 0

index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
module.exports = {
44
rules: {
5-
'img-uses-alt': require('./lib/rules/img-uses-alt')
5+
'img-uses-alt': require('./lib/rules/img-uses-alt'),
6+
'onClick-uses-role': require('./lib/rules/onClick-uses-role')
67
},
78
configs: {
89
recommended: {

lib/hasAttribute.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use strict';
2+
3+
module.exports = function hasAttribute(attributes, attribute, strictSpread) {
4+
var strict = strictSpread || false;
5+
var idx = 0;
6+
7+
var hasAttr = attributes.some(function(attr, index) {
8+
// If the attributes contain a spread attribute, then follow strictSpread.
9+
if (attr.type === 'JSXSpreadAttribute') {
10+
return !strict;
11+
}
12+
if (attr.name.name.toUpperCase() === attribute.toUpperCase()) {
13+
idx = index; // Keep track of the index.
14+
return true;
15+
}
16+
return false;
17+
});
18+
19+
return hasAttr ? attributes[idx] : false;
20+
};

lib/isHiddenFromScreenReader.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'use strict';
2+
3+
module.exports = function isHiddenFromScreenReader(attributes) {
4+
return attributes.some(function(attribute) {
5+
var name = attribute.name.name.toUpperCase();
6+
var value = attribute.value && attribute.value.value;
7+
8+
return name === 'ARIA-HIDDEN' && (value === true || value === null);
9+
});
10+
};

lib/isInteractiveElement.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use strict';
2+
3+
var hasAttribute = require('./hasAttribute');
4+
5+
var interactiveMap = {
6+
a: function(attributes) {
7+
var hasHref = hasAttribute(attributes, 'href');
8+
var hasTabIndex = hasAttribute(attributes, 'tabIndex');
9+
return (Boolean(hasHref) || !hasHref && Boolean(hasTabIndex));
10+
},
11+
button: function() {
12+
return true;
13+
},
14+
input: function(attributes) {
15+
var hasTypeAttr = hasAttribute(attributes, 'type');
16+
return hasTypeAttr ? hasTypeAttr.value.value.toUpperCase() !== 'HIDDEN' : true;
17+
},
18+
option: function() {
19+
return true;
20+
},
21+
select: function() {
22+
return true;
23+
},
24+
textarea: function() {
25+
return true;
26+
}
27+
};
28+
29+
module.exports = function isInteractiveElement(tagName, attributes) {
30+
if (interactiveMap.hasOwnProperty(tagName) === false) {
31+
return false;
32+
}
33+
34+
return interactiveMap[tagName](attributes);
35+
};

lib/rules/img-uses-alt.js

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,13 @@
88
// Rule Definition
99
// ------------------------------------------------------------------------------
1010

11-
// function containsAlt(attribute) {
12-
13-
// };
11+
var hasAttribute = require('../hasAttribute');
1412

1513
module.exports = function(context) {
1614

17-
// var configuration = context.options[0] || {};
18-
1915
return {
2016
JSXOpeningElement: function(node) {
21-
var hasAltProp = node.attributes.some(function(attribute) {
22-
// If the attributes contain a spread attribute, then pass rule.
23-
if (attribute.type === 'JSXSpreadAttribute') {
24-
return true;
25-
}
26-
27-
return attribute.name.name.toUpperCase() === 'ALT';
28-
});
17+
var hasAltProp = hasAttribute(node.attributes, 'alt');
2918

3019
if (hasAltProp === false) {
3120
context.report({

lib/rules/onClick-uses-role.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* @fileoverview Enforce non-interactive elements with click handlers use role attribute.
3+
* @author Ethan Cohen
4+
*/
5+
'use strict';
6+
7+
var isHiddenFromScreenReader = require('../isHiddenFromScreenReader');
8+
var isInteractiveElement = require('../isInteractiveElement');
9+
var hasAttribute = require('../hasAttribute');
10+
11+
// ------------------------------------------------------------------------------
12+
// Rule Definition
13+
// ------------------------------------------------------------------------------
14+
15+
module.exports = function(context) {
16+
return {
17+
JSXOpeningElement: function(node) {
18+
var attributes = node.attributes;
19+
if (hasAttribute(attributes, 'onclick') === false) {
20+
return;
21+
}
22+
23+
var isHidden = isHiddenFromScreenReader(attributes);
24+
var isInteractive = isInteractiveElement(node.name.name, attributes);
25+
var hasRoleAttribute = hasAttribute(attributes, 'role');
26+
27+
// Visible, non-interactive elements require role attribute.
28+
if (isHidden === false && isInteractive === false && hasRoleAttribute === false) {
29+
context.report({
30+
node: node,
31+
message: 'Visible, non-interactive elements with click handlers must have role attribute.'
32+
});
33+
}
34+
}
35+
};
36+
};
37+
38+
module.exports.schema = [{
39+
type: 'object'
40+
}];

tests/lib/rules/onClick-uses-role.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @fileoverview Enforce img tags use alt attribute.
3+
* @author Ethan Cohen
4+
*/
5+
6+
'use strict';
7+
8+
// -----------------------------------------------------------------------------
9+
// Requirements
10+
// -----------------------------------------------------------------------------
11+
12+
var rule = require('../../../lib/rules/onClick-uses-role');
13+
var RuleTester = require('eslint').RuleTester;
14+
15+
var parserOptions = {
16+
ecmaVersion: 6,
17+
ecmaFeatures: {
18+
jsx: true
19+
}
20+
};
21+
22+
// -----------------------------------------------------------------------------
23+
// Tests
24+
// -----------------------------------------------------------------------------
25+
26+
var ruleTester = new RuleTester();
27+
28+
var expectedError = {
29+
message: 'Visible, non-interactive elements with click handlers must have role attribute.',
30+
type: 'JSXOpeningElement'
31+
};
32+
33+
ruleTester.run('onClick-uses-role', rule, {
34+
valid: [
35+
{code: '<div onClick={() => void 0} role="button" />;', parserOptions: parserOptions},
36+
{code: '<div className="foo" />;', parserOptions: parserOptions},
37+
{code: '<div onClick={() => void 0} role="button" aria-hidden />;', parserOptions: parserOptions},
38+
{code: '<div onClick={() => void 0} role="button" aria-hidden={true} />;', parserOptions: parserOptions},
39+
{code: '<div onClick={() => void 0} role="button" aria-hidden={false} />;', parserOptions: parserOptions},
40+
{code: '<input type="text" onClick={() => void 0} />', parserOptions: parserOptions},
41+
{code: '<input onClick={() => void 0} />', parserOptions: parserOptions},
42+
{code: '<button onClick={() => void 0} className="foo" />', parserOptions: parserOptions},
43+
{code: '<option onClick={() => void 0} className="foo" />', parserOptions: parserOptions},
44+
{code: '<select onClick={() => void 0} className="foo" />', parserOptions: parserOptions},
45+
{code: '<textarea onClick={() => void 0} className="foo" />', parserOptions: parserOptions},
46+
{code: '<a tabIndex="0" onClick={() => void 0} />', parserOptions: parserOptions},
47+
{code: '<a role="button" onClick={() => void 0} />', parserOptions: parserOptions},
48+
{code: '<a onClick={() => void 0} href="http://x.y.z" />', parserOptions: parserOptions},
49+
{code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />', parserOptions: parserOptions}
50+
],
51+
invalid: [
52+
{code: '<div onClick={() => void 0} />;', errors: [expectedError], parserOptions: parserOptions},
53+
{code: '<section onClick={() => void 0} />;', errors: [expectedError], parserOptions: parserOptions},
54+
{code: '<main onClick={() => void 0} />;', errors: [expectedError], parserOptions: parserOptions},
55+
{code: '<article onClick={() => void 0} />;', errors: [expectedError], parserOptions: parserOptions},
56+
{code: '<header onClick={() => void 0} />;', errors: [expectedError], parserOptions: parserOptions},
57+
{code: '<footer onClick={() => void 0} />;', errors: [expectedError], parserOptions: parserOptions},
58+
{code: '<div onClick={() => void 0} aria-hidden={false} />;', errors: [expectedError], parserOptions: parserOptions},
59+
{code: '<input onClick={() => void 0} type="hidden" />;', errors: [expectedError], parserOptions: parserOptions},
60+
{code: '<a onClick={() => void 0} />', errors: [expectedError], parserOptions: parserOptions}
61+
]
62+
});

0 commit comments

Comments
 (0)