diff --git a/lib/commons/dom/get-root-children.js b/lib/commons/dom/get-root-children.js
new file mode 100644
index 0000000000..47a03daeac
--- /dev/null
+++ b/lib/commons/dom/get-root-children.js
@@ -0,0 +1,48 @@
+import { nodeLookup } from '../../core/utils';
+import cache from '../../core/base/cache';
+
+/**
+ * Return the child virtual nodes of the root node
+ * @method getRootChildren
+ * @memberof axe.commons.dom
+ * @instance
+ * @param {Element|VirtualNode} node
+ * @returns {VirtualNode[]|undefined}
+ */
+export default function getRootChildren(node) {
+ const { vNode } = nodeLookup(node);
+ const { shadowId } = vNode;
+
+ const childrenMap = cache.get('getRootChildrenMap', () => ({}));
+ if (childrenMap[shadowId]) {
+ return childrenMap[shadowId];
+ }
+
+ // top of tree
+ if (vNode.parent === null) {
+ childrenMap[shadowId] = [...vNode.children];
+ return childrenMap[shadowId];
+ }
+
+ // disconnected tree
+ if (!vNode.parent) {
+ childrenMap[shadowId] = undefined;
+ return childrenMap[shadowId];
+ }
+
+ // since the virtual tree does not have a #shadowRoot element the root virtual
+ // node is the shadow host element. however the shadow host element is not inside
+ // the shadow DOM tree so we return the children of the shadow host element in
+ // order to not cross shadow DOM boundaries.
+ //
+ // TODO: slotted elements share the shadowId of the shadow tree it is attached to
+ // but should not be used to find id's inside the shadow tree. throw an error
+ // until we resolve this
+ if (vNode.shadowId !== vNode.parent.shadowId) {
+ throw new Error(
+ 'Getting root children of shadow DOM elements is not supported'
+ );
+ }
+
+ return getRootChildren(vNode.parent);
+}
diff --git a/lib/commons/dom/idrefs.js b/lib/commons/dom/idrefs.js
index be3dc56db0..bb55ef9da1 100644
--- a/lib/commons/dom/idrefs.js
+++ b/lib/commons/dom/idrefs.js
@@ -1,5 +1,10 @@
-import getRootNode from './get-root-node';
-import { tokenList } from '../../core/utils';
+import getRootChildren from './get-root-children';
+import {
+ tokenList,
+ nodeLookup,
+ querySelectorAll,
+ getRootNode
+} from '../../core/utils';
/**
* Get elements referenced via a space-separated token attribute;
@@ -18,24 +23,41 @@ import { tokenList } from '../../core/utils';
*
*/
function idrefs(node, attr) {
- node = node.actualNode || node;
+ const { domNode, vNode } = nodeLookup(node);
+ const results = [];
+ const attrValue = vNode ? vNode.attr(attr) : node.getAttribute(attr);
+
+ if (!attrValue) {
+ return results;
+ }
try {
- const doc = getRootNode(node);
- const result = [];
- let attrValue = node.getAttribute(attr);
-
- if (attrValue) {
- attrValue = tokenList(attrValue);
- for (let index = 0; index < attrValue.length; index++) {
- result.push(doc.getElementById(attrValue[index]));
- }
+ const root = getRootNode(domNode);
+ for (const token of tokenList(attrValue)) {
+ results.push(root.getElementById(token));
}
-
- return result;
} catch {
- throw new TypeError('Cannot resolve id references for non-DOM nodes');
+ const rootVNodes = getRootChildren(vNode);
+ if (!rootVNodes) {
+ throw new TypeError('Cannot resolve id references for non-DOM nodes');
+ }
+
+ for (const token of tokenList(attrValue)) {
+ let result = null;
+
+ for (const root of rootVNodes) {
+ const foundNode = querySelectorAll(root, `#${token}`)[0];
+ if (foundNode) {
+ result = foundNode;
+ break;
+ }
+ }
+
+ results.push(result);
+ }
}
+
+ return results;
}
export default idrefs;
diff --git a/lib/commons/dom/index.js b/lib/commons/dom/index.js
index 3449238aa1..f9c40eebab 100644
--- a/lib/commons/dom/index.js
+++ b/lib/commons/dom/index.js
@@ -14,6 +14,7 @@ export { default as getElementCoordinates } from './get-element-coordinates';
export { default as getElementStack } from './get-element-stack';
export { default as getModalDialog } from './get-modal-dialog';
export { default as getOverflowHiddenAncestors } from './get-overflow-hidden-ancestors';
+export { default as getRootChildren } from './get-root-children';
export { default as getRootNode } from './get-root-node';
export { default as getScrollOffset } from './get-scroll-offset';
export { default as getTabbableElements } from './get-tabbable-elements';
diff --git a/lib/commons/text/accessible-text.js b/lib/commons/text/accessible-text.js
index 3dfb04157a..5cab563455 100644
--- a/lib/commons/text/accessible-text.js
+++ b/lib/commons/text/accessible-text.js
@@ -1,5 +1,5 @@
import accessibleTextVirtual from './accessible-text-virtual';
-import { getNodeFromTree } from '../../core/utils';
+import { nodeLookup } from '../../core/utils';
/**
* Finds virtual node and calls accessibleTextVirtual()
@@ -12,8 +12,8 @@ import { getNodeFromTree } from '../../core/utils';
* @return {string}
*/
function accessibleText(element, context) {
- const virtualNode = getNodeFromTree(element); // throws an exception on purpose if axe._tree not correct
- return accessibleTextVirtual(virtualNode, context);
+ const { vNode } = nodeLookup(element);
+ return accessibleTextVirtual(vNode, context);
}
export default accessibleText;
diff --git a/test/commons/dom/get-root-children.js b/test/commons/dom/get-root-children.js
new file mode 100644
index 0000000000..d203d06f08
--- /dev/null
+++ b/test/commons/dom/get-root-children.js
@@ -0,0 +1,30 @@
+describe('dom.getRootChildren', () => {
+ const getRootChildren = axe.commons.dom.getRootChildren;
+ const fixture = document.querySelector('#fixture');
+ const queryShadowFixture = axe.testUtils.queryShadowFixture;
+
+ it('should return the children of the root node of a complete tree', () => {
+ axe.setup();
+ const expected = axe.utils.getNodeFromTree(
+ document.documentElement
+ ).children;
+ assert.deepEqual(getRootChildren(fixture), expected);
+ });
+
+ it('should return undefined for disconnected tree', () => {
+ axe.setup();
+ axe.utils.getNodeFromTree(document.documentElement).parent = undefined;
+ assert.isUndefined(getRootChildren(fixture));
+ });
+
+ it('should throw for shadow DOM', () => {
+ const target = queryShadowFixture(
+ '
',
+ 'Hello World
'
+ );
+
+ assert.throws(() => {
+ getRootChildren(target);
+ });
+ });
+});
diff --git a/test/commons/dom/idrefs.js b/test/commons/dom/idrefs.js
index b187612232..eba811682c 100644
--- a/test/commons/dom/idrefs.js
+++ b/test/commons/dom/idrefs.js
@@ -1,60 +1,49 @@
function createContentIDR() {
- 'use strict';
- var group = document.createElement('div');
+ const group = document.createElement('div');
group.id = 'target';
return group;
}
function makeShadowTreeIDR(node) {
- 'use strict';
- var root = node.attachShadow({ mode: 'open' });
- var div = document.createElement('div');
+ const root = node.attachShadow({ mode: 'open' });
+ const div = document.createElement('div');
div.className = 'parent';
div.setAttribute('target', 'target');
root.appendChild(div);
div.appendChild(createContentIDR());
}
-describe('dom.idrefs', function () {
- 'use strict';
+describe('dom.idrefs', () => {
+ const fixture = document.getElementById('fixture');
+ const shadowSupported = axe.testUtils.shadowSupport.v1;
+ const idrefs = axe.commons.dom.idrefs;
- var fixture = document.getElementById('fixture');
- var shadowSupported = axe.testUtils.shadowSupport.v1;
-
- afterEach(function () {
- fixture.innerHTML = '';
- });
-
- it('should find referenced nodes by ID', function () {
+ it('should find referenced nodes by ID', () => {
fixture.innerHTML =
'' +
'';
- var start = document.getElementById('start'),
+ const start = document.getElementById('start'),
expected = [
document.getElementById('target1'),
document.getElementById('target2')
];
- assert.deepEqual(
- axe.commons.dom.idrefs(start, 'aria-cats'),
- expected,
- 'Should find it!'
- );
+ assert.deepEqual(idrefs(start, 'aria-cats'), expected, 'Should find it!');
});
(shadowSupported ? it : xit)(
'should find only referenced nodes within the current root: shadow DOM',
- function () {
+ () => {
// shadow DOM v1 - note: v0 is compatible with this code, so no need
// to specifically test this
fixture.innerHTML = '';
makeShadowTreeIDR(fixture.firstChild);
- var start = fixture.firstChild.shadowRoot.querySelector('.parent');
- var expected = [fixture.firstChild.shadowRoot.getElementById('target')];
+ const start = fixture.firstChild.shadowRoot.querySelector('.parent');
+ const expected = [fixture.firstChild.shadowRoot.getElementById('target')];
assert.deepEqual(
- axe.commons.dom.idrefs(start, 'target'),
+ idrefs(start, 'target'),
expected,
'should only find stuff in the shadow DOM'
);
@@ -63,58 +52,273 @@ describe('dom.idrefs', function () {
(shadowSupported ? it : xit)(
'should find only referenced nodes within the current root: document',
- function () {
+ () => {
// shadow DOM v1 - note: v0 is compatible with this code, so no need
// to specifically test this
fixture.innerHTML =
'';
makeShadowTreeIDR(fixture.firstChild);
- var start = fixture.querySelector('.parent');
- var expected = [document.getElementById('target')];
+ const start = fixture.querySelector('.parent');
+ const expected = [document.getElementById('target')];
assert.deepEqual(
- axe.commons.dom.idrefs(start, 'target'),
+ idrefs(start, 'target'),
expected,
'should only find stuff in the document'
);
}
);
- it('should insert null if a reference is not found', function () {
+ it('should insert null if a reference is not found', () => {
fixture.innerHTML =
'' +
'';
- var start = document.getElementById('start'),
+ const start = document.getElementById('start'),
expected = [
document.getElementById('target1'),
document.getElementById('target2'),
null
];
- assert.deepEqual(
- axe.commons.dom.idrefs(start, 'aria-cats'),
- expected,
- 'Should find it!'
- );
+ assert.deepEqual(idrefs(start, 'aria-cats'), expected, 'Should find it!');
});
- it('should not fail when extra whitespace is used', function () {
+ it('should not fail when extra whitespace is used', () => {
fixture.innerHTML =
'' +
'';
- var start = document.getElementById('start'),
+ const start = document.getElementById('start'),
expected = [
document.getElementById('target1'),
document.getElementById('target2'),
null
];
- assert.deepEqual(
- axe.commons.dom.idrefs(start, 'aria-cats'),
- expected,
- 'Should find it!'
- );
+ assert.deepEqual(idrefs(start, 'aria-cats'), expected, 'Should find it!');
+ });
+
+ describe('SerialVirtualNode', () => {
+ it('should find referenced nodes by ID', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const start = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ attributes: {
+ 'aria-cats': 'target1 target2'
+ }
+ });
+ const target1 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target1'
+ });
+ const target2 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target2'
+ });
+
+ root.parent = null;
+ root.children = [start, target1, target2];
+
+ start.parent = root;
+ target1.parent = root;
+ target2.parent = root;
+
+ assert.deepEqual(
+ idrefs(start, 'aria-cats'),
+ [target1, target2],
+ 'Should find it!'
+ );
+ });
+
+ it('should throw for elements in shadow DOM', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const outsideTarget = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target'
+ });
+ const host = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const shadowParent = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ attributes: {
+ target: 'target'
+ }
+ });
+ const shadowTarget = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target'
+ });
+
+ root.parent = null;
+ root.children = [outsideTarget, host];
+
+ outsideTarget.parent = root;
+
+ host.parent = root;
+ host.children = [shadowParent, shadowTarget];
+
+ shadowParent.parent = host;
+ shadowParent.shadowId = 'abc123';
+
+ shadowTarget.parent = host;
+ shadowTarget.shadowId = 'abc123';
+
+ assert.throws(() => {
+ idrefs(shadowParent, 'target');
+ });
+ });
+
+ it('should find only referenced nodes within the current root: document', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const outsideTarget = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target'
+ });
+ const start = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ attributes: {
+ target: 'target'
+ }
+ });
+ const host = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const shadowParent = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ attributes: {
+ target: 'target'
+ }
+ });
+ const shadowTarget = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target'
+ });
+
+ root.parent = null;
+ root.children = [outsideTarget, start, host];
+
+ outsideTarget.parent = root;
+ start.parent = root;
+
+ host.parent = root;
+ host.children = [shadowParent, shadowTarget];
+
+ shadowParent.parent = host;
+ shadowParent.shadowId = 'abc123';
+
+ shadowTarget.parent = host;
+ shadowTarget.shadowId = 'abc123';
+
+ assert.deepEqual(
+ idrefs(start, 'target'),
+ [outsideTarget],
+ 'should only find stuff in the document'
+ );
+ });
+
+ it('should insert null if a reference is not found', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const start = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ attributes: {
+ 'aria-cats': 'target1 target2 target3'
+ }
+ });
+ const target1 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target1'
+ });
+ const target2 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target2'
+ });
+
+ root.parent = null;
+ root.children = [start, target1, target2];
+
+ start.parent = root;
+ target1.parent = root;
+ target2.parent = root;
+
+ assert.deepEqual(
+ idrefs(start, 'aria-cats'),
+ [target1, target2, null],
+ 'Should find it!'
+ );
+ });
+
+ it('should not fail when extra whitespace is used', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const start = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ attributes: {
+ 'aria-cats': ' \ttarget1 \n target2 target3 \n\t'
+ }
+ });
+ const target1 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target1'
+ });
+ const target2 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target2'
+ });
+
+ root.parent = null;
+ root.children = [start, target1, target2];
+
+ start.parent = root;
+ target1.parent = root;
+ target2.parent = root;
+
+ assert.deepEqual(
+ idrefs(start, 'aria-cats'),
+ [target1, target2, null],
+ 'Should find it!'
+ );
+ });
+
+ it('should throw if in disconnected tree', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'div'
+ });
+ const start = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ attributes: {
+ 'aria-cats': 'target1 target2'
+ }
+ });
+ const target1 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target1'
+ });
+ const target2 = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'target2'
+ });
+
+ root.parent = undefined;
+ root.children = [start, target1, target2];
+
+ start.parent = root;
+ target1.parent = root;
+ target2.parent = root;
+
+ assert.throws(() => {
+ idrefs(start, 'aria-cats');
+ });
+ });
});
});
diff --git a/test/integration/virtual-rules/button-name.js b/test/integration/virtual-rules/button-name.js
index d54ef0f62d..2cbfc627db 100644
--- a/test/integration/virtual-rules/button-name.js
+++ b/test/integration/virtual-rules/button-name.js
@@ -12,7 +12,44 @@ describe('button-name virtual-rule', () => {
assert.lengthOf(results.incomplete, 0);
});
- it('should incomplete for aria-labelledby', () => {
+ it('should pass for aria-labelledby in complete tree', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'body'
+ });
+ const node = new axe.SerialVirtualNode({
+ nodeName: 'button',
+ attributes: {
+ 'aria-labelledby': 'foobar'
+ }
+ });
+ const foobar = new axe.SerialVirtualNode({
+ nodeName: 'div',
+ id: 'foobar'
+ });
+ const text = new axe.SerialVirtualNode({
+ nodeName: '#text',
+ nodeType: 3,
+ nodeValue: 'foobar'
+ });
+
+ root.parent = null;
+ root.children = [node, foobar];
+
+ node.parent = root;
+
+ foobar.parent = root;
+ foobar.children = [text];
+
+ text.parent = foobar;
+
+ const results = axe.runVirtualRule('button-name', node);
+
+ assert.lengthOf(results.passes, 1);
+ assert.lengthOf(results.violations, 0);
+ assert.lengthOf(results.incomplete, 0);
+ });
+
+ it('should incomplete for aria-labelledby in disconnected tree', () => {
const node = new axe.SerialVirtualNode({
nodeName: 'button',
attributes: {
@@ -179,6 +216,30 @@ describe('button-name virtual-rule', () => {
assert.lengthOf(results.incomplete, 0);
});
+ it('should fail for missing aria-labelledby in complete tree', () => {
+ const root = new axe.SerialVirtualNode({
+ nodeName: 'body'
+ });
+ const node = new axe.SerialVirtualNode({
+ nodeName: 'button',
+ attributes: {
+ 'aria-labelledby': 'foobar'
+ }
+ });
+
+ root.parent = null;
+ root.children = [node];
+
+ node.parent = root;
+ node.children = [];
+
+ const results = axe.runVirtualRule('button-name', node);
+
+ assert.lengthOf(results.passes, 0);
+ assert.lengthOf(results.violations, 1);
+ assert.lengthOf(results.incomplete, 0);
+ });
+
it('should fail when title is empty', () => {
const node = new axe.SerialVirtualNode({
nodeName: 'button',