Skip to content

Commit 9994400

Browse files
committed
Merge remote-tracking branch 'origin/main' into user/aubreyquinn/badge
2 parents a48dfdf + 915a776 commit 9994400

9 files changed

+204
-9
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# aria-describedby provides additional context and is not meant for primary labeling. (`avoid-using-aria-describedby-for-primary-labelling`)
2+
3+
You should avoid using `aria-describedby` as a primary labeling mechanism because it is intended to provide supplementary or additional information, not to act as the main label for an element.
4+
5+
**Purpose:** `aria-describedby` is designed to describe an element in more detail beyond the primary label, such as offering extended help text, usage instructions, or explanations. It’s meant to be used alongside a label, not to replace it.
6+
7+
**Accessibility and User Experience:** Some screen readers may not announce content associated with `aria-describedby`, or users might disable this feature for various reasons, such as reducing verbosity or simplifying their experience. This makes relying on `aria-describedby` as the primary labeling mechanism risky because it can lead to critical information being missed by users who need it. Screen readers treat `aria-describedby` differently than `aria-labelledby`. The `aria-labelledby` attribute is explicitly used for primary labeling, and it ensures that the name of an element is read in the expected order and with the right emphasis. On the other hand, `aria-describedby` provides additional context that may be read after the main label, so it may confuse users if used as the primary label.
8+
9+
## Rule Details
10+
11+
Examples of **incorrect** code for this rule:
12+
13+
```jsx
14+
<Button aria-describedby="submitDesc icon={<CalendarMonthRegular />} />
15+
<p id="submitDesc">Click to submit your form. This will save your data.</p>
16+
```
17+
18+
```jsx
19+
<TextField id="myInput" aria-describedby="nameDesc" placeholder="Enter your name" />
20+
<p id="nameDesc">Name</p>
21+
```
22+
23+
Examples of **correct** code for this rule:
24+
25+
```jsx
26+
<Button aria-label="submit" aria-describedby="submitDesc icon={<CalendarMonthRegular />} />
27+
<p id="submitDesc">Click to submit your form. This will save your data.</p>
28+
```
29+
30+
```jsx
31+
<Label htmlFor="myInput">Name</Label>
32+
<TextField id="myInput" aria-describedby="nameDesc" placeholder="Enter your name" />
33+
<p id="nameDesc">This field is for your full legal name.</p>
34+
```
35+
36+
## Further Reading
37+
38+
- [ARIA Describedby: Definition & Examples for Screen Readers](https://accessiblyapp.com/blog/aria-describedby/)
39+
- [aria-describedby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby)
40+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
const applicableComponents = ["Button", "ToggleButton", "CompoundButton"];
5+
6+
module.exports = {
7+
applicableComponents
8+
};
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
const applicableComponents = ["Input", "Slider", "DatePicker", "TextArea", "TextField"];
5+
6+
module.exports = {
7+
applicableComponents
8+
};

lib/index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ module.exports = {
3737
"radio-button-missing-label": require("./rules/radio-button-missing-label"),
3838
"radiogroup-missing-label": require("./rules/radiogroup-missing-label"),
3939
"prefer-aria-over-title-attribute": require("./rules/prefer-aria-over-title-attribute"),
40+
"avoid-using-aria-describedby-for-primary-labelling": require("./rules/avoid-using-aria-describedby-for-primary-labelling"),
4041
"dialogbody-needs-title-content-and-actions": require("./rules/dialogbody-needs-title-content-and-actions"),
4142
"dialogsurface-needs-aria": require("./rules/dialogsurface-needs-aria"),
4243
"spinner-needs-labelling": require("./rules/spinner-needs-labelling"),
@@ -68,10 +69,10 @@ module.exports = {
6869
"@microsoft/fluentui-jsx-a11y/radio-button-missing-label": "error",
6970
"@microsoft/fluentui-jsx-a11y/radiogroup-missing-label": "error",
7071
"@microsoft/fluentui-jsx-a11y/prefer-aria-over-title-attribute": "warn",
72+
"@microsoft/fluentui-jsx-a11y/avoid-using-aria-describedby-for-primary-labelling": "warn",
7173
"@microsoft/fluentui-jsx-a11y/dialogbody-needs-title-content-and-actions": "error",
7274
"@microsoft/fluentui-jsx-a11y/dialogsurface-needs-aria": "error",
73-
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error",
74-
"@microsoft/fluentui-jsx-a11y/badge-needs-accessible-name": "error"
75+
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error"
7576
}
7677
}
7778
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
"use strict";
5+
6+
const { applicableComponents: inputComponents } = require("../applicableComponents/inputBasedComponents");
7+
const { applicableComponents: buttonComponents } = require("../applicableComponents/buttonBasedComponents");
8+
const { elementType } = require("jsx-ast-utils");
9+
const {
10+
isInsideLabelTag,
11+
hasAssociatedLabelViaHtmlFor,
12+
hasAssociatedLabelViaAriaLabelledBy,
13+
hasAssociatedLabelViaAriaDescribedby
14+
} = require("../util/labelUtils");
15+
16+
const { hasFieldParent } = require("../util/hasFieldParent");
17+
const { hasNonEmptyProp } = require("../util/hasNonEmptyProp");
18+
const { hasToolTipParent } = require("../util/hasTooltipParent");
19+
const { hasTextContentChild } = require("../util/hasTextContentChild");
20+
21+
//------------------------------------------------------------------------------
22+
// Rule Definition
23+
//------------------------------------------------------------------------------
24+
25+
/** @type {import('eslint').Rule.RuleModule} */
26+
module.exports = {
27+
meta: {
28+
messages: {
29+
noAriaDescribedbyAsLabel: "Accessibility: aria-describedby provides additional context and is not meant for primary labeling."
30+
},
31+
type: "suggestion", // `problem`, `suggestion`, or `layout`
32+
docs: {
33+
description: "aria-describedby provides additional context and is not meant for primary labeling.",
34+
recommended: true,
35+
url: null // URL to the documentation page for this rule
36+
},
37+
fixable: null, // Or `code` or `whitespace`
38+
schema: [] // Add a schema if the rule has options
39+
},
40+
41+
create(context) {
42+
return {
43+
JSXElement(node) {
44+
const openingElement = node.openingElement;
45+
46+
if (
47+
buttonComponents.includes(elementType(openingElement)) && // It's a button-based component
48+
!hasToolTipParent(context) && // It doesn't have a tooltip parent
49+
!hasTextContentChild(node) && // It doesn't have text content
50+
!hasNonEmptyProp(openingElement.attributes, "title") && // Doesn't have a title
51+
!hasNonEmptyProp(openingElement.attributes, "aria-label") && // Doesn't have an aria-label
52+
!hasAssociatedLabelViaAriaLabelledBy(openingElement, context) && // Doesn't have aria-labelledby
53+
hasAssociatedLabelViaAriaDescribedby(openingElement, context) // But it does have aria-describedby
54+
) {
55+
context.report({
56+
node,
57+
messageId: "noAriaDescribedbyAsLabel"
58+
});
59+
}
60+
61+
if (
62+
inputComponents.includes(elementType(openingElement)) && // It's an input component
63+
!hasFieldParent(context) && // It doesn't have a field parent
64+
!isInsideLabelTag(context) && // It's not inside a label tag
65+
!hasAssociatedLabelViaHtmlFor(openingElement, context) && // Doesn't have a label via htmlFor
66+
!hasAssociatedLabelViaAriaLabelledBy(openingElement, context) && // Doesn't have aria-labelledby
67+
hasAssociatedLabelViaAriaDescribedby(openingElement, context) // But it does have aria-describedby
68+
) {
69+
context.report({
70+
node,
71+
messageId: "noAriaDescribedbyAsLabel"
72+
});
73+
}
74+
}
75+
};
76+
}
77+
};

lib/rules/image-button-missing-aria.js

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const { hasNonEmptyProp } = require("../util/hasNonEmptyProp");
77
const { hasToolTipParent } = require("../util/hasTooltipParent");
88
const { hasTextContentChild } = require("../util/hasTextContentChild");
99
const { hasAssociatedLabelViaAriaLabelledBy } = require("../util/labelUtils");
10+
const { applicableComponents } = require("../applicableComponents/buttonBasedComponents");
1011
var hasProp = require("jsx-ast-utils").hasProp;
1112
var elementType = require("jsx-ast-utils").elementType;
1213

@@ -39,11 +40,7 @@ module.exports = {
3940
const openingElement = node.openingElement;
4041

4142
// if it is not a button, return
42-
if (
43-
elementType(openingElement) !== "Button" &&
44-
elementType(openingElement) !== "ToggleButton" &&
45-
elementType(openingElement) !== "CompoundButton"
46-
) {
43+
if (!applicableComponents.includes(elementType(openingElement))) {
4744
return;
4845
}
4946

lib/rules/input-missing-label.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const { elementType } = require("jsx-ast-utils");
77

88
const { isInsideLabelTag, hasAssociatedLabelViaHtmlFor, hasAssociatedLabelViaAriaLabelledBy } = require("../util/labelUtils");
99
const { hasFieldParent } = require("../util/hasFieldParent");
10-
const applicableComponents = ["Input", "Slider", "DatePicker"];
10+
const { applicableComponents } = require("../applicableComponents/inputBasedComponents");
1111

1212
//------------------------------------------------------------------------------
1313
// Rule Definition

lib/util/labelUtils.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,23 @@ function hasAssociatedLabelViaAriaLabelledBy(openingElement, context) {
7979
return hasAssociatedLabelViaAriaLabelledBy && hasHtmlId;
8080
}
8181

82+
/**
83+
* Determines if the element has a label with the matching id associated with it via aria-describedby.
84+
* e.g.
85+
* <Label id={labelId}>Sample input</Label>
86+
* <Input aria-describedby={labelId} />
87+
* @param {*} openingElement
88+
* @param {*} context
89+
* @returns boolean for match found or not.
90+
*/
91+
function hasAssociatedLabelViaAriaDescribedby(openingElement, context) {
92+
const hasAssociatedLabelViaAriadescribedby = hasNonEmptyProp(openingElement.attributes, "aria-describedby");
93+
const idValue = getPropValue(getProp(openingElement.attributes, "aria-describedby"));
94+
const hasHtmlId = hasLabelWithHtmlId(idValue, context);
95+
96+
return hasAssociatedLabelViaAriadescribedby && hasHtmlId;
97+
}
98+
8299
/**
83100
* Determines if the element has a label associated with it via htmlFor
84101
* e.g.
@@ -131,6 +148,6 @@ module.exports = {
131148
hasLabelWithHtmlId,
132149
hasAssociatedLabelViaAriaLabelledBy,
133150
hasAssociatedLabelViaHtmlFor,
151+
hasAssociatedLabelViaAriaDescribedby,
134152
hasAssociatedAriaText
135153
};
136-
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
"use strict";
5+
6+
//------------------------------------------------------------------------------
7+
// Requirements
8+
//------------------------------------------------------------------------------
9+
10+
const rule = require("../../../lib/rules/avoid-using-aria-describedby-for-primary-labelling"),
11+
RuleTester = require("eslint").RuleTester;
12+
13+
RuleTester.setDefaultConfig({
14+
parserOptions: {
15+
ecmaVersion: 6,
16+
ecmaFeatures: {
17+
jsx: true
18+
}
19+
}
20+
});
21+
22+
//------------------------------------------------------------------------------
23+
// Tests
24+
//------------------------------------------------------------------------------
25+
26+
const ruleTester = new RuleTester();
27+
ruleTester.run("avoid-using-aria-describedby-for-primary-labelling", rule, {
28+
valid: [
29+
'<><Button aria-label="submit" aria-describedby="submitDesc" icon={<CalendarMonthRegular />} /><Label id="submitDesc">Click to submit your form. This will save your data.</Label></>',
30+
'<><Button aria-describedby="submitDesc">Submit</Button><Label id="submitDesc">Click to submit your form. This will save your data.</Label></>',
31+
'<><Label htmlFor="myInput">Name</Label><TextField id="myInput" aria-describedby="nameDesc" placeholder="Enter your name" /><Label id="nameDesc">This field is for your full legal name.</Label></>'
32+
],
33+
invalid: [
34+
{
35+
code: '<><Button aria-describedby="submitDesc" icon={<CalendarMonthRegular />} /><Label id="submitDesc">Click to submit your form. This will save your data.</Label></>',
36+
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
37+
},
38+
{
39+
code: '<><TextField id="myInput" aria-describedby="nameDesc" /><Label id="nameDesc">Name</Label></>',
40+
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
41+
},
42+
{
43+
code: '<><Input type="text" aria-describedby="nameDesc" /><Label id="nameDesc">Name</Label></>',
44+
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
45+
}
46+
]
47+
});

0 commit comments

Comments
 (0)