diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 25aa0af0f3..e934eb2212 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -39,6 +39,7 @@ | image-alt | Ensures <img> elements have alternate text or a role of none or presentation | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | | image-redundant-alt | Ensure button and link text is not repeated as image alternative | Minor | cat.text-alternatives, best-practice | true | | input-image-alt | Ensures <input type="image"> elements have alternate text | Critical | cat.text-alternatives, wcag2a, wcag111, section508, section508.22.a | true | +| label-content-name-mismatch | Ensures that interactive elements labelled through their content must have their visible label as part of their accessible name | Serious | wcag21a, wcag253 | true | | label-title-only | Ensures that every form element is not solely labeled using the title or aria-describedby attributes | Serious | cat.forms, best-practice | true | | label | Ensures every form element has a label | Minor, Serious, Critical | cat.forms, wcag2a, wcag332, wcag131, section508, section508.22.n | true | | landmark-banner-is-top-level | Ensures the banner landmark is at top level | Moderate | cat.semantics, best-practice | true | diff --git a/lib/checks/label/label-content-name-mismatch.js b/lib/checks/label/label-content-name-mismatch.js new file mode 100644 index 0000000000..0b83d49193 --- /dev/null +++ b/lib/checks/label/label-content-name-mismatch.js @@ -0,0 +1,34 @@ +function getAriaText(elm) { + if (elm.getAttribute('aria-label')) { + return elm + .getAttribute('aria-label') + .toLowerCase() + .trim(); + } + if (elm.getAttribute('aria-labelledby')) { + return elm + .getAttribute('aria-labelledby') + .toLowerCase() + .trim(); + } + return null; +} + +const accessibleText = getAriaText(node); +// if no accessible text - fail +if (!accessibleText || !accessibleText.length) { + return false; +} + +const contentText = node.textContent.toLowerCase().trim(); +// if no text content - fail +if (!contentText || !contentText.length) { + return false; +} + +// if text content is not part of accessible text - fail +if (!accessibleText.includes(contentText)) { + return false; +} + +return true; diff --git a/lib/checks/label/label-content-name-mismatch.json b/lib/checks/label/label-content-name-mismatch.json new file mode 100644 index 0000000000..6f18ac1a2a --- /dev/null +++ b/lib/checks/label/label-content-name-mismatch.json @@ -0,0 +1,11 @@ +{ + "id": "label-content-name-mismatch", + "evaluate": "label-content-name-mismatch.js", + "metadata": { + "impact": "serious", + "messages": { + "pass": "Interactive element contains visible label as part of it's accessible name", + "fail": "Interactive element does not contain visible label as part of it's accessible name" + } + } +} diff --git a/lib/rules/label-content-name-mismatch-matches.js b/lib/rules/label-content-name-mismatch-matches.js new file mode 100644 index 0000000000..87f9fc292d --- /dev/null +++ b/lib/rules/label-content-name-mismatch-matches.js @@ -0,0 +1,33 @@ +/** + * Applicability: + * This rule applies to any element that has: + * - a semantic role that is a widget that supports name from content, and + * - visible text content, and + * - an aria-label or aria-labelledby attribute. + */ +const { aria } = axe.commons; +const role = aria.getRole(node, { noImplicit: false, fallback: true }); + +// no role - exclude +if (!role) { + return false; +} + +const rolesWithNameFromContents = aria.getRolesWithNameFromContents(); + +// if role is not one of roles with name from contents - exclude +if (!rolesWithNameFromContents.includes(role)) { + return false; +} + +// if no accessible name attributes - exclude +if (!node.getAttribute('aria-labelledby') && !node.getAttribute('aria-label')) { + return false; +} + +// if no text content - exclude +if (node.textContent.toLowerCase().trim().length <= 0) { + return false; +} + +return true; diff --git a/lib/rules/label-content-name-mismatch.json b/lib/rules/label-content-name-mismatch.json new file mode 100644 index 0000000000..7a9df795f6 --- /dev/null +++ b/lib/rules/label-content-name-mismatch.json @@ -0,0 +1,17 @@ +{ + "id": "label-content-name-mismatch", + "matches": "label-content-name-mismatch-matches.js", + "tags": [ + "wcag21a", + "wcag253" + ], + "metadata": { + "description": "Ensures that interactive elements labelled through their content must have their visible label as part of their accessible name", + "help": "Interactive elements must have their visible label as part of their accessible name" + }, + "all": [], + "any": [ + "label-content-name-mismatch" + ], + "none": [] +} diff --git a/test/checks/label/label-content-name-mismatch.js b/test/checks/label/label-content-name-mismatch.js new file mode 100644 index 0000000000..1032a18541 --- /dev/null +++ b/test/checks/label/label-content-name-mismatch.js @@ -0,0 +1,127 @@ +describe('label-content-name-mismatch', function() { + 'use strict'; + + var fixture = document.getElementById('fixture'); + var check = undefined; + var options = undefined; + // var checkSetup = axe.testUtils.checkSetup; + // var shadowSupport = axe.testUtils.shadowSupport; + + beforeEach(function() { + check = checks['label-content-name-mismatch']; + }); + + afterEach(function() { + fixture.innerHTML = ''; + check = undefined; + axe._tree = undefined; + }); + + it('returns false when element has an empty aria-label', function() { + fixture.innerHTML = + ''; + var node = fixture.querySelector('#target'); + var tree = (axe._tree = axe.utils.getFlattenedTree(fixture)); + var vNode = axe.utils.getNodeFromTree(tree[0], node); + var actual = check.evaluate(node, options, vNode); + assert.isFalse(actual); + }); + + it('returns false when element has an empty aria-labelledby', function() { + fixture.innerHTML = + ''; + var node = fixture.querySelector('#target'); + var tree = (axe._tree = axe.utils.getFlattenedTree(fixture)); + var actual = check.evaluate( + node, + options, + axe.utils.getNodeFromTree(tree[0], node) + ); + assert.isFalse(actual); + }); + + it('returns false when element has no text content', function() { + fixture.innerHTML = + ''; + var node = fixture.querySelector('#target'); + var tree = (axe._tree = axe.utils.getFlattenedTree(fixture)); + var actual = check.evaluate( + node, + options, + axe.utils.getNodeFromTree(tree[0], node) + ); + assert.isFalse(actual); + }); + + it('returns false when accessible text does not contain text content', function() { + fixture.innerHTML = + ''; + var node = fixture.querySelector('#target'); + var tree = (axe._tree = axe.utils.getFlattenedTree(fixture)); + var actual = check.evaluate( + node, + options, + axe.utils.getNodeFromTree(tree[0], node) + ); + assert.isFalse(actual); + }); + + it('returns true when visible label and accessible name matches when trailing white spaces are removed', function() { + fixture.innerHTML = + '