|
3 | 3 |
|
4 | 4 | import { TSESLint, TSESTree } from "@typescript-eslint/utils"; |
5 | 5 | import { hasNonEmptyProp } from "./hasNonEmptyProp"; |
6 | | -import { hasAssociatedLabelViaAriaLabelledBy, isInsideLabelTag, hasAssociatedLabelViaHtmlFor } from "./labelUtils"; |
| 6 | +import { |
| 7 | + hasAssociatedLabelViaAriaLabelledBy, |
| 8 | + isInsideLabelTag, |
| 9 | + hasAssociatedLabelViaHtmlFor, |
| 10 | + hasAssociatedLabelViaAriaDescribedby |
| 11 | +} from "./labelUtils"; |
7 | 12 | import { hasFieldParent } from "./hasFieldParent"; |
8 | 13 | import { elementType } from "jsx-ast-utils"; |
9 | 14 | import { JSXOpeningElement } from "estree-jsx"; |
| 15 | +import { hasToolTipParent } from "./hasTooltipParent"; |
| 16 | +import { hasLabeledChild } from "./hasLabeledChild"; |
10 | 17 |
|
11 | 18 | export type LabeledControlConfig = { |
12 | 19 | 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 | 20 | messageId: string; |
19 | 21 | description: string; |
| 22 | + labelProps: string[]; // e.g. ["aria-label", "title", "label"] |
| 23 | + /** Accept a parent <Field label="..."> wrapper as providing the label. */ |
| 24 | + allowFieldParent: boolean; // default false |
| 25 | + allowHtmlFor: boolean /** Accept <label htmlFor="..."> association. */; |
| 26 | + allowLabelledBy: boolean /** Accept aria-labelledby association. */; |
| 27 | + allowWrappingLabel: boolean /** Accept being wrapped in a <label> element. */; |
| 28 | + allowTooltipParent: boolean /** Accept a parent <Tooltip content="..."> wrapper as providing the label. */; |
| 29 | + /** |
| 30 | + * Accept aria-describedby as a labeling strategy. |
| 31 | + * NOTE: This is discouraged for *primary* labeling; prefer text/aria-label/labelledby. |
| 32 | + * Keep this off unless a specific component (e.g., Icon-only buttons) intentionally uses it. |
| 33 | + */ |
| 34 | + allowDescribedBy: boolean; |
| 35 | + // NEW: treat labeled child content (img alt, svg title, aria-label on role="img") as the name |
| 36 | + allowLabeledChild: boolean; |
20 | 37 | }; |
21 | 38 |
|
22 | 39 | /** |
23 | 40 | * 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. |
| 41 | + * per the rule configuration. This centralizes all supported labeling strategies. |
26 | 42 | * |
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`). |
| 43 | + * Supported strategies (gated by config flags): |
| 44 | + * 1) Parent <Field label="..."> context .............................. (allowFieldParent) |
| 45 | + * 2) Non-empty inline prop(s) like aria-label/title .................. (labelProps) |
| 46 | + * 3) Wrapped by a <label> ............................................ (allowWrappingLabel) |
| 47 | + * 4) <label htmlFor="..."> / htmlFor association ..................... (allowFor) |
| 48 | + * 5) aria-labelledby association ..................................... (allowLabelledBy) |
| 49 | + * 6) Parent <Tooltip content="..."> context .......................... (allowTooltipParent) |
| 50 | + * 7) aria-describedby association (opt-in; discouraged as primary) .... (allowDescribedBy) |
| 51 | + * 8) treat labeled child content (img alt, svg title, aria-label on role="img") as the name |
33 | 52 | * |
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`. |
| 53 | + * This checks for presence of an accessible *name* only; not contrast or UX. |
49 | 54 | */ |
50 | 55 | 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 | + const allowFieldParent = !!config.allowFieldParent; |
| 57 | + const allowWrappingLabel = !!config.allowWrappingLabel; |
| 58 | + const allowHtmlFor = !!config.allowHtmlFor; |
| 59 | + const allowLabelledBy = !!config.allowLabelledBy; |
| 60 | + const allowTooltipParent = !!config.allowTooltipParent; |
| 61 | + const allowDescribedBy = !!config.allowDescribedBy; |
| 62 | + const allowLabeledChild = !!config.allowLabeledChild; |
| 63 | + |
| 64 | + if (allowFieldParent && hasFieldParent(context)) return true; |
| 65 | + if (config.labelProps?.some(p => hasNonEmptyProp(node.attributes, p))) return true; |
| 66 | + if (allowWrappingLabel && isInsideLabelTag(context)) return true; |
| 67 | + if (allowHtmlFor && hasAssociatedLabelViaHtmlFor(node, context)) return true; |
| 68 | + if (allowLabelledBy && hasAssociatedLabelViaAriaLabelledBy(node, context)) return true; |
| 69 | + if (allowTooltipParent && hasToolTipParent(context)) return true; |
| 70 | + if (allowDescribedBy && hasAssociatedLabelViaAriaDescribedby(node, context)) return true; |
| 71 | + if (allowLabeledChild && hasLabeledChild(node, context)) return true; |
| 72 | + |
56 | 73 | return false; |
57 | 74 | } |
58 | 75 |
|
59 | 76 | /** |
60 | 77 | * 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. |
| 78 | + * accessible labeling on a specific JSX element/component. |
86 | 79 | */ |
87 | 80 | export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.RuleModule<string, []> { |
88 | 81 | return { |
|
0 commit comments