// Theme 11 — Forms: every field has a programmatic label + group/structure checks. import type { Doc, El } from "../parse/html.js"; import { attr, hasAttr, hasBoundAttr, hasDynamicSpread, descendants, ancestors, visibleText, textContent } from "../parse/html.js"; import { isIntrinsic } from "../parse/jsx-bridge.js"; import { controlLabel, isFormField, mayInjectContent, isNameExempt } from "../name.js"; import { isValidAutocomplete, CREDENTIAL_FIELDS } from "../autofill.js"; // The element's WHOLE content is a slot/snippet passthrough — a DIRECT `` child or // a `{@render …}`/`{children}` render-expression text node — so the legend the caller // provides is invisible here. (Must be a direct child: a fieldset that merely *contains* // component fields with no legend is a real violation, not a passthrough.) function contentInjected(el: El): boolean { return el.children.some((c) => (c.type === "element" && c.tag === "slot") || (c.type === "text" && /\{@render|\{children/.test(c.data))); } import type { Rule, RuleFinding } from "./rule.js"; // title/placeholder are NOT real labels for these rules const REAL_LABEL = new Set(["for", "wrapping", "aria-label", "aria-labelledby"]); const controlLabelMissing: Rule = { id: "control-label-missing", criteria: ["4.1.2"], severity: "bloquant", run(doc: Doc): RuleFinding[] { const out: RuleFinding[] = []; for (const el of doc.elements) { if (!isFormField(el)) continue; if (isNameExempt(el)) continue; // not exposed, or explicitly presentational const { via } = controlLabel(el, doc); if (via && REAL_LABEL.has(via)) continue; // title yields an accessible name (accname fallback / WCAG H65) — a weak one, surfaced // as a minor control-name-title-only finding, not a blocking "no label". if (via === "title") continue; // A spread (JSX {...props} / Vue v-bind="…" / Svelte {...rest}) may inject // aria-label/aria-labelledby we can't see — don't assert a missing label. if (hasDynamicSpread(el)) continue; if (hasAttr(el, "placeholder")) continue; // reported by placeholder-as-label out.push({ criteriaId: "4.1.2", el, msgId: "control-label-missing", params: { tag: el.tag }, }); } return out; }, }; const placeholderAsLabel: Rule = { id: "placeholder-as-label", criteria: ["4.1.2"], severity: "majeur", run(doc: Doc): RuleFinding[] { const out: RuleFinding[] = []; for (const el of doc.elements) { if (!isFormField(el)) continue; if (!hasAttr(el, "placeholder")) continue; const { via } = controlLabel(el, doc); if (via && REAL_LABEL.has(via)) continue; out.push({ criteriaId: "4.1.2", el, msgId: "placeholder-as-label", params: { value: attr(el, "placeholder") ?? "" }, }); } return out; }, }; const fieldsetLegendMissing: Rule = { id: "fieldset-legend-missing", criteria: ["1.3.1"], severity: "majeur", run(doc: Doc): RuleFinding[] { const out: RuleFinding[] = []; for (const el of doc.elements) { if (el.tag !== "fieldset") continue; const legend = el.children.find((c) => c.type === "element" && c.tag === "legend"); if (legend && legend.type === "element" && visibleText(legend)) continue; if (hasAttr(el, "aria-label") || hasAttr(el, "aria-labelledby")) continue; if (contentInjected(el)) continue; // legend supplied via slot/{@render children()}/a component out.push({ criteriaId: "1.3.1", el, msgId: "fieldset-legend-missing", }); } return out; }, }; const formFieldMultipleLabels: Rule = { id: "form-field-multiple-labels", criteria: ["4.1.2"], severity: "mineur", run(doc: Doc): RuleFinding[] { const counts = new Map(); for (const el of doc.elements) { if (el.tag !== "label") continue; const f = attr(el, "for"); if (f) counts.set(f, (counts.get(f) ?? 0) + 1); } const out: RuleFinding[] = []; for (const el of doc.elements) { if (!isFormField(el)) continue; const id = attr(el, "id"); if (id && (counts.get(id) ?? 0) > 1) { out.push({ criteriaId: "4.1.2", el, msgId: "form-field-multiple-labels", params: { tag: el.tag, count: counts.get(id) ?? 0, id }, }); } } return out; }, }; const selectHasOption: Rule = { id: "select-has-option", criteria: ["4.1.2"], severity: "mineur", run(doc: Doc): RuleFinding[] { const out: RuleFinding[] = []; for (const el of doc.elements) { if (el.tag !== "select") continue; if (descendants(el).some((d) => d.tag === "option")) continue; if (mayInjectContent(el) || contentInjected(el)) continue; // options injected via /component/{@render} out.push({ criteriaId: "4.1.2", el, msgId: "select-has-option", }); } return out; }, }; // An attribute key that carries an element id (`id`, `inputId`, `data-testid`, `htmlFor`…): // the signature a design-system component uses to hand an id down to the control it renders. const ID_ATTR_KEY = /id/i; // A