Skip to content

Commit 2306b4d

Browse files
authored
Merge pull request #147 from microsoft/users/aubreyquinn/swatchPicker
Users/aubreyquinn/swatch picker
2 parents ff2b8dd + 4f771ca commit 2306b4d

File tree

12 files changed

+654
-45
lines changed

12 files changed

+654
-45
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

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ Any use of third-party trademarks or logos are subject to those third-party's po
137137
| [spin-button-needs-labelling](docs/rules/spin-button-needs-labelling.md) | Accessibility: SpinButtons must have an accessible label | ✅ | | |
138138
| [spin-button-unrecommended-labelling](docs/rules/spin-button-unrecommended-labelling.md) | Accessibility: Unrecommended accessibility labelling - SpinButton | ✅ | | |
139139
| [spinner-needs-labelling](docs/rules/spinner-needs-labelling.md) | Accessibility: Spinner must have either aria-label or label, aria-live and aria-busy attributes | ✅ | | |
140+
| [swatchpicker-needs-labelling](docs/rules/swatchpicker-needs-labelling.md) | Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.. | ✅ | | |
140141
| [switch-needs-labelling](docs/rules/switch-needs-labelling.md) | Accessibility: Switch must have an accessible label | ✅ | | |
141142
| [tablist-and-tabs-need-labelling](docs/rules/tablist-and-tabs-need-labelling.md) | This rule aims to ensure that Tabs with icons but no text labels have an accessible name and that Tablist is properly labeled. | ✅ | | |
142143
| [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | |
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc. (`@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling`)
2+
3+
💼 This rule is enabled in the ✅ `recommended` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
All interactive elements must have an accessible name.
8+
9+
SwatchPicker without a label or accessible labeling lack an accessible name for assistive technology users.
10+
11+
<https://www.w3.org/WAI/standards-guidelines/act/rules/e086e5/>
12+
13+
## Ways to fix
14+
15+
- Add an aria-label or aria-labelledby attribute to the SwatchPicker tag. You can also use the Field component.
16+
17+
## Rule Details
18+
19+
This rule aims to make SwatchPickers accessible.
20+
21+
Examples of **incorrect** code for this rule:
22+
23+
```jsx
24+
<SwatchPicker />
25+
<Radio></Radio>
26+
```
27+
28+
```jsx
29+
<Label>This is a switch.</Label>
30+
<SwatchPicker
31+
onChange={onChange}
32+
/>
33+
```
34+
35+
Examples of **correct** code for this rule:
36+
37+
```jsx
38+
<Label id="my-label-1">This is a Radio.</Label>
39+
<SwatchPicker
40+
delectedValue="00B053"
41+
onSelectionChange={onSel}
42+
aria-labelledby="my-label-1"
43+
/>
44+
```
45+
46+
```jsx
47+
<SwatchPicker aria-label="anything" selectedValue="00B053" onSelectionChange={onSel}>
48+
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
49+
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
50+
</SwatchPicker>
51+
```
52+
53+
```jsx
54+
<Field label="Pick a colour">
55+
<SwatchPicker>
56+
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
57+
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
58+
</SwatchPicker>
59+
</Field>
60+
```

lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ module.exports = {
4444
"@microsoft/fluentui-jsx-a11y/spin-button-needs-labelling": "error",
4545
"@microsoft/fluentui-jsx-a11y/spin-button-unrecommended-labelling": "error",
4646
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error",
47+
"@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling": "error",
4748
"@microsoft/fluentui-jsx-a11y/switch-needs-labelling": "error",
4849
"@microsoft/fluentui-jsx-a11y/tablist-and-tabs-need-labelling": "error",
4950
"@microsoft/fluentui-jsx-a11y/toolbar-missing-aria": "error",
@@ -81,6 +82,7 @@ module.exports = {
8182
"spin-button-needs-labelling": rules.spinButtonNeedsLabelling,
8283
"spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling,
8384
"spinner-needs-labelling": rules.spinnerNeedsLabelling,
85+
"swatchpicker-needs-labelling": rules.swatchpickerNeedsLabelling,
8486
"switch-needs-labelling": rules.switchNeedsLabelling,
8587
"tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling,
8688
"toolbar-missing-aria": rules.toolbarMissingAria,

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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
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+
component: "SwatchPicker",
14+
labelProps: ["aria-label"],
15+
allowFieldParent: true,
16+
allowFor: false,
17+
allowLabelledBy: true,
18+
allowWrappingLabel: false,
19+
messageId: "noUnlabeledSwatchPicker",
20+
description: "Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.."
21+
})
22+
);

lib/util/ruleFactory.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
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

Comments
 (0)