Skip to content

Commit

Permalink
fix(color-contrast): greatly improve color-contrast-matches speed. ad…
Browse files Browse the repository at this point in the history
…d aria/get-accessible-ref (#2635)

* feat(color-contrast): greatly improve color-contrast-matches speed. add aria/get-accessible-ref

* filter by aria-labelledby

* fixes

* delete
  • Loading branch information
straker authored Nov 17, 2020
1 parent ea0917e commit ba174bd
Show file tree
Hide file tree
Showing 5 changed files with 218 additions and 66 deletions.
73 changes: 73 additions & 0 deletions lib/commons/aria/get-accessible-refs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import getRootNode from '../dom/get-root-node';
import cache from '../../core/base/cache';
import { tokenList } from '../../core/utils';
import standards from '../../standards';
import { sanitize } from '../text/';

const idRefsRegex = /^idrefs?$/;

/**
* Cache all ID references of a node and its children
*/
function cacheIdRefs(node, idRefs, refAttrs) {
if (node.hasAttribute) {
if (node.nodeName.toUpperCase() === 'LABEL' && node.hasAttribute('for')) {
const id = node.getAttribute('for');
idRefs[id] = idRefs[id] || [];
idRefs[id].push(node);
}

for (let i = 0; i < refAttrs.length; ++i) {
const attr = refAttrs[i];
const attrValue = sanitize(node.getAttribute(attr) || '');

if (!attrValue) {
continue;
}

const tokens = tokenList(attrValue);
for (let k = 0; k < tokens.length; ++k) {
idRefs[tokens[k]] = idRefs[tokens[k]] || [];
idRefs[tokens[k]].push(node);
}
}
}

for (let i = 0; i < node.children.length; i++) {
cacheIdRefs(node.children[i], idRefs, refAttrs);
}
}

/**
* Return all DOM nodes that use the nodes ID in the accessibility tree.
* @param {Element} node
* @returns {Element[]}
*/
function getAccessibleRefs(node) {
node = node.actualNode || node;
let root = getRootNode(node);
root = root.documentElement || root; // account for shadow roots

let idRefsByRoot = cache.get('idRefsByRoot');
if (!idRefsByRoot) {
idRefsByRoot = new WeakMap();
cache.set('idRefsByRoot', idRefsByRoot);
}

let idRefs = idRefsByRoot.get(root);
if (!idRefs) {
idRefs = {};
idRefsByRoot.set(root, idRefs);

const refAttrs = Object.keys(standards.ariaAttrs).filter(attr => {
const { type } = standards.ariaAttrs[attr];
return idRefsRegex.test(type);
});

cacheIdRefs(root, idRefs, refAttrs);
}

return idRefs[node.id] || [];
}

export default getAccessibleRefs;
1 change: 1 addition & 0 deletions lib/commons/aria/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export { default as allowedAttr } from './allowed-attr';
export { default as arialabelText } from './arialabel-text';
export { default as arialabelledbyText } from './arialabelledby-text';
export { default as getAccessibleRefs } from './get-accessible-refs';
export { default as getElementUnallowedRoles } from './get-element-unallowed-roles';
export { default as getExplicitRole } from './get-explicit-role';
export { default as getOwnedVirtual } from './get-owned-virtual';
Expand Down
57 changes: 2 additions & 55 deletions lib/commons/aria/is-accessible-ref.js
Original file line number Diff line number Diff line change
@@ -1,65 +1,12 @@
import standards from '../../standards';
import getRootNode from '../dom/get-root-node';
import cache from '../../core/base/cache';
import { tokenList } from '../../core/utils';

const idRefsRegex = /^idrefs?$/;

function cacheIdRefs(node, refAttrs) {
if (node.hasAttribute) {
const idRefs = cache.get('idRefs');

if (node.nodeName.toUpperCase() === 'LABEL' && node.hasAttribute('for')) {
idRefs[node.getAttribute('for')] = true;
}

for (let i = 0; i < refAttrs.length; ++i) {
const attr = refAttrs[i];

if (!node.hasAttribute(attr)) {
continue;
}

const attrValue = node.getAttribute(attr);

const tokens = tokenList(attrValue);

for (let k = 0; k < tokens.length; ++k) {
idRefs[tokens[k]] = true;
}
}
}

for (let i = 0; i < node.children.length; i++) {
cacheIdRefs(node.children[i], refAttrs);
}
}
import getAccessibleRefs from './get-accessible-refs';

/**
* Check that a DOM node is a reference in the accessibility tree
* @param {Element} node
* @returns {Boolean}
*/
function isAccessibleRef(node) {
node = node.actualNode || node;
let root = getRootNode(node);
root = root.documentElement || root; // account for shadow roots
const id = node.id;

// because axe.commons is not available in axe.utils, we can't do
// this caching when we build up the virtual tree
if (!cache.get('idRefs')) {
cache.set('idRefs', {});
// Get all idref(s) attributes on the lookup table
const refAttrs = Object.keys(standards.ariaAttrs).filter(attr => {
const { type } = standards.ariaAttrs[attr];
return idRefsRegex.test(type);
});

cacheIdRefs(root, refAttrs);
}

return cache.get('idRefs')[id] === true;
return !!getAccessibleRefs(node).length;
}

export default isAccessibleRef;
20 changes: 9 additions & 11 deletions lib/rules/color-contrast-matches.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
/* global document */
import { getAccessibleRefs } from '../commons/aria';
import { findUpVirtual, visuallyOverlaps, getRootNode } from '../commons/dom';
import { visibleVirtual, removeUnicode, sanitize } from '../commons/text';
import { isDisabled } from '../commons/forms';
import {
getNodeFromTree,
querySelectorAll,
escapeSelector
} from '../core/utils';
import { getNodeFromTree, querySelectorAll, tokenList } from '../core/utils';

function colorContrastMatches(node, virtualNode) {
const { nodeName, type: inputType } = virtualNode.props;
Expand Down Expand Up @@ -96,12 +93,13 @@ function colorContrastMatches(node, virtualNode) {
while (ancestorNode) {
// Find any ancestor (including itself) that is used with aria-labelledby
if (ancestorNode.props.id) {
const doc = getRootNode(node);
const escapedId = escapeSelector(ancestorNode.props.id);
const controls = Array.from(
doc.querySelectorAll(`[aria-labelledby~="${escapedId}"]`)
);
const virtualControls = controls.map(control => getNodeFromTree(control));
const virtualControls = getAccessibleRefs(ancestorNode)
.filter(control => {
return tokenList(
control.getAttribute('aria-labelledby') || ''
).includes(ancestorNode.props.id);
})
.map(control => getNodeFromTree(control));

ariaLabelledbyControls.push(...virtualControls);
}
Expand Down
133 changes: 133 additions & 0 deletions test/commons/aria/get-accessible-refs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
describe('aria.getAccessibleRefs', function() {
'use strict';

var fixture = document.getElementById('fixture');
var getAccessibleRefs = axe.commons.aria.getAccessibleRefs;
var shadowSupport = axe.testUtils.shadowSupport.v1;

function setLookup(attrs) {
axe.configure({
standards: {
ariaAttrs: attrs
}
});
}

before(function() {
axe._load({});
});

afterEach(function() {
fixture.innerHTML = '';
axe.reset();
});

it('returns empty array by default', function() {
fixture.innerHTML = '<div id="foo"><div>';
var node = document.getElementById('foo');
assert.lengthOf(getAccessibleRefs(node), 0);
});

it('returns array of nodes for IDs used in aria IDREF attributes', function() {
setLookup({ 'aria-foo': { type: 'idref' } });
fixture.innerHTML = '<div id="ref" aria-foo="foo"></div><i id="foo"></i>';
var node = document.getElementById('foo');
var ref = document.getElementById('ref');
assert.deepEqual(getAccessibleRefs(node), [ref]);
});

it('returns array of nodes for IDs used in aria IDREFS attributes', function() {
setLookup({ 'aria-bar': { type: 'idrefs' } });
fixture.innerHTML =
'<div id="ref" aria-bar="foo bar"></div><i id="foo"></i><b id="bar"></b>';

var node1 = document.getElementById('foo');
var node2 = document.getElementById('bar');
var ref = document.getElementById('ref');
assert.deepEqual(getAccessibleRefs(node1), [ref]);
assert.deepEqual(getAccessibleRefs(node2), [ref]);
});

it('returns array of nodes for IDs used in label[for] attributes', function() {
setLookup({ 'aria-foo': { type: 'idref' } });
fixture.innerHTML = '<label id="ref" for="baz">baz</label><input id="baz">';
var node = document.getElementById('baz');
var ref = document.getElementById('ref');
assert.deepEqual(getAccessibleRefs(node), [ref]);
});

it('returns all nodes used in aria IDREF attributes', function() {
setLookup({ 'aria-bar': { type: 'idrefs' } });
fixture.innerHTML =
'<div id="ref1" aria-bar="foo"><div id="ref2" aria-bar="foo"></div><i id="foo"></i>';

var node = document.getElementById('foo');
var ref1 = document.getElementById('ref1');
var ref2 = document.getElementById('ref2');

assert.deepEqual(getAccessibleRefs(node), [ref1, ref2]);
});

(shadowSupport ? it : xit)('works inside shadow DOM', function() {
setLookup({ 'aria-bar': { type: 'idref' } });
fixture.innerHTML = '<div id="foo"></div>';

var shadow = document.getElementById('foo').attachShadow({ mode: 'open' });
shadow.innerHTML = '<div id="ref" aria-bar="bar"></div><b id="bar"></b>';

var node = shadow.getElementById('bar');
var ref = shadow.getElementById('ref');
assert.deepEqual(getAccessibleRefs(node), [ref]);
});

(shadowSupport ? it : xit)(
'returns empty array for IDREFs inside shadow DOM',
function() {
setLookup({ 'aria-foo': { type: 'idrefs' } });
fixture.innerHTML = '<div id="foo"><div id="bar"></div></div>';
var node1 = document.getElementById('foo');
var node2 = document.getElementById('bar');

var shadow = node1.attachShadow({ mode: 'open' });
shadow.innerHTML = '<div aria-foo="foo bar"><slot></slot></div>';

assert.lengthOf(getAccessibleRefs(node1), 0);
assert.lengthOf(getAccessibleRefs(node2), 0);
}
);

(shadowSupport ? it : xit)(
'returns empty array for IDREFs outside shadow DOM',
function() {
setLookup({ 'aria-bar': { type: 'idref' } });
fixture.innerHTML =
'<div id="foo" aria-bar="bar"><div aria-bar="bar"></div></div>';

var shadow = document
.getElementById('foo')
.attachShadow({ mode: 'open' });
shadow.innerHTML = '<div id="bar"><slot></slot></div>';

var node = shadow.getElementById('bar');
assert.lengthOf(getAccessibleRefs(node), 0);
}
);

(shadowSupport ? it : xit)('separates IDREFs by roots', function() {
setLookup({ 'aria-bar': { type: 'idref' } });
fixture.innerHTML =
'<div id="foo"></div><div id="outside" aria-bar="foo"></div><div id="shadow"></div>';

var shadow = document
.getElementById('shadow')
.attachShadow({ mode: 'open' });
shadow.innerHTML = '<div id="foo"><div id="inside" aria-bar="foo"></div>';

var outsideNode = document.getElementById('foo');
var outsideRef = document.getElementById('outside');
var insideNode = shadow.getElementById('foo');
var insideRef = shadow.getElementById('inside');
assert.deepEqual(getAccessibleRefs(outsideNode), [outsideRef]);
assert.deepEqual(getAccessibleRefs(insideNode), [insideRef]);
});
});

0 comments on commit ba174bd

Please sign in to comment.