Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sd/region check #450

Merged
merged 5 commits into from
Jul 20, 2017
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a comment about how this code works?

.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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo in the comment: it => is

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>');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this DIV have a role on it for aria-label to be considered content? It isn't exposed consistently for non-interactive elements or landmarks in my experience

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't matter for the test, but I think you're right. Adding role="img".


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 () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh, good one.

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>';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The href is inside the shadow DOM, but the IDref is in the light DOM...? How does that work?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The href attribute isn't an IDREF attribute. It's a URL, which can only point to light DOM IDs/names. Pretty sure longdesc can't point to things inside shadow DOM for the same reason either.

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 () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo: noRecurstion

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
18 changes: 18 additions & 0 deletions test/testutils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
var testUtils = {};

testUtils.MockCheckContext = function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

'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