Skip to content

Commit 57d4894

Browse files
Merge pull request #2607 from NullVoxPopuli/nvp/template-lint-extract-rule-template-require-input-label
Extract rule: template-require-input-label
2 parents e05b4fb + 29f41df commit 57d4894

4 files changed

Lines changed: 622 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
197197
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
198198
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
199199
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
200+
| [template-require-input-label](docs/rules/template-require-input-label.md) | require label for form input elements | | | |
200201
| [template-require-lang-attribute](docs/rules/template-require-lang-attribute.md) | require lang attribute on html element | | | |
201202
| [template-require-mandatory-role-attributes](docs/rules/template-require-mandatory-role-attributes.md) | require mandatory ARIA attributes for ARIA roles | | | |
202203
| [template-require-media-caption](docs/rules/template-require-media-caption.md) | require captions for audio and video elements | | | |
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# ember/template-require-input-label
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Users with assistive technology need user-input form elements to have
6+
associated labels.
7+
8+
The rule applies to the following HTML tags:
9+
10+
- `<input>`
11+
- `<textarea>`
12+
- `<select>`
13+
14+
The rule also applies to the following ember components:
15+
16+
- `<Textarea />`
17+
- `<Input />`
18+
- `{{textarea}}`
19+
- `{{input}}`
20+
21+
The label is **essential** for users. Leaving it out will cause **three**
22+
different WCAG criteria to fail:
23+
24+
- [1.3.1, Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)
25+
- [3.3.2, Labels or Instructions](https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html)
26+
- [4.1.2, Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
27+
28+
It is also associated with this common failure:
29+
30+
- [#68: Failure of Success Criterion 4.1.2 due to a user interface control not having a programmatically determined name](https://www.w3.org/WAI/WCAG21/Techniques/failures/F68)
31+
32+
This rule checks to see if the input is contained by a label element. If it is
33+
not, it checks to see if the input has any of these three attributes: `id`,
34+
`aria-label`, or `aria-labelledby`. While the `id` element on the input is not
35+
a concrete indicator of the presence of an associated `<label>` element with a
36+
`for` attribute, it is a good indicator that one likely exists.
37+
38+
This rule does not allow an input to use a `title` attribute for a valid label.
39+
This is because implementation by browsers is unreliable and incomplete.
40+
41+
This rule is unable to determine if a valid label is present if `...attributes`
42+
is used, and must allow it to pass. However, developers are encouraged to write
43+
tests to ensure that a valid label is present for each input element present.
44+
45+
## Examples
46+
47+
This rule **forbids** the following:
48+
49+
```gjs
50+
<template>
51+
<div><input /></div>
52+
</template>
53+
```
54+
55+
```gjs
56+
<template>
57+
<input title="some label text" />
58+
</template>
59+
```
60+
61+
```gjs
62+
<template>
63+
<textarea />
64+
</template>
65+
```
66+
67+
This rule **allows** the following:
68+
69+
```gjs
70+
<template>
71+
<label>Some Label Text<input /></label>
72+
</template>
73+
```
74+
75+
```gjs
76+
<template>
77+
<input id="someId" />
78+
</template>
79+
```
80+
81+
```gjs
82+
<template>
83+
<input aria-label="Label Text Here" />
84+
</template>
85+
```
86+
87+
```gjs
88+
<template>
89+
<input aria-labelledby="someButtonId" />
90+
</template>
91+
```
92+
93+
```gjs
94+
<template>
95+
<input ...attributes />
96+
</template>
97+
```
98+
99+
```gjs
100+
<template>
101+
<input type="hidden" />
102+
</template>
103+
```
104+
105+
## Migration
106+
107+
- the recommended fix is to add an associated label element.
108+
- another option is to add an aria-label to the input element.
109+
- wrapping the input element in a label element is also allowed; however this is less flexible for styling purposes, so use with awareness.
110+
111+
## Configuration
112+
113+
- boolean - `true` to enable / `false` to disable
114+
- object -- An object with the following keys:
115+
- `labelTags` -- An array of component names for that may be used as label replacements (in addition to the HTML `label` tag)
116+
117+
## References
118+
119+
- [Understanding Success Criterion 1.3.1: Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships)
120+
- [Understanding Success Criterion 3.3.2: Labels or Instructions](https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html)
121+
- [Understanding Success Criterion 4.1.2: Name, Role, Value](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html)
122+
- [Using label elements to associate text labels and form controls](https://www.w3.org/WAI/WCAG21/Techniques/html/H44.html)
123+
- [Using aria-labelledby to provide a name for user interface controls](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA16)
124+
- [Using aria-label to provide an invisible label where a visible label cannot be used](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA14.html)
125+
- [Failure due to a user interface control not having a programmatically determined name](https://www.w3.org/WAI/WCAG21/Techniques/failures/F68)
126+
- [Failure due to visually formatting a set of phone number fields but not including a text label](https://www.w3.org/WAI/WCAG21/Techniques/failures/F82)
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
function hasAttr(node, name) {
2+
return node.attributes?.some((a) => a.name === name);
3+
}
4+
5+
function isString(value) {
6+
return typeof value === 'string';
7+
}
8+
9+
function isRegExp(value) {
10+
return value instanceof RegExp;
11+
}
12+
13+
function allowedFormat(value) {
14+
return isString(value) || isRegExp(value);
15+
}
16+
17+
function parseConfig(config) {
18+
if (config === false) {
19+
return false;
20+
}
21+
22+
if (config === true || config === undefined) {
23+
return { labelTags: ['label'] };
24+
}
25+
26+
if (config && typeof config === 'object' && Array.isArray(config.labelTags)) {
27+
return {
28+
labelTags: ['label', ...config.labelTags.filter(allowedFormat)],
29+
};
30+
}
31+
32+
return { labelTags: ['label'] };
33+
}
34+
35+
function matchesLabelTag(tag, configuredTag) {
36+
return isRegExp(configuredTag) ? configuredTag.test(tag) : configuredTag === tag;
37+
}
38+
39+
/** @type {import('eslint').Rule.RuleModule} */
40+
module.exports = {
41+
meta: {
42+
type: 'suggestion',
43+
docs: {
44+
description: 'require label for form input elements',
45+
category: 'Accessibility',
46+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-input-label.md',
47+
templateMode: 'both',
48+
},
49+
schema: [
50+
{
51+
anyOf: [
52+
{ type: 'boolean' },
53+
{
54+
type: 'object',
55+
properties: {
56+
labelTags: {
57+
type: 'array',
58+
},
59+
},
60+
additionalProperties: false,
61+
},
62+
],
63+
},
64+
],
65+
messages: {
66+
requireLabel: 'form elements require a valid associated label.',
67+
multipleLabels: 'form elements should not have multiple labels.',
68+
},
69+
originallyFrom: {
70+
name: 'ember-template-lint',
71+
rule: 'lib/rules/require-input-label.js',
72+
docs: 'docs/rule/require-input-label.md',
73+
tests: 'test/unit/rules/require-input-label-test.js',
74+
},
75+
},
76+
77+
create(context) {
78+
const config = parseConfig(context.options[0]);
79+
if (config === false) {
80+
return {};
81+
}
82+
83+
const filename = context.getFilename();
84+
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');
85+
const elementStack = [];
86+
87+
function hasValidLabelParent() {
88+
for (let i = elementStack.length - 1; i >= 0; i--) {
89+
const entry = elementStack[i];
90+
const hasMatchingLabelTag = config.labelTags.some((configuredTag) =>
91+
matchesLabelTag(entry.tag, configuredTag)
92+
);
93+
94+
if (hasMatchingLabelTag) {
95+
if (entry.tag !== 'label') {
96+
return true;
97+
}
98+
99+
const children = entry.node.children || [];
100+
return children.length > 1;
101+
}
102+
}
103+
return false;
104+
}
105+
106+
return {
107+
GlimmerElementNode(node) {
108+
elementStack.push({ tag: node.tag, node });
109+
110+
if (isStrictMode && (node.tag === 'Input' || node.tag === 'Textarea')) {
111+
return;
112+
}
113+
114+
const tagName = node.tag?.toLowerCase();
115+
if (tagName !== 'input' && tagName !== 'textarea' && tagName !== 'select') {
116+
return;
117+
}
118+
119+
// Skip if input has type="hidden"
120+
const typeAttr = node.attributes?.find((a) => a.name === 'type');
121+
if (typeAttr?.value?.type === 'GlimmerTextNode' && typeAttr.value.chars === 'hidden') {
122+
return;
123+
}
124+
125+
// Skip if has ...attributes (can't determine labelling)
126+
if (hasAttr(node, '...attributes')) {
127+
return;
128+
}
129+
130+
let labelCount = 0;
131+
const validLabel = hasValidLabelParent();
132+
if (validLabel) {
133+
labelCount++;
134+
}
135+
136+
const hasId = hasAttr(node, 'id');
137+
const hasAriaLabel = hasAttr(node, 'aria-label');
138+
const hasAriaLabelledBy = hasAttr(node, 'aria-labelledby');
139+
if (hasId) {
140+
labelCount++;
141+
}
142+
if (hasAriaLabel) {
143+
labelCount++;
144+
}
145+
if (hasAriaLabelledBy) {
146+
labelCount++;
147+
}
148+
149+
if (labelCount === 1) {
150+
return;
151+
}
152+
153+
if (validLabel && hasId) {
154+
return;
155+
}
156+
157+
context.report({
158+
node,
159+
messageId: labelCount === 0 ? 'requireLabel' : 'multipleLabels',
160+
});
161+
},
162+
'GlimmerElementNode:exit'() {
163+
elementStack.pop();
164+
},
165+
166+
GlimmerMustacheStatement(node) {
167+
const name = node.path?.original;
168+
if (name !== 'input' && name !== 'textarea') {
169+
return;
170+
}
171+
172+
const pairs = node.hash?.pairs || [];
173+
174+
function hasPair(key) {
175+
return pairs.some((p) => p.key === key);
176+
}
177+
178+
// Skip if type="hidden" (literal string only)
179+
const typePair = pairs.find((p) => p.key === 'type');
180+
if (typePair?.value?.type === 'GlimmerStringLiteral' && typePair.value.value === 'hidden') {
181+
return;
182+
}
183+
184+
// If in a valid label, it's valid
185+
if (hasValidLabelParent()) {
186+
return;
187+
}
188+
189+
// If has id, it's valid
190+
if (hasPair('id')) {
191+
return;
192+
}
193+
194+
context.report({
195+
node,
196+
messageId: 'requireLabel',
197+
});
198+
},
199+
};
200+
},
201+
};

0 commit comments

Comments
 (0)