Skip to content

Commit 4d7b5f3

Browse files
committed
updated rule factory with tooltip, describedby, and labeled child
1 parent 2306b4d commit 4d7b5f3

File tree

4 files changed

+116
-67
lines changed

4 files changed

+116
-67
lines changed

lib/rules/swatchpicker-needs-labelling.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,15 @@ import { makeLabeledControlRule } from "../util/ruleFactory";
1111
export default ESLintUtils.RuleCreator.withoutDocs(
1212
makeLabeledControlRule({
1313
component: "SwatchPicker",
14+
messageId: "noUnlabeledSwatchPicker",
15+
description: "Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc..",
1416
labelProps: ["aria-label"],
1517
allowFieldParent: true,
16-
allowFor: false,
18+
allowHtmlFor: false,
1719
allowLabelledBy: true,
1820
allowWrappingLabel: false,
19-
messageId: "noUnlabeledSwatchPicker",
20-
description: "Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.."
21+
allowTooltipParent: false,
22+
allowDescribedBy: false,
23+
allowLabeledChild: false
2124
})
2225
);

lib/util/hasLabeledChild.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { TSESLint, TSESTree } from "@typescript-eslint/utils";
5+
6+
// eslint-disable-next-line no-unused-vars
7+
export const hasLabeledChild = (openingElement: TSESTree.JSXOpeningElement, context: TSESLint.RuleContext<string, unknown[]>): boolean => {
8+
// TODO: function not yet implemented
9+
return false;
10+
};

lib/util/ruleFactory.ts

Lines changed: 52 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,86 +3,79 @@
33

44
import { TSESLint, TSESTree } from "@typescript-eslint/utils";
55
import { hasNonEmptyProp } from "./hasNonEmptyProp";
6-
import { hasAssociatedLabelViaAriaLabelledBy, isInsideLabelTag, hasAssociatedLabelViaHtmlFor } from "./labelUtils";
6+
import {
7+
hasAssociatedLabelViaAriaLabelledBy,
8+
isInsideLabelTag,
9+
hasAssociatedLabelViaHtmlFor,
10+
hasAssociatedLabelViaAriaDescribedby
11+
} from "./labelUtils";
712
import { hasFieldParent } from "./hasFieldParent";
813
import { elementType } from "jsx-ast-utils";
914
import { JSXOpeningElement } from "estree-jsx";
15+
import { hasToolTipParent } from "./hasTooltipParent";
16+
import { hasLabeledChild } from "./hasLabeledChild";
1017

1118
export type LabeledControlConfig = {
1219
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>
1820
messageId: string;
1921
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;
2037
};
2138

2239
/**
2340
* 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.
2642
*
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
3352
*
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.
4954
*/
5055
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+
5673
return false;
5774
}
5875

5976
/**
6077
* 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.
8679
*/
8780
export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.RuleModule<string, []> {
8881
return {

tests/lib/rules/utils/ruleFactory.test.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,42 @@ jest.mock("../../../../lib/util/hasNonEmptyProp", () => ({
77
hasNonEmptyProp: jest.fn()
88
}));
99
jest.mock("../../../../lib/util/labelUtils", () => ({
10+
hasAssociatedLabelViaAriaDescribedby: jest.fn(),
1011
hasAssociatedLabelViaAriaLabelledBy: jest.fn(),
1112
isInsideLabelTag: jest.fn(),
1213
hasAssociatedLabelViaHtmlFor: jest.fn()
1314
}));
1415
jest.mock("../../../../lib/util/hasFieldParent", () => ({
1516
hasFieldParent: jest.fn()
1617
}));
18+
jest.mock("../../../../lib/util/hasLabeledChild", () => ({
19+
hasLabeledChild: jest.fn()
20+
}));
1721

1822
import { hasNonEmptyProp } from "../../../../lib/util/hasNonEmptyProp";
19-
import { hasAssociatedLabelViaAriaLabelledBy, isInsideLabelTag, hasAssociatedLabelViaHtmlFor } from "../../../../lib/util/labelUtils";
23+
import {
24+
hasAssociatedLabelViaAriaLabelledBy,
25+
isInsideLabelTag,
26+
hasAssociatedLabelViaHtmlFor,
27+
hasAssociatedLabelViaAriaDescribedby
28+
} from "../../../../lib/util/labelUtils";
2029
import { hasFieldParent } from "../../../../lib/util/hasFieldParent";
2130

2231
// Import the module under test AFTER mocks
2332
import { hasAccessibleLabel, LabeledControlConfig, makeLabeledControlRule } from "../../../../lib/util/ruleFactory";
2433
import type { TSESTree } from "@typescript-eslint/utils";
2534
import { Rule, RuleTester } from "eslint";
35+
import { hasLabeledChild } from "../../../../lib/util/hasLabeledChild";
2636

2737
// Helper: reset all mocks to a default "false" stance
2838
const resetAllMocksToFalse = () => {
2939
(hasNonEmptyProp as jest.Mock).mockReset().mockReturnValue(false);
3040
(hasAssociatedLabelViaAriaLabelledBy as jest.Mock).mockReset().mockReturnValue(false);
41+
(hasAssociatedLabelViaAriaDescribedby as jest.Mock).mockReset().mockReturnValue(false);
3142
(isInsideLabelTag as jest.Mock).mockReset().mockReturnValue(false);
3243
(hasAssociatedLabelViaHtmlFor as jest.Mock).mockReset().mockReturnValue(false);
3344
(hasFieldParent as jest.Mock).mockReset().mockReturnValue(false);
45+
(hasLabeledChild as jest.Mock).mockReset().mockReturnValue(false);
3446
};
3547

3648
beforeEach(() => {
@@ -59,11 +71,14 @@ describe("hasAccessibleLabel (unit)", () => {
5971
component: "RadioGroup",
6072
labelProps: ["label", "aria-label"],
6173
allowFieldParent: true,
62-
allowFor: true,
74+
allowHtmlFor: true,
6375
allowLabelledBy: true,
6476
allowWrappingLabel: true,
77+
allowTooltipParent: false,
78+
allowDescribedBy: true,
6579
messageId: "errorMsg",
66-
description: "anything"
80+
description: "anything",
81+
allowLabeledChild: false
6782
};
6883

6984
test("returns false when no heuristics pass", () => {
@@ -106,6 +121,12 @@ describe("hasAccessibleLabel (unit)", () => {
106121
const node = makeOpeningElement("RadioGroup");
107122
expect(hasAccessibleLabel(node, {}, cfg)).toBe(true);
108123
});
124+
125+
test("true when allowDescribedByBy and hasAssociatedLabelViaAriaDescribedBy(...) === true", () => {
126+
(hasAssociatedLabelViaAriaDescribedby as jest.Mock).mockReturnValue(true);
127+
const node = makeOpeningElement("RadioGroup");
128+
expect(hasAccessibleLabel(node, {}, cfg)).toBe(true);
129+
});
109130
});
110131

111132
/* -------------------------------------------------------------------------- */
@@ -119,11 +140,14 @@ describe("makeLabeledControlRule (RuleTester integration)", () => {
119140
component: "RadioGroup",
120141
labelProps: ["label", "aria-label"],
121142
allowFieldParent: true,
122-
allowFor: true,
143+
allowHtmlFor: true,
123144
allowLabelledBy: true,
124145
allowWrappingLabel: true,
146+
allowTooltipParent: false,
147+
allowDescribedBy: true,
125148
messageId: "noUnlabeledRadioGroup",
126-
description: "Accessibility: RadioGroup must have a programmatic and visual label."
149+
description: "Accessibility: RadioGroup must have a programmatic and visual label.",
150+
allowLabeledChild: false
127151
};
128152

129153
// 1) No heuristics -> report
@@ -253,4 +277,23 @@ describe("makeLabeledControlRule (RuleTester integration)", () => {
253277
invalid: []
254278
});
255279
});
280+
281+
// 8) in rare cases
282+
describe("accepts when aria-describedby is present", () => {
283+
beforeEach(() => {
284+
resetAllMocksToFalse();
285+
(hasAssociatedLabelViaAriaDescribedby as jest.Mock).mockImplementation(
286+
(node: any) =>
287+
Array.isArray(node?.attributes) &&
288+
node.attributes.some((a: any) => a?.type === "JSXAttribute" && a?.name?.name === "aria-describedby")
289+
);
290+
});
291+
292+
const rule = makeLabeledControlRule(baseCfg);
293+
294+
ruleTester.run("no-unlabeled-radio-group (aria-describedby)", rule as unknown as Rule.RuleModule, {
295+
valid: [{ code: `<RadioGroup aria-describedby="groupLabelId" />` }],
296+
invalid: [{ code: `<RadioGroup />`, errors: [{ messageId: baseCfg.messageId }] }]
297+
});
298+
});
256299
});

0 commit comments

Comments
 (0)