/** * `a11y/required-accessible-name` — fires for components with an * `a11y.requireName` policy whose JSX usage has no discoverable accessible * name. * * Accepted name sources (any one is enough): * - non-empty `usage_text_child` content * - `aria-label="..."` (static or dynamic both pass; we cannot prove a * dynamic value is empty at scan time) * - `aria-labelledby={...}` * - `title="..."` * * Components that explicitly opt out via `aria-hidden="true"` are skipped — * they are not part of the accessibility tree. */ import type { FactIndex, UsagePropResolvedFact } from "../facts/index.js"; import { makeFinding } from "./finding.js"; import type { Finding } from "./types.js"; import { indexPropsByNodeId, indexTextChildrenByNodeId, readUsageNode } from "./utils.js"; export const RULE_ID = "a11y/required-accessible-name"; export const RULE_VERSION = "1"; const NAME_SOURCE_PROPS = new Set(["aria-label", "aria-labelledby", "title"]); export function ruleA11yRequiredAccessibleName(ix: FactIndex): Finding[] { const findings: Finding[] = []; const propsByNode = indexPropsByNodeId(ix); const textByNode = indexTextChildrenByNodeId(ix); for (const usageComponent of ix.byKind("usage_component")) { const policy = ix.policy.a11yNameRequired(usageComponent.componentId); if (!policy) continue; const node = readUsageNode(ix, usageComponent.nodeId); if (!node) continue; const props = propsByNode.get(node.id) ?? []; if (props.some(isAriaHidden)) continue; if (hasAccessibleName(props, textByNode.get(node.id) ?? [])) continue; findings.push( makeFinding({ ruleId: RULE_ID, ruleVersion: RULE_VERSION, severity: policy.severity, message: `<${node.element}> has no accessible name. ${policy.because}`.trim(), location: node.location, evidence: ix.evidence([node.id, usageComponent.id, policy.id]), fingerprintIdentity: { file: node.file, componentId: usageComponent.componentId, element: node.element, nodePath: node.nodePath, }, attributes: { componentId: usageComponent.componentId, element: node.element, }, }) ); } return findings; } function hasAccessibleName( props: UsagePropResolvedFact[], textChildren: ReadonlyArray<{ text: string }> ): boolean { for (const child of textChildren) { if (typeof child.text === "string" && child.text.trim().length > 0) return true; } for (const prop of props) { if (!NAME_SOURCE_PROPS.has(prop.prop)) continue; if (prop.resolution === "spread") continue; if (prop.resolution === "dynamic") return true; if (prop.resolution === "static") { if (typeof prop.value === "string" && prop.value.trim().length > 0) return true; if (prop.value !== undefined && prop.value !== "" && prop.value !== null) return true; } } return false; } function isAriaHidden(prop: UsagePropResolvedFact): boolean { if (prop.prop !== "aria-hidden") return false; if (prop.resolution !== "static") return false; return prop.value === true || prop.value === "true"; }