Skip to content

Commit 4caf285

Browse files
committed
Improve the interactive-supports-focus test to provide contextualized error messages
1 parent 577101e commit 4caf285

File tree

4 files changed

+370
-138
lines changed

4 files changed

+370
-138
lines changed

__tests__/src/rules/interactive-supports-focus-test.js

Lines changed: 206 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -8,138 +8,224 @@
88
// Requirements
99
// -----------------------------------------------------------------------------
1010

11+
import includes from 'array-includes';
1112
import { RuleTester } from 'eslint';
13+
import {
14+
eventHandlers,
15+
eventHandlersByType,
16+
} from 'jsx-ast-utils';
17+
import { configs } from '../../../src/index';
1218
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
1319
import rule from '../../../src/rules/interactive-supports-focus';
20+
import ruleOptionsMapperFactory from '../../__util__/ruleOptionsMapperFactory';
1421

1522
// -----------------------------------------------------------------------------
1623
// Tests
1724
// -----------------------------------------------------------------------------
1825

1926
const ruleTester = new RuleTester();
2027

21-
const expectedError = {
22-
message: 'Elements with interactive roles must be focusable.',
23-
type: 'JSXOpeningElement',
24-
};
28+
function template(strings, ...keys) {
29+
return (...values) => keys.reduce(
30+
(acc, k, i) => acc + values[k] + strings[i + 1],
31+
strings[0],
32+
);
33+
}
2534

26-
ruleTester.run('interactive-supports-focus', rule, {
35+
const ruleName = 'interactive-supports-focus';
36+
const type = 'JSXOpeningElement';
37+
const codeTemplate = template`<${0} role="${1}" ${2}={() => void 0} />`;
38+
const tabbableTemplate = template`Elements with the '${0}' interactive role must be tabbable.`;
39+
const focusableTemplate = template`Elements with the '${0}' interactive role must be focusable.`;
40+
41+
const recommendedOptions =
42+
(configs.recommended.rules[`jsx-a11y/${ruleName}`][1] || {});
43+
44+
const strictOptions =
45+
(configs.strict.rules[`jsx-a11y/${ruleName}`][1] || {});
46+
47+
const alwaysValid = [
48+
{ code: '<div />' },
49+
{ code: '<div aria-hidden onClick={() => void 0} />' },
50+
{ code: '<div aria-hidden={true == true} onClick={() => void 0} />' },
51+
{ code: '<div aria-hidden={true === true} onClick={() => void 0} />' },
52+
{ code: '<div aria-hidden={hidden !== false} onClick={() => void 0} />' },
53+
{ code: '<div aria-hidden={hidden != false} onClick={() => void 0} />' },
54+
{ code: '<div aria-hidden={1 < 2} onClick={() => void 0} />' },
55+
{ code: '<div aria-hidden={1 <= 2} onClick={() => void 0} />' },
56+
{ code: '<div aria-hidden={2 > 1} onClick={() => void 0} />' },
57+
{ code: '<div aria-hidden={2 >= 1} onClick={() => void 0} />' },
58+
{ code: '<div onClick={() => void 0} />;' },
59+
{ code: '<div onClick={() => void 0} tabIndex={undefined} />;' },
60+
{ code: '<div onClick={() => void 0} tabIndex="bad" />;' },
61+
{ code: '<div onClick={() => void 0} role={undefined} />;' },
62+
{ code: '<div role="section" onClick={() => void 0} />' },
63+
{ code: '<div onClick={() => void 0} aria-hidden={false} />;' },
64+
{ code: '<div onClick={() => void 0} {...props} />;' },
65+
{ code: '<input type="text" onClick={() => void 0} />' },
66+
{ code: '<input type="hidden" onClick={() => void 0} tabIndex="-1" />' },
67+
{ code: '<input type="hidden" onClick={() => void 0} tabIndex={-1} />' },
68+
{ code: '<input onClick={() => void 0} />' },
69+
{ code: '<input onClick={() => void 0} role="combobox" />' },
70+
{ code: '<button onClick={() => void 0} className="foo" />' },
71+
{ code: '<option onClick={() => void 0} className="foo" />' },
72+
{ code: '<select onClick={() => void 0} className="foo" />' },
73+
{ code: '<area href="#" onClick={() => void 0} className="foo" />' },
74+
{ code: '<area onClick={() => void 0} className="foo" />' },
75+
{ code: '<textarea onClick={() => void 0} className="foo" />' },
76+
{ code: '<a onClick="showNextPage();">Next page</a>' },
77+
{ code: '<a onClick="showNextPage();" tabIndex={undefined}>Next page</a>' },
78+
{ code: '<a onClick="showNextPage();" tabIndex="bad">Next page</a>' },
79+
{ code: '<a onClick={() => void 0} />' },
80+
{ code: '<a tabIndex="0" onClick={() => void 0} />' },
81+
{ code: '<a tabIndex={dynamicTabIndex} onClick={() => void 0} />' },
82+
{ code: '<a tabIndex={0} onClick={() => void 0} />' },
83+
{ code: '<a role="button" href="#" onClick={() => void 0} />' },
84+
{ code: '<a onClick={() => void 0} href="http://x.y.z" />' },
85+
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />' },
86+
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex={0} />' },
87+
{ code: '<a onClick={() => void 0} href="http://x.y.z" role="button" />' },
88+
{ code: '<TestComponent onClick={doFoo} />' },
89+
{ code: '<input onClick={() => void 0} type="hidden" />;' },
90+
{ code: '<span onClick="submitForm();">Submit</span>' },
91+
{ code: '<span onClick="submitForm();" tabIndex={undefined}>Submit</span>' },
92+
{ code: '<span onClick="submitForm();" tabIndex="bad">Submit</span>' },
93+
{ code: '<span onClick="doSomething();" tabIndex="0">Click me!</span>' },
94+
{ code: '<span onClick="doSomething();" tabIndex={0}>Click me!</span>' },
95+
{ code: '<span onClick="doSomething();" tabIndex="-1">Click me too!</span>' },
96+
{
97+
code: '<a href="javascript:void(0);" onClick="doSomething();">Click ALL the things!</a>',
98+
},
99+
{ code: '<section onClick={() => void 0} />;' },
100+
{ code: '<main onClick={() => void 0} />;' },
101+
{ code: '<article onClick={() => void 0} />;' },
102+
{ code: '<header onClick={() => void 0} />;' },
103+
{ code: '<footer onClick={() => void 0} />;' },
104+
{ code: '<div role="button" tabIndex="0" onClick={() => void 0} />' },
105+
{ code: '<div role="checkbox" tabIndex="0" onClick={() => void 0} />' },
106+
{ code: '<div role="link" tabIndex="0" onClick={() => void 0} />' },
107+
{ code: '<div role="menuitem" tabIndex="0" onClick={() => void 0} />' },
108+
{ code: '<div role="menuitemcheckbox" tabIndex="0" onClick={() => void 0} />' },
109+
{ code: '<div role="menuitemradio" tabIndex="0" onClick={() => void 0} />' },
110+
{ code: '<div role="option" tabIndex="0" onClick={() => void 0} />' },
111+
{ code: '<div role="radio" tabIndex="0" onClick={() => void 0} />' },
112+
{ code: '<div role="spinbutton" tabIndex="0" onClick={() => void 0} />' },
113+
{ code: '<div role="switch" tabIndex="0" onClick={() => void 0} />' },
114+
{ code: '<div role="tab" tabIndex="0" onClick={() => void 0} />' },
115+
{ code: '<div role="textbox" tabIndex="0" onClick={() => void 0} />' },
116+
{ code: '<Foo.Bar onClick={() => void 0} aria-hidden={false} />;' },
117+
{ code: '<Input onClick={() => void 0} type="hidden" />;' },
118+
];
119+
120+
const interactiveRoles = [
121+
'button',
122+
'checkbox',
123+
'link',
124+
'gridcell',
125+
'menuitem',
126+
'menuitemcheckbox',
127+
'menuitemradio',
128+
'option',
129+
'radio',
130+
'searchbox',
131+
'slider',
132+
'spinbutton',
133+
'switch',
134+
'tab',
135+
'textbox',
136+
'treeitem',
137+
];
138+
139+
const recommendedRoles = [
140+
'button',
141+
'checkbox',
142+
'link',
143+
'searchbox',
144+
'spinbutton',
145+
'switch',
146+
'textbox',
147+
];
148+
149+
const strictRoles = [
150+
'button',
151+
'checkbox',
152+
'link',
153+
'progressbar',
154+
'searchbox',
155+
'slider',
156+
'spinbutton',
157+
'switch',
158+
'textbox',
159+
];
160+
161+
const staticElements = [
162+
'div',
163+
];
164+
165+
const triggeringHandlers = [
166+
...eventHandlersByType.mouse,
167+
...eventHandlersByType.keyboard,
168+
];
169+
170+
const passReducer = roles =>
171+
staticElements.reduce((elementAcc, element) =>
172+
elementAcc.concat(roles.reduce((roleAcc, role) =>
173+
roleAcc.concat(eventHandlers
174+
.filter(handler => !includes(triggeringHandlers, handler))
175+
.map(handler => ({
176+
code: codeTemplate(element, role, handler),
177+
}),
178+
),
179+
), []),
180+
), []);
181+
182+
const failReducer = (roles, messageTemplate) =>
183+
staticElements.reduce((elementAcc, element) =>
184+
elementAcc.concat(roles.reduce((roleAcc, role) =>
185+
roleAcc.concat(triggeringHandlers
186+
.map(handler => ({
187+
code: codeTemplate(element, role, handler),
188+
errors: [{
189+
type,
190+
message: messageTemplate(role),
191+
}],
192+
}),
193+
),
194+
), []),
195+
), []);
196+
197+
ruleTester.run(`${ruleName}:recommended`, rule, {
27198
valid: [
28-
{ code: '<div />' },
29-
{ code: '<div aria-hidden onClick={() => void 0} />' },
30-
{ code: '<div aria-hidden={true == true} onClick={() => void 0} />' },
31-
{ code: '<div aria-hidden={true === true} onClick={() => void 0} />' },
32-
{ code: '<div aria-hidden={hidden !== false} onClick={() => void 0} />' },
33-
{ code: '<div aria-hidden={hidden != false} onClick={() => void 0} />' },
34-
{ code: '<div aria-hidden={1 < 2} onClick={() => void 0} />' },
35-
{ code: '<div aria-hidden={1 <= 2} onClick={() => void 0} />' },
36-
{ code: '<div aria-hidden={2 > 1} onClick={() => void 0} />' },
37-
{ code: '<div aria-hidden={2 >= 1} onClick={() => void 0} />' },
38-
{ code: '<div onClick={() => void 0} />;' },
39-
{ code: '<div onClick={() => void 0} tabIndex={undefined} />;' },
40-
{ code: '<div onClick={() => void 0} tabIndex="bad" />;' },
41-
{ code: '<div onClick={() => void 0} role={undefined} />;' },
42-
{ code: '<div role="section" onClick={() => void 0} />' },
43-
{ code: '<div onClick={() => void 0} aria-hidden={false} />;' },
44-
{ code: '<div onClick={() => void 0} {...props} />;' },
45-
{ code: '<input type="text" onClick={() => void 0} />' },
46-
{ code: '<input type="hidden" onClick={() => void 0} tabIndex="-1" />' },
47-
{ code: '<input type="hidden" onClick={() => void 0} tabIndex={-1} />' },
48-
{ code: '<input onClick={() => void 0} />' },
49-
{ code: '<input onClick={() => void 0} role="combobox" />' },
50-
{ code: '<button onClick={() => void 0} className="foo" />' },
51-
{ code: '<option onClick={() => void 0} className="foo" />' },
52-
{ code: '<select onClick={() => void 0} className="foo" />' },
53-
{ code: '<area href="#" onClick={() => void 0} className="foo" />' },
54-
{ code: '<area onClick={() => void 0} className="foo" />' },
55-
{ code: '<textarea onClick={() => void 0} className="foo" />' },
56-
{ code: '<a onClick="showNextPage();">Next page</a>' },
57-
{ code: '<a onClick="showNextPage();" tabIndex={undefined}>Next page</a>' },
58-
{ code: '<a onClick="showNextPage();" tabIndex="bad">Next page</a>' },
59-
{ code: '<a onClick={() => void 0} />' },
60-
{ code: '<a tabIndex="0" onClick={() => void 0} />' },
61-
{ code: '<a tabIndex={dynamicTabIndex} onClick={() => void 0} />' },
62-
{ code: '<a tabIndex={0} onClick={() => void 0} />' },
63-
{ code: '<a role="button" href="#" onClick={() => void 0} />' },
64-
{ code: '<a onClick={() => void 0} href="http://x.y.z" />' },
65-
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex="0" />' },
66-
{ code: '<a onClick={() => void 0} href="http://x.y.z" tabIndex={0} />' },
67-
{ code: '<a onClick={() => void 0} href="http://x.y.z" role="button" />' },
68-
{ code: '<TestComponent onClick={doFoo} />' },
69-
{ code: '<input onClick={() => void 0} type="hidden" />;' },
70-
{ code: '<span onClick="submitForm();">Submit</span>', errors: [expectedError] },
71-
{ code: '<span onClick="submitForm();" tabIndex={undefined}>Submit</span>' },
72-
{ code: '<span onClick="submitForm();" tabIndex="bad">Submit</span>' },
73-
{ code: '<span onClick="doSomething();" tabIndex="0">Click me!</span>' },
74-
{ code: '<span onClick="doSomething();" tabIndex={0}>Click me!</span>' },
75-
{ code: '<span onClick="doSomething();" tabIndex="-1">Click me too!</span>' },
76-
{
77-
code: '<a href="javascript:void(0);" onClick="doSomething();">Click ALL the things!</a>',
78-
},
79-
{ code: '<section onClick={() => void 0} />;' },
80-
{ code: '<main onClick={() => void 0} />;' },
81-
{ code: '<article onClick={() => void 0} />;' },
82-
{ code: '<header onClick={() => void 0} />;' },
83-
{ code: '<footer onClick={() => void 0} />;' },
84-
{ code: '<div role="button" tabIndex="0" onClick={() => void 0} />' },
85-
{ code: '<div role="checkbox" tabIndex="0" onClick={() => void 0} />' },
86-
{ code: '<div role="link" tabIndex="0" onClick={() => void 0} />' },
87-
{ code: '<div role="menuitem" tabIndex="0" onClick={() => void 0} />' },
88-
{ code: '<div role="menuitemcheckbox" tabIndex="0" onClick={() => void 0} />' },
89-
{ code: '<div role="menuitemradio" tabIndex="0" onClick={() => void 0} />' },
90-
{ code: '<div role="option" tabIndex="0" onClick={() => void 0} />' },
91-
{ code: '<div role="radio" tabIndex="0" onClick={() => void 0} />' },
92-
{ code: '<div role="spinbutton" tabIndex="0" onClick={() => void 0} />' },
93-
{ code: '<div role="switch" tabIndex="0" onClick={() => void 0} />' },
94-
{ code: '<div role="tab" tabIndex="0" onClick={() => void 0} />' },
95-
{ code: '<div role="textbox" tabIndex="0" onClick={() => void 0} />' },
96-
{ code: '<Foo.Bar onClick={() => void 0} aria-hidden={false} />;' },
97-
{ code: '<Input onClick={() => void 0} type="hidden" />;' },
98-
].map(parserOptionsMapper),
199+
...alwaysValid,
200+
...passReducer(interactiveRoles),
201+
]
202+
.map(ruleOptionsMapperFactory(recommendedOptions))
203+
.map(parserOptionsMapper),
204+
invalid: [
205+
...failReducer(recommendedRoles, tabbableTemplate),
206+
...failReducer(
207+
interactiveRoles.filter(role => !includes(recommendedRoles, role)),
208+
focusableTemplate,
209+
),
210+
]
211+
.map(ruleOptionsMapperFactory(recommendedOptions))
212+
.map(parserOptionsMapper),
213+
});
99214

215+
ruleTester.run(`${ruleName}:strict`, rule, {
216+
valid: [
217+
...alwaysValid,
218+
...passReducer(interactiveRoles),
219+
]
220+
.map(ruleOptionsMapperFactory(strictOptions))
221+
.map(parserOptionsMapper),
100222
invalid: [
101-
// onClick
102-
{ code: '<span role="button" onClick={() => void 0} />', errors: [expectedError] },
103-
{ code: '<a role="button" onClick={() => void 0} />', errors: [expectedError] },
104-
{ code: '<div role="button" onClick={() => void 0} />', errors: [expectedError] },
105-
{ code: '<div role="checkbox" onClick={() => void 0} />', errors: [expectedError] },
106-
{ code: '<div role="link" onClick={() => void 0} />', errors: [expectedError] },
107-
{ code: '<div role="gridcell" onClick={() => void 0} />', errors: [expectedError] },
108-
{ code: '<div role="menuitem" onClick={() => void 0} />', errors: [expectedError] },
109-
{ code: '<div role="menuitemcheckbox" onClick={() => void 0} />', errors: [expectedError] },
110-
{ code: '<div role="menuitemradio" onClick={() => void 0} />', errors: [expectedError] },
111-
{ code: '<div role="option" onClick={() => void 0} />', errors: [expectedError] },
112-
{ code: '<div role="radio" onClick={() => void 0} />', errors: [expectedError] },
113-
{ code: '<div role="searchbox" onClick={() => void 0} />', errors: [expectedError] },
114-
{ code: '<div role="slider" onClick={() => void 0} />', errors: [expectedError] },
115-
{ code: '<div role="spinbutton" onClick={() => void 0} />', errors: [expectedError] },
116-
{ code: '<div role="switch" onClick={() => void 0} />', errors: [expectedError] },
117-
{ code: '<div role="tab" onClick={() => void 0} />', errors: [expectedError] },
118-
{ code: '<div role="textbox" onClick={() => void 0} />', errors: [expectedError] },
119-
{ code: '<div role="treeitem" onClick={() => void 0} />', errors: [expectedError] },
120-
// onKeyPress
121-
{ code: '<span role="button" onKeyPress={() => void 0} />', errors: [expectedError] },
122-
{ code: '<a role="button" onKeyPress={() => void 0} />', errors: [expectedError] },
123-
{ code: '<div role="button" onKeyPress={() => void 0} />', errors: [expectedError] },
124-
{ code: '<div role="checkbox" onKeyPress={() => void 0} />', errors: [expectedError] },
125-
{ code: '<div role="link" onKeyPress={() => void 0} />', errors: [expectedError] },
126-
{ code: '<div role="gridcell" onKeyPress={() => void 0} />', errors: [expectedError] },
127-
{ code: '<div role="menuitem" onKeyPress={() => void 0} />', errors: [expectedError] },
128-
{ code: '<div role="menuitemcheckbox" onKeyPress={() => void 0} />', errors: [expectedError] },
129-
{ code: '<div role="menuitemradio" onKeyPress={() => void 0} />', errors: [expectedError] },
130-
{ code: '<div role="option" onKeyPress={() => void 0} />', errors: [expectedError] },
131-
{ code: '<div role="radio" onKeyPress={() => void 0} />', errors: [expectedError] },
132-
{ code: '<div role="searchbox" onKeyPress={() => void 0} />', errors: [expectedError] },
133-
{ code: '<div role="slider" onKeyPress={() => void 0} />', errors: [expectedError] },
134-
{ code: '<div role="spinbutton" onKeyPress={() => void 0} />', errors: [expectedError] },
135-
{ code: '<div role="switch" onKeyPress={() => void 0} />', errors: [expectedError] },
136-
{ code: '<div role="tab" onKeyPress={() => void 0} />', errors: [expectedError] },
137-
{ code: '<div role="textbox" onKeyPress={() => void 0} />', errors: [expectedError] },
138-
{ code: '<div role="treeitem" onKeyPress={() => void 0} />', errors: [expectedError] },
139-
// Other interactive handlers
140-
{ code: '<div role="button" onKeyDown={() => void 0} />', errors: [expectedError] },
141-
{ code: '<div role="button" onKeyUp={() => void 0} />', errors: [expectedError] },
142-
{ code: '<div role="button" onMouseDown={() => void 0} />', errors: [expectedError] },
143-
{ code: '<div role="button" onMouseUp={() => void 0} />', errors: [expectedError] },
144-
].map(parserOptionsMapper),
223+
...failReducer(strictRoles, tabbableTemplate),
224+
...failReducer(
225+
interactiveRoles.filter(role => !includes(strictRoles, role)),
226+
focusableTemplate,
227+
),
228+
]
229+
.map(ruleOptionsMapperFactory(strictOptions))
230+
.map(parserOptionsMapper),
145231
});

0 commit comments

Comments
 (0)