Skip to content

Commit 4833376

Browse files
committed
[new rule] control-has-associated-label checks interactives for a label
1 parent e53906d commit 4833376

File tree

4 files changed

+432
-3
lines changed

4 files changed

+432
-3
lines changed
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
/* eslint-env jest */
2+
/**
3+
* @fileoverview Control elements must be associated with a text label
4+
* @author jessebeach
5+
*/
6+
7+
// -----------------------------------------------------------------------------
8+
// Requirements
9+
// -----------------------------------------------------------------------------
10+
11+
import { RuleTester } from 'eslint';
12+
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
13+
import rule from '../../../src/rules/control-has-associated-label';
14+
15+
// -----------------------------------------------------------------------------
16+
// Tests
17+
// -----------------------------------------------------------------------------
18+
19+
const ruleTester = new RuleTester();
20+
21+
const ruleName = 'control-has-associated-label';
22+
23+
const expectedError = {
24+
message: 'A control must be associated with a text label.',
25+
type: 'JSXOpeningElement',
26+
};
27+
28+
ruleTester.run(ruleName, rule, {
29+
valid: [
30+
// Custom Control Components
31+
{ code: '<CustomControl><span><span>Save</span></span></CustomControl>', options: [{ depth: 3, controlComponents: ['CustomControl'] }] },
32+
{ code: '<CustomControl><span><span label="Save"></span></span></CustomControl>', options: [{ depth: 3, controlComponents: ['CustomControl'], labelAttributes: ['label'] }] },
33+
// Interactive Elements
34+
{ code: '<button>Save</button>' },
35+
{ code: '<button><span>Save</span></button>' },
36+
{ code: '<button><span><span>Save</span></span></button>', options: [{ depth: 3 }] },
37+
{ code: '<button><span><span><span><span><span><span><span><span>Save</span></span></span></span></span></span></span></span></button>', options: [{ depth: 9 }] },
38+
{ code: '<button><img alt="Save" /></button>' },
39+
{ code: '<button aria-label="Save" />' },
40+
{ code: '<button><span aria-label="Save" /></button>' },
41+
{ code: '<button aria-labelledby="js_1" />' },
42+
{ code: '<button><span aria-labelledby="js_1" /></button>' },
43+
{ code: '<button>{sureWhyNot}</button>' },
44+
{ code: '<button><span><span label="Save"></span></span></button>', options: [{ depth: 3, labelAttributes: ['label'] }] },
45+
{ code: '<a href="#">Save</a>' },
46+
{ code: '<area href="#">Save</area>' },
47+
{ code: '<audio>Save</audio>' },
48+
{ code: '<canvas>Save</canvas>' },
49+
{ code: '<label>Save</label>' },
50+
{ code: '<link>Save</link>' },
51+
{ code: '<menuitem>Save</menuitem>' },
52+
{ code: '<option>Save</option>' },
53+
{ code: '<textarea>Save</textarea>' },
54+
{ code: '<video>Save</video>' },
55+
// Inputs. Ignore them because they might get a label from a wrapping label element.
56+
{ code: '<input />' },
57+
{ code: '<input type="button" />' },
58+
{ code: '<input type="checkbox" />' },
59+
{ code: '<input type="color" />' },
60+
{ code: '<input type="date" />' },
61+
{ code: '<input type="datetime" />' },
62+
{ code: '<input type="email" />' },
63+
{ code: '<input type="file" />' },
64+
{ code: '<input type="image" />' },
65+
{ code: '<input type="month" />' },
66+
{ code: '<input type="number" />' },
67+
{ code: '<input type="password" />' },
68+
{ code: '<input type="radio" />' },
69+
{ code: '<input type="range" />' },
70+
{ code: '<input type="reset" />' },
71+
{ code: '<input type="search" />' },
72+
{ code: '<input type="submit" />' },
73+
{ code: '<input type="tel" />' },
74+
{ code: '<input type="text" />' },
75+
{ code: '<input type="time" />' },
76+
{ code: '<input type="url" />' },
77+
{ code: '<input type="week" />' },
78+
// Marginal interactive elements. It is difficult to insist that these
79+
// elements contain a text label.
80+
{ code: '<embed />' },
81+
{ code: '<td />' },
82+
{ code: '<th />' },
83+
{ code: '<tr />' },
84+
// Interactive Roles
85+
{ code: '<div role="button">Save</div>' },
86+
{ code: '<div role="checkbox">Save</div>' },
87+
{ code: '<div role="columnheader">Save</div>' },
88+
{ code: '<div role="combobox">Save</div>' },
89+
{ code: '<div role="grid">Save</div>' },
90+
{ code: '<div role="gridcell">Save</div>' },
91+
{ code: '<div role="link">Save</div>' },
92+
{ code: '<div role="listbox">Save</div>' },
93+
{ code: '<div role="menu">Save</div>' },
94+
{ code: '<div role="menubar">Save</div>' },
95+
{ code: '<div role="menuitem">Save</div>' },
96+
{ code: '<div role="menuitemcheckbox">Save</div>' },
97+
{ code: '<div role="menuitemradio">Save</div>' },
98+
{ code: '<div role="option">Save</div>' },
99+
{ code: '<div role="progressbar">Save</div>' },
100+
{ code: '<div role="radio">Save</div>' },
101+
{ code: '<div role="radiogroup">Save</div>' },
102+
{ code: '<div role="row">Save</div>' },
103+
{ code: '<div role="rowheader">Save</div>' },
104+
{ code: '<div role="searchbox">Save</div>' },
105+
{ code: '<div role="slider">Save</div>' },
106+
{ code: '<div role="spinbutton">Save</div>' },
107+
{ code: '<div role="switch">Save</div>' },
108+
{ code: '<div role="tab">Save</div>' },
109+
{ code: '<div role="tablist">Save</div>' },
110+
{ code: '<div role="textbox">Save</div>' },
111+
{ code: '<div role="toolbar">Save</div>' },
112+
{ code: '<div role="tree">Save</div>' },
113+
{ code: '<div role="treegrid">Save</div>' },
114+
{ code: '<div role="treeitem">Save</div>' },
115+
{ code: '<div role="button" aria-label="Save" />' },
116+
{ code: '<div role="checkbox" aria-label="Save" />' },
117+
{ code: '<div role="columnheader" aria-label="Save" />' },
118+
{ code: '<div role="combobox" aria-label="Save" />' },
119+
{ code: '<div role="grid" aria-label="Save" />' },
120+
{ code: '<div role="gridcell" aria-label="Save" />' },
121+
{ code: '<div role="link" aria-label="Save" />' },
122+
{ code: '<div role="listbox" aria-label="Save" />' },
123+
{ code: '<div role="menu" aria-label="Save" />' },
124+
{ code: '<div role="menubar" aria-label="Save" />' },
125+
{ code: '<div role="menuitem" aria-label="Save" />' },
126+
{ code: '<div role="menuitemcheckbox" aria-label="Save" />' },
127+
{ code: '<div role="menuitemradio" aria-label="Save" />' },
128+
{ code: '<div role="option" aria-label="Save" />' },
129+
{ code: '<div role="progressbar" aria-label="Save" />' },
130+
{ code: '<div role="radio" aria-label="Save" />' },
131+
{ code: '<div role="radiogroup" aria-label="Save" />' },
132+
{ code: '<div role="row" aria-label="Save" />' },
133+
{ code: '<div role="rowheader" aria-label="Save" />' },
134+
{ code: '<div role="searchbox" aria-label="Save" />' },
135+
{ code: '<div role="slider" aria-label="Save" />' },
136+
{ code: '<div role="spinbutton" aria-label="Save" />' },
137+
{ code: '<div role="switch" aria-label="Save" />' },
138+
{ code: '<div role="tab" aria-label="Save" />' },
139+
{ code: '<div role="tablist" aria-label="Save" />' },
140+
{ code: '<div role="textbox" aria-label="Save" />' },
141+
{ code: '<div role="toolbar" aria-label="Save" />' },
142+
{ code: '<div role="tree" aria-label="Save" />' },
143+
{ code: '<div role="treegrid" aria-label="Save" />' },
144+
{ code: '<div role="treeitem" aria-label="Save" />' },
145+
{ code: '<div role="button" aria-labelledby="js_1" />' },
146+
{ code: '<div role="checkbox" aria-labelledby="js_1" />' },
147+
{ code: '<div role="columnheader" aria-labelledby="js_1" />' },
148+
{ code: '<div role="combobox" aria-labelledby="js_1" />' },
149+
{ code: '<div role="grid" aria-labelledby="js_1" />' },
150+
{ code: '<div role="gridcell" aria-labelledby="js_1" />' },
151+
{ code: '<div role="link" aria-labelledby="js_1" />' },
152+
{ code: '<div role="listbox" aria-labelledby="js_1" />' },
153+
{ code: '<div role="menu" aria-labelledby="js_1" />' },
154+
{ code: '<div role="menubar" aria-labelledby="js_1" />' },
155+
{ code: '<div role="menuitem" aria-labelledby="js_1" />' },
156+
{ code: '<div role="menuitemcheckbox" aria-labelledby="js_1" />' },
157+
{ code: '<div role="menuitemradio" aria-labelledby="js_1" />' },
158+
{ code: '<div role="option" aria-labelledby="js_1" />' },
159+
{ code: '<div role="progressbar" aria-labelledby="js_1" />' },
160+
{ code: '<div role="radio" aria-labelledby="js_1" />' },
161+
{ code: '<div role="radiogroup" aria-labelledby="js_1" />' },
162+
{ code: '<div role="row" aria-labelledby="js_1" />' },
163+
{ code: '<div role="rowheader" aria-labelledby="js_1" />' },
164+
{ code: '<div role="searchbox" aria-labelledby="js_1" />' },
165+
{ code: '<div role="slider" aria-labelledby="js_1" />' },
166+
{ code: '<div role="spinbutton" aria-labelledby="js_1" />' },
167+
{ code: '<div role="switch" aria-labelledby="js_1" />' },
168+
{ code: '<div role="tab" aria-labelledby="js_1" />' },
169+
{ code: '<div role="tablist" aria-labelledby="js_1" />' },
170+
{ code: '<div role="textbox" aria-labelledby="js_1" />' },
171+
{ code: '<div role="toolbar" aria-labelledby="Save" />' },
172+
{ code: '<div role="tree" aria-labelledby="js_1" />' },
173+
{ code: '<div role="treegrid" aria-labelledby="js_1" />' },
174+
{ code: '<div role="treeitem" aria-labelledby="js_1" />' },
175+
// Non-interactive Elements
176+
{ code: '<abbr />' },
177+
{ code: '<article />' },
178+
{ code: '<blockquote />' },
179+
{ code: '<br />' },
180+
{ code: '<caption />' },
181+
{ code: '<dd />' },
182+
{ code: '<details />' },
183+
{ code: '<dfn />' },
184+
{ code: '<dialog />' },
185+
{ code: '<dir />' },
186+
{ code: '<dl />' },
187+
{ code: '<dt />' },
188+
{ code: '<fieldset />' },
189+
{ code: '<figcaption />' },
190+
{ code: '<figure />' },
191+
{ code: '<footer />' },
192+
{ code: '<form />' },
193+
{ code: '<frame />' },
194+
{ code: '<h1 />' },
195+
{ code: '<h2 />' },
196+
{ code: '<h3 />' },
197+
{ code: '<h4 />' },
198+
{ code: '<h5 />' },
199+
{ code: '<h6 />' },
200+
{ code: '<hr />' },
201+
{ code: '<iframe />' },
202+
{ code: '<img />' },
203+
{ code: '<legend />' },
204+
{ code: '<li />' },
205+
{ code: '<main />' },
206+
{ code: '<mark />' },
207+
{ code: '<marquee />' },
208+
{ code: '<menu />' },
209+
{ code: '<meter />' },
210+
{ code: '<nav />' },
211+
{ code: '<ol />' },
212+
{ code: '<p />' },
213+
{ code: '<pre />' },
214+
{ code: '<progress />' },
215+
{ code: '<ruby />' },
216+
{ code: '<section />' },
217+
{ code: '<table />' },
218+
{ code: '<tbody />' },
219+
{ code: '<td />' },
220+
{ code: '<tfoot />' },
221+
{ code: '<thead />' },
222+
{ code: '<time />' },
223+
{ code: '<ul />' },
224+
// Non-interactive Roles
225+
{ code: '<div role="alert" />' },
226+
{ code: '<div role="alertdialog" />' },
227+
{ code: '<div role="application" />' },
228+
{ code: '<div role="article" />' },
229+
{ code: '<div role="banner" />' },
230+
{ code: '<div role="cell" />' },
231+
{ code: '<div role="complementary" />' },
232+
{ code: '<div role="contentinfo" />' },
233+
{ code: '<div role="definition" />' },
234+
{ code: '<div role="dialog" />' },
235+
{ code: '<div role="directory" />' },
236+
{ code: '<div role="document" />' },
237+
{ code: '<div role="feed" />' },
238+
{ code: '<div role="figure" />' },
239+
{ code: '<div role="form" />' },
240+
{ code: '<div role="group" />' },
241+
{ code: '<div role="heading" />' },
242+
{ code: '<div role="img" />' },
243+
{ code: '<div role="list" />' },
244+
{ code: '<div role="listitem" />' },
245+
{ code: '<div role="log" />' },
246+
{ code: '<div role="main" />' },
247+
{ code: '<div role="marquee" />' },
248+
{ code: '<div role="math" />' },
249+
{ code: '<div role="navigation" />' },
250+
{ code: '<div role="none" />' },
251+
{ code: '<div role="note" />' },
252+
{ code: '<div role="presentation" />' },
253+
{ code: '<div role="region" />' },
254+
{ code: '<div role="rowgroup" />' },
255+
{ code: '<div role="scrollbar" />' },
256+
{ code: '<div role="search" />' },
257+
{ code: '<div role="separator" />' },
258+
{ code: '<div role="status" />' },
259+
{ code: '<div role="table" />' },
260+
{ code: '<div role="tabpanel" />' },
261+
{ code: '<div role="term" />' },
262+
{ code: '<div role="timer" />' },
263+
{ code: '<div role="tooltip" />' },
264+
].map(parserOptionsMapper),
265+
invalid: [
266+
{ code: '<button />', errors: [expectedError] },
267+
{ code: '<button><span /></button>', errors: [expectedError] },
268+
{ code: '<button><img /></button>', errors: [expectedError] },
269+
{ code: '<button><span title="This is not a real label" /></button>', errors: [expectedError] },
270+
{ code: '<button><span><span><span>Save</span></span></span></button>', options: [{ depth: 3 }], errors: [expectedError] },
271+
{ code: '<CustomControl><span><span></span></span></CustomControl>', options: [{ depth: 3, controlComponents: ['CustomControl'] }], errors: [expectedError] },
272+
{ code: '<a href="#" />', errors: [expectedError] },
273+
{ code: '<area href="#" />', errors: [expectedError] },
274+
{ code: '<audio />', errors: [expectedError] },
275+
{ code: '<canvas />', errors: [expectedError] },
276+
{ code: '<label />', errors: [expectedError] },
277+
{ code: '<link />', errors: [expectedError] },
278+
{ code: '<menuitem />', errors: [expectedError] },
279+
{ code: '<option />', errors: [expectedError] },
280+
{ code: '<textarea />', errors: [expectedError] },
281+
{ code: '<video />', errors: [expectedError] },
282+
// Interactive Roles
283+
{ code: '<div role="button" />', errors: [expectedError] },
284+
{ code: '<div role="checkbox" />', errors: [expectedError] },
285+
{ code: '<div role="columnheader" />', errors: [expectedError] },
286+
{ code: '<div role="combobox" />', errors: [expectedError] },
287+
{ code: '<div role="grid" />', errors: [expectedError] },
288+
{ code: '<div role="gridcell" />', errors: [expectedError] },
289+
{ code: '<div role="link" />', errors: [expectedError] },
290+
{ code: '<div role="listbox" />', errors: [expectedError] },
291+
{ code: '<div role="menu" />', errors: [expectedError] },
292+
{ code: '<div role="menubar" />', errors: [expectedError] },
293+
{ code: '<div role="menuitem" />', errors: [expectedError] },
294+
{ code: '<div role="menuitemcheckbox" />', errors: [expectedError] },
295+
{ code: '<div role="menuitemradio" />', errors: [expectedError] },
296+
{ code: '<div role="option" />', errors: [expectedError] },
297+
{ code: '<div role="progressbar" />', errors: [expectedError] },
298+
{ code: '<div role="radio" />', errors: [expectedError] },
299+
{ code: '<div role="radiogroup" />', errors: [expectedError] },
300+
{ code: '<div role="row" />', errors: [expectedError] },
301+
{ code: '<div role="rowheader" />', errors: [expectedError] },
302+
{ code: '<div role="searchbox" />', errors: [expectedError] },
303+
{ code: '<div role="slider" />', errors: [expectedError] },
304+
{ code: '<div role="spinbutton" />', errors: [expectedError] },
305+
{ code: '<div role="switch" />', errors: [expectedError] },
306+
{ code: '<div role="tab" />', errors: [expectedError] },
307+
{ code: '<div role="tablist" />', errors: [expectedError] },
308+
{ code: '<div role="textbox" />', errors: [expectedError] },
309+
{ code: '<div role="toolbar" />', errors: [expectedError] },
310+
{ code: '<div role="tree" />', errors: [expectedError] },
311+
{ code: '<div role="treegrid" />', errors: [expectedError] },
312+
{ code: '<div role="treeitem" />', errors: [expectedError] },
313+
].map(parserOptionsMapper),
314+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# control-has-associated-label
2+
3+
Write a useful explanation here!
4+
5+
### References
6+
7+
1.
8+
9+
## Rule details
10+
11+
This rule takes no arguments.
12+
13+
### Succeed
14+
```jsx
15+
<div />
16+
```
17+
18+
### Fail
19+
```jsx
20+
21+
```

src/index.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ module.exports = {
1212
'aria-role': require('./rules/aria-role'),
1313
'aria-unsupported-elements': require('./rules/aria-unsupported-elements'),
1414
'click-events-have-key-events': require('./rules/click-events-have-key-events'),
15+
'control-has-associated-label': require('./rules/control-has-associated-label'),
1516
'heading-has-content': require('./rules/heading-has-content'),
1617
'html-has-lang': require('./rules/html-has-lang'),
1718
'iframe-has-title': require('./rules/iframe-has-title'),
1819
'img-redundant-alt': require('./rules/img-redundant-alt'),
1920
'interactive-supports-focus': require('./rules/interactive-supports-focus'),
20-
'label-has-for': require('./rules/label-has-for'),
2121
'label-has-associated-control': require('./rules/label-has-associated-control'),
22+
'label-has-for': require('./rules/label-has-for'),
2223
lang: require('./rules/lang'),
2324
'media-has-caption': require('./rules/media-has-caption'),
2425
'mouse-events-have-key-events': require('./rules/mouse-events-have-key-events'),
@@ -58,6 +59,7 @@ module.exports = {
5859
'jsx-a11y/aria-role': 'error',
5960
'jsx-a11y/aria-unsupported-elements': 'error',
6061
'jsx-a11y/click-events-have-key-events': 'error',
62+
'jsx-a11y/control-has-associated-label': 'off',
6163
'jsx-a11y/heading-has-content': 'error',
6264
'jsx-a11y/html-has-lang': 'error',
6365
'jsx-a11y/iframe-has-title': 'error',
@@ -76,14 +78,13 @@ module.exports = {
7678
],
7779
},
7880
],
79-
'jsx-a11y/label-has-for': 'error',
8081
'jsx-a11y/label-has-associated-control': 'error',
82+
'jsx-a11y/label-has-for': 'error',
8183
'jsx-a11y/media-has-caption': 'error',
8284
'jsx-a11y/mouse-events-have-key-events': 'error',
8385
'jsx-a11y/no-access-key': 'error',
8486
'jsx-a11y/no-autofocus': 'error',
8587
'jsx-a11y/no-distracting-elements': 'error',
86-
8788
'jsx-a11y/no-interactive-element-to-noninteractive-role': [
8889
'error',
8990
{
@@ -184,6 +185,7 @@ module.exports = {
184185
'jsx-a11y/aria-role': 'error',
185186
'jsx-a11y/aria-unsupported-elements': 'error',
186187
'jsx-a11y/click-events-have-key-events': 'error',
188+
'jsx-a11y/control-has-associated-label': 'off',
187189
'jsx-a11y/heading-has-content': 'error',
188190
'jsx-a11y/html-has-lang': 'error',
189191
'jsx-a11y/iframe-has-title': 'error',

0 commit comments

Comments
 (0)