Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/rules/template-require-input-label.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ This rule **allows** the following:
- boolean - `true` to enable / `false` to disable
- object -- An object with the following keys:
- `labelTags` -- An array of component names for that may be used as label replacements (in addition to the HTML `label` tag)
- `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.

## References

Expand Down
91 changes: 78 additions & 13 deletions lib/rules/template-require-input-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@ function hasAttr(node, name) {
return node.attributes?.some((a) => a.name === name);
}

function getStaticAttrStr(node, name) {
const attr = node.attributes?.find((a) => a.name === name);
if (!attr || !attr.value) {
return null;
}
if (attr.value.type === 'GlimmerTextNode') {
return attr.value.chars.trim();
}
if (
attr.value.type === 'GlimmerMustacheStatement' &&
attr.value.path?.type === 'GlimmerStringLiteral'
) {
return attr.value.path.value.trim();
}
return null; // dynamic — can't determine statically
}

function isString(value) {
return typeof value === 'string';
}
Expand All @@ -20,16 +37,20 @@ function parseConfig(config) {
}

if (config === true || config === undefined) {
return { labelTags: ['label'] };
return { labelTags: ['label'], checkLabelFor: false };
}

if (config && typeof config === 'object' && Array.isArray(config.labelTags)) {
if (config && typeof config === 'object') {
const labelTags = Array.isArray(config.labelTags)
? ['label', ...config.labelTags.filter(allowedFormat)]
: ['label'];
return {
labelTags: ['label', ...config.labelTags.filter(allowedFormat)],
labelTags,
checkLabelFor: config.checkLabelFor === true,
};
}

return { labelTags: ['label'] };
return { labelTags: ['label'], checkLabelFor: false };
}

function matchesLabelTag(tag, configuredTag) {
Expand All @@ -56,6 +77,9 @@ module.exports = {
labelTags: {
type: 'array',
},
checkLabelFor: {
type: 'boolean',
},
},
additionalProperties: false,
},
Expand Down Expand Up @@ -88,6 +112,34 @@ module.exports = {
// Only populated in GJS/GTS files via ImportDeclaration
const importedFormComponents = new Map();

// checkLabelFor: collect static <label for="X"> values into a Set and
// defer id-only controls until Program:exit so forward-declared labels
// are captured too. Single visitor pass + O(1) Set lookup keeps the
// cross-reference O(n + m) total (n = label nodes, m = id-only controls).
const labelForValues = new Set();
const pendingIdNodes = []; // { node } — reported if no matching for= found

function collectLabelFor(node) {
if (!config.checkLabelFor || node.tag !== 'label') {
return;
}
const forValue = getStaticAttrStr(node, 'for');
if (forValue) {
labelForValues.add(forValue);
}
}

function deferIdOnlyCheck(node) {
if (!config.checkLabelFor) {
return;
}
const idValue = getStaticAttrStr(node, 'id');
if (idValue) {
pendingIdNodes.push({ node, id: idValue });
}
// Dynamic id — can't match statically, fall back to skip.
}

function hasValidLabelParent() {
for (let i = elementStack.length - 1; i >= 0; i--) {
const entry = elementStack[i];
Expand Down Expand Up @@ -126,6 +178,7 @@ module.exports = {

GlimmerElementNode(node) {
elementStack.push({ tag: node.tag, node });
collectLabelFor(node);

const tag = node.tag;
// Is this tag one we should check?
Expand Down Expand Up @@ -160,24 +213,25 @@ module.exports = {
labelCount++;
}

const hasId = hasAttr(node, 'id');
const hasAriaLabel = hasAttr(node, 'aria-label');
const hasAriaLabelledBy = hasAttr(node, 'aria-labelledby');
if (hasId) {
if (hasAttr(node, 'aria-label')) {
labelCount++;
}
if (hasAriaLabel) {
labelCount++;
}
if (hasAriaLabelledBy) {
if (hasAttr(node, 'aria-labelledby')) {
labelCount++;
}

if (labelCount === 1) {
return;
}

if (validLabel && hasId) {
// An `id` may pair with a sibling `<label for>` we can't see in this
// template. Treat id-only as valid to avoid false positives, but don't
// count it toward labelCount — otherwise id + aria-label is wrongly
// flagged as multiple labels. With checkLabelFor enabled, the deferred
// helper does collect the id and verifies it against `<label for>`
// values gathered from the same template at Program:exit.
if (labelCount === 0 && hasAttr(node, 'id')) {
deferIdOnlyCheck(node);
return;
}

Expand Down Expand Up @@ -230,6 +284,17 @@ module.exports = {
messageId: 'requireLabel',
});
},

'Program:exit'() {
if (!config.checkLabelFor) {
return;
}
for (const { node, id } of pendingIdNodes) {
if (!labelForValues.has(id)) {
context.report({ node, messageId: 'requireLabel' });
}
}
},
};
},
};
112 changes: 108 additions & 4 deletions tests/lib/rules/template-require-input-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ ruleTester.run('template-require-input-label', rule, {
'<template><input id="probablyHasLabel" /></template>',
'<template><input aria-label={{labelText}} /></template>',
'<template><input aria-labelledby="someIdValue" /></template>',
// https://github.com/ember-template-lint/ember-template-lint/issues/3388
// id alone does not establish a labeling relationship — a <label for> is
// needed and cannot be verified statically. Only aria-label/labelledby
// should count.
'<template><input id="hello" aria-label="hello" /></template>',
'<template><input id="hello" aria-labelledby="someIdValue" /></template>',
'<template><div></div></template>',
'<template><Input id="foo" /></template>',
'<template>{{input id="foo"}}</template>',
Expand Down Expand Up @@ -63,6 +69,45 @@ ruleTester.run('template-require-input-label', rule, {
code: '<template><input /></template>',
options: [false],
},
// checkLabelFor: label before input
{
code: '<template><label for="email">Email</label><input id="email" /></template>',
options: [{ checkLabelFor: true }],
},
// checkLabelFor: label after input (forward reference resolved at Program:exit)
{
code: '<template><input id="email" /><label for="email">Email</label></template>',
options: [{ checkLabelFor: true }],
},
// checkLabelFor: input also has aria-label — not id-only, so not checked
{
code: '<template><input id="email" aria-label="Email" /></template>',
options: [{ checkLabelFor: true }],
},
// checkLabelFor: dynamic id — can't match statically, falls back to skip
{
code: '<template><input id={{this.fieldId}} /></template>',
options: [{ checkLabelFor: true }],
},
// checkLabelFor: the common Ember `(unique-id)` pattern uses dynamic
// bindings on both sides — both for= and id= are dynamic, so neither is
// collected, and the input falls back to the skip-if-id-present branch.
// We deliberately don't resolve the {{#let}} binding symbolically.
{
code: '<template>{{#let (unique-id) as |myId|}}<label for={{myId}}>Name</label><input id={{myId}} />{{/let}}</template>',
options: [{ checkLabelFor: true }],
},
// checkLabelFor: combined with labelTags — a CustomLabel wrapper still
// satisfies the label requirement (same as without checkLabelFor).
{
code: '<template><CustomLabel><input id="email" /></CustomLabel></template>',
options: [{ labelTags: ['CustomLabel'], checkLabelFor: true }],
},
// checkLabelFor: mustache string-literal for= collected correctly
{
code: '<template><label for={{"email"}}>Email</label><input id="email" /></template>',
options: [{ checkLabelFor: true }],
},
],
invalid: [
{
Expand Down Expand Up @@ -97,12 +142,14 @@ ruleTester.run('template-require-input-label', rule, {
errors: [{ message: MULTIPLE_LABELS }],
},
{
code: '<template><input id="label-input" aria-label="second label"></template>',
code: '<template><label>Input label<input aria-label="Custom label"></label></template>',
output: null,
errors: [{ message: MULTIPLE_LABELS }],
},
{
code: '<template><label>Input label<input aria-label="Custom label"></label></template>',
// id is irrelevant for labelling here — wrapping <label> + aria-label is
// still multiple labels.
code: '<template><label>Input label<input id="foo" aria-label="Custom label"></label></template>',
output: null,
errors: [{ message: MULTIPLE_LABELS }],
},
Expand Down Expand Up @@ -150,6 +197,27 @@ ruleTester.run('template-require-input-label', rule, {
output: null,
errors: [{ message: NO_LABEL }],
},
// checkLabelFor: id with no matching <label for> in the same template
{
code: '<template><input id="email" /></template>',
output: null,
options: [{ checkLabelFor: true }],
errors: [{ message: NO_LABEL }],
},
// checkLabelFor: id with a mismatched for= (typo)
{
code: '<template><label for="emal">Email</label><input id="email" /></template>',
output: null,
options: [{ checkLabelFor: true }],
errors: [{ message: NO_LABEL }],
},
// checkLabelFor: two inputs, only one has a matching label
{
code: '<template><label for="name">Name</label><input id="name" /><input id="email" /></template>',
output: null,
options: [{ checkLabelFor: true }],
errors: [{ message: NO_LABEL }],
},
],
});

Expand All @@ -173,6 +241,9 @@ hbsRuleTester.run('template-require-input-label', rule, {
'<input id="probablyHasLabel" />',
'<input aria-label={{labelText}} />',
'<input aria-labelledby="someIdValue" />',
// https://github.com/ember-template-lint/ember-template-lint/issues/3388
'<input id="hello" aria-label="hello" />',
'<input id="hello" aria-labelledby="someIdValue" />',
'<div></div>',
'<Input id="foo" />',
'{{input id="foo"}}',
Expand Down Expand Up @@ -217,6 +288,25 @@ hbsRuleTester.run('template-require-input-label', rule, {
code: '<input />',
options: [false],
},
// checkLabelFor: label before/after input within the same .hbs file
{
code: '<label for="email">Email</label><input id="email" />',
options: [{ checkLabelFor: true }],
},
{
code: '<input id="email" /><label for="email">Email</label>',
options: [{ checkLabelFor: true }],
},
// checkLabelFor: id with aria-label is not deferred
{
code: '<input id="email" aria-label="Email" />',
options: [{ checkLabelFor: true }],
},
// checkLabelFor: dynamic id falls back to skip
{
code: '<input id={{this.fieldId}} />',
options: [{ checkLabelFor: true }],
},
],
invalid: [
{
Expand Down Expand Up @@ -261,12 +351,12 @@ hbsRuleTester.run('template-require-input-label', rule, {
errors: [{ message: MULTIPLE_LABELS }],
},
{
code: '<input id="label-input" aria-label="second label">',
code: '<label>Input label<input aria-label="Custom label"></label>',
output: null,
errors: [{ message: MULTIPLE_LABELS }],
},
{
code: '<label>Input label<input aria-label="Custom label"></label>',
code: '<label>Input label<input id="foo" aria-label="Custom label"></label>',
output: null,
errors: [{ message: MULTIPLE_LABELS }],
},
Expand Down Expand Up @@ -315,5 +405,19 @@ hbsRuleTester.run('template-require-input-label', rule, {
output: null,
errors: [{ message: MULTIPLE_LABELS }],
},
// checkLabelFor: id with no matching <label for> in the same .hbs file
{
code: '<input id="email" />',
output: null,
options: [{ checkLabelFor: true }],
errors: [{ message: NO_LABEL }],
},
// checkLabelFor: typo in for= attribute
{
code: '<label for="emal">Email</label><input id="email" />',
output: null,
options: [{ checkLabelFor: true }],
errors: [{ message: NO_LABEL }],
},
],
});
Loading