Skip to content

Commit 82dc999

Browse files
authored
Merge pull request #326 from jessebeach/label-has-associated-control
Introduce the label-has-associated-control rule.
2 parents b62a435 + df5db8a commit 82dc999

13 files changed

+892
-7
lines changed

__mocks__/JSXElementMock.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
export default function JSXElementMock(tagName, attributes, children = []) {
1+
/**
2+
* @flow
3+
*/
4+
5+
import JSXAttributeMock from './JSXAttributeMock';
6+
7+
export default function JSXElementMock(
8+
tagName: string,
9+
attributes: Array<JSXAttributeMock>,
10+
children: Array<Node> = [],
11+
) {
212
return {
313
type: 'JSXElement',
414
openingElement: {

__mocks__/JSXSpreadAttributeMock.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* @flow
3+
*/
4+
5+
import IdentifierMock from './IdentifierMock';
6+
7+
export default function JSXSpreadAttributeMock(identifier: string) {
8+
return {
9+
type: 'JSXSpreadAttribute',
10+
argument: IdentifierMock(identifier),
11+
};
12+
}

__mocks__/JSXTextMock.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @flow
3+
*/
4+
export default function JSXTextMock(value: string) {
5+
return {
6+
type: 'JSXText',
7+
value,
8+
raw: value,
9+
};
10+
}

__mocks__/LiteralMock.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* @flow
3+
*/
4+
export default function LiteralMock(value: string) {
5+
return {
6+
type: 'Literal',
7+
value,
8+
raw: value,
9+
};
10+
}

__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
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/* eslint-env jest */
2+
/**
3+
* @fileoverview Enforce label tags have an associated control.
4+
* @author Jesse Beach
5+
*/
6+
7+
// -----------------------------------------------------------------------------
8+
// Requirements
9+
// -----------------------------------------------------------------------------
10+
11+
import { RuleTester } from 'eslint';
12+
import parserOptionsMapper from '../../__util__/parserOptionsMapper';
13+
import rule from '../../../src/rules/label-has-associated-control';
14+
import ruleOptionsMapperFactory from '../../__util__/ruleOptionsMapperFactory';
15+
16+
// -----------------------------------------------------------------------------
17+
// Tests
18+
// -----------------------------------------------------------------------------
19+
20+
const ruleTester = new RuleTester();
21+
22+
const ruleName = 'label-has-associated-control';
23+
24+
const expectedError = {
25+
message: 'A form label must be associated with a control.',
26+
type: 'JSXOpeningElement',
27+
};
28+
29+
const htmlForValid = [
30+
{ code: '<label htmlFor="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }] },
31+
{ code: '<label htmlFor="js_id" aria-label="A label" />' },
32+
{ 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 = [
40+
{ code: '<label>A label<input /></label>' },
41+
{ code: '<label><img alt="A label" /><input /></label>' },
42+
{ code: '<label><img aria-label="A label" /><input /></label>' },
43+
{ code: '<label><span>A label<input /></span></label>' },
44+
{ code: '<label><span><span>A label<input /></span></span></label>', options: [{ depth: 3 }] },
45+
{ code: '<label><span><span><span>A label<input /></span></span></span></label>', options: [{ depth: 4 }] },
46+
{ code: '<label><span><span><span><span>A label</span><input /></span></span></span></label>', options: [{ depth: 5 }] },
47+
{ code: '<label><span><span><span><span aria-label="A label" /><input /></span></span></span></label>', options: [{ depth: 5 }] },
48+
{ code: '<label><span><span><span><input aria-label="A label" /></span></span></span></label>', options: [{ depth: 5 }] },
49+
// Custom controlComponents.
50+
{ code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }] },
51+
{ code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }] },
52+
{ code: '<CustomLabel><span label="A label"><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }] },
53+
];
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+
97+
const neverValid = [
98+
{ code: '<label htmlFor="js_id" />', errors: [expectedError] },
99+
{ code: '<label htmlFor="js_id"><input /></label>', errors: [expectedError] },
100+
{ code: '<label></label>', errors: [expectedError] },
101+
{ code: '<label>A label</label>', errors: [expectedError] },
102+
{ code: '<div><label /><input /></div>', errors: [expectedError] },
103+
{ code: '<div><label>A label</label><input /></div>', errors: [expectedError] },
104+
// Custom label component.
105+
{ code: '<CustomLabel aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
106+
{ code: '<CustomLabel label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
107+
// Custom label attributes.
108+
{ code: '<label label="A label" />', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
109+
// Custom controlComponents.
110+
{ code: '<label><span><CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
111+
{ code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
112+
{ code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedError] },
113+
];
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+
});
133+
134+
// nesting valid
135+
ruleTester.run(ruleName, rule, {
136+
valid: [
137+
...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,
167+
].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),
180+
invalid: [
181+
...neverValid,
182+
].map(parserOptionsMapper),
183+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/* eslint-env jest */
2+
import mayContainChildComponent from '../../../src/util/mayContainChildComponent';
3+
import JSXAttributeMock from '../../../__mocks__/JSXAttributeMock';
4+
import JSXElementMock from '../../../__mocks__/JSXElementMock';
5+
import JSXExpressionContainerMock from '../../../__mocks__/JSXExpressionContainerMock';
6+
7+
describe('mayContainChildComponent', () => {
8+
describe('no FancyComponent', () => {
9+
it('should return false', () => {
10+
expect(mayContainChildComponent(
11+
JSXElementMock('div', [], [
12+
JSXElementMock('div', [], [
13+
JSXElementMock('span', [], []),
14+
JSXElementMock('span', [], [
15+
JSXElementMock('span', [], []),
16+
JSXElementMock('span', [], [
17+
JSXElementMock('span', [], []),
18+
]),
19+
]),
20+
]),
21+
JSXElementMock('span', [], []),
22+
JSXElementMock('img', [
23+
JSXAttributeMock('src', 'some/path'),
24+
]),
25+
]),
26+
'FancyComponent',
27+
5,
28+
)).toBe(false);
29+
});
30+
});
31+
describe('contains an indicated component', () => {
32+
it('should return true', () => {
33+
expect(mayContainChildComponent(
34+
JSXElementMock('div', [], [
35+
JSXElementMock('input'),
36+
]),
37+
'input',
38+
)).toBe(true);
39+
});
40+
it('should return true', () => {
41+
expect(mayContainChildComponent(
42+
JSXElementMock('div', [], [
43+
JSXElementMock('FancyComponent'),
44+
]),
45+
'FancyComponent',
46+
)).toBe(true);
47+
});
48+
it('FancyComponent is outside of default depth, should return false', () => {
49+
expect(mayContainChildComponent(
50+
JSXElementMock('div', [], [
51+
JSXElementMock('div', [], [
52+
JSXElementMock('FancyComponent'),
53+
]),
54+
]),
55+
'FancyComponent',
56+
)).toBe(false);
57+
});
58+
it('FancyComponent is inside of custom depth, should return true', () => {
59+
expect(mayContainChildComponent(
60+
JSXElementMock('div', [], [
61+
JSXElementMock('div', [], [
62+
JSXElementMock('FancyComponent'),
63+
]),
64+
]),
65+
'FancyComponent',
66+
2,
67+
)).toBe(true);
68+
});
69+
it('deep nesting, should return true', () => {
70+
expect(mayContainChildComponent(
71+
JSXElementMock('div', [], [
72+
JSXElementMock('div', [], [
73+
JSXElementMock('span', [], []),
74+
JSXElementMock('span', [], [
75+
JSXElementMock('span', [], []),
76+
JSXElementMock('span', [], [
77+
JSXElementMock('span', [], [
78+
JSXElementMock('span', [], [
79+
JSXElementMock('FancyComponent'),
80+
]),
81+
]),
82+
]),
83+
]),
84+
]),
85+
JSXElementMock('span', [], []),
86+
JSXElementMock('img', [
87+
JSXAttributeMock('src', 'some/path'),
88+
]),
89+
]),
90+
'FancyComponent',
91+
6,
92+
)).toBe(true);
93+
});
94+
});
95+
describe('Intederminate situations', () => {
96+
describe('expression container children', () => {
97+
it('should return true', () => {
98+
expect(mayContainChildComponent(
99+
JSXElementMock('div', [], [
100+
JSXExpressionContainerMock('mysteryBox'),
101+
]),
102+
'FancyComponent',
103+
)).toBe(true);
104+
});
105+
});
106+
});
107+
});

0 commit comments

Comments
 (0)