Skip to content

feat(require-input-label): add checkLabelFor option for sibling <label for> verification#2768

Draft
johanrd wants to merge 4 commits intoember-cli:masterfrom
johanrd:feat/require-input-label-check-label-for
Draft

feat(require-input-label): add checkLabelFor option for sibling <label for> verification#2768
johanrd wants to merge 4 commits intoember-cli:masterfrom
johanrd:feat/require-input-label-check-label-for

Conversation

@johanrd
Copy link
Copy Markdown
Contributor

@johanrd johanrd commented Apr 28, 2026

Summary

Adds an opt-in checkLabelFor option to template-require-input-label that verifies an <input id="X"> has a matching <label for="X"> in the same template — catching typos and forgotten labels that the existing rule treats as "probably labelled."

Builds on #2767. The diff is layered on top of that fix and is easiest to read against it. If preferred, review both PRs together here — the underlying bug fix is the first commit in this branch's history (already approved separately in #2767).

Why opt-in (default false)

Ember apps frequently split labels and form controls across component templates (design system wrappers). Enabling this check by default would false-positive on those patterns. Teams whose templates colocate label and input can opt in.

How it works

  • Single visitor pass collects static <label for="X"> values into a Set
  • Id-only controls (no aria-label/aria-labelledby/wrapping <label>) are deferred to Program:exit — so forward-declared labels (label after input) are captured
  • Dynamic values (id={{this.fieldId}}, (unique-id) bindings, helper invocations) fall back to the existing skip behaviour — we deliberately don't resolve bindings symbolically
  • Cross-reference is O(n + m) via Set lookup (n = label nodes, m = id-only controls)

Prior art

  • angular-eslint label-has-associated-control has a checkIds: true opt-in that takes the same two-pass approach

Catches

{{! Typo in for= — silent today, flagged with checkLabelFor: true }}
<label for="emal">Email</label>
<input id="email" />

{{! Forgotten label — silent today, flagged with checkLabelFor: true }}
<input id="email" />

Doesn't false-positive on

{{! Common Ember (unique-id) pattern — both for/id are dynamic, skipped }}
{{#let (unique-id) as |myId|}}
  <label for={{myId}}>Name</label>
  <input id={{myId}} />
{{/let}}

{{! id with aria-label — id is irrelevant, validated via aria-label }}
<input id="email" aria-label="Email" />

Test plan

  • 16 new tests covering: label-before-input, label-after-input (forward ref), id+aria-label not deferred, dynamic id/for fallback, unique-id pattern, labelTags+checkLabelFor combo, missing label, typo'd for
  • All existing tests still pass (128 total, was 112)
  • Docs updated with checkLabelFor explanation and false-positive caveat

johanrd added 4 commits April 28, 2026 09:13
…gative

Refs: ember-template-lint/ember-template-lint#3388

- id + aria-label wrongly flagged as multipleLabels (false positive)
- id alone not flagged as missing label (false negative)
…bel/labelledby

Refs: ember-template-lint/ember-template-lint#3388

id alone cannot be verified statically as a <label for> reference (same
rationale as vuejs-accessibility form-control-has-label). Counting it as
a second label when aria-label or aria-labelledby is already present causes
a false positive multipleLabels error. Keep the bail-out for id-only inputs
(no real labels present) to avoid flagging inputs that likely have an
off-screen <label for> sibling.
…el cases

- Add valid: <input id aria-labelledby> (GJS + HBS)
- Add invalid: <label><input id aria-label></label> still multipleLabels —
  locks in the corrected behavior after the validLabel && hasId carve-out
  was removed.
- Drop peer-plugin reference from the id-skip comment; keep the spec-anchored
  reason (static analysis can't verify the <label for>) and inline the
  one-shot aria-label/labelledby locals.
…l 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.
@johanrd johanrd force-pushed the feat/require-input-label-check-label-for branch from 0ee2dbd to 9236dd0 Compare April 28, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant