From 07398b5d529032077627bb2f05dea442ccf7bc4b Mon Sep 17 00:00:00 2001 From: Iryna Vasylenko Date: Wed, 17 Sep 2025 16:06:42 +0100 Subject: [PATCH 1/6] Fix merge conflicts --- README.md | 3 +- docs/rules/tag-dismissible-needs-labelling.md | 49 +++++++++++ docs/rules/tag-needs-name.md | 61 +++++++++++++ lib/index.ts | 78 +++++++++-------- lib/rules/index.ts | 2 + lib/rules/tag-dismissible-needs-labelling.ts | 87 +++++++++++++++++++ lib/rules/tag-needs-name.ts | 59 +++++++++++++ .../tag-dismissible-needs-labelling.test.ts | 42 +++++++++ tests/lib/rules/tag-needs-name.test.ts | 50 +++++++++++ 9 files changed, 392 insertions(+), 39 deletions(-) create mode 100644 docs/rules/tag-dismissible-needs-labelling.md create mode 100644 docs/rules/tag-needs-name.md create mode 100644 lib/rules/tag-dismissible-needs-labelling.ts create mode 100644 lib/rules/tag-needs-name.ts create mode 100644 tests/lib/rules/tag-dismissible-needs-labelling.test.ts create mode 100644 tests/lib/rules/tag-needs-name.test.ts diff --git a/README.md b/README.md index 29e74b0..f1bf646 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,10 @@ 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. | ✅ | | | +| [tag-dismissible-needs-labelling](docs/rules/tag-dismissible-needs-labelling.md) | This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button | ✅ | | | +| [tag-needs-name](docs/rules/tag-needs-name.md) | This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby. | ✅ | | | | [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | | | [tooltip-not-recommended](docs/rules/tooltip-not-recommended.md) | Accessibility: Prefer text content or aria over a tooltip for these components MenuItem, SpinButton | ✅ | | | | [visual-label-better-than-aria-suggestion](docs/rules/visual-label-better-than-aria-suggestion.md) | Visual label is better than an aria-label because sighted users can't read the aria-label text. | | ✅ | | diff --git a/docs/rules/tag-dismissible-needs-labelling.md b/docs/rules/tag-dismissible-needs-labelling.md new file mode 100644 index 0000000..c82f1ec --- /dev/null +++ b/docs/rules/tag-dismissible-needs-labelling.md @@ -0,0 +1,49 @@ +# This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button (`@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling`) + +💼 This rule is enabled in the ✅ `recommended` config. + + + +All interactive elements must have an accessible name. + +Dismissible Tag components render a dismiss/close button that must have an accessible name for screen reader users. + +When a Tag has the `dismissible` prop, it must provide a `dismissIcon` with an `aria-label`. + + + +## Rule Details + +This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button. + +Examples of **incorrect** code for this rule: + +```jsx +Dismissible tag +``` + +```jsx +Dismissible tag +``` + +```jsx +Dismissible tag +``` + +Examples of **correct** code for this rule: + +```jsx +Regular tag +``` + +```jsx +}>Tag with icon +``` + +```jsx +Dismissible tag +``` + +```jsx +}>Tag with icon +``` diff --git a/docs/rules/tag-needs-name.md b/docs/rules/tag-needs-name.md new file mode 100644 index 0000000..98fe768 --- /dev/null +++ b/docs/rules/tag-needs-name.md @@ -0,0 +1,61 @@ +# This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby (`@microsoft/fluentui-jsx-a11y/tag-needs-name`) + +💼 This rule is enabled in the ✅ `recommended` config. + + + +All interactive elements must have an accessible name. + +Tag components need an accessible name for screen reader users. + +Please provide text content, aria-label, or aria-labelledby. + + + +## Rule Details + +This rule aims to ensure that Tag components have an accessible name via text content, aria-label, or aria-labelledby. + +Examples of **incorrect** code for this rule: + +```jsx + +``` + +```jsx + +``` + +```jsx + +``` + +```jsx +}> +``` + +```jsx +} /> +``` + +Examples of **correct** code for this rule: + +```jsx +Tag with some text +``` + +```jsx + +``` + +```jsx +Some text +``` + +```jsx +}>Tag with icon and text +``` + +```jsx +} aria-label="Settings tag"> +``` diff --git a/lib/index.ts b/lib/index.ts index bbf4bfc..efef4a8 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -13,6 +13,43 @@ import * as rules from "./rules"; // import all rules in lib/rules module.exports = { + rules: { + "accordion-header-needs-labelling": rules.accordionHeaderNeedsLabelling, + "accordion-item-needs-header-and-panel": rules.accordionItemNeedsHeaderAndPanel, + "avatar-needs-name": rules.avatarNeedsName, + "avoid-using-aria-describedby-for-primary-labelling": rules.avoidUsingAriaDescribedByForPrimaryLabelling, + "badge-needs-accessible-name": rules.badgeNeedsAccessibleName, + "breadcrumb-needs-labelling": rules.breadcrumbNeedsLabelling, + "checkbox-needs-labelling": rules.checkboxNeedsLabelling, + "combobox-needs-labelling": rules.comboboxNeedsLabelling, + "compound-button-needs-labelling": rules.compoundButtonNeedsLabelling, + "counter-badge-needs-count": rules.counterBadgeNeedsCount, + "dialogbody-needs-title-content-and-actions": rules.dialogbodyNeedsTitleContentAndActions, + "dialogsurface-needs-aria": rules.dialogsurfaceNeedsAria, + "dropdown-needs-labelling": rules.dropdownNeedsLabelling, + "field-needs-labelling": rules.fieldNeedsLabelling, + "image-button-missing-aria": rules.imageButtonMissingAria, + "input-components-require-accessible-name": rules.inputComponentsRequireAccessibleName, + "link-missing-labelling": rules.linkMissingLabelling, + "menu-item-needs-labelling": rules.menuItemNeedsLabelling, + "no-empty-buttons": rules.noEmptyButtons, + "no-empty-components": rules.noEmptyComponents, + "prefer-aria-over-title-attribute": rules.preferAriaOverTitleAttribute, + "progressbar-needs-labelling": rules.progressbarNeedsLabelling, + "radio-button-missing-label": rules.radioButtonMissingLabel, + "radiogroup-missing-label": rules.radiogroupMissingLabel, + "rating-needs-name": rules.ratingNeedsName, + "spin-button-needs-labelling": rules.spinButtonNeedsLabelling, + "spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling, + "spinner-needs-labelling": rules.spinnerNeedsLabelling, + "switch-needs-labelling": rules.switchNeedsLabelling, + "tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling, + "tag-dismissible-needs-labelling": rules.tagDismissibleNeedsLabelling, + "tag-needs-name": rules.tagNeedsName, + "toolbar-missing-aria": rules.toolbarMissingAria, + "tooltip-not-recommended": rules.tooltipNotRecommended, + "visual-label-better-than-aria-suggestion": rules.visualLabelBetterThanAriaSuggestion + }, configs: { recommended: { rules: { @@ -44,54 +81,19 @@ 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/tag-dismissible-needs-labelling": "error", + "@microsoft/fluentui-jsx-a11y/tag-needs-name": "error", "@microsoft/fluentui-jsx-a11y/toolbar-missing-aria": "error", "@microsoft/fluentui-jsx-a11y/tooltip-not-recommended": "error", "@microsoft/fluentui-jsx-a11y/visual-label-better-than-aria-suggestion": "warn" } } - }, - rules: { - "accordion-header-needs-labelling": rules.accordionHeaderNeedsLabelling, - "accordion-item-needs-header-and-panel": rules.accordionItemNeedsHeaderAndPanel, - "avatar-needs-name": rules.avatarNeedsName, - "avoid-using-aria-describedby-for-primary-labelling": rules.avoidUsingAriaDescribedByForPrimaryLabelling, - "badge-needs-accessible-name": rules.badgeNeedsAccessibleName, - "breadcrumb-needs-labelling": rules.breadcrumbNeedsLabelling, - "checkbox-needs-labelling": rules.checkboxNeedsLabelling, - "combobox-needs-labelling": rules.comboboxNeedsLabelling, - "compound-button-needs-labelling": rules.compoundButtonNeedsLabelling, - "counter-badge-needs-count": rules.counterBadgeNeedsCount, - "dialogbody-needs-title-content-and-actions": rules.dialogbodyNeedsTitleContentAndActions, - "dialogsurface-needs-aria": rules.dialogsurfaceNeedsAria, - "dropdown-needs-labelling": rules.dropdownNeedsLabelling, - "field-needs-labelling": rules.fieldNeedsLabelling, - "image-button-missing-aria": rules.imageButtonMissingAria, - "input-components-require-accessible-name": rules.inputComponentsRequireAccessibleName, - "link-missing-labelling": rules.linkMissingLabelling, - "menu-item-needs-labelling": rules.menuItemNeedsLabelling, - "no-empty-buttons": rules.noEmptyButtons, - "no-empty-components": rules.noEmptyComponents, - "prefer-aria-over-title-attribute": rules.preferAriaOverTitleAttribute, - "progressbar-needs-labelling": rules.progressbarNeedsLabelling, - "radio-button-missing-label": rules.radioButtonMissingLabel, - "radiogroup-missing-label": rules.radiogroupMissingLabel, - "rating-needs-name": rules.ratingNeedsName, - "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, - "tooltip-not-recommended": rules.tooltipNotRecommended, - "visual-label-better-than-aria-suggestion": rules.visualLabelBetterThanAriaSuggestion } }; // import processors module.exports.processors = { // add your processors here -}; +}; \ No newline at end of file diff --git a/lib/rules/index.ts b/lib/rules/index.ts index bfdbe7b..e546052 100644 --- a/lib/rules/index.ts +++ b/lib/rules/index.ts @@ -32,6 +32,8 @@ 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 tagDismissibleNeedsLabelling } from "./tag-dismissible-needs-labelling"; +export { default as tagNeedsName } from "./tag-needs-name"; export { default as toolbarMissingAria } from "./toolbar-missing-aria"; export { default as tooltipNotRecommended } from "./tooltip-not-recommended"; export { default as visualLabelBetterThanAriaSuggestion } from "./visual-label-better-than-aria-suggestion"; diff --git a/lib/rules/tag-dismissible-needs-labelling.ts b/lib/rules/tag-dismissible-needs-labelling.ts new file mode 100644 index 0000000..762917c --- /dev/null +++ b/lib/rules/tag-dismissible-needs-labelling.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { elementType, hasProp, getProp, getPropValue } from "jsx-ast-utils"; +import { JSXOpeningElement, JSXAttribute } from "estree-jsx"; + +//------------------------------------------------------------------------------ +// Utility Functions +//------------------------------------------------------------------------------ + +/** + * Checks if a value is a non-empty string (same logic as hasNonEmptyProp for strings) + */ +const isNonEmptyString = (value: any): boolean => { + return typeof value === "string" && value.trim().length > 0; +}; + +/** + * Checks if an object has a non-empty string property + */ +const hasNonEmptyObjectProperty = (obj: any, propertyName: string): boolean => { + if (!obj || typeof obj !== "object") return false; + return isNonEmptyString(obj[propertyName]); +}; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ +const rule = ESLintUtils.RuleCreator.withoutDocs({ + defaultOptions: [], + meta: { + type: "problem", + docs: { + description: + "This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button", + recommended: false + }, + fixable: undefined, + schema: [], + messages: { + missingDismissLabel: "Accessibility: Dismissible Tag must have dismissIcon with aria-label" + }, + }, + create(context) { + return { + // visitor functions for different types of nodes + JSXElement(node: TSESTree.JSXElement) { + const openingElement = node.openingElement; + + // if it is not a Tag, return + if (elementType(openingElement as JSXOpeningElement) !== "Tag") { + return; + } + + // Check if Tag has dismissible prop + const isDismissible = hasProp(openingElement.attributes as JSXAttribute[], "dismissible"); + if (!isDismissible) { + return; + } + + // Check if dismissible Tag has dismissIcon with aria-label + const dismissIconProp = getProp(openingElement.attributes as JSXAttribute[], "dismissIcon"); + + if (!dismissIconProp) { + context.report({ + node, + messageId: `missingDismissLabel` + }); + return; + } + + // Get the dismissIcon value and check if it has valid aria-label + const dismissIconValue = getPropValue(dismissIconProp); + + if (!hasNonEmptyObjectProperty(dismissIconValue, "aria-label")) { + context.report({ + node, + messageId: `missingDismissLabel` + }); + } + } + }; + } +}); + +export default rule; diff --git a/lib/rules/tag-needs-name.ts b/lib/rules/tag-needs-name.ts new file mode 100644 index 0000000..c02b98f --- /dev/null +++ b/lib/rules/tag-needs-name.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { elementType } from "jsx-ast-utils"; +import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; +import { hasTextContentChild } from "../util/hasTextContentChild"; +import { hasAssociatedLabelViaAriaLabelledBy } from "../util/labelUtils"; +import { JSXOpeningElement } from "estree-jsx"; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +const rule = ESLintUtils.RuleCreator.withoutDocs({ + defaultOptions: [], + meta: { + type: "problem", + docs: { + description: + "This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby.", + recommended: "strict", + url: "https://react.fluentui.dev/?path=/docs/components-tag-tag--docs" + }, + fixable: undefined, + schema: [], + messages: { + missingAriaLabel: "Accessibility: Tag must have an accessible name" + } + }, + create(context) { + return { + // visitor functions for different types of nodes + JSXElement(node: TSESTree.JSXElement) { + const openingElement = node.openingElement; + + // if it is not a Tag, return + if (elementType(openingElement as JSXOpeningElement) !== "Tag") { + return; + } + + // Check if tag has any accessible name + const hasTextContent = hasTextContentChild(node); + const hasAriaLabel = hasNonEmptyProp(openingElement.attributes, "aria-label"); + const hasAriaLabelledBy = hasAssociatedLabelViaAriaLabelledBy(openingElement, context); + const hasAccessibleName = hasTextContent || hasAriaLabel || hasAriaLabelledBy; + + if (!hasAccessibleName) { + context.report({ + node, + messageId: `missingAriaLabel` + }); + } + } + }; + } +}); + +export default rule; diff --git a/tests/lib/rules/tag-dismissible-needs-labelling.test.ts b/tests/lib/rules/tag-dismissible-needs-labelling.test.ts new file mode 100644 index 0000000..71a4460 --- /dev/null +++ b/tests/lib/rules/tag-dismissible-needs-labelling.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Rule } from "eslint"; +import ruleTester from "./helper/ruleTester"; +import rule from "../../../lib/rules/tag-dismissible-needs-labelling"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ +ruleTester.run("tag-dismissible-needs-labelling", rule as unknown as Rule.RuleModule, { + valid: [ + // Valid cases for dismissible Tag component + // Non-dismissible tags should be ignored + "Regular tag", + '}>Tag with icon', + + // Dismissible tags with proper labelling + 'Dismissible tag', + '}>Tag with icon' + ], + + invalid: [ + // Invalid cases for dismissible Tag component + { + code: 'Dismissible tag', + errors: [{ messageId: "missingDismissLabel" }] + }, + { + code: 'Dismissible tag', + errors: [{ messageId: "missingDismissLabel" }] + }, + { + code: 'Dismissible tag', + errors: [{ messageId: "missingDismissLabel" }] + } + ] +}); diff --git a/tests/lib/rules/tag-needs-name.test.ts b/tests/lib/rules/tag-needs-name.test.ts new file mode 100644 index 0000000..605d357 --- /dev/null +++ b/tests/lib/rules/tag-needs-name.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Rule } from "eslint"; +import ruleTester from "./helper/ruleTester"; +import rule from "../../../lib/rules/tag-needs-name"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +ruleTester.run("tag-needs-name", rule as unknown as Rule.RuleModule, { + valid: [ + // Valid cases for Tag component + "Tag with some text", + "Some text", + '', + 'Some text', + '}>Tag with icon and text', + '} aria-label="Settings tag">' + ], + + invalid: [ + // Invalid cases for Tag component + { + code: "", + errors: [{ messageId: "missingAriaLabel" }] + }, + { + code: "", + errors: [{ messageId: "missingAriaLabel" }] + }, + { + code: '', + errors: [{ messageId: "missingAriaLabel" }] + }, + { + code: '}>', + errors: [{ messageId: "missingAriaLabel" }] + }, + { + code: '} />', + errors: [{ messageId: "missingAriaLabel" }] + } + ] +}); From b4135578afdf997d90ba04cc473e0f9352b4bfb3 Mon Sep 17 00:00:00 2001 From: Iryna Vasylenko Date: Wed, 17 Sep 2025 16:23:02 +0100 Subject: [PATCH 2/6] Refactor the rule --- README.md | 74 +++++++++--------- docs/rules/tag-dismissible-needs-labelling.md | 2 +- lib/index.ts | 76 +++++++++---------- lib/rules/tag-dismissible-needs-labelling.ts | 43 ++++++----- .../tag-dismissible-needs-labelling.test.ts | 28 +++++-- tests/lib/rules/tag-needs-name.test.ts | 8 +- 6 files changed, 127 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index f1bf646..1b6b712 100644 --- a/README.md +++ b/README.md @@ -107,42 +107,42 @@ Any use of third-party trademarks or logos are subject to those third-party's po ✅ Set in the `recommended` configuration.\ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -| Name                                               | Description | 💼 | ⚠️ | 🔧 | -| :--------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :- | :- | :- | -| [accordion-header-needs-labelling](docs/rules/accordion-header-needs-labelling.md) | The accordion header is a button and it needs an accessibile name e.g. text content, aria-label, aria-labelledby. | ✅ | | | -| [accordion-item-needs-header-and-panel](docs/rules/accordion-item-needs-header-and-panel.md) | An AccordionItem needs exactly one header and one panel | ✅ | | | -| [avatar-needs-name](docs/rules/avatar-needs-name.md) | Accessibility: Avatar must have an accessible labelling: name, aria-label, aria-labelledby | ✅ | | | -| [avoid-using-aria-describedby-for-primary-labelling](docs/rules/avoid-using-aria-describedby-for-primary-labelling.md) | aria-describedby provides additional context and is not meant for primary labeling. | ✅ | | | -| [badge-needs-accessible-name](docs/rules/badge-needs-accessible-name.md) | | ✅ | | 🔧 | -| [breadcrumb-needs-labelling](docs/rules/breadcrumb-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | -| [checkbox-needs-labelling](docs/rules/checkbox-needs-labelling.md) | Accessibility: Checkbox without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [combobox-needs-labelling](docs/rules/combobox-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | -| [compound-button-needs-labelling](docs/rules/compound-button-needs-labelling.md) | Accessibility: Compound buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | -| [counter-badge-needs-count](docs/rules/counter-badge-needs-count.md) | | ✅ | | 🔧 | -| [dialogbody-needs-title-content-and-actions](docs/rules/dialogbody-needs-title-content-and-actions.md) | A DialogBody should have a header(DialogTitle), content(DialogContent), and footer(DialogActions) | ✅ | | | -| [dialogsurface-needs-aria](docs/rules/dialogsurface-needs-aria.md) | DialogueSurface need accessible labelling: aria-describedby on DialogueSurface and aria-label or aria-labelledby(if DialogueTitle is missing) | ✅ | | | -| [dropdown-needs-labelling](docs/rules/dropdown-needs-labelling.md) | Accessibility: Dropdown menu must have an id and it needs to be linked via htmlFor of a Label | ✅ | | | -| [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have label | ✅ | | | -| [image-button-missing-aria](docs/rules/image-button-missing-aria.md) | Accessibility: Image buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | -| [input-components-require-accessible-name](docs/rules/input-components-require-accessible-name.md) | Accessibility: Input fields must have accessible labelling: aria-label, aria-labelledby or an associated label | ✅ | | | -| [link-missing-labelling](docs/rules/link-missing-labelling.md) | Accessibility: Image links must have an accessible name. Add either text content, labelling to the image or labelling to the link itself. | ✅ | | 🔧 | -| [menu-item-needs-labelling](docs/rules/menu-item-needs-labelling.md) | Accessibility: MenuItem without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [no-empty-buttons](docs/rules/no-empty-buttons.md) | Accessibility: Button, ToggleButton, SplitButton, MenuButton, CompoundButton must either text content or icon or child component | ✅ | | | -| [no-empty-components](docs/rules/no-empty-components.md) | FluentUI components should not be empty | ✅ | | | -| [prefer-aria-over-title-attribute](docs/rules/prefer-aria-over-title-attribute.md) | The title attribute is not consistently read by screen readers, and its behavior can vary depending on the screen reader and the user's settings. | | ✅ | 🔧 | -| [progressbar-needs-labelling](docs/rules/progressbar-needs-labelling.md) | Accessibility: Progressbar must have aria-valuemin, aria-valuemax, aria-valuenow, aria-describedby and either aria-label or aria-labelledby attributes | ✅ | | | -| [radio-button-missing-label](docs/rules/radio-button-missing-label.md) | Accessibility: Radio button without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [radiogroup-missing-label](docs/rules/radiogroup-missing-label.md) | Accessibility: RadioGroup without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [rating-needs-name](docs/rules/rating-needs-name.md) | Accessibility: Ratings must have accessible labelling: name, aria-label, aria-labelledby or itemLabel which generates aria-label | ✅ | | | -| [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 | ✅ | | | -| [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. | ✅ | | | -| [tag-dismissible-needs-labelling](docs/rules/tag-dismissible-needs-labelling.md) | This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button | ✅ | | | -| [tag-needs-name](docs/rules/tag-needs-name.md) | This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby. | ✅ | | | -| [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | | -| [tooltip-not-recommended](docs/rules/tooltip-not-recommended.md) | Accessibility: Prefer text content or aria over a tooltip for these components MenuItem, SpinButton | ✅ | | | -| [visual-label-better-than-aria-suggestion](docs/rules/visual-label-better-than-aria-suggestion.md) | Visual label is better than an aria-label because sighted users can't read the aria-label text. | | ✅ | | +| Name                                               | Description | 💼 | ⚠️ | 🔧 | +| :--------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :- | :- | :- | +| [accordion-header-needs-labelling](docs/rules/accordion-header-needs-labelling.md) | The accordion header is a button and it needs an accessibile name e.g. text content, aria-label, aria-labelledby. | ✅ | | | +| [accordion-item-needs-header-and-panel](docs/rules/accordion-item-needs-header-and-panel.md) | An AccordionItem needs exactly one header and one panel | ✅ | | | +| [avatar-needs-name](docs/rules/avatar-needs-name.md) | Accessibility: Avatar must have an accessible labelling: name, aria-label, aria-labelledby | ✅ | | | +| [avoid-using-aria-describedby-for-primary-labelling](docs/rules/avoid-using-aria-describedby-for-primary-labelling.md) | aria-describedby provides additional context and is not meant for primary labeling. | ✅ | | | +| [badge-needs-accessible-name](docs/rules/badge-needs-accessible-name.md) | | ✅ | | 🔧 | +| [breadcrumb-needs-labelling](docs/rules/breadcrumb-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | +| [checkbox-needs-labelling](docs/rules/checkbox-needs-labelling.md) | Accessibility: Checkbox without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [combobox-needs-labelling](docs/rules/combobox-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | +| [compound-button-needs-labelling](docs/rules/compound-button-needs-labelling.md) | Accessibility: Compound buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | +| [counter-badge-needs-count](docs/rules/counter-badge-needs-count.md) | | ✅ | | 🔧 | +| [dialogbody-needs-title-content-and-actions](docs/rules/dialogbody-needs-title-content-and-actions.md) | A DialogBody should have a header(DialogTitle), content(DialogContent), and footer(DialogActions) | ✅ | | | +| [dialogsurface-needs-aria](docs/rules/dialogsurface-needs-aria.md) | DialogueSurface need accessible labelling: aria-describedby on DialogueSurface and aria-label or aria-labelledby(if DialogueTitle is missing) | ✅ | | | +| [dropdown-needs-labelling](docs/rules/dropdown-needs-labelling.md) | Accessibility: Dropdown menu must have an id and it needs to be linked via htmlFor of a Label | ✅ | | | +| [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have label | ✅ | | | +| [image-button-missing-aria](docs/rules/image-button-missing-aria.md) | Accessibility: Image buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | +| [input-components-require-accessible-name](docs/rules/input-components-require-accessible-name.md) | Accessibility: Input fields must have accessible labelling: aria-label, aria-labelledby or an associated label | ✅ | | | +| [link-missing-labelling](docs/rules/link-missing-labelling.md) | Accessibility: Image links must have an accessible name. Add either text content, labelling to the image or labelling to the link itself. | ✅ | | 🔧 | +| [menu-item-needs-labelling](docs/rules/menu-item-needs-labelling.md) | Accessibility: MenuItem without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [no-empty-buttons](docs/rules/no-empty-buttons.md) | Accessibility: Button, ToggleButton, SplitButton, MenuButton, CompoundButton must either text content or icon or child component | ✅ | | | +| [no-empty-components](docs/rules/no-empty-components.md) | FluentUI components should not be empty | ✅ | | | +| [prefer-aria-over-title-attribute](docs/rules/prefer-aria-over-title-attribute.md) | The title attribute is not consistently read by screen readers, and its behavior can vary depending on the screen reader and the user's settings. | | ✅ | 🔧 | +| [progressbar-needs-labelling](docs/rules/progressbar-needs-labelling.md) | Accessibility: Progressbar must have aria-valuemin, aria-valuemax, aria-valuenow, aria-describedby and either aria-label or aria-labelledby attributes | ✅ | | | +| [radio-button-missing-label](docs/rules/radio-button-missing-label.md) | Accessibility: Radio button without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [radiogroup-missing-label](docs/rules/radiogroup-missing-label.md) | Accessibility: RadioGroup without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [rating-needs-name](docs/rules/rating-needs-name.md) | Accessibility: Ratings must have accessible labelling: name, aria-label, aria-labelledby or itemLabel which generates aria-label | ✅ | | | +| [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 | ✅ | | | +| [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. | ✅ | | | +| [tag-dismissible-needs-labelling](docs/rules/tag-dismissible-needs-labelling.md) | This rule aims to ensure that dismissible Tag components have proper accessibility labelling: either aria-label on dismissIcon or aria-label on Tag with role='presentation' on dismissIcon | ✅ | | | +| [tag-needs-name](docs/rules/tag-needs-name.md) | This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby. | ✅ | | | +| [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | | +| [tooltip-not-recommended](docs/rules/tooltip-not-recommended.md) | Accessibility: Prefer text content or aria over a tooltip for these components MenuItem, SpinButton | ✅ | | | +| [visual-label-better-than-aria-suggestion](docs/rules/visual-label-better-than-aria-suggestion.md) | Visual label is better than an aria-label because sighted users can't read the aria-label text. | | ✅ | | diff --git a/docs/rules/tag-dismissible-needs-labelling.md b/docs/rules/tag-dismissible-needs-labelling.md index c82f1ec..c2da269 100644 --- a/docs/rules/tag-dismissible-needs-labelling.md +++ b/docs/rules/tag-dismissible-needs-labelling.md @@ -1,4 +1,4 @@ -# This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button (`@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling`) +# This rule aims to ensure that dismissible Tag components have proper accessibility labelling: either aria-label on dismissIcon or aria-label on Tag with role='presentation' on dismissIcon (`@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling`) 💼 This rule is enabled in the ✅ `recommended` config. diff --git a/lib/index.ts b/lib/index.ts index efef4a8..82cfc3c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -13,43 +13,6 @@ import * as rules from "./rules"; // import all rules in lib/rules module.exports = { - rules: { - "accordion-header-needs-labelling": rules.accordionHeaderNeedsLabelling, - "accordion-item-needs-header-and-panel": rules.accordionItemNeedsHeaderAndPanel, - "avatar-needs-name": rules.avatarNeedsName, - "avoid-using-aria-describedby-for-primary-labelling": rules.avoidUsingAriaDescribedByForPrimaryLabelling, - "badge-needs-accessible-name": rules.badgeNeedsAccessibleName, - "breadcrumb-needs-labelling": rules.breadcrumbNeedsLabelling, - "checkbox-needs-labelling": rules.checkboxNeedsLabelling, - "combobox-needs-labelling": rules.comboboxNeedsLabelling, - "compound-button-needs-labelling": rules.compoundButtonNeedsLabelling, - "counter-badge-needs-count": rules.counterBadgeNeedsCount, - "dialogbody-needs-title-content-and-actions": rules.dialogbodyNeedsTitleContentAndActions, - "dialogsurface-needs-aria": rules.dialogsurfaceNeedsAria, - "dropdown-needs-labelling": rules.dropdownNeedsLabelling, - "field-needs-labelling": rules.fieldNeedsLabelling, - "image-button-missing-aria": rules.imageButtonMissingAria, - "input-components-require-accessible-name": rules.inputComponentsRequireAccessibleName, - "link-missing-labelling": rules.linkMissingLabelling, - "menu-item-needs-labelling": rules.menuItemNeedsLabelling, - "no-empty-buttons": rules.noEmptyButtons, - "no-empty-components": rules.noEmptyComponents, - "prefer-aria-over-title-attribute": rules.preferAriaOverTitleAttribute, - "progressbar-needs-labelling": rules.progressbarNeedsLabelling, - "radio-button-missing-label": rules.radioButtonMissingLabel, - "radiogroup-missing-label": rules.radiogroupMissingLabel, - "rating-needs-name": rules.ratingNeedsName, - "spin-button-needs-labelling": rules.spinButtonNeedsLabelling, - "spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling, - "spinner-needs-labelling": rules.spinnerNeedsLabelling, - "switch-needs-labelling": rules.switchNeedsLabelling, - "tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling, - "tag-dismissible-needs-labelling": rules.tagDismissibleNeedsLabelling, - "tag-needs-name": rules.tagNeedsName, - "toolbar-missing-aria": rules.toolbarMissingAria, - "tooltip-not-recommended": rules.tooltipNotRecommended, - "visual-label-better-than-aria-suggestion": rules.visualLabelBetterThanAriaSuggestion - }, configs: { recommended: { rules: { @@ -90,10 +53,47 @@ module.exports = { "@microsoft/fluentui-jsx-a11y/visual-label-better-than-aria-suggestion": "warn" } } + }, + rules: { + "accordion-header-needs-labelling": rules.accordionHeaderNeedsLabelling, + "accordion-item-needs-header-and-panel": rules.accordionItemNeedsHeaderAndPanel, + "avatar-needs-name": rules.avatarNeedsName, + "avoid-using-aria-describedby-for-primary-labelling": rules.avoidUsingAriaDescribedByForPrimaryLabelling, + "badge-needs-accessible-name": rules.badgeNeedsAccessibleName, + "breadcrumb-needs-labelling": rules.breadcrumbNeedsLabelling, + "checkbox-needs-labelling": rules.checkboxNeedsLabelling, + "combobox-needs-labelling": rules.comboboxNeedsLabelling, + "compound-button-needs-labelling": rules.compoundButtonNeedsLabelling, + "counter-badge-needs-count": rules.counterBadgeNeedsCount, + "dialogbody-needs-title-content-and-actions": rules.dialogbodyNeedsTitleContentAndActions, + "dialogsurface-needs-aria": rules.dialogsurfaceNeedsAria, + "dropdown-needs-labelling": rules.dropdownNeedsLabelling, + "field-needs-labelling": rules.fieldNeedsLabelling, + "image-button-missing-aria": rules.imageButtonMissingAria, + "input-components-require-accessible-name": rules.inputComponentsRequireAccessibleName, + "link-missing-labelling": rules.linkMissingLabelling, + "menu-item-needs-labelling": rules.menuItemNeedsLabelling, + "no-empty-buttons": rules.noEmptyButtons, + "no-empty-components": rules.noEmptyComponents, + "prefer-aria-over-title-attribute": rules.preferAriaOverTitleAttribute, + "progressbar-needs-labelling": rules.progressbarNeedsLabelling, + "radio-button-missing-label": rules.radioButtonMissingLabel, + "radiogroup-missing-label": rules.radiogroupMissingLabel, + "rating-needs-name": rules.ratingNeedsName, + "spin-button-needs-labelling": rules.spinButtonNeedsLabelling, + "spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling, + "spinner-needs-labelling": rules.spinnerNeedsLabelling, + "switch-needs-labelling": rules.switchNeedsLabelling, + "tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling, + "tag-dismissible-needs-labelling": rules.tagDismissibleNeedsLabelling, + "tag-needs-name": rules.tagNeedsName, + "toolbar-missing-aria": rules.toolbarMissingAria, + "tooltip-not-recommended": rules.tooltipNotRecommended, + "visual-label-better-than-aria-suggestion": rules.visualLabelBetterThanAriaSuggestion } }; // import processors module.exports.processors = { // add your processors here -}; \ No newline at end of file +}; diff --git a/lib/rules/tag-dismissible-needs-labelling.ts b/lib/rules/tag-dismissible-needs-labelling.ts index 762917c..a2027b2 100644 --- a/lib/rules/tag-dismissible-needs-labelling.ts +++ b/lib/rules/tag-dismissible-needs-labelling.ts @@ -3,6 +3,7 @@ import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; import { elementType, hasProp, getProp, getPropValue } from "jsx-ast-utils"; +import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; import { JSXOpeningElement, JSXAttribute } from "estree-jsx"; //------------------------------------------------------------------------------ @@ -10,20 +11,12 @@ import { JSXOpeningElement, JSXAttribute } from "estree-jsx"; //------------------------------------------------------------------------------ /** - * Checks if a value is a non-empty string (same logic as hasNonEmptyProp for strings) + * Checks if a value is a non-empty string */ const isNonEmptyString = (value: any): boolean => { return typeof value === "string" && value.trim().length > 0; }; -/** - * Checks if an object has a non-empty string property - */ -const hasNonEmptyObjectProperty = (obj: any, propertyName: string): boolean => { - if (!obj || typeof obj !== "object") return false; - return isNonEmptyString(obj[propertyName]); -}; - //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ @@ -33,14 +26,16 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ type: "problem", docs: { description: - "This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button", - recommended: false + "This rule aims to ensure that dismissible Tag components have proper accessibility labelling: either aria-label on dismissIcon or aria-label on Tag with role on dismissIcon", + recommended: "strict", + url: "https://react.fluentui.dev/?path=/docs/components-tag-tag--docs" }, fixable: undefined, schema: [], messages: { - missingDismissLabel: "Accessibility: Dismissible Tag must have dismissIcon with aria-label" - }, + missingDismissLabel: + "Accessibility: Dismissible Tag must have either aria-label on dismissIcon or aria-label on Tag with role on dismissIcon" + } }, create(context) { return { @@ -59,9 +54,8 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ return; } - // Check if dismissible Tag has dismissIcon with aria-label + // Check if dismissible Tag has proper accessibility labelling const dismissIconProp = getProp(openingElement.attributes as JSXAttribute[], "dismissIcon"); - if (!dismissIconProp) { context.report({ node, @@ -70,10 +64,23 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ return; } - // Get the dismissIcon value and check if it has valid aria-label const dismissIconValue = getPropValue(dismissIconProp); - - if (!hasNonEmptyObjectProperty(dismissIconValue, "aria-label")) { + + // Check if dismissIcon has aria-label + const dismissIconHasAriaLabel = + dismissIconValue && typeof dismissIconValue === "object" && isNonEmptyString((dismissIconValue as any)["aria-label"]); + + // Check if dismissIcon has role + const dismissIconHasRole = + dismissIconValue && typeof dismissIconValue === "object" && isNonEmptyString((dismissIconValue as any)["role"]); + + // Check if Tag has aria-label (required when dismissIcon has role) + const tagHasAriaLabel = hasNonEmptyProp(openingElement.attributes, "aria-label"); + // Valid patterns: + // Option 1: dismissIcon has aria-label + // Option 2: Tag has aria-label and dismissIcon has role + const hasValidLabelling = dismissIconHasAriaLabel || (tagHasAriaLabel && dismissIconHasRole); + if (!hasValidLabelling) { context.report({ node, messageId: `missingDismissLabel` diff --git a/tests/lib/rules/tag-dismissible-needs-labelling.test.ts b/tests/lib/rules/tag-dismissible-needs-labelling.test.ts index 71a4460..26fa276 100644 --- a/tests/lib/rules/tag-dismissible-needs-labelling.test.ts +++ b/tests/lib/rules/tag-dismissible-needs-labelling.test.ts @@ -17,26 +17,42 @@ ruleTester.run("tag-dismissible-needs-labelling", rule as unknown as Rule.RuleMo // Valid cases for dismissible Tag component // Non-dismissible tags should be ignored "Regular tag", - '}>Tag with icon', - - // Dismissible tags with proper labelling + "}>Tag with icon", + // Option 1: dismissIcon with aria-label 'Dismissible tag', - '}>Tag with icon' + '}>Tag with icon', + // Option 2: Tag with aria-label and dismissIcon with role + 'Dismissible tag' ], invalid: [ // Invalid cases for dismissible Tag component { - code: 'Dismissible tag', + code: "Dismissible tag", errors: [{ messageId: "missingDismissLabel" }] }, { - code: 'Dismissible tag', + code: "Dismissible tag", errors: [{ messageId: "missingDismissLabel" }] }, { code: 'Dismissible tag', errors: [{ messageId: "missingDismissLabel" }] + }, + // Missing aria-label on Tag when dismissIcon has role + { + code: 'Dismissible tag', + errors: [{ messageId: "missingDismissLabel" }] + }, + // Empty aria-label on Tag with dismissIcon role + { + code: 'Dismissible tag', + errors: [{ messageId: "missingDismissLabel" }] + }, + // Tag has aria-label but dismissIcon has empty role + { + code: 'Dismissible tag', + errors: [{ messageId: "missingDismissLabel" }] } ] }); diff --git a/tests/lib/rules/tag-needs-name.test.ts b/tests/lib/rules/tag-needs-name.test.ts index 605d357..1d3e56f 100644 --- a/tests/lib/rules/tag-needs-name.test.ts +++ b/tests/lib/rules/tag-needs-name.test.ts @@ -20,7 +20,7 @@ ruleTester.run("tag-needs-name", rule as unknown as Rule.RuleModule, { "Some text", '', 'Some text', - '}>Tag with icon and text', + "}>Tag with icon and text", '} aria-label="Settings tag">' ], @@ -35,15 +35,15 @@ ruleTester.run("tag-needs-name", rule as unknown as Rule.RuleModule, { errors: [{ messageId: "missingAriaLabel" }] }, { - code: '', + code: '', errors: [{ messageId: "missingAriaLabel" }] }, { - code: '}>', + code: "}>", errors: [{ messageId: "missingAriaLabel" }] }, { - code: '} />', + code: "} />", errors: [{ messageId: "missingAriaLabel" }] } ] From d4ca313f4be37005944154e236dfb168df73a801 Mon Sep 17 00:00:00 2001 From: Iryna Vasylenko Date: Thu, 18 Sep 2025 10:21:47 +0100 Subject: [PATCH 3/6] Fix build --- README.md | 74 +++++++++---------- docs/rules/tag-dismissible-needs-labelling.md | 2 +- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 1b6b712..0f56557 100644 --- a/README.md +++ b/README.md @@ -107,42 +107,42 @@ Any use of third-party trademarks or logos are subject to those third-party's po ✅ Set in the `recommended` configuration.\ 🔧 Automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/user-guide/command-line-interface#--fix). -| Name                                               | Description | 💼 | ⚠️ | 🔧 | -| :--------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :- | :- | :- | -| [accordion-header-needs-labelling](docs/rules/accordion-header-needs-labelling.md) | The accordion header is a button and it needs an accessibile name e.g. text content, aria-label, aria-labelledby. | ✅ | | | -| [accordion-item-needs-header-and-panel](docs/rules/accordion-item-needs-header-and-panel.md) | An AccordionItem needs exactly one header and one panel | ✅ | | | -| [avatar-needs-name](docs/rules/avatar-needs-name.md) | Accessibility: Avatar must have an accessible labelling: name, aria-label, aria-labelledby | ✅ | | | -| [avoid-using-aria-describedby-for-primary-labelling](docs/rules/avoid-using-aria-describedby-for-primary-labelling.md) | aria-describedby provides additional context and is not meant for primary labeling. | ✅ | | | -| [badge-needs-accessible-name](docs/rules/badge-needs-accessible-name.md) | | ✅ | | 🔧 | -| [breadcrumb-needs-labelling](docs/rules/breadcrumb-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | -| [checkbox-needs-labelling](docs/rules/checkbox-needs-labelling.md) | Accessibility: Checkbox without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [combobox-needs-labelling](docs/rules/combobox-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | -| [compound-button-needs-labelling](docs/rules/compound-button-needs-labelling.md) | Accessibility: Compound buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | -| [counter-badge-needs-count](docs/rules/counter-badge-needs-count.md) | | ✅ | | 🔧 | -| [dialogbody-needs-title-content-and-actions](docs/rules/dialogbody-needs-title-content-and-actions.md) | A DialogBody should have a header(DialogTitle), content(DialogContent), and footer(DialogActions) | ✅ | | | -| [dialogsurface-needs-aria](docs/rules/dialogsurface-needs-aria.md) | DialogueSurface need accessible labelling: aria-describedby on DialogueSurface and aria-label or aria-labelledby(if DialogueTitle is missing) | ✅ | | | -| [dropdown-needs-labelling](docs/rules/dropdown-needs-labelling.md) | Accessibility: Dropdown menu must have an id and it needs to be linked via htmlFor of a Label | ✅ | | | -| [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have label | ✅ | | | -| [image-button-missing-aria](docs/rules/image-button-missing-aria.md) | Accessibility: Image buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | -| [input-components-require-accessible-name](docs/rules/input-components-require-accessible-name.md) | Accessibility: Input fields must have accessible labelling: aria-label, aria-labelledby or an associated label | ✅ | | | -| [link-missing-labelling](docs/rules/link-missing-labelling.md) | Accessibility: Image links must have an accessible name. Add either text content, labelling to the image or labelling to the link itself. | ✅ | | 🔧 | -| [menu-item-needs-labelling](docs/rules/menu-item-needs-labelling.md) | Accessibility: MenuItem without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [no-empty-buttons](docs/rules/no-empty-buttons.md) | Accessibility: Button, ToggleButton, SplitButton, MenuButton, CompoundButton must either text content or icon or child component | ✅ | | | -| [no-empty-components](docs/rules/no-empty-components.md) | FluentUI components should not be empty | ✅ | | | -| [prefer-aria-over-title-attribute](docs/rules/prefer-aria-over-title-attribute.md) | The title attribute is not consistently read by screen readers, and its behavior can vary depending on the screen reader and the user's settings. | | ✅ | 🔧 | -| [progressbar-needs-labelling](docs/rules/progressbar-needs-labelling.md) | Accessibility: Progressbar must have aria-valuemin, aria-valuemax, aria-valuenow, aria-describedby and either aria-label or aria-labelledby attributes | ✅ | | | -| [radio-button-missing-label](docs/rules/radio-button-missing-label.md) | Accessibility: Radio button without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [radiogroup-missing-label](docs/rules/radiogroup-missing-label.md) | Accessibility: RadioGroup without label must have an accessible and visual label: aria-labelledby | ✅ | | | -| [rating-needs-name](docs/rules/rating-needs-name.md) | Accessibility: Ratings must have accessible labelling: name, aria-label, aria-labelledby or itemLabel which generates aria-label | ✅ | | | -| [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 | ✅ | | | -| [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. | ✅ | | | -| [tag-dismissible-needs-labelling](docs/rules/tag-dismissible-needs-labelling.md) | This rule aims to ensure that dismissible Tag components have proper accessibility labelling: either aria-label on dismissIcon or aria-label on Tag with role='presentation' on dismissIcon | ✅ | | | -| [tag-needs-name](docs/rules/tag-needs-name.md) | This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby. | ✅ | | | -| [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | | -| [tooltip-not-recommended](docs/rules/tooltip-not-recommended.md) | Accessibility: Prefer text content or aria over a tooltip for these components MenuItem, SpinButton | ✅ | | | -| [visual-label-better-than-aria-suggestion](docs/rules/visual-label-better-than-aria-suggestion.md) | Visual label is better than an aria-label because sighted users can't read the aria-label text. | | ✅ | | +| Name                                               | Description | 💼 | ⚠️ | 🔧 | +| :--------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :- | :- | :- | +| [accordion-header-needs-labelling](docs/rules/accordion-header-needs-labelling.md) | The accordion header is a button and it needs an accessibile name e.g. text content, aria-label, aria-labelledby. | ✅ | | | +| [accordion-item-needs-header-and-panel](docs/rules/accordion-item-needs-header-and-panel.md) | An AccordionItem needs exactly one header and one panel | ✅ | | | +| [avatar-needs-name](docs/rules/avatar-needs-name.md) | Accessibility: Avatar must have an accessible labelling: name, aria-label, aria-labelledby | ✅ | | | +| [avoid-using-aria-describedby-for-primary-labelling](docs/rules/avoid-using-aria-describedby-for-primary-labelling.md) | aria-describedby provides additional context and is not meant for primary labeling. | ✅ | | | +| [badge-needs-accessible-name](docs/rules/badge-needs-accessible-name.md) | | ✅ | | 🔧 | +| [breadcrumb-needs-labelling](docs/rules/breadcrumb-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | +| [checkbox-needs-labelling](docs/rules/checkbox-needs-labelling.md) | Accessibility: Checkbox without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [combobox-needs-labelling](docs/rules/combobox-needs-labelling.md) | All interactive elements must have an accessible name | ✅ | | | +| [compound-button-needs-labelling](docs/rules/compound-button-needs-labelling.md) | Accessibility: Compound buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | +| [counter-badge-needs-count](docs/rules/counter-badge-needs-count.md) | | ✅ | | 🔧 | +| [dialogbody-needs-title-content-and-actions](docs/rules/dialogbody-needs-title-content-and-actions.md) | A DialogBody should have a header(DialogTitle), content(DialogContent), and footer(DialogActions) | ✅ | | | +| [dialogsurface-needs-aria](docs/rules/dialogsurface-needs-aria.md) | DialogueSurface need accessible labelling: aria-describedby on DialogueSurface and aria-label or aria-labelledby(if DialogueTitle is missing) | ✅ | | | +| [dropdown-needs-labelling](docs/rules/dropdown-needs-labelling.md) | Accessibility: Dropdown menu must have an id and it needs to be linked via htmlFor of a Label | ✅ | | | +| [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have label | ✅ | | | +| [image-button-missing-aria](docs/rules/image-button-missing-aria.md) | Accessibility: Image buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | +| [input-components-require-accessible-name](docs/rules/input-components-require-accessible-name.md) | Accessibility: Input fields must have accessible labelling: aria-label, aria-labelledby or an associated label | ✅ | | | +| [link-missing-labelling](docs/rules/link-missing-labelling.md) | Accessibility: Image links must have an accessible name. Add either text content, labelling to the image or labelling to the link itself. | ✅ | | 🔧 | +| [menu-item-needs-labelling](docs/rules/menu-item-needs-labelling.md) | Accessibility: MenuItem without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [no-empty-buttons](docs/rules/no-empty-buttons.md) | Accessibility: Button, ToggleButton, SplitButton, MenuButton, CompoundButton must either text content or icon or child component | ✅ | | | +| [no-empty-components](docs/rules/no-empty-components.md) | FluentUI components should not be empty | ✅ | | | +| [prefer-aria-over-title-attribute](docs/rules/prefer-aria-over-title-attribute.md) | The title attribute is not consistently read by screen readers, and its behavior can vary depending on the screen reader and the user's settings. | | ✅ | 🔧 | +| [progressbar-needs-labelling](docs/rules/progressbar-needs-labelling.md) | Accessibility: Progressbar must have aria-valuemin, aria-valuemax, aria-valuenow, aria-describedby and either aria-label or aria-labelledby attributes | ✅ | | | +| [radio-button-missing-label](docs/rules/radio-button-missing-label.md) | Accessibility: Radio button without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [radiogroup-missing-label](docs/rules/radiogroup-missing-label.md) | Accessibility: RadioGroup without label must have an accessible and visual label: aria-labelledby | ✅ | | | +| [rating-needs-name](docs/rules/rating-needs-name.md) | Accessibility: Ratings must have accessible labelling: name, aria-label, aria-labelledby or itemLabel which generates aria-label | ✅ | | | +| [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 | ✅ | | | +| [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. | ✅ | | | +| [tag-dismissible-needs-labelling](docs/rules/tag-dismissible-needs-labelling.md) | This rule aims to ensure that dismissible Tag components have proper accessibility labelling: either aria-label on dismissIcon or aria-label on Tag with role on dismissIcon | ✅ | | | +| [tag-needs-name](docs/rules/tag-needs-name.md) | This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby. | ✅ | | | +| [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | | +| [tooltip-not-recommended](docs/rules/tooltip-not-recommended.md) | Accessibility: Prefer text content or aria over a tooltip for these components MenuItem, SpinButton | ✅ | | | +| [visual-label-better-than-aria-suggestion](docs/rules/visual-label-better-than-aria-suggestion.md) | Visual label is better than an aria-label because sighted users can't read the aria-label text. | | ✅ | | diff --git a/docs/rules/tag-dismissible-needs-labelling.md b/docs/rules/tag-dismissible-needs-labelling.md index c2da269..1875bcf 100644 --- a/docs/rules/tag-dismissible-needs-labelling.md +++ b/docs/rules/tag-dismissible-needs-labelling.md @@ -1,4 +1,4 @@ -# This rule aims to ensure that dismissible Tag components have proper accessibility labelling: either aria-label on dismissIcon or aria-label on Tag with role='presentation' on dismissIcon (`@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling`) +# This rule aims to ensure that dismissible Tag components have proper accessibility labelling: either aria-label on dismissIcon or aria-label on Tag with role on dismissIcon (`@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling`) 💼 This rule is enabled in the ✅ `recommended` config. From e970efd1d134f6f11cd247ad5c3fa5b7b0014a67 Mon Sep 17 00:00:00 2001 From: Iryna Vasylenko Date: Thu, 18 Sep 2025 15:33:39 +0100 Subject: [PATCH 4/6] Refactor Tag rules to use ruleFactory --- lib/rules/tag-needs-name.ts | 69 +++++++++----------------------- lib/util/hasTriggerProp.ts | 15 +++++++ lib/util/hasValidNestedProp.ts | 28 +++++++++++++ lib/util/ruleFactory.ts | 72 ++++++++++++++++++++++++++-------- 4 files changed, 117 insertions(+), 67 deletions(-) create mode 100644 lib/util/hasTriggerProp.ts create mode 100644 lib/util/hasValidNestedProp.ts diff --git a/lib/rules/tag-needs-name.ts b/lib/rules/tag-needs-name.ts index c02b98f..e20b99f 100644 --- a/lib/rules/tag-needs-name.ts +++ b/lib/rules/tag-needs-name.ts @@ -1,59 +1,26 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; -import { elementType } from "jsx-ast-utils"; -import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; -import { hasTextContentChild } from "../util/hasTextContentChild"; -import { hasAssociatedLabelViaAriaLabelledBy } from "../util/labelUtils"; -import { JSXOpeningElement } from "estree-jsx"; +import { ESLintUtils } from "@typescript-eslint/utils"; +import { makeLabeledControlRule } from "../util/ruleFactory"; //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ -const rule = ESLintUtils.RuleCreator.withoutDocs({ - defaultOptions: [], - meta: { - type: "problem", - docs: { - description: - "This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby.", - recommended: "strict", - url: "https://react.fluentui.dev/?path=/docs/components-tag-tag--docs" - }, - fixable: undefined, - schema: [], - messages: { - missingAriaLabel: "Accessibility: Tag must have an accessible name" - } - }, - create(context) { - return { - // visitor functions for different types of nodes - JSXElement(node: TSESTree.JSXElement) { - const openingElement = node.openingElement; - - // if it is not a Tag, return - if (elementType(openingElement as JSXOpeningElement) !== "Tag") { - return; - } - - // Check if tag has any accessible name - const hasTextContent = hasTextContentChild(node); - const hasAriaLabel = hasNonEmptyProp(openingElement.attributes, "aria-label"); - const hasAriaLabelledBy = hasAssociatedLabelViaAriaLabelledBy(openingElement, context); - const hasAccessibleName = hasTextContent || hasAriaLabel || hasAriaLabelledBy; - - if (!hasAccessibleName) { - context.report({ - node, - messageId: `missingAriaLabel` - }); - } - } - }; - } -}); - -export default rule; +export default ESLintUtils.RuleCreator.withoutDocs( + makeLabeledControlRule({ + component: "Tag", + messageId: "missingAriaLabel", + description: "Accessibility: Tag must have an accessible name", + labelProps: ["aria-label"], + allowFieldParent: false, + allowHtmlFor: false, + allowLabelledBy: true, + allowWrappingLabel: false, + allowTooltipParent: false, + allowDescribedBy: false, + allowLabeledChild: false, + allowTextContentChild: true + }) +); diff --git a/lib/util/hasTriggerProp.ts b/lib/util/hasTriggerProp.ts new file mode 100644 index 0000000..26c289a --- /dev/null +++ b/lib/util/hasTriggerProp.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TSESTree } from "@typescript-eslint/utils"; +import { hasProp } from "jsx-ast-utils"; +import { JSXAttribute } from "estree-jsx"; + +/** + * Checks if a component has a specific trigger prop. + * This is useful for rules that only apply when certain props are present + * (e.g., dismissible, expandable, collapsible, etc.) + */ +export const hasTriggerProp = (openingElement: TSESTree.JSXOpeningElement, triggerProp: string): boolean => { + return hasProp(openingElement.attributes as JSXAttribute[], triggerProp); +}; diff --git a/lib/util/hasValidNestedProp.ts b/lib/util/hasValidNestedProp.ts new file mode 100644 index 0000000..0eb0ea0 --- /dev/null +++ b/lib/util/hasValidNestedProp.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TSESTree } from "@typescript-eslint/utils"; +import { getProp, getPropValue } from "jsx-ast-utils"; +import { JSXAttribute } from "estree-jsx"; + +/** + * Checks if a value is a non-empty string + */ +const isNonEmptyString = (value: any): boolean => { + return typeof value === "string" && value.trim().length > 0; +}; + +/** + * Validates nested properties within a complex prop (object prop). + * This is useful for props like dismissIcon={{ "aria-label": "close", role: "button" }} + * where you need to check specific properties within the object. + */ +export const hasValidNestedProp = (openingElement: TSESTree.JSXOpeningElement, propName: string, nestedKey: string): boolean => { + const prop = getProp(openingElement.attributes as JSXAttribute[], propName); + if (!prop) { + return false; + } + + const propValue = getPropValue(prop); + return Boolean(propValue && typeof propValue === "object" && isNonEmptyString((propValue as any)[nestedKey])); +}; diff --git a/lib/util/ruleFactory.ts b/lib/util/ruleFactory.ts index 3b70bcc..feee34c 100644 --- a/lib/util/ruleFactory.ts +++ b/lib/util/ruleFactory.ts @@ -14,26 +14,31 @@ import { elementType } from "jsx-ast-utils"; import { JSXOpeningElement } from "estree-jsx"; import { hasToolTipParent } from "./hasTooltipParent"; import { hasLabeledChild } from "./hasLabeledChild"; +import { hasTextContentChild } from "./hasTextContentChild"; +import { hasTriggerProp } from "./hasTriggerProp"; export type LabeledControlConfig = { component: string | RegExp; messageId: string; description: string; labelProps: string[]; // e.g. ["aria-label", "title", "label"] - /** Accept a parent wrapper as providing the label. */ - allowFieldParent: boolean; // default false - allowHtmlFor: boolean /** Accept