Skip to content

Commit 9f99841

Browse files
author
Iryna Vasylenko
committed
Add eslint rule for the Tag component
1 parent 5fb9d2b commit 9f99841

File tree

10 files changed

+358
-2
lines changed

10 files changed

+358
-2
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ Any use of third-party trademarks or logos are subject to those third-party's po
139139
| [spinner-needs-labelling](docs/rules/spinner-needs-labelling.md) | Accessibility: Spinner must have either aria-label or label, aria-live and aria-busy attributes | ✅ | | |
140140
| [switch-needs-labelling](docs/rules/switch-needs-labelling.md) | Accessibility: Switch must have an accessible label | ✅ | | |
141141
| [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. | ✅ | | |
142+
| [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 | ✅ | | |
143+
| [tag-needs-name](docs/rules/tag-needs-name.md) | This rule aims to ensure that Tag component must have an accessible name via text content, aria-label, or aria-labelledby. | ✅ | | |
142144
| [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | |
143145
| [tooltip-not-recommended](docs/rules/tooltip-not-recommended.md) | Accessibility: Prefer text content or aria over a tooltip for these components MenuItem, SpinButton | ✅ | | |
144146
| [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. | | ✅ | |
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Accessibility: Dismissible Tag components must have an aria-label on the dismiss button (`@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling`)
2+
3+
💼 This rule is enabled in the ✅ `recommended` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
All interactive elements must have an accessible name.
8+
9+
Dismissible Tag components render a dismiss/close button that must have an accessible name for screen reader users.
10+
11+
When a Tag has the `dismissible` prop, it must provide a `dismissIcon` with an `aria-label`.
12+
13+
<https://react.fluentui.dev/?path=/docs/components-tag-tag--docs>
14+
15+
## Rule Details
16+
17+
This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button.
18+
19+
Examples of **incorrect** code for this rule:
20+
21+
```jsx
22+
<Tag dismissible>Dismissible tag</Tag>
23+
```
24+
25+
```jsx
26+
<Tag dismissible dismissIcon={{}}>Dismissible tag</Tag>
27+
```
28+
29+
```jsx
30+
<Tag dismissible dismissIcon={{ "aria-label": "" }}>Dismissible tag</Tag>
31+
```
32+
33+
Examples of **correct** code for this rule:
34+
35+
```jsx
36+
<Tag>Regular tag</Tag>
37+
```
38+
39+
```jsx
40+
<Tag icon={<SettingsIcon />}>Tag with icon</Tag>
41+
```
42+
43+
```jsx
44+
<Tag dismissible dismissIcon={{ "aria-label": "remove" }}>Dismissible tag</Tag>
45+
```
46+
47+
```jsx
48+
<Tag dismissible dismissIcon={{ "aria-label": "close" }} icon={<CalendarMonthRegular />}>Tag with icon</Tag>
49+
```

docs/rules/tag-needs-name.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Accessibility: Tag must have an accessible name (`@microsoft/fluentui-jsx-a11y/tag-needs-name`)
2+
3+
💼 This rule is enabled in the ✅ `recommended` config.
4+
5+
<!-- end auto-generated rule header -->
6+
7+
All interactive elements must have an accessible name.
8+
9+
Tag components need an accessible name for screen reader users.
10+
11+
Please provide text content, aria-label, or aria-labelledby.
12+
13+
<https://react.fluentui.dev/?path=/docs/components-tag-tag--docs>
14+
15+
## Rule Details
16+
17+
This rule aims to ensure that Tag components have an accessible name via text content, aria-label, or aria-labelledby.
18+
19+
Examples of **incorrect** code for this rule:
20+
21+
```jsx
22+
<Tag></Tag>
23+
```
24+
25+
```jsx
26+
<Tag />
27+
```
28+
29+
```jsx
30+
<Tag aria-label=""></Tag>
31+
```
32+
33+
```jsx
34+
<Tag icon={<SettingsIcon />}></Tag>
35+
```
36+
37+
```jsx
38+
<Tag icon={<SettingsIcon />} />
39+
```
40+
41+
Examples of **correct** code for this rule:
42+
43+
```jsx
44+
<Tag>Tag with some text</Tag>
45+
```
46+
47+
```jsx
48+
<Tag aria-label="Accessible tag name"></Tag>
49+
```
50+
51+
```jsx
52+
<Tag aria-label="Tag label">Some text</Tag>
53+
```
54+
55+
```jsx
56+
<Tag icon={<SettingsIcon />}>Tag with icon and text</Tag>
57+
```
58+
59+
```jsx
60+
<Tag icon={<SettingsIcon />} aria-label="Settings tag"></Tag>
61+
```

lib/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ module.exports = {
4444
"spinner-needs-labelling": rules.spinnerNeedsLabelling,
4545
"switch-needs-labelling": rules.switchNeedsLabelling,
4646
"tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling,
47+
"tag-dismissible-needs-labelling": rules.tagDismissibleNeedsLabelling,
48+
"tag-needs-name": rules.tagNeedsName,
4749
"toolbar-missing-aria": rules.toolbarMissingAria,
4850
"tooltip-not-recommended": rules.tooltipNotRecommended,
4951
"visual-label-better-than-aria-suggestion": rules.visualLabelBetterThanAriaSuggestion
@@ -81,6 +83,8 @@ module.exports = {
8183
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error",
8284
"@microsoft/fluentui-jsx-a11y/switch-needs-labelling": "error",
8385
"@microsoft/fluentui-jsx-a11y/tablist-and-tabs-need-labelling": "error",
86+
"@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling": "error",
87+
"@microsoft/fluentui-jsx-a11y/tag-needs-name": "error",
8488
"@microsoft/fluentui-jsx-a11y/toolbar-missing-aria": "error",
8589
"@microsoft/fluentui-jsx-a11y/tooltip-not-recommended": "error",
8690
"@microsoft/fluentui-jsx-a11y/visual-label-better-than-aria-suggestion": "warn"

lib/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export { default as spinButtonUnrecommendedLabelling } from "./spin-button-unrec
2828
export { default as spinnerNeedsLabelling } from "./spinner-needs-labelling";
2929
export { default as switchNeedsLabelling } from "./switch-needs-labelling";
3030
export { default as tablistAndTabsNeedLabelling } from "./tablist-and-tabs-need-labelling";
31+
export { default as tagDismissibleNeedsLabelling } from "./tag-dismissible-needs-labelling";
32+
export { default as tagNeedsName } from "./tag-needs-name";
3133
export { default as toolbarMissingAria } from "./toolbar-missing-aria";
3234
export { default as tooltipNotRecommended } from "./tooltip-not-recommended";
3335
export { default as visualLabelBetterThanAriaSuggestion } from "./visual-label-better-than-aria-suggestion";
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
5+
import { elementType, hasProp, getProp, getPropValue } from "jsx-ast-utils";
6+
import { JSXOpeningElement, JSXAttribute } from "estree-jsx";
7+
8+
//------------------------------------------------------------------------------
9+
// Utility Functions
10+
//------------------------------------------------------------------------------
11+
12+
/**
13+
* Checks if a value is a non-empty string (same logic as hasNonEmptyProp for strings)
14+
*/
15+
const isNonEmptyString = (value: any): boolean => {
16+
return typeof value === "string" && value.trim().length > 0;
17+
};
18+
19+
/**
20+
* Checks if an object has a non-empty string property
21+
*/
22+
const hasNonEmptyObjectProperty = (obj: any, propertyName: string): boolean => {
23+
if (!obj || typeof obj !== "object") return false;
24+
return isNonEmptyString(obj[propertyName]);
25+
};
26+
27+
//------------------------------------------------------------------------------
28+
// Rule Definition
29+
//------------------------------------------------------------------------------
30+
const rule = ESLintUtils.RuleCreator.withoutDocs({
31+
defaultOptions: [],
32+
meta: {
33+
type: "problem",
34+
docs: {
35+
description:
36+
"This rule aims to ensure that dismissible Tag components have an aria-label on the dismiss button",
37+
recommended: false
38+
},
39+
fixable: undefined,
40+
schema: [],
41+
messages: {
42+
missingDismissLabel: "Accessibility: Dismissible Tag must have dismissIcon with aria-label"
43+
},
44+
},
45+
create(context) {
46+
return {
47+
// visitor functions for different types of nodes
48+
JSXElement(node: TSESTree.JSXElement) {
49+
const openingElement = node.openingElement;
50+
51+
// if it is not a Tag, return
52+
if (elementType(openingElement as JSXOpeningElement) !== "Tag") {
53+
return;
54+
}
55+
56+
// Check if Tag has dismissible prop
57+
const isDismissible = hasProp(openingElement.attributes as JSXAttribute[], "dismissible");
58+
if (!isDismissible) {
59+
return;
60+
}
61+
62+
// Check if dismissible Tag has dismissIcon with aria-label
63+
const dismissIconProp = getProp(openingElement.attributes as JSXAttribute[], "dismissIcon");
64+
65+
if (!dismissIconProp) {
66+
context.report({
67+
node,
68+
messageId: `missingDismissLabel`
69+
});
70+
return;
71+
}
72+
73+
// Get the dismissIcon value and check if it has valid aria-label
74+
const dismissIconValue = getPropValue(dismissIconProp);
75+
76+
if (!hasNonEmptyObjectProperty(dismissIconValue, "aria-label")) {
77+
context.report({
78+
node,
79+
messageId: `missingDismissLabel`
80+
});
81+
}
82+
}
83+
};
84+
}
85+
});
86+
87+
export default rule;

lib/rules/tag-needs-name.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
5+
import { elementType } from "jsx-ast-utils";
6+
import { hasNonEmptyProp } from "../util/hasNonEmptyProp";
7+
import { hasTextContentChild } from "../util/hasTextContentChild";
8+
import { hasAssociatedLabelViaAriaLabelledBy } from "../util/labelUtils";
9+
import { JSXOpeningElement } from "estree-jsx";
10+
11+
//------------------------------------------------------------------------------
12+
// Rule Definition
13+
//------------------------------------------------------------------------------
14+
15+
const rule = ESLintUtils.RuleCreator.withoutDocs({
16+
defaultOptions: [],
17+
meta: {
18+
type: "problem",
19+
docs: {
20+
description:
21+
"This rule aims to ensure that Tag component have an accessible name via text content, aria-label, or aria-labelledby.",
22+
recommended: "strict",
23+
url: "https://react.fluentui.dev/?path=/docs/components-tag-tag--docs"
24+
},
25+
fixable: undefined,
26+
schema: [],
27+
messages: {
28+
missingAriaLabel: "Accessibility: Tag must have an accessible name"
29+
}
30+
},
31+
create(context) {
32+
return {
33+
// visitor functions for different types of nodes
34+
JSXElement(node: TSESTree.JSXElement) {
35+
const openingElement = node.openingElement;
36+
37+
// if it is not a Tag, return
38+
if (elementType(openingElement as JSXOpeningElement) !== "Tag") {
39+
return;
40+
}
41+
42+
// Check if tag has any accessible name
43+
const hasTextContent = hasTextContentChild(node);
44+
const hasAriaLabel = hasNonEmptyProp(openingElement.attributes, "aria-label");
45+
const hasAriaLabelledBy = hasAssociatedLabelViaAriaLabelledBy(openingElement, context);
46+
const hasAccessibleName = hasTextContent || hasAriaLabel || hasAriaLabelledBy;
47+
48+
if (!hasAccessibleName) {
49+
context.report({
50+
node,
51+
messageId: `missingAriaLabel`
52+
});
53+
}
54+
}
55+
};
56+
}
57+
});
58+
59+
export default rule;

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { Rule } from "eslint";
5+
import ruleTester from "./helper/ruleTester";
6+
import rule from "../../../lib/rules/tag-dismissible-needs-labelling";
7+
8+
//------------------------------------------------------------------------------
9+
// Requirements
10+
//------------------------------------------------------------------------------
11+
12+
//------------------------------------------------------------------------------
13+
// Tests
14+
//------------------------------------------------------------------------------
15+
ruleTester.run("tag-dismissible-needs-labelling", rule as unknown as Rule.RuleModule, {
16+
valid: [
17+
// Valid cases for dismissible Tag component
18+
// Non-dismissible tags should be ignored
19+
"<Tag>Regular tag</Tag>",
20+
'<Tag icon={<SettingsIcon />}>Tag with icon</Tag>',
21+
22+
// Dismissible tags with proper labelling
23+
'<Tag dismissible dismissIcon={{ "aria-label": "remove" }}>Dismissible tag</Tag>',
24+
'<Tag dismissible dismissIcon={{ "aria-label": "close" }} icon={<CalendarMonthRegular />}>Tag with icon</Tag>'
25+
],
26+
27+
invalid: [
28+
// Invalid cases for dismissible Tag component
29+
{
30+
code: '<Tag dismissible>Dismissible tag</Tag>',
31+
errors: [{ messageId: "missingDismissLabel" }]
32+
},
33+
{
34+
code: '<Tag dismissible dismissIcon={{}}>Dismissible tag</Tag>',
35+
errors: [{ messageId: "missingDismissLabel" }]
36+
},
37+
{
38+
code: '<Tag dismissible dismissIcon={{ "aria-label": "" }}>Dismissible tag</Tag>',
39+
errors: [{ messageId: "missingDismissLabel" }]
40+
}
41+
]
42+
});

0 commit comments

Comments
 (0)