UNPKG

@jitsi/js-utils

Version:

Utilities for Jitsi JS projects

233 lines (232 loc) 9.61 kB
// 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; }