|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT License. |
| 3 | + |
| 4 | +import { TSESLint, TSESTree } from "@typescript-eslint/utils"; |
| 5 | +import { hasNonEmptyProp } from "./hasNonEmptyProp"; |
| 6 | +import { hasAssociatedLabelViaAriaLabelledBy, isInsideLabelTag, hasAssociatedLabelViaHtmlFor } from "./labelUtils"; |
| 7 | +import { hasFieldParent } from "./hasFieldParent"; |
| 8 | +import { elementType } from "jsx-ast-utils"; |
| 9 | +import { JSXOpeningElement } from "estree-jsx"; |
| 10 | + |
| 11 | +export type LabeledControlConfig = { |
| 12 | + component: string | RegExp; |
| 13 | + labelProps: string[]; // e.g. ["label", "aria-label"] |
| 14 | + allowFieldParent: boolean; // e.g. <Field label=...><RadioGroup/></Field> |
| 15 | + allowFor: boolean; // htmlFor |
| 16 | + allowLabelledBy: boolean; // aria-labelledby |
| 17 | + allowWrappingLabel: boolean; // <label>...</label> |
| 18 | + messageId: string; |
| 19 | + description: string; |
| 20 | +}; |
| 21 | + |
| 22 | +/** |
| 23 | + * Returns `true` if the JSX opening element is considered **accessibly labelled** |
| 24 | + * per the rule configuration. This function centralizes all supported labelling |
| 25 | + * strategies so the rule stays small and testable. |
| 26 | + * |
| 27 | + * The supported strategies (gated by `config` flags) are: |
| 28 | + * 1) A parent `<Field>`-like wrapper that provides the label context (`allowFieldParent`). |
| 29 | + * 2) A non-empty inline prop such as `aria-label` or `title` (`labelProps`). |
| 30 | + * 3) Being wrapped by a `<label>` element (`allowWrappingLabel`). |
| 31 | + * 4) Associated `<label for="...">` / `htmlFor` relation (`allowFor`). |
| 32 | + * 5) `aria-labelledby` association to an element with textual content (`allowLabelledBy`). |
| 33 | + * |
| 34 | + * Note: This does not validate contrast or UX; it only checks the existence of |
| 35 | + * an accessible **name** via common HTML/ARIA labelling patterns. |
| 36 | + * |
| 37 | + * @param node - The JSX opening element we’re inspecting (e.g., `<Input ...>` opening node). |
| 38 | + * @param context - ESLint rule context or tree-walker context used by helper functions to |
| 39 | + * resolve scope/ancestors and collect referenced nodes. |
| 40 | + * @param config - Rule configuration describing which components/props/associations count as labelled. |
| 41 | + * Expected shape: |
| 42 | + * - `component: string | RegExp` — component tag name or regex to match. |
| 43 | + * - `labelProps: string[]` — prop names that, when non-empty, count as labels (e.g., `["aria-label","title"]`). |
| 44 | + * - `allowFieldParent?: boolean` — if true, a recognized parent “Field” wrapper satisfies labelling. |
| 45 | + * - `allowWrappingLabel?: boolean` — if true, being inside a `<label>` satisfies labelling. |
| 46 | + * - `allowFor?: boolean` — if true, `<label htmlFor>` association is considered. |
| 47 | + * - `allowLabelledBy?: boolean` — if true, `aria-labelledby` association is considered. |
| 48 | + * @returns `true` if any configured labelling strategy succeeds; otherwise `false`. |
| 49 | + */ |
| 50 | +export function hasAccessibleLabel(node: TSESTree.JSXOpeningElement, context: any, config: LabeledControlConfig): boolean { |
| 51 | + if (config.allowFieldParent && hasFieldParent(context)) return true; |
| 52 | + if (config.labelProps.some(p => hasNonEmptyProp(node.attributes, p))) return true; |
| 53 | + if (config.allowWrappingLabel && isInsideLabelTag(context)) return true; |
| 54 | + if (config.allowFor && hasAssociatedLabelViaHtmlFor(node, context)) return true; |
| 55 | + if (config.allowLabelledBy && hasAssociatedLabelViaAriaLabelledBy(node, context)) return true; |
| 56 | + return false; |
| 57 | +} |
| 58 | + |
| 59 | +/** |
| 60 | + * Factory for a minimal, strongly-configurable ESLint rule that enforces |
| 61 | + * accessible labelling on a specific JSX element/component. |
| 62 | + * |
| 63 | + * The rule: |
| 64 | + * • Matches opening elements by `config.component` (exact name or RegExp). |
| 65 | + * • Uses `hasAccessibleLabel` to decide whether the element is labelled. |
| 66 | + * • Reports with `messageId` if no labelling strategy succeeds. |
| 67 | + * |
| 68 | + * Example: |
| 69 | + * ```ts |
| 70 | + * export default makeLabeledControlRule( |
| 71 | + * { |
| 72 | + * component: /^(?:input|textarea|Select|ComboBox)$/i, |
| 73 | + * labelProps: ["aria-label", "aria-labelledby", "title"], |
| 74 | + * allowFieldParent: true, |
| 75 | + * allowWrappingLabel: true, |
| 76 | + * allowFor: true, |
| 77 | + * allowLabelledBy: true, |
| 78 | + * }, |
| 79 | + * "missingLabel", |
| 80 | + * "Provide an accessible label (e.g., via <label>, htmlFor, aria-label, or aria-labelledby)." |
| 81 | + * ); |
| 82 | + * ``` |
| 83 | + * |
| 84 | + * @param config - See `hasAccessibleLabel` for the configuration fields and semantics. |
| 85 | + * @returns An ESLint `RuleModule` that reports when the configured component lacks an accessible label. |
| 86 | + */ |
| 87 | +export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.RuleModule<string, []> { |
| 88 | + return { |
| 89 | + meta: { |
| 90 | + type: "problem", |
| 91 | + messages: { [config.messageId]: config.description }, |
| 92 | + docs: { |
| 93 | + description: config.description, |
| 94 | + recommended: "strict", |
| 95 | + url: "https://www.w3.org/TR/html-aria/" |
| 96 | + }, |
| 97 | + schema: [] |
| 98 | + }, |
| 99 | + defaultOptions: [], |
| 100 | + |
| 101 | + create(context: TSESLint.RuleContext<string, []>) { |
| 102 | + return { |
| 103 | + JSXOpeningElement(node: TSESTree.JSXOpeningElement) { |
| 104 | + // elementType expects an ESTree JSX node — cast is fine |
| 105 | + const name = elementType(node as unknown as JSXOpeningElement); |
| 106 | + const matches = typeof config.component === "string" ? name === config.component : config.component.test(name); |
| 107 | + |
| 108 | + if (!matches) return; |
| 109 | + |
| 110 | + if (!hasAccessibleLabel(node, context, config)) { |
| 111 | + context.report({ node, messageId: config.messageId }); |
| 112 | + } |
| 113 | + } |
| 114 | + }; |
| 115 | + } |
| 116 | + }; |
| 117 | +} |
0 commit comments