Skip to content

Commit 3544dfd

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

File tree

4 files changed

+173
-2
lines changed

4 files changed

+173
-2
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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+
// Interactive Elements
31+
{ code: '<button>Save</button>' },
32+
{ code: '<button><span>Save</span></button>' },
33+
{ code: '<button><span><span>Save</span></span></button>', options: [{ depth: 3 }] },
34+
{ code: '<button><span><span><span><span><span><span><span><span>Save</span></span></span></span></span></span></span></span></button>', options: [{ depth: 9 }] },
35+
{ code: '<button><img alt="Save" /></button>' },
36+
{ code: '<button aria-label="Save" />' },
37+
{ code: '<button><span aria-label="Save" /></button>' },
38+
{ code: '<button aria-labelledby="js_1" />' },
39+
{ code: '<button><span aria-labelledby="js_1" /></button>' },
40+
{ code: '<button>{sureWhyNot}</button>' },
41+
// Interactive Roles
42+
{ code: '<div role="button">Save</div>' },
43+
].map(parserOptionsMapper),
44+
invalid: [
45+
{ code: '<button />', errors: [expectedError] },
46+
{ code: '<button><span /></button>', errors: [expectedError] },
47+
{ code: '<button><img /></button>', errors: [expectedError] },
48+
{ code: '<button><span title="This is not a real label" /></button>', errors: [expectedError] },
49+
].map(parserOptionsMapper),
50+
});
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'),
@@ -58,10 +59,12 @@ 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': 'error',
6163
'jsx-a11y/heading-has-content': 'error',
6264
'jsx-a11y/html-has-lang': 'error',
6365
'jsx-a11y/iframe-has-title': 'error',
6466
'jsx-a11y/img-redundant-alt': 'error',
67+
6568
'jsx-a11y/interactive-supports-focus': [
6669
'error',
6770
{
@@ -76,8 +79,9 @@ module.exports = {
7679
],
7780
},
7881
],
79-
'jsx-a11y/label-has-for': 'error',
82+
8083
'jsx-a11y/label-has-associated-control': 'error',
84+
'jsx-a11y/label-has-for': 'error',
8185
'jsx-a11y/media-has-caption': 'error',
8286
'jsx-a11y/mouse-events-have-key-events': 'error',
8387
'jsx-a11y/no-access-key': 'error',
@@ -90,6 +94,7 @@ module.exports = {
9094
tr: ['none', 'presentation'],
9195
},
9296
],
97+
9398
'jsx-a11y/no-noninteractive-element-interactions': [
9499
'error',
95100
{
@@ -110,6 +115,7 @@ module.exports = {
110115
img: ['onError', 'onLoad'],
111116
},
112117
],
118+
113119
'jsx-a11y/no-noninteractive-element-to-interactive-role': [
114120
'error',
115121
{
@@ -136,15 +142,18 @@ module.exports = {
136142
td: ['gridcell'],
137143
},
138144
],
145+
139146
'jsx-a11y/no-noninteractive-tabindex': [
140147
'error',
141148
{
142149
tags: [],
143150
roles: ['tabpanel'],
144151
},
145152
],
153+
146154
'jsx-a11y/no-onchange': 'error',
147155
'jsx-a11y/no-redundant-roles': 'error',
156+
148157
'jsx-a11y/no-static-element-interactions': [
149158
'error',
150159
{
@@ -158,6 +167,7 @@ module.exports = {
158167
],
159168
},
160169
],
170+
161171
'jsx-a11y/role-has-required-aria-props': 'error',
162172
'jsx-a11y/role-supports-aria-props': 'error',
163173
'jsx-a11y/scope': 'error',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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, getLiteralPropValue, 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 isInteractiveElement from '../util/isInteractiveElement';
17+
import isInteractiveRole from '../util/isInteractiveRole';
18+
import mayContainChildComponent from '../util/mayContainChildComponent';
19+
import mayHaveAccessibleLabel from '../util/mayHaveAccessibleLabel';
20+
21+
const errorMessage = 'A control must be associated with a text label.';
22+
23+
const schema = generateObjSchema({
24+
labelComponents: arraySchema,
25+
labelAttributes: arraySchema,
26+
controlComponents: arraySchema,
27+
depth: {
28+
description: 'JSX tree depth limit to check for accessible label',
29+
type: 'integer',
30+
minimum: 0,
31+
},
32+
});
33+
34+
const defaultControlElements = [
35+
'button',
36+
];
37+
const defaultControlRoles = [
38+
'button',
39+
];
40+
41+
module.exports = {
42+
meta: {
43+
docs: {},
44+
schema: [schema],
45+
},
46+
47+
create: (context: ESLintContext) => {
48+
const options = context.options[0] || {};
49+
const labelComponents = options.labelComponents || [];
50+
const controlComponents = options.controlComponents || defaultControlElements;
51+
const controlRoles = options.controlRoles || defaultControlRoles;
52+
53+
const rule = (node: JSXElement) => {
54+
const tag = elementType(node.openingElement);
55+
const props = node.openingElement.attributes;
56+
const role = getLiteralPropValue(getProp(props, 'role'));
57+
if (controlComponents.indexOf(tag) === -1) {
58+
return;
59+
}
60+
const nodeIsInteractiveElement = isInteractiveElement(tag, props);
61+
const nodeIsInteractiveRoleElement = isInteractiveRole(tag, props);
62+
63+
let hasAccessibleLabel = true;
64+
if (nodeIsInteractiveElement || nodeIsInteractiveRoleElement) {
65+
// Prevent crazy recursion.
66+
const recursionDepth = Math.min(
67+
options.depth === undefined ? 2 : options.depth,
68+
25,
69+
);
70+
hasAccessibleLabel = mayHaveAccessibleLabel(
71+
node,
72+
recursionDepth,
73+
options.labelAttributes,
74+
);
75+
}
76+
77+
if (!hasAccessibleLabel) {
78+
context.report({
79+
node: node.openingElement,
80+
message: errorMessage,
81+
});
82+
}
83+
};
84+
85+
// Create visitor selectors.
86+
return {
87+
JSXElement: rule,
88+
};
89+
},
90+
};

0 commit comments

Comments
 (0)