Skip to content

Commit 7131930

Browse files
committed
added new lint rule for SwatchPicker component
1 parent 8a349aa commit 7131930

File tree

9 files changed

+565
-3
lines changed

9 files changed

+565
-3
lines changed

COVERAGE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ We currently cover the following components:
6363
- [N/A] SkeletonItem
6464
- [x] SpinButton
6565
- [x] Spinner
66-
- [] SwatchPicker
66+
- [x] SwatchPicker
67+
- [] ColorSwatch
68+
- [] ImageSwatch
69+
- [] EmptySwatch
70+
- [] SwatchPickerRow
6771
- [x] Switch
6872
- [] SearchBox
6973
- [] Table
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# $DESCRIPTION (@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling)
2+
3+
All interactive elements must have an accessible name.
4+
5+
SwatchPicker without a label or accessible labeling lack an accessible name for assistive technology users.
6+
7+
<https://www.w3.org/WAI/standards-guidelines/act/rules/e086e5/>
8+
9+
## Ways to fix
10+
11+
- Add an aria-label or aria-labelledby attribute to the SwatchPicker tag. You can also use the Field component.
12+
13+
## Rule Details
14+
15+
This rule aims to make SwatchPickers accessible.
16+
17+
Examples of **incorrect** code for this rule:
18+
19+
```jsx
20+
<SwatchPicker />
21+
<Radio></Radio>
22+
```
23+
24+
```jsx
25+
<Label>This is a switch.</Label>
26+
<SwatchPicker
27+
onChange={onChange}
28+
/>
29+
```
30+
31+
Examples of **correct** code for this rule:
32+
33+
```jsx
34+
<Label id="my-label-1">This is a Radio.</Label>
35+
<SwatchPicker
36+
delectedValue="00B053"
37+
onSelectionChange={onSel}
38+
aria-labelledby="my-label-1"
39+
/>
40+
```
41+
42+
```jsx
43+
<SwatchPicker aria-label="anything" selectedValue="00B053" onSelectionChange={onSel}>
44+
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
45+
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
46+
</SwatchPicker>
47+
```
48+
49+
```jsx
50+
<Field label="Pick a colour">
51+
<SwatchPicker>
52+
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
53+
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
54+
</SwatchPicker>
55+
</Field>
56+
```

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ module.exports = {
4242
"spin-button-needs-labelling": rules.spinButtonNeedsLabelling,
4343
"spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling,
4444
"spinner-needs-labelling": rules.spinnerNeedsLabelling,
45+
"swatchpicker-needs-labelling": rules.swatchpickerNeedsLabelling,
4546
"switch-needs-labelling": rules.switchNeedsLabelling,
4647
"tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling,
4748
"toolbar-missing-aria": rules.toolbarMissingAria,
@@ -79,6 +80,7 @@ module.exports = {
7980
"@microsoft/fluentui-jsx-a11y/spin-button-needs-labelling": "error",
8081
"@microsoft/fluentui-jsx-a11y/spin-button-unrecommended-labelling": "error",
8182
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error",
83+
"@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling": "error",
8284
"@microsoft/fluentui-jsx-a11y/switch-needs-labelling": "error",
8385
"@microsoft/fluentui-jsx-a11y/tablist-and-tabs-need-labelling": "error",
8486
"@microsoft/fluentui-jsx-a11y/toolbar-missing-aria": "error",

lib/rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export { default as ratingNeedsName } from "./rating-needs-name";
2929
export { default as spinButtonNeedsLabelling } from "./spin-button-needs-labelling";
3030
export { default as spinButtonUnrecommendedLabelling } from "./spin-button-unrecommended-labelling";
3131
export { default as spinnerNeedsLabelling } from "./spinner-needs-labelling";
32+
export { default as swatchpickerNeedsLabelling } from "./swatchpicker-needs-labelling";
3233
export { default as switchNeedsLabelling } from "./switch-needs-labelling";
3334
export { default as tablistAndTabsNeedLabelling } from "./tablist-and-tabs-need-labelling";
3435
export { default as toolbarMissingAria } from "./toolbar-missing-aria";

lib/rules/radiogroup-missing-label.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({
3535
return {
3636
// visitor functions for different types of nodes
3737
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
38-
// if it is not a Checkbox, return
38+
// if it is not a RadioGroup, return
3939
if (elementType(node as JSXOpeningElement) !== "RadioGroup") {
4040
return;
4141
}
4242

43-
// if the Checkbox has a label, if the Switch has an associated label, return
43+
// if the RadioGroup has a label, return
4444
if (
4545
hasFieldParent(context) ||
4646
hasNonEmptyProp(node.attributes, "label") ||
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { ESLintUtils } from "@typescript-eslint/utils";
5+
import { makeLabeledControlRule } from "../util/ruleFactory";
6+
7+
//------------------------------------------------------------------------------
8+
// Rule Definition
9+
//------------------------------------------------------------------------------
10+
11+
export default ESLintUtils.RuleCreator.withoutDocs(
12+
makeLabeledControlRule(
13+
{
14+
component: "SwatchPicker",
15+
labelProps: ["aria-label"],
16+
allowFieldParent: true,
17+
allowFor: false,
18+
allowLabelledBy: true,
19+
allowWrappingLabel: false
20+
},
21+
"noUnlabeledSwatchPicker",
22+
"Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.."
23+
)
24+
);

lib/util/ruleFactory.ts

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

0 commit comments

Comments
 (0)