From 2beede6e02885333debde9763d62e194dc49944b Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 3 Jul 2017 14:51:46 +0200 Subject: [PATCH 1/6] Fix: Use virtualNode in hidden-content rule --- lib/checks/visibility/hidden-content.js | 2 +- test/checks/visibility/hidden-content.js | 33 ++++++++++++++---------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/checks/visibility/hidden-content.js b/lib/checks/visibility/hidden-content.js index 516267f70f..3daef0a360 100644 --- a/lib/checks/visibility/hidden-content.js +++ b/lib/checks/visibility/hidden-content.js @@ -1,7 +1,7 @@ let styles = window.getComputedStyle(node); let whitelist = ['SCRIPT', 'HEAD', 'TITLE', 'NOSCRIPT', 'STYLE', 'TEMPLATE']; -if (!whitelist.includes(node.tagName.toUpperCase()) && axe.commons.dom.hasContent(node)) { +if (!whitelist.includes(node.tagName.toUpperCase()) && axe.commons.dom.hasContent(virtualNode)) { if (styles.getPropertyValue('display') === 'none') { return undefined; } else if (styles.getPropertyValue('visibility') === 'hidden') { diff --git a/test/checks/visibility/hidden-content.js b/test/checks/visibility/hidden-content.js index ed836ac66e..32e10e5203 100644 --- a/test/checks/visibility/hidden-content.js +++ b/test/checks/visibility/hidden-content.js @@ -10,37 +10,44 @@ 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.call(checkContext, node, undefined, virtualNode)); }); }); From 99e8b737f679395692d5572ce79ccb64c9321d82 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Tue, 4 Jul 2017 13:51:32 +0200 Subject: [PATCH 2/6] fix: whitespace in hidden-content test --- test/checks/visibility/hidden-content.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/checks/visibility/hidden-content.js b/test/checks/visibility/hidden-content.js index 32e10e5203..89c04d7070 100644 --- a/test/checks/visibility/hidden-content.js +++ b/test/checks/visibility/hidden-content.js @@ -1,7 +1,7 @@ describe('hidden content', function () { 'use strict'; - var fixture = document.getElementById('fixture'); + var fixture = document.getElementById('fixture'); var checkContext = { _data: null, @@ -12,7 +12,7 @@ describe('hidden content', function () { function checkSetup (html, options, target) { fixture.innerHTML = html; - axe._tree = axe.utils.getFlattenedTree(fixture); + 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]; @@ -25,17 +25,17 @@ describe('hidden content', function () { }); it('should return undefined with display:none and children', function () { - var params = checkSetup(''); + var params = checkSetup(''); assert.isUndefined(checks['hidden-content'].evaluate.apply(checkContext, params)); }); it('should return undefined with visibility:hidden and children', function () { - var params = checkSetup(''); + 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 () { - var params = checkSetup('
'); + var params = checkSetup('
'); assert.isTrue(checks['hidden-content'].evaluate.apply(checkContext, params)); }); @@ -46,7 +46,7 @@ describe('hidden content', function () { it('should skip whitelisted elements', function () { var node = document.querySelector('head'); - axe._tree = axe.utils.getFlattenedTree(document.documentElement); + axe._tree = axe.utils.getFlattenedTree(document.documentElement); var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); assert.isTrue(checks['hidden-content'].evaluate.call(checkContext, node, undefined, virtualNode)); }); From aac57c0378a87282385bb6227b03ea164549327a Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Tue, 4 Jul 2017 17:03:35 +0200 Subject: [PATCH 3/6] feat: Add dom.getComposedParent function --- lib/commons/dom/find-up.js | 5 +- lib/commons/dom/get-composed-parent.js | 19 +++++++ lib/commons/dom/is-visible.js | 3 +- test/commons/dom/get-composed-parent.js | 72 +++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 lib/commons/dom/get-composed-parent.js create mode 100644 test/commons/dom/get-composed-parent.js 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/is-visible.js b/lib/commons/dom/is-visible.js index 60add9d77a..433fdc25b3 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -64,8 +64,7 @@ dom.isVisible = function (el, screenReader, recursed) { return false; } - parent = (el.assignedSlot) ? el.assignedSlot : el.parentNode; - + parent = dom.getComposedParent(el); if (parent) { return dom.isVisible(parent, screenReader, true); } 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')); + }); +}); From 789d62e64dc296009294c45d9e16d5bb0d749b15 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Wed, 5 Jul 2017 18:26:50 +0200 Subject: [PATCH 4/6] feat: Allow hidden-content to work through shadow DOM bounds --- lib/checks/visibility/hidden-content.js | 15 ++++--- lib/commons/dom/has-content.js | 51 +++++++++++++++--------- lib/commons/dom/is-visible.js | 3 +- test/checks/visibility/hidden-content.js | 35 +++++++++++++++- test/commons/dom/has-content.js | 34 ++++++++++++++++ 5 files changed, 110 insertions(+), 28 deletions(-) diff --git a/lib/checks/visibility/hidden-content.js b/lib/checks/visibility/hidden-content.js index 3daef0a360..13358e3aa0 100644 --- a/lib/checks/visibility/hidden-content.js +++ b/lib/checks/visibility/hidden-content.js @@ -1,13 +1,16 @@ -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(virtualNode)) { + 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/has-content.js b/lib/commons/dom/has-content.js index 5581018d19..b70fee41dc 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,24 @@ * @param {Object} virtual DOM node * @return boolean */ -dom.hasContent = function hasContent(elm) { - if ( - elm.actualNode.textContent.trim() || +dom.hasContent = function hasContent (elm) { + /* global console */ + console.log( + elm.actualNode, + hasChildTextNodes(elm), + dom.isVisualContent(elm.actualNode), 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; + ); + 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 433fdc25b3..6318e2b8e8 100644 --- a/lib/commons/dom/is-visible.js +++ b/lib/commons/dom/is-visible.js @@ -64,11 +64,10 @@ dom.isVisible = function (el, screenReader, recursed) { return false; } - parent = dom.getComposedParent(el); + 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 89c04d7070..22fde18476 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 shadowSupport = document.body && typeof document.body.attachShadow === 'function'; var checkContext = { _data: null, data: function (d) { @@ -48,6 +49,36 @@ describe('hidden content', function () { var node = document.querySelector('head'); axe._tree = axe.utils.getFlattenedTree(document.documentElement); var virtualNode = axe.utils.getNodeFromTree(axe._tree[0], node); - assert.isTrue(checks['hidden-content'].evaluate.call(checkContext, node, undefined, virtualNode)); + assert.isTrue(checks['hidden-content'].evaluate(node, undefined, virtualNode)); + }); + + (shadowSupport ? it : xit)('works on elements in a shadow DOM', function () { + /* global console */ + fixture.innerHTML = '
text
'; + var shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = ''; + axe._tree = axe.utils.getFlattenedTree(fixture); + console.log(axe._tree); + + var shadow = document.querySelector('#shadow'); + var virtualShadow = axe.utils.getNodeFromTree(axe._tree[0], shadow); + console.log(virtualShadow, 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/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]) + ); + }); }); From 6a6bd736dfd90dfb41acf0f71f7897bb43116251 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Thu, 6 Jul 2017 12:49:40 +0200 Subject: [PATCH 5/6] fix: Remove log statements --- lib/commons/dom/has-content.js | 7 ------- test/checks/visibility/hidden-content.js | 3 --- 2 files changed, 10 deletions(-) diff --git a/lib/commons/dom/has-content.js b/lib/commons/dom/has-content.js index b70fee41dc..415c9ff3ab 100644 --- a/lib/commons/dom/has-content.js +++ b/lib/commons/dom/has-content.js @@ -20,13 +20,6 @@ function hasChildTextNodes (elm) { * @return boolean */ dom.hasContent = function hasContent (elm) { - /* global console */ - console.log( - elm.actualNode, - hasChildTextNodes(elm), - dom.isVisualContent(elm.actualNode), - aria.label(elm) - ); return ( // It has text hasChildTextNodes(elm) || diff --git a/test/checks/visibility/hidden-content.js b/test/checks/visibility/hidden-content.js index 22fde18476..f563f57ed2 100644 --- a/test/checks/visibility/hidden-content.js +++ b/test/checks/visibility/hidden-content.js @@ -53,18 +53,15 @@ describe('hidden content', function () { }); (shadowSupport ? it : xit)('works on elements in a shadow DOM', function () { - /* global console */ fixture.innerHTML = '
text
'; var shadowRoot = document.getElementById('shadow').attachShadow({ mode: 'open' }); shadowRoot.innerHTML = ''; axe._tree = axe.utils.getFlattenedTree(fixture); - console.log(axe._tree); var shadow = document.querySelector('#shadow'); var virtualShadow = axe.utils.getNodeFromTree(axe._tree[0], shadow); - console.log(virtualShadow, shadow); assert.isTrue( checks['hidden-content'].evaluate(shadow, undefined, virtualShadow) ); From 2211d78c52267845dc7f2b2d003c1a246669cdb0 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Fri, 7 Jul 2017 11:48:28 +0200 Subject: [PATCH 6/6] fix: Adjust if formatting --- lib/checks/visibility/hidden-content.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/checks/visibility/hidden-content.js b/lib/checks/visibility/hidden-content.js index 13358e3aa0..37ebcdbe48 100644 --- a/lib/checks/visibility/hidden-content.js +++ b/lib/checks/visibility/hidden-content.js @@ -1,8 +1,6 @@ const whitelist = ['SCRIPT', 'HEAD', 'TITLE', 'NOSCRIPT', 'STYLE', 'TEMPLATE']; -if ( - !whitelist.includes(node.tagName.toUpperCase()) && - axe.commons.dom.hasContent(virtualNode) -) { +if (!whitelist.includes(node.tagName.toUpperCase()) && + axe.commons.dom.hasContent(virtualNode)) { const styles = window.getComputedStyle(node); if (styles.getPropertyValue('display') === 'none') {