Skip to content

Commit

Permalink
fix(skip-link): work with absolute and relative paths (#2875)
Browse files Browse the repository at this point in the history
* fix(skip-link): work with absoulte and relative paths

* revert playground

* fix ie11

* fix ie11 again

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

Co-authored-by: Wilco Fiers <[email protected]>

* finalize

* fix

* fix ie11 (always)

* typo

* fix jsdom test

* Update lib/commons/dom/is-current-page-link.js

Co-authored-by: Wilco Fiers <[email protected]>

* fix issue with usemap

* fix ie11

Co-authored-by: Wilco Fiers <[email protected]>
  • Loading branch information
straker and WilcoFiers authored Jan 20, 2022
1 parent 2e27dca commit ee49d3e
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 20 deletions.
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

0 comments on commit ee49d3e

Please sign in to comment.