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',