diff --git a/doc/rule-descriptions.md b/doc/rule-descriptions.md index 987b0fa0f2..90d28f4ca8 100644 --- a/doc/rule-descriptions.md +++ b/doc/rule-descriptions.md @@ -52,6 +52,7 @@ | landmark-no-duplicate-banner | Ensures the document has at most one banner landmark | Moderate | cat.semantics, best-practice | true | | landmark-no-duplicate-contentinfo | Ensures the document has at most one contentinfo landmark | Moderate | cat.semantics, best-practice | true | | landmark-one-main | Ensures the document has only one main landmark and each iframe in the page has at most one main landmark | Moderate | cat.semantics, best-practice | true | +| landmark-unique | Landmarks must have a unique role or role/label/title (i.e. accessible name) combination | Moderate | cat.semantics, best-practice | true | | layout-table | Ensures presentational <table> elements do not use <th>, <caption> elements or the summary attribute | Serious | cat.semantics, wcag2a, wcag131 | true | | link-in-text-block | Links can be distinguished without relying on color | Serious | cat.color, experimental, wcag2a, wcag141 | true | | link-name | Ensures links have discernible text | Serious | cat.name-role-value, wcag2a, wcag412, wcag244, section508, section508.22.a | true | diff --git a/lib/checks/landmarks/landmark-is-unique-after.js b/lib/checks/landmarks/landmark-is-unique-after.js new file mode 100644 index 0000000000..9264ec7c37 --- /dev/null +++ b/lib/checks/landmarks/landmark-is-unique-after.js @@ -0,0 +1,23 @@ +var uniqueLandmarks = []; + +// filter out landmark elements that share the same role and accessible text +// so every non-unique landmark isn't reported as a failure (just the first) +return results.filter(currentResult => { + var findMatch = someResult => { + return ( + currentResult.data.role === someResult.data.role && + currentResult.data.accessibleText === someResult.data.accessibleText + ); + }; + + var matchedResult = uniqueLandmarks.find(findMatch); + if (matchedResult) { + matchedResult.result = false; + matchedResult.relatedNodes.push(currentResult.relatedNodes[0]); + return false; + } + + uniqueLandmarks.push(currentResult); + currentResult.relatedNodes = []; + return true; +}); diff --git a/lib/checks/landmarks/landmark-is-unique.js b/lib/checks/landmarks/landmark-is-unique.js new file mode 100644 index 0000000000..4181685c5c --- /dev/null +++ b/lib/checks/landmarks/landmark-is-unique.js @@ -0,0 +1,7 @@ +var role = axe.commons.aria.getRole(node); +var accessibleText = axe.commons.text.accessibleTextVirtual(virtualNode); +accessibleText = accessibleText ? accessibleText.toLowerCase() : null; +this.data({ role: role, accessibleText: accessibleText }); +this.relatedNodes([node]); + +return true; diff --git a/lib/checks/landmarks/landmark-is-unique.json b/lib/checks/landmarks/landmark-is-unique.json new file mode 100644 index 0000000000..05cf5d2933 --- /dev/null +++ b/lib/checks/landmarks/landmark-is-unique.json @@ -0,0 +1,12 @@ +{ + "id": "landmark-is-unique", + "evaluate": "landmark-is-unique.js", + "after": "landmark-is-unique-after.js", + "metadata": { + "impact": "moderate", + "messages": { + "pass": "Landmarks must have a unique role or role/label/title (i.e. accessible name) combination", + "fail": "The landmark must have a unique aria-label, aria-labelledby, or title to make landmarks distinguishable" + } + } +} diff --git a/lib/rules/landmark-unique-matches.js b/lib/rules/landmark-unique-matches.js new file mode 100644 index 0000000000..a75addc490 --- /dev/null +++ b/lib/rules/landmark-unique-matches.js @@ -0,0 +1,41 @@ +/* + * Since this is a best-practice rule, we are filtering elements as dictated by ARIA 1.1 Practices regardless of treatment by browser/AT combinations. + * + * Info: https://www.w3.org/TR/wai-aria-practices-1.1/#aria_landmark + */ +var excludedParentsForHeaderFooterLandmarks = [ + 'article', + 'aside', + 'main', + 'nav', + 'section' +].join(','); +function isHeaderFooterLandmark(headerFooterElement) { + return !axe.commons.dom.findUpVirtual( + headerFooterElement, + excludedParentsForHeaderFooterLandmarks + ); +} + +function isLandmarkVirtual(virtualNode) { + var { actualNode } = virtualNode; + var landmarkRoles = axe.commons.aria.getRolesByType('landmark'); + var role = axe.commons.aria.getRole(actualNode); + if (!role) { + return false; + } + + var nodeName = actualNode.nodeName.toUpperCase(); + if (nodeName === 'HEADER' || nodeName === 'FOOTER') { + return isHeaderFooterLandmark(virtualNode); + } + + if (nodeName === 'SECTION' || nodeName === 'FORM') { + var accessibleText = axe.commons.text.accessibleTextVirtual(virtualNode); + return !!accessibleText; + } + + return landmarkRoles.indexOf(role) >= 0 || role === 'region'; +} + +return isLandmarkVirtual(virtualNode) && axe.commons.dom.isVisible(node, true); diff --git a/lib/rules/landmark-unique.json b/lib/rules/landmark-unique.json new file mode 100644 index 0000000000..3a6a901566 --- /dev/null +++ b/lib/rules/landmark-unique.json @@ -0,0 +1,13 @@ +{ + "id": "landmark-unique", + "selector": "[role=banner], [role=complementary], [role=contentinfo], [role=main], [role=navigation], [role=region], [role=search], [role=form], form, footer, header, aside, main, nav, section", + "tags": ["cat.semantics", "best-practice"], + "metadata": { + "help": "Ensures landmarks are unique", + "description": "Landmarks must have a unique role or role/label/title (i.e. accessible name) combination" + }, + "matches": "landmark-unique-matches.js", + "all": [], + "any": ["landmark-is-unique"], + "none": [] +} diff --git a/test/checks/landmarks/landmark-is-unique-after.js b/test/checks/landmarks/landmark-is-unique-after.js new file mode 100644 index 0000000000..c626ba5e3c --- /dev/null +++ b/test/checks/landmarks/landmark-is-unique-after.js @@ -0,0 +1,82 @@ +describe('landmark-is-unique-after', function() { + 'use strict'; + + var checkContext = axe.testUtils.MockCheckContext(); + function createResult(result, data) { + return { + result: result, + data: data + }; + } + + function createResultWithSameRelatedNodes(result, data) { + return Object.assign(createResult(result, data), { + relatedNodes: [createResult(result, data)] + }); + } + + function createResultWithProvidedRelatedNodes(result, data, relatedNodes) { + return Object.assign(createResult(result, data), { + relatedNodes: relatedNodes + }); + } + + afterEach(function() { + axe._tree = undefined; + checkContext.reset(); + }); + + it('should update duplicate landmarks with failed result', function() { + var result = checks['landmark-is-unique'].after([ + createResultWithSameRelatedNodes(true, { + role: 'some role', + accessibleText: 'some accessibleText' + }), + createResultWithSameRelatedNodes(true, { + role: 'some role', + accessibleText: 'some accessibleText' + }), + createResultWithSameRelatedNodes(true, { + role: 'different role', + accessibleText: 'some accessibleText' + }), + createResultWithSameRelatedNodes(true, { + role: 'some role', + accessibleText: 'different accessibleText' + }) + ]); + + var expectedResult = [ + createResultWithProvidedRelatedNodes( + false, + { + role: 'some role', + accessibleText: 'some accessibleText' + }, + [ + createResult(true, { + role: 'some role', + accessibleText: 'some accessibleText' + }) + ] + ), + createResultWithProvidedRelatedNodes( + true, + { + role: 'different role', + accessibleText: 'some accessibleText' + }, + [] + ), + createResultWithProvidedRelatedNodes( + true, + { + role: 'some role', + accessibleText: 'different accessibleText' + }, + [] + ) + ]; + assert.deepEqual(result, expectedResult); + }); +}); diff --git a/test/checks/landmarks/landmark-is-unique.js b/test/checks/landmarks/landmark-is-unique.js new file mode 100644 index 0000000000..89b896a947 --- /dev/null +++ b/test/checks/landmarks/landmark-is-unique.js @@ -0,0 +1,59 @@ +describe('landmark-is-unique', function() { + 'use strict'; + + var checkContext = new axe.testUtils.MockCheckContext(); + var fixture; + var axeFixtureSetup; + + beforeEach(function() { + fixture = document.getElementById('fixture'); + axeFixtureSetup = axe.testUtils.fixtureSetup; + }); + + afterEach(function() { + axe._tree = undefined; + checkContext.reset(); + }); + + it('should return true, with correct role and no accessible text', function() { + axeFixtureSetup('
test
'); + var node = fixture.querySelector('div'); + var expectedData = { + accessibleText: null, + role: 'main' + }; + axe._tree = axe.utils.getFlattenedTree(fixture); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue( + checks['landmark-is-unique'].evaluate.call( + checkContext, + node, + {}, + virtualNode + ) + ); + assert.deepEqual(checkContext._data, expectedData); + assert.deepEqual(checkContext._relatedNodes, [node]); + }); + + it('should return true, with correct role and the accessible text lowercased', function() { + axeFixtureSetup('
test
'); + var node = fixture.querySelector('div'); + var expectedData = { + accessibleText: 'test text', + role: 'main' + }; + axe._tree = axe.utils.getFlattenedTree(fixture); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue( + checks['landmark-is-unique'].evaluate.call( + checkContext, + node, + {}, + virtualNode + ) + ); + assert.deepEqual(checkContext._data, expectedData); + assert.deepEqual(checkContext._relatedNodes, [node]); + }); +}); diff --git a/test/integration/rules/landmark-unique/frame.html b/test/integration/rules/landmark-unique/frame.html new file mode 100644 index 0000000000..45a8915dca --- /dev/null +++ b/test/integration/rules/landmark-unique/frame.html @@ -0,0 +1,14 @@ + + + + + landmark-unique test + + + +
Second main
+
iframe-form-with-label
+
+
+ + diff --git a/test/integration/rules/landmark-unique/landmark-unique-fail.html b/test/integration/rules/landmark-unique/landmark-unique-fail.html new file mode 100644 index 0000000000..df74c01328 --- /dev/null +++ b/test/integration/rules/landmark-unique/landmark-unique-fail.html @@ -0,0 +1,53 @@ +
First main
+ + +
First header
+
Second header
+ +
+
+ +
form-with-label
+
form-with-label
+
+
+ +
+
+ +
aside-with-label
+
aside-with-label
+
+
+ + + + +
iframe-form-with-label
+
+ + + + + + + + + + + + +
+
+ +
+
+ + + + + + + +
+
\ No newline at end of file diff --git a/test/integration/rules/landmark-unique/landmark-unique-fail.json b/test/integration/rules/landmark-unique/landmark-unique-fail.json new file mode 100644 index 0000000000..6039a02ec3 --- /dev/null +++ b/test/integration/rules/landmark-unique/landmark-unique-fail.json @@ -0,0 +1,23 @@ +{ + "description": "landmark-unique-fail tests", + "rule": "landmark-unique", + "violations": [ + ["#violation-main-1"], + ["#violation-header-1"], + ["#violation-form-aria-label-1"], + ["#violation-form-aria-labelledby-1"], + ["#violation-aside-aria-label-1"], + ["#violation-aside-aria-labelledby-1"], + ["#violation-footer-1"], + ["#violation-form-through-iframe-1"], + ["#violation-nav-through-iframe-1"], + ["#violation-role-banner"], + ["#violation-role-complementary"], + ["#violation-role-contentinfo"], + ["#violation-role-main"], + ["#violation-role-region"], + ["#violation-role-search"], + ["#violation-nav"], + ["#violation-section"] + ] +} diff --git a/test/integration/rules/landmark-unique/landmark-unique-pass.html b/test/integration/rules/landmark-unique/landmark-unique-pass.html new file mode 100644 index 0000000000..386b1ee361 --- /dev/null +++ b/test/integration/rules/landmark-unique/landmark-unique-pass.html @@ -0,0 +1,21 @@ +
Only main
+ +
Only header
+ +
+
+ +
form-with-label-1
+
form-with-label-2
+
+
+ +
+
+ +
aside-with-label-1
+
aside-with-label-2
+
+
+ + diff --git a/test/integration/rules/landmark-unique/landmark-unique-pass.json b/test/integration/rules/landmark-unique/landmark-unique-pass.json new file mode 100644 index 0000000000..033484a36e --- /dev/null +++ b/test/integration/rules/landmark-unique/landmark-unique-pass.json @@ -0,0 +1,17 @@ +{ + "description": "landmark-unique-pass tests", + "rule": "landmark-unique", + "passes": [ + ["#pass-main"], + ["#pass-header"], + ["#pass-form-aria-label-1"], + ["#pass-form-aria-label-2"], + ["#pass-form-aria-labelledby-1"], + ["#pass-form-aria-labelledby-2"], + ["#pass-aside-aria-label-1"], + ["#pass-aside-aria-label-2"], + ["#pass-aside-aria-labelledby-1"], + ["#pass-aside-aria-labelledby-2"], + ["#pass-footer"] + ] +} diff --git a/test/rule-matches/landmark-unique-matches.js b/test/rule-matches/landmark-unique-matches.js new file mode 100644 index 0000000000..4364b11a43 --- /dev/null +++ b/test/rule-matches/landmark-unique-matches.js @@ -0,0 +1,201 @@ +describe('landmark-unique-matches', function() { + 'use strict'; + var rule; + var fixture; + var axeFixtureSetup; + var shadowSupport = axe.testUtils.shadowSupport.v1; + var excludedDescendantsForHeadersFooters = [ + 'article', + 'aside', + 'main', + 'nav', + 'section' + ]; + var headerFooterElements = ['header', 'footer']; + + beforeEach(function() { + fixture = document.getElementById('fixture'); + axeFixtureSetup = axe.testUtils.fixtureSetup; + rule = axe._audit.rules.find(function(rule) { + return rule.id === 'landmark-unique'; + }); + }); + + it('should not match because not a landmark', function() { + axeFixtureSetup('

some heading

'); + var node = fixture.querySelector('h1'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + }); + + it('should pass because is a landmark', function() { + axeFixtureSetup('
some banner
'); + var node = fixture.querySelector('div'); + fixture.appendChild(node); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(rule.matches(node, virtualNode)); + }); + + it('should not match because landmark is hidden', function() { + axeFixtureSetup('
some banner
'); + var node = fixture.querySelector('div'); + node.style.display = 'none'; + fixture.appendChild(node); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + }); + + describe('form and section elements must have accessible names to be matched', function() { + var sectionFormElements = ['section', 'form']; + + sectionFormElements.forEach(function(elementType) { + it( + 'should match because it is a ' + elementType + ' with a label', + function() { + axeFixtureSetup( + '<' + + elementType + + ' aria-label="sample label">some ' + + elementType + + '' + ); + var node = fixture.querySelector(elementType); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(rule.matches(node, virtualNode)); + } + ); + + it( + 'should not match because it is a ' + elementType + ' without a label', + function() { + axeFixtureSetup( + '<' + + elementType + + '>some ' + + elementType + + '' + ); + var node = fixture.querySelector(elementType); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + } + ); + }); + }); + + describe('header/footers should only match when not inside the excluded descendants', function() { + headerFooterElements.forEach(function(elementType) { + excludedDescendantsForHeadersFooters.forEach(function(exclusionType) { + it( + 'should not match because ' + + elementType + + ' is contained inside an ' + + exclusionType, + function() { + axeFixtureSetup( + '<' + + exclusionType + + ' aria-label="sample label"><' + + elementType + + '>an element' + ); + var node = fixture.querySelector(elementType); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isFalse(rule.matches(node, virtualNode)); + } + ); + }); + + it( + 'should match because ' + + elementType + + ' is not contained inside the excluded descendants', + function() { + axeFixtureSetup( + '<' + elementType + '>an element' + ); + var node = fixture.querySelector(elementType); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(rule.matches(node, virtualNode)); + } + ); + }); + }); + + if (shadowSupport) { + it('return true for landmarks contained within shadow dom', function() { + var container = document.createElement('div'); + var shadow = container.attachShadow({ mode: 'open' }); + shadow.innerHTML = ''; + + axeFixtureSetup(container); + var vNode = axe.utils.querySelectorAll(axe._tree[0], 'footer')[0]; + assert.isTrue(rule.matches(vNode.actualNode, vNode)); + }); + + describe('header/footers should only match when not inside the excluded descendants within shadow dom', function() { + var container; + var shadow; + + beforeEach(function() { + container = document.createElement('div'); + shadow = container.attachShadow({ mode: 'open' }); + }); + + headerFooterElements.forEach(function(elementType) { + excludedDescendantsForHeadersFooters.forEach(function(exclusionType) { + it( + 'should not match because ' + + elementType + + ' is contained inside an ' + + exclusionType + + '', + function() { + shadow.innerHTML = + '<' + + exclusionType + + ' aria-label="sample label"><' + + elementType + + '>an element'; + + axeFixtureSetup(container); + var virtualNode = axe.utils.querySelectorAll( + axe._tree[0], + elementType + )[0]; + assert.isFalse(rule.matches(virtualNode.actualNode, virtualNode)); + } + ); + }); + + it( + 'should match because ' + + elementType + + ' is not contained inside the excluded descendants', + function() { + shadow.innerHTML = + '<' + elementType + '>an element'; + axeFixtureSetup(container); + var virtualNode = axe.utils.querySelectorAll( + axe._tree[0], + elementType + )[0]; + assert.isTrue(rule.matches(virtualNode.actualNode, virtualNode)); + } + ); + }); + }); + } +});