Skip to content

Commit b1fa54e

Browse files
committed
Add jsx-a11y-control-has-associated-label rule
1 parent 77eb78c commit b1fa54e

File tree

7 files changed

+636
-0
lines changed

7 files changed

+636
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ See also [Configuring ESLint](https://eslint.org/docs/user-guide/configuring).
4848
| Rule ID | Description | |
4949
|:--------|:------------|:--:|
5050
| [@croutonn/jsx-a11y-anchor-has-content](./docs/rules/jsx-a11y-anchor-has-content.md) | Enforce that anchors have content and that the content is accessible to screen readers | |
51+
| [@croutonn/jsx-a11y-control-has-associated-label](./docs/rules/jsx-a11y-control-has-associated-label.md) | Enforce that a control (an interactive element) has a text label | |
5152
| [@croutonn/typescript-react-component-type](./docs/rules/typescript-react-component-type.md) | enforce `FC` and `FunctionComponent` types to one or the other | ⭐️✒️ |
5253
| [@croutonn/typescript-react-require-props-suffix](./docs/rules/typescript-react-require-props-suffix.md) | require that prop interface names be suffixed with `Props` | ⭐️ |
5354
| [@croutonn/typescript-react-require-props-type](./docs/rules/typescript-react-require-props-type.md) | require an props type to be provided to a React component | ⭐️ |
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# @croutonn/jsx-a11y-control-has-associated-label
2+
> Enforce that a control (an interactive element) has a text label
3+
4+
## Rule Details
5+
6+
There are two supported ways to supply a control with a text label:
7+
8+
- Provide text content inside the element.
9+
- Use the `aria-label` attribute on the element, with a text value.
10+
- Use the `aria-labelledby` attribute on the element, and point the IDREF value to an element with an accessible label.
11+
- Alternatively, with an `img` tag, you may use the `alt` attribute to supply a text description of the image.
12+
13+
The rule is permissive in the sense that it will assume that expressions will eventually provide a label. So an element like this will pass.
14+
15+
```jsx
16+
<button type="button">{maybeSomethingThatContainsALabel}</button>
17+
```
18+
19+
## How do I resolve this error?
20+
21+
### Case: I have a simple button that requires a label.
22+
23+
Provide text content in the `button` element.
24+
25+
```jsx
26+
<button type="button">Save</button>
27+
```
28+
29+
### Case: I have an icon button and I don't want visible text.
30+
31+
Use the `aria-label` attribute and provide the text label as the value.
32+
33+
```jsx
34+
<button type="button" aria-label="Save" class="icon-save" />
35+
```
36+
37+
### Case: The label for my element is already located on the page and I don't want to repeat the text in my source code.
38+
39+
Use the `aria-labelledby` attribute and point the IDREF value to an element with an accessible label.
40+
41+
```jsx
42+
<div id="js_1">Comment</div>
43+
<textarea aria-labelledby="js_1"></textarea>
44+
```
45+
46+
### Case: My label and input components are custom components, but I still want to require that they have an accessible text label.
47+
48+
You can configure the rule to be aware of your custom components. Refer to the Rule Details below.
49+
50+
```jsx
51+
<CustomInput label="Surname" type="text" value={value} />
52+
```
53+
54+
### Succeed
55+
```jsx
56+
<button type="button" aria-label="Save" class="icon-save" />
57+
```
58+
59+
### Fail
60+
```jsx
61+
<button type="button" class="icon-save" />
62+
```
63+
64+
## Options
65+
66+
This rule takes one optional object argument of type object:
67+
68+
```json
69+
{
70+
"rules": {
71+
"jsx-a11y/control-has-associated-label": [ 2, {
72+
"labelAttributes": ["label"],
73+
"controlComponents": ["CustomComponent"],
74+
"ignoreElements": [
75+
"audio",
76+
"canvas",
77+
"embed",
78+
"input",
79+
"textarea",
80+
"tr",
81+
"video",
82+
],
83+
"ignoreRoles": [
84+
"grid",
85+
"listbox",
86+
"menu",
87+
"menubar",
88+
"radiogroup",
89+
"row",
90+
"tablist",
91+
"toolbar",
92+
"tree",
93+
"treegrid",
94+
],
95+
"depth": 3,
96+
}],
97+
}
98+
}
99+
```
100+
101+
- `labelAttributes` is a list of attributes to check on the control component and its children for a label. Use this if you have a custom component that uses a string passed on a prop to render an HTML `label`, for example.
102+
- `controlComponents` is a list of custom React Components names that will render down to an interactive element.
103+
- `ignoreElements` is an array of elements that should not be considered control (interactive) elements and therefore they do not require a text label.
104+
- `ignoreRoles` is an array of ARIA roles that should not be considered control (interactive) roles and therefore they do not require a text label.
105+
- `depth` (default 2, max 25) is an integer that determines how deep within a `JSXElement` the rule should look for text content or an element with a label to determine if the interactive element will have an accessible label.
106+
- `"ignoreAttributeInner"` false (default: `true`) Ignore rule if element is inside component attribute.
107+
- `"attributeTracingDepth"` -1 (default: `4`) The number of AST nodes trace when applying ignoreAttributeInner. Specify `-1` to trace indefinitely.
108+
109+
## Accessibility guidelines
110+
- [WCAG 1.3.1](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships)
111+
- [WCAG 3.3.2](https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions)
112+
- [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value)
113+
114+
## Implementation
115+
116+
- [Rule source](../../lib/rules/jsx-a11y-control-has-associated-label.js)
117+
- [Test source](../../tests/lib/rules/jsx-a11y-control-has-associated-label.js)

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module.exports = {
88
rules: {
99
"func-style": require("./rules/func-style"),
1010
"jsx-a11y-anchor-has-content": require("./rules/jsx-a11y-anchor-has-content"),
11+
"jsx-a11y-control-has-associated-label": require("./rules/jsx-a11y-control-has-associated-label"),
1112
"typescript-react-component-type": require("./rules/typescript-react-component-type"),
1213
"typescript-react-require-props-suffix": require("./rules/typescript-react-require-props-suffix"),
1314
"typescript-react-require-props-type": require("./rules/typescript-react-require-props-type")
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"use strict";
2+
3+
/**
4+
* @typedef {import("eslint").Rule.RuleModule} RuleModule
5+
* @typedef {import("estree").Node} ASTNode
6+
*/
7+
8+
const { elementType, getProp, getLiteralPropValue } = require("jsx-ast-utils");
9+
const includes = require("array-includes");
10+
const { arraySchema, generateObjSchema } = require("eslint-plugin-jsx-a11y/lib/util/schemas");
11+
const isDOMElement = require("eslint-plugin-jsx-a11y/lib/util/isDOMElement").default;
12+
const isHiddenFromScreenReader = require("eslint-plugin-jsx-a11y/lib/util/isHiddenFromScreenReader").default;
13+
const isInteractiveElement = require("eslint-plugin-jsx-a11y/lib/util/isInteractiveElement").default;
14+
const isInteractiveRole = require("eslint-plugin-jsx-a11y/lib/util/isInteractiveRole").default;
15+
const mayHaveAccessibleLabel = require("eslint-plugin-jsx-a11y/lib/util/mayHaveAccessibleLabel").default;
16+
const findAncestor = require("../utils/find-ancestor");
17+
18+
const errorMessages = {
19+
error: "A control must be associated with a text label."
20+
};
21+
22+
const ignoreList = ["link"];
23+
24+
const schema = generateObjSchema({
25+
labelAttributes: arraySchema,
26+
controlComponents: arraySchema,
27+
ignoreElements: arraySchema,
28+
ignoreRoles: arraySchema,
29+
depth: {
30+
description: "JSX tree depth limit to check for accessible label",
31+
type: "integer",
32+
minimum: 0
33+
},
34+
ignoreAttributeInner: { type: "boolean" },
35+
attributeTracingDepth: { type: "integer" }
36+
});
37+
38+
/**
39+
* @type RuleModule
40+
*/
41+
module.exports = {
42+
meta: {
43+
docs: {
44+
45+
description: "Enforce that a control (an interactive element) has a text label",
46+
47+
category: "Best Practices",
48+
49+
recommended: false,
50+
url: "https://github.com/croutonn/eslint-plugin/blob/main/docs/rules/jsx-a11y-control-has-associated-label.md"
51+
},
52+
53+
fixable: null,
54+
messages: errorMessages,
55+
schema: [schema],
56+
57+
type: "suggestion"
58+
},
59+
60+
create: context => {
61+
const options = context.options[0] || {};
62+
const {
63+
labelAttributes = [],
64+
controlComponents = [],
65+
ignoreElements = [],
66+
ignoreRoles = [],
67+
ignoreAttributeInner = true,
68+
attributeTracingDepth = 4
69+
} = options;
70+
71+
const newIgnoreElements = new Set([...ignoreElements, ...ignoreList]);
72+
73+
/**
74+
* Core function of the rule
75+
* @param {ASTNode} node target node
76+
* @returns {void}
77+
*/
78+
function rule(node) {
79+
const tag = elementType(node.openingElement);
80+
const role = getLiteralPropValue(getProp(node.openingElement.attributes, "role"));
81+
82+
83+
// Ignore interactive elements that might get their label from a source
84+
// that cannot be discerned from static analysis, like
85+
// <label><input />Save</label>
86+
if (newIgnoreElements.has(tag)) {
87+
return;
88+
}
89+
90+
// Ignore roles that are "interactive" but should not require a label.
91+
if (includes(ignoreRoles, role)) {
92+
return;
93+
}
94+
const props = node.openingElement.attributes;
95+
const nodeIsDOMElement = isDOMElement(tag);
96+
const nodeIsHiddenFromScreenReader = isHiddenFromScreenReader(tag, props);
97+
const nodeIsInteractiveElement = isInteractiveElement(tag, props);
98+
const nodeIsInteractiveRole = isInteractiveRole(tag, props);
99+
const nodeIsControlComponent = controlComponents.indexOf(tag) > -1;
100+
101+
if (nodeIsHiddenFromScreenReader) {
102+
return;
103+
}
104+
105+
let hasAccessibleLabel = true;
106+
107+
if (
108+
nodeIsInteractiveElement ||
109+
(
110+
nodeIsDOMElement &&
111+
nodeIsInteractiveRole
112+
) ||
113+
nodeIsControlComponent
114+
115+
) {
116+
117+
// Prevent crazy recursion.
118+
const recursionDepth = Math.min(
119+
options.depth === void 0 ? 2 : options.depth,
120+
25
121+
);
122+
123+
hasAccessibleLabel = mayHaveAccessibleLabel(
124+
node,
125+
recursionDepth,
126+
labelAttributes
127+
);
128+
}
129+
130+
if (ignoreAttributeInner && findAncestor(node, (parent => parent.type === "JSXAttribute"), attributeTracingDepth)) {
131+
132+
return;
133+
}
134+
135+
if (!hasAccessibleLabel) {
136+
context.report({
137+
node: node.openingElement,
138+
messageId: "error"
139+
});
140+
}
141+
}
142+
143+
// Create visitor selectors.
144+
return {
145+
JSXElement: rule
146+
};
147+
}
148+
};

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"eslint": ">=7.11.0"
2323
},
2424
"dependencies": {
25+
"array-includes": "^3.1.1",
2526
"eslint-plugin-jsx-a11y": "^6.4.1",
2627
"jsx-ast-utils": "^3.1.0"
2728
},

0 commit comments

Comments
 (0)