query-selector-shadow-dom
Version:
use querySelector syntax to search for nodes inside of (nested) shadow roots
177 lines (155 loc) • 6.57 kB
JavaScript
import { normalizeSelector } from './normalize';
/**
* Finds first matching elements on the page that may be in a shadow root using a complex selector of n-depth
*
* Don't have to specify all shadow roots to button, tree is travered to find the correct element
*
* Example querySelectorAllDeep('downloads-item:nth-child(4) #remove');
*
* Example should work on chrome://downloads outputting the remove button inside of a download card component
*
* Example find first active download link element querySelectorDeep('#downloads-list .is-active a[href^="https://"]');
*
* Another example querySelectorAllDeep('#downloads-list div#title-area + a');
e.g.
*/
export function querySelectorAllDeep(selector, root = document, allElements = null) {
return _querySelectorDeep(selector, true, root, allElements);
}
export function querySelectorDeep(selector, root = document, allElements = null) {
return _querySelectorDeep(selector, false, root, allElements);
}
function _querySelectorDeep(selector, findMany, root, allElements = null) {
selector = normalizeSelector(selector);
let lightElement = root.querySelector(selector);
if (document.head.createShadowRoot || document.head.attachShadow) {
// no need to do any special if selector matches something specific in light-dom
if (!findMany && lightElement) {
return lightElement;
}
// split on commas because those are a logical divide in the operation
const selectionsToMake = splitByCharacterUnlessQuoted(selector, ',');
return selectionsToMake.reduce((acc, minimalSelector) => {
// if not finding many just reduce the first match
if (!findMany && acc) {
return acc;
}
// do best to support complex selectors and split the query
const splitSelector = splitByCharacterUnlessQuoted(minimalSelector
//remove white space at start of selector
.replace(/^\s+/g, '')
.replace(/\s*([>+~]+)\s*/g, '$1'), ' ')
// filter out entry white selectors
.filter((entry) => !!entry)
// convert "a > b" to ["a", "b"]
.map((entry) => splitByCharacterUnlessQuoted(entry, '>'));
const possibleElementsIndex = splitSelector.length - 1;
const lastSplitPart = splitSelector[possibleElementsIndex][splitSelector[possibleElementsIndex].length - 1];
const possibleElements = collectAllElementsDeep(lastSplitPart, root, allElements);
const findElements = findMatchingElement(splitSelector, possibleElementsIndex, root);
if (findMany) {
acc = acc.concat(possibleElements.filter(findElements));
return acc;
} else {
acc = possibleElements.find(findElements);
return acc || null;
}
}, findMany ? [] : null);
} else {
if (!findMany) {
return lightElement;
} else {
return root.querySelectorAll(selector);
}
}
}
function findMatchingElement(splitSelector, possibleElementsIndex, root) {
return (element) => {
let position = possibleElementsIndex;
let parent = element;
let foundElement = false;
while (parent && !isDocumentNode(parent)) {
let foundMatch = true;
if (splitSelector[position].length === 1) {
foundMatch = parent.matches(splitSelector[position]);
} else {
// selector is in the format "a > b"
// make sure a few parents match in order
const reversedParts = ([]).concat(splitSelector[position]).reverse();
let newParent = parent;
for (const part of reversedParts) {
if (!newParent || !newParent.matches(part)) {
foundMatch = false;
break;
}
newParent = findParentOrHost(newParent, root);
}
}
if (foundMatch && position === 0) {
foundElement = true;
break;
}
if (foundMatch) {
position--;
}
parent = findParentOrHost(parent, root);
}
return foundElement;
};
}
function splitByCharacterUnlessQuoted(selector, character) {
return selector.match(/\\?.|^$/g).reduce((p, c) => {
if (c === '"' && !p.sQuote) {
p.quote ^= 1;
p.a[p.a.length - 1] += c;
} else if (c === '\'' && !p.quote) {
p.sQuote ^= 1;
p.a[p.a.length - 1] += c;
} else if (!p.quote && !p.sQuote && c === character) {
p.a.push('');
} else {
p.a[p.a.length - 1] += c;
}
return p;
}, { a: [''] }).a;
}
/**
* Checks if the node is a document node or not.
* @param {Node} node
* @returns {node is Document | DocumentFragment}
*/
function isDocumentNode(node) {
return node.nodeType === Node.DOCUMENT_FRAGMENT_NODE || node.nodeType === Node.DOCUMENT_NODE;
}
function findParentOrHost(element, root) {
const parentNode = element.parentNode;
return (parentNode && parentNode.host && parentNode.nodeType === 11) ? parentNode.host : parentNode === root ? null : parentNode;
}
/**
* Finds all elements on the page, inclusive of those within shadow roots.
* @param {string=} selector Simple selector to filter the elements by. e.g. 'a', 'div.main'
* @return {!Array<string>} List of anchor hrefs.
* @author ebidel@ (Eric Bidelman)
* License Apache-2.0
*/
export function collectAllElementsDeep(selector = null, root, cachedElements = null) {
let allElements = [];
if (cachedElements) {
allElements = cachedElements;
} else {
const findAllElements = function(nodes) {
for (let i = 0; i < nodes.length; i++) {
const el = nodes[i];
allElements.push(el);
// If the element has a shadow root, dig deeper.
if (el.shadowRoot) {
findAllElements(el.shadowRoot.querySelectorAll('*'));
}
}
};
if(root.shadowRoot) {
findAllElements(root.shadowRoot.querySelectorAll('*'));
}
findAllElements(root.querySelectorAll('*'));
}
return selector ? allElements.filter(el => el.matches(selector)) : allElements; }