Skip to content

Commit ec4f193

Browse files
authored
[new] - no-static-element-interaction and click-events-have-key-events rules (#79)
* [new] - Implement click-events-have-key-events rule Fixes #77 * [new] - Implement no-static-element-interactions rule. Fixes #78 * [docs] - Cleanup & write docs for new rules. * [docs] - clean up new rule documentation.
1 parent c3d3cba commit ec4f193

9 files changed

+327
-23
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ Then configure the rules you want to use under the rules section.
7575
- [aria-proptypes](docs/rules/aria-proptypes.md): Enforce ARIA state and property values are valid.
7676
- [aria-role](docs/rules/aria-role.md): Enforce that elements with ARIA roles must use a valid, non-abstract ARIA role.
7777
- [aria-unsupported-elements](docs/rules/aria-unsupported-elements.md): Enforce that elements that do not support ARIA roles, states, and properties do not have those attributes.
78+
- [click-events-have-key-events](docs/rules/click-events-have-key-events.md): Enforce a clickable non-interactive element has at least one keyboard event listener.
7879
- [heading-has-content](docs/rules/heading-has-content.md): Enforce heading (`h1`, `h2`, etc) elements contain accessible content.
7980
- [href-no-hash](docs/rules/href-no-hash.md): Enforce an anchor element's `href` prop value is not just `#`.
8081
- [html-has-lang](docs/rules/html-has-lang.md): Enforce `<html>` element has `lang` prop.
@@ -86,6 +87,7 @@ Then configure the rules you want to use under the rules section.
8687
- [no-access-key](docs/rules/no-access-key.md): Enforce that the `accessKey` prop is not used on any element to avoid complications with keyboard commands used by a screenreader.
8788
- [no-marquee](docs/rules/no-marquee.md): Enforce `<marquee>` elements are not used.
8889
- [no-onchange](docs/rules/no-onchange.md): Enforce usage of `onBlur` over `onChange` on select menus for accessibility.
90+
- [no-static-element-interactions](docs/rules/no-static-element-interactions.md): Enforce non-interactive elements have no interactive handlers.
8991
- [onclick-has-focus](docs/rules/onclick-has-focus.md): Enforce that elements with `onClick` handlers must be focusable.
9092
- [onclick-has-role](docs/rules/onclick-has-role.md): Enforce that non-interactive, visible elements (such as `<div>`) that have click handlers use the role attribute.
9193
- [role-has-required-aria-props](docs/rules/role-has-required-aria-props.md): Enforce that elements with ARIA roles must have all required attributes for that role.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# click-events-have-key-events
2+
3+
Enforce `onClick` is accompanied by at least one of the following: `onKeyUp`, `onKeyDown`, `onKeyPress`. Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, AT compatibility, and screenreader users.
4+
5+
## Rule details
6+
7+
This rule takes no arguments.
8+
9+
### Succeed
10+
```jsx
11+
<div onClick={() => {}} onKeyDown={this.handleKeyDown} />
12+
<div onClick={() => {}} onKeyUp={this.handleKeyUp} />
13+
<div onClick={() => {}} onKeyPress={this.handleKeyPress} />
14+
```
15+
16+
### Fail
17+
```jsx
18+
<div onClick={() => {}} />
19+
```

docs/rules/mouse-events-have-key-events.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# mouse-events-have-key-events
22

3-
Enforce onmouseover/onmouseout are accompanied by onfocus/onblur. Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, AT compatability, and screenreader users.
3+
Enforce onmouseover/onmouseout are accompanied by onfocus/onblur. Coding for the keyboard is important for users with physical disabilities who cannot use a mouse, AT compatibility, and screenreader users.
44

55
## Rule details
66

@@ -22,4 +22,4 @@ In example 3 and 4 below, even if otherProps contains onBlur and/or onFocus, thi
2222
<div onMouseOut={ () => void 0 } />
2323
<div onMouseOver={ () => void 0 } {...otherProps} />
2424
<div onMouseOut={ () => void 0 } {...otherProps} />
25-
```
25+
```
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# click-events-have-key-events
2+
3+
Enforce non-interactive DOM elements have no interactive handlers. Static elements such as `<div>` and `<span>` should not have mouse/keyboard event listeners. Instead use something more semantic, such as a button or a link.
4+
5+
## Rule details
6+
7+
This rule takes no arguments.
8+
9+
### Succeed
10+
```jsx
11+
<button onClick={() => {}} className="foo" />
12+
<div className="foo" {...props} />
13+
<input type="text" onClick={() => {}} />
14+
```
15+
16+
### Fail
17+
```jsx
18+
<div onClick={() => {}} />
19+
```

src/index.js

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module.exports = {
77
'aria-proptypes': require('./rules/aria-proptypes'),
88
'aria-role': require('./rules/aria-role'),
99
'aria-unsupported-elements': require('./rules/aria-unsupported-elements'),
10+
'click-events-have-key-events': require('./rules/click-events-have-key-events'),
1011
'heading-has-content': require('./rules/heading-has-content'),
1112
'href-no-hash': require('./rules/href-no-hash'),
1213
'html-has-lang': require('./rules/html-has-lang'),
@@ -18,6 +19,7 @@ module.exports = {
1819
'no-access-key': require('./rules/no-access-key'),
1920
'no-marquee': require('./rules/no-marquee'),
2021
'no-onchange': require('./rules/no-onchange'),
22+
'no-static-element-interactions': require('./rules/no-static-element-interactions'),
2123
'onclick-has-focus': require('./rules/onclick-has-focus'),
2224
'onclick-has-role': require('./rules/onclick-has-role'),
2325
'role-has-required-aria-props': require('./rules/role-has-required-aria-props'),
@@ -33,27 +35,29 @@ module.exports = {
3335
},
3436
},
3537
rules: {
36-
'jsx-a11y/anchor-has-content': 2,
37-
'jsx-a11y/aria-props': 2,
38-
'jsx-a11y/aria-proptypes': 2,
39-
'jsx-a11y/aria-role': 2,
40-
'jsx-a11y/aria-unsupported-elements': 2,
41-
'jsx-a11y/heading-has-content': 2,
42-
'jsx-a11y/href-no-hash': 2,
43-
'jsx-a11y/html-has-lang': 2,
44-
'jsx-a11y/img-has-alt': 2,
45-
'jsx-a11y/img-redundant-alt': 2,
46-
'jsx-a11y/label-has-for': 2,
47-
'jsx-a11y/mouse-events-have-key-events': 2,
48-
'jsx-a11y/no-access-key': 2,
49-
'jsx-a11y/no-marquee': 2,
50-
'jsx-a11y/no-onchange': 2,
51-
'jsx-a11y/onclick-has-focus': 2,
52-
'jsx-a11y/onclick-has-role': 2,
53-
'jsx-a11y/role-has-required-aria-props': 2,
54-
'jsx-a11y/role-supports-aria-props': 2,
55-
'jsx-a11y/scope': 2,
56-
'jsx-a11y/tabindex-no-positive': 2,
38+
'jsx-a11y/anchor-has-content': 'error',
39+
'jsx-a11y/aria-props': 'error',
40+
'jsx-a11y/aria-proptypes': 'error',
41+
'jsx-a11y/aria-role': 'error',
42+
'jsx-a11y/aria-unsupported-elements': 'error',
43+
'jsx-a11y/click-events-have-key-events': 'error',
44+
'jsx-a11y/heading-has-content': 'error',
45+
'jsx-a11y/href-no-hash': 'error',
46+
'jsx-a11y/html-has-lang': 'error',
47+
'jsx-a11y/img-has-alt': 'error',
48+
'jsx-a11y/img-redundant-alt': 'error',
49+
'jsx-a11y/label-has-for': 'error',
50+
'jsx-a11y/mouse-events-have-key-events': 'error',
51+
'jsx-a11y/no-access-key': 'error',
52+
'jsx-a11y/no-marquee': 'error',
53+
'jsx-a11y/no-onchange': 'error',
54+
'jsx-a11y/no-static-element-interactions': 'warn',
55+
'jsx-a11y/onclick-has-focus': 'error',
56+
'jsx-a11y/onclick-has-role': 'error',
57+
'jsx-a11y/role-has-required-aria-props': 'error',
58+
'jsx-a11y/role-supports-aria-props': 'error',
59+
'jsx-a11y/scope': 'error',
60+
'jsx-a11y/tabindex-no-positive': 'error',
5761
},
5862
},
5963
},
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @fileoverview Enforce a clickable non-interactive element has at least 1 keyboard event listener.
3+
* @author Ethan Cohen
4+
*/
5+
6+
// ----------------------------------------------------------------------------
7+
// Rule Definition
8+
// ----------------------------------------------------------------------------
9+
10+
import { getProp, hasAnyProp, elementType } from 'jsx-ast-utils';
11+
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
12+
import isInteractiveElement from '../util/isInteractiveElement';
13+
14+
const errorMessage = 'Visible, non-interactive elements with click handlers' +
15+
' must have at least one keyboard listener.';
16+
17+
module.exports = {
18+
meta: {
19+
docs: {},
20+
21+
schema: [
22+
{ type: 'object' },
23+
],
24+
},
25+
26+
create: context => ({
27+
JSXOpeningElement: node => {
28+
const props = node.attributes;
29+
if (getProp(props, 'onclick') === undefined) {
30+
return;
31+
}
32+
33+
const type = elementType(node);
34+
const requiredProps = ['onkeydown', 'onkeyup', 'onkeypress'];
35+
36+
if (isHiddenFromScreenReader(type, props)) {
37+
return;
38+
} else if (isInteractiveElement(type, props)) {
39+
return;
40+
} else if (hasAnyProp(props, requiredProps)) {
41+
return;
42+
}
43+
44+
// Visible, non-interactive elements with click handlers require one keyboard event listener.
45+
context.report({
46+
node,
47+
message: errorMessage,
48+
});
49+
},
50+
}),
51+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* @fileoverview Enforce non-interactive elements have no interactive handlers.
3+
* @author Ethan Cohen
4+
*/
5+
6+
// ----------------------------------------------------------------------------
7+
// Rule Definition
8+
// ----------------------------------------------------------------------------
9+
10+
import { hasAnyProp, elementType } from 'jsx-ast-utils';
11+
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
12+
import isInteractiveElement from '../util/isInteractiveElement';
13+
14+
const errorMessage =
15+
'Visible, non-interactive elements should not have mouse or keyboard event listeners';
16+
17+
module.exports = {
18+
meta: {
19+
docs: {},
20+
21+
schema: [
22+
{ type: 'object' },
23+
],
24+
},
25+
26+
create: context => ({
27+
JSXOpeningElement: node => {
28+
const props = node.attributes;
29+
const type = elementType(node);
30+
31+
const interactiveProps = [
32+
'onclick',
33+
'ondblclick',
34+
'onkeydown',
35+
'onkeyup',
36+
'onkeypress',
37+
];
38+
39+
if (isHiddenFromScreenReader(type, props)) {
40+
return;
41+
} else if (isInteractiveElement(type, props)) {
42+
return;
43+
} else if (hasAnyProp(props, interactiveProps) === false) {
44+
return;
45+
}
46+
47+
// Visible, non-interactive elements should not have an interactive handler.
48+
context.report({
49+
node,
50+
message: errorMessage,
51+
});
52+
},
53+
}),
54+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @fileoverview Enforce a clickable non-interactive element has at least 1 keyboard event listener.
3+
* @author Ethan Cohen
4+
*/
5+
6+
// -----------------------------------------------------------------------------
7+
// Requirements
8+
// -----------------------------------------------------------------------------
9+
10+
import rule from '../../../src/rules/click-events-have-key-events';
11+
import { RuleTester } from 'eslint';
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 errorMessage = 'Visible, non-interactive elements with click handlers' +
27+
' must have at least one keyboard listener.';
28+
29+
const expectedError = {
30+
message: errorMessage,
31+
type: 'JSXOpeningElement',
32+
};
33+
34+
ruleTester.run('onclick-has-role', rule, {
35+
valid: [
36+
{ code: '<div onClick={() => void 0} onKeyDown={foo}/>;', parserOptions },
37+
{ code: '<div onClick={() => void 0} onKeyUp={foo} />;', parserOptions },
38+
{ code: '<div onClick={() => void 0} onKeyPress={foo}/>;', parserOptions },
39+
{ code: '<div onClick={() => void 0} onKeyDown={foo} onKeyUp={bar} />;', parserOptions },
40+
{ code: '<div onClick={() => void 0} onKeyDown={foo} {...props} />;', parserOptions },
41+
{ code: '<div className="foo" />;', parserOptions },
42+
{ code: '<div onClick={() => void 0} aria-hidden />;', parserOptions },
43+
{ code: '<div onClick={() => void 0} aria-hidden={true} />;', parserOptions },
44+
{ code: '<div onClick={() => void 0} aria-hidden={false} onKeyDown={foo} />;', parserOptions },
45+
{
46+
code: '<div onClick={() => void 0} onKeyDown={foo} aria-hidden={undefined} />;',
47+
parserOptions,
48+
},
49+
{ code: '<input type="text" onClick={() => void 0} />', parserOptions },
50+
{ code: '<input onClick={() => void 0} />', parserOptions },
51+
{ code: '<button onClick={() => void 0} className="foo" />', parserOptions },
52+
{ code: '<option onClick={() => void 0} className="foo" />', parserOptions },
53+
{ code: '<select onClick={() => void 0} className="foo" />', parserOptions },
54+
{ code: '<textarea onClick={() => void 0} className="foo" />', parserOptions },
55+
{ code: '<a tabIndex="0" onClick={() => void 0} />', parserOptions },
56+
{ code: '<a onClick={() => void 0} href="http://x.y.z" />', parserOptions },
57+
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />', parserOptions },
58+
{ code: '<input onClick={() => void 0} type="hidden" />;', parserOptions },
59+
{ code: '<TestComponent onClick={doFoo} />', parserOptions },
60+
{ code: '<Button onClick={doFoo} />', parserOptions },
61+
],
62+
invalid: [
63+
{ code: '<div onClick={() => void 0} />;', errors: [expectedError], parserOptions },
64+
{
65+
code: '<div onClick={() => void 0} role={undefined} />;',
66+
errors: [expectedError],
67+
parserOptions,
68+
},
69+
{ code: '<div onClick={() => void 0} {...props} />;', errors: [expectedError], parserOptions },
70+
{ code: '<section onClick={() => void 0} />;', errors: [expectedError], parserOptions },
71+
{ code: '<main onClick={() => void 0} />;', errors: [expectedError], parserOptions },
72+
{ code: '<article onClick={() => void 0} />;', errors: [expectedError], parserOptions },
73+
{ code: '<header onClick={() => void 0} />;', errors: [expectedError], parserOptions },
74+
{ code: '<footer onClick={() => void 0} />;', errors: [expectedError], parserOptions },
75+
{
76+
code: '<div onClick={() => void 0} aria-hidden={false} />;',
77+
errors: [expectedError],
78+
parserOptions,
79+
},
80+
{ code: '<a onClick={() => void 0} />', errors: [expectedError], parserOptions },
81+
],
82+
});

0 commit comments

Comments
 (0)