Skip to content

Commit defcc00

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

File tree

4 files changed

+145
-2
lines changed

4 files changed

+145
-2
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
{ code: '<button>Save</button>' },
31+
].map(parserOptionsMapper),
32+
invalid: [
33+
{ code: '<button><span /></button>', errors: [expectedError] }
34+
].map(parserOptionsMapper),
35+
});
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: 12 additions & 2 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'),
@@ -55,10 +56,12 @@ module.exports = {
5556
'jsx-a11y/aria-role': 'error',
5657
'jsx-a11y/aria-unsupported-elements': 'error',
5758
'jsx-a11y/click-events-have-key-events': 'error',
59+
'jsx-a11y/control-has-associated-label': 'error',
5860
'jsx-a11y/heading-has-content': 'error',
5961
'jsx-a11y/html-has-lang': 'error',
6062
'jsx-a11y/iframe-has-title': 'error',
6163
'jsx-a11y/img-redundant-alt': 'error',
64+
6265
'jsx-a11y/interactive-supports-focus': [
6366
'error',
6467
{
@@ -73,8 +76,9 @@ module.exports = {
7376
],
7477
},
7578
],
76-
'jsx-a11y/label-has-for': 'error',
79+
7780
'jsx-a11y/label-has-associated-control': 'error',
81+
'jsx-a11y/label-has-for': 'error',
7882
'jsx-a11y/media-has-caption': 'error',
7983
'jsx-a11y/mouse-events-have-key-events': 'error',
8084
'jsx-a11y/no-access-key': 'error',
@@ -87,6 +91,7 @@ module.exports = {
8791
tr: ['none', 'presentation'],
8892
},
8993
],
94+
9095
'jsx-a11y/no-noninteractive-element-interactions': [
9196
'error',
9297
{
@@ -105,6 +110,7 @@ module.exports = {
105110
img: ['onError', 'onLoad'],
106111
},
107112
],
113+
108114
'jsx-a11y/no-noninteractive-element-to-interactive-role': [
109115
'error',
110116
{
@@ -131,15 +137,18 @@ module.exports = {
131137
td: ['gridcell'],
132138
},
133139
],
140+
134141
'jsx-a11y/no-noninteractive-tabindex': [
135142
'error',
136143
{
137144
tags: [],
138145
roles: ['tabpanel'],
139146
},
140147
],
148+
141149
'jsx-a11y/no-onchange': 'error',
142150
'jsx-a11y/no-redundant-roles': 'error',
151+
143152
'jsx-a11y/no-static-element-interactions': [
144153
'error',
145154
{
@@ -153,6 +162,7 @@ module.exports = {
153162
],
154163
},
155164
],
165+
156166
'jsx-a11y/role-has-required-aria-props': 'error',
157167
'jsx-a11y/role-supports-aria-props': 'error',
158168
'jsx-a11y/scope': 'error',
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @fileoverview Enforce controls are associated with a text label.
3+
* @author Jesse Beach
4+
*
5+
* @flow
6+
*/
7+
8+
// ----------------------------------------------------------------------------
9+
// Rule Definition
10+
// ----------------------------------------------------------------------------
11+
12+
import { getProp, getPropValue, elementType } from 'jsx-ast-utils';
13+
import type { JSXElement } from 'ast-types-flow';
14+
import { generateObjSchema, arraySchema } from '../util/schemas';
15+
import type { ESLintContext } from '../../flow/eslint';
16+
import mayContainChildComponent from '../util/mayContainChildComponent';
17+
import mayHaveAccessibleLabel from '../util/mayHaveAccessibleLabel';
18+
19+
const errorMessage = 'A control must be associated with a text label.';
20+
21+
const schema = generateObjSchema({
22+
labelComponents: arraySchema,
23+
labelAttributes: arraySchema,
24+
controlComponents: arraySchema,
25+
depth: {
26+
description: 'JSX tree depth limit to check for accessible label',
27+
type: 'integer',
28+
minimum: 0,
29+
},
30+
});
31+
32+
const defaultControlElements = [
33+
'button'
34+
];
35+
36+
module.exports = {
37+
meta: {
38+
docs: {},
39+
schema: [schema],
40+
},
41+
42+
create: (context: ESLintContext) => {
43+
const options = context.options[0] || {};
44+
const labelComponents = options.labelComponents || [];
45+
const controlComponents = options.controlComponents || defaultControlElements;
46+
47+
const rule = (node: JSXElement) => {
48+
if (controlComponents.indexOf(elementType(node.openingElement)) === -1) {
49+
return;
50+
}
51+
52+
// Prevent crazy recursion.
53+
const recursionDepth = Math.min(
54+
options.depth === undefined ? 2 : options.depth,
55+
25,
56+
);
57+
const hasAccessibleLabel = mayHaveAccessibleLabel(
58+
node,
59+
recursionDepth,
60+
options.labelAttributes,
61+
);
62+
63+
// htmlFor case
64+
if (!hasAccessibleLabel) {
65+
context.report({
66+
node: node.openingElement,
67+
message: errorMessage,
68+
});
69+
}
70+
};
71+
72+
// Create visitor selectors.
73+
return {
74+
JSXElement: rule,
75+
};
76+
},
77+
};

0 commit comments

Comments
 (0)