Skip to content

Commit c0ddacf

Browse files
committed
Introduce label-has-associated-control rule
2 parents d8ff542 + b19f4df commit c0ddacf

22 files changed

+511
-63
lines changed

__tests__/__util__/ruleOptionsMapperFactory.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ type ESLintTestRunnerTestCase = {
1111

1212
export default function ruleOptionsMapperFactory(ruleOptions: Array<mixed> = []) {
1313
// eslint-disable-next-line
14-
return ({ code, errors, options, parserOptions }: ESLintTestRunnerTestCase): ESLintTestRunnerTestCase => ({
15-
code,
16-
errors,
17-
options: (options || []).concat(ruleOptions),
18-
parserOptions,
19-
});
14+
return ({ code, errors, options, parserOptions }: ESLintTestRunnerTestCase): ESLintTestRunnerTestCase => {
15+
return {
16+
code,
17+
errors,
18+
// Flatten the array of objects in an array of one object.
19+
options: (options || []).concat(ruleOptions).reduce((acc, item) => [{
20+
...acc[0],
21+
...item,
22+
}], [{}]),
23+
parserOptions,
24+
};
25+
};
2026
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const alwaysValid = [
115115
{ code: '<div role="switch" tabIndex="0" onClick={() => void 0} />' },
116116
{ code: '<div role="tab" tabIndex="0" onClick={() => void 0} />' },
117117
{ code: '<div role="textbox" tabIndex="0" onClick={() => void 0} />' },
118+
{ code: '<div role="textbox" aria-disabled="true" onClick={() => void 0} />' },
118119
{ code: '<Foo.Bar onClick={() => void 0} aria-hidden={false} />;' },
119120
{ code: '<Input onClick={() => void 0} type="hidden" />;' },
120121
];

__tests__/src/rules/label-has-associated-control-test.js

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { RuleTester } from 'eslint';
1212
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
1313
import rule from '../../../src/rules/label-has-associated-control';
14+
import ruleOptionsMapperFactory from '../../__util__/ruleOptionsMapperFactory';
1415

1516
// -----------------------------------------------------------------------------
1617
// Tests
@@ -25,12 +26,17 @@ const expectedError = {
2526
type: 'JSXOpeningElement',
2627
};
2728

28-
const alwaysValid = [
29-
{ code: '<div />' },
30-
{ code: '<CustomElement />' },
29+
const htmlForValid = [
3130
{ code: '<label htmlFor="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }] },
3231
{ code: '<label htmlFor="js_id" aria-label="A label" />' },
3332
{ code: '<label htmlFor="js_id" aria-labelledby="A label" />' },
33+
// Custom label component.
34+
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }] },
35+
{ code: '<CustomLabel htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
36+
// Custom label attributes.
37+
{ code: '<label htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'] }] },
38+
];
39+
const nestingValid = [
3440
{ code: '<label>A label<input /></label>' },
3541
{ code: '<label><img alt="A label" /><input /></label>' },
3642
{ code: '<label><img aria-label="A label" /><input /></label>' },
@@ -40,16 +46,54 @@ const alwaysValid = [
4046
{ code: '<label><span><span><span><span>A label</span><input /></span></span></span></label>', options: [{ depth: 5 }] },
4147
{ code: '<label><span><span><span><span aria-label="A label" /><input /></span></span></span></label>', options: [{ depth: 5 }] },
4248
{ code: '<label><span><span><span><input aria-label="A label" /></span></span></span></label>', options: [{ depth: 5 }] },
43-
// Custom label component.
44-
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }] },
45-
{ code: '<CustomLabel htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
46-
// Custom label attributes.
47-
{ code: '<label htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'] }] },
4849
// Custom controlComponents.
4950
{ code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }] },
5051
{ code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }] },
5152
{ code: '<CustomLabel><span label="A label"><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }] },
5253
];
54+
55+
const bothValid = [
56+
{ code: '<label htmlFor="js_id"><span><span><span>A label<input /></span></span></span></label>', options: [{ depth: 4 }] },
57+
{ code: '<label htmlFor="js_id" aria-label="A label"><input /></label>' },
58+
{ code: '<label htmlFor="js_id" aria-labelledby="A label"><input /></label>' },
59+
// Custom label component.
60+
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label"><input /></CustomLabel>', options: [{ labelComponents: ['CustomLabel'] }] },
61+
{ code: '<CustomLabel htmlFor="js_id" label="A label"><input /></CustomLabel>', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }] },
62+
// Custom label attributes.
63+
{ code: '<label htmlFor="js_id" label="A label"><input /></label>', options: [{ labelAttributes: ['label'] }] },
64+
];
65+
66+
const alwaysValid = [
67+
{ code: '<div />' },
68+
{ code: '<CustomElement />' },
69+
];
70+
71+
const htmlForInvalid = [
72+
{ code: '<label htmlFor="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }], errors: [expectedError] },
73+
{ code: '<label htmlFor="js_id" aria-label="A label" />', errors: [expectedError] },
74+
{ code: '<label htmlFor="js_id" aria-labelledby="A label" />', errors: [expectedError] },
75+
// Custom label component.
76+
{ code: '<CustomLabel htmlFor="js_id" aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
77+
{ code: '<CustomLabel htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
78+
// Custom label attributes.
79+
{ code: '<label htmlFor="js_id" label="A label" />', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
80+
];
81+
const nestingInvalid = [
82+
{ code: '<label>A label<input /></label>', errors: [expectedError] },
83+
{ code: '<label><img alt="A label" /><input /></label>', errors: [expectedError] },
84+
{ code: '<label><img aria-label="A label" /><input /></label>', errors: [expectedError] },
85+
{ code: '<label><span>A label<input /></span></label>', errors: [expectedError] },
86+
{ code: '<label><span><span>A label<input /></span></span></label>', options: [{ depth: 3 }], errors: [expectedError] },
87+
{ code: '<label><span><span><span>A label<input /></span></span></span></label>', options: [{ depth: 4 }], errors: [expectedError] },
88+
{ code: '<label><span><span><span><span>A label</span><input /></span></span></span></label>', options: [{ depth: 5 }], errors: [expectedError] },
89+
{ code: '<label><span><span><span><span aria-label="A label" /><input /></span></span></span></label>', options: [{ depth: 5 }], errors: [expectedError] },
90+
{ code: '<label><span><span><span><input aria-label="A label" /></span></span></span></label>', options: [{ depth: 5 }], errors: [expectedError] },
91+
// Custom controlComponents.
92+
{ code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
93+
{ code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
94+
{ code: '<CustomLabel><span label="A label"><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedError] },
95+
];
96+
5397
const neverValid = [
5498
{ code: '<label htmlFor="js_id" />', errors: [expectedError] },
5599
{ code: '<label htmlFor="js_id"><input /></label>', errors: [expectedError] },
@@ -67,11 +111,72 @@ const neverValid = [
67111
{ code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
68112
{ code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedError] },
69113
];
114+
// htmlFor valid
115+
ruleTester.run(ruleName, rule, {
116+
valid: [
117+
...alwaysValid,
118+
...htmlForValid,
119+
]
120+
.map(ruleOptionsMapperFactory({
121+
assert: 'htmlFor',
122+
}))
123+
.map(parserOptionsMapper),
124+
invalid: [
125+
...neverValid,
126+
...nestingInvalid,
127+
]
128+
.map(ruleOptionsMapperFactory({
129+
assert: 'htmlFor',
130+
}))
131+
.map(parserOptionsMapper),
132+
});
70133

134+
// nesting valid
71135
ruleTester.run(ruleName, rule, {
72136
valid: [
73137
...alwaysValid,
138+
...nestingValid,
139+
]
140+
.map(ruleOptionsMapperFactory({
141+
assert: 'nesting',
142+
}))
143+
.map(parserOptionsMapper),
144+
invalid: [
145+
...neverValid,
146+
...htmlForInvalid,
147+
]
148+
.map(ruleOptionsMapperFactory({
149+
assert: 'nesting',
150+
}))
151+
.map(parserOptionsMapper),
152+
});
153+
154+
// either valid
155+
ruleTester.run(ruleName, rule, {
156+
valid: [
157+
...alwaysValid,
158+
...htmlForValid,
159+
...nestingValid,
160+
]
161+
.map(ruleOptionsMapperFactory({
162+
assert: 'either',
163+
}))
164+
.map(parserOptionsMapper),
165+
invalid: [
166+
...neverValid,
74167
].map(parserOptionsMapper),
168+
});
169+
170+
// both valid
171+
ruleTester.run(ruleName, rule, {
172+
valid: [
173+
...alwaysValid,
174+
...bothValid,
175+
]
176+
.map(ruleOptionsMapperFactory({
177+
assert: 'both',
178+
}))
179+
.map(parserOptionsMapper),
75180
invalid: [
76181
...neverValid,
77182
].map(parserOptionsMapper),

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ const alwaysValid = [
117117
{ code: '<html onClick={() => {}} />;' },
118118
{ code: '<i onClick={() => {}} />;' },
119119
{ code: '<iframe onLoad={() => {}} />;' },
120+
{ code: '<img onError={() => {}} />;' },
120121
{ code: '<img onLoad={() => {}} />;' },
121122
{ code: '<ins onClick={() => {}} />;' },
122123
{ code: '<kbd onClick={() => {}} />;' },
@@ -229,7 +230,6 @@ const alwaysValid = [
229230
{ code: '<div role="article" onEmptied={() => {}} />;' },
230231
{ code: '<div role="article" onEncrypted={() => {}} />;' },
231232
{ code: '<div role="article" onEnded={() => {}} />;' },
232-
{ code: '<div role="article" onError={() => {}} />;' },
233233
{ code: '<div role="article" onLoadedData={() => {}} />;' },
234234
{ code: '<div role="article" onLoadedMetadata={() => {}} />;' },
235235
{ code: '<div role="article" onLoadStart={() => {}} />;' },
@@ -245,8 +245,6 @@ const alwaysValid = [
245245
{ code: '<div role="article" onTimeUpdate={() => {}} />;' },
246246
{ code: '<div role="article" onVolumeChange={() => {}} />;' },
247247
{ code: '<div role="article" onWaiting={() => {}} />;' },
248-
{ code: '<div role="article" onLoad={() => {}} />;' },
249-
{ code: '<div role="article" onError={() => {}} />;' },
250248
{ code: '<div role="article" onAnimationStart={() => {}} />;' },
251249
{ code: '<div role="article" onAnimationEnd={() => {}} />;' },
252250
{ code: '<div role="article" onAnimationIteration={() => {}} />;' },
@@ -344,6 +342,8 @@ const neverValid = [
344342
{ code: '<div role="article" onKeyPress={() => {}} />;', errors: [expectedError] },
345343
{ code: '<div role="article" onKeyUp={() => {}} />;', errors: [expectedError] },
346344
{ code: '<div role="article" onClick={() => {}} />;', errors: [expectedError] },
345+
{ code: '<div role="article" onLoad={() => {}} />;', errors: [expectedError] },
346+
{ code: '<div role="article" onError={() => {}} />;', errors: [expectedError] },
347347
{ code: '<div role="article" onMouseDown={() => {}} />;', errors: [expectedError] },
348348
{ code: '<div role="article" onMouseUp={() => {}} />;', errors: [expectedError] },
349349
];
@@ -395,7 +395,6 @@ ruleTester.run(`${ruleName}:recommended`, rule, {
395395
{ code: '<div role="article" onEmptied={() => {}} />;' },
396396
{ code: '<div role="article" onEncrypted={() => {}} />;' },
397397
{ code: '<div role="article" onEnded={() => {}} />;' },
398-
{ code: '<div role="article" onError={() => {}} />;' },
399398
{ code: '<div role="article" onLoadedData={() => {}} />;' },
400399
{ code: '<div role="article" onLoadedMetadata={() => {}} />;' },
401400
{ code: '<div role="article" onLoadStart={() => {}} />;' },
@@ -411,8 +410,6 @@ ruleTester.run(`${ruleName}:recommended`, rule, {
411410
{ code: '<div role="article" onTimeUpdate={() => {}} />;' },
412411
{ code: '<div role="article" onVolumeChange={() => {}} />;' },
413412
{ code: '<div role="article" onWaiting={() => {}} />;' },
414-
{ code: '<div role="article" onLoad={() => {}} />;' },
415-
{ code: '<div role="article" onError={() => {}} />;' },
416413
{ code: '<div role="article" onAnimationStart={() => {}} />;' },
417414
{ code: '<div role="article" onAnimationEnd={() => {}} />;' },
418415
{ code: '<div role="article" onAnimationIteration={() => {}} />;' },
@@ -427,10 +424,14 @@ ruleTester.run(`${ruleName}:recommended`, rule, {
427424
.map(parserOptionsMapper),
428425
});
429426

427+
const strictOptions =
428+
(configs.strict.rules[`jsx-a11y/${ruleName}`][1] || {});
430429
ruleTester.run(`${ruleName}:strict`, rule, {
431430
valid: [
432431
...alwaysValid,
433-
].map(parserOptionsMapper),
432+
]
433+
.map(ruleOptionsMapperFactory(strictOptions))
434+
.map(parserOptionsMapper),
434435
invalid: [
435436
...neverValid,
436437
// All the possible handlers
@@ -452,5 +453,7 @@ ruleTester.run(`${ruleName}:strict`, rule, {
452453
{ code: '<div role="article" onMouseMove={() => {}} />;', errors: [expectedError] },
453454
{ code: '<div role="article" onMouseOut={() => {}} />;', errors: [expectedError] },
454455
{ code: '<div role="article" onMouseOver={() => {}} />;', errors: [expectedError] },
455-
].map(parserOptionsMapper),
456+
]
457+
.map(ruleOptionsMapperFactory(strictOptions))
458+
.map(parserOptionsMapper),
456459
});

__tests__/src/rules/role-supports-aria-props-test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ ruleTester.run('role-supports-aria-props', rule, {
7979
{ code: '<a href="#" aria-atomic />' },
8080
{ code: '<a href="#" aria-busy />' },
8181
{ code: '<a href="#" aria-controls />' },
82+
{ code: '<a href="#" aria-current />' },
8283
{ code: '<a href="#" aria-describedby />' },
8384
{ code: '<a href="#" aria-disabled />' },
8485
{ code: '<a href="#" aria-dropeffect />' },
@@ -410,6 +411,7 @@ ruleTester.run('role-supports-aria-props', rule, {
410411
{ code: '<h5 aria-hidden />' },
411412
{ code: '<h6 aria-hidden />' },
412413
{ code: '<hr aria-hidden />' },
414+
{ code: '<li aria-current />' },
413415
{ code: '<li aria-expanded />' },
414416
{ code: '<meter aria-atomic />' },
415417
{ code: '<nav aria-expanded />' },
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/* eslint-env jest */
2+
import getComputedRole from '../../../src/util/getComputedRole';
3+
import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock';
4+
5+
describe('getComputedRole', () => {
6+
describe('explicit role', () => {
7+
describe('valid role', () => {
8+
it('should return the role', () => {
9+
expect(getComputedRole(
10+
'div',
11+
[JSXAttributeMock('role', 'button')],
12+
)).toBe('button');
13+
});
14+
});
15+
describe('invalid role', () => {
16+
describe('has implicit', () => {
17+
it('should return the implicit role', () => {
18+
expect(getComputedRole(
19+
'li',
20+
[JSXAttributeMock('role', 'beeswax')],
21+
)).toBe('listitem');
22+
});
23+
});
24+
describe('lacks implicit', () => {
25+
it('should return null', () => {
26+
expect(getComputedRole(
27+
'div',
28+
[JSXAttributeMock('role', 'beeswax')],
29+
)).toBeNull();
30+
});
31+
});
32+
});
33+
34+
describe('no role', () => {
35+
describe('has implicit', () => {
36+
it('should return the implicit role', () => {
37+
expect(getComputedRole(
38+
'li',
39+
[],
40+
)).toBe('listitem');
41+
});
42+
});
43+
describe('lacks implicit', () => {
44+
it('should return null', () => {
45+
expect(getComputedRole(
46+
'div',
47+
[],
48+
)).toBeNull();
49+
});
50+
});
51+
});
52+
});
53+
describe('implicit role', () => {
54+
describe('has implicit', () => {
55+
it('should return the implicit role', () => {
56+
expect(getComputedRole(
57+
'li',
58+
[JSXAttributeMock('role', 'beeswax')],
59+
)).toBe('listitem');
60+
});
61+
});
62+
describe('lacks implicit', () => {
63+
it('should return null', () => {
64+
expect(getComputedRole(
65+
'div',
66+
[],
67+
)).toBeNull();
68+
});
69+
});
70+
});
71+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/* eslint-env jest */
2+
import getExplicitRole from '../../../src/util/getExplicitRole';
3+
import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock';
4+
5+
describe('getExplicitRole', () => {
6+
describe('valid role', () => {
7+
it('should return the role', () => {
8+
expect(getExplicitRole(
9+
'div',
10+
[JSXAttributeMock('role', 'button')],
11+
)).toBe('button');
12+
});
13+
});
14+
describe('invalid role', () => {
15+
it('should return null', () => {
16+
expect(getExplicitRole(
17+
'div',
18+
[JSXAttributeMock('role', 'beeswax')],
19+
)).toBeNull();
20+
});
21+
});
22+
describe('no role', () => {
23+
it('should return null', () => {
24+
expect(getExplicitRole(
25+
'div',
26+
[],
27+
)).toBeNull();
28+
});
29+
});
30+
});

0 commit comments

Comments
 (0)