Skip to content

Commit 3977d44

Browse files
authored
Merge pull request #152 from microsoft/users/aubreyquinn/factory
updated rule factory with tooltip, describedby, and labeled child
2 parents 2306b4d + 9c88311 commit 3977d44

File tree

5 files changed

+192
-67
lines changed

5 files changed

+192
-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 {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { AST_NODE_TYPES, TSESTree } from "@typescript-eslint/types";
5+
import { TSESLint } from "@typescript-eslint/utils";
6+
import { hasLabeledChild } from "../../../../lib/util/hasLabeledChild";
7+
8+
// helper for loc/range
9+
const mockLocRange = () => ({
10+
loc: {
11+
start: { line: 0, column: 0 },
12+
end: { line: 0, column: 0 }
13+
},
14+
range: [0, 0] as [number, number]
15+
});
16+
17+
// minimal JSXOpeningElement: <img />
18+
const openingEl: TSESTree.JSXOpeningElement = {
19+
type: AST_NODE_TYPES.JSXOpeningElement,
20+
name: {
21+
type: AST_NODE_TYPES.JSXIdentifier,
22+
name: "img",
23+
...mockLocRange()
24+
},
25+
attributes: [], // no props
26+
selfClosing: true, // <img />
27+
...mockLocRange()
28+
};
29+
30+
// minimal RuleContext mock
31+
const mockContext = {
32+
report: jest.fn(),
33+
getSourceCode: jest.fn()
34+
} as unknown as TSESLint.RuleContext<string, unknown[]>;
35+
36+
describe("hasLabeledChild", () => {
37+
it("returns false for a self-closing <img /> with no labeled children", () => {
38+
expect(hasLabeledChild(openingEl, mockContext)).toBe(false);
39+
});
40+
});

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

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,47 @@ 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+
}));
21+
jest.mock("../../../../lib/util/hasTooltipParent", () => ({
22+
hasToolTipParent: jest.fn()
23+
}));
1724

1825
import { hasNonEmptyProp } from "../../../../lib/util/hasNonEmptyProp";
19-
import { hasAssociatedLabelViaAriaLabelledBy, isInsideLabelTag, hasAssociatedLabelViaHtmlFor } from "../../../../lib/util/labelUtils";
26+
import {
27+
hasAssociatedLabelViaAriaLabelledBy,
28+
isInsideLabelTag,
29+
hasAssociatedLabelViaHtmlFor,
30+
hasAssociatedLabelViaAriaDescribedby
31+
} from "../../../../lib/util/labelUtils";
2032
import { hasFieldParent } from "../../../../lib/util/hasFieldParent";
2133

2234
// Import the module under test AFTER mocks
2335
import { hasAccessibleLabel, LabeledControlConfig, makeLabeledControlRule } from "../../../../lib/util/ruleFactory";
2436
import type { TSESTree } from "@typescript-eslint/utils";
2537
import { Rule, RuleTester } from "eslint";
38+
import { hasLabeledChild } from "../../../../lib/util/hasLabeledChild";
39+
import { hasToolTipParent } from "../../../../lib/util/hasTooltipParent";
2640

2741
// Helper: reset all mocks to a default "false" stance
2842
const resetAllMocksToFalse = () => {
2943
(hasNonEmptyProp as jest.Mock).mockReset().mockReturnValue(false);
3044
(hasAssociatedLabelViaAriaLabelledBy as jest.Mock).mockReset().mockReturnValue(false);
45+
(hasAssociatedLabelViaAriaDescribedby as jest.Mock).mockReset().mockReturnValue(false);
3146
(isInsideLabelTag as jest.Mock).mockReset().mockReturnValue(false);
3247
(hasAssociatedLabelViaHtmlFor as jest.Mock).mockReset().mockReturnValue(false);
3348
(hasFieldParent as jest.Mock).mockReset().mockReturnValue(false);
49+
(hasLabeledChild as jest.Mock).mockReset().mockReturnValue(false);
50+
(hasToolTipParent as jest.Mock).mockReset().mockReturnValue(false);
3451
};
3552

3653
beforeEach(() => {
@@ -59,11 +76,14 @@ describe("hasAccessibleLabel (unit)", () => {
5976
component: "RadioGroup",
6077
labelProps: ["label", "aria-label"],
6178
allowFieldParent: true,
62-
allowFor: true,
79+
allowHtmlFor: true,
6380
allowLabelledBy: true,
6481
allowWrappingLabel: true,
82+
allowTooltipParent: true,
83+
allowDescribedBy: true,
6584
messageId: "errorMsg",
66-
description: "anything"
85+
description: "anything",
86+
allowLabeledChild: false
6787
};
6888

6989
test("returns false when no heuristics pass", () => {
@@ -78,6 +98,12 @@ describe("hasAccessibleLabel (unit)", () => {
7898
expect(hasAccessibleLabel(node, {}, cfg)).toBe(true);
7999
});
80100

101+
test("true when allowTooltipParent and hasTooltipParent(ctx) === true", () => {
102+
(hasToolTipParent as jest.Mock).mockReturnValue(true);
103+
const node = makeOpeningElement("RadioGroup");
104+
expect(hasAccessibleLabel(node, {}, cfg)).toBe(true);
105+
});
106+
81107
test("true when a label prop is non-empty via hasNonEmptyProp", () => {
82108
(hasNonEmptyProp as jest.Mock).mockImplementation((attrs, name) => (name === "label" ? true : false));
83109
const node = makeOpeningElement("RadioGroup", [
@@ -106,6 +132,12 @@ describe("hasAccessibleLabel (unit)", () => {
106132
const node = makeOpeningElement("RadioGroup");
107133
expect(hasAccessibleLabel(node, {}, cfg)).toBe(true);
108134
});
135+
136+
test("true when allowDescribedByBy and hasAssociatedLabelViaAriaDescribedBy(...) === true", () => {
137+
(hasAssociatedLabelViaAriaDescribedby as jest.Mock).mockReturnValue(true);
138+
const node = makeOpeningElement("RadioGroup");
139+
expect(hasAccessibleLabel(node, {}, cfg)).toBe(true);
140+
});
109141
});
110142

111143
/* -------------------------------------------------------------------------- */
@@ -119,11 +151,14 @@ describe("makeLabeledControlRule (RuleTester integration)", () => {
119151
component: "RadioGroup",
120152
labelProps: ["label", "aria-label"],
121153
allowFieldParent: true,
122-
allowFor: true,
154+
allowHtmlFor: true,
123155
allowLabelledBy: true,
124156
allowWrappingLabel: true,
157+
allowTooltipParent: true,
158+
allowDescribedBy: true,
125159
messageId: "noUnlabeledRadioGroup",
126-
description: "Accessibility: RadioGroup must have a programmatic and visual label."
160+
description: "Accessibility: RadioGroup must have a programmatic and visual label.",
161+
allowLabeledChild: false
127162
};
128163

129164
// 1) No heuristics -> report
@@ -253,4 +288,48 @@ describe("makeLabeledControlRule (RuleTester integration)", () => {
253288
invalid: []
254289
});
255290
});
291+
292+
// 8) in rare cases
293+
describe("accepts when aria-describedby is present", () => {
294+
beforeEach(() => {
295+
resetAllMocksToFalse();
296+
(hasAssociatedLabelViaAriaDescribedby as jest.Mock).mockImplementation(
297+
(node: any) =>
298+
Array.isArray(node?.attributes) &&
299+
node.attributes.some((a: any) => a?.type === "JSXAttribute" && a?.name?.name === "aria-describedby")
300+
);
301+
});
302+
303+
const rule = makeLabeledControlRule(baseCfg);
304+
305+
ruleTester.run("no-unlabeled-radio-group (aria-describedby)", rule as unknown as Rule.RuleModule, {
306+
valid: [{ code: `<RadioGroup aria-describedby="groupLabelId" />` }],
307+
invalid: [{ code: `<RadioGroup />`, errors: [{ messageId: baseCfg.messageId }] }]
308+
});
309+
});
310+
311+
// 9) tool tip parent
312+
describe("accepts when parent Tooltip provides label", () => {
313+
beforeEach(() => {
314+
resetAllMocksToFalse();
315+
(hasToolTipParent as jest.Mock).mockReturnValue(true);
316+
});
317+
318+
const rule = makeLabeledControlRule(baseCfg);
319+
320+
ruleTester.run("no-unlabeled-radio-group (Tooltip parent)", rule as unknown as Rule.RuleModule, {
321+
valid: [
322+
{
323+
code: `
324+
<>
325+
<Tooltip label="Account type">
326+
<RadioGroup />
327+
</Tooltip>
328+
</>
329+
`
330+
}
331+
],
332+
invalid: []
333+
});
334+
});
256335
});

0 commit comments

Comments
 (0)