Skip to content

Commit 2eeb82e

Browse files
committed
[new] - Implement onclick-has-focus rule. (#28)
* [new] - Implement onclick-has-focus Fixes #15 * [fix] - input type="hidden" will be hidden from screenreader. Also cleaned up onclick-uses-role to match onclick-has-focus. Added MDN references to these docs as well.
1 parent 4f6ec08 commit 2eeb82e

12 files changed

+203
-22
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Then configure the rules you want to use under the rules section.
8080
- [role-requires-aria](docs/rules/role-requires-aria.md): Enforce that elements with ARIA roles must have all required attributes for that role.
8181
- [no-unsupported-elements-use-aria](docs/rules/no-unsupported-elements-use-aria.md): Enforce that elements that do not support ARIA roles, states and properties do not have those attributes.
8282
- [avoid-positive-tabindex](docs/rules/avoid-positive-tabindex.md): Enforce tabIndex value is not greater than zero.
83+
- [onclick-has-focus](docs/rules/onclick-has-focus.md): Enforce that elements with onClick handlers must be focusable.
8384

8485
## Contributing
8586
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/onclick-has-focus.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# onclick-has-focus
2+
3+
Enforce that visible elements with onClick handlers must be focusable. Visible means that it is not hidden from a screen reader. Examples of non-interactive elements are `div`, `section`, and `a` elements without a href prop. Elements which have click handlers but are not focusable can not be used by keyboard-only users.
4+
5+
To make an element focusable, you can either set the tabIndex property, or use an element type which is inherently focusable.
6+
7+
Elements that are inherently focusable are as follows:
8+
- `<input>`, `<button>`, `<select>` and `<textarea>` elements which are not disabled
9+
- `<a>` or `<area>` elements with an `href` attribute.
10+
11+
#### References
12+
1. [AX_FOCUS_02](https://github.com/GoogleChrome/accessibility-developer-tools/wiki/Audit-Rules#ax_focus_02)
13+
2. [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_button_role#Keyboard_and_focus)
14+
15+
## Rule details
16+
17+
This rule takes no arguments.
18+
19+
### Succeed
20+
```jsx
21+
<!-- Good: div with onClick attribute is hidden from screen reader -->
22+
<div aria-hidden onClick={() => void 0} />
23+
<!-- Good: span with onClick attribute is in the tab order -->
24+
<span onClick="doSomething();" tabIndex="0">Click me!</span>
25+
<!-- Good: span with onClick attribute may be focused programmatically -->
26+
<span onClick="doSomething();" tabIndex="-1">Click me too!</span>
27+
<!-- Good: anchor element with href is inherently focusable -->
28+
<a href="javascript:void(0);" onClick="doSomething();">Click ALL the things!</a>
29+
<!-- Good: buttons are inherently focusable -->
30+
<button onClick="doSomething();">Click the button :)</a>
31+
```
32+
33+
### Fail
34+
```jsx
35+
<!-- Bad: span with onClick attribute has no tabindex -->
36+
<span onclick="submitForm();">Submit</span>
37+
<!-- Bad: anchor element without href is not focusable -->
38+
<a onclick="showNextPage();">Next page</a>
39+
```

docs/rules/onclick-uses-role.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# onclick-uses-role
22

3-
Enforce visible, non-interactive elements with click handlers use role attribute. Visible means that it is not hidden from a screen reader. Examples of non-interactive elements are `div`, `section`, and `a` elements without a href prop.The purpose of the role attribute is to identify to screenreaders the exact function of an element.
3+
Enforce visible, non-interactive elements with click handlers use role attribute. Visible means that it is not hidden from a screen reader. Examples of non-interactive elements are `div`, `section`, and `a` elements without a href prop. The purpose of the role attribute is to identify to screenreaders the exact function of an element.
4+
5+
#### References
6+
1. [MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Techniques/Using_the_button_role#Keyboard_and_focus)
47

58
## Rule details
69

@@ -21,5 +24,4 @@ This rule takes no arguments.
2124
<div onClick={() => void 0} {...props} />
2225
<div onClick={() => void 0} aria-hidden={false} />
2326
<a onClick={() => void 0} />
24-
<input onClick={() => void 0} type="hidden" /> // May not be hidden from screenreader ¯\_(ツ)_/¯
25-
```
27+
```

src/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ module.exports = {
1515
'no-invalid-aria': require('./rules/no-invalid-aria'),
1616
'role-requires-aria': require('./rules/role-requires-aria'),
1717
'no-unsupported-elements-use-aria': require('./rules/no-unsupported-elements-use-aria'),
18-
'avoid-positive-tabindex': require('./rules/avoid-positive-tabindex')
18+
'avoid-positive-tabindex': require('./rules/avoid-positive-tabindex'),
19+
'onclick-has-focus': require('./rules/onclick-has-focus')
1920
},
2021
configs: {
2122
recommended: {

src/rules/onclick-has-focus.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @fileoverview Enforce that elements with onClick handlers must be focusable.
3+
* @author Ethan Cohen
4+
*/
5+
'use strict';
6+
7+
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
8+
import isInteractiveElement from '../util/isInteractiveElement';
9+
import hasAttribute from '../util/hasAttribute';
10+
import getNodeType from '../util/getNodeType';
11+
import getAttributeValue from '../util/getAttributeValue';
12+
13+
// ----------------------------------------------------------------------------
14+
// Rule Definition
15+
// ----------------------------------------------------------------------------
16+
17+
const errorMessage = 'Elements with onClick handlers must be focusable. ' +
18+
'Either set the tabIndex property (usually 0), or use an element type which ' +
19+
'is inherently focusable such as `button`.';
20+
21+
module.exports = context => ({
22+
JSXOpeningElement: node => {
23+
const { attributes } = node;
24+
if (hasAttribute(attributes, 'onClick') === false) {
25+
return;
26+
}
27+
28+
const type = getNodeType(node);
29+
30+
if (isHiddenFromScreenReader(type, attributes)) {
31+
return;
32+
} else if (isInteractiveElement(type, attributes)) {
33+
return;
34+
} else if (getAttributeValue(hasAttribute(attributes, 'tabIndex'))) {
35+
return;
36+
}
37+
38+
context.report({
39+
node,
40+
message: errorMessage
41+
});
42+
}
43+
});
44+
45+
module.exports.schema = [
46+
{ type: 'object' }
47+
];

src/rules/onclick-uses-role.js

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,21 @@ module.exports = context => ({
2525
return;
2626
}
2727

28-
const isVisible = isHiddenFromScreenReader(attributes) === false;
29-
const isNonInteractive = isInteractiveElement(getNodeType(node), attributes) === false;
30-
const roleAttribute = hasAttribute(attributes, 'role');
31-
const noRoleAttribute = roleAttribute === false || Boolean(getAttributeValue(roleAttribute)) === false;
28+
const type = getNodeType(node);
3229

33-
// Visible, non-interactive elements require role attribute.
34-
if (isVisible && isNonInteractive && noRoleAttribute) {
35-
context.report({
36-
node,
37-
message: errorMessage
38-
});
30+
if (isHiddenFromScreenReader(type, attributes)) {
31+
return;
32+
} else if (isInteractiveElement(type, attributes)) {
33+
return;
34+
} else if (getAttributeValue(hasAttribute(attributes, 'role'))) {
35+
return;
3936
}
37+
38+
// Visible, non-interactive elements require role attribute.
39+
context.report({
40+
node,
41+
message: errorMessage
42+
});
4043
}
4144
});
4245

src/rules/redundant-alt.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ module.exports = context => ({
5050
}
5151

5252
const value = getAttributeValue(altProp);
53-
const isVisible = isHiddenFromScreenReader(node.attributes) === false;
53+
const isVisible = isHiddenFromScreenReader(type, node.attributes) === false;
5454

5555
if (Boolean(value) && typeof value === 'string' && isVisible) {
5656
const hasRedundancy = REDUNDANT_WORDS

src/util/isHiddenFromScreenReader.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,26 @@
11
'use strict';
22

33
import hasAttribute from './hasAttribute';
4-
import getAttributeValue from './getAttributeValue';
4+
import getAttributeValue, { getLiteralAttributeValue } from './getAttributeValue';
55

66

77

88
/**
99
* Returns boolean indicating that the aria-hidden prop
10-
* is present or the value is true.
10+
* is present or the value is true. Will also return true if
11+
* there is an input with type='hidden'.
1112
*
1213
* <div aria-hidden /> is equivalent to the DOM as <div aria-hidden=true />.
1314
*/
14-
const isHiddenFromScreenReader = attributes => {
15+
const isHiddenFromScreenReader = (type, attributes) => {
16+
if (type.toUpperCase() === 'INPUT') {
17+
const hidden = getLiteralAttributeValue(hasAttribute(attributes, 'type'));
18+
19+
if (hidden && hidden.toUpperCase() == 'HIDDEN') {
20+
return true;
21+
}
22+
}
23+
1524
const ariaHidden = getAttributeValue(hasAttribute(attributes, 'aria-hidden'));
1625
return ariaHidden === true;
1726
};

src/util/isInteractiveElement.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use strict';
22

33
import hasAttribute from './hasAttribute';
4-
import getAttributeValue from './getAttributeValue';
4+
import { getLiteralAttributeValue } from './getAttributeValue';
55
import DOMElements from './attributes/DOM';
66

77

@@ -11,11 +11,13 @@ const interactiveMap = {
1111
a: attributes => {
1212
const hasHref = hasAttribute(attributes, 'href');
1313
const hasTabIndex = hasAttribute(attributes, 'tabIndex');
14-
return (Boolean(hasHref) || !hasHref && Boolean(hasTabIndex));
14+
return (Boolean(hasHref) || (!hasHref && Boolean(hasTabIndex)));
1515
},
16+
// This is same as `a` interactivity function
17+
area: attributes => interactiveMap.a(attributes),
1618
button: () => true,
1719
input: attributes => {
18-
const typeAttr = getAttributeValue(hasAttribute(attributes, 'type'));
20+
const typeAttr = getLiteralAttributeValue(hasAttribute(attributes, 'type'));
1921
return typeAttr ? typeAttr.toUpperCase() !== 'HIDDEN' : true;
2022
},
2123
option: () => true,

tests/src/rules/avoid-positive-tabindex.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ ruleTester.run('avoid-positive-tabindex', rule, {
3434
valid: [
3535
{ code: '<div />;', parserOptions },
3636
{ code: '<div {...props} />', parserOptions },
37+
{ code: '<div id="main" />', parserOptions },
3738
{ code: '<div tabIndex={undefined} />', parserOptions },
3839
{ code: '<div tabIndex={`${undefined}`} />', parserOptions },
3940
{ code: '<div tabIndex={`${undefined}${undefined}`} />', parserOptions },

0 commit comments

Comments
 (0)