Skip to content

Commit 9236dd0

Browse files
committed
feat(require-input-label): add checkLabelFor option for sibling <label for> verification
Opt-in (default false). When enabled, an input with only a static `id` (no aria-label/labelledby/wrapping label) is verified against the set of static `<label for="X">` values collected from the same template. Inputs without a matching label are flagged. Implementation: - Single visitor pass collects `for` values into a Set - Id-only controls are deferred to Program:exit so forward-declared labels are captured (label after input) - Dynamic `id` / `for` (mustache paths, helper invocations, `(unique-id)` bindings) fall back to the existing skip behaviour — we deliberately don't resolve bindings symbolically - Set lookup keeps cross-reference O(n + m) - collectLabelFor / deferIdOnlyCheck helpers extracted to keep the GlimmerElementNode visitor under the complexity-20 lint budget Default off because Ember apps frequently split label/input across component templates (design system wrappers); enabling without that caveat would false-positive on those patterns. Documented in the rule docs.
1 parent 41bcb2e commit 9236dd0

3 files changed

Lines changed: 166 additions & 5 deletions

File tree

docs/rules/template-require-input-label.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ This rule **allows** the following:
115115
- boolean - `true` to enable / `false` to disable
116116
- object -- An object with the following keys:
117117
- `labelTags` -- An array of component names for that may be used as label replacements (in addition to the HTML `label` tag)
118+
- `checkLabelFor` -- Boolean (default `false`). When `true`, an input with only an `id` attribute (no `aria-label`/`aria-labelledby`/wrapping `<label>`) is verified by looking for a sibling `<label for="X">` with a matching value in the same template. Inputs with no matching label are flagged. Both `id` and `for` must be static strings to be cross-referenced; dynamic values (e.g. `id={{this.fieldId}}` or the common `(unique-id)` helper pattern) fall back to the default skip behaviour. **This option may produce false positives** in apps where labels and form controls live in separate component templates — leave it disabled in that case.
118119

119120
## References
120121

lib/rules/template-require-input-label.js

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@ function hasAttr(node, name) {
22
return node.attributes?.some((a) => a.name === name);
33
}
44

5+
function getStaticAttrStr(node, name) {
6+
const attr = node.attributes?.find((a) => a.name === name);
7+
if (!attr || !attr.value) {
8+
return null;
9+
}
10+
if (attr.value.type === 'GlimmerTextNode') {
11+
return attr.value.chars.trim();
12+
}
13+
if (
14+
attr.value.type === 'GlimmerMustacheStatement' &&
15+
attr.value.path?.type === 'GlimmerStringLiteral'
16+
) {
17+
return attr.value.path.value.trim();
18+
}
19+
return null; // dynamic — can't determine statically
20+
}
21+
522
function isString(value) {
623
return typeof value === 'string';
724
}
@@ -20,16 +37,20 @@ function parseConfig(config) {
2037
}
2138

2239
if (config === true || config === undefined) {
23-
return { labelTags: ['label'] };
40+
return { labelTags: ['label'], checkLabelFor: false };
2441
}
2542

26-
if (config && typeof config === 'object' && Array.isArray(config.labelTags)) {
43+
if (config && typeof config === 'object') {
44+
const labelTags = Array.isArray(config.labelTags)
45+
? ['label', ...config.labelTags.filter(allowedFormat)]
46+
: ['label'];
2747
return {
28-
labelTags: ['label', ...config.labelTags.filter(allowedFormat)],
48+
labelTags,
49+
checkLabelFor: config.checkLabelFor === true,
2950
};
3051
}
3152

32-
return { labelTags: ['label'] };
53+
return { labelTags: ['label'], checkLabelFor: false };
3354
}
3455

3556
function matchesLabelTag(tag, configuredTag) {
@@ -56,6 +77,9 @@ module.exports = {
5677
labelTags: {
5778
type: 'array',
5879
},
80+
checkLabelFor: {
81+
type: 'boolean',
82+
},
5983
},
6084
additionalProperties: false,
6185
},
@@ -88,6 +112,34 @@ module.exports = {
88112
// Only populated in GJS/GTS files via ImportDeclaration
89113
const importedFormComponents = new Map();
90114

115+
// checkLabelFor: collect static <label for="X"> values into a Set and
116+
// defer id-only controls until Program:exit so forward-declared labels
117+
// are captured too. Single visitor pass + O(1) Set lookup keeps the
118+
// cross-reference O(n + m) total (n = label nodes, m = id-only controls).
119+
const labelForValues = new Set();
120+
const pendingIdNodes = []; // { node } — reported if no matching for= found
121+
122+
function collectLabelFor(node) {
123+
if (!config.checkLabelFor || node.tag !== 'label') {
124+
return;
125+
}
126+
const forValue = getStaticAttrStr(node, 'for');
127+
if (forValue) {
128+
labelForValues.add(forValue);
129+
}
130+
}
131+
132+
function deferIdOnlyCheck(node) {
133+
if (!config.checkLabelFor) {
134+
return;
135+
}
136+
const idValue = getStaticAttrStr(node, 'id');
137+
if (idValue) {
138+
pendingIdNodes.push({ node, id: idValue });
139+
}
140+
// Dynamic id — can't match statically, fall back to skip.
141+
}
142+
91143
function hasValidLabelParent() {
92144
for (let i = elementStack.length - 1; i >= 0; i--) {
93145
const entry = elementStack[i];
@@ -126,6 +178,7 @@ module.exports = {
126178

127179
GlimmerElementNode(node) {
128180
elementStack.push({ tag: node.tag, node });
181+
collectLabelFor(node);
129182

130183
const tag = node.tag;
131184
// Is this tag one we should check?
@@ -174,8 +227,11 @@ module.exports = {
174227
// An `id` may pair with a sibling `<label for>` we can't see in this
175228
// template. Treat id-only as valid to avoid false positives, but don't
176229
// count it toward labelCount — otherwise id + aria-label is wrongly
177-
// flagged as multiple labels.
230+
// flagged as multiple labels. With checkLabelFor enabled, the deferred
231+
// helper does collect the id and verifies it against `<label for>`
232+
// values gathered from the same template at Program:exit.
178233
if (labelCount === 0 && hasAttr(node, 'id')) {
234+
deferIdOnlyCheck(node);
179235
return;
180236
}
181237

@@ -228,6 +284,17 @@ module.exports = {
228284
messageId: 'requireLabel',
229285
});
230286
},
287+
288+
'Program:exit'() {
289+
if (!config.checkLabelFor) {
290+
return;
291+
}
292+
for (const { node, id } of pendingIdNodes) {
293+
if (!labelForValues.has(id)) {
294+
context.report({ node, messageId: 'requireLabel' });
295+
}
296+
}
297+
},
231298
};
232299
},
233300
};

tests/lib/rules/template-require-input-label.js

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,45 @@ ruleTester.run('template-require-input-label', rule, {
6969
code: '<template><input /></template>',
7070
options: [false],
7171
},
72+
// checkLabelFor: label before input
73+
{
74+
code: '<template><label for="email">Email</label><input id="email" /></template>',
75+
options: [{ checkLabelFor: true }],
76+
},
77+
// checkLabelFor: label after input (forward reference resolved at Program:exit)
78+
{
79+
code: '<template><input id="email" /><label for="email">Email</label></template>',
80+
options: [{ checkLabelFor: true }],
81+
},
82+
// checkLabelFor: input also has aria-label — not id-only, so not checked
83+
{
84+
code: '<template><input id="email" aria-label="Email" /></template>',
85+
options: [{ checkLabelFor: true }],
86+
},
87+
// checkLabelFor: dynamic id — can't match statically, falls back to skip
88+
{
89+
code: '<template><input id={{this.fieldId}} /></template>',
90+
options: [{ checkLabelFor: true }],
91+
},
92+
// checkLabelFor: the common Ember `(unique-id)` pattern uses dynamic
93+
// bindings on both sides — both for= and id= are dynamic, so neither is
94+
// collected, and the input falls back to the skip-if-id-present branch.
95+
// We deliberately don't resolve the {{#let}} binding symbolically.
96+
{
97+
code: '<template>{{#let (unique-id) as |myId|}}<label for={{myId}}>Name</label><input id={{myId}} />{{/let}}</template>',
98+
options: [{ checkLabelFor: true }],
99+
},
100+
// checkLabelFor: combined with labelTags — a CustomLabel wrapper still
101+
// satisfies the label requirement (same as without checkLabelFor).
102+
{
103+
code: '<template><CustomLabel><input id="email" /></CustomLabel></template>',
104+
options: [{ labelTags: ['CustomLabel'], checkLabelFor: true }],
105+
},
106+
// checkLabelFor: mustache string-literal for= collected correctly
107+
{
108+
code: '<template><label for={{"email"}}>Email</label><input id="email" /></template>',
109+
options: [{ checkLabelFor: true }],
110+
},
72111
],
73112
invalid: [
74113
{
@@ -158,6 +197,27 @@ ruleTester.run('template-require-input-label', rule, {
158197
output: null,
159198
errors: [{ message: NO_LABEL }],
160199
},
200+
// checkLabelFor: id with no matching <label for> in the same template
201+
{
202+
code: '<template><input id="email" /></template>',
203+
output: null,
204+
options: [{ checkLabelFor: true }],
205+
errors: [{ message: NO_LABEL }],
206+
},
207+
// checkLabelFor: id with a mismatched for= (typo)
208+
{
209+
code: '<template><label for="emal">Email</label><input id="email" /></template>',
210+
output: null,
211+
options: [{ checkLabelFor: true }],
212+
errors: [{ message: NO_LABEL }],
213+
},
214+
// checkLabelFor: two inputs, only one has a matching label
215+
{
216+
code: '<template><label for="name">Name</label><input id="name" /><input id="email" /></template>',
217+
output: null,
218+
options: [{ checkLabelFor: true }],
219+
errors: [{ message: NO_LABEL }],
220+
},
161221
],
162222
});
163223

@@ -228,6 +288,25 @@ hbsRuleTester.run('template-require-input-label', rule, {
228288
code: '<input />',
229289
options: [false],
230290
},
291+
// checkLabelFor: label before/after input within the same .hbs file
292+
{
293+
code: '<label for="email">Email</label><input id="email" />',
294+
options: [{ checkLabelFor: true }],
295+
},
296+
{
297+
code: '<input id="email" /><label for="email">Email</label>',
298+
options: [{ checkLabelFor: true }],
299+
},
300+
// checkLabelFor: id with aria-label is not deferred
301+
{
302+
code: '<input id="email" aria-label="Email" />',
303+
options: [{ checkLabelFor: true }],
304+
},
305+
// checkLabelFor: dynamic id falls back to skip
306+
{
307+
code: '<input id={{this.fieldId}} />',
308+
options: [{ checkLabelFor: true }],
309+
},
231310
],
232311
invalid: [
233312
{
@@ -326,5 +405,19 @@ hbsRuleTester.run('template-require-input-label', rule, {
326405
output: null,
327406
errors: [{ message: MULTIPLE_LABELS }],
328407
},
408+
// checkLabelFor: id with no matching <label for> in the same .hbs file
409+
{
410+
code: '<input id="email" />',
411+
output: null,
412+
options: [{ checkLabelFor: true }],
413+
errors: [{ message: NO_LABEL }],
414+
},
415+
// checkLabelFor: typo in for= attribute
416+
{
417+
code: '<label for="emal">Email</label><input id="email" />',
418+
output: null,
419+
options: [{ checkLabelFor: true }],
420+
errors: [{ message: NO_LABEL }],
421+
},
329422
],
330423
});

0 commit comments

Comments
 (0)