|
| 1 | +'use strict'; |
| 2 | + |
| 3 | +// See html-validate (https://html-validate.org/rules/input-missing-label.html) for the related peer rule. |
| 4 | +// |
| 5 | +// Validates <label for="x"> in two ways: |
| 6 | +// 1. Points to a labelable HTML control defined in the same template |
| 7 | +// (not a <div> or other arbitrary element). |
| 8 | +// 2. If the target is already nested inside the label, flag it as |
| 9 | +// redundant (the `for` adds nothing — the nested element is |
| 10 | +// already associated via the containment rule). |
| 11 | +// |
| 12 | +// Dynamic `for` values (mustache) are skipped. Targets we can't find in |
| 13 | +// this template are also skipped (partial templates, yielded content). |
| 14 | + |
| 15 | +const LABELABLE_TAGS = new Set([ |
| 16 | + 'button', |
| 17 | + 'input', |
| 18 | + 'meter', |
| 19 | + 'output', |
| 20 | + 'progress', |
| 21 | + 'select', |
| 22 | + 'textarea', |
| 23 | +]); |
| 24 | + |
| 25 | +// Ember's built-in <Input> / <Textarea> components render to native <input> |
| 26 | +// and <textarea> and accept `id=` forwarding — they are valid targets for |
| 27 | +// <label for="…">. In classic Handlebars `<Input>` always resolves globally |
| 28 | +// to the built-in; in strict GJS/GTS the tag must be explicitly imported. |
| 29 | +// Resolution logic: |
| 30 | +// |
| 31 | +// 1. PascalCase tag with a local import binding → check whether the |
| 32 | +// import source is `@ember/component` and the imported name is |
| 33 | +// `Input` / `Textarea`. If so, the local alias (e.g. `<MyInput>`) |
| 34 | +// still resolves to the built-in → labelable. Imports from other |
| 35 | +// modules → NOT labelable (false-negative acceptable). |
| 36 | +// 2. PascalCase tag with no local import binding in classic HBS (no |
| 37 | +// import scope) → global resolution; treat as the built-in. |
| 38 | +// In strict GJS/GTS, no import binding → NOT the built-in. |
| 39 | +const EMBER_BUILTIN_FORM_COMPONENTS = new Set(['Input', 'Textarea']); |
| 40 | + |
| 41 | +// Cache of imports parsed once per sourceCode. Key is sourceCode (a fresh |
| 42 | +// object per ESLint traversal); value is a Map<localName, importedName|null> |
| 43 | +// where null means "bound to an import from some other module". Turns a |
| 44 | +// per-call O(n) scan of ast.body into an amortized O(1) lookup per tag. |
| 45 | +const IMPORT_CACHE = new WeakMap(); |
| 46 | + |
| 47 | +function getImportedComponents(sourceCode) { |
| 48 | + if (!sourceCode) { |
| 49 | + return null; |
| 50 | + } |
| 51 | + let cached = IMPORT_CACHE.get(sourceCode); |
| 52 | + if (cached) { |
| 53 | + return cached; |
| 54 | + } |
| 55 | + const ast = sourceCode.ast; |
| 56 | + if (!ast || !Array.isArray(ast.body)) { |
| 57 | + return null; |
| 58 | + } |
| 59 | + cached = new Map(); |
| 60 | + for (const decl of ast.body) { |
| 61 | + if (decl.type !== 'ImportDeclaration') { |
| 62 | + continue; |
| 63 | + } |
| 64 | + const fromEmberComponent = decl.source?.value === '@ember/component'; |
| 65 | + for (const specifier of decl.specifiers) { |
| 66 | + const local = specifier.local?.name; |
| 67 | + if (!local) { |
| 68 | + continue; |
| 69 | + } |
| 70 | + if (!fromEmberComponent) { |
| 71 | + // Local binding exists but points outside @ember/component → not |
| 72 | + // the built-in. Record null so we short-circuit future lookups. |
| 73 | + cached.set(local, null); |
| 74 | + continue; |
| 75 | + } |
| 76 | + // Only named imports (`import { Input }`, `import { Input as X }`) |
| 77 | + // introduce a built-in binding. Default and namespace imports from |
| 78 | + // @ember/component are not the form components — skip them. |
| 79 | + if (specifier.type !== 'ImportSpecifier') { |
| 80 | + continue; |
| 81 | + } |
| 82 | + cached.set(local, specifier.imported?.name); |
| 83 | + } |
| 84 | + } |
| 85 | + IMPORT_CACHE.set(sourceCode, cached); |
| 86 | + return cached; |
| 87 | +} |
| 88 | + |
| 89 | +function resolvesToEmberFormComponent(tagName, sourceCode, isStrictMode) { |
| 90 | + if (!tagName) { |
| 91 | + return false; |
| 92 | + } |
| 93 | + if (!isStrictMode) { |
| 94 | + // Classic HBS: <Input>/<Textarea> resolve globally to the built-in. |
| 95 | + return EMBER_BUILTIN_FORM_COMPONENTS.has(tagName); |
| 96 | + } |
| 97 | + const imports = getImportedComponents(sourceCode); |
| 98 | + if (!imports) { |
| 99 | + return false; |
| 100 | + } |
| 101 | + if (imports.has(tagName)) { |
| 102 | + const importedName = imports.get(tagName); |
| 103 | + return importedName !== null && EMBER_BUILTIN_FORM_COMPONENTS.has(importedName); |
| 104 | + } |
| 105 | + // No import binding in strict GJS/GTS — not the built-in. |
| 106 | + return false; |
| 107 | +} |
| 108 | + |
| 109 | +function findAttr(node, name) { |
| 110 | + return node.attributes?.find((attr) => attr.name === name); |
| 111 | +} |
| 112 | + |
| 113 | +function getStaticAttrString(node, name) { |
| 114 | + const attr = findAttr(node, name); |
| 115 | + if (!attr || !attr.value || attr.value.type !== 'GlimmerTextNode') { |
| 116 | + return null; |
| 117 | + } |
| 118 | + return attr.value.chars; |
| 119 | +} |
| 120 | + |
| 121 | +function isInputHidden(node, sourceCode, isStrictMode) { |
| 122 | + // Native <input type="hidden">. |
| 123 | + if (node.tag === 'input') { |
| 124 | + const type = getStaticAttrString(node, 'type'); |
| 125 | + return type !== null && type.toLowerCase() === 'hidden'; |
| 126 | + } |
| 127 | + // Ember <Input type="hidden"> (including aliased imports). Renders to a |
| 128 | + // native <input type="hidden"> → not labelable for the same reason. |
| 129 | + if (resolvesToEmberFormComponent(node.tag, sourceCode, isStrictMode)) { |
| 130 | + // Only <Input> carries a type= attribute; <Textarea> never has hidden |
| 131 | + // semantics. But check the attr regardless — cheap and keeps the |
| 132 | + // predicate symmetric. |
| 133 | + const type = getStaticAttrString(node, 'type'); |
| 134 | + return type !== null && type.toLowerCase() === 'hidden'; |
| 135 | + } |
| 136 | + return false; |
| 137 | +} |
| 138 | + |
| 139 | +function isLabelable(node, sourceCode, isStrictMode) { |
| 140 | + if (!node || node.type !== 'GlimmerElementNode') { |
| 141 | + return false; |
| 142 | + } |
| 143 | + if (isInputHidden(node, sourceCode, isStrictMode)) { |
| 144 | + return false; |
| 145 | + } |
| 146 | + if (resolvesToEmberFormComponent(node.tag, sourceCode, isStrictMode)) { |
| 147 | + return true; |
| 148 | + } |
| 149 | + if (!LABELABLE_TAGS.has(node.tag)) { |
| 150 | + return false; |
| 151 | + } |
| 152 | + return true; |
| 153 | +} |
| 154 | + |
| 155 | +function isDescendant(candidate, ancestor) { |
| 156 | + let current = candidate.parent; |
| 157 | + while (current) { |
| 158 | + if (current === ancestor) { |
| 159 | + return true; |
| 160 | + } |
| 161 | + current = current.parent; |
| 162 | + } |
| 163 | + return false; |
| 164 | +} |
| 165 | + |
| 166 | +// Per HTML §4.10.4 ("The label element"), a label with BOTH `for=` and a |
| 167 | +// labelable descendant binds to the `for`-referenced element — the |
| 168 | +// containment rule is the *implicit* binding and only applies when no |
| 169 | +// `for` is present. When `for` is present, it wins. |
| 170 | +// |
| 171 | +// So `redundantFor` should only fire when the `for` target is the same |
| 172 | +// element that would have been the implicit control — i.e. the FIRST |
| 173 | +// labelable descendant (HTML uses "first labelable element in tree order |
| 174 | +// that is a descendant of the label element", excluding hidden inputs). |
| 175 | +// A label with multiple labelable descendants and `for=` pointing at a |
| 176 | +// non-first one is expressing an explicit choice and must not be flagged. |
| 177 | +function findFirstLabelableDescendant(node, sourceCode, isStrictMode) { |
| 178 | + if (!node.children) { |
| 179 | + return null; |
| 180 | + } |
| 181 | + for (const child of node.children) { |
| 182 | + if (!child || child.type !== 'GlimmerElementNode') { |
| 183 | + continue; |
| 184 | + } |
| 185 | + if (isLabelable(child, sourceCode, isStrictMode)) { |
| 186 | + return child; |
| 187 | + } |
| 188 | + const nested = findFirstLabelableDescendant(child, sourceCode, isStrictMode); |
| 189 | + if (nested) { |
| 190 | + return nested; |
| 191 | + } |
| 192 | + } |
| 193 | + return null; |
| 194 | +} |
| 195 | + |
| 196 | +/** @type {import('eslint').Rule.RuleModule} */ |
| 197 | +module.exports = { |
| 198 | + meta: { |
| 199 | + type: 'problem', |
| 200 | + docs: { |
| 201 | + description: 'require `<label for>` to point at a labelable form control', |
| 202 | + category: 'Accessibility', |
| 203 | + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-valid-label-for.md', |
| 204 | + templateMode: 'both', |
| 205 | + }, |
| 206 | + fixable: null, |
| 207 | + schema: [], |
| 208 | + messages: { |
| 209 | + notLabelable: |
| 210 | + '`<label for="{{id}}">` must reference a labelable form control (`<input>`, `<select>`, `<textarea>`, `<button>`, `<meter>`, `<output>`, `<progress>`, or Ember `<Input>` / `<Textarea>`)', |
| 211 | + redundantFor: |
| 212 | + '`for="{{id}}"` is redundant: `<label>` already contains the referenced element', |
| 213 | + }, |
| 214 | + }, |
| 215 | + |
| 216 | + create(context) { |
| 217 | + // Per-<template>-block state: multi-template .gjs files compose |
| 218 | + // independent DOM subtrees (e.g. `const Foo = <template>…</template>; |
| 219 | + // <template>…</template>` in one file). Each block's <label for> must |
| 220 | + // bind to ids declared in the same block — ids from sibling templates |
| 221 | + // aren't present in the composed DOM at runtime. |
| 222 | + let idToElement = new Map(); |
| 223 | + let labels = []; |
| 224 | + |
| 225 | + const filename = context.filename ?? context.getFilename?.() ?? ''; |
| 226 | + const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts'); |
| 227 | + |
| 228 | + function resetTemplateState() { |
| 229 | + idToElement = new Map(); |
| 230 | + labels = []; |
| 231 | + } |
| 232 | + |
| 233 | + function validateCurrentTemplate() { |
| 234 | + const sourceCode = context.sourceCode || context.getSourceCode(); |
| 235 | + for (const { labelNode, forAttr, forValue } of labels) { |
| 236 | + const target = idToElement.get(forValue); |
| 237 | + if (!target) { |
| 238 | + continue; |
| 239 | + } |
| 240 | + if (!isLabelable(target, sourceCode, isStrictMode)) { |
| 241 | + context.report({ |
| 242 | + node: forAttr, |
| 243 | + messageId: 'notLabelable', |
| 244 | + data: { id: forValue }, |
| 245 | + }); |
| 246 | + continue; |
| 247 | + } |
| 248 | + if (isDescendant(target, labelNode)) { |
| 249 | + // Only redundant when `for` resolves to the SAME element that |
| 250 | + // the implicit-containment rule would bind — the first |
| 251 | + // labelable descendant. If `for` points at a later labelable |
| 252 | + // descendant, the author is overriding the implicit choice, |
| 253 | + // which is not redundant. |
| 254 | + const implicit = findFirstLabelableDescendant(labelNode, sourceCode, isStrictMode); |
| 255 | + if (implicit && implicit === target) { |
| 256 | + context.report({ |
| 257 | + node: forAttr, |
| 258 | + messageId: 'redundantFor', |
| 259 | + data: { id: forValue }, |
| 260 | + }); |
| 261 | + } |
| 262 | + } |
| 263 | + } |
| 264 | + resetTemplateState(); |
| 265 | + } |
| 266 | + |
| 267 | + return { |
| 268 | + // Multi-template .gjs: reset state on each <template> entry so ids |
| 269 | + // from earlier templates don't leak into the next one's for= resolution. |
| 270 | + GlimmerTemplate() { |
| 271 | + resetTemplateState(); |
| 272 | + }, |
| 273 | + 'GlimmerTemplate:exit'() { |
| 274 | + validateCurrentTemplate(); |
| 275 | + }, |
| 276 | + GlimmerElementNode(node) { |
| 277 | + const idValue = getStaticAttrString(node, 'id'); |
| 278 | + if (idValue && !idToElement.has(idValue)) { |
| 279 | + idToElement.set(idValue, node); |
| 280 | + } |
| 281 | + if (node.tag === 'label') { |
| 282 | + const forAttr = findAttr(node, 'for'); |
| 283 | + const forValue = getStaticAttrString(node, 'for'); |
| 284 | + if (forAttr && forValue) { |
| 285 | + labels.push({ labelNode: node, forAttr, forValue }); |
| 286 | + } |
| 287 | + } |
| 288 | + }, |
| 289 | + 'Program:exit'() { |
| 290 | + // Fallback for .hbs (no GlimmerTemplate wrapper) — if |
| 291 | + // GlimmerTemplate:exit never fired, any pending labels/ids here are |
| 292 | + // from the single implicit template and need validation. |
| 293 | + if (labels.length > 0 || idToElement.size > 0) { |
| 294 | + validateCurrentTemplate(); |
| 295 | + } |
| 296 | + }, |
| 297 | + }; |
| 298 | + }, |
| 299 | +}; |
0 commit comments