Skip to content

Commit 642c8f1

Browse files
authored
fix(skip-link,region): Allow multiple skiplinks at page top (#1496)
* fix(skip-link,region): improve defenition of skip-link * fix selector * add test * use check test * redelete unneeded file
1 parent 3babcb6 commit 642c8f1

File tree

13 files changed

+277
-73
lines changed

13 files changed

+277
-73
lines changed

lib/checks/navigation/region.js

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,11 @@
11
const { dom, aria } = axe.commons;
2-
3-
// Return the skplink, if any
4-
function getSkiplink(virtualNode) {
5-
const firstLink = axe.utils.querySelectorAll(virtualNode, 'a[href]')[0];
6-
if (
7-
firstLink &&
8-
axe.commons.dom.getElementByReference(firstLink.actualNode, 'href')
9-
) {
10-
return firstLink.actualNode;
11-
}
12-
}
13-
14-
const skipLink = getSkiplink(virtualNode);
152
const landmarkRoles = aria.getRolesByType('landmark');
163

174
// Create a list of nodeNames that have a landmark as an implicit role
185
const implicitLandmarks = landmarkRoles
196
.reduce((arr, role) => arr.concat(aria.implicitNodes(role)), [])
207
.filter(r => r !== null);
218

22-
// Check if the current element is the skiplink
23-
function isSkipLink(vNode) {
24-
return skipLink && skipLink === vNode.actualNode;
25-
}
26-
279
// Check if the current element is a landmark
2810
function isRegion(virtualNode) {
2911
const node = virtualNode.actualNode;
@@ -61,7 +43,8 @@ function findRegionlessElms(virtualNode) {
6143
// End recursion if the element is a landmark, skiplink, or hidden content
6244
if (
6345
isRegion(virtualNode) ||
64-
isSkipLink(virtualNode) ||
46+
(dom.isSkipLink(virtualNode.actualNode) &&
47+
dom.getElementByReference(virtualNode.actualNode, 'href')) ||
6548
!dom.isVisible(node, true)
6649
) {
6750
return [];

lib/commons/dom/is-skip-link.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/* global dom */
2+
3+
// test for hrefs that start with # or /# (for angular)
4+
const isInternalLinkRegex = /^\/?#[^/!]/;
5+
6+
/**
7+
* Determines if element is a skip link
8+
* @method isSkipLink
9+
* @memberof axe.commons.dom
10+
* @instance
11+
* @param {Element} element
12+
* @return {Boolean}
13+
*/
14+
dom.isSkipLink = function(element) {
15+
if (!isInternalLinkRegex.test(element.getAttribute('href'))) {
16+
return false;
17+
}
18+
19+
// define a skip link as any anchor element whose href starts with `#...`
20+
// and which precedes the first anchor element whose href doesn't start
21+
// with `#...` (that is, a link to a page)
22+
const firstPageLink = axe.utils.querySelectorAll(
23+
axe._tree,
24+
'a:not([href^="#"]):not([href^="/#"]):not([href^="javascript"])'
25+
)[0];
26+
27+
// if there are no page links then all all links will need to be
28+
// considered as skip links
29+
if (!firstPageLink) {
30+
return true;
31+
}
32+
33+
return (
34+
element.compareDocumentPosition(firstPageLink.actualNode) ===
35+
element.DOCUMENT_POSITION_FOLLOWING
36+
);
37+
};

lib/rules/skip-link-matches.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
return /^#[^/!]/.test(node.getAttribute('href'));
1+
return axe.commons.dom.isSkipLink(node);

lib/rules/skip-link.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"id": "skip-link",
3-
"selector": "a[href]",
3+
"selector": "a[href^=\"#\"], a[href^=\"/#\"]",
44
"matches": "skip-link-matches.js",
55
"tags": ["cat.keyboard", "best-practice"],
66
"metadata": {

test/checks/navigation/skip-link.js

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,28 @@ describe('skip-link', function() {
77
fixture.innerHTML = '';
88
});
99

10-
it('should return false if the href points to another document', function() {
11-
fixture.innerHTML =
12-
'<a href="something.html#mainheader">Click Here</a><h1 id="mainheader">Introduction</h1>';
13-
var node = fixture.querySelector('a');
14-
assert.isFalse(checks['skip-link'].evaluate(node));
15-
});
16-
17-
it('should return false if the href points to a non-existent element', function() {
18-
fixture.innerHTML =
19-
'<a href="#spacecamp">Click Here</a><h1 id="mainheader">Introduction</h1>';
20-
var node = fixture.querySelector('a');
21-
assert.isFalse(checks['skip-link'].evaluate(node));
22-
});
23-
2410
it('should return true if the href points to an element with an ID', function() {
2511
fixture.innerHTML =
2612
'<a href="#target">Click Here</a><h1 id="target">Introduction</h1>';
13+
axe._tree = axe.utils.getFlattenedTree(fixture);
2714
var node = fixture.querySelector('a');
2815
assert.isTrue(checks['skip-link'].evaluate(node));
2916
});
3017

3118
it('should return true if the href points to an element with an name', function() {
3219
fixture.innerHTML = '<a href="#target">Click Here</a><a name="target"></a>';
20+
axe._tree = axe.utils.getFlattenedTree(fixture);
3321
var node = fixture.querySelector('a');
3422
assert.isTrue(checks['skip-link'].evaluate(node));
3523
});
3624

25+
it('should return false if the href points to a non-existent element', function() {
26+
fixture.innerHTML =
27+
'<a href="#spacecamp">Click Here</a><h1 id="mainheader">Introduction</h1>';
28+
var node = fixture.querySelector('a');
29+
assert.isFalse(checks['skip-link'].evaluate(node));
30+
});
31+
3732
it('should return undefined if the target has display:none', function() {
3833
fixture.innerHTML =
3934
'<a href="#target">Click Here</a>' +
@@ -49,18 +44,4 @@ describe('skip-link', function() {
4944
var node = fixture.querySelector('a');
5045
assert.isUndefined(checks['skip-link'].evaluate(node));
5146
});
52-
53-
it('should return true if the URI encoded href points to an element with an ID', function() {
54-
fixture.innerHTML =
55-
'<a href="#%3Ctarget%3E">Click Here</a><h1 id="&lt;target&gt;">Introduction</h1>';
56-
var node = fixture.querySelector('a');
57-
assert.isTrue(checks['skip-link'].evaluate(node));
58-
});
59-
60-
it('should return true if the URI is an Angular skiplink', function() {
61-
fixture.innerHTML =
62-
'<a href="/#target">Click Here</a><h1 id="target">Introduction</h1>';
63-
var node = fixture.querySelector('a');
64-
assert.isTrue(checks['skip-link'].evaluate(node));
65-
});
6647
});

test/commons/dom/is-skip-link.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
describe('dom.isSkipLink', function() {
2+
'use strict';
3+
4+
var fixture = document.getElementById('fixture');
5+
6+
afterEach(function() {
7+
fixture.innerHTML = '';
8+
});
9+
10+
it('should return true if the href points to an ID', function() {
11+
fixture.innerHTML = '<a href="#target">Click Here</a>';
12+
axe._tree = axe.utils.getFlattenedTree(fixture);
13+
var node = fixture.querySelector('a');
14+
assert.isTrue(axe.commons.dom.isSkipLink(node));
15+
});
16+
17+
it('should return false if the href points to another document', function() {
18+
fixture.innerHTML = '<a href="something.html#target">Click Here</a>';
19+
axe._tree = axe.utils.getFlattenedTree(fixture);
20+
var node = fixture.querySelector('a');
21+
assert.isFalse(axe.commons.dom.isSkipLink(node));
22+
});
23+
24+
it('should return true if the URI encoded href points to an element with an ID', function() {
25+
fixture.innerHTML = '<a href="#%3Ctarget%3E">Click Here</a>';
26+
axe._tree = axe.utils.getFlattenedTree(fixture);
27+
var node = fixture.querySelector('a');
28+
assert.isTrue(axe.commons.dom.isSkipLink(node));
29+
});
30+
31+
it('should return true if the URI is an Angular skiplink', function() {
32+
fixture.innerHTML = '<a href="/#target">Click Here</a>';
33+
axe._tree = axe.utils.getFlattenedTree(fixture);
34+
var node = fixture.querySelector('a');
35+
assert.isTrue(axe.commons.dom.isSkipLink(node));
36+
});
37+
38+
it('should return true for multiple skip-links', function() {
39+
fixture.innerHTML =
40+
'<a id="skip-link1" href="#target1">Click Here></a><a id="skip-link2" href="/#target2">Click Here></a><a id="skip-link3" href="#target3">Click Here></a>';
41+
axe._tree = axe.utils.getFlattenedTree(fixture);
42+
var nodes = fixture.querySelectorAll('a');
43+
for (var i = 0; i < nodes.length; i++) {
44+
assert.isTrue(axe.commons.dom.isSkipLink(nodes[i]));
45+
}
46+
});
47+
48+
it('should return true if the element is before a page link', function() {
49+
fixture.innerHTML =
50+
'<a id="skip-link" href="#target">Click Here></a><a href="/page">New Page</a>';
51+
axe._tree = axe.utils.getFlattenedTree(fixture);
52+
var node = fixture.querySelector('#skip-link');
53+
assert.isTrue(axe.commons.dom.isSkipLink(node));
54+
});
55+
56+
it('should return false if the element is after a page link', function() {
57+
fixture.innerHTML =
58+
'<a href="/page">New Page</a><a id="skip-link" href="#target">Click Here></a>';
59+
axe._tree = axe.utils.getFlattenedTree(fixture);
60+
var node = fixture.querySelector('#skip-link');
61+
assert.isFalse(axe.commons.dom.isSkipLink(node));
62+
});
63+
64+
it('should ignore links that start with `href=javascript`', function() {
65+
fixture.innerHTML =
66+
'<a href="javascript:void">New Page</a><a id="skip-link" href="#target">Click Here></a>';
67+
axe._tree = axe.utils.getFlattenedTree(fixture);
68+
var node = fixture.querySelector('#skip-link');
69+
assert.isTrue(axe.commons.dom.isSkipLink(node));
70+
});
71+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<!DOCTYPE html>
2+
<html lang="en" id="pass1">
3+
<head>
4+
<title>skip-link test</title>
5+
<meta charset="utf8">
6+
<link rel="stylesheet" type="text/css" href="/node_modules/mocha/mocha.css" />
7+
<script src="/node_modules/mocha/mocha.js"></script>
8+
<script src="/node_modules/chai/chai.js"></script>
9+
<script src="/axe.js"></script>
10+
<script src="/test/testutils.js"></script>
11+
<script>
12+
mocha.setup({
13+
timeout: 10000,
14+
ui: 'bdd'
15+
});
16+
var assert = chai.assert;
17+
</script>
18+
</head>
19+
<body>
20+
<a href="#fail1-tgt" id="fail1">bad link 1</a>
21+
<div id="mocha"></div>
22+
<script src="skip-link-fail.js"></script>
23+
<script src="/test/integration/adapter.js"></script>
24+
</body>
25+
</html>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
describe('skip-link test pass', function() {
2+
'use strict';
3+
var results;
4+
5+
before(function(done) {
6+
axe.testUtils.awaitNestedLoad(function() {
7+
axe.run({ runOnly: { type: 'rule', values: ['skip-link'] } }, function(
8+
err,
9+
r
10+
) {
11+
assert.isNull(err);
12+
results = r;
13+
done();
14+
});
15+
});
16+
});
17+
18+
describe('violations', function() {
19+
it('should find 1', function() {
20+
assert.lengthOf(results.violations, 1);
21+
});
22+
23+
it('should find 1 nodes', function() {
24+
assert.lengthOf(results.violations[0].nodes, 1);
25+
});
26+
});
27+
28+
describe('passes', function() {
29+
it('should find 0', function() {
30+
assert.lengthOf(results.passes, 0);
31+
});
32+
});
33+
34+
it('should find 0 inapplicable', function() {
35+
assert.lengthOf(results.inapplicable, 0);
36+
});
37+
38+
it('should find 0 incomplete', function() {
39+
assert.lengthOf(results.incomplete, 0);
40+
});
41+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<!DOCTYPE html>
2+
<html lang="en" id="pass1">
3+
<head>
4+
<title>skip-link test</title>
5+
<meta charset="utf8">
6+
<link rel="stylesheet" type="text/css" href="/node_modules/mocha/mocha.css" />
7+
<script src="/node_modules/mocha/mocha.js"></script>
8+
<script src="/node_modules/chai/chai.js"></script>
9+
<script src="/axe.js"></script>
10+
<script src="/test/testutils.js"></script>
11+
<script>
12+
mocha.setup({
13+
timeout: 10000,
14+
ui: 'bdd'
15+
});
16+
var assert = chai.assert;
17+
</script>
18+
</head>
19+
<body>
20+
21+
<div id="pass1-tgt"></div>
22+
<a href="#pass1-tgt" id="pass1">Link</a>
23+
24+
<a href="#pass2-tgt" id="pass2">Link</a>
25+
26+
<div id="pass3-tgt"></div>
27+
<a href="/#pass3-tgt" id="pass3">Link (angular)</a>
28+
29+
<div id="canttell1-tgt" style="display:none"></div>
30+
<a href="#canttell1-tgt" id="canttell1">Link</a>
31+
32+
<!-- since these elements are page links, they needs to be at the bottom
33+
of the test so all the prior links are considered skip links -->
34+
<a name="pass2-tgt"></a>
35+
36+
<a href="foo#bar" id="ignore1">link</a>
37+
<a href="#" id="ignore2">link</a>
38+
<div id="mocha"></div>
39+
<script src="skip-link-pass.js"></script>
40+
<script src="/test/integration/adapter.js"></script>
41+
</body>
42+
</html>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
describe('skip-link test pass', function() {
2+
'use strict';
3+
var results;
4+
5+
before(function(done) {
6+
axe.testUtils.awaitNestedLoad(function() {
7+
axe.run({ runOnly: { type: 'rule', values: ['skip-link'] } }, function(
8+
err,
9+
r
10+
) {
11+
assert.isNull(err);
12+
results = r;
13+
done();
14+
});
15+
});
16+
});
17+
18+
describe('violations', function() {
19+
it('should find 0', function() {
20+
assert.lengthOf(results.violations, 0);
21+
});
22+
});
23+
24+
describe('passes', function() {
25+
it('should find 3', function() {
26+
assert.lengthOf(results.passes[0].nodes, 3);
27+
});
28+
});
29+
30+
it('should find 0 inapplicable', function() {
31+
assert.lengthOf(results.inapplicable, 0);
32+
});
33+
34+
it('should find 1 incomplete', function() {
35+
assert.lengthOf(results.incomplete, 1);
36+
});
37+
});

0 commit comments

Comments
 (0)