Skip to content

Commit 0ee2dbd

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) 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 7316baf commit 0ee2dbd

3 files changed

Lines changed: 159 additions & 8 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: 65 additions & 8 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,13 @@ 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+
91122
function hasValidLabelParent() {
92123
for (let i = elementStack.length - 1; i >= 0; i--) {
93124
const entry = elementStack[i];
@@ -127,6 +158,14 @@ module.exports = {
127158
GlimmerElementNode(node) {
128159
elementStack.push({ tag: node.tag, node });
129160

161+
// Collect <label for="X"> values for the checkLabelFor option.
162+
if (config.checkLabelFor && node.tag === 'label') {
163+
const forValue = getStaticAttrStr(node, 'for');
164+
if (forValue) {
165+
labelForValues.add(forValue);
166+
}
167+
}
168+
130169
const tag = node.tag;
131170
// Is this tag one we should check?
132171
// - Native <input>/<textarea>/<select> always.
@@ -173,11 +212,18 @@ module.exports = {
173212
return;
174213
}
175214

176-
// id alone is treated as a potential <label for> reference that static
177-
// analysis cannot verify (see vuejs-accessibility form-control-has-label
178-
// for the same rationale). Skip only when no other label is present to
179-
// avoid a false positive when id is combined with aria-label/labelledby.
215+
// id alone is treated as a potential <label for> reference. When
216+
// checkLabelFor is off we can't verify it statically so we skip;
217+
// when it's on we defer the check to Program:exit where we have
218+
// the full set of <label for> values from the same template.
180219
if (labelCount === 0 && hasAttr(node, 'id')) {
220+
if (config.checkLabelFor) {
221+
const idValue = getStaticAttrStr(node, 'id');
222+
if (idValue) {
223+
pendingIdNodes.push({ node, id: idValue });
224+
}
225+
// Dynamic id — can't match statically, fall back to skip.
226+
}
181227
return;
182228
}
183229

@@ -230,6 +276,17 @@ module.exports = {
230276
messageId: 'requireLabel',
231277
});
232278
},
279+
280+
'Program:exit'() {
281+
if (!config.checkLabelFor) {
282+
return;
283+
}
284+
for (const { node, id } of pendingIdNodes) {
285+
if (!labelForValues.has(id)) {
286+
context.report({ node, messageId: 'requireLabel' });
287+
}
288+
}
289+
},
233290
};
234291
},
235292
};

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

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

@@ -216,6 +276,25 @@ hbsRuleTester.run('template-require-input-label', rule, {
216276
code: '<input />',
217277
options: [false],
218278
},
279+
// checkLabelFor: label before/after input within the same .hbs file
280+
{
281+
code: '<label for="email">Email</label><input id="email" />',
282+
options: [{ checkLabelFor: true }],
283+
},
284+
{
285+
code: '<input id="email" /><label for="email">Email</label>',
286+
options: [{ checkLabelFor: true }],
287+
},
288+
// checkLabelFor: id with aria-label is not deferred
289+
{
290+
code: '<input id="email" aria-label="Email" />',
291+
options: [{ checkLabelFor: true }],
292+
},
293+
// checkLabelFor: dynamic id falls back to skip
294+
{
295+
code: '<input id={{this.fieldId}} />',
296+
options: [{ checkLabelFor: true }],
297+
},
219298
],
220299
invalid: [
221300
{
@@ -309,5 +388,19 @@ hbsRuleTester.run('template-require-input-label', rule, {
309388
output: null,
310389
errors: [{ message: MULTIPLE_LABELS }],
311390
},
391+
// checkLabelFor: id with no matching <label for> in the same .hbs file
392+
{
393+
code: '<input id="email" />',
394+
output: null,
395+
options: [{ checkLabelFor: true }],
396+
errors: [{ message: NO_LABEL }],
397+
},
398+
// checkLabelFor: typo in for= attribute
399+
{
400+
code: '<label for="emal">Email</label><input id="email" />',
401+
output: null,
402+
options: [{ checkLabelFor: true }],
403+
errors: [{ message: NO_LABEL }],
404+
},
312405
],
313406
});

0 commit comments

Comments
 (0)