From d286e176ada91d938703fde40579ec08b2e79468 Mon Sep 17 00:00:00 2001
From: Aubrey Quinn
Date: Mon, 16 Sep 2024 15:59:38 +0100
Subject: [PATCH 1/5] started implementation for new rule, added helper
function
---
...-aria-describedby-for-primary-labelling.md | 40 +++++++++++++++++++
.../buttonBasedComponents.js | 8 ++++
.../inputBasedComponents.js | 8 ++++
lib/rules/image-button-missing-aria.js | 7 +---
lib/rules/input-missing-label.js | 2 +-
lib/util/labelUtils.js | 21 +++++++++-
6 files changed, 78 insertions(+), 8 deletions(-)
create mode 100644 docs/rules/avoid-using-aria-describedby-for-primary-labelling.md
create mode 100644 lib/applicableComponents/buttonBasedComponents.js
create mode 100644 lib/applicableComponents/inputBasedComponents.js
diff --git a/docs/rules/avoid-using-aria-describedby-for-primary-labelling.md b/docs/rules/avoid-using-aria-describedby-for-primary-labelling.md
new file mode 100644
index 0000000..2ad7be6
--- /dev/null
+++ b/docs/rules/avoid-using-aria-describedby-for-primary-labelling.md
@@ -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
+Click to submit your form. This will save your data.
+```
+
+```jsx
+
+Name
+```
+
+Examples of **correct** code for this rule:
+
+```jsx
+Click to submit your form. This will save your data.
+```
+
+```jsx
+Name
+
+This field is for your full legal name.
+```
+
+## 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)
+
diff --git a/lib/applicableComponents/buttonBasedComponents.js b/lib/applicableComponents/buttonBasedComponents.js
new file mode 100644
index 0000000..469972e
--- /dev/null
+++ b/lib/applicableComponents/buttonBasedComponents.js
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+const applicableComponents = ["Button", "ToggleButton", "CompoundButton"];
+
+module.exports = {
+ applicableComponents
+};
diff --git a/lib/applicableComponents/inputBasedComponents.js b/lib/applicableComponents/inputBasedComponents.js
new file mode 100644
index 0000000..c84d8d1
--- /dev/null
+++ b/lib/applicableComponents/inputBasedComponents.js
@@ -0,0 +1,8 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+const applicableComponents = ["Input", "Slider", "DatePicker"];
+
+module.exports = {
+ applicableComponents
+};
diff --git a/lib/rules/image-button-missing-aria.js b/lib/rules/image-button-missing-aria.js
index 82c6284..38229d1 100644
--- a/lib/rules/image-button-missing-aria.js
+++ b/lib/rules/image-button-missing-aria.js
@@ -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;
@@ -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;
}
diff --git a/lib/rules/input-missing-label.js b/lib/rules/input-missing-label.js
index a4d33ff..7068829 100644
--- a/lib/rules/input-missing-label.js
+++ b/lib/rules/input-missing-label.js
@@ -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
diff --git a/lib/util/labelUtils.js b/lib/util/labelUtils.js
index 266225f..13be3fa 100644
--- a/lib/util/labelUtils.js
+++ b/lib/util/labelUtils.js
@@ -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.
+ * Sample input
+ *
+ * @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.
@@ -99,6 +116,6 @@ module.exports = {
hasLabelWithHtmlForId,
hasLabelWithHtmlId,
hasAssociatedLabelViaAriaLabelledBy,
- hasAssociatedLabelViaHtmlFor
+ hasAssociatedLabelViaHtmlFor,
+ hasAssociatedLabelViaAriaDescribedby
};
-
From fae2a658d55bf8fc578d78a375c977f0023bc14c Mon Sep 17 00:00:00 2001
From: Aubrey Quinn
Date: Mon, 16 Sep 2024 17:02:04 +0100
Subject: [PATCH 2/5] added logic for new lint rule for aria describedby
---
.../inputBasedComponents.js | 2 +-
...-aria-describedby-for-primary-labelling.js | 78 +++++++++++++++++++
lib/util/labelUtils.js | 2 +-
...-aria-describedby-for-primary-labelling.js | 52 +++++++++++++
4 files changed, 132 insertions(+), 2 deletions(-)
create mode 100644 lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
create mode 100644 tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
diff --git a/lib/applicableComponents/inputBasedComponents.js b/lib/applicableComponents/inputBasedComponents.js
index c84d8d1..2db61d3 100644
--- a/lib/applicableComponents/inputBasedComponents.js
+++ b/lib/applicableComponents/inputBasedComponents.js
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-const applicableComponents = ["Input", "Slider", "DatePicker"];
+const applicableComponents = ["Input", "Slider", "DatePicker", "TextArea", "TextField"];
module.exports = {
applicableComponents
diff --git a/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js b/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
new file mode 100644
index 0000000..4a8a24e
--- /dev/null
+++ b/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
@@ -0,0 +1,78 @@
+// 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: "problem", // `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"
+ });
+ }
+ }
+ };
+ }
+};
+
diff --git a/lib/util/labelUtils.js b/lib/util/labelUtils.js
index 13be3fa..b287340 100644
--- a/lib/util/labelUtils.js
+++ b/lib/util/labelUtils.js
@@ -56,7 +56,7 @@ function hasLabelWithHtmlId(idValue, context) {
return false;
}
const sourceCode = context.getSourceCode();
- const regex = /]*id[^>]*=[^>]*[{"|{'|"|']([^>'"}]*)['|"|'}|"}][^>]*>/gim;
+ const regex = /<(?:Label|div|span|p)[^>]*id[^>]*=[^>]*[{"|{'|"|']([^>'"}]*)['|"|'}|"}][^>]*>/gim;
const matches = regex.exec(sourceCode.text);
return !!matches && matches.some(match => match === idValue);
diff --git a/tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js b/tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
new file mode 100644
index 0000000..f4f6fb3
--- /dev/null
+++ b/tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
@@ -0,0 +1,52 @@
+// 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: [
+ '<> } />Click to submit your form. This will save your data.
>',
+ '<>Submit Click to submit your form. This will save your data.
>',
+ '<>Name This field is for your full legal name.
>'
+ ],
+ invalid: [
+ {
+ code: '<> } />Click to submit your form. This will save your data.
>',
+ errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
+ },
+ {
+ code: '<>Name
>',
+ errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
+ },
+ {
+ code: '<>Name >',
+ errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
+ },
+ {
+ code: '<>Name
>',
+ errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
+ }
+ ]
+});
+
From 9e6b79c0cf34379864f9e5da2570de553be4e001 Mon Sep 17 00:00:00 2001
From: Aubrey Quinn
Date: Mon, 16 Sep 2024 17:43:44 +0100
Subject: [PATCH 3/5] added new rule to index.js
---
lib/index.js | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/lib/index.js b/lib/index.js
index 8de1478..1aeb5e4 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -36,7 +36,8 @@ module.exports = {
"avatar-needs-name": require("./rules/avatar-needs-name"),
"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")
+ "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")
},
configs: {
recommended: {
@@ -63,7 +64,8 @@ module.exports = {
"@microsoft/fluentui-jsx-a11y/avatar-needs-name": "error",
"@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"
+ "@microsoft/fluentui-jsx-a11y/prefer-aria-over-title-attribute": "warn",
+ "@microsoft/fluentui-jsx-a11y/avoid-using-aria-describedby-for-primary-labelling": "warn"
}
}
}
From a238390d3d083e265720cb67b5ccc9763edf623d Mon Sep 17 00:00:00 2001
From: Aubrey Quinn
Date: Mon, 16 Sep 2024 19:42:19 +0100
Subject: [PATCH 4/5] added headings to valid labels
---
lib/util/labelUtils.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/util/labelUtils.js b/lib/util/labelUtils.js
index b287340..b2e8968 100644
--- a/lib/util/labelUtils.js
+++ b/lib/util/labelUtils.js
@@ -56,7 +56,7 @@ function hasLabelWithHtmlId(idValue, context) {
return false;
}
const sourceCode = context.getSourceCode();
- const regex = /<(?:Label|div|span|p)[^>]*id[^>]*=[^>]*[{"|{'|"|']([^>'"}]*)['|"|'}|"}][^>]*>/gim;
+ const regex = /<(?:label|div|span|p|h[1-6])[^>]*id[^>]*=[^>]*[{"|{'|"|']([^>'"}]*)['|"|'}|"}][^>]*>/gim;
const matches = regex.exec(sourceCode.text);
return !!matches && matches.some(match => match === idValue);
From 7385185d21b6d11b94e8d728625f534bc6c9f9a8 Mon Sep 17 00:00:00 2001
From: Aubrey Quinn
Date: Tue, 17 Sep 2024 17:10:41 +0100
Subject: [PATCH 5/5] updated test file
---
...sing-aria-describedby-for-primary-labelling.js | 3 +--
lib/util/labelUtils.js | 2 +-
...sing-aria-describedby-for-primary-labelling.js | 15 +++++----------
3 files changed, 7 insertions(+), 13 deletions(-)
diff --git a/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js b/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
index 4a8a24e..8878738 100644
--- a/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
+++ b/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
@@ -28,7 +28,7 @@ module.exports = {
messages: {
noAriaDescribedbyAsLabel: "Accessibility: aria-describedby provides additional context and is not meant for primary labeling."
},
- type: "problem", // `problem`, `suggestion`, or `layout`
+ type: "suggestion", // `problem`, `suggestion`, or `layout`
docs: {
description: "aria-describedby provides additional context and is not meant for primary labeling.",
recommended: true,
@@ -75,4 +75,3 @@ module.exports = {
};
}
};
-
diff --git a/lib/util/labelUtils.js b/lib/util/labelUtils.js
index b2e8968..13be3fa 100644
--- a/lib/util/labelUtils.js
+++ b/lib/util/labelUtils.js
@@ -56,7 +56,7 @@ function hasLabelWithHtmlId(idValue, context) {
return false;
}
const sourceCode = context.getSourceCode();
- const regex = /<(?:label|div|span|p|h[1-6])[^>]*id[^>]*=[^>]*[{"|{'|"|']([^>'"}]*)['|"|'}|"}][^>]*>/gim;
+ const regex = /]*id[^>]*=[^>]*[{"|{'|"|']([^>'"}]*)['|"|'}|"}][^>]*>/gim;
const matches = regex.exec(sourceCode.text);
return !!matches && matches.some(match => match === idValue);
diff --git a/tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js b/tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
index f4f6fb3..3812535 100644
--- a/tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
+++ b/tests/lib/rules/avoid-using-aria-describedby-for-primary-labelling.js
@@ -26,27 +26,22 @@ RuleTester.setDefaultConfig({
const ruleTester = new RuleTester();
ruleTester.run("avoid-using-aria-describedby-for-primary-labelling", rule, {
valid: [
- '<> } />Click to submit your form. This will save your data.
>',
- '<>Submit Click to submit your form. This will save your data.
>',
- '<>Name This field is for your full legal name.
>'
+ '<> } />Click to submit your form. This will save your data. >',
+ '<>Submit Click to submit your form. This will save your data. >',
+ '<>Name This field is for your full legal name. >'
],
invalid: [
{
- code: '<> } />Click to submit your form. This will save your data.
>',
+ code: '<> } />Click to submit your form. This will save your data. >',
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
},
{
- code: '<>Name
>',
+ code: '<>Name >',
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
},
{
code: '<>Name >',
errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
- },
- {
- code: '<>Name
>',
- errors: [{ messageId: "noAriaDescribedbyAsLabel" }]
}
]
});
-