Skip to content

Commit 2f2d469

Browse files
authored
Merge pull request #156 from iryna-vas/users/ivasylenko/tags
Eslint rules for the Tag component
2 parents f754743 + 73ecc5a commit 2f2d469

14 files changed

+494
-52
lines changed

README.md

Lines changed: 44 additions & 43 deletions
Large diffs are not rendered by default.
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# 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`)
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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,10 @@ module.exports = {
4949
"@microsoft/fluentui-jsx-a11y/spin-button-needs-labelling": "error",
5050
"@microsoft/fluentui-jsx-a11y/spin-button-unrecommended-labelling": "error",
5151
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error",
52-
"@microsoft/fluentui-jsx-a11y/swatchpicker-needs-labelling": "error",
5352
"@microsoft/fluentui-jsx-a11y/switch-needs-labelling": "error",
5453
"@microsoft/fluentui-jsx-a11y/tablist-and-tabs-need-labelling": "error",
54+
"@microsoft/fluentui-jsx-a11y/tag-dismissible-needs-labelling": "error",
55+
"@microsoft/fluentui-jsx-a11y/tag-needs-name": "error",
5556
"@microsoft/fluentui-jsx-a11y/toolbar-missing-aria": "error",
5657
"@microsoft/fluentui-jsx-a11y/tooltip-not-recommended": "error",
5758
"@microsoft/fluentui-jsx-a11y/visual-label-better-than-aria-suggestion": "warn"
@@ -92,9 +93,10 @@ module.exports = {
9293
"spin-button-needs-labelling": rules.spinButtonNeedsLabelling,
9394
"spin-button-unrecommended-labelling": rules.spinButtonUnrecommendedLabelling,
9495
"spinner-needs-labelling": rules.spinnerNeedsLabelling,
95-
"swatchpicker-needs-labelling": rules.swatchpickerNeedsLabelling,
9696
"switch-needs-labelling": rules.switchNeedsLabelling,
9797
"tablist-and-tabs-need-labelling": rules.tablistAndTabsNeedLabelling,
98+
"tag-dismissible-needs-labelling": rules.tagDismissibleNeedsLabelling,
99+
"tag-needs-name": rules.tagNeedsName,
98100
"toolbar-missing-aria": rules.toolbarMissingAria,
99101
"tooltip-not-recommended": rules.tooltipNotRecommended,
100102
"visual-label-better-than-aria-suggestion": rules.visualLabelBetterThanAriaSuggestion

lib/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export { default as spinnerNeedsLabelling } from "./spinner-needs-labelling";
3636
export { default as swatchpickerNeedsLabelling } from "./swatchpicker-needs-labelling";
3737
export { default as switchNeedsLabelling } from "./switch-needs-labelling";
3838
export { default as tablistAndTabsNeedLabelling } from "./tablist-and-tabs-need-labelling";
39+
export { default as tagDismissibleNeedsLabelling } from "./tag-dismissible-needs-labelling";
40+
export { default as tagNeedsName } from "./tag-needs-labelling";
3941
export { default as toolbarMissingAria } from "./toolbar-missing-aria";
4042
export { default as tooltipNotRecommended } from "./tooltip-not-recommended";
4143
export { default as visualLabelBetterThanAriaSuggestion } from "./visual-label-better-than-aria-suggestion";
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
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 { hasNonEmptyProp } from "../util/hasNonEmptyProp";
7+
import { JSXOpeningElement, JSXAttribute } from "estree-jsx";
8+
9+
//------------------------------------------------------------------------------
10+
// Utility Functions
11+
//------------------------------------------------------------------------------
12+
13+
/**
14+
* Checks if a value is a non-empty string
15+
*/
16+
const isNonEmptyString = (value: any): boolean => {
17+
return typeof value === "string" && value.trim().length > 0;
18+
};
19+
20+
//------------------------------------------------------------------------------
21+
// Rule Definition
22+
//------------------------------------------------------------------------------
23+
const rule = ESLintUtils.RuleCreator.withoutDocs({
24+
defaultOptions: [],
25+
meta: {
26+
type: "problem",
27+
docs: {
28+
description:
29+
"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",
30+
recommended: "strict",
31+
url: "https://react.fluentui.dev/?path=/docs/components-tag-tag--docs"
32+
},
33+
fixable: undefined,
34+
schema: [],
35+
messages: {
36+
missingDismissLabel:
37+
"Accessibility: Dismissible Tag must have either aria-label on dismissIcon or aria-label on Tag with role on dismissIcon"
38+
}
39+
},
40+
create(context) {
41+
return {
42+
// visitor functions for different types of nodes
43+
JSXElement(node: TSESTree.JSXElement) {
44+
const openingElement = node.openingElement;
45+
46+
// if it is not a Tag, return
47+
if (elementType(openingElement as JSXOpeningElement) !== "Tag") {
48+
return;
49+
}
50+
51+
// Check if Tag has dismissible prop
52+
const isDismissible = hasProp(openingElement.attributes as JSXAttribute[], "dismissible");
53+
if (!isDismissible) {
54+
return;
55+
}
56+
57+
// Check if dismissible Tag has proper accessibility labelling
58+
const dismissIconProp = getProp(openingElement.attributes as JSXAttribute[], "dismissIcon");
59+
if (!dismissIconProp) {
60+
context.report({
61+
node,
62+
messageId: `missingDismissLabel`
63+
});
64+
return;
65+
}
66+
67+
const dismissIconValue = getPropValue(dismissIconProp);
68+
69+
// Check if dismissIcon has aria-label
70+
const dismissIconHasAriaLabel =
71+
dismissIconValue && typeof dismissIconValue === "object" && isNonEmptyString((dismissIconValue as any)["aria-label"]);
72+
73+
// Check if dismissIcon has role
74+
const dismissIconHasRole =
75+
dismissIconValue && typeof dismissIconValue === "object" && isNonEmptyString((dismissIconValue as any)["role"]);
76+
77+
// Check if Tag has aria-label (required when dismissIcon has role)
78+
const tagHasAriaLabel = hasNonEmptyProp(openingElement.attributes, "aria-label");
79+
// Valid patterns:
80+
// Option 1: dismissIcon has aria-label
81+
// Option 2: Tag has aria-label and dismissIcon has role
82+
const hasValidLabelling = dismissIconHasAriaLabel || (tagHasAriaLabel && dismissIconHasRole);
83+
if (!hasValidLabelling) {
84+
context.report({
85+
node,
86+
messageId: `missingDismissLabel`
87+
});
88+
}
89+
}
90+
};
91+
}
92+
});
93+
94+
export default rule;

lib/rules/tag-needs-labelling.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { ESLintUtils } from "@typescript-eslint/utils";
5+
import { makeLabeledControlRule } from "../util/ruleFactory";
6+
7+
//------------------------------------------------------------------------------
8+
// Rule Definition
9+
//------------------------------------------------------------------------------
10+
11+
export default ESLintUtils.RuleCreator.withoutDocs(
12+
makeLabeledControlRule({
13+
component: "Tag",
14+
messageId: "missingAriaLabel",
15+
description: "Accessibility: Tag must have an accessible name",
16+
labelProps: ["aria-label"],
17+
allowFieldParent: false,
18+
allowHtmlFor: false,
19+
allowLabelledBy: true,
20+
allowWrappingLabel: false,
21+
allowTooltipParent: false,
22+
allowDescribedBy: false,
23+
allowLabeledChild: false,
24+
allowTextContentChild: true
25+
})
26+
);

lib/util/hasTriggerProp.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { TSESTree } from "@typescript-eslint/utils";
5+
import { hasProp } from "jsx-ast-utils";
6+
import { JSXAttribute } from "estree-jsx";
7+
8+
/**
9+
* Checks if a component has a specific trigger prop.
10+
* This is useful for rules that only apply when certain props are present
11+
* (e.g., dismissible, expandable, collapsible, etc.)
12+
*/
13+
export const hasTriggerProp = (openingElement: TSESTree.JSXOpeningElement, triggerProp: string): boolean => {
14+
return hasProp(openingElement.attributes as JSXAttribute[], triggerProp);
15+
};

lib/util/hasValidNestedProp.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
import { TSESTree } from "@typescript-eslint/utils";
5+
import { getProp, getPropValue } from "jsx-ast-utils";
6+
import { JSXAttribute } from "estree-jsx";
7+
8+
/**
9+
* Checks if a value is a non-empty string
10+
*/
11+
const isNonEmptyString = (value: any): boolean => {
12+
return typeof value === "string" && value.trim().length > 0;
13+
};
14+
15+
/**
16+
* Validates if a component has a specific nested property with a non-empty string value.
17+
*/
18+
export const hasValidNestedProp = (openingElement: TSESTree.JSXOpeningElement, propName: string, nestedKey: string): boolean => {
19+
const prop = getProp(openingElement.attributes as JSXAttribute[], propName);
20+
if (!prop) {
21+
return false;
22+
}
23+
24+
const propValue = getPropValue(prop);
25+
return Boolean(propValue && typeof propValue === "object" && isNonEmptyString((propValue as any)[nestedKey]));
26+
};

lib/util/ruleFactory.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { hasToolTipParent } from "./hasTooltipParent";
1616
import { hasLabeledChild } from "./hasLabeledChild";
1717
import { hasDefinedProp } from "./hasDefinedProp";
1818
import { hasTextContentChild } from "./hasTextContentChild";
19+
import { hasTriggerProp } from "./hasTriggerProp";
1920

2021
/**
2122
* Configuration options for a rule created via the `ruleFactory`
@@ -47,10 +48,10 @@ export type LabeledControlConfig = {
4748
* Keep this off unless a specific component (e.g., Icon-only buttons) intentionally uses it.
4849
*/
4950
allowDescribedBy: boolean;
50-
/** Treat labeled child content (img `alt`, svg `title`, `aria-label` on `role="img"`) as the name */
51-
allowLabeledChild: boolean;
52-
/** Accept text children to provide the label e.g. <Button>Click me</Button> */
53-
allowTextContentChild?: boolean;
51+
allowLabeledChild: boolean; // Accept labeled child elements to provide the label e.g. <Button><img alt="..." /></Button>
52+
allowTextContentChild?: boolean; // Accept text children to provide the label e.g. <Button>Click me</Button>
53+
triggerProp?: string; // Only apply rule when this trigger prop is present (e.g., "dismissible", "disabled")
54+
customValidator?: Function; // Custom validation logic
5455
};
5556

5657
/**
@@ -66,6 +67,8 @@ export type LabeledControlConfig = {
6667
* 6) Parent <Tooltip content="..."> context .......................... (allowTooltipParent)
6768
* 7) aria-describedby association (opt-in; discouraged as primary) .... (allowDescribedBy)
6869
* 8) treat labeled child content (img alt, svg title, aria-label on role="img") as the name
70+
* 9) Conditional application based on trigger prop ................... (triggerProp)
71+
* 10) Custom validation for complex scenarios ......................... (customValidator)
6972
*
7073
* This checks for presence of an accessible *name* only; not contrast or UX.
7174
*/
@@ -128,7 +131,17 @@ export function makeLabeledControlRule(config: LabeledControlConfig): TSESLint.R
128131

129132
if (!matches) return;
130133

131-
if (!hasAccessibleLabel(opening, node, context, config)) {
134+
if (config.triggerProp && !hasTriggerProp(opening, config.triggerProp)) {
135+
return;
136+
}
137+
138+
// Use custom validator if provided, otherwise use standard accessibility check
139+
let isValid: boolean;
140+
config.customValidator
141+
? (isValid = config.customValidator(opening))
142+
: (isValid = hasAccessibleLabel(opening, node, context, config));
143+
144+
if (!isValid) {
132145
// report on the opening tag for better location
133146
context.report({ node: opening, messageId: config.messageId });
134147
}

0 commit comments

Comments
 (0)