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
12 changes: 8 additions & 4 deletions lib/commons/dom/get-element-by-reference.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import isCurrentPageLink from './is-current-page-link';

/**
* Returns a reference to the element matching the attr URL fragment value
* @method getElementByReference
Expand All @@ -13,10 +15,12 @@ function getElementByReference(node, attr) {
return null;
}

if (fragment.charAt(0) === '#') {
fragment = decodeURIComponent(fragment.substring(1));
} else if (fragment.substr(0, 2) === '/#') {
fragment = decodeURIComponent(fragment.substring(2));
if (attr === 'href' && !isCurrentPageLink(node)) {
return null;
}

if (fragment.indexOf('#') !== -1) {
fragment = decodeURIComponent(fragment.substr(fragment.indexOf('#') + 1));
}

let candidate = document.getElementById(fragment);
Expand Down
1 change: 1 addition & 0 deletions lib/commons/dom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export { default as hasContentVirtual } from './has-content-virtual';
export { default as hasContent } from './has-content';
export { default as idrefs } from './idrefs';
export { default as insertedIntoFocusOrder } from './inserted-into-focus-order';
export { default as isCurrentPageLink } from './is-current-page-link';
export { default as isFocusable } from './is-focusable';
export { default as isHiddenWithCSS } from './is-hidden-with-css';
export { default as isHTML5 } from './is-html5';
Expand Down
66 changes: 66 additions & 0 deletions lib/commons/dom/is-current-page-link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// angular skip links start with /#
const angularSkipLinkRegex = /^\/\#/;

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

/**
* Determine if an anchor elements href attribute references the current page.
* @method isCurrentPageLink
* @memberof axe.commons.dom
* @param {HTMLAnchorElement} anchor
* @return {Boolean|null}
*/
export default function isCurrentPageLink(anchor) {
const href = anchor.getAttribute('href');
if (!href || href === '#') {
return false;
}

if (angularSkipLinkRegex.test(href)) {
return true;
}

const { hash, protocol, hostname, port, pathname } = anchor;
if (angularRouterLinkRegex.test(hash)) {
return false;
}

if (href.charAt(0) === '#') {
return true;
}

// jsdom can have window.location.origin set to "null" (the string)
// if the url option is not set when parsing the dom string
if (
typeof window.location?.origin !== 'string' ||
window.location.origin.indexOf('://') === -1
) {
return null;
}

// ie11 does not support window.origin
const currentPageUrl = window.location.origin + window.location.pathname;

// ie11 does not have anchor.origin so we need to construct
// it ourselves
// also ie11 has empty protocol, hostname, and port when the
// link is relative, so use window.location.origin in these cases
let url;
if (!hostname) {
url = window.location.origin;
} else {
url = `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}

// ie11 has empty pathname if link is just a hash, so use
// window.location.pathname in these cases
if (!pathname) {
url += window.location.pathname;
} else {
// ie11 pathname does not start with / but chrome and firefox do
url += (pathname[0] !== '/' ? '/' : '') + pathname;
}

return url === currentPageUrl;
}
39 changes: 23 additions & 16 deletions lib/commons/dom/is-skip-link.js
Original file line number Diff line number Diff line change
@@ -1,34 +1,43 @@
import cache from '../../core/base/cache';
import { querySelectorAll } from '../../core/utils';

// test for hrefs that start with # or /# (for angular)
const isInternalLinkRegex = /^\/?#[^/!]/;
import isCurrentPageLink from './is-current-page-link';

/**
* Determines if element is a skip link
* Determines if element is a skip link.
*
* 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.
* @method isSkipLink
* @memberof axe.commons.dom
* @instance
* @param {Element} element
* @return {Boolean}
*/
function isSkipLink(element) {
if (!isInternalLinkRegex.test(element.getAttribute('href'))) {
export default function isSkipLink(element) {
if (!element.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)
firstPageLink = querySelectorAll(
// TODO: es-module-_tree
axe._tree,
'a:not([href^="#"]):not([href^="/#"]):not([href^="javascript"])'
)[0];
// jsdom can have window.location.origin set to null
if (!window.location.origin) {
firstPageLink = querySelectorAll(
// TODO: es-module-_tree
axe._tree,
'a:not([href^="#"]):not([href^="/#"]):not([href^="javascript:"])'
)[0];
} else {
firstPageLink = querySelectorAll(
axe._tree,
'a[href]:not([href^="javascript:"])'
).find(link => !isCurrentPageLink(link.actualNode));
}

// null will signify no first page link
cache.set('firstPageLink', firstPageLink || null);
Expand All @@ -45,5 +54,3 @@ function isSkipLink(element) {
element.DOCUMENT_POSITION_FOLLOWING
);
}

export default isSkipLink;
39 changes: 39 additions & 0 deletions test/commons/dom/get-element-by-reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,28 @@ describe('dom.getElementByReference', function() {
assert.isNull(result);
});

it('should return node if target is found (href)', function() {
fixture.innerHTML =
'<a id="link" href="#target">Hi</a>' + '<a id="target"></a>';

var node = document.getElementById('link'),
expected = document.getElementById('target'),
result = axe.commons.dom.getElementByReference(node, 'href');

assert.equal(result, expected);
});

it('should return node if target is found (usemap)', function() {
fixture.innerHTML =
'<img id="link" usemap="#target">Hi</a>' + '<map id="target"></map>';

var node = document.getElementById('link'),
expected = document.getElementById('target'),
result = axe.commons.dom.getElementByReference(node, 'usemap');

assert.equal(result, expected);
});

it('should prioritize ID', function() {
fixture.innerHTML =
'<a id="link" href="#target">Hi</a>' +
Expand Down Expand Up @@ -81,4 +103,21 @@ describe('dom.getElementByReference', function() {

assert.equal(result, expected);
});

it('should work with absolute links', function() {
var currentPage = window.location.origin + window.location.pathname;

fixture.innerHTML =
'<a id="link" href="' +
currentPage +
'#target">Hi</a>' +
'<a id="target"></a>' +
'<a name="target"></a>';

var node = document.getElementById('link'),
expected = document.getElementById('target'),
result = axe.commons.dom.getElementByReference(node, 'href');

assert.equal(result, expected);
});
});
66 changes: 66 additions & 0 deletions test/commons/dom/is-current-page-link.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
describe('is-current-page-link', function() {
var isCurrentPageLink = axe.commons.dom.isCurrentPageLink;
var currentPage = window.location.origin + window.location.pathname;
var base;

afterEach(function() {
if (base) {
document.head.removeChild(base);
}
});

it('should return true for hash links', function() {
var anchor = document.createElement('a');
anchor.href = '#main';
document.body.appendChild(anchor);
assert.isTrue(isCurrentPageLink(anchor));
});

it('should return true for relative links to the same page', function() {
var anchor = document.createElement('a');
anchor.href = window.location.pathname;
assert.isTrue(isCurrentPageLink(anchor));
});

it('should return true for absolute links to the same page', function() {
var anchor = document.createElement('a');
anchor.href = currentPage;
assert.isTrue(isCurrentPageLink(anchor));
});

it('should return true for angular skip links', function() {
var anchor = document.createElement('a');
anchor.href = '/#main';
assert.isTrue(isCurrentPageLink(anchor));
});

it('should return false for just "#"', function() {
var anchor = document.createElement('a');
anchor.href = '#';
assert.isFalse(isCurrentPageLink(anchor));
});

it('should return false for relative links to a different page', function() {
var anchor = document.createElement('a');
anchor.href = '/foo/bar/index.html';
assert.isFalse(isCurrentPageLink(anchor));
});

it('should return false for absolute links to a different page', function() {
var anchor = document.createElement('a');
anchor.href = 'https://my-page.com/foo/bar/index.html';
assert.isFalse(isCurrentPageLink(anchor));
});

it('should return false for angular router links (#!)', function() {
var anchor = document.createElement('a');
anchor.href = '#!main';
assert.isFalse(isCurrentPageLink(anchor));
});

it('should return false for angular router links (#/)', function() {
var anchor = document.createElement('a');
anchor.href = '#/main';
assert.isFalse(isCurrentPageLink(anchor));
});
});
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));
});
});
Loading