Skip to content

Commit

Permalink
feat: Make region check work with shadow DOM
Browse files Browse the repository at this point in the history
  • Loading branch information
WilcoFiers committed Jul 18, 2017
1 parent 02daad1 commit ecd222f
Show file tree
Hide file tree
Showing 6 changed files with 179 additions and 75 deletions.
69 changes: 43 additions & 26 deletions lib/checks/navigation/region.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,51 @@
//jshint latedef: false
const { dom, aria } = axe.commons;
function getSkiplink (virtualNode) {
const firstLink = axe.utils.querySelectorAll(virtualNode, 'a[href]')[0];
if (firstLink && axe.commons.dom.getElementByReference(firstLink.actualNode, 'href')) {
return firstLink.actualNode;
}
}

var landmarkRoles = axe.commons.aria.getRolesByType('landmark'),
firstLink = node.querySelector('a[href]');
const skipLink = getSkiplink(virtualNode);
const landmarkRoles = aria.getRolesByType('landmark');
const implicitLandmarks = landmarkRoles
.reduce((arr, role) => arr.concat(aria.implicitNodes(role)), [])
.filter(r => r !== null).map(r => r.toUpperCase());

function isSkipLink(n) {
return firstLink &&
axe.commons.dom.isFocusable(axe.commons.dom.getElementByReference(firstLink, 'href')) &&
firstLink === n;
// Check if the current element it the skiplink
function isSkipLink (node) {
return skipLink && skipLink === node;
}

function isLandmark(n) {
var role = n.getAttribute('role');
return role && (landmarkRoles.indexOf(role) !== -1);
// Check if the current element is a landmark
function isLandmark (node) {
const nodeName = node.nodeName.toUpperCase();
return (landmarkRoles.includes(node.getAttribute('role')) ||
implicitLandmarks.includes(nodeName));
}

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);
}
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 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;
12 changes: 3 additions & 9 deletions lib/commons/dom/get-element-by-reference.js
Original file line number Diff line number Diff line change
@@ -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;
};
6 changes: 3 additions & 3 deletions lib/commons/dom/has-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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)
))
)))
);
};
130 changes: 94 additions & 36 deletions test/checks/navigation/region.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div id="target"><div role="main"><a href="a.html#mainheader">Click Here</a><div><h1 id="mainheader" tabindex="0">Introduction</h1></div></div></div>';
var node = fixture.querySelector('#target');
assert.isTrue(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><div role="main"><a href="a.html#mainheader">Click Here</a><div><h1 id="mainheader" tabindex="0">Introduction</h1></div></div></div>');

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 = '<div id="target"><img src="data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7"><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>';
var node = fixture.querySelector('#target');
assert.isFalse(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><img src="data:image/gif;base64,R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl3/zy7////wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAkAABAALAAAAAAQABAAAAVVICSOZGlCQAosJ6mu7fiyZeKqNKToQGDsM8hBADgUXoGAiqhSvp5QAnQKGIgUhwFUYLCVDFCrKUE1lBavAViFIDlTImbKC5Gm2hB0SlBCBMQiB0UjIQA7"><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>');

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 = '<div id="target"><p></p><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>';
var node = fixture.querySelector('#target');
assert.isTrue(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><p></p><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>');

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 = '<div id="target"><div><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div></div>';
var node = fixture.querySelector('#target');
assert.isTrue(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><div><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div></div>');

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 = '<div id="target"><p style="display: none">Click Here</p><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>';
var node = fixture.querySelector('#target');
assert.isTrue(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><p style="display: none">Click Here</p><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>');

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 = '<div id="target"><a href="#mainheader">Click Here</a><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>';
var node = fixture.querySelector('#target');
assert.isTrue(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><a href="#mainheader">Click Here</a><div role="main"><h1 id="mainheader" tabindex="0">Introduction</h1></div></div>');

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 = '<div id="target"><div>This is random content.</div><div role="main"><h1 id="mainheader">Introduction</h1></div></div>';
var node = fixture.querySelector('#target');
assert.isFalse(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><div>This is random content.</div><div role="main"><h1 id="mainheader">Introduction</h1></div></div>');

assert.isFalse(checks.region.evaluate.apply(checkContext, checkArgs));
assert.equal(checkContext._relatedNodes.length, 1);
});

Expand All @@ -74,9 +66,75 @@ describe('region', function () {
});

it('should return false when there is a non-skiplink', function () {
fixture.innerHTML = '<div id="target"><a href="something.html#mainheader">Click Here</a><div role="main"><h1 id="mainheader">Introduction</h1></div></div>';
var node = fixture.querySelector('#target');
assert.isFalse(checks.region.evaluate.call(checkContext, node));
var checkArgs = checkSetup('<div id="target"><a href="something.html#mainheader">Click Here</a><div role="main"><h1 id="mainheader">Introduction</h1></div></div>');

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('<div id="target"><script>axe.run()</script><div role="main">Content</div></div>');

assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs));
});

it('should considered aria labelled elements as content', function () {
var checkArgs = checkSetup('<div id="target"><div aria-label="axe-core logo"></div><div role="main">Content</div></div>');

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('<div id="target"><header>branding</header><main>Content </main><aside>stuff</aside><footer>copyright</footer></div>');

assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs));
assert.lengthOf(checkContext._relatedNodes, 0);
});

(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 = '<div role="main"><slot></slot></div>';
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 = '<a href="#foo">skiplink</a><div>Content</div>';

var shadow = div.querySelector('div').attachShadow({ mode: 'open' });
shadow.innerHTML = '<div role="main" id=#foo"><slot></slot></div>';
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 = '<span id="foo">Content!</span>';
var shadow = div.attachShadow({ mode: 'open' });
shadow.innerHTML = '<a href="#foo">skiplink</a><div role="main"><slot></slot></div>';
var checkArgs = checkSetup(div);

assert.isTrue(checks.region.evaluate.apply(checkContext, checkArgs));
assert.lengthOf(checkContext._relatedNodes, 0);
});
});
7 changes: 7 additions & 0 deletions test/commons/dom/has-content.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ describe('dom.hasContent', function () {
);
});

it('is false if noRecurstion is true and the content is not in a child', function () {
fixture.innerHTML = '<div id="target"><span> text </span></div>';
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 = '<div id="target"></div>';
var shadow = fixture.querySelector('#target').attachShadow({ mode: 'open' });
Expand Down
30 changes: 29 additions & 1 deletion test/testutils.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -42,8 +60,18 @@ testUtils.checkSetup = function (content, options, target) {
target = options;
options = {};
}
// Normalize target, allow it to be the inserted node or '#target'
target = target || (content instanceof Node ? content : '#target');
testUtils.fixtureSetup(content);
var node = axe.utils.querySelectorAll(axe._tree[0], target || '#target')[0];

var node;
if (typeof target === 'string') {
node = axe.utils.querySelectorAll(axe._tree[0], target)[0];
} else if (target instanceof Node) {
node = axe.utils.getNodeFromTree(axe._tree[0], target);
} else {
node = target;
}
return [node.actualNode, options, node];
};

Expand Down

0 comments on commit ecd222f

Please sign in to comment.