diff --git a/README.md b/README.md index 2eda206..eead4c4 100644 --- a/README.md +++ b/README.md @@ -170,46 +170,47 @@ 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 | ✅ | | | -| [colorswatch-needs-labelling](docs/rules/colorswatch-needs-labelling.md) | Accessibility: ColorSwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | | -| [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 | ✅ | | | -| [emptyswatch-needs-labelling](docs/rules/emptyswatch-needs-labelling.md) | Accessibility: EmptySwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | | -| [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 | ✅ | | | -| [image-needs-alt](docs/rules/image-needs-alt.md) | Accessibility: Image must have alt attribute with a meaningful description of the image. If the image is decorative, use alt="". | ✅ | | | -| [imageswatch-needs-labelling](docs/rules/imageswatch-needs-labelling.md) | Accessibility: ImageSwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | | -| [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. | | ✅ | 🔧 | -| [prefer-disabledfocusable-over-disabled](docs/rules/prefer-disabledfocusable-over-disabled.md) | Prefer 'disabledFocusable' over 'disabled' when component has loading state to maintain keyboard navigation accessibility | | ✅ | 🔧 | -| [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 | ✅ | | | -| [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 | ✅ | | | -| [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 | ✅ | | | +| [colorswatch-needs-labelling](docs/rules/colorswatch-needs-labelling.md) | Accessibility: ColorSwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | | +| [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 | ✅ | | | +| [emptyswatch-needs-labelling](docs/rules/emptyswatch-needs-labelling.md) | Accessibility: EmptySwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | | +| [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 | ✅ | | | +| [image-needs-alt](docs/rules/image-needs-alt.md) | Accessibility: Image must have alt attribute with a meaningful description of the image. If the image is decorative, use alt="". | ✅ | | | +| [imageswatch-needs-labelling](docs/rules/imageswatch-needs-labelling.md) | Accessibility: ImageSwatch must have an accessible name via aria-label, Tooltip, aria-labelledby, etc.. | ✅ | | | +| [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. | | ✅ | 🔧 | +| [prefer-disabledfocusable-over-disabled](docs/rules/prefer-disabledfocusable-over-disabled.md) | Prefer 'disabledFocusable' over 'disabled' when component has loading state to maintain keyboard navigation accessibility | | ✅ | 🔧 | +| [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) | Accessibility: Tag must have an accessible name | ✅ | | | +| [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. | | ✅ | | + + \ No newline at end of file diff --git a/docs/rules/tag-dismissible-needs-labelling.md b/docs/rules/tag-dismissible-needs-labelling.md new file mode 100644 index 0000000..1875bcf --- /dev/null +++ b/docs/rules/tag-dismissible-needs-labelling.md @@ -0,0 +1,49 @@ +# 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. + + + +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..c88112d --- /dev/null +++ b/docs/rules/tag-needs-name.md @@ -0,0 +1,61 @@ +# Accessibility: Tag must have an accessible name (`@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 1c063e5..9de9152 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -49,9 +49,10 @@ 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" @@ -92,9 +93,10 @@ 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, + "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 diff --git a/lib/rules/index.ts b/lib/rules/index.ts index c31ac56..219d9bd 100644 --- a/lib/rules/index.ts +++ b/lib/rules/index.ts @@ -36,6 +36,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-labelling"; 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..a2027b2 --- /dev/null +++ b/lib/rules/tag-dismissible-needs-labelling.ts @@ -0,0 +1,94 @@ +// 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 { hasNonEmptyProp } from "../util/hasNonEmptyProp"; +import { JSXOpeningElement, JSXAttribute } from "estree-jsx"; + +//------------------------------------------------------------------------------ +// Utility Functions +//------------------------------------------------------------------------------ + +/** + * Checks if a value is a non-empty string + */ +const isNonEmptyString = (value: any): boolean => { + return typeof value === "string" && value.trim().length > 0; +}; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ +const rule = ESLintUtils.RuleCreator.withoutDocs({ + defaultOptions: [], + meta: { + type: "problem", + docs: { + description: + "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 either aria-label on dismissIcon or aria-label on Tag with role on dismissIcon" + } + }, + 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 proper accessibility labelling + const dismissIconProp = getProp(openingElement.attributes as JSXAttribute[], "dismissIcon"); + if (!dismissIconProp) { + context.report({ + node, + messageId: `missingDismissLabel` + }); + return; + } + + const dismissIconValue = getPropValue(dismissIconProp); + + // 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` + }); + } + } + }; + } +}); + +export default rule; diff --git a/lib/rules/tag-needs-labelling.ts b/lib/rules/tag-needs-labelling.ts new file mode 100644 index 0000000..e20b99f --- /dev/null +++ b/lib/rules/tag-needs-labelling.ts @@ -0,0 +1,26 @@ +// 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: "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..9c99d8a --- /dev/null +++ b/lib/util/hasValidNestedProp.ts @@ -0,0 +1,26 @@ +// 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 if a component has a specific nested property with a non-empty string value. + */ +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 7e285f7..b245044 100644 --- a/lib/util/ruleFactory.ts +++ b/lib/util/ruleFactory.ts @@ -16,6 +16,7 @@ import { hasToolTipParent } from "./hasTooltipParent"; import { hasLabeledChild } from "./hasLabeledChild"; import { hasDefinedProp } from "./hasDefinedProp"; import { hasTextContentChild } from "./hasTextContentChild"; +import { hasTriggerProp } from "./hasTriggerProp"; /** * Configuration options for a rule created via the `ruleFactory` @@ -47,10 +48,10 @@ export type LabeledControlConfig = { * Keep this off unless a specific component (e.g., Icon-only buttons) intentionally uses it. */ allowDescribedBy: boolean; - /** Treat labeled child content (img `alt`, svg `title`, `aria-label` on `role="img"`) as the name */ - allowLabeledChild: boolean; - /** Accept text children to provide the label e.g. */ - allowTextContentChild?: boolean; + allowLabeledChild: boolean; // Accept labeled child elements to provide the label e.g. + allowTextContentChild?: boolean; // Accept text children to provide the label e.g. + triggerProp?: string; // Only apply rule when this trigger prop is present (e.g., "dismissible", "disabled") + customValidator?: Function; // Custom validation logic }; /** @@ -66,6 +67,8 @@ export type LabeledControlConfig = { * 6) Parent context .......................... (allowTooltipParent) * 7) aria-describedby association (opt-in; discouraged as primary) .... (allowDescribedBy) * 8) treat labeled child content (img alt, svg title, aria-label on role="img") as the name + * 9) Conditional application based on trigger prop ................... (triggerProp) + * 10) Custom validation for complex scenarios ......................... (customValidator) * * This checks for presence of an accessible *name* only; not contrast or UX. */ @@ -128,7 +131,17 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R if (!matches) return; - if (!hasAccessibleLabel(opening, node, context, config)) { + if (config.triggerProp && !hasTriggerProp(opening, config.triggerProp)) { + return; + } + + // Use custom validator if provided, otherwise use standard accessibility check + let isValid: boolean; + config.customValidator + ? (isValid = config.customValidator(opening)) + : (isValid = hasAccessibleLabel(opening, node, context, config)); + + if (!isValid) { // report on the opening tag for better location context.report({ node: opening, messageId: config.messageId }); } 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..26fa276 --- /dev/null +++ b/tests/lib/rules/tag-dismissible-needs-labelling.test.ts @@ -0,0 +1,58 @@ +// 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", + // Option 1: dismissIcon with aria-label + 'Dismissible tag', + '}>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", + errors: [{ messageId: "missingDismissLabel" }] + }, + { + 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-labelling.test.ts b/tests/lib/rules/tag-needs-labelling.test.ts new file mode 100644 index 0000000..d254993 --- /dev/null +++ b/tests/lib/rules/tag-needs-labelling.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-labelling"; + +//------------------------------------------------------------------------------ +// 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" }] + } + ] +}); diff --git a/tests/lib/rules/utils/hasValidNestedProp.test.ts b/tests/lib/rules/utils/hasValidNestedProp.test.ts new file mode 100644 index 0000000..2e37be7 --- /dev/null +++ b/tests/lib/rules/utils/hasValidNestedProp.test.ts @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +jest.mock("jsx-ast-utils", () => ({ + getProp: jest.fn(), + getPropValue: jest.fn() +})); + +import { getProp, getPropValue } from "jsx-ast-utils"; +import { hasValidNestedProp } from "../../../../lib/util/hasValidNestedProp"; + +describe("hasValidNestedProp", () => { + const opening = { attributes: [] } as any; + + beforeEach(() => { + (getProp as jest.Mock).mockReset(); + (getPropValue as jest.Mock).mockReset(); + }); + + test("returns false when the prop is not present (e.g. )", () => { + // Example: + (getProp as jest.Mock).mockReturnValue(undefined); + const result = hasValidNestedProp(opening, "dismissIcon", "aria-label"); + expect(result).toBe(false); + expect(getProp).toHaveBeenCalledWith(opening.attributes, "dismissIcon"); + }); + + test("returns false when nested key is missing or empty string", () => { + // Example: + (getProp as jest.Mock).mockReturnValue({}); + (getPropValue as jest.Mock).mockReturnValue({}); + expect(hasValidNestedProp(opening, "dismissIcon", "aria-label")).toBe(false); + + // Example: + (getPropValue as jest.Mock).mockReturnValue({ "aria-label": " " }); + expect(hasValidNestedProp(opening, "dismissIcon", "aria-label")).toBe(false); + }); + + test("returns true when nested key is a non-empty string (e.g. )", () => { + // Example: + (getProp as jest.Mock).mockReturnValue({}); + (getPropValue as jest.Mock).mockReturnValue({ "aria-label": "Dismiss" }); + expect(hasValidNestedProp(opening, "dismissIcon", "aria-label")).toBe(true); + }); +}); diff --git a/tests/lib/rules/utils/ruleFactory.test.ts b/tests/lib/rules/utils/ruleFactory.test.ts index f26d2bf..178e18a 100644 --- a/tests/lib/rules/utils/ruleFactory.test.ts +++ b/tests/lib/rules/utils/ruleFactory.test.ts @@ -107,7 +107,7 @@ describe("hasAccessibleLabel (unit)", () => { getSourceCode: jest.fn() } as unknown as TSESLint.RuleContext; - const cfg: Required = { + const cfg: LabeledControlConfig = { component: "RadioGroup", requiredProps: ["alt"], labelProps: ["label", "aria-label"], @@ -292,7 +292,7 @@ describe("hasAccessibleLabel (unit)", () => { const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6, ecmaFeatures: { jsx: true } } }); describe("makeLabeledControlRule (RuleTester integration)", () => { - const baseCfg: Required = { + const baseCfg: LabeledControlConfig = { component: "RadioGroup", requiredProps: ["alt"], labelProps: ["label", "aria-label"],