Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
73 changes: 37 additions & 36 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 @@
# 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.

<!-- 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 @@ -44,9 +44,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 @@ -82,9 +83,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 @@ -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";
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;
59 changes: 59 additions & 0 deletions lib/rules/tag-needs-name.ts
Original file line number Diff line number Diff line change
@@ -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;
58 changes: 58 additions & 0 deletions tests/lib/rules/tag-dismissible-needs-labelling.test.ts
Original file line number Diff line number Diff line change
@@ -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
"<Tag>Regular tag</Tag>",
"<Tag icon={<SettingsIcon />}>Tag with icon</Tag>",
// Option 1: dismissIcon with aria-label
'<Tag dismissible dismissIcon={{ "aria-label": "remove" }}>Dismissible tag</Tag>',
'<Tag dismissible dismissIcon={{ "aria-label": "close" }} icon={<CalendarMonthRegular />}>Tag with icon</Tag>',
// Option 2: Tag with aria-label and dismissIcon with role
'<Tag dismissible aria-label="Dismissible tag" dismissIcon={{ role: "presentation" }}>Dismissible tag</Tag>'
],

invalid: [
// Invalid cases for dismissible Tag component
{
code: "<Tag dismissible>Dismissible tag</Tag>",
errors: [{ messageId: "missingDismissLabel" }]
},
{
code: "<Tag dismissible dismissIcon={{}}>Dismissible tag</Tag>",
errors: [{ messageId: "missingDismissLabel" }]
},
{
code: '<Tag dismissible dismissIcon={{ "aria-label": "" }}>Dismissible tag</Tag>',
errors: [{ messageId: "missingDismissLabel" }]
},
// Missing aria-label on Tag when dismissIcon has role
{
code: '<Tag dismissible dismissIcon={{ role: "presentation" }}>Dismissible tag</Tag>',
errors: [{ messageId: "missingDismissLabel" }]
},
// Empty aria-label on Tag with dismissIcon role
{
code: '<Tag dismissible aria-label="" dismissIcon={{ role: "presentation" }}>Dismissible tag</Tag>',
errors: [{ messageId: "missingDismissLabel" }]
},
// Tag has aria-label but dismissIcon has empty role
{
code: '<Tag dismissible aria-label="Dismissible tag" dismissIcon={{ role: "" }}>Dismissible tag</Tag>',
errors: [{ messageId: "missingDismissLabel" }]
}
]
});
Loading