diff --git a/lib/checks/navigation/region.js b/lib/checks/navigation/region.js index 4443bf535a..a956251dd2 100644 --- a/lib/checks/navigation/region.js +++ b/lib/checks/navigation/region.js @@ -1,34 +1,57 @@ -//jshint latedef: false +const { dom, aria } = axe.commons; -var landmarkRoles = axe.commons.aria.getRolesByType('landmark'), - firstLink = node.querySelector('a[href]'); - -function isSkipLink(n) { - return firstLink && - axe.commons.dom.isFocusable(axe.commons.dom.getElementByReference(firstLink, 'href')) && - firstLink === n; +// Return the skplink, if any +function getSkiplink (virtualNode) { + const firstLink = axe.utils.querySelectorAll(virtualNode, 'a[href]')[0]; + if (firstLink && axe.commons.dom.getElementByReference(firstLink.actualNode, 'href')) { + return firstLink.actualNode; + } } -function isLandmark(n) { - var role = n.getAttribute('role'); - return role && (landmarkRoles.indexOf(role) !== -1); +const skipLink = getSkiplink(virtualNode); +const landmarkRoles = aria.getRolesByType('landmark'); + +// Create a list of nodeNames that have a landmark as an implicit role +const implicitLandmarks = landmarkRoles + .reduce((arr, role) => arr.concat(aria.implicitNodes(role)), []) + .filter(r => r !== null).map(r => r.toUpperCase()); + +// Check if the current element is the skiplink +function isSkipLink (node) { + return skipLink && skipLink === node; } -function checkRegion(n) { - if (isLandmark(n)) { return null; } - if (isSkipLink(n)) { return getViolatingChildren(n); } - if (axe.commons.dom.isVisible(n, true) && - (axe.commons.text.visible(n, true, true) || axe.commons.dom.isVisualContent(n))) { return n; } - return getViolatingChildren(n); +// Check if the current element is a landmark +function isLandmark (node) { + if (node.hasAttribute('role')) { + return landmarkRoles.includes(node.getAttribute('role').toLowerCase()); + } else { + return implicitLandmarks.includes(node.nodeName.toUpperCase()); + } } -function getViolatingChildren(n) { - var children = axe.commons.utils.toArray(n.children); - if (children.length === 0) { return []; } - return children.map(checkRegion) - .filter(function (c) { return c !== null; }) - .reduce(function (a, b) { return a.concat(b); }, []); + +/** + * Find all visible elements not wrapped inside a landmark or skiplink + */ +function findRegionlessElms (virtualNode) { + const node = virtualNode.actualNode; + // End recursion if the element is a landmark, skiplink, or hidden content + if (isLandmark(node) || isSkipLink(node) || !dom.isVisible(node, true)) { + return []; + + // Return the node is a content element + } else if (dom.hasContent(node, /* noRecursion: */ true)) { + return [node]; + + // Recursively look at all child elements + } else { + return virtualNode.children.filter(({ actualNode }) => actualNode.nodeType === 1) + .map(findRegionlessElms) + .reduce((a, b) => a.concat(b), []); // flatten the results + } } -var v = getViolatingChildren(node); -this.relatedNodes(v); -return !v.length; +var regionlessNodes = findRegionlessElms(virtualNode); +this.relatedNodes(regionlessNodes); + +return regionlessNodes.length === 0; diff --git a/lib/commons/dom/get-element-by-reference.js b/lib/commons/dom/get-element-by-reference.js index 44027aeda3..aa24d35ca1 100644 --- a/lib/commons/dom/get-element-by-reference.js +++ b/lib/commons/dom/get-element-by-reference.js @@ -1,26 +1,20 @@ /*global dom */ dom.getElementByReference = function (node, attr) { - 'use strict'; - - var candidate, - fragment = node.getAttribute(attr), - doc = document; + let fragment = node.getAttribute(attr); if (fragment && fragment.charAt(0) === '#') { fragment = fragment.substring(1); - candidate = doc.getElementById(fragment); + let candidate = document.getElementById(fragment); if (candidate) { return candidate; } - candidate = doc.getElementsByName(fragment); + candidate = document.getElementsByName(fragment); if (candidate.length) { return candidate[0]; } - } - return null; }; \ No newline at end of file diff --git a/lib/commons/dom/has-content.js b/lib/commons/dom/has-content.js index 72bcfb449f..bd5407c033 100644 --- a/lib/commons/dom/has-content.js +++ b/lib/commons/dom/has-content.js @@ -19,7 +19,7 @@ function hasChildTextNodes (elm) { * @param {Object} virtual DOM node * @return boolean */ -dom.hasContent = function hasContent (elm) { +dom.hasContent = function hasContent (elm, noRecursion) { if (!elm.actualNode) { elm = axe.utils.getNodeFromTree(axe._tree[0], elm); } @@ -31,8 +31,8 @@ dom.hasContent = function hasContent (elm) { // It has an ARIA label !!aria.label(elm) || // or one of it's descendants does - elm.children.some(child => ( + (!noRecursion && elm.children.some(child => ( child.actualNode.nodeType === 1 && dom.hasContent(child) - )) + ))) ); }; diff --git a/test/checks/navigation/region.js b/test/checks/navigation/region.js index 8bcad07f35..7a2c35328e 100644 --- a/test/checks/navigation/region.js +++ b/test/checks/navigation/region.js @@ -2,70 +2,62 @@ describe('region', function () { 'use strict'; var fixture = document.getElementById('fixture'); + var shadowSupport = axe.testUtils.shadowSupport; + var checkSetup = axe.testUtils.checkSetup; - var checkContext = { - _relatedNodes: [], - _data: null, - data: function (d) { - this._data = d; - }, - relatedNodes: function (rn) { - this._relatedNodes = rn; - } - }; + var checkContext = new axe.testUtils.MockCheckContext(); afterEach(function () { fixture.innerHTML = ''; - checkContext._relatedNodes = []; - checkContext._data = null; + checkContext.reset(); }); it('should return true when all content is inside the region', function () { - fixture.innerHTML = '
Click Here

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isTrue(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('
Click Here

Introduction

'); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 0); }); it('should return false when img content is outside the region', function () { - fixture.innerHTML = '

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isFalse(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('

Introduction

'); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 1); }); it('should return true when textless text content is outside the region', function () { - fixture.innerHTML = '

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isTrue(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('

Introduction

'); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 0); }); it('should return true when wrapper content is outside the region', function () { - fixture.innerHTML = '

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isTrue(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('

Introduction

'); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 0); }); it('should return true when invisible content is outside the region', function () { - fixture.innerHTML = '

Click Here

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isTrue(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('

Click Here

Introduction

'); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 0); }); it('should return true when there is a skiplink', function () { - fixture.innerHTML = '
Click Here

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isTrue(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('
Click Here

Introduction

'); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 0); }); it('should return false when there is a non-region element', function () { - fixture.innerHTML = '
This is random content.

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isFalse(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('
This is random content.

Introduction

'); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 1); }); @@ -74,9 +66,83 @@ describe('region', function () { }); it('should return false when there is a non-skiplink', function () { - fixture.innerHTML = '
Click Here

Introduction

'; - var node = fixture.querySelector('#target'); - assert.isFalse(checks.region.evaluate.call(checkContext, node)); + var checkArgs = checkSetup('
Click Here

Introduction

'); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); assert.equal(checkContext._relatedNodes.length, 1); }); + + it('should return true if the non-region element is a script', function () { + var checkArgs = checkSetup('
Content
'); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); + }); + + it('should considered aria labelled elements as content', function () { + var checkArgs = checkSetup('
Content
'); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._relatedNodes, [ + fixture.querySelector('div[aria-label]') + ]); + }); + + it('should allow native landmark elements', function () { + var checkArgs = checkSetup('
branding
Content
'); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); + assert.lengthOf(checkContext._relatedNodes, 0); + }); + + it('ignores native landmark elements with an overwriting role', function () { + var checkArgs = checkSetup('
'); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); + assert.lengthOf(checkContext._relatedNodes, 1); + assert.deepEqual(checkContext._relatedNodes, [fixture.querySelector('main')]); + }); + + (shadowSupport.v1 ? it : xit)('should test Shadow tree content', function () { + var div = document.createElement('div'); + var shadow = div.attachShadow({ mode: 'open' }); + shadow.innerHTML = 'Some text'; + var checkArgs = checkSetup(div); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._relatedNodes, [div]); + }); + + (shadowSupport.v1 ? it : xit)('should test slotted content', function () { + var div = document.createElement('div'); + div.innerHTML = 'Some content'; + var shadow = div.attachShadow({ mode: 'open' }); + shadow.innerHTML = '
'; + var checkArgs = checkSetup(div); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); + assert.lengthOf(checkContext._relatedNodes, 0); + }); + + (shadowSupport.v1 ? it : xit)('should ignore skiplink targets inside shadow trees', function () { + var div = document.createElement('div'); + div.innerHTML = 'skiplink
Content
'; + + var shadow = div.querySelector('div').attachShadow({ mode: 'open' }); + shadow.innerHTML = '
'; + var checkArgs = checkSetup(div); + + assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs)); + assert.deepEqual(checkContext._relatedNodes, [div.querySelector('a')]); + }); + + (shadowSupport.v1 ? it : xit)('should find the skiplink in shadow DOM', function () { + var div = document.createElement('div'); + div.innerHTML = 'Content!'; + var shadow = div.attachShadow({ mode: 'open' }); + shadow.innerHTML = 'skiplink
'; + var checkArgs = checkSetup(div); + + assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs)); + assert.lengthOf(checkContext._relatedNodes, 0); + }); }); diff --git a/test/commons/dom/has-content.js b/test/commons/dom/has-content.js index 8b10bcbd88..68c6c6c0a4 100644 --- a/test/commons/dom/has-content.js +++ b/test/commons/dom/has-content.js @@ -77,6 +77,13 @@ describe('dom.hasContent', function () { ); }); + it('is false if noRecursion is true and the content is not in a child', function () { + fixture.innerHTML = '
text
'; + tree = axe.utils.getFlattenedTree(fixture); + + assert.isFalse(hasContent(axe.utils.querySelectorAll(tree, '#target')[0], true)); + }); + (shadowSupport ? it : xit)('looks at content of shadow dom elements', function () { fixture.innerHTML = '
'; var shadow = fixture.querySelector('#target').attachShadow({ mode: 'open' }); diff --git a/test/testutils.js b/test/testutils.js index ab8cfc811c..543597248c 100644 --- a/test/testutils.js +++ b/test/testutils.js @@ -1,5 +1,23 @@ var testUtils = {}; +testUtils.MockCheckContext = function () { + 'use strict'; + return { + _relatedNodes: [], + _data: null, + data: function (d) { + this._data = d; + }, + relatedNodes: function (rn) { + this._relatedNodes = rn; + }, + reset: function () { + this._data = null; + this._relatedNodes = []; + } + }; +}; + testUtils.shadowSupport = (function(document) { 'use strict'; var v0 = document.body && typeof document.body.createShadowRoot === 'function',