/** @odoo-module */ import { HootDomError, getTag, isFirefox, isIterable, parseRegExp } from "../hoot_dom_utils"; import { waitUntil } from "./time"; /** * @typedef {number | [number, number] | { * w?: number; * h?: number; * width?: number; * height?: number; * }} Dimensions * * @typedef {{ * root?: Target; * tabbable?: boolean; * }} FocusableOptions * * @typedef {{ * keepInlineTextNodes?: boolean; * tabSize?: number; * type?: "html" | "xml"; * }} FormatXmlOptions * * @typedef {{ * inline: boolean; * level: number; * value: MarkupLayerValue; * }} MarkupLayer * * @typedef {{ * close?: string; * open?: string; * textContent?: string; * }} MarkupLayerValue * * @typedef {(node: Node, selector: string) => Node[]} NodeGetter * * @typedef {string | string[] | number | boolean | File[]} NodeValue * * @typedef {number | [number, number] | { * x?: number; * y?: number; * left?: number; * top?: number, * clientX?: number; * clientY?: number; * pageX?: number; * pageY?: number; * screenX?: number; * screenY?: number; * }} Position * * @typedef {(content: string) => (node: Node, index: number, nodes: Node[]) => boolean | Node} PseudoClassPredicateBuilder * * @typedef {{ * displayed?: boolean; * exact?: number; * root?: HTMLElement; * viewPort?: boolean; * visible?: boolean; * }} QueryOptions * * @typedef {{ * trimPadding?: boolean; * }} QueryRectOptions * * @typedef {{ * raw?: boolean; * }} QueryTextOptions * * @typedef {import("./time").WaitOptions} WaitOptions */ /** * @template T * @typedef {T | Iterable} MaybeIterable */ /** * @template [T=Node] * @typedef {MaybeIterable | string | null | undefined | false} Target */ //----------------------------------------------------------------------------- // Global //----------------------------------------------------------------------------- const { Boolean, document, DOMParser, innerWidth, innerHeight, Map, MutationObserver, Number: { isInteger: $isInteger, isNaN: $isNaN, parseInt: $parseInt, parseFloat: $parseFloat }, Object: { keys: $keys, values: $values }, RegExp, Set, } = globalThis; //----------------------------------------------------------------------------- // Internal //----------------------------------------------------------------------------- /** * @param {string[]} values */ const and = (values) => { const last = values.pop(); if (values.length) { return [values.join(", "), last].join(" and "); } else { return last; } }; const compilePseudoClassRegex = () => { const customKeys = [...customPseudoClasses.keys()].filter((k) => k !== "has" && k !== "not"); return new RegExp(`:(${customKeys.join("|")})`); }; /** * @param {Element[]} elements * @param {string} selector */ const elementsMatch = (elements, selector) => { if (!elements.length) { return false; } return parseSelector(selector).some((selectorParts) => { const [baseSelector, ...filters] = selectorParts.at(-1); for (let i = 0; i < elements.length; i++) { if (baseSelector && !elements[i].matches(baseSelector)) { return false; } if (!filters.every((filter) => matchFilter(filter, elements, i))) { return false; } } return true; }); }; /** * @param {Node} node * @returns {Element | null} */ const ensureElement = (node) => { if (node) { if (isDocument(node)) { return node.documentElement; } if (isWindow(node)) { return node.document.documentElement; } if (isElement(node)) { return node; } } return null; }; /** * @param {Iterable} nodes * @param {number} level * @param {boolean} [keepInlineTextNodes] */ const extractLayers = (nodes, level, keepInlineTextNodes) => { /** @type {MarkupLayer[]} */ const layers = []; for (const node of nodes) { if (node.nodeType === Node.COMMENT_NODE) { continue; } if (node.nodeType === Node.TEXT_NODE) { const textContent = node.nodeValue.replaceAll(/\n/g, ""); const trimmedTextContent = textContent.trim(); if (trimmedTextContent) { const inline = textContent === trimmedTextContent; layers.push({ inline, level, value: { textContent: trimmedTextContent } }); } continue; } const [open, close] = node.outerHTML.replace(`>${node.innerHTML}<`, ">\n<").split("\n"); const layer = { inline: false, level, value: { open, close } }; layers.push(layer); const childLayers = extractLayers(node.childNodes, level + 1, false); if (keepInlineTextNodes && childLayers.length === 1 && childLayers[0].inline) { layer.value.textContent = childLayers[0].value.textContent; } else { layers.push(...childLayers); } } return layers; }; /** * @param {Iterable} nodesToFilter */ const filterUniqueNodes = (nodesToFilter) => { /** @type {Node[]} */ const nodes = []; for (const node of nodesToFilter) { if (isQueryableNode(node) && !nodes.includes(node)) { nodes.push(node); } } return nodes; }; /** * @param {MarkupLayer[]} layers * @param {number} tabSize */ const generateStringFromLayers = (layers, tabSize) => { const result = []; let layerIndex = 0; while (layers.length > 0) { const layer = layers[layerIndex]; const { level, value } = layer; const pad = " ".repeat(tabSize * level); let nextLayerIndex = layerIndex + 1; if (value.open) { if (value.textContent) { // node with inline textContent (no wrapping white-spaces) result.push(`${pad}${value.open}${value.textContent}${value.close}`); layers.splice(layerIndex, 1); nextLayerIndex--; } else { result.push(`${pad}${value.open}`); delete value.open; } } else { if (value.close) { result.push(`${pad}${value.close}`); } else if (value.textContent) { result.push(`${pad}${value.textContent}`); } layers.splice(layerIndex, 1); nextLayerIndex--; } if (nextLayerIndex >= layers.length) { layerIndex = nextLayerIndex - 1; continue; } const nextLayer = layers[nextLayerIndex]; if (nextLayerIndex === 0 || nextLayer.level > layers[nextLayerIndex - 1].level) { layerIndex = nextLayerIndex; } else { layerIndex = nextLayerIndex - 1; } } return result.join("\n"); }; /** * @param {Node} node * @returns {NodeValue} */ const getNodeContent = (node) => { switch (getTag(node)) { case "input": case "option": case "textarea": return getNodeValue(node); case "select": return [...node.selectedOptions].map(getNodeValue).join(","); } return getNodeText(node); }; /** * @param {string} string */ const getStringContent = (string) => string.match(R_QUOTE_CONTENT)?.[2] || string; /** * @param {string} [char] */ const isChar = (char) => Boolean(char) && R_CHAR.test(char); /** * @template T * @param {T} object * @returns {T extends Document ? true : false} */ const isDocument = (object) => object?.nodeType === Node.DOCUMENT_NODE; /** * @template T * @param {T} object * @returns {T extends Element ? true: false} */ const isElement = (object) => object?.nodeType === Node.ELEMENT_NODE; /** * @param {Node} node */ const isQueryableNode = (node) => QUERYABLE_NODE_TYPES.includes(node.nodeType); /** * @param {Element} [el] */ const isRootElement = (el) => el && R_ROOT_ELEMENT.test(el.nodeName || ""); /** * @param {Element} el */ const isShadowRoot = (el) => el.nodeType === Node.DOCUMENT_FRAGMENT_NODE && Boolean(el.host); /** * @template T * @param {T} object * @returns {T extends Window ? true : false} */ const isWindow = (object) => object?.window === object && object.constructor.name === "Window"; /** * @param {string} [char] */ const isWhiteSpace = (char) => Boolean(char) && R_HORIZONTAL_WHITESPACE.test(char); /** * @param {string} pseudoClass * @param {(node: Node) => NodeValue} getContent */ const makePatternBasedPseudoClass = (pseudoClass, getContent) => { return (content) => { let regex; try { regex = parseRegExp(content); } catch (err) { throw selectorError(pseudoClass, err.message); } if (regex instanceof RegExp) { return function containsRegExp(node) { return regex.test(String(getContent(node))); }; } else { const lowerContent = content.toLowerCase(); return function containsString(node) { return getStringContent(String(getContent(node))) .toLowerCase() .includes(lowerContent); }; } }; }; /** * * @param {string | (node: Node, index: number, nodes: Node[]) => boolean} filter * @param {Node} node * @param {number} index * @param {Node[]} allNodes * @returns */ const matchFilter = (filter, nodes, index) => { const node = nodes[index]; if (typeof filter === "function") { return filter(node, index, nodes); } else { return node.matches?.(String(filter)); } }; /** * @template T * @param {T} value * @param {(keyof T)[]} propsA * @param {(keyof T)[]} propsB * @returns {[number, number]} */ const parseNumberTuple = (value, propsA, propsB) => { let result = []; if (value && typeof value === "object") { if (isIterable(value)) { [result[0], result[1]] = [...value]; } else { for (const prop of propsA) { result[0] ??= value[prop]; } for (const prop of propsB) { result[1] ??= value[prop]; } } } else { result = [value, value]; } return result.map($parseFloat); }; /** * Parses a given selector string into a list of selector groups. * * - the return value is a list of selector `group` objects (representing comma-separated * selectors); * - a `group` is composed of one or more `part` objects (representing space-separated * selector parts inside of a group); * - a `part` is composed of a base selector (string) and zero or more 'filters' (predicates). * * @param {string} selector */ const parseSelector = (selector) => { /** * @param {string} selector */ const addToSelector = (selector) => { registerChar = false; const index = currentPart.length - 1; if (typeof currentPart[index] === "string") { currentPart[index] += selector; } else { currentPart.push(selector); } }; /** @type {(string | ReturnType)[]} */ const firstPart = [""]; const firstGroup = [firstPart]; const groups = [firstGroup]; const parens = [0, 0]; let currentGroup = groups.at(-1); let currentPart = currentGroup.at(-1); let currentPseudo = null; let currentQuote = null; let registerChar = true; for (let i = 0; i < selector.length; i++) { const char = selector[i]; registerChar = true; switch (char) { // Group separator (comma) case ",": { if (!currentQuote && !currentPseudo) { groups.push([[""]]); currentGroup = groups.at(-1); currentPart = currentGroup.at(-1); registerChar = false; } break; } // Part separator (white space) case " ": case "\t": case "\n": case "\r": case "\f": case "\v": { if (!currentQuote && !currentPseudo) { if (currentPart[0] || currentPart.length > 1) { // Only push new part if the current one is not empty // (has at least 1 character OR 1 pseudo-class filter) currentGroup.push([""]); currentPart = currentGroup.at(-1); } registerChar = false; } break; } // Quote delimiters case `'`: case `"`: { if (char === currentQuote) { currentQuote = null; } else if (!currentQuote) { currentQuote = char; } break; } // Combinators case ">": case "+": case "~": { if (!currentQuote && !currentPseudo) { while (isWhiteSpace(selector[i + 1])) { i++; } addToSelector(char); } break; } // Pseudo classes case ":": { if (!currentQuote && !currentPseudo) { let pseudo = ""; while (isChar(selector[i + 1])) { pseudo += selector[++i]; } if (customPseudoClasses.has(pseudo)) { if (selector[i + 1] === "(") { parens[0]++; i++; registerChar = false; } currentPseudo = [pseudo, ""]; } else { addToSelector(char + pseudo); } } break; } // Parentheses case "(": { if (!currentQuote) { parens[0]++; } break; } case ")": { if (!currentQuote) { parens[1]++; } break; } } if (currentPseudo) { if (parens[0] === parens[1]) { const [pseudo, content] = currentPseudo; const makeFilter = customPseudoClasses.get(pseudo); if (pseudo === "iframe" && !currentPart[0].startsWith("iframe")) { // Special case: to optimise the ":iframe" pseudo class, we // always select actual `iframe` elements. // Note that this may create "impossible" tag names (like "iframediv") // but this pseudo won't work on non-iframe elements anyway. currentPart[0] = `iframe${currentPart[0]}`; } currentPart.push(makeFilter(getStringContent(content))); currentPseudo = null; } else if (registerChar) { currentPseudo[1] += selector[i]; } } else if (registerChar) { addToSelector(selector[i]); } } return groups; }; /** * @param {string} xmlString * @param {"html" | "xml"} type */ const parseXml = (xmlString, type) => { const wrapperTag = type === "html" ? "body" : "templates"; const document = parser.parseFromString( `<${wrapperTag}>${xmlString}`, `text/${type}` ); if (document.getElementsByTagName("parsererror").length) { const trimmed = xmlString.length > 80 ? xmlString.slice(0, 80) + "…" : xmlString; throw new HootDomError( `error while parsing ${trimmed}: ${getNodeText( document.getElementsByTagName("parsererror")[0] )}` ); } return document.getElementsByTagName(wrapperTag)[0].childNodes; }; /** * Converts a CSS pixel value to a number, removing the 'px' part. * * @param {string} val */ const pixelValueToNumber = (val) => $parseFloat(val.endsWith("px") ? val.slice(0, -2) : val); /** * @param {Node[]} nodes * @param {string} selector */ const queryWithCustomSelector = (nodes, selector) => { const selectorGroups = parseSelector(selector); const foundNodes = []; for (const selectorParts of selectorGroups) { let groupNodes = nodes; for (const [partSelector, ...filters] of selectorParts) { let baseSelector = partSelector; let nodeGetter; switch (baseSelector[0]) { case "+": { nodeGetter = NEXT_SIBLING; break; } case ">": { nodeGetter = DIRECT_CHILDREN; break; } case "~": { nodeGetter = NEXT_SIBLINGS; break; } } // Slices modifier (if any) if (nodeGetter) { baseSelector = baseSelector.slice(1); } // Retrieve matching nodes and apply filters const getNodes = nodeGetter || DESCENDANTS; let currentGroupNodes = groupNodes.flatMap((node) => getNodes(node, baseSelector)); // Filter/replace nodes based on custom pseudo-classes const pseudosReturningNode = new Set(); for (const filter of filters) { const filteredGroupNodes = []; for (let i = 0; i < currentGroupNodes.length; i++) { const result = matchFilter(filter, currentGroupNodes, i); if (result === true) { filteredGroupNodes.push(currentGroupNodes[i]); } else if (result) { filteredGroupNodes.push(result); pseudosReturningNode.add(filter.name); } } if (pseudosReturningNode.size > 1) { const pseudoList = [...pseudosReturningNode]; throw selectorError( pseudoList[0], `cannot use multiple pseudo-classes returning nodes (${and(pseudoList)})` ); } currentGroupNodes = filteredGroupNodes; } groupNodes = currentGroupNodes; } foundNodes.push(...groupNodes); } return filterUniqueNodes(foundNodes); }; /** * @param {string} pseudoClass * @param {string} message */ const selectorError = (pseudoClass, message) => new HootDomError(`invalid selector \`:${pseudoClass}\`: ${message}`); // Regexes const R_CHAR = /[\w-]/; const R_QUOTE_CONTENT = /^\s*(['"])?([^]*?)\1\s*$/; const R_ROOT_ELEMENT = /^(HTML|HEAD|BODY)$/; /** * \s without \n and \v */ const R_HORIZONTAL_WHITESPACE = /[\r\t\f \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g; const QUERYABLE_NODE_TYPES = [Node.ELEMENT_NODE, Node.DOCUMENT_NODE, Node.DOCUMENT_FRAGMENT_NODE]; const parser = new DOMParser(); // Node getters /** @type {NodeGetter} */ const DIRECT_CHILDREN = (node, selector) => { const children = []; for (const childNode of node.childNodes) { if (childNode.matches?.(selector)) { children.push(childNode); } } return children; }; /** @type {NodeGetter} */ const DESCENDANTS = (node, selector) => [...(node.querySelectorAll?.(selector || "*") || [])]; /** @type {NodeGetter} */ const NEXT_SIBLING = (node, selector) => { const sibling = node.nextElementSibling; return sibling?.matches?.(selector) ? [sibling] : []; }; /** @type {NodeGetter} */ const NEXT_SIBLINGS = (node, selector) => { const siblings = []; while ((node = node.nextElementSibling)) { if (node.matches?.(selector)) { siblings.push(node); } } return siblings; }; /** @type {Map, observer: MutationObserver }>} */ const observers = new Map(); const currentDimensions = { width: innerWidth, height: innerHeight, }; let getDefaultRoot = () => document; //----------------------------------------------------------------------------- // Pseudo classes //----------------------------------------------------------------------------- /** @type {Map} */ const customPseudoClasses = new Map(); customPseudoClasses .set("contains", makePatternBasedPseudoClass("contains", getNodeText)) .set("displayed", () => { return function displayed(node) { return isNodeDisplayed(node); }; }) .set("empty", () => { return function empty(node) { return isEmpty(node); }; }) .set("eq", (content) => { const index = $parseInt(content); if (!$isInteger(index)) { throw selectorError("eq", `expected index to be an integer (got ${content})`); } return function eq(node, i, nodes) { return index < 0 ? i === nodes.length + index : i === index; }; }) .set("first", () => { return function first(node, i) { return i === 0; }; }) .set("focusable", () => { return function focusable(node) { return isNodeFocusable(node); }; }) .set("has", (content) => { return function has(node) { return Boolean(queryAll(content, { root: node }).length); }; }) .set("hidden", () => { return function hidden(node) { return !isNodeVisible(node); }; }) .set("iframe", () => { return function iframe(node) { // Note: should only apply on `iframe` elements /** @see parseSelector */ const doc = node.contentDocument; return doc && doc.readyState !== "loading" ? doc : false; }; }) .set("last", () => { return function last(node, i, nodes) { return i === nodes.length - 1; }; }) .set("not", (content) => { return function not(node) { return !matches(node, content); }; }) .set("only", () => { return function only(node, i, nodes) { return nodes.length === 1; }; }) .set("scrollable", () => { return function scrollable(node) { return isNodeScrollable(node); }; }) .set("selected", () => { return function selected(node) { return Boolean(node.selected); }; }) .set("shadow", () => { return function shadow(node) { return node.shadowRoot || false; }; }) .set("value", makePatternBasedPseudoClass("value", getNodeValue)) .set("visible", () => { return function visible(node) { return isNodeVisible(node); }; }); const rCustomPseudoClass = compilePseudoClassRegex(); //----------------------------------------------------------------------------- // Internal exports (inside Hoot/Hoot-DOM) //----------------------------------------------------------------------------- export function cleanupDOM() { // Dimensions currentDimensions.width = innerWidth; currentDimensions.height = innerHeight; // Observers const remainingObservers = observers.size; if (remainingObservers) { for (const { observer } of observers.values()) { observer.disconnect(); } observers.clear(); } } /** * @param {Node | () => Node} node */ export function defineRootNode(node) { if (typeof node === "function") { getDefaultRoot = node; } else if (node) { getDefaultRoot = () => node; } else { getDefaultRoot = () => document; } } export function getCurrentDimensions() { return currentDimensions; } /** * @param {Node} [node] * @returns {Document} */ export function getDocument(node) { node ||= getDefaultRoot(); return isDocument(node) ? node : node.ownerDocument || document; } /** * @param {Node} node * @param {string} attribute * @returns {string | null} */ export function getNodeAttribute(node, attribute) { return node.getAttribute?.(attribute) ?? null; } /** * @param {Node} node * @returns {NodeValue} */ export function getNodeValue(node) { switch (node.type) { case "checkbox": case "radio": return node.checked; case "file": return [...node.files]; case "number": case "range": return node.valueAsNumber; case "date": case "datetime-local": case "month": case "time": case "week": return node.valueAsDate.toISOString(); } return node.value; } /** * @param {Node} node * @param {QueryRectOptions} [options] */ export function getNodeRect(node, options) { if (!isElement(node)) { return new DOMRect(); } /** @type {DOMRect} */ const rect = node.getBoundingClientRect(); const parentFrame = getParentFrame(node); if (parentFrame) { const parentRect = getNodeRect(parentFrame); rect.x -= parentRect.x; rect.y -= parentRect.y; } if (!options?.trimPadding) { return rect; } const style = getStyle(node); const { x, y, width, height } = rect; const [pl, pr, pt, pb] = ["left", "right", "top", "bottom"].map((side) => pixelValueToNumber(style.getPropertyValue(`padding-${side}`)) ); return new DOMRect(x + pl, y + pt, width - (pl + pr), height - (pt + pb)); } /** * @param {Node} node * @param {QueryTextOptions} [options] * @returns {string} */ export function getNodeText(node, options) { let content; if (typeof node.innerText === "string") { content = node.innerText; } else { content = node.textContent; } if (options?.raw) { return content; } return content.replace(R_HORIZONTAL_WHITESPACE, " ").trim(); } /** * @template {Node} T * @param {T} node * @returns {T extends Element ? CSSStyleDeclaration : null} */ export function getStyle(node) { return isElement(node) ? getComputedStyle(node) : null; } /** * @param {Node} [node] * @returns {Window} */ export function getWindow(node) { return getDocument(node).defaultView; } /** * @param {Node} node * @returns {boolean} */ export function isCheckable(node) { switch (getTag(node)) { case "input": return node.type === "checkbox" || node.type === "radio"; case "label": return isCheckable(node.control); default: return false; } } /** * @param {unknown} value * @returns {boolean} */ export function isEmpty(value) { if (!value) { return true; } if (typeof value === "object") { if (isNode(value)) { return isEmpty(getNodeContent(value)); } if (!isIterable(value)) { value = $keys(value); } return [...value].length === 0; } return false; } /** * Returns whether the given object is an {@link EventTarget}. * * @template T * @param {T} object * @returns {T extends EventTarget ? true : false} * @example * isEventTarget(window); // true * @example * isEventTarget(new App()); // false */ export function isEventTarget(object) { return object && typeof object.addEventListener === "function"; } /** * Returns whether the given object is a {@link Node} object. * Note that it is independant from the {@link Node} class itself to support * cross-window checks. * * @template T * @param {T} object * @returns {T extends Node ? true : false} */ export function isNode(object) { return object && typeof object.nodeType === "number" && typeof object.nodeName === "string"; } /** * @param {Node} node */ export function isNodeCssVisible(node) { const element = ensureElement(node); if (element === getDefaultRoot() || isRootElement(element)) { return true; } const style = getStyle(element); if (style?.visibility === "hidden" || style?.opacity === "0") { return false; } const parent = element.parentNode; return !parent || isNodeCssVisible(isShadowRoot(parent) ? parent.host : parent); } /** * @param {Window | Node} node */ export function isNodeDisplayed(node) { const element = ensureElement(node); if (!isInDOM(element)) { return false; } if (isRootElement(element) || element.offsetParent || element.closest("svg")) { return true; } // `position=fixed` elements in Chrome do not have an `offsetParent` return !isFirefox() && getStyle(element)?.position === "fixed"; } /** * @param {Node} node * @param {FocusableOptions} node */ export function isNodeFocusable(node, options) { return ( isNodeDisplayed(node) && node.matches?.(FOCUSABLE_SELECTOR) && (!options?.tabbable || node.tabIndex >= 0) ); } /** * @param {Window | Node} node */ export function isNodeInViewPort(node) { const element = ensureElement(node); const { x, y } = getNodeRect(element); return y > 0 && y < currentDimensions.height && x > 0 && x < currentDimensions.width; } /** * @param {Window | Node} node * @param {"x" | "y"} [axis] */ export function isNodeScrollable(node, axis) { if (!isElement(node)) { return false; } const [scrollProp, sizeProp] = axis === "x" ? ["scrollWidth", "clientWidth"] : ["scrollHeight", "clientHeight"]; if (node[scrollProp] > node[sizeProp]) { const overflow = getStyle(node).getPropertyValue("overflow"); if (/\bauto\b|\bscroll\b/.test(overflow)) { return true; } } return false; } /** * @param {Window | Node} node */ export function isNodeVisible(node) { const element = ensureElement(node); // Must be displayed and not hidden by CSS if (!isNodeDisplayed(element) || !isNodeCssVisible(element)) { return false; } let visible = false; // Check size (width & height) const { width, height } = getNodeRect(element); visible = width > 0 && height > 0; // Check content (if display=contents) if (!visible && getStyle(element)?.display === "contents") { for (const child of element.childNodes) { if (isNodeVisible(child)) { return true; } } } return visible; } /** * @param {Dimensions} dimensions * @returns {[number, number]} */ export function parseDimensions(dimensions) { return parseNumberTuple(dimensions, ["width", "w"], ["height", "h"]); } /** * @param {Position} position * @returns {[number, number]} */ export function parsePosition(position) { return parseNumberTuple( position, ["x", "left", "clientX", "pageX", "screenX"], ["y", "top", "clientY", "pageY", "screenY"] ); } /** * @param {number} width * @param {number} height */ export function setDimensions(width, height) { const defaultRoot = getDefaultRoot(); if (!$isNaN(width)) { currentDimensions.width = width; defaultRoot.style?.setProperty("width", `${width}px`, "important"); } if (!$isNaN(height)) { currentDimensions.height = height; defaultRoot.style?.setProperty("height", `${height}px`, "important"); } } /** * @param {Node} node * @param {{ object?: boolean }} [options] * @returns {string | string[]} */ export function toSelector(node, options) { const parts = { tag: node.nodeName.toLowerCase(), }; if (node.id) { parts.id = `#${node.id}`; } if (node.classList?.length) { parts.class = `.${[...node.classList].join(".")}`; } return options?.object ? parts : $values(parts).join(""); } // Following selector is based on this spec: // https://html.spec.whatwg.org/multipage/interaction.html#dom-tabindex export const FOCUSABLE_SELECTOR = [ "a[href]", "area[href]", "button:enabled", "details > summary:first-of-type", "iframe", "input:enabled", "select:enabled", "textarea:enabled", "[tabindex]", "[contenteditable=true]", ].join(","); //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** * Returns a standardized representation of the given `string` value as a human-readable * XML string template (or HTML if the `type` option is `"html"`). * * @param {string} value * @param {FormatXmlOptions} [options] * @returns {string} */ export function formatXml(value, options) { const nodes = parseXml(value, options?.type || "xml"); const layers = extractLayers(nodes, 0, options?.keepInlineTextNodes ?? false); return generateStringFromLayers(layers, options?.tabSize ?? 4); } /** * Returns the active element in the given document. Further checks are performed * in the following cases: * - the given node is an iframe (checks in its content document); * - the given node has a shadow root (checks in that shadow root document); * - the given node is the body of an iframe (checks in the parent document). * * @param {Node} [node] */ export function getActiveElement(node) { const document = getDocument(node); const window = getWindow(node); const { activeElement } = document; const { contentDocument, shadowRoot } = activeElement; if (contentDocument && contentDocument.activeElement !== contentDocument.body) { // Active element is an "iframe" element (with an active element other than its own body): if (contentDocument.activeElement === contentDocument.body) { // Active element is the body of the iframe: // -> returns that element return contentDocument.activeElement } else { // Active element is something else than the body: // -> get the active element inside the iframe document return getActiveElement(contentDocument); } } if (shadowRoot) { // Active element has a shadow root: // -> get the active element inside its root return shadowRoot.activeElement; } if (activeElement === document.body && window !== window.parent) { // Active element is the body of an iframe: // -> get the active element of its parent frame (recursively) return getActiveElement(window.parent.document); } return activeElement; } /** * Returns the list of focusable elements in the given parent, sorted by their `tabIndex` * property. * * @see {@link isFocusable} for more information * @param {FocusableOptions} [options] * @returns {Element[]} * @example * getFocusableElements(); */ export function getFocusableElements(options) { const parent = queryOne(options?.root || getDefaultRoot()); if (typeof parent.querySelectorAll !== "function") { return []; } const byTabIndex = {}; for (const element of parent.querySelectorAll(FOCUSABLE_SELECTOR)) { const { tabIndex } = element; if ((options?.tabbable && tabIndex < 0) || !isNodeDisplayed(element)) { continue; } if (!byTabIndex[tabIndex]) { byTabIndex[tabIndex] = []; } byTabIndex[tabIndex].push(element); } const withTabIndexZero = byTabIndex[0] || []; delete byTabIndex[0]; return [...$values(byTabIndex).flat(), ...withTabIndexZero]; } /** * Returns the next focusable element after the current active element if it is * contained in the given parent. * * @see {@link getFocusableElements} * @param {FocusableOptions} [options] * @returns {Element | null} * @example * getPreviousFocusableElement(); */ export function getNextFocusableElement(options) { const parent = queryOne(options?.root || getDefaultRoot()); const focusableEls = getFocusableElements({ ...options, parent }); const index = focusableEls.indexOf(getActiveElement(parent)); return focusableEls[index + 1] || null; } /** * Returns the parent `