diff --git a/lib/core/utils/qsa.js b/lib/core/utils/qsa.js
index 1a6db08905..ac32c21ad7 100644
--- a/lib/core/utils/qsa.js
+++ b/lib/core/utils/qsa.js
@@ -29,7 +29,7 @@ function matchesPseudos (target, exp) {
if (!exp.pseudos || exp.pseudos.reduce((result, pseudo) => {
if (pseudo.name === 'not') {
- return result && !matchExpressions([target], pseudo.expressions, false).length;
+ return result && !matchExpressions([target], pseudo.expressions, false, target.shadowId).length;
}
throw new Error('the pseudo selector ' + pseudo.name + ' has not yet been implemented');
}, true)) {
@@ -38,27 +38,6 @@ function matchesPseudos (target, exp) {
return false;
}
-function matchSelector (targets, exp, recurse) {
- var result = [];
-
- targets = Array.isArray(targets) ? targets : [targets];
- targets.forEach((target) => {
- if (matchesTag(target.actualNode, exp) &&
- matchesClasses(target.actualNode, exp) &&
- matchesAttributes(target.actualNode, exp) &&
- matchesId(target.actualNode, exp) &&
- matchesPseudos(target, exp)) {
- result.push(target);
- }
- if (recurse) {
- result = result.concat(matchSelector(target.children.filter((child) => {
- return !exp.id || child.shadowId === target.shadowId;
- }), exp, recurse));
- }
- });
- return result;
-}
-
var escapeRegExp = (function(){
/*! Credit: XRegExp 0.6.1 (c) 2007-2008 Steven Levithan MIT License */
var from = /(?=[\-\[\]{}()*+?.\\\^$|,#\s])/g;
@@ -194,27 +173,80 @@ convertExpressions = function (expressions) {
});
};
-matchExpressions = function (domTree, expressions, recurse) {
- return expressions.reduce((collected, exprArr) => {
- var candidates = domTree;
- exprArr.forEach((exp, index) => {
- recurse = exp.combinator === '>' ? false : recurse;
- if ([' ', '>'].includes(exp.combinator) === false) {
- throw new Error('axe.utils.querySelectorAll does not support the combinator: ' + exp.combinator);
+function createLocalVariables (nodes, anyLevel, thisLevel, parentShadowId) {
+ let retVal = {
+ nodes: nodes.slice(),
+ anyLevel: anyLevel,
+ thisLevel: thisLevel,
+ parentShadowId: parentShadowId
+ };
+ retVal.nodes.reverse();
+ return retVal;
+}
+
+function matchesSelector (node, 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])
+ );
+}
+
+matchExpressions = function (domTree, expressions, recurse, parentShadowId, filter) {
+ //jshint maxstatements:34
+ //jshint maxcomplexity:15
+ let stack = [];
+ let nodes = Array.isArray(domTree) ? domTree : [domTree];
+ let currentLevel = createLocalVariables(nodes, expressions, [], parentShadowId);
+ let result = [];
+
+ while (currentLevel.nodes.length) {
+ let node = currentLevel.nodes.pop();
+ let childOnly = []; // we will add hierarchical '>' selectors here
+ let childAny = [];
+ let combined = currentLevel.anyLevel.slice().concat(currentLevel.thisLevel);
+ let added = false;
+ // see if node matches
+ for ( let i = 0; i < combined.length; i++) {
+ let exp = combined[i];
+ if (matchesSelector(node, exp) &&
+ (!exp[0].id || node.shadowId === currentLevel.parentShadowId)) {
+ if (exp.length === 1) {
+ if (!added && (!filter || filter(node))) {
+ result.push(node);
+ added = true;
+ }
+ } else {
+ let rest = exp.slice(1);
+ if ([' ', '>'].includes(rest[0].combinator) === false) {
+ throw new Error('axe.utils.querySelectorAll does not support the combinator: ' + exp[1].combinator);
+ }
+ if (rest[0].combinator === '>') {
+ // add the rest to the childOnly array
+ childOnly.push(rest);
+ } else {
+ // add the rest to the childAny array
+ childAny.push(rest);
+ }
+ }
}
- candidates = candidates.reduce((result, node) => {
- return result.concat(matchSelector(index ? node.children : node, exp, recurse));
- }, []);
- });
-
- // Ensure elements aren't added multiple times
- return candidates.reduce((collected, candidate) => {
- if (collected.includes(candidate) === false) {
- collected.push(candidate);
+ if (currentLevel.anyLevel.includes(exp) &&
+ (!exp[0].id || node.shadowId === currentLevel.parentShadowId)) {
+ childAny.push(exp);
}
- return collected;
- }, collected);
- }, []);
+ }
+ // "recurse"
+ if (node.children && node.children.length && recurse) {
+ stack.push(currentLevel);
+ currentLevel = createLocalVariables(node.children, childAny, childOnly, node.shadowId);
+ }
+ // check for "return"
+ while (!currentLevel.nodes.length && stack.length) {
+ currentLevel = stack.pop();
+ }
+ }
+ return result;
};
/**
@@ -227,9 +259,26 @@ matchExpressions = function (domTree, expressions, recurse) {
* @return {NodeList} Elements matched by any of the selectors
*/
axe.utils.querySelectorAll = function (domTree, selector) {
+ return axe.utils.querySelectorAllFilter(domTree, selector);
+};
+
+/**
+ * querySelectorAllFilter implements querySelectorAll on the virtual DOM with
+ * ability to filter the returned nodes using an optional supplied filter function
+ *
+ * @method querySelectorAll
+ * @memberof axe.utils
+ * @instance
+ * @param {NodeList} domTree flattened tree collection to search
+ * @param {String} selector String containing one or more CSS selectors separated by commas
+ * @param {Function} filter function (optional)
+ * @return {NodeList} Elements matched by any of the selectors and filtered by the filter function
+ */
+
+axe.utils.querySelectorAllFilter = function (domTree, selector, filter) {
domTree = Array.isArray(domTree) ? domTree : [domTree];
var expressions = axe.utils.cssParser.parse(selector);
expressions = expressions.selectors ? expressions.selectors : [expressions];
expressions = convertExpressions(expressions);
- return matchExpressions(domTree, expressions, true);
+ return matchExpressions(domTree, expressions, true, domTree[0].shadowId, filter);
};
diff --git a/lib/core/utils/select.js b/lib/core/utils/select.js
index 029db974cf..a751df14ab 100644
--- a/lib/core/utils/select.js
+++ b/lib/core/utils/select.js
@@ -73,6 +73,22 @@ function pushNode(result, nodes, context) {
return result;
}
+/**
+ * returns true if any of the nodes in the list is a parent of another node in the list
+ * @param {Array} the array of include nodes
+ * @return {Boolean}
+ */
+function hasOverlappingIncludes(includes) {
+ let list = includes.slice();
+ while (list.length > 1) {
+ let last = list.pop();
+ if (list[list.length - 1].actualNode.contains(last.actualNode)) {
+ return true;
+ }
+ }
+ return false;
+}
+
/**
* Selects elements which match `selector` that are included and excluded via the `Context` object
* @param {String} selector CSS selector of the HTMLElements to select
@@ -83,6 +99,10 @@ axe.utils.select = function select(selector, context) {
'use strict';
var result = [], candidate;
+ if (!Array.isArray(context.include)) {
+ context.include = Array.from(context.include);
+ }
+ context.include.sort(axe.utils.nodeSorter); // ensure that the order of the include nodes is document order
for (var i = 0, l = context.include.length; i < l; i++) {
candidate = context.include[i];
if (candidate.actualNode.nodeType === candidate.actualNode.ELEMENT_NODE &&
@@ -91,6 +111,8 @@ axe.utils.select = function select(selector, context) {
}
result = pushNode(result, axe.utils.querySelectorAll(candidate, selector), context);
}
-
- return result.sort(axe.utils.nodeSorter);
+ if (context.include.length > 1 && hasOverlappingIncludes(context.include)) {
+ result.sort(axe.utils.nodeSorter);
+ }
+ return result;
};
diff --git a/test/core/utils/qsa.js b/test/core/utils/qsa.js
index d9483cc267..18109b25b9 100644
--- a/test/core/utils/qsa.js
+++ b/test/core/utils/qsa.js
@@ -109,6 +109,10 @@ describe('axe.utils.querySelectorAll', function () {
var result = axe.utils.querySelectorAll(dom, '#one');
assert.equal(result.length, 1);
});
+ it('should find nodes using id, but not in shadow DOM', function () {
+ var result = axe.utils.querySelectorAll(dom[0].children[0], '#one');
+ assert.equal(result.length, 1);
+ });
it('should find nodes using id, within a shadow DOM', function () {
var result = axe.utils.querySelectorAll(dom[0].children[0].children[2], '#one');
assert.equal(result.length, 1);
@@ -182,4 +186,10 @@ describe('axe.utils.querySelectorAll', function () {
assert.isBelow(divOnes.length, divs.length + ones.length,
'Elements matching both parts of a selector should not be included twice');
});
+ it('should return nodes sorted by document position', function () {
+ var result = axe.utils.querySelectorAll(dom, 'ul, #one');
+ assert.equal(result[0].actualNode.nodeName, 'UL');
+ assert.equal(result[1].actualNode.nodeName, 'DIV');
+ assert.equal(result[2].actualNode.nodeName, 'UL');
+ });
});
diff --git a/test/core/utils/select.js b/test/core/utils/select.js
index 48bff29677..2ac60392a6 100644
--- a/test/core/utils/select.js
+++ b/test/core/utils/select.js
@@ -136,6 +136,18 @@ describe('axe.utils.select', function () {
});
+ it('should sort by DOM order on overlapping elements', function () {
+ fixture.innerHTML = '
';
+
+ var result = axe.utils.select('.bananas', { include: [axe.utils.getFlattenedTree($id('one'))[0],
+ axe.utils.getFlattenedTree($id('zero'))[0]] });
+
+ assert.deepEqual(result.map(function (n) { return n.actualNode; }),
+ [$id('target1'), $id('target1'), $id('target2')]);
+ assert.equal(result.length, 3);
+
+ });
});
\ No newline at end of file