Skip to content

added rule for aria-describedby #94

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Sep 17, 2024
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
40 changes: 40 additions & 0 deletions docs/rules/avoid-using-aria-describedby-for-primary-labelling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# aria-describedby provides additional context and is not meant for primary labeling. (`avoid-using-aria-describedby-for-primary-labelling`)

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.

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

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

## Rule Details

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

```jsx
<Button aria-describedby="submitDesc icon={<CalendarMonthRegular />} />
<p id="submitDesc">Click to submit your form. This will save your data.</p>
```

```jsx
<TextField id="myInput" aria-describedby="nameDesc" placeholder="Enter your name" />
<p id="nameDesc">Name</p>
```

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

```jsx
<Button aria-label="submit" aria-describedby="submitDesc icon={<CalendarMonthRegular />} />
<p id="submitDesc">Click to submit your form. This will save your data.</p>
```

```jsx
<Label htmlFor="myInput">Name</Label>
<TextField id="myInput" aria-describedby="nameDesc" placeholder="Enter your name" />
<p id="nameDesc">This field is for your full legal name.</p>
```

## Further Reading

- [ARIA Describedby: Definition & Examples for Screen Readers](https://accessiblyapp.com/blog/aria-describedby/)
- [aria-describedby](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-describedby)

8 changes: 8 additions & 0 deletions lib/applicableComponents/buttonBasedComponents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

const applicableComponents = ["Button", "ToggleButton", "CompoundButton"];

module.exports = {
applicableComponents
};
8 changes: 8 additions & 0 deletions lib/applicableComponents/inputBasedComponents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

const applicableComponents = ["Input", "Slider", "DatePicker", "TextArea", "TextField"];

module.exports = {
applicableComponents
};
5 changes: 5 additions & 0 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ module.exports = {
"radio-button-missing-label": require("./rules/radio-button-missing-label"),
"radiogroup-missing-label": require("./rules/radiogroup-missing-label"),
"prefer-aria-over-title-attribute": require("./rules/prefer-aria-over-title-attribute"),
"avoid-using-aria-describedby-for-primary-labelling": require("./rules/avoid-using-aria-describedby-for-primary-labelling"),
"dialogbody-needs-title-content-and-actions": require("./rules/dialogbody-needs-title-content-and-actions"),
"dialogsurface-needs-aria": require("./rules/dialogsurface-needs-aria"),
"spinner-needs-labelling": require("./rules/spinner-needs-labelling")
Expand Down Expand Up @@ -67,9 +68,13 @@ module.exports = {
"@microsoft/fluentui-jsx-a11y/radio-button-missing-label": "error",
"@microsoft/fluentui-jsx-a11y/radiogroup-missing-label": "error",
"@microsoft/fluentui-jsx-a11y/prefer-aria-over-title-attribute": "warn",
<<<<<<< HEAD
"@microsoft/fluentui-jsx-a11y/avoid-using-aria-describedby-for-primary-labelling": "warn"
=======
"@microsoft/fluentui-jsx-a11y/dialogbody-needs-title-content-and-actions": "error",
"@microsoft/fluentui-jsx-a11y/dialogsurface-needs-aria": "error",
"@microsoft/fluentui-jsx-a11y/spinner-needs-labelling": "error"
>>>>>>> origin/main
}
}
}
Expand Down
77 changes: 77 additions & 0 deletions lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

"use strict";

const { applicableComponents: inputComponents } = require("../applicableComponents/inputBasedComponents");
const { applicableComponents: buttonComponents } = require("../applicableComponents/buttonBasedComponents");
const { elementType } = require("jsx-ast-utils");
const {
isInsideLabelTag,
hasAssociatedLabelViaHtmlFor,
hasAssociatedLabelViaAriaLabelledBy,
hasAssociatedLabelViaAriaDescribedby
} = require("../util/labelUtils");

const { hasFieldParent } = require("../util/hasFieldParent");
const { hasNonEmptyProp } = require("../util/hasNonEmptyProp");
const { hasToolTipParent } = require("../util/hasTooltipParent");
const { hasTextContentChild } = require("../util/hasTextContentChild");

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
messages: {
noAriaDescribedbyAsLabel: "Accessibility: aria-describedby provides additional context and is not meant for primary labeling."
},
type: "suggestion", // `problem`, `suggestion`, or `layout`
docs: {
description: "aria-describedby provides additional context and is not meant for primary labeling.",
recommended: true,
url: null // URL to the documentation page for this rule
},
fixable: null, // Or `code` or `whitespace`
schema: [] // Add a schema if the rule has options
},

create(context) {
return {
JSXElement(node) {
const openingElement = node.openingElement;

if (
buttonComponents.includes(elementType(openingElement)) && // It's a button-based component
!hasToolTipParent(context) && // It doesn't have a tooltip parent
!hasTextContentChild(node) && // It doesn't have text content
!hasNonEmptyProp(openingElement.attributes, "title") && // Doesn't have a title
!hasNonEmptyProp(openingElement.attributes, "aria-label") && // Doesn't have an aria-label
!hasAssociatedLabelViaAriaLabelledBy(openingElement, context) && // Doesn't have aria-labelledby
hasAssociatedLabelViaAriaDescribedby(openingElement, context) // But it does have aria-describedby
) {
context.report({
node,
messageId: "noAriaDescribedbyAsLabel"
});
}

if (
inputComponents.includes(elementType(openingElement)) && // It's an input component
!hasFieldParent(context) && // It doesn't have a field parent
!isInsideLabelTag(context) && // It's not inside a label tag
!hasAssociatedLabelViaHtmlFor(openingElement, context) && // Doesn't have a label via htmlFor
!hasAssociatedLabelViaAriaLabelledBy(openingElement, context) && // Doesn't have aria-labelledby
hasAssociatedLabelViaAriaDescribedby(openingElement, context) // But it does have aria-describedby
) {
context.report({
node,
messageId: "noAriaDescribedbyAsLabel"
});
}
}
};
}
};
7 changes: 2 additions & 5 deletions lib/rules/image-button-missing-aria.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { hasNonEmptyProp } = require("../util/hasNonEmptyProp");
const { hasToolTipParent } = require("../util/hasTooltipParent");
const { hasTextContentChild } = require("../util/hasTextContentChild");
const { hasAssociatedLabelViaAriaLabelledBy } = require("../util/labelUtils");
const { applicableComponents } = require("../applicableComponents/buttonBasedComponents");
var hasProp = require("jsx-ast-utils").hasProp;
var elementType = require("jsx-ast-utils").elementType;

Expand Down Expand Up @@ -39,11 +40,7 @@ module.exports = {
const openingElement = node.openingElement;

// if it is not a button, return
if (
elementType(openingElement) !== "Button" &&
elementType(openingElement) !== "ToggleButton" &&
elementType(openingElement) !== "CompoundButton"
) {
if (!applicableComponents.includes(elementType(openingElement))) {
return;
}

Expand Down
2 changes: 1 addition & 1 deletion lib/rules/input-missing-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const { elementType } = require("jsx-ast-utils");

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

//------------------------------------------------------------------------------
// Rule Definition
Expand Down
19 changes: 18 additions & 1 deletion lib/util/labelUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,23 @@ function hasAssociatedLabelViaAriaLabelledBy(openingElement, context) {
return hasAssociatedLabelViaAriaLabelledBy && hasHtmlId;
}

/**
* Determines if the element has a label with the matching id associated with it via aria-describedby.
* e.g.
* <Label id={labelId}>Sample input</Label>
* <Input aria-describedby={labelId} />
* @param {*} openingElement
* @param {*} context
* @returns boolean for match found or not.
*/
function hasAssociatedLabelViaAriaDescribedby(openingElement, context) {
const hasAssociatedLabelViaAriadescribedby = hasNonEmptyProp(openingElement.attributes, "aria-describedby");
const idValue = getPropValue(getProp(openingElement.attributes, "aria-describedby"));
const hasHtmlId = hasLabelWithHtmlId(idValue, context);

return hasAssociatedLabelViaAriadescribedby && hasHtmlId;
}

/**
* Determines if the element has a label associated with it via htmlFor
* e.g.
Expand Down Expand Up @@ -131,6 +148,6 @@ module.exports = {
hasLabelWithHtmlId,
hasAssociatedLabelViaAriaLabelledBy,
hasAssociatedLabelViaHtmlFor,
hasAssociatedLabelViaAriaDescribedby,
hasAssociatedAriaText
};

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------

const rule = require("../../../lib/rules/avoid-using-aria-describedby-for-primary-labelling"),
RuleTester = require("eslint").RuleTester;

RuleTester.setDefaultConfig({
parserOptions: {
ecmaVersion: 6,
ecmaFeatures: {
jsx: true
}
}
});

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------

const ruleTester = new RuleTester();
ruleTester.run("avoid-using-aria-describedby-for-primary-labelling", rule, {
valid: [
'<><Button aria-label="submit" aria-describedby="submitDesc" icon={<CalendarMonthRegular />} /><Label id="submitDesc">Click to submit your form. This will save your data.</Label></>',
'<><Button aria-describedby="submitDesc">Submit</Button><Label id="submitDesc">Click to submit your form. This will save your data.</Label></>',
'<><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></>'
],
invalid: [
{
code: '<><Button aria-describedby="submitDesc" icon={<CalendarMonthRegular />} /><Label id="submitDesc">Click to submit your form. This will save your data.</Label></>',
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
},
{
code: '<><TextField id="myInput" aria-describedby="nameDesc" /><Label id="nameDesc">Name</Label></>',
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
},
{
code: '<><Input type="text" aria-describedby="nameDesc" /><Label id="nameDesc">Name</Label></>',
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
}
]
});
Loading