Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ We currently cover the following components:
- [N/A] SkeletonItem
- [x] SpinButton
- [x] Spinner
- [] SwatchPicker
- [x] SwatchPicker
- [] ColorSwatch
- [] ImageSwatch
- [] EmptySwatch
- [] SwatchPickerRow
- [x] Switch
- [] SearchBox
- [] Table
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ Any use of third-party trademarks or logos are subject to those third-party's po
| [spin-button-needs-labelling](docs/rules/spin-button-needs-labelling.md) | Accessibility: SpinButtons must have an accessible label | ✅ | | |
| [spin-button-unrecommended-labelling](docs/rules/spin-button-unrecommended-labelling.md) | Accessibility: Unrecommended accessibility labelling - SpinButton | ✅ | | |
| [spinner-needs-labelling](docs/rules/spinner-needs-labelling.md) | Accessibility: Spinner must have either aria-label or label, aria-live and aria-busy attributes | ✅ | | |
| [swatchpicker-needs-labelling](docs/rules/swatchpicker-needs-labelling.md) | Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.. | ✅ | | |
| [switch-needs-labelling](docs/rules/switch-needs-labelling.md) | Accessibility: Switch must have an accessible label | ✅ | | |
| [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. | ✅ | | |
| [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | |
Expand Down
60 changes: 60 additions & 0 deletions docs/rules/swatchpicker-needs-labelling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc. (`@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling`)

💼 This rule is enabled in the ✅ `recommended` config.

<!-- end auto-generated rule header -->

All interactive elements must have an accessible name.

SwatchPicker without a label or accessible labeling lack an accessible name for assistive technology users.

<https://www.w3.org/WAI/standards-guidelines/act/rules/e086e5/>

## Ways to fix

- Add an aria-label or aria-labelledby attribute to the SwatchPicker tag. You can also use the Field component.

## Rule Details

This rule aims to make SwatchPickers accessible.

Examples of **incorrect** code for this rule:

```jsx
<SwatchPicker />
<Radio></Radio>
```

```jsx
<Label>This is a switch.</Label>
<SwatchPicker
onChange={onChange}
/>
```

Examples of **correct** code for this rule:

```jsx
<Label id="my-label-1">This is a Radio.</Label>
<SwatchPicker
delectedValue="00B053"
onSelectionChange={onSel}
aria-labelledby="my-label-1"
/>
```

```jsx
<SwatchPicker aria-label="anything" selectedValue="00B053" onSelectionChange={onSel}>
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
```

```jsx
<Field label="Pick a colour">
<SwatchPicker>
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
</Field>
```
2 changes: 2 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = {
"spin-button-needs-labelling": rules.spinButtonNeedsLabelling,
"spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling,
"spinner-needs-labelling": rules.spinnerNeedsLabelling,
"swatchpicker-needs-labelling": rules.swatchpickerNeedsLabelling,
"switch-needs-labelling": rules.switchNeedsLabelling,
"tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling,
"toolbar-missing-aria": rules.toolbarMissingAria,
Expand Down Expand Up @@ -79,6 +80,7 @@ module.exports = {
"@microsoft/fluentui-jsx-a11y/spin-button-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/spin-button-unrecommended-labelling": "error",
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/switch-needs-labelling": "error",
"@microsoft/fluentui-jsx-a11y/tablist-and-tabs-need-labelling": "error",
"@microsoft/fluentui-jsx-a11y/toolbar-missing-aria": "error",
Expand Down
1 change: 1 addition & 0 deletions lib/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export { default as ratingNeedsName } from "./rating-needs-name";
export { default as spinButtonNeedsLabelling } from "./spin-button-needs-labelling";
export { default as spinButtonUnrecommendedLabelling } from "./spin-button-unrecommended-labelling";
export { default as spinnerNeedsLabelling } from "./spinner-needs-labelling";
export { default as swatchpickerNeedsLabelling } from "./swatchpicker-needs-labelling";
export { default as switchNeedsLabelling } from "./switch-needs-labelling";
export { default as tablistAndTabsNeedLabelling } from "./tablist-and-tabs-need-labelling";
export { default as toolbarMissingAria } from "./toolbar-missing-aria";
Expand Down
4 changes: 2 additions & 2 deletions lib/rules/radiogroup-missing-label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({
return {
// visitor functions for different types of nodes
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
// if it is not a Checkbox, return
// if it is not a RadioGroup, return
if (elementType(node as JSXOpeningElement) !== "RadioGroup") {
return;
}

// if the Checkbox has a label, if the Switch has an associated label, return
// if the RadioGroup has a label, return
if (
hasFieldParent(context) ||
hasNonEmptyProp(node.attributes, "label") ||
Expand Down
24 changes: 24 additions & 0 deletions lib/rules/swatchpicker-needs-labelling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { ESLintUtils } from "@typescript-eslint/utils";
import { makeLabeledControlRule } from "../util/ruleFactory";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

export default ESLintUtils.RuleCreator.withoutDocs(
makeLabeledControlRule(
{
component: "SwatchPicker",
labelProps: ["aria-label"],
allowFieldParent: true,
allowFor: false,
allowLabelledBy: true,
allowWrappingLabel: false
},
"noUnlabeledSwatchPicker",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for readability, consider moving messageId and description to required properties in the LabeledControlConfig instead of just passing as params here

"Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.."
)
);
121 changes: 121 additions & 0 deletions lib/util/ruleFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { TSESLint, TSESTree } from "@typescript-eslint/utils";
import { hasNonEmptyProp } from "./hasNonEmptyProp";
import { hasAssociatedLabelViaAriaLabelledBy, isInsideLabelTag, hasAssociatedLabelViaHtmlFor } from "./labelUtils";
import { hasFieldParent } from "./hasFieldParent";
import { elementType } from "jsx-ast-utils";
import { JSXOpeningElement } from "estree-jsx";

export type LabeledControlConfig = {
component: string | RegExp;
labelProps: string[]; // e.g. ["label", "aria-label"]
allowFieldParent?: boolean; // e.g. <Field label=...><RadioGroup/></Field>
allowFor?: boolean; // htmlFor
allowLabelledBy?: boolean; // aria-labelledby
allowWrappingLabel?: boolean; // <label>...</label>
};

/**
* Returns `true` if the JSX opening element is considered **accessibly labelled**
* per the rule configuration. This function centralizes all supported labelling
* strategies so the rule stays small and testable.
*
* The supported strategies (gated by `config` flags) are:
* 1) A parent `<Field>`-like wrapper that provides the label context (`allowFieldParent`).
* 2) A non-empty inline prop such as `aria-label` or `title` (`labelProps`).
* 3) Being wrapped by a `<label>` element (`allowWrappingLabel`).
* 4) Associated `<label for="...">` / `htmlFor` relation (`allowFor`).
* 5) `aria-labelledby` association to an element with textual content (`allowLabelledBy`).
*
* Note: This does not validate contrast or UX; it only checks the existence of
* an accessible **name** via common HTML/ARIA labelling patterns.
*
* @param node - The JSX opening element we’re inspecting (e.g., `<Input ...>` opening node).
* @param context - ESLint rule context or tree-walker context used by helper functions to
* resolve scope/ancestors and collect referenced nodes.
* @param config - Rule configuration describing which components/props/associations count as labelled.
* Expected shape:
* - `component: string | RegExp` — component tag name or regex to match.
* - `labelProps: string[]` — prop names that, when non-empty, count as labels (e.g., `["aria-label","title"]`).
* - `allowFieldParent?: boolean` — if true, a recognized parent “Field” wrapper satisfies labelling.
* - `allowWrappingLabel?: boolean` — if true, being inside a `<label>` satisfies labelling.
* - `allowFor?: boolean` — if true, `<label htmlFor>` association is considered.
* - `allowLabelledBy?: boolean` — if true, `aria-labelledby` association is considered.
* @returns `true` if any configured labelling strategy succeeds; otherwise `false`.
*/
export function hasAccessibleLabel(node: TSESTree.JSXOpeningElement, context: any, config: LabeledControlConfig): boolean {
if (config.allowFieldParent && hasFieldParent(context)) return true;
if (config.labelProps.some(p => hasNonEmptyProp(node.attributes, p))) return true;
if (config.allowWrappingLabel && isInsideLabelTag(context)) return true;
if (config.allowFor && hasAssociatedLabelViaHtmlFor(node, context)) return true;
if (config.allowLabelledBy && hasAssociatedLabelViaAriaLabelledBy(node, context)) return true;
return false;
}

/**
* Factory for a minimal, strongly-configurable ESLint rule that enforces
* accessible labelling on a specific JSX element/component.
*
* The rule:
* • Matches opening elements by `config.component` (exact name or RegExp).
* • Uses `hasAccessibleLabel` to decide whether the element is labelled.
* • Reports with `messageId` if no labelling strategy succeeds.
*
* Example:
* ```ts
* export default makeLabeledControlRule(
* {
* component: /^(?:input|textarea|Select|ComboBox)$/i,
* labelProps: ["aria-label", "aria-labelledby", "title"],
* allowFieldParent: true,
* allowWrappingLabel: true,
* allowFor: true,
* allowLabelledBy: true,
* },
* "missingLabel",
* "Provide an accessible label (e.g., via <label>, htmlFor, aria-label, or aria-labelledby)."
* );
* ```
*
* @param config - See `hasAccessibleLabel` for the configuration fields and semantics.
* @param messageId - The message key used in `meta.messages` (e.g., "missingLabel").
* @param description - Human-readable rule description and the text displayed for `messageId`.
* @returns An ESLint `RuleModule` that reports when the configured component lacks an accessible label.
*/
export function makeLabeledControlRule(
config: LabeledControlConfig,
messageId: string,
description: string
): TSESLint.RuleModule<string, []> {
return {
meta: {
type: "problem" as const,
messages: { [messageId]: description },
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this have a const assertion too?

docs: {
description,
recommended: "strict" as const, // not `true`
url: "https://www.w3.org/TR/html-aria/"
},
schema: []
},
defaultOptions: [] as const,

create(context: TSESLint.RuleContext<string, []>) {
return {
JSXOpeningElement(node: TSESTree.JSXOpeningElement) {
// elementType expects an ESTree JSX node — cast is fine
const name = elementType(node as unknown as JSXOpeningElement);
const matches = typeof config.component === "string" ? name === config.component : config.component.test(name);

if (!matches) return;

if (!hasAccessibleLabel(node, context, config)) {
context.report({ node, messageId });
}
}
};
}
};
}
97 changes: 97 additions & 0 deletions tests/lib/rules/swatchpicker-needs-labelling-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { Rule } from "eslint";
import ruleTester from "./helper/ruleTester";
import rule from "../../../lib/rules/swatchpicker-needs-labelling";

// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------

ruleTester.run("swatchpicker-needs-labelling", rule as unknown as Rule.RuleModule, {
valid: [
// 1) aria-label on the SwatchPicker

`<SwatchPicker aria-label="Choose color" selectedValue="00B053" onSelectionChange={onSel}>
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
`,
// 2) aria-labelledby → text element
`
<>
<span id="colorLabel">Choose color</span>
<SwatchPicker aria-labelledby="colorLabel">
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
</>
`,
// 3) aria-labelledby → Fluent Label
`<>
<Label id="colorLabel">Choose color</Label>
<SwatchPicker aria-labelledby="colorLabel">
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
</>
`,
// 4) aria-labelledby with multiple ids (concatenated label)
`<>
<span id="a">Choose</span> <span id="b">favorite color</span>
<SwatchPicker aria-labelledby="a b">
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
</>
`,
// 5) Field wrapper with label prop
`
<Field label="Choose color">
<SwatchPicker>
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
</Field>
`
],

invalid: [
// Unlabeled SwatchPicker (children present, but no accessible name)
{
code: `
<SwatchPicker>
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
`,
errors: [{ messageId: "noUnlabeledSwatchPicker" }]
},
{
// 7) Native <label> wrapping (implicit label)
code: `
<label>
Choose color
<SwatchPicker>
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
</label>
`,
errors: [{ messageId: "noUnlabeledSwatchPicker" }]
},
{
code: `
<>
<label htmlFor="colorPicker">Choose color</label>
<SwatchPicker id="colorPicker">
<ColorSwatch color="#FF1921" value="FF1921" aria-label="red" />
<ColorSwatch color="#00B053" value="00B053" aria-label="green" />
</SwatchPicker>
</>
`,
errors: [{ messageId: "noUnlabeledSwatchPicker" }]
}
]
});
Loading
Loading