diff --git a/lib/checks/visibility/hidden-content.js b/lib/checks/visibility/hidden-content.js index 516267f70f..37ebcdbe48 100644 --- a/lib/checks/visibility/hidden-content.js +++ b/lib/checks/visibility/hidden-content.js @@ -1,13 +1,14 @@ -let styles = window.getComputedStyle(node); +const whitelist = ['SCRIPT', 'HEAD', 'TITLE', 'NOSCRIPT', 'STYLE', 'TEMPLATE']; +if (!whitelist.includes(node.tagName.toUpperCase()) && + axe.commons.dom.hasContent(virtualNode)) { -let whitelist = ['SCRIPT', 'HEAD', 'TITLE', 'NOSCRIPT', 'STYLE', 'TEMPLATE']; -if (!whitelist.includes(node.tagName.toUpperCase()) && axe.commons.dom.hasContent(node)) { + const styles = window.getComputedStyle(node); if (styles.getPropertyValue('display') === 'none') { return undefined; } else if (styles.getPropertyValue('visibility') === 'hidden') { - if (node.parentNode) { - var parentStyle = window.getComputedStyle(node.parentNode); - } + // Check if visibility isn't inherited + const parent = axe.commons.dom.getComposedParent(node); + const parentStyle = parent && window.getComputedStyle(parent); if (!parentStyle || parentStyle.getPropertyValue('visibility') !== 'hidden') { return undefined; } diff --git a/lib/commons/dom/find-up.js b/lib/commons/dom/find-up.js index 0b0714cb3c..06e20ffe05 100644 --- a/lib/commons/dom/find-up.js +++ b/lib/commons/dom/find-up.js @@ -22,11 +22,8 @@ dom.findUp = function (element, target) { return null; } - parent = (element.assignedSlot) ? element.assignedSlot : element.parentNode; - if (parent.nodeType === 11) { - parent = parent.host; - } // recursively walk up the DOM, checking each parent node + parent = dom.getComposedParent(element); while (parent && matches.indexOf(parent) === -1) { parent = (parent.assignedSlot) ? parent.assignedSlot : parent.parentNode; if (parent && parent.nodeType === 11) { diff --git a/lib/commons/dom/get-composed-parent.js b/lib/commons/dom/get-composed-parent.js new file mode 100644 index 0000000000..c5cfc74339 --- /dev/null +++ b/lib/commons/dom/get-composed-parent.js @@ -0,0 +1,19 @@ +/*global dom */ +/** + * Get an element's parent in the composed tree + * @param DOMNode Element + * @return DOMNode Parent element + */ +dom.getComposedParent = function getComposedParent (element) { + if (element.assignedSlot) { + return element.assignedSlot; // content of a shadow DOM slot + } else if (element.parentNode) { + var parentNode = element.parentNode; + if (parentNode.nodeType === 1) { + return parentNode; // Regular node + } else if (parentNode.host) { + return parentNode.host; // Shadow root + } + } + return null; // Root node +}; diff --git a/lib/commons/dom/has-content.js b/lib/commons/dom/has-content.js index 5581018d19..415c9ff3ab 100644 --- a/lib/commons/dom/has-content.js +++ b/lib/commons/dom/has-content.js @@ -1,4 +1,17 @@ -/*global dom, aria, axe */ +/*global dom, aria */ +const hiddenTextElms = [ + 'HEAD', 'TITLE', 'TEMPLATE', 'SCRIPT','STYLE', + 'IFRAME', 'OBJECT', 'VIDEO', 'AUDIO', 'NOSCRIPT' +]; + +function hasChildTextNodes (elm) { + if (!hiddenTextElms.includes(elm.actualNode.nodeName.toUpperCase())) { + return elm.children.some(({ actualNode }) => ( + actualNode.nodeType === 3 && actualNode.nodeValue.trim() + )); + } +} + /** * Check that the element has visible content * in the form of either text, an aria-label or visual content such as image @@ -6,22 +19,17 @@ * @param {Object} virtual DOM node * @return boolean */ -dom.hasContent = function hasContent(elm) { - if ( - elm.actualNode.textContent.trim() || - aria.label(elm) - ) { - return true; - } - - const contentElms = axe.utils.querySelectorAll(elm, '*'); - for (let i = 0; i < contentElms.length; i++) { - if ( - aria.label(contentElms[i]) || - dom.isVisualContent(contentElms[i].actualNode) - ) { - return true; - } - } - return false; +dom.hasContent = function hasContent (elm) { + return ( + // It has text + hasChildTextNodes(elm) || + // It is a graphical element + dom.isVisualContent(elm.actualNode) || + // It has an ARIA label + !!aria.label(elm) || + // or one of it's descendants does + elm.children.some(child => ( + child.actualNode.nodeType === 1 && dom.hasContent(child) + )) + ); }; diff --git a/lib/commons/dom/is-visible.js b/lib/commons/dom/is-visible.js index 60add9d77a..6318e2b8e8 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -65,11 +65,9 @@ dom.isVisible = function (el, screenReader, recursed) { } parent = (el.assignedSlot) ? el.assignedSlot : el.parentNode; - if (parent) { return dom.isVisible(parent, screenReader, true); } return false; - }; diff --git a/test/checks/visibility/hidden-content.js b/test/checks/visibility/hidden-content.js index ed836ac66e..f563f57ed2 100644 --- a/test/checks/visibility/hidden-content.js +++ b/test/checks/visibility/hidden-content.js @@ -1,8 +1,9 @@ +/* global xit */ describe('hidden content', function () { 'use strict'; - var fixture = document.getElementById('fixture'); - + var fixture = document.getElementById('fixture'); + var shadowSupport = document.body && typeof document.body.attachShadow === 'function'; var checkContext = { _data: null, data: function (d) { @@ -10,37 +11,71 @@ describe('hidden content', function () { } }; + function checkSetup (html, options, target) { + fixture.innerHTML = html; + axe._tree = axe.utils.getFlattenedTree(fixture); + var node = fixture.querySelector(target || '#target'); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + return [node, options, virtualNode]; + } + afterEach(function () { fixture.innerHTML = ''; checkContext._data = null; + axe._tree = undefined; }); it('should return undefined with display:none and children', function () { - fixture.innerHTML = ''; - var node = fixture.querySelector('#target'); - assert.isUndefined(checks['hidden-content'].evaluate.call(checkContext, node)); + var params = checkSetup(''); + assert.isUndefined(checks['hidden-content'].evaluate.apply(checkContext, params)); }); it('should return undefined with visibility:hidden and children', function () { - fixture.innerHTML = ''; - var node = fixture.querySelector('#target'); - assert.isUndefined(checks['hidden-content'].evaluate.call(checkContext, node)); + var params = checkSetup(''); + assert.isUndefined(checks['hidden-content'].evaluate.apply(checkContext, params)); }); it('should return true with visibility:hidden and parent with visibility:hidden', function () { - fixture.innerHTML = '
'; - var node = fixture.querySelector('#target'); - assert.isTrue(checks['hidden-content'].evaluate.call(checkContext, node)); + var params = checkSetup('
'); + assert.isTrue(checks['hidden-content'].evaluate.apply(checkContext, params)); }); it('should return true with aria-hidden and no content', function () { - fixture.innerHTML = ''; - var node = fixture.querySelector('#target'); - assert.isTrue(checks['hidden-content'].evaluate.call(checkContext, node)); + var params = checkSetup(''); + assert.isTrue(checks['hidden-content'].evaluate.apply(checkContext, params)); }); it('should skip whitelisted elements', function () { var node = document.querySelector('head'); - assert.isTrue(checks['hidden-content'].evaluate.call(checkContext, node)); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); + var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); + assert.isTrue(checks['hidden-content'].evaluate(node, undefined, virtualNode)); + }); + + (shadowSupport ? it : xit)('works on elements in a shadow DOM', function () { + fixture.innerHTML = '
text
'; + var shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ''; + axe._tree = axe.utils.getFlattenedTree(fixture); + + var shadow = document.querySelector('#shadow'); + var virtualShadow = axe.utils.getNodeFromTree(axe._tree[0], shadow); + assert.isTrue( + checks['hidden-content'].evaluate(shadow, undefined, virtualShadow) + ); + + var target = shadowRoot.querySelector('#target'); + var virtualTarget = axe.utils.getNodeFromTree(axe._tree[0], target); + assert.isUndefined( + checks['hidden-content'].evaluate(target, undefined, virtualTarget) + ); + + var content = document.querySelector('#content'); + var virtualContent = axe.utils.getNodeFromTree(axe._tree[0], content); + assert.isTrue( + checks['hidden-content'].evaluate(content, undefined, virtualContent) + ); }); }); diff --git a/test/commons/dom/get-composed-parent.js b/test/commons/dom/get-composed-parent.js new file mode 100644 index 0000000000..4dfd41a230 --- /dev/null +++ b/test/commons/dom/get-composed-parent.js @@ -0,0 +1,72 @@ +/* global xit */ +describe('dom.getComposedParent', function () { + 'use strict'; + var getComposedParent = axe.commons.dom.getComposedParent; + var fixture = document.getElementById('fixture'); + var shadowSupport = document.body && typeof document.body.attachShadow === 'function'; + + afterEach(function () { + fixture.innerHTML = ''; + }); + + it('returns the parentNode normally', function () { + fixture.innerHTML = '
'; + + var actual = getComposedParent(document.getElementById('target')); + assert.instanceOf(actual, Node); + assert.equal(actual, document.getElementById('parent')); + }); + + it('returns null from the documentElement', function () { + assert.isNull( + getComposedParent(document.documentElement) + ); + }); + + (shadowSupport ? it : xit)('returns the slot node for slotted content', function () { + fixture.innerHTML = '
'; + var shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '
' + + '' + + '
'; + + var actual = getComposedParent(fixture.querySelector('#target')); + assert.instanceOf(actual, Node); + assert.equal(actual, shadowRoot.querySelector('#parent')); + }); + + (shadowSupport ? it : xit)('returns explicitly slotted nodes', function () { + fixture.innerHTML = '
'; + var shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '
' + + '' + + '' + + '
'; + + var actual = getComposedParent(fixture.querySelector('#target')); + assert.instanceOf(actual, Node); + assert.equal(actual, shadowRoot.querySelector('#parent')); + }); + + (shadowSupport ? it : xit)('returns elements within a shadow tree', function () { + fixture.innerHTML = '
content
'; + var shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '
' + + '' + + '
'; + + var actual = getComposedParent(shadowRoot.querySelector('#target')); + assert.instanceOf(actual, Node); + assert.equal(actual, shadowRoot.querySelector('#parent')); + }); + + (shadowSupport ? it : xit)('returns the host when it reaches the shadow root', function () { + fixture.innerHTML = '
content
'; + var shadowRoot = document.getElementById('parent').attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '
'; + + var actual = getComposedParent(shadowRoot.querySelector('#target')); + assert.instanceOf(actual, Node); + assert.equal(actual, fixture.querySelector('#parent')); + }); +}); diff --git a/test/commons/dom/has-content.js b/test/commons/dom/has-content.js index 6d9e20bcc9..315956bafd 100644 --- a/test/commons/dom/has-content.js +++ b/test/commons/dom/has-content.js @@ -1,7 +1,9 @@ +/* global xit */ describe('dom.hasContent', function () { 'use strict'; var hasContent = axe.commons.dom.hasContent; var fixture = document.getElementById('fixture'); + var shadowSupport = document.body && typeof document.body.attachShadow === 'function'; var tree; it('returns false if there is no content', function () { @@ -51,4 +53,36 @@ describe('dom.hasContent', function () { hasContent(axe.utils.querySelectorAll(tree, '#target')[0]) ); }); + + it('is false if the element does not show text', function () { + fixture.innerHTML = ''; + tree = axe.utils.getFlattenedTree(fixture); + assert.isFalse( + hasContent(axe.utils.querySelectorAll(tree, '#target')[0]) + ); + }); + + (shadowSupport ? it : xit)('looks at content of shadow dom elements', function () { + fixture.innerHTML = '
'; + var shadow = fixture.querySelector('#target').attachShadow({ mode: 'open' }); + shadow.innerHTML = 'Some text'; + tree = axe.utils.getFlattenedTree(fixture); + + assert.isTrue( + hasContent(axe.utils.querySelectorAll(tree, '#target')[0]) + ); + }); + + (shadowSupport ? it : xit)('looks at the slots in a shadow tree', function () { + fixture.innerHTML = '
some text
'; + var shadow = fixture.querySelector('#shadow').attachShadow({ mode: 'open' }); + shadow.innerHTML = '
'; + tree = axe.utils.getFlattenedTree(fixture); + var node = axe.utils.querySelectorAll(tree, '.target'); + + axe.log(tree, node); + assert.isTrue( + hasContent(axe.utils.querySelectorAll(tree, '.target')[0]) + ); + }); });