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

fix(skip-link): work with absolute and relative paths #2875

Merged
merged 15 commits into from
Jan 20, 2022
46 changes: 34 additions & 12 deletions lib/commons/dom/is-skip-link.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import cache from '../../core/base/cache';
import { querySelectorAll } from '../../core/utils';

// test for hrefs that start with # or /# (for angular)
const isInternalLinkRegex = /^\/?#[^/!]/;
// angular skip links start with /#
const angularSkipLinkRegex = /^\/\#/;

// angular router link uses #! or #/
const angularRouterLinkRegex = /^#[!/]/;

/**
* Determines if element is a skip link
Expand All @@ -13,22 +16,41 @@ const isInternalLinkRegex = /^\/?#[^/!]/;
* @return {Boolean}
*/
function isSkipLink(element) {
if (!isInternalLinkRegex.test(element.getAttribute('href'))) {
return false;
}

let firstPageLink;
if (typeof cache.get('firstPageLink') !== 'undefined') {
firstPageLink = cache.get('firstPageLink');
} else {
// define a skip link as any anchor element whose href starts with `#...`
// and which precedes the first anchor element whose href doesn't start
// with `#...` (that is, a link to a page)
// define a skip link as any anchor element whose resolved href
// resolves to the current page and uses a fragment identifier (#)
// and which precedes the first anchor element whose resolved href
// does not resolve to the current page or that doesn't use a
// fragment identifier
straker marked this conversation as resolved.
Show resolved Hide resolved
const currentPage = window.location.origin + window.location.pathname;
straker marked this conversation as resolved.
Show resolved Hide resolved
firstPageLink = querySelectorAll(
// TODO: es-module-_tree
axe._tree,
'a:not([href^="#"]):not([href^="/#"]):not([href^="javascript"])'
)[0];
'a[href]:not([href^="javascript:"])'
).find(({ actualNode }) => {
const href = actualNode.getAttribute('href');
if (angularSkipLinkRegex.test(href)) {
return false;
}

if (!actualNode.hash || angularRouterLinkRegex.test(actualNode.hash)) {
return true;
}

// ie11 doesn't fully resolve links that are just '#target'...
if (href.indexOf('#') === 0) {
return false;
}

// ie11 does not have .origin so we need to construct it ourselves
const { protocol, hostname, port, pathname } = actualNode;
const linkPage = `${protocol}//${hostname}${
port ? `:${port}` : ''
}${pathname}`;
return linkPage !== currentPage;
});

// null will signify no first page link
cache.set('firstPageLink', firstPageLink || null);
Expand Down
56 changes: 56 additions & 0 deletions test/commons/dom/is-skip-link.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ describe('dom.isSkipLink', function() {
'use strict';

var fixture = document.getElementById('fixture');
var baseEl;

afterEach(function() {
fixture.innerHTML = '';

if (baseEl) {
baseEl.parentNode.removeChild(baseEl);
}
});

it('should return true if the href points to an ID', function() {
Expand Down Expand Up @@ -35,6 +40,20 @@ describe('dom.isSkipLink', function() {
assert.isTrue(axe.commons.dom.isSkipLink(node));
});

it('should return false if the URI is angular #!', function() {
fixture.innerHTML = '<a href="#!target">Click Here</a>';
axe._tree = axe.utils.getFlattenedTree(fixture);
var node = fixture.querySelector('a');
assert.isFalse(axe.commons.dom.isSkipLink(node));
});

it('should return false if the URI is angular #/', function() {
fixture.innerHTML = '<a href="#/target">Click Here</a>';
axe._tree = axe.utils.getFlattenedTree(fixture);
var node = fixture.querySelector('a');
assert.isFalse(axe.commons.dom.isSkipLink(node));
});

it('should return true for multiple skip-links', function() {
fixture.innerHTML =
'<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>';
Expand Down Expand Up @@ -68,4 +87,41 @@ describe('dom.isSkipLink', function() {
var node = fixture.querySelector('#skip-link');
assert.isTrue(axe.commons.dom.isSkipLink(node));
});

it('should return true for hash href that resolves to current page', function() {
fixture.innerHTML =
'<a href="' + window.location.pathname + '#target">Click Here</a>';
axe._tree = axe.utils.getFlattenedTree(fixture);
var node = fixture.querySelector('a');
assert.isTrue(axe.commons.dom.isSkipLink(node));
});

it('should return true for absolute path hash href', function() {
var url = window.location.href;
fixture.innerHTML = '<a href="' + url + '#target">Click Here</a>';
axe._tree = axe.utils.getFlattenedTree(fixture);
var node = fixture.querySelector('a');
assert.isTrue(axe.commons.dom.isSkipLink(node));
});

it('should return false for absolute path href that points to another document', function() {
var origin = window.location.origin;
fixture.innerHTML =
'<a href="' + origin + '/something.html#target">Click Here</a>';
axe._tree = axe.utils.getFlattenedTree(fixture);
var node = fixture.querySelector('a');
assert.isFalse(axe.commons.dom.isSkipLink(node));
});

it('should return false for href with <base> tag that points to another document', function() {
baseEl = document.createElement('base');
baseEl.href = 'https://www.google.com/';
document.getElementsByTagName('head')[0].appendChild(baseEl);

fixture.innerHTML =
'<a href="' + window.location.pathname + '#target">Click Here</a>';
axe._tree = axe.utils.getFlattenedTree(fixture);
var node = fixture.querySelector('a');
assert.isFalse(axe.commons.dom.isSkipLink(node));
});
});