Skip to content

Commit a61c7e7

Browse files
committed
Improve the interactive-supports-focus test to provide contextualized error messages
1 parent 9e9e406 commit a61c7e7

File tree

4 files changed

+365
-140
lines changed

4 files changed

+365
-140
lines changed

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

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

226+
ruleTester.run(`${ruleName}:strict`, rule, {
227+
valid: [
228+
...alwaysValid,
229+
...passReducer(
230+
interactiveRoles,
231+
eventHandlers.filter(handler => !includes(triggeringHandlers, handler)),
232+
codeTemplate,
233+
),
234+
...passReducer(
235+
interactiveRoles.filter(role => !includes(strictRoles, role)),
236+
eventHandlers.filter(handler => includes(triggeringHandlers, handler)),
237+
tabindexTemplate,
238+
),
239+
]
240+
.map(ruleOptionsMapperFactory(strictOptions))
241+
.map(parserOptionsMapper),
100242
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),
243+
...failReducer(strictRoles, triggeringHandlers, tabbableTemplate),
244+
...failReducer(
245+
interactiveRoles.filter(role => !includes(strictRoles, role)),
246+
triggeringHandlers,
247+
focusableTemplate,
248+
),
249+
]
250+
.map(ruleOptionsMapperFactory(strictOptions))
251+
.map(parserOptionsMapper),
145252
});

0 commit comments

Comments
 (0)