|
8 | 8 | // Requirements
|
9 | 9 | // -----------------------------------------------------------------------------
|
10 | 10 |
|
| 11 | +import includes from 'array-includes'; |
11 | 12 | import { RuleTester } from 'eslint';
|
| 13 | +import { |
| 14 | + eventHandlers, |
| 15 | + eventHandlersByType, |
| 16 | +} from 'jsx-ast-utils'; |
| 17 | +import { configs } from '../../../src/index'; |
12 | 18 | import parserOptionsMapper from '../../__util__/parserOptionsMapper';
|
13 | 19 | import rule from '../../../src/rules/interactive-supports-focus';
|
| 20 | +import ruleOptionsMapperFactory from '../../__util__/ruleOptionsMapperFactory'; |
14 | 21 |
|
15 | 22 | // -----------------------------------------------------------------------------
|
16 | 23 | // Tests
|
17 | 24 | // -----------------------------------------------------------------------------
|
18 | 25 |
|
19 | 26 | const ruleTester = new RuleTester();
|
20 | 27 |
|
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 | +} |
25 | 34 |
|
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, { |
27 | 198 | 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 | +}); |
99 | 214 |
|
| 215 | +ruleTester.run(`${ruleName}:strict`, rule, { |
| 216 | + valid: [ |
| 217 | + ...alwaysValid, |
| 218 | + ...passReducer(interactiveRoles), |
| 219 | + ] |
| 220 | + .map(ruleOptionsMapperFactory(strictOptions)) |
| 221 | + .map(parserOptionsMapper), |
100 | 222 | 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), |
145 | 231 | });
|
0 commit comments