Skip to content

Commit d8ff542

Browse files
committed
Introduce the label-has-associated-control rule.
1 parent be8ed74 commit d8ff542

12 files changed

+748
-1
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+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
15+
// -----------------------------------------------------------------------------
16+
// Tests
17+
// -----------------------------------------------------------------------------
18+
19+
const ruleTester = new RuleTester();
20+
21+
const ruleName = 'label-has-associated-control';
22+
23+
const expectedError = {
24+
message: 'A form label must be associated with a control.',
25+
type: 'JSXOpeningElement',
26+
};
27+
28+
const alwaysValid = [
29+
{ code: '<div />' },
30+
{ code: '<CustomElement />' },
31+
{ code: '<label htmlFor="js_id"><span><span><span>A label</span></span></span></label>', options: [{ depth: 4 }] },
32+
{ code: '<label htmlFor="js_id" aria-label="A label" />' },
33+
{ code: '<label htmlFor="js_id" aria-labelledby="A label" />' },
34+
{ code: '<label>A label<input /></label>' },
35+
{ code: '<label><img alt="A label" /><input /></label>' },
36+
{ code: '<label><img aria-label="A label" /><input /></label>' },
37+
{ code: '<label><span>A label<input /></span></label>' },
38+
{ code: '<label><span><span>A label<input /></span></span></label>', options: [{ depth: 3 }] },
39+
{ code: '<label><span><span><span>A label<input /></span></span></span></label>', options: [{ depth: 4 }] },
40+
{ code: '<label><span><span><span><span>A label</span><input /></span></span></span></label>', options: [{ depth: 5 }] },
41+
{ code: '<label><span><span><span><span aria-label="A label" /><input /></span></span></span></label>', options: [{ depth: 5 }] },
42+
{ 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'] }] },
48+
// Custom controlComponents.
49+
{ code: '<label><span>A label<CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }] },
50+
{ code: '<CustomLabel><span>A label<CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }] },
51+
{ code: '<CustomLabel><span label="A label"><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }] },
52+
];
53+
const neverValid = [
54+
{ code: '<label htmlFor="js_id" />', errors: [expectedError] },
55+
{ code: '<label htmlFor="js_id"><input /></label>', errors: [expectedError] },
56+
{ code: '<label></label>', errors: [expectedError] },
57+
{ code: '<label>A label</label>', errors: [expectedError] },
58+
{ code: '<div><label /><input /></div>', errors: [expectedError] },
59+
{ code: '<div><label>A label</label><input /></div>', errors: [expectedError] },
60+
// Custom label component.
61+
{ code: '<CustomLabel aria-label="A label" />', options: [{ labelComponents: ['CustomLabel'] }], errors: [expectedError] },
62+
{ code: '<CustomLabel label="A label" />', options: [{ labelAttributes: ['label'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
63+
// Custom label attributes.
64+
{ code: '<label label="A label" />', options: [{ labelAttributes: ['label'] }], errors: [expectedError] },
65+
// Custom controlComponents.
66+
{ code: '<label><span><CustomInput /></span></label>', options: [{ controlComponents: ['CustomInput'] }], errors: [expectedError] },
67+
{ code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'] }], errors: [expectedError] },
68+
{ code: '<CustomLabel><span><CustomInput /></span></CustomLabel>', options: [{ controlComponents: ['CustomInput'], labelComponents: ['CustomLabel'], labelAttributes: ['label'] }], errors: [expectedError] },
69+
];
70+
71+
ruleTester.run(ruleName, rule, {
72+
valid: [
73+
...alwaysValid,
74+
].map(parserOptionsMapper),
75+
invalid: [
76+
...neverValid,
77+
].map(parserOptionsMapper),
78+
});
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)