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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 44 additions & 43 deletions README.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions docs/rules/tag-dismissible-needs-labelling.md
Original file line number Diff line number Diff line change
@@ -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.

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

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`.

<https://react.fluentui.dev/?path=/docs/components-tag-tag--docs>

## 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
<Tag dismissible>Dismissible tag</Tag>
```

```jsx
<Tag dismissible dismissIcon={{}}>Dismissible tag</Tag>
```

```jsx
<Tag dismissible dismissIcon={{ "aria-label": "" }}>Dismissible tag</Tag>
```

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

```jsx
<Tag>Regular tag</Tag>
```

```jsx
<Tag icon={<SettingsIcon />}>Tag with icon</Tag>
```

```jsx
<Tag dismissible dismissIcon={{ "aria-label": "remove" }}>Dismissible tag</Tag>
```

```jsx
<Tag dismissible dismissIcon={{ "aria-label": "close" }} icon={<CalendarMonthRegular />}>Tag with icon</Tag>
```
61 changes: 61 additions & 0 deletions docs/rules/tag-needs-name.md
Original file line number Diff line number Diff line change
@@ -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.

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

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.

<https://react.fluentui.dev/?path=/docs/components-tag-tag--docs>

## 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
<Tag></Tag>
```

```jsx
<Tag />
```

```jsx
<Tag aria-label=""></Tag>
```

```jsx
<Tag icon={<SettingsIcon />}></Tag>
```

```jsx
<Tag icon={<SettingsIcon />} />
```

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

```jsx
<Tag>Tag with some text</Tag>
```

```jsx
<Tag aria-label="Accessible tag name"></Tag>
```

```jsx
<Tag aria-label="Tag label">Some text</Tag>
```

```jsx
<Tag icon={<SettingsIcon />}>Tag with icon and text</Tag>
```

```jsx
<Tag icon={<SettingsIcon />} aria-label="Settings tag"></Tag>
```
6 changes: 4 additions & 2 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
94 changes: 94 additions & 0 deletions lib/rules/tag-dismissible-needs-labelling.ts
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 26 additions & 0 deletions lib/rules/tag-needs-labelling.ts
Original file line number Diff line number Diff line change
@@ -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
})
);
15 changes: 15 additions & 0 deletions lib/util/hasTriggerProp.ts
Original file line number Diff line number Diff line change
@@ -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);
};
26 changes: 26 additions & 0 deletions lib/util/hasValidNestedProp.ts
Original file line number Diff line number Diff line change
@@ -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]));
};
23 changes: 18 additions & 5 deletions lib/util/ruleFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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. <Button>Click me</Button> */
allowTextContentChild?: boolean;
allowLabeledChild: boolean; // Accept labeled child elements to provide the label e.g. <Button><img alt="..." /></Button>
allowTextContentChild?: boolean; // Accept text children to provide the label e.g. <Button>Click me</Button>
triggerProp?: string; // Only apply rule when this trigger prop is present (e.g., "dismissible", "disabled")
customValidator?: Function; // Custom validation logic
};

/**
Expand All @@ -66,6 +67,8 @@ export type LabeledControlConfig = {
* 6) Parent <Tooltip content="..."> 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.
*/
Expand Down Expand Up @@ -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 });
}
Expand Down
Loading
Loading