Skip to content

Commit 8e893bd

Browse files
committed
Extract rule: template-require-input-label
1 parent e05b4fb commit 8e893bd

4 files changed

Lines changed: 690 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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ember/template-require-input-label
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Require form input elements to have an associated label for accessibility.
6+
7+
## Rule Details
8+
9+
This rule enforces that input, textarea, and select elements have a way to be labeled, either through an `id` attribute (which can be referenced by a `<label for="...">`) or through `aria-label` or `aria-labelledby` attributes.
10+
11+
## Examples
12+
13+
Examples of **incorrect** code for this rule:
14+
15+
```gjs
16+
<template>
17+
<input type="text" />
18+
</template>
19+
20+
<template>
21+
<textarea></textarea>
22+
</template>
23+
24+
<template>
25+
<select>
26+
<option>Option 1</option>
27+
</select>
28+
</template>
29+
```
30+
31+
Examples of **correct** code for this rule:
32+
33+
```gjs
34+
<template>
35+
<label for="name">Name:</label>
36+
<input id="name" type="text" />
37+
</template>
38+
39+
<template>
40+
<input aria-label="Name" type="text" />
41+
</template>
42+
43+
<template>
44+
<input aria-labelledby="name-label" type="text" />
45+
</template>
46+
47+
<template>
48+
<input type="hidden" />
49+
</template>
50+
```
51+
52+
## Migration
53+
54+
- the recommended fix is to add an associated label element.
55+
- another option is to add an aria-label to the input element.
56+
- wrapping the input element in a label element is also allowed; however this is less flexible for styling purposes, so use with awareness.
57+
58+
## Options
59+
60+
| Name | Type | Default | Description |
61+
| ----------- | ---------- | ------- | -------------------------------------------------------------------- |
62+
| `labelTags` | `string[]` | `[]` | Additional tag names to treat as label elements (besides `<label>`). |
63+
64+
## References
65+
66+
- [WCAG 2.1 - Labels or Instructions](https://www.w3.org/WAI/WCAG21/Understanding/labels-or-instructions.html)
67+
- [MDN - aria-label](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-label)
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
function hasAttr(node, name) {
2+
return node.attributes?.some((a) => a.name === name);
3+
}
4+
5+
/** @type {import('eslint').Rule.RuleModule} */
6+
module.exports = {
7+
meta: {
8+
type: 'suggestion',
9+
docs: {
10+
description: 'require label for form input elements',
11+
category: 'Accessibility',
12+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-require-input-label.md',
13+
templateMode: 'both',
14+
},
15+
schema: [
16+
{
17+
type: 'object',
18+
properties: {
19+
labelTags: {
20+
type: 'array',
21+
items: { type: 'string' },
22+
},
23+
},
24+
additionalProperties: false,
25+
},
26+
],
27+
messages: {
28+
requireLabel: 'Input elements should have an associated label.',
29+
multipleLabels: 'Input element has multiple labelling mechanisms.',
30+
},
31+
originallyFrom: {
32+
name: 'ember-template-lint',
33+
rule: 'lib/rules/require-input-label.js',
34+
docs: 'docs/rule/require-input-label.md',
35+
tests: 'test/unit/rules/require-input-label-test.js',
36+
},
37+
},
38+
39+
create(context) {
40+
const options = context.options[0] || {};
41+
const customLabelTags = options.labelTags || [];
42+
const labelTags = new Set(['label', ...customLabelTags]);
43+
const elementStack = [];
44+
45+
function hasValidLabelParent() {
46+
for (let i = elementStack.length - 1; i >= 0; i--) {
47+
const entry = elementStack[i];
48+
if (labelTags.has(entry.tag)) {
49+
// Custom label tags (not 'label') are always considered valid
50+
if (entry.tag !== 'label') {
51+
return true;
52+
}
53+
// For 'label' tag, valid only if it has more than one child (text content + input)
54+
const children = entry.node.children || [];
55+
return children.length > 1;
56+
}
57+
}
58+
return false;
59+
}
60+
61+
return {
62+
GlimmerElementNode(node) {
63+
elementStack.push({ tag: node.tag, node });
64+
65+
const tagName = node.tag?.toLowerCase();
66+
if (tagName !== 'input' && tagName !== 'textarea' && tagName !== 'select') {
67+
return;
68+
}
69+
70+
// Skip if input has type="hidden"
71+
const typeAttr = node.attributes?.find((a) => a.name === 'type');
72+
if (typeAttr?.value?.type === 'GlimmerTextNode' && typeAttr.value.chars === 'hidden') {
73+
return;
74+
}
75+
76+
// Skip if has ...attributes (can't determine labelling)
77+
if (hasAttr(node, '...attributes')) {
78+
return;
79+
}
80+
81+
let labelCount = 0;
82+
const validLabel = hasValidLabelParent();
83+
if (validLabel) {
84+
labelCount++;
85+
}
86+
87+
const hasId = hasAttr(node, 'id');
88+
const hasAriaLabel = hasAttr(node, 'aria-label');
89+
const hasAriaLabelledBy = hasAttr(node, 'aria-labelledby');
90+
if (hasId) {
91+
labelCount++;
92+
}
93+
if (hasAriaLabel) {
94+
labelCount++;
95+
}
96+
if (hasAriaLabelledBy) {
97+
labelCount++;
98+
}
99+
100+
if (labelCount === 1) {
101+
return;
102+
}
103+
104+
// Special case: label parent + id is OK (common pattern)
105+
if (validLabel && hasId) {
106+
return;
107+
}
108+
109+
context.report({
110+
node,
111+
messageId: labelCount === 0 ? 'requireLabel' : 'multipleLabels',
112+
});
113+
},
114+
'GlimmerElementNode:exit'() {
115+
elementStack.pop();
116+
},
117+
118+
GlimmerMustacheStatement(node) {
119+
const name = node.path?.original;
120+
if (name !== 'input' && name !== 'textarea') {
121+
return;
122+
}
123+
124+
const pairs = node.hash?.pairs || [];
125+
126+
function hasPair(key) {
127+
return pairs.some((p) => p.key === key);
128+
}
129+
130+
// Skip if type="hidden" (literal string only)
131+
const typePair = pairs.find((p) => p.key === 'type');
132+
if (typePair?.value?.type === 'GlimmerStringLiteral' && typePair.value.value === 'hidden') {
133+
return;
134+
}
135+
136+
// If in a valid label, it's valid
137+
if (hasValidLabelParent()) {
138+
return;
139+
}
140+
141+
// If has id, it's valid
142+
if (hasPair('id')) {
143+
return;
144+
}
145+
146+
context.report({
147+
node,
148+
messageId: 'requireLabel',
149+
});
150+
},
151+
};
152+
},
153+
};

0 commit comments

Comments
 (0)