Skip to content

Commit ba8db40

Browse files
committed
no-stat-element-interactions should only trigger on elements that are non-interactive
1 parent a2f36a6 commit ba8db40

File tree

5 files changed

+227
-45
lines changed

5 files changed

+227
-45
lines changed

__mocks__/genInteractives.js

Lines changed: 43 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,35 @@ const pureInteractiveElements = domElements
2121

2222
const interactiveElementsMap = {
2323
...pureInteractiveElements,
24-
a: [
25-
{prop: 'href', value: '#'}
26-
],
27-
area: [
28-
{prop: 'href', value: '#'}
29-
],
30-
input: [
31-
{prop: 'type', value: 'text'}
32-
],
24+
a: [{prop: 'href', value: '#'}],
25+
area: [{prop: 'href', value: '#'}],
26+
form: [],
27+
input: [],
28+
'input[type=\"button\"]': [{prop: 'type', value: 'button'}],
29+
'input[type=\"checkbox\"]': [{prop: 'type', value: 'checkbox'}],
30+
'input[type=\"color\"]': [{prop: 'type', value: 'color'}],
31+
'input[type=\"date\"]': [{prop: 'type', value: 'date'}],
32+
'input[type=\"datetime\"]': [{prop: 'type', value: 'datetime'}],
33+
'input[type=\"datetime\"]': [{prop: 'type', value: 'datetime'}],
34+
'input[type=\"email\"]': [{prop: 'type', value: 'email'}],
35+
'input[type=\"file\"]': [{prop: 'type', value: 'file'}],
36+
'input[type=\"image\"]': [{prop: 'type', value: 'image'}],
37+
'input[type=\"month\"]': [{prop: 'type', value: 'month'}],
38+
'input[type=\"number\"]': [{prop: 'type', value: 'number'}],
39+
'input[type=\"password\"]': [{prop: 'type', value: 'password'}],
40+
'input[type=\"radio\"]': [{prop: 'type', value: 'radio'}],
41+
'input[type=\"range\"]': [{prop: 'type', value: 'range'}],
42+
'input[type=\"reset\"]': [{prop: 'type', value: 'reset'}],
43+
'input[type=\"search\"]': [{prop: 'type', value: 'search'}],
44+
'input[type=\"submit\"]': [{prop: 'type', value: 'submit'}],
45+
'input[type=\"tel\"]': [{prop: 'type', value: 'tel'}],
46+
'input[type=\"text\"]': [{prop: 'type', value: 'text'}],
47+
'input[type=\"time\"]': [{prop: 'type', value: 'time'}],
48+
'input[type=\"url\"]': [{prop: 'type', value: 'url'}],
49+
'input[type=\"week\"]': [{prop: 'type', value: 'week'}],
3350
};
3451

35-
const pureNonInteractiveElementsMap = {
52+
const nonInteractiveElementsMap = {
3653
a: [],
3754
area: [],
3855
article: [],
@@ -41,7 +58,6 @@ const pureNonInteractiveElementsMap = {
4158
dt: [],
4259
fieldset: [],
4360
figure: [],
44-
form: [],
4561
frame: [],
4662
h1: [],
4763
h2: [],
@@ -51,8 +67,9 @@ const pureNonInteractiveElementsMap = {
5167
h6: [],
5268
hr: [],
5369
img: [],
54-
input: [],
70+
'input[type=\"hidden\"]': [{prop: 'type', value: 'hidden'}],
5571
li: [],
72+
main: [],
5673
nav: [],
5774
ol: [],
5875
table: [],
@@ -63,13 +80,6 @@ const pureNonInteractiveElementsMap = {
6380
ul: [],
6481
};
6582

66-
const nonInteractiveElementsMap = {
67-
...pureNonInteractiveElementsMap,
68-
input: [
69-
{prop: 'type', value: 'hidden'}
70-
],
71-
};
72-
7383
const indeterminantInteractiveElementsMap = domElements
7484
.reduce(
7585
(
@@ -98,8 +108,13 @@ const nonInteractiveRoles = roleNames.filter(
98108

99109
export function genInteractiveElements () {
100110
return Object.keys(interactiveElementsMap)
101-
.map(name => {
102-
const attributes = interactiveElementsMap[name].map(
111+
.map(elementSymbol => {
112+
const bracketIndex = elementSymbol.indexOf('[');
113+
let name = elementSymbol;
114+
if (bracketIndex > -1) {
115+
name = elementSymbol.slice(0, bracketIndex);
116+
}
117+
const attributes = interactiveElementsMap[elementSymbol].map(
103118
({prop, value}) => JSXAttributeMock(prop, value)
104119
);
105120
return JSXElementMock(name, attributes);
@@ -116,8 +131,13 @@ export function genInteractiveRoleElements () {
116131

117132
export function genNonInteractiveElements () {
118133
return Object.keys(nonInteractiveElementsMap)
119-
.map(name => {
120-
const attributes = nonInteractiveElementsMap[name].map(
134+
.map(elementSymbol => {
135+
const bracketIndex = elementSymbol.indexOf('[');
136+
let name = elementSymbol;
137+
if (bracketIndex > -1) {
138+
name = elementSymbol.slice(0, bracketIndex);
139+
}
140+
const attributes = nonInteractiveElementsMap[elementSymbol].map(
121141
({prop, value}) => JSXAttributeMock(prop, value)
122142
);
123143
return JSXElementMock(name, attributes);

__tests__/src/rules/no-static-element-interactions-test.js

Lines changed: 145 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,42 @@ const expectedError = {
2626
type: 'JSXOpeningElement',
2727
};
2828

29-
ruleTester.run('onclick-has-role', rule, {
29+
ruleTester.run('no-static-element-interactions', rule, {
3030
valid: [
3131
{ code: '<div className="foo" />;' },
3232
{ code: '<div className="foo" {...props} />;' },
3333
{ code: '<div onClick={() => void 0} aria-hidden />;' },
3434
{ code: '<div onClick={() => void 0} aria-hidden={true} />;' },
35-
{ code: '<input type="text" onClick={() => void 0} />' },
35+
{ code: '<div onClick={() => void 0} />;' },
36+
{ code: '<div onClick={() => void 0} role={undefined} />;' },
37+
{ code: '<div onClick={() => void 0} {...props} />;' },
38+
{ code: '<div onKeyUp={() => void 0} aria-hidden={false} />;' },
39+
/* All flavors of input */
3640
{ code: '<input onClick={() => void 0} />' },
41+
{ code: '<input type="button" onClick={() => void 0} />' },
42+
{ code: '<input type="checkbox" onClick={() => void 0} />' },
43+
{ code: '<input type="color" onClick={() => void 0} />' },
44+
{ code: '<input type="date" onClick={() => void 0} />' },
45+
{ code: '<input type="datetime" onClick={() => void 0} />' },
46+
{ code: '<input type="datetime-local" onClick={() => void 0} />' },
47+
{ code: '<input type="email" onClick={() => void 0} />' },
48+
{ code: '<input type="file" onClick={() => void 0} />' },
49+
{ code: '<input type="image" onClick={() => void 0} />' },
50+
{ code: '<input type="month" onClick={() => void 0} />' },
51+
{ code: '<input type="number" onClick={() => void 0} />' },
52+
{ code: '<input type="password" onClick={() => void 0} />' },
53+
{ code: '<input type="radio" onClick={() => void 0} />' },
54+
{ code: '<input type="range" onClick={() => void 0} />' },
55+
{ code: '<input type="reset" onClick={() => void 0} />' },
56+
{ code: '<input type="search" onClick={() => void 0} />' },
57+
{ code: '<input type="submit" onClick={() => void 0} />' },
58+
{ code: '<input type="tel" onClick={() => void 0} />' },
59+
{ code: '<input type="text" onClick={() => void 0} />' },
60+
{ code: '<input type="time" onClick={() => void 0} />' },
61+
{ code: '<input type="url" onClick={() => void 0} />' },
62+
{ code: '<input type="week" onClick={() => void 0} />' },
63+
/* End all flavors of input */
64+
{ code: '<input type="hidden" onClick={() => void 0} />' },
3765
{ code: '<button onClick={() => void 0} className="foo" />' },
3866
{ code: '<option onClick={() => void 0} className="foo" />' },
3967
{ code: '<select onClick={() => void 0} className="foo" />' },
@@ -42,25 +70,126 @@ ruleTester.run('onclick-has-role', rule, {
4270
{ code: '<a onClick={() => void 0} href="http://x.y.z" />' },
4371
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />' },
4472
{ code: '<input onClick={() => void 0} type="hidden" />;' },
73+
{ code: '<form onClick={() => {}} />;' },
74+
{ code: '<section onClick={() => void 0} />;' },
75+
{ code: '<header onKeyDown={() => void 0} />;' },
76+
{ code: '<footer onKeyPress={() => void 0} />;' },
4577
{ code: '<TestComponent onClick={doFoo} />' },
4678
{ code: '<Button onClick={doFoo} />' },
79+
/* HTML elements attributed with an interactive role */
80+
{ code: '<div role="button" onClick={() => {}} />;' },
81+
{ code: '<div role="checkbox" onClick={() => {}} />;' },
82+
{ code: '<div role="columnheader" onClick={() => {}} />;' },
83+
{ code: '<div role="combobox" onClick={() => {}} />;' },
84+
{ code: '<div role="form" onClick={() => {}} />;' },
85+
{ code: '<div role="gridcell" onClick={() => {}} />;' },
86+
{ code: '<div role="link" onClick={() => {}} />;' },
87+
{ code: '<div role="menuitem" onClick={() => {}} />;' },
88+
{ code: '<div role="menuitemcheckbox" onClick={() => {}} />;' },
89+
{ code: '<div role="menuitemradio" onClick={() => {}} />;' },
90+
{ code: '<div role="option" onClick={() => {}} />;' },
91+
{ code: '<div role="radio" onClick={() => {}} />;' },
92+
{ code: '<div role="rowheader" onClick={() => {}} />;' },
93+
{ code: '<div role="searchbox" onClick={() => {}} />;' },
94+
{ code: '<div role="slider" onClick={() => {}} />;' },
95+
{ code: '<div role="spinbutton" onClick={() => {}} />;' },
96+
{ code: '<div role="switch" onClick={() => {}} />;' },
97+
{ code: '<div role="tab" onClick={() => {}} />;' },
98+
{ code: '<div role="textbox" onClick={() => {}} />;' },
99+
{ code: '<div role="treeitem" onClick={() => {}} />;' },
100+
/* Presentation is a special case role that indicates intentional static semantics */
101+
{ code: '<div role="presentation" onClick={() => {}} />;' },
47102
].map(parserOptionsMapper),
48103
invalid: [
49-
{ code: '<div onClick={() => void 0} />;', errors: [expectedError] },
50-
{
51-
code: '<div onClick={() => void 0} role={undefined} />;',
52-
errors: [expectedError],
53-
},
54-
{ code: '<div onClick={() => void 0} {...props} />;', errors: [expectedError] },
55-
{ code: '<section onClick={() => void 0} />;', errors: [expectedError] },
104+
/* HTML elements with an inherent, non-interactive role */
56105
{ code: '<main onClick={() => void 0} />;', errors: [expectedError] },
57-
{ code: '<article onDblClick={() => void 0} />;', errors: [expectedError] },
58-
{ code: '<header onKeyDown={() => void 0} />;', errors: [expectedError] },
59-
{ code: '<footer onKeyPress={() => void 0} />;', errors: [expectedError] },
60-
{
61-
code: '<div onKeyUp={() => void 0} aria-hidden={false} />;',
62-
errors: [expectedError],
63-
},
64106
{ code: '<a onClick={() => void 0} />', errors: [expectedError] },
107+
{ code: '<a onClick={() => {}} />;', errors: [expectedError] },
108+
{ code: '<area onClick={() => {}} />;', errors: [expectedError] },
109+
{ code: '<article onClick={() => {}} />;', errors: [expectedError] },
110+
{ code: '<article onDblClick={() => void 0} />;', errors: [expectedError] },
111+
{ code: '<dd onClick={() => {}} />;', errors: [expectedError] },
112+
{ code: '<dfn onClick={() => {}} />;', errors: [expectedError] },
113+
{ code: '<dt onClick={() => {}} />;', errors: [expectedError] },
114+
{ code: '<fieldset onClick={() => {}} />;', errors: [expectedError] },
115+
{ code: '<figure onClick={() => {}} />;', errors: [expectedError] },
116+
{ code: '<frame onClick={() => {}} />;', errors: [expectedError] },
117+
{ code: '<h1 onClick={() => {}} />;', errors: [expectedError] },
118+
{ code: '<h2 onClick={() => {}} />;', errors: [expectedError] },
119+
{ code: '<h3 onClick={() => {}} />;', errors: [expectedError] },
120+
{ code: '<h4 onClick={() => {}} />;', errors: [expectedError] },
121+
{ code: '<h5 onClick={() => {}} />;', errors: [expectedError] },
122+
{ code: '<h6 onClick={() => {}} />;', errors: [expectedError] },
123+
{ code: '<hr onClick={() => {}} />;', errors: [expectedError] },
124+
{ code: '<img onClick={() => {}} />;', errors: [expectedError] },
125+
{ code: '<li onClick={() => {}} />;', errors: [expectedError] },
126+
{ code: '<nav onClick={() => {}} />;', errors: [expectedError] },
127+
{ code: '<ol onClick={() => {}} />;', errors: [expectedError] },
128+
{ code: '<table onClick={() => {}} />;', errors: [expectedError] },
129+
{ code: '<tbody onClick={() => {}} />;', errors: [expectedError] },
130+
{ code: '<tfoot onClick={() => {}} />;', errors: [expectedError] },
131+
{ code: '<thead onClick={() => {}} />;', errors: [expectedError] },
132+
{ code: '<tr onClick={() => {}} />;', errors: [expectedError] },
133+
{ code: '<ul onClick={() => {}} />;', errors: [expectedError] },
134+
/* HTML elements attributed with a non-interactive role */
135+
{ code: '<div role="alert" onClick={() => {}} />;', errors: [expectedError] },
136+
{ code: '<div role="alertdialog" onClick={() => {}} />;', errors: [expectedError] },
137+
{ code: '<div role="application" onClick={() => {}} />;', errors: [expectedError] },
138+
{ code: '<div role="article" onClick={() => {}} />;', errors: [expectedError] },
139+
{ code: '<div role="banner" onClick={() => {}} />;', errors: [expectedError] },
140+
{ code: '<div role="cell" onClick={() => {}} />;', errors: [expectedError] },
141+
{ code: '<div role="command" onClick={() => {}} />;', errors: [expectedError] },
142+
{ code: '<div role="complementary" onClick={() => {}} />;', errors: [expectedError] },
143+
{ code: '<div role="composite" onClick={() => {}} />;', errors: [expectedError] },
144+
{ code: '<div role="contentinfo" onClick={() => {}} />;', errors: [expectedError] },
145+
{ code: '<div role="definition" onClick={() => {}} />;', errors: [expectedError] },
146+
{ code: '<div role="dialog" onClick={() => {}} />;', errors: [expectedError] },
147+
{ code: '<div role="directory" onClick={() => {}} />;', errors: [expectedError] },
148+
{ code: '<div role="document" onClick={() => {}} />;', errors: [expectedError] },
149+
{ code: '<div role="feed" onClick={() => {}} />;', errors: [expectedError] },
150+
{ code: '<div role="figure" onClick={() => {}} />;', errors: [expectedError] },
151+
{ code: '<div role="grid" onClick={() => {}} />;', errors: [expectedError] },
152+
{ code: '<div role="group" onClick={() => {}} />;', errors: [expectedError] },
153+
{ code: '<div role="heading" onClick={() => {}} />;', errors: [expectedError] },
154+
{ code: '<div role="img" onClick={() => {}} />;', errors: [expectedError] },
155+
{ code: '<div role="input" onClick={() => {}} />;', errors: [expectedError] },
156+
{ code: '<div role="landmark" onClick={() => {}} />;', errors: [expectedError] },
157+
{ code: '<div role="list" onClick={() => {}} />;', errors: [expectedError] },
158+
{ code: '<div role="listbox" onClick={() => {}} />;', errors: [expectedError] },
159+
{ code: '<div role="listitem" onClick={() => {}} />;', errors: [expectedError] },
160+
{ code: '<div role="log" onClick={() => {}} />;', errors: [expectedError] },
161+
{ code: '<div role="main" onClick={() => {}} />;', errors: [expectedError] },
162+
{ code: '<div role="marquee" onClick={() => {}} />;', errors: [expectedError] },
163+
{ code: '<div role="math" onClick={() => {}} />;', errors: [expectedError] },
164+
{ code: '<div role="menu" onClick={() => {}} />;', errors: [expectedError] },
165+
{ code: '<div role="menubar" onClick={() => {}} />;', errors: [expectedError] },
166+
{ code: '<div role="navigation" onClick={() => {}} />;', errors: [expectedError] },
167+
{ code: '<div role="note" onClick={() => {}} />;', errors: [expectedError] },
168+
{ code: '<div role="progressbar" onClick={() => {}} />;', errors: [expectedError] },
169+
{ code: '<div role="radiogroup" onClick={() => {}} />;', errors: [expectedError] },
170+
{ code: '<div role="range" onClick={() => {}} />;', errors: [expectedError] },
171+
{ code: '<div role="region" onClick={() => {}} />;', errors: [expectedError] },
172+
{ code: '<div role="roletype" onClick={() => {}} />;', errors: [expectedError] },
173+
{ code: '<div role="row" onClick={() => {}} />;', errors: [expectedError] },
174+
{ code: '<div role="rowgroup" onClick={() => {}} />;', errors: [expectedError] },
175+
{ code: '<div role="search" onClick={() => {}} />;', errors: [expectedError] },
176+
{ code: '<div role="section" onClick={() => {}} />;', errors: [expectedError] },
177+
{ code: '<div role="sectionhead" onClick={() => {}} />;', errors: [expectedError] },
178+
{ code: '<div role="select" onClick={() => {}} />;', errors: [expectedError] },
179+
{ code: '<div role="separator" onClick={() => {}} />;', errors: [expectedError] },
180+
{ code: '<div role="scrollbar" onClick={() => {}} />;', errors: [expectedError] },
181+
{ code: '<div role="status" onClick={() => {}} />;', errors: [expectedError] },
182+
{ code: '<div role="structure" onClick={() => {}} />;', errors: [expectedError] },
183+
{ code: '<div role="table" onClick={() => {}} />;', errors: [expectedError] },
184+
{ code: '<div role="tablist" onClick={() => {}} />;', errors: [expectedError] },
185+
{ code: '<div role="tabpanel" onClick={() => {}} />;', errors: [expectedError] },
186+
{ code: '<div role="term" onClick={() => {}} />;', errors: [expectedError] },
187+
{ code: '<div role="timer" onClick={() => {}} />;', errors: [expectedError] },
188+
{ code: '<div role="toolbar" onClick={() => {}} />;', errors: [expectedError] },
189+
{ code: '<div role="tooltip" onClick={() => {}} />;', errors: [expectedError] },
190+
{ code: '<div role="tree" onClick={() => {}} />;', errors: [expectedError] },
191+
{ code: '<div role="treegrid" onClick={() => {}} />;', errors: [expectedError] },
192+
{ code: '<div role="widget" onClick={() => {}} />;', errors: [expectedError] },
193+
{ code: '<div role="window" onClick={() => {}} />;', errors: [expectedError] },
65194
].map(parserOptionsMapper),
66195
});

__tests__/src/util/isNonInteractiveElement-test.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ import {
88
genNonInteractiveElements,
99
} from '../../../__mocks__/genInteractives';
1010

11+
const genElementSymbol = (openingElement) => {
12+
return openingElement.name.name + (
13+
(openingElement.attributes.length > 0)
14+
? `${openingElement.attributes.map(
15+
attr => `[${attr.name.name}=\"${attr.value.value}\"]` ).join('')
16+
}`
17+
: ''
18+
);
19+
};
20+
1121
describe('isNonInteractiveElement', () => {
1222
describe('JSX Components (no tagName)', () => {
1323
it('should identify them as interactive elements', () => {
@@ -18,7 +28,7 @@ describe('isNonInteractiveElement', () => {
1828
describe('non-interactive elements', () => {
1929
genNonInteractiveElements().forEach(
2030
({ openingElement }) => {
21-
it(`should identify \`${openingElement.name.name}\` as a non-interactive element`, () => {
31+
it(`should identify \`${genElementSymbol(openingElement)}\` as a non-interactive element`, () => {
2232
expect(isNonInteractiveElement(
2333
elementType(openingElement),
2434
openingElement.attributes,
@@ -30,7 +40,7 @@ describe('isNonInteractiveElement', () => {
3040
describe('interactive elements', () => {
3141
genInteractiveElements().forEach(
3242
({ openingElement }) => {
33-
it(`should not identify \`${openingElement.name.name}\` as a non-interactive element`, () => {
43+
it(`should NOT identify \`${genElementSymbol(openingElement)}\` as a non-interactive element`, () => {
3444
expect(isNonInteractiveElement(
3545
elementType(openingElement),
3646
openingElement.attributes,
@@ -42,7 +52,7 @@ describe('isNonInteractiveElement', () => {
4252
describe('indeterminate elements', () => {
4353
genIndeterminantInteractiveElements().forEach(
4454
({ openingElement }) => {
45-
it(`should not identify \`${openingElement.name.name}\` as a non-interactive element`, () => {
55+
it(`should NOT identify \`${openingElement.name.name}\` as a non-interactive element`, () => {
4656
expect(isNonInteractiveElement(
4757
elementType(openingElement),
4858
openingElement.attributes,

src/rules/no-static-element-interactions.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
/**
22
* @fileoverview Enforce non-interactive elements have no interactive handlers.
33
* @author Ethan Cohen
4+
* @flow
45
*/
56

67
// ----------------------------------------------------------------------------
78
// Rule Definition
89
// ----------------------------------------------------------------------------
910

10-
import { hasAnyProp, elementType } from 'jsx-ast-utils';
11+
import {
12+
elementType,
13+
getLiteralPropValue,
14+
getProp,
15+
hasAnyProp,
16+
} from 'jsx-ast-utils';
1117
import { generateObjSchema } from '../util/schemas';
1218
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
1319
import isInteractiveElement from '../util/isInteractiveElement';
20+
import isNonInteractiveElement from '../util/isNonInteractiveElement';
1421

1522
const errorMessage =
1623
'Visible, non-interactive elements should not have mouse or keyboard event listeners';
@@ -36,11 +43,27 @@ module.exports = {
3643
'onkeypress',
3744
];
3845

46+
const hasInteractiveProps = hasAnyProp(props, interactiveProps);
47+
3948
if (isHiddenFromScreenReader(type, props)) {
4049
return;
50+
} else if (
51+
['presentation', 'none'].indexOf(
52+
getLiteralPropValue(getProp(props, 'role'))
53+
) > -1
54+
) {
55+
// Presentation is an intentional signal from the author that this
56+
// element is not meant to be perceivable. For example, a click screen
57+
// to close a dialog .
58+
return;
4159
} else if (isInteractiveElement(type, props)) {
4260
return;
43-
} else if (hasAnyProp(props, interactiveProps) === false) {
61+
} else if (!hasInteractiveProps) {
62+
return;
63+
} else if (
64+
hasInteractiveProps
65+
&& !isNonInteractiveElement(type, props)
66+
) {
4467
return;
4568
}
4669

0 commit comments

Comments
 (0)