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
+
+
+
+
+
+
+
+
+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
+
+
+
+
+
+
+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 +
+ '' +
+ 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 +
+ '' +
+ 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' +
+ elementType +
+ '>' +
+ exclusionType +
+ '>'
+ );
+ 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' + elementType + '>'
+ );
+ 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' +
+ elementType +
+ '>' +
+ exclusionType +
+ '>';
+
+ 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' + elementType + '>';
+ axeFixtureSetup(container);
+ var virtualNode = axe.utils.querySelectorAll(
+ axe._tree[0],
+ elementType
+ )[0];
+ assert.isTrue(rule.matches(virtualNode.actualNode, virtualNode));
+ }
+ );
+ });
+ });
+ }
+});