Skip to content

Commit a0aabb4

Browse files
committed
feat: add template-valid-label-for — validate label[for] targets labelable controls
1 parent 414d6d5 commit a0aabb4

4 files changed

Lines changed: 570 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
290290
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | 📋 | | |
291291
| [template-require-valid-form-groups](docs/rules/template-require-valid-form-groups.md) | require grouped form controls to have fieldset/legend or WAI-ARIA group labeling | | | |
292292
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | 📋 | | |
293+
| [template-valid-label-for](docs/rules/template-valid-label-for.md) | require `<label for>` to point at a labelable form control | | | |
293294

294295
### Best Practices
295296

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# ember/template-valid-label-for
2+
3+
<!-- end auto-generated rule header -->
4+
5+
This rule validates that `<label for="x">` references a labelable form
6+
control (`<input>` — except `type="hidden"``<select>`, `<textarea>`,
7+
`<button>`, `<meter>`, `<output>`, `<progress>`, plus Ember's built-in
8+
`<Input>` / `<Textarea>`) defined in the same template.
9+
10+
It also flags `for` as redundant when the referenced element is the one
11+
that HTML's implicit-containment rule would have bound anyway — i.e. the
12+
**first labelable descendant** of the `<label>` (per HTML §4.10.4). When
13+
a label contains multiple labelable descendants and `for` points at a
14+
non-first one, the author is deliberately overriding the implicit choice;
15+
this is not redundant and is NOT flagged.
16+
17+
Only the label side is checked. Use `template-require-input-label` for the
18+
other direction (every input should have a label).
19+
20+
## Examples
21+
22+
This rule **forbids** the following:
23+
24+
```hbs
25+
<label for='first-name'>First name</label>
26+
<div id='first-name'>text</div>
27+
28+
<label for='email'>
29+
Email
30+
<input id='email' />
31+
</label>
32+
```
33+
34+
This rule **allows** the following:
35+
36+
```hbs
37+
<label for='first-name'>First name</label>
38+
<input id='first-name' />
39+
40+
<label for='country'>Country</label>
41+
<select id='country'><option>NO</option></select>
42+
43+
{{! Nested association — for attribute omitted. }}
44+
<label>
45+
Email
46+
<input />
47+
</label>
48+
49+
{{! Dynamic for / id — skipped. }}
50+
<label for={{this.fieldId}}>Dynamic</label>
51+
```
52+
53+
## Limitations
54+
55+
- Dynamic `for` or `id` values (mustache) are skipped.
56+
- Targets that live outside the template file (rendered by a yielded
57+
component or a partial) can't be validated and are silently ignored.
58+
- Multiple occurrences of the same `id` are tracked as the first one seen;
59+
`template-no-duplicate-id` handles the duplicate case separately.
60+
- Classic curly-helper invocations like `{{input id="x"}}` or
61+
`{{textarea id="x"}}` are not collected as label targets — only angle-bracket
62+
element forms contribute `id`s. A `<label for="x">` that points at a
63+
curly-helper-rendered control is **silently ignored** by the rule (the
64+
association is not visible to the static analyzer; the rule does not report
65+
it as missing, but it also can't validate it). Use the angle-bracket form
66+
(`<Input id="x" />`, `<Textarea id="x" />`, or a native `<input id="x" />`)
67+
when you need the rule to see the association.
68+
- **Scope:** native HTML labelable controls plus Ember's built-in `<Input>`
69+
and `<Textarea>` components (which render to `<input>` / `<textarea>` and
70+
accept `id=` forwarding, so they are valid `<label for>` targets).
71+
Resolution depends on template mode:
72+
- **Classic Handlebars (`.hbs`):** `<Input>` / `<Textarea>` always resolve
73+
globally to the built-in — treated as labelable.
74+
- **Strict GJS/GTS (`.gjs` / `.gts`):** the rule inspects the file's
75+
`import` declarations. A PascalCase tag is treated as a built-in
76+
labelable component if and only if it's imported from
77+
`@ember/component` — whether bound under the original name
78+
(`import { Input }`) or a local alias (`import { Input as MyInput }`).
79+
Imports from other modules (custom libraries, local components) are
80+
NOT recognized as labelable.
81+
82+
Other components — custom labelable wrappers, component compositions —
83+
are not detected. Rewrite to native controls, use Ember's built-in, or
84+
suppress on a case-by-case basis.
85+
86+
## References
87+
88+
- [HTML spec: Labelable elements](https://html.spec.whatwg.org/multipage/forms.html#category-label)
89+
- Adapted from [`html-validate`'s `valid-for`](https://html-validate.org/rules/valid-for.html) (MIT).
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
'use strict';
2+
3+
// See html-validate (https://html-validate.org/rules/input-missing-label.html) for the related peer rule.
4+
//
5+
// Validates <label for="x"> in two ways:
6+
// 1. Points to a labelable HTML control defined in the same template
7+
// (not a <div> or other arbitrary element).
8+
// 2. If the target is already nested inside the label, flag it as
9+
// redundant (the `for` adds nothing — the nested element is
10+
// already associated via the containment rule).
11+
//
12+
// Dynamic `for` values (mustache) are skipped. Targets we can't find in
13+
// this template are also skipped (partial templates, yielded content).
14+
15+
const LABELABLE_TAGS = new Set([
16+
'button',
17+
'input',
18+
'meter',
19+
'output',
20+
'progress',
21+
'select',
22+
'textarea',
23+
]);
24+
25+
// Ember's built-in <Input> / <Textarea> components render to native <input>
26+
// and <textarea> and accept `id=` forwarding — they are valid targets for
27+
// <label for="…">. In classic Handlebars `<Input>` always resolves globally
28+
// to the built-in; in strict GJS/GTS the tag must be explicitly imported.
29+
// Resolution logic:
30+
//
31+
// 1. PascalCase tag with a local import binding → check whether the
32+
// import source is `@ember/component` and the imported name is
33+
// `Input` / `Textarea`. If so, the local alias (e.g. `<MyInput>`)
34+
// still resolves to the built-in → labelable. Imports from other
35+
// modules → NOT labelable (false-negative acceptable).
36+
// 2. PascalCase tag with no local import binding in classic HBS (no
37+
// import scope) → global resolution; treat as the built-in.
38+
// In strict GJS/GTS, no import binding → NOT the built-in.
39+
const EMBER_BUILTIN_FORM_COMPONENTS = new Set(['Input', 'Textarea']);
40+
41+
// Cache of imports parsed once per sourceCode. Key is sourceCode (a fresh
42+
// object per ESLint traversal); value is a Map<localName, importedName|null>
43+
// where null means "bound to an import from some other module". Turns a
44+
// per-call O(n) scan of ast.body into an amortized O(1) lookup per tag.
45+
const IMPORT_CACHE = new WeakMap();
46+
47+
function getImportedComponents(sourceCode) {
48+
if (!sourceCode) {
49+
return null;
50+
}
51+
let cached = IMPORT_CACHE.get(sourceCode);
52+
if (cached) {
53+
return cached;
54+
}
55+
const ast = sourceCode.ast;
56+
if (!ast || !Array.isArray(ast.body)) {
57+
return null;
58+
}
59+
cached = new Map();
60+
for (const decl of ast.body) {
61+
if (decl.type !== 'ImportDeclaration') {
62+
continue;
63+
}
64+
const fromEmberComponent = decl.source?.value === '@ember/component';
65+
for (const specifier of decl.specifiers) {
66+
const local = specifier.local?.name;
67+
if (!local) {
68+
continue;
69+
}
70+
if (!fromEmberComponent) {
71+
// Local binding exists but points outside @ember/component → not
72+
// the built-in. Record null so we short-circuit future lookups.
73+
cached.set(local, null);
74+
continue;
75+
}
76+
// Only named imports (`import { Input }`, `import { Input as X }`)
77+
// introduce a built-in binding. Default and namespace imports from
78+
// @ember/component are not the form components — skip them.
79+
if (specifier.type !== 'ImportSpecifier') {
80+
continue;
81+
}
82+
cached.set(local, specifier.imported?.name);
83+
}
84+
}
85+
IMPORT_CACHE.set(sourceCode, cached);
86+
return cached;
87+
}
88+
89+
function resolvesToEmberFormComponent(tagName, sourceCode, isStrictMode) {
90+
if (!tagName) {
91+
return false;
92+
}
93+
if (!isStrictMode) {
94+
// Classic HBS: <Input>/<Textarea> resolve globally to the built-in.
95+
return EMBER_BUILTIN_FORM_COMPONENTS.has(tagName);
96+
}
97+
const imports = getImportedComponents(sourceCode);
98+
if (!imports) {
99+
return false;
100+
}
101+
if (imports.has(tagName)) {
102+
const importedName = imports.get(tagName);
103+
return importedName !== null && EMBER_BUILTIN_FORM_COMPONENTS.has(importedName);
104+
}
105+
// No import binding in strict GJS/GTS — not the built-in.
106+
return false;
107+
}
108+
109+
function findAttr(node, name) {
110+
return node.attributes?.find((attr) => attr.name === name);
111+
}
112+
113+
function getStaticAttrString(node, name) {
114+
const attr = findAttr(node, name);
115+
if (!attr || !attr.value || attr.value.type !== 'GlimmerTextNode') {
116+
return null;
117+
}
118+
return attr.value.chars;
119+
}
120+
121+
function isInputHidden(node, sourceCode, isStrictMode) {
122+
// Native <input type="hidden">.
123+
if (node.tag === 'input') {
124+
const type = getStaticAttrString(node, 'type');
125+
return type !== null && type.toLowerCase() === 'hidden';
126+
}
127+
// Ember <Input type="hidden"> (including aliased imports). Renders to a
128+
// native <input type="hidden"> → not labelable for the same reason.
129+
if (resolvesToEmberFormComponent(node.tag, sourceCode, isStrictMode)) {
130+
// Only <Input> carries a type= attribute; <Textarea> never has hidden
131+
// semantics. But check the attr regardless — cheap and keeps the
132+
// predicate symmetric.
133+
const type = getStaticAttrString(node, 'type');
134+
return type !== null && type.toLowerCase() === 'hidden';
135+
}
136+
return false;
137+
}
138+
139+
function isLabelable(node, sourceCode, isStrictMode) {
140+
if (!node || node.type !== 'GlimmerElementNode') {
141+
return false;
142+
}
143+
if (isInputHidden(node, sourceCode, isStrictMode)) {
144+
return false;
145+
}
146+
if (resolvesToEmberFormComponent(node.tag, sourceCode, isStrictMode)) {
147+
return true;
148+
}
149+
if (!LABELABLE_TAGS.has(node.tag)) {
150+
return false;
151+
}
152+
return true;
153+
}
154+
155+
function isDescendant(candidate, ancestor) {
156+
let current = candidate.parent;
157+
while (current) {
158+
if (current === ancestor) {
159+
return true;
160+
}
161+
current = current.parent;
162+
}
163+
return false;
164+
}
165+
166+
// Per HTML §4.10.4 ("The label element"), a label with BOTH `for=` and a
167+
// labelable descendant binds to the `for`-referenced element — the
168+
// containment rule is the *implicit* binding and only applies when no
169+
// `for` is present. When `for` is present, it wins.
170+
//
171+
// So `redundantFor` should only fire when the `for` target is the same
172+
// element that would have been the implicit control — i.e. the FIRST
173+
// labelable descendant (HTML uses "first labelable element in tree order
174+
// that is a descendant of the label element", excluding hidden inputs).
175+
// A label with multiple labelable descendants and `for=` pointing at a
176+
// non-first one is expressing an explicit choice and must not be flagged.
177+
function findFirstLabelableDescendant(node, sourceCode, isStrictMode) {
178+
if (!node.children) {
179+
return null;
180+
}
181+
for (const child of node.children) {
182+
if (!child || child.type !== 'GlimmerElementNode') {
183+
continue;
184+
}
185+
if (isLabelable(child, sourceCode, isStrictMode)) {
186+
return child;
187+
}
188+
const nested = findFirstLabelableDescendant(child, sourceCode, isStrictMode);
189+
if (nested) {
190+
return nested;
191+
}
192+
}
193+
return null;
194+
}
195+
196+
/** @type {import('eslint').Rule.RuleModule} */
197+
module.exports = {
198+
meta: {
199+
type: 'problem',
200+
docs: {
201+
description: 'require `<label for>` to point at a labelable form control',
202+
category: 'Accessibility',
203+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-valid-label-for.md',
204+
templateMode: 'both',
205+
},
206+
fixable: null,
207+
schema: [],
208+
messages: {
209+
notLabelable:
210+
'`<label for="{{id}}">` must reference a labelable form control (`<input>`, `<select>`, `<textarea>`, `<button>`, `<meter>`, `<output>`, `<progress>`, or Ember `<Input>` / `<Textarea>`)',
211+
redundantFor:
212+
'`for="{{id}}"` is redundant: `<label>` already contains the referenced element',
213+
},
214+
},
215+
216+
create(context) {
217+
// Per-<template>-block state: multi-template .gjs files compose
218+
// independent DOM subtrees (e.g. `const Foo = <template>…</template>;
219+
// <template>…</template>` in one file). Each block's <label for> must
220+
// bind to ids declared in the same block — ids from sibling templates
221+
// aren't present in the composed DOM at runtime.
222+
let idToElement = new Map();
223+
let labels = [];
224+
225+
const filename = context.filename ?? context.getFilename?.() ?? '';
226+
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');
227+
228+
function resetTemplateState() {
229+
idToElement = new Map();
230+
labels = [];
231+
}
232+
233+
function validateCurrentTemplate() {
234+
const sourceCode = context.sourceCode || context.getSourceCode();
235+
for (const { labelNode, forAttr, forValue } of labels) {
236+
const target = idToElement.get(forValue);
237+
if (!target) {
238+
continue;
239+
}
240+
if (!isLabelable(target, sourceCode, isStrictMode)) {
241+
context.report({
242+
node: forAttr,
243+
messageId: 'notLabelable',
244+
data: { id: forValue },
245+
});
246+
continue;
247+
}
248+
if (isDescendant(target, labelNode)) {
249+
// Only redundant when `for` resolves to the SAME element that
250+
// the implicit-containment rule would bind — the first
251+
// labelable descendant. If `for` points at a later labelable
252+
// descendant, the author is overriding the implicit choice,
253+
// which is not redundant.
254+
const implicit = findFirstLabelableDescendant(labelNode, sourceCode, isStrictMode);
255+
if (implicit && implicit === target) {
256+
context.report({
257+
node: forAttr,
258+
messageId: 'redundantFor',
259+
data: { id: forValue },
260+
});
261+
}
262+
}
263+
}
264+
resetTemplateState();
265+
}
266+
267+
return {
268+
// Multi-template .gjs: reset state on each <template> entry so ids
269+
// from earlier templates don't leak into the next one's for= resolution.
270+
GlimmerTemplate() {
271+
resetTemplateState();
272+
},
273+
'GlimmerTemplate:exit'() {
274+
validateCurrentTemplate();
275+
},
276+
GlimmerElementNode(node) {
277+
const idValue = getStaticAttrString(node, 'id');
278+
if (idValue && !idToElement.has(idValue)) {
279+
idToElement.set(idValue, node);
280+
}
281+
if (node.tag === 'label') {
282+
const forAttr = findAttr(node, 'for');
283+
const forValue = getStaticAttrString(node, 'for');
284+
if (forAttr && forValue) {
285+
labels.push({ labelNode: node, forAttr, forValue });
286+
}
287+
}
288+
},
289+
'Program:exit'() {
290+
// Fallback for .hbs (no GlimmerTemplate wrapper) — if
291+
// GlimmerTemplate:exit never fired, any pending labels/ids here are
292+
// from the single implicit template and need validation.
293+
if (labels.length > 0 || idToElement.size > 0) {
294+
validateCurrentTemplate();
295+
}
296+
},
297+
};
298+
},
299+
};

0 commit comments

Comments
 (0)