From 71319307c499fde3abbe1c61a1d4887783d918b5 Mon Sep 17 00:00:00 2001 From: Aubrey Quinn Date: Tue, 16 Sep 2025 16:43:44 +0100 Subject: [PATCH 1/4] added new lint rule for SwatchPicker component --- COVERAGE.md | 6 +- docs/rules/swatchpicker-needs-labelling.md | 56 ++++ lib/index.ts | 2 + lib/rules/index.ts | 1 + lib/rules/radiogroup-missing-label.ts | 4 +- lib/rules/swatchpicker-needs-labelling.ts | 24 ++ lib/util/ruleFactory.ts | 121 +++++++++ .../swatchpicker-needs-labelling-test.ts | 97 +++++++ tests/lib/rules/utils/ruleFactory.test.ts | 257 ++++++++++++++++++ 9 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 docs/rules/swatchpicker-needs-labelling.md create mode 100644 lib/rules/swatchpicker-needs-labelling.ts create mode 100644 lib/util/ruleFactory.ts create mode 100644 tests/lib/rules/swatchpicker-needs-labelling-test.ts create mode 100644 tests/lib/rules/utils/ruleFactory.test.ts diff --git a/COVERAGE.md b/COVERAGE.md index c9c1c32..d65a83b 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -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 diff --git a/docs/rules/swatchpicker-needs-labelling.md b/docs/rules/swatchpicker-needs-labelling.md new file mode 100644 index 0000000..73edb5a --- /dev/null +++ b/docs/rules/swatchpicker-needs-labelling.md @@ -0,0 +1,56 @@ +# $DESCRIPTION (@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling) + +All interactive elements must have an accessible name. + +SwatchPicker without a label or accessible labeling lack an accessible name for assistive technology users. + + + +## 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 + + +``` + +```jsx + + +``` + +Examples of **correct** code for this rule: + +```jsx + + +``` + +```jsx + + + + +``` + +```jsx + + + + + + +``` diff --git a/lib/index.ts b/lib/index.ts index 70b806a..494b4f4 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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, @@ -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", diff --git a/lib/rules/index.ts b/lib/rules/index.ts index ec59318..bfdbe7b 100644 --- a/lib/rules/index.ts +++ b/lib/rules/index.ts @@ -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"; diff --git a/lib/rules/radiogroup-missing-label.ts b/lib/rules/radiogroup-missing-label.ts index eb585f2..a74a3c9 100644 --- a/lib/rules/radiogroup-missing-label.ts +++ b/lib/rules/radiogroup-missing-label.ts @@ -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") || diff --git a/lib/rules/swatchpicker-needs-labelling.ts b/lib/rules/swatchpicker-needs-labelling.ts new file mode 100644 index 0000000..75efb85 --- /dev/null +++ b/lib/rules/swatchpicker-needs-labelling.ts @@ -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", + "Accessibility: SwatchPicker must have an accessible name via aria-label, aria-labelledby, Field component, etc.." + ) +); diff --git a/lib/util/ruleFactory.ts b/lib/util/ruleFactory.ts new file mode 100644 index 0000000..c1262a2 --- /dev/null +++ b/lib/util/ruleFactory.ts @@ -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. + allowFor?: boolean; // htmlFor + allowLabelledBy?: boolean; // aria-labelledby + allowWrappingLabel?: boolean; // +}; + +/** + * 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 ``-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 `