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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le
| [template-require-valid-alt-text](docs/rules/template-require-valid-alt-text.md) | require valid alt text for images and other elements | 📋 | | |
| [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 | | | |
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | 📋 | | |
| [template-valid-label-for](docs/rules/template-valid-label-for.md) | require `<label for>` to point at a labelable form control | | | |

### Best Practices

Expand Down
89 changes: 89 additions & 0 deletions docs/rules/template-valid-label-for.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ember/template-valid-label-for

<!-- end auto-generated rule header -->

This rule validates that `<label for="x">` references a labelable form
control (`<input>` — except `type="hidden"` — `<select>`, `<textarea>`,
`<button>`, `<meter>`, `<output>`, `<progress>`, plus Ember's built-in
`<Input>` / `<Textarea>`) defined in the same template.

It also flags `for` as redundant when the referenced element is the one
that HTML's implicit-containment rule would have bound anyway — i.e. the
**first labelable descendant** of the `<label>` (per HTML §4.10.4). When
a label contains multiple labelable descendants and `for` points at a
non-first one, the author is deliberately overriding the implicit choice;
this is not redundant and is NOT flagged.

Only the label side is checked. Use `template-require-input-label` for the
other direction (every input should have a label).

## Examples

This rule **forbids** the following:

```hbs
<label for='first-name'>First name</label>
<div id='first-name'>text</div>

<label for='email'>
Email
<input id='email' />
</label>
```

This rule **allows** the following:

```hbs
<label for='first-name'>First name</label>
<input id='first-name' />

<label for='country'>Country</label>
<select id='country'><option>NO</option></select>

{{! Nested association — for attribute omitted. }}
<label>
Email
<input />
</label>

{{! Dynamic for / id — skipped. }}
<label for={{this.fieldId}}>Dynamic</label>
```

## Limitations

- Dynamic `for` or `id` values (mustache) are skipped.
- Targets that live outside the template file (rendered by a yielded
component or a partial) can't be validated and are silently ignored.
- Multiple occurrences of the same `id` are tracked as the first one seen;
`template-no-duplicate-id` handles the duplicate case separately.
- Classic curly-helper invocations like `{{input id="x"}}` or
`{{textarea id="x"}}` are not collected as label targets — only angle-bracket
element forms contribute `id`s. A `<label for="x">` that points at a
curly-helper-rendered control is **silently ignored** by the rule (the
association is not visible to the static analyzer; the rule does not report
it as missing, but it also can't validate it). Use the angle-bracket form
(`<Input id="x" />`, `<Textarea id="x" />`, or a native `<input id="x" />`)
when you need the rule to see the association.
- **Scope:** native HTML labelable controls plus Ember's built-in `<Input>`
and `<Textarea>` components (which render to `<input>` / `<textarea>` and
accept `id=` forwarding, so they are valid `<label for>` targets).
Resolution depends on template mode:
- **Classic Handlebars (`.hbs`):** `<Input>` / `<Textarea>` always resolve
globally to the built-in — treated as labelable.
- **Strict GJS/GTS (`.gjs` / `.gts`):** the rule inspects the file's
`import` declarations. A PascalCase tag is treated as a built-in
labelable component if and only if it's imported from
`@ember/component` — whether bound under the original name
(`import { Input }`) or a local alias (`import { Input as MyInput }`).
Imports from other modules (custom libraries, local components) are
NOT recognized as labelable.

Other components — custom labelable wrappers, component compositions —
are not detected. Rewrite to native controls, use Ember's built-in, or
suppress on a case-by-case basis.

## References

- [HTML spec: Labelable elements](https://html.spec.whatwg.org/multipage/forms.html#category-label)
- Adapted from [`html-validate`'s `valid-for`](https://html-validate.org/rules/valid-for.html) (MIT).
299 changes: 299 additions & 0 deletions lib/rules/template-valid-label-for.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
'use strict';

// See html-validate (https://html-validate.org/rules/valid-for.html) for the related peer rule.
//
// Validates <label for="x"> in two ways:
// 1. Points to a labelable HTML control defined in the same template
// (not a <div> or other arbitrary element).
// 2. If the target is already nested inside the label, flag it as
// redundant (the `for` adds nothing — the nested element is
// already associated via the containment rule).
//
// Dynamic `for` values (mustache) are skipped. Targets we can't find in
// this template are also skipped (partial templates, yielded content).

const LABELABLE_TAGS = new Set([
'button',
'input',
'meter',
'output',
'progress',
'select',
'textarea',
]);

// Ember's built-in <Input> / <Textarea> components render to native <input>
// and <textarea> and accept `id=` forwarding — they are valid targets for
// <label for="…">. In classic Handlebars `<Input>` always resolves globally
// to the built-in; in strict GJS/GTS the tag must be explicitly imported.
// Resolution logic:
//
// 1. PascalCase tag with a local import binding → check whether the
// import source is `@ember/component` and the imported name is
// `Input` / `Textarea`. If so, the local alias (e.g. `<MyInput>`)
// still resolves to the built-in → labelable. Imports from other
// modules → NOT labelable (false-negative acceptable).
// 2. PascalCase tag with no local import binding in classic HBS (no
// import scope) → global resolution; treat as the built-in.
// In strict GJS/GTS, no import binding → NOT the built-in.
const EMBER_BUILTIN_FORM_COMPONENTS = new Set(['Input', 'Textarea']);

// Cache of imports parsed once per sourceCode. Key is sourceCode (a fresh
// object per ESLint traversal); value is a Map<localName, importedName|null>
// where null means "bound to an import from some other module". Turns a
// per-call O(n) scan of ast.body into an amortized O(1) lookup per tag.
const IMPORT_CACHE = new WeakMap();

function getImportedComponents(sourceCode) {
if (!sourceCode) {
return null;
}
let cached = IMPORT_CACHE.get(sourceCode);
if (cached) {
return cached;
}
const ast = sourceCode.ast;
if (!ast || !Array.isArray(ast.body)) {
return null;
}
cached = new Map();
for (const decl of ast.body) {
if (decl.type !== 'ImportDeclaration') {
continue;
}
const fromEmberComponent = decl.source?.value === '@ember/component';
for (const specifier of decl.specifiers) {
const local = specifier.local?.name;
if (!local) {
continue;
}
if (!fromEmberComponent) {
// Local binding exists but points outside @ember/component → not
// the built-in. Record null so we short-circuit future lookups.
cached.set(local, null);
continue;
}
// Only named imports (`import { Input }`, `import { Input as X }`)
// introduce a built-in binding. Default and namespace imports from
// @ember/component are not the form components — skip them.
if (specifier.type !== 'ImportSpecifier') {
continue;
}
cached.set(local, specifier.imported?.name);
}
}
IMPORT_CACHE.set(sourceCode, cached);
return cached;
}

function resolvesToEmberFormComponent(tagName, sourceCode, isStrictMode) {
if (!tagName) {
return false;
}
if (!isStrictMode) {
// Classic HBS: <Input>/<Textarea> resolve globally to the built-in.
return EMBER_BUILTIN_FORM_COMPONENTS.has(tagName);
}
const imports = getImportedComponents(sourceCode);
if (!imports) {
return false;
}
if (imports.has(tagName)) {
const importedName = imports.get(tagName);
return importedName !== null && EMBER_BUILTIN_FORM_COMPONENTS.has(importedName);
}
// No import binding in strict GJS/GTS — not the built-in.
return false;
}

function findAttr(node, name) {
return node.attributes?.find((attr) => attr.name === name);
}

function getStaticAttrString(node, name) {
const attr = findAttr(node, name);
if (!attr || !attr.value || attr.value.type !== 'GlimmerTextNode') {
return null;
}
return attr.value.chars;
}

function isInputHidden(node, sourceCode, isStrictMode) {
// Native <input type="hidden">.
if (node.tag === 'input') {
const type = getStaticAttrString(node, 'type');
return type !== null && type.toLowerCase() === 'hidden';
}
// Ember <Input type="hidden"> (including aliased imports). Renders to a
// native <input type="hidden"> → not labelable for the same reason.
if (resolvesToEmberFormComponent(node.tag, sourceCode, isStrictMode)) {
// Only <Input> carries a type= attribute; <Textarea> never has hidden
// semantics. But check the attr regardless — cheap and keeps the
// predicate symmetric.
const type = getStaticAttrString(node, 'type');
return type !== null && type.toLowerCase() === 'hidden';
}
return false;
}

function isLabelable(node, sourceCode, isStrictMode) {
if (!node || node.type !== 'GlimmerElementNode') {
return false;
}
if (isInputHidden(node, sourceCode, isStrictMode)) {
return false;
}
if (resolvesToEmberFormComponent(node.tag, sourceCode, isStrictMode)) {
return true;
}
if (!LABELABLE_TAGS.has(node.tag)) {
return false;
}
return true;
}

function isDescendant(candidate, ancestor) {
let current = candidate.parent;
while (current) {
if (current === ancestor) {
return true;
}
current = current.parent;
}
return false;
}

// Per HTML §4.10.4 ("The label element"), a label with BOTH `for=` and a
// labelable descendant binds to the `for`-referenced element — the
// containment rule is the *implicit* binding and only applies when no
// `for` is present. When `for` is present, it wins.
//
// So `redundantFor` should only fire when the `for` target is the same
// element that would have been the implicit control — i.e. the FIRST
// labelable descendant (HTML uses "first labelable element in tree order
// that is a descendant of the label element", excluding hidden inputs).
// A label with multiple labelable descendants and `for=` pointing at a
// non-first one is expressing an explicit choice and must not be flagged.
function findFirstLabelableDescendant(node, sourceCode, isStrictMode) {
if (!node.children) {
return null;
}
for (const child of node.children) {
if (!child || child.type !== 'GlimmerElementNode') {
continue;
}
if (isLabelable(child, sourceCode, isStrictMode)) {
return child;
}
const nested = findFirstLabelableDescendant(child, sourceCode, isStrictMode);
if (nested) {
return nested;
}
}
return null;
}

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'require `<label for>` to point at a labelable form control',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-valid-label-for.md',
templateMode: 'both',
},
fixable: null,
schema: [],
messages: {
notLabelable:
'`<label for="{{id}}">` must reference a labelable form control (`<input>`, `<select>`, `<textarea>`, `<button>`, `<meter>`, `<output>`, `<progress>`, or Ember `<Input>` / `<Textarea>`)',
redundantFor:
'`for="{{id}}"` is redundant: `<label>` already contains the referenced element',
},
},

create(context) {
// Per-<template>-block state: multi-template .gjs files compose
// independent DOM subtrees (e.g. `const Foo = <template>…</template>;
// <template>…</template>` in one file). Each block's <label for> must
// bind to ids declared in the same block — ids from sibling templates
// aren't present in the composed DOM at runtime.
let idToElement = new Map();
let labels = [];

const filename = context.filename ?? context.getFilename?.() ?? '';
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');

function resetTemplateState() {
idToElement = new Map();
labels = [];
}

function validateCurrentTemplate() {
const sourceCode = context.sourceCode || context.getSourceCode();
for (const { labelNode, forAttr, forValue } of labels) {
const target = idToElement.get(forValue);
if (!target) {
continue;
}
if (!isLabelable(target, sourceCode, isStrictMode)) {
context.report({
node: forAttr,
messageId: 'notLabelable',
data: { id: forValue },
});
continue;
}
if (isDescendant(target, labelNode)) {
// Only redundant when `for` resolves to the SAME element that
// the implicit-containment rule would bind — the first
// labelable descendant. If `for` points at a later labelable
// descendant, the author is overriding the implicit choice,
// which is not redundant.
const implicit = findFirstLabelableDescendant(labelNode, sourceCode, isStrictMode);
if (implicit && implicit === target) {
context.report({
node: forAttr,
messageId: 'redundantFor',
data: { id: forValue },
});
}
}
}
resetTemplateState();
}

return {
// Multi-template .gjs: reset state on each <template> entry so ids
// from earlier templates don't leak into the next one's for= resolution.
GlimmerTemplate() {
resetTemplateState();
},
'GlimmerTemplate:exit'() {
validateCurrentTemplate();
},
GlimmerElementNode(node) {
const idValue = getStaticAttrString(node, 'id');
if (idValue && !idToElement.has(idValue)) {
idToElement.set(idValue, node);
}
if (node.tag === 'label') {
const forAttr = findAttr(node, 'for');
const forValue = getStaticAttrString(node, 'for');
if (forAttr && forValue) {
labels.push({ labelNode: node, forAttr, forValue });
}
}
},
'Program:exit'() {
// Fallback for .hbs (no GlimmerTemplate wrapper) — if
// GlimmerTemplate:exit never fired, any pending labels/ids here are
// from the single implicit template and need validation.
if (labels.length > 0 || idToElement.size > 0) {
validateCurrentTemplate();
}
},
};
},
};
Loading
Loading