diff --git a/lib/core/base/virtual-node.js b/lib/core/base/virtual-node.js new file mode 100644 index 0000000000..c2cc1f1b48 --- /dev/null +++ b/lib/core/base/virtual-node.js @@ -0,0 +1,85 @@ +const whitespaceRegex = /[\t\r\n\f]/g; + +// class is unused in the file... +// eslint-disable-next-line no-unused-vars +class VirtualNode { + /** + * Wrap the real node and provide list of the flattened children + * + * @param node {Node} - the node in question + * @param shadowId {String} - the ID of the shadow DOM to which this node belongs + */ + constructor(node, shadowId) { + this.shadowId = shadowId; + this.children = []; + this.actualNode = node; + + this._isHidden = null; // will be populated by axe.utils.isHidden + this._cache = {}; + + // abstract Node and Element APIs so we can run axe in DOM-less + // environments. these are static properties in the assumption + // that axe does not change any of them while it runs. + this.elementNodeType = node.nodeType; + this.elementNodeName = node.nodeName.toLowerCase(); + this.elementId = node.id; + + if (axe._cache.get('nodeMap')) { + axe._cache.get('nodeMap').set(node, this); + } + } + + /** + * Determine if the actualNode has the given class name. + * @see https://j11y.io/jquery/#v=2.0.3&fn=jQuery.fn.hasClass + * @param {String} className - The class to check for. + * @return {Boolean} True if the actualNode has the given class, false otherwise. + */ + hasClass(className) { + if (typeof this.actualNode.className !== 'string') { + return false; + } + + let selector = ' ' + className + ' '; + return ( + (' ' + this.actualNode.className + ' ') + .replace(whitespaceRegex, ' ') + .indexOf(selector) >= 0 + ); + } + + /** + * Get the value of the given attribute name. + * @param {String} attrName - The name of the attribute. + * @returns {String|null} The value of the attribute or null if the attribute does not exist + */ + attr(attrName) { + if (typeof this.actualNode.getAttribute !== 'function') { + return null; + } + + return this.actualNode.getAttribute(attrName); + } + + /** + * Determine if the element is focusable and cache the result. + * @return {Boolean} True if the element is focusable, false otherwise. + */ + get isFocusable() { + if (!this._cache.hasOwnProperty('isFocusable')) { + this._cache.isFocusable = axe.commons.dom.isFocusable(this.actualNode); + } + return this._cache.isFocusable; + } + + /** + * Return the list of tabbable elements for this element and cache the result. + * @returns {VirtualNode[]} + */ + get tabbableElements() { + if (!this._cache.hasOwnProperty('tabbableElements')) { + this._cache.tabbableElements = axe.commons.dom.getTabbableElements(this); + } + return this._cache.tabbableElements; + } +} diff --git a/lib/core/utils/flattened-tree.js b/lib/core/utils/flattened-tree.js index e07a6b7a5b..8b78eebf10 100644 --- a/lib/core/utils/flattened-tree.js +++ b/lib/core/utils/flattened-tree.js @@ -1,3 +1,4 @@ +/* global VirtualNode */ /*eslint no-use-before-define: 0*/ var axe = axe || { utils: {} }; @@ -19,39 +20,6 @@ var axe = axe || { utils: {} }; * the spec for this) */ -/** - * Wrap the real node and provide list of the flattened children - * - * @param node {Node} - the node in question - * @param shadowId {String} - the ID of the shadow DOM to which this node belongs - * @return {Object} - the wrapped node - */ -function virtualDOMfromNode(node, shadowId) { - const vNodeCache = {}; - const vNode = { - shadowId: shadowId, - children: [], - actualNode: node, - _isHidden: null, // will be populated by axe.utils.isHidden - get isFocusable() { - if (!vNodeCache.hasOwnProperty('_isFocusable')) { - vNodeCache._isFocusable = axe.commons.dom.isFocusable(node); - } - return vNodeCache._isFocusable; - }, - get tabbableElements() { - if (!vNodeCache.hasOwnProperty('_tabbableElements')) { - vNodeCache._tabbableElements = axe.commons.dom.getTabbableElements( - this - ); - } - return vNodeCache._tabbableElements; - } - }; - axe._cache.get('nodeMap').set(node, vNode); - return vNode; -} - /** * find all the fallback content for a and return these as an array * this array will also include any #text nodes @@ -98,7 +66,7 @@ function flattenTree(node, shadowId) { if (axe.utils.isShadowRoot(node)) { // generate an ID for this shadow root and overwrite the current // closure shadowId with this value so that it cascades down the tree - retVal = virtualDOMfromNode(node, shadowId); + retVal = new VirtualNode(node, shadowId); shadowId = 'a' + Math.random() @@ -128,7 +96,7 @@ function flattenTree(node, shadowId) { if (false && styl.display !== 'contents') { // intentionally commented out // has a box - retVal = virtualDOMfromNode(node, shadowId); + retVal = new VirtualNode(node, shadowId); retVal.children = realArray.reduce(reduceShadowDOM, []); return [retVal]; } else { @@ -136,13 +104,13 @@ function flattenTree(node, shadowId) { } } else { if (node.nodeType === 1) { - retVal = virtualDOMfromNode(node, shadowId); + retVal = new VirtualNode(node, shadowId); realArray = Array.from(node.childNodes); retVal.children = realArray.reduce(reduceShadowDOM, []); return [retVal]; } else if (node.nodeType === 3) { // text - return [virtualDOMfromNode(node)]; + return [new VirtualNode(node)]; } return undefined; } diff --git a/lib/core/utils/qsa.js b/lib/core/utils/qsa.js index 2139cb9d0f..b4d4df89ed 100644 --- a/lib/core/utils/qsa.js +++ b/lib/core/utils/qsa.js @@ -4,34 +4,29 @@ var matchExpressions = function() {}; // todo: implement an option to follow aria-owns -function matchesTag(node, exp) { +function matchesTag(vNode, exp) { return ( - node.nodeType === 1 && - (exp.tag === '*' || node.nodeName.toLowerCase() === exp.tag) + vNode.elementNodeType === 1 && + (exp.tag === '*' || vNode.elementNodeName === exp.tag) ); } -function matchesClasses(node, exp) { - return ( - !exp.classes || - exp.classes.reduce((result, cl) => { - return result && (node.className && node.className.match(cl.regexp)); - }, true) - ); +function matchesClasses(vNode, exp) { + return !exp.classes || exp.classes.every(cl => vNode.hasClass(cl.value)); } -function matchesAttributes(node, exp) { +function matchesAttributes(vNode, exp) { return ( !exp.attributes || exp.attributes.reduce((result, att) => { - var nodeAtt = node.getAttribute(att.key); + var nodeAtt = vNode.attr(att.key); return result && nodeAtt !== null && (!att.value || att.test(nodeAtt)); }, true) ); } -function matchesId(node, exp) { - return !exp.id || node.id === exp.id; +function matchesId(vNode, exp) { + return !exp.id || vNode.elementId === exp.id; } function matchesPseudos(target, exp) { @@ -194,40 +189,40 @@ convertExpressions = function(expressions) { }); }; -function createLocalVariables(nodes, anyLevel, thisLevel, parentShadowId) { +function createLocalVariables(vNodes, anyLevel, thisLevel, parentShadowId) { let retVal = { - nodes: nodes.slice(), + vNodes: vNodes.slice(), anyLevel: anyLevel, thisLevel: thisLevel, parentShadowId: parentShadowId }; - retVal.nodes.reverse(); + retVal.vNodes.reverse(); return retVal; } -function matchesSelector(node, exp) { +function matchesSelector(vNode, exp) { return ( - matchesTag(node.actualNode, exp[0]) && - matchesClasses(node.actualNode, exp[0]) && - matchesAttributes(node.actualNode, exp[0]) && - matchesId(node.actualNode, exp[0]) && - matchesPseudos(node, exp[0]) + matchesTag(vNode, exp[0]) && + matchesClasses(vNode, exp[0]) && + matchesAttributes(vNode, exp[0]) && + matchesId(vNode, exp[0]) && + matchesPseudos(vNode, exp[0]) ); } matchExpressions = function(domTree, expressions, recurse, filter) { let stack = []; - let nodes = Array.isArray(domTree) ? domTree : [domTree]; + let vNodes = Array.isArray(domTree) ? domTree : [domTree]; let currentLevel = createLocalVariables( - nodes, + vNodes, expressions, [], domTree[0].shadowId ); let result = []; - while (currentLevel.nodes.length) { - let node = currentLevel.nodes.pop(); + while (currentLevel.vNodes.length) { + let vNode = currentLevel.vNodes.pop(); let childOnly = []; // we will add hierarchical '>' selectors here let childAny = []; let combined = currentLevel.anyLevel.slice().concat(currentLevel.thisLevel); @@ -236,12 +231,12 @@ matchExpressions = function(domTree, expressions, recurse, filter) { for (let i = 0; i < combined.length; i++) { let exp = combined[i]; if ( - matchesSelector(node, exp) && - (!exp[0].id || node.shadowId === currentLevel.parentShadowId) + matchesSelector(vNode, exp) && + (!exp[0].id || vNode.shadowId === currentLevel.parentShadowId) ) { if (exp.length === 1) { - if (!added && (!filter || filter(node))) { - result.push(node); + if (!added && (!filter || filter(vNode))) { + result.push(vNode); added = true; } } else { @@ -263,23 +258,23 @@ matchExpressions = function(domTree, expressions, recurse, filter) { } if ( currentLevel.anyLevel.includes(exp) && - (!exp[0].id || node.shadowId === currentLevel.parentShadowId) + (!exp[0].id || vNode.shadowId === currentLevel.parentShadowId) ) { childAny.push(exp); } } // "recurse" - if (node.children && node.children.length && recurse) { + if (vNode.children && vNode.children.length && recurse) { stack.push(currentLevel); currentLevel = createLocalVariables( - node.children, + vNode.children, childAny, childOnly, - node.shadowId + vNode.shadowId ); } // check for "return" - while (!currentLevel.nodes.length && stack.length) { + while (!currentLevel.vNodes.length && stack.length) { currentLevel = stack.pop(); } } diff --git a/test/core/base/virtual-node.js b/test/core/base/virtual-node.js new file mode 100644 index 0000000000..7ed2d21822 --- /dev/null +++ b/test/core/base/virtual-node.js @@ -0,0 +1,219 @@ +/*global axe, VirtualNode */ +describe('VirtualNode', function() { + 'use strict'; + var node; + + beforeEach(function() { + node = document.createElement('div'); + }); + + it('should be a function', function() { + assert.isFunction(VirtualNode); + }); + + it('should accept two parameters', function() { + assert.lengthOf(VirtualNode, 2); + }); + + describe('prototype', function() { + it('should have public properties', function() { + var vNode = new VirtualNode(node, 'foo'); + + assert.equal(vNode.shadowId, 'foo'); + assert.typeOf(vNode.children, 'array'); + assert.equal(vNode.actualNode, node); + }); + + it('should abstract Node and Element APIs', function() { + node.id = 'monkeys'; + var vNode = new VirtualNode(node); + + assert.equal(vNode.elementNodeType, 1); + assert.equal(vNode.elementNodeName, 'div'); + assert.equal(vNode.elementId, 'monkeys'); + }); + + it('should lowercase nodeName', function() { + var node = { + nodeName: 'FOOBAR' + }; + var vNode = new VirtualNode(node); + + assert.equal(vNode.elementNodeName, 'foobar'); + }); + + describe('hasClass', function() { + it('should return true when the element has the class', function() { + node.setAttribute('class', 'my-class'); + var vNode = new VirtualNode(node); + + assert.isTrue(vNode.hasClass('my-class')); + }); + + it('should return true when the element contains more than one class', function() { + node.setAttribute('class', 'my-class a11y-focus visually-hidden'); + var vNode = new VirtualNode(node); + + assert.isTrue(vNode.hasClass('my-class')); + assert.isTrue(vNode.hasClass('a11y-focus')); + assert.isTrue(vNode.hasClass('visually-hidden')); + }); + + it('should return false when the element does not contain the class', function() { + var vNode = new VirtualNode(node); + + assert.isFalse(vNode.hasClass('my-class')); + }); + + it('should return false when the element contains only part of the class', function() { + node.setAttribute('class', 'my-class'); + var vNode = new VirtualNode(node); + + assert.isFalse(vNode.hasClass('class')); + }); + + it('should return false for text nodes', function() { + node.textContent = 'hello'; + var vNode = new VirtualNode(node.firstChild); + + assert.isFalse(vNode.hasClass('my-class')); + }); + + it('should return false if className is not of type string', function() { + var node = { + nodeName: 'DIV', + className: null + }; + var vNode = new VirtualNode(node); + + assert.isFalse(vNode.hasClass('my-class')); + }); + + it('should return true for whitespace characters', function() { + node.setAttribute( + 'class', + 'my-class\ta11y-focus\rvisually-hidden\ngrid\fcontainer' + ); + var vNode = new VirtualNode(node); + + assert.isTrue(vNode.hasClass('my-class')); + assert.isTrue(vNode.hasClass('a11y-focus')); + assert.isTrue(vNode.hasClass('visually-hidden')); + assert.isTrue(vNode.hasClass('grid')); + assert.isTrue(vNode.hasClass('container')); + }); + }); + + describe('attr', function() { + it('should return the value of the given attribute', function() { + node.setAttribute('data-foo', 'bar'); + var vNode = new VirtualNode(node); + + assert.equal(vNode.attr('data-foo'), 'bar'); + }); + + it('should return null for text nodes', function() { + node.textContent = 'hello'; + var vNode = new VirtualNode(node.firstChild); + + assert.isNull(vNode.attr('data-foo')); + }); + + it('should return null if getAttribute is not a function', function() { + var node = { + nodeName: 'DIV', + getAttribute: null + }; + var vNode = new VirtualNode(node); + + assert.isNull(vNode.attr('data-foo')); + }); + }); + + describe('isFocusable', function() { + var commons; + + beforeEach(function() { + commons = axe.commons = axe.commons; + }); + + afterEach(function() { + axe.commons = commons; + }); + + it('should call dom.isFocusable', function() { + var called = false; + axe.commons = { + dom: { + isFocusable: function() { + called = true; + } + } + }; + var vNode = new VirtualNode(node); + vNode.isFocusable; + + assert.isTrue(called); + }); + + it('should only call dom.isFocusable once', function() { + var count = 0; + axe.commons = { + dom: { + isFocusable: function() { + count++; + } + } + }; + var vNode = new VirtualNode(node); + vNode.isFocusable; + vNode.isFocusable; + vNode.isFocusable; + assert.equal(count, 1); + }); + }); + + describe('tabbableElements', function() { + var commons; + + beforeEach(function() { + commons = axe.commons = axe.commons; + }); + + afterEach(function() { + axe.commons = commons; + }); + + it('should call dom.getTabbableElements', function() { + var called = false; + axe.commons = { + dom: { + getTabbableElements: function() { + called = true; + } + } + }; + var vNode = new VirtualNode(node); + vNode.tabbableElements; + + assert.isTrue(called); + }); + + it('should only call dom.getTabbableElements once', function() { + var count = 0; + axe.commons = { + dom: { + getTabbableElements: function() { + count++; + } + } + }; + var vNode = new VirtualNode(node); + vNode.tabbableElements; + vNode.tabbableElements; + vNode.tabbableElements; + assert.equal(count, 1); + }); + }); + }); +}); diff --git a/test/core/utils/qsa.js b/test/core/utils/qsa.js index 7c9aeaf0cb..3cc4a2519f 100644 --- a/test/core/utils/qsa.js +++ b/test/core/utils/qsa.js @@ -1,116 +1,48 @@ -function Vnode(nodeName, className, attributes, id) { - 'use strict'; - this.nodeName = nodeName.toUpperCase(); - this.id = id; - this.attributes = attributes || []; - this.className = className; - this.nodeType = 1; - - this.attributes.push({ - key: 'id', - value: typeof id !== 'undefined' ? id : null - }); - this.attributes.push({ - key: 'class', - value: typeof className !== 'undefined' ? className : null - }); +function setShadowId(vNode, shadowId) { + vNode.shadowId = shadowId; + for (var i = 0; i < vNode.children.length; i++) { + setShadowId(vNode.children[i], shadowId); + } } -Vnode.prototype.getAttribute = function(att) { - 'use strict'; - var attribute = this.attributes.find(function(item) { - return item.key === att; - }); - return attribute ? attribute.value : null; -}; - function getTestDom() { 'use strict'; - return [ - { - actualNode: new Vnode('html'), - children: [ - { - actualNode: new Vnode('body'), - children: [ - { - actualNode: new Vnode('div', 'first', [ - { - key: 'data-a11yhero', - value: 'faulkner' - } - ]), - shadowId: 'a', - children: [ - { - actualNode: new Vnode('ul'), - shadowId: 'a', - children: [ - { - actualNode: new Vnode('li', 'breaking'), - shadowId: 'a', - children: [] - }, - { - actualNode: new Vnode('li', 'breaking'), - shadowId: 'a', - children: [] - } - ] - } - ] - }, - { - actualNode: new Vnode('div', '', [], 'one'), - children: [] - }, - { - actualNode: new Vnode('div', 'second third'), - shadowId: 'b', - children: [ - { - actualNode: new Vnode('ul'), - shadowId: 'b', - children: [ - { - actualNode: new Vnode( - 'li', - undefined, - [ - { - key: 'role', - value: 'tab' - } - ], - 'one' - ), - shadowId: 'b', - children: [] - }, - { - actualNode: new Vnode( - 'li', - undefined, - [ - { - key: 'role', - value: 'button' - } - ], - 'one' - ), - shadowId: 'c', - children: [] - } - ] - } - ] - } - ] - } - ] - } - ]; + var html = document.createElement('html'); + html.innerHTML = + '' + + '' + + '
' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + ''; + + // remove the head node + var head = html.querySelector('head'); + if (head) { + head.parentNode.removeChild(head); + } + + var tree = axe.utils.getFlattenedTree(html); + + // setup shadowIds for testing + var first = axe.utils.getNodeFromTree(html.querySelector('.first')); + var second = axe.utils.getNodeFromTree(html.querySelector('.second')); + setShadowId(first, 'a'); + setShadowId(second, 'b'); + axe.utils.getNodeFromTree(html.querySelector('[role="button"]')).shadowId = + 'c'; + + return tree; } describe('axe.utils.querySelectorAllFilter', function() {