Skip to content

Commit

Permalink
feat(qsa, flatten-tree): abstract Node and Element apis in virtual tr…
Browse files Browse the repository at this point in the history
…ee (#1562)

* feat(qsa, flatten-tree): abstract Node and Element apis in virtual tree

* create VirtualNode constructor function and tests

* make VirtualNode a class, happy path

* fix non-supported IE11 .remove()

* typecheck propertly

* convert new code to use vNode cache function rather than property

* rename node to vNode

* change back to getters

* fix tests
  • Loading branch information
straker authored May 24, 2019
1 parent a4b5240 commit 2f2e590
Show file tree
Hide file tree
Showing 5 changed files with 381 additions and 182 deletions.
85 changes: 85 additions & 0 deletions lib/core/base/virtual-node.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
42 changes: 5 additions & 37 deletions lib/core/utils/flattened-tree.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* global VirtualNode */
/*eslint no-use-before-define: 0*/
var axe = axe || { utils: {} };

Expand All @@ -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 <slot> and return these as an array
* this array will also include any #text nodes
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -128,21 +96,21 @@ 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 {
return realArray.reduce(reduceShadowDOM, []);
}
} 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;
}
Expand Down
67 changes: 31 additions & 36 deletions lib/core/utils/qsa.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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();
}
}
Expand Down
Loading

0 comments on commit 2f2e590

Please sign in to comment.