Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -44,6 +44,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 Expand Up @@ -81,6 +82,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
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
22 changes: 22 additions & 0 deletions lib/rules/swatchpicker-needs-labelling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// 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,
messageId: "noUnlabeledSwatchPicker",
description: "Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.."
})
);
117 changes: 117 additions & 0 deletions lib/util/ruleFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// 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>
messageId: string;
description: string;
};

/**
* 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.
* @returns An ESLint `RuleModule` that reports when the configured component lacks an accessible label.
*/
export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.RuleModule<string, []> {
return {
meta: {
type: "problem",
messages: { [config.messageId]: config.description },
docs: {
description: config.description,
recommended: "strict",
url: "https://www.w3.org/TR/html-aria/"
},
schema: []
},
defaultOptions: [],

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: config.messageId });
}
}
};
}
};
}
Loading
Loading