@jitsi/js-utils
Version:
Utilities for Jitsi JS projects
233 lines (232 loc) • 9.61 kB
JavaScript
// Regex constants for efficient reuse across selector parsing.
const SIMPLE_TAG_NAME_REGEX = /^[a-zA-Z][\w-]*$/;
const MULTI_ATTRIBUTE_SELECTOR_REGEX = /^([a-zA-Z][\w-]*)?(\[(?:\*\|)?([^=\]]+)=["']?([^"'\]]+)["']?\])+$/;
const SINGLE_ATTRIBUTE_REGEX = /\[(?:\*\|)?([^=\]]+)=["']?([^"'\]]+)["']?\]/g;
const WHITESPACE_AROUND_COMBINATOR_REGEX = /\s*>\s*/g;
/**
* Parses a CSS selector into reusable components.
*
* @param {string} selector - The CSS selector to parse.
* @returns {IParsedSelector} - Object with tagName and attrConditions properties.
*/
function _parseSelector(selector) {
// Wildcard selector
if (selector === '*') {
return {
tagName: null, // null means match all tag names
attrConditions: []
};
}
// Simple tag name
if (SIMPLE_TAG_NAME_REGEX.test(selector)) {
return {
tagName: selector,
attrConditions: []
};
}
// Attribute selector: tagname[attr="value"] or
// tagname[attr1="value1"][attr2="value2"] (with optional wildcard namespace)
const multiAttrMatch = selector.match(MULTI_ATTRIBUTE_SELECTOR_REGEX);
if (multiAttrMatch) {
const tagName = multiAttrMatch[1];
const attrConditions = [];
let attrMatch;
while ((attrMatch = SINGLE_ATTRIBUTE_REGEX.exec(selector)) !== null) {
attrConditions.push({
name: attrMatch[1], // This properly strips the *| prefix
value: attrMatch[2]
});
}
return {
tagName,
attrConditions
};
}
// Unsupported selector
throw new SyntaxError(`Unsupported selector pattern: '${selector}'`);
}
function _filterAndMatchElements(elements, selector, findFirst) {
const { tagName, attrConditions } = _parseSelector(selector);
const results = [];
for (const element of elements) {
// Check tag name if specified
if (tagName && !(element.localName === tagName || element.tagName === tagName)) {
continue;
}
// Check if all attribute conditions match
const allMatch = attrConditions.every(condition => element.getAttribute(condition.name) === condition.value);
if (allMatch) {
results.push(element);
if (findFirst) {
return element;
}
}
}
return findFirst ? null : results;
}
function _traverseDirectChildren(startElements, selectorParts, findFirst) {
let currentElements = startElements;
for (const part of selectorParts) {
const nextElements = [];
currentElements.forEach(el => {
// Get direct children
const directChildren = Array.from(el.children || []);
// Use same helper as handlers
const matchingChildren = _filterAndMatchElements(directChildren, part, false);
nextElements.push(...matchingChildren);
});
currentElements = nextElements;
// If we have no results, we can stop early (applies to both querySelector and querySelectorAll)
if (currentElements.length === 0) {
return findFirst ? null : [];
}
}
return findFirst ? currentElements[0] || null : currentElements;
}
function _handleScopeSelector(element, selector, findFirst) {
let searchSelector = selector.substring(6);
// Handle :scope > tagname (direct children)
if (searchSelector.startsWith('>')) {
searchSelector = searchSelector.substring(1);
// Split by > and use shared traversal logic
const parts = searchSelector.split('>');
// Start from the element itself (scope)
return _traverseDirectChildren([element], parts, findFirst);
}
return null;
}
function _handleDirectChildSelectors(element, selector, findFirst) {
const parts = selector.split('>');
// First find elements matching the first part (this could be descendants, not just direct children)
const startElements = _querySelectorInternal(element, parts[0], false);
// If no starting elements found, return early
if (startElements.length === 0) {
return findFirst ? null : [];
}
// Use shared traversal logic for the remaining parts
return _traverseDirectChildren(startElements, parts.slice(1), findFirst);
}
function _handleSimpleTagSelector(element, selector, findFirst) {
const elements = Array.from(element.getElementsByTagName(selector));
if (findFirst) {
return elements[0] || null;
}
return elements;
}
function _handleAttributeSelector(element, selector, findFirst) {
const { tagName } = _parseSelector(selector); // Just to get tagName for optimization
// Handler's job: find the right elements to search
const elementsToCheck = (tagName
? Array.from(element.getElementsByTagName(tagName))
: Array.from(element.getElementsByTagName('*')));
// Common helper does the matching
return _filterAndMatchElements(elementsToCheck, selector, findFirst);
}
function _querySelectorInternal(element, selector, findFirst) {
// Normalize whitespace around > combinators first
const normalizedSelector = selector.replace(WHITESPACE_AROUND_COMBINATOR_REGEX, '>');
// Handle :scope pseudo-selector
if (normalizedSelector.startsWith(':scope')) {
return _handleScopeSelector(element, normalizedSelector, findFirst);
}
// Handle nested > selectors (direct child combinators)
if (normalizedSelector.includes('>')) {
return _handleDirectChildSelectors(element, normalizedSelector, findFirst);
}
// Fast path: simple tag name
if (normalizedSelector === '*' || SIMPLE_TAG_NAME_REGEX.test(normalizedSelector)) {
return _handleSimpleTagSelector(element, normalizedSelector, findFirst);
}
// Attribute selector: tagname[attr="value"] or
// tagname[attr1="value1"][attr2="value2"] (with optional wildcard namespace)
if (normalizedSelector.match(MULTI_ATTRIBUTE_SELECTOR_REGEX)) {
return _handleAttributeSelector(element, normalizedSelector, findFirst);
}
// Unsupported selector - throw SyntaxError to match browser behavior
throw new SyntaxError(`Failed to execute 'querySelector${findFirst ? '' : 'All'}' on 'Element': '${selector}' is not a valid selector.`);
}
/**
* Converts a Node to an array of Elements for searching.
* Handles Document, DocumentFragment, and Element nodes.
*
* @param {Node} node - The Node to convert.
* @returns {Element[]} - Array of Elements to search.
*/
function _getSearchElements(node) {
const { nodeType } = node;
// Document (nodeType 9)
if (nodeType === 9) {
const doc = node;
return doc.documentElement ? [doc.documentElement] : [];
}
// DocumentFragment (nodeType 11)
if (nodeType === 11) {
const frag = node;
return Array.from(frag.children || []);
}
// Element (nodeType 1) or other
return [node];
}
/**
* Implements querySelector functionality using the shared internal logic.
* Supports the same selectors as querySelectorAll but returns only the first match.
*
* @param {Node} node - The Node which is the root of the tree to query.
* @param {string} selectors - The CSS selector to match elements against.
* @returns {Element | null} - The first Element which matches the selector, or null.
*/
export function querySelector(node, selectors) {
const searchElements = _getSearchElements(node);
const { nodeType } = node;
// For Document/DocumentFragment, check if any root element matches selector
// Only simple selectors can match root elements; complex selectors will throw, which is fine
if (nodeType === 9 || nodeType === 11) {
try {
const match = _filterAndMatchElements(searchElements, selectors, true);
if (match) {
return match;
}
}
catch {
// Complex selector (multilevel selectors) - can't match root element, will search descendants below
}
}
// Search descendants of all root elements
for (const element of searchElements) {
const result = _querySelectorInternal(element, selectors, true);
if (result) {
return result;
}
}
return null;
}
/**
* Implements querySelectorAll functionality using the shared internal logic.
* Supports :scope pseudo-selector, direct child selectors, and common CSS selectors.
*
* @param {Node} node - The Node which is the root of the tree to query.
* @param {string} selector - The CSS selector to match elements against.
* @returns {Element[]} - Array of Elements matching the selector.
*/
export function querySelectorAll(node, selector) {
const searchElements = _getSearchElements(node);
const { nodeType } = node;
const results = [];
// For Document/DocumentFragment, check if any root element matches selector
// Only simple selectors can match root elements; complex selectors will throw, which is fine
if (nodeType === 9 || nodeType === 11) {
try {
const matches = _filterAndMatchElements(searchElements, selector, false);
results.push(...matches);
}
catch {
// Complex selector (multilevel selectors) - can't match root element, will search descendants below
}
}
// Search descendants of all root elements
for (const element of searchElements) {
const descendants = _querySelectorInternal(element, selector, false);
results.push(...descendants);
}
return results;
}