Odoo18-Base/addons/web/static/lib/hoot-dom/helpers/dom.js
2025-03-04 12:23:19 +07:00

1831 lines
54 KiB
JavaScript

/** @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<T>} MaybeIterable
*/
/**
* @template [T=Node]
* @typedef {MaybeIterable<T> | 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<Node>} 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<Node>} 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<PseudoClassPredicateBuilder>)[]} */
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}</${wrapperTag}>`,
`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<HTMLElement, { callbacks: Set<MutationCallback>, observer: MutationObserver }>} */
const observers = new Map();
const currentDimensions = {
width: innerWidth,
height: innerHeight,
};
let getDefaultRoot = () => document;
//-----------------------------------------------------------------------------
// Pseudo classes
//-----------------------------------------------------------------------------
/** @type {Map<string, PseudoClassPredicateBuilder>} */
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 `<iframe>` of a given node (if any).
*
* @param {Node} node
* @returns {HTMLIFrameElement | null}
*/
export function getParentFrame(node) {
const document = getDocument(node);
if (!document) {
return null;
}
const view = document.defaultView;
if (view !== view.parent) {
for (const iframe of view.parent.document.getElementsByTagName("iframe")) {
if (iframe.contentWindow === view) {
return iframe;
}
}
}
return null;
}
/**
* Returns the previous focusable element before 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 getPreviousFocusableElement(options) {
const parent = queryOne(options?.root || getDefaultRoot());
const focusableEls = getFocusableElements({ ...options, parent });
const index = focusableEls.indexOf(getActiveElement(parent));
return index < 0 ? focusableEls.at(-1) : focusableEls[index - 1] || null;
}
/**
* Checks whether a target is displayed, meaning that it has an offset parent and
* is contained in the current document.
*
* Note that it does not mean that the target is "visible" (it can still be hidden
* by CSS properties such as `width`, `opacity`, `visiblity`, etc.).
*
* @param {Target} target
* @returns {boolean}
*/
export function isDisplayed(target) {
return queryAll(target, { displayed: true }).length > 0;
}
/**
* Returns whether the given node is editable, meaning that it is an `":enabled"`
* `<input>` or `<textarea>` {@link Element};
*
* Note: this does **NOT** support elements with `contenteditable="true"`.
*
* @param {Node} node
* @returns {boolean}
* @example
* isEditable(document.querySelector("input")); // true
* @example
* isEditable(document.body); // false
*/
export function isEditable(node) {
return (
isElement(node) &&
!node.matches?.(":disabled") &&
["input", "textarea"].includes(getTag(node))
);
}
/**
* Returns whether an element is focusable. Focusable elements are either:
* - `<a>` or `<area>` elements with an `href` attribute;
* - *enabled* `<button>`, `<input>`, `<select>` and `<textarea>` elements;
* - `<iframe>` elements;
* - any element with its `contenteditable` attribute set to `"true"`.
*
* A focusable element must also not have a `tabIndex` property set to less than 0.
*
* @see {@link FOCUSABLE_SELECTOR}
* @param {Target} target
* @param {FocusableOptions} [options]
* @returns {boolean}
*/
export function isFocusable(target, options) {
const nodes = queryAll(...arguments);
return nodes.length && nodes.every((node) => isNodeFocusable(node, options));
}
/**
* Returns whether the given target is contained in the current root document.
*
* @param {Window | Node} target
* @returns {boolean}
* @example
* isInDOM(queryFirst("div")); // true
* @example
* isInDOM(document.createElement("div")); // Not attached -> false
*/
export function isInDOM(target) {
return ensureElement(target)?.isConnected;
}
/**
* Checks whether a target is *at least partially* visible in the current viewport.
*
* @param {Target} target
* @returns {boolean}
*/
export function isInViewPort(target) {
return queryAll(target, { viewPort: true }).length > 0;
}
/**
* Returns whether an element is scrollable.
*
* @param {Target} target
* @param {"x" | "y"} [axis]
* @returns {boolean}
*/
export function isScrollable(target, axis) {
const nodes = queryAll(target);
return nodes.length && nodes.every((node) => isNodeScrollable(node, axis));
}
/**
* Checks whether a target is visible, meaning that it is "displayed" (see {@link isDisplayed}),
* has a non-zero width and height, and is not hidden by "opacity" or "visibility"
* CSS properties.
*
* Note that it does not account for:
* - the position of the target in the viewport (e.g. negative x/y coordinates)
* - the color of the target (e.g. transparent text with no background).
*
* @param {Target} target
* @returns {boolean}
*/
export function isVisible(target) {
return queryAll(target, { visible: true }).length > 0;
}
/**
* Equivalent to the native `node.matches(selector)`, with a few differences:
* - it can take any {@link Target} (strings, nodes and iterable of nodes);
* - it supports custom pseudo-classes, such as ":contains" or ":visible".
*
* @param {Target} target
* @param {string} selector
* @returns {boolean}
* @example
* matches("input[name=surname]", ":value(John)");
* @example
* matches(buttonEl, ":contains(Submit)");
*/
export function matches(target, selector) {
return elementsMatch(queryAll(target), selector);
}
/**
* Listens for DOM mutations on a given target.
*
* This helper has 2 main advantages over directly calling the native MutationObserver:
* - it ensures a single observer is created for a given target, even if multiple
* callbacks are registered;
* - it keeps track of these observers, which allows to check whether an observer
* is still running while it should not, and to disconnect all running observers
* at once.
*
* @param {HTMLElement} target
* @param {MutationCallback} callback
*/
export function observe(target, callback) {
if (observers.has(target)) {
observers.get(target).callbacks.add(callback);
} else {
const callbacks = new Set([callback]);
const observer = new MutationObserver((mutations, observer) => {
for (const callback of callbacks) {
callback(mutations, observer);
}
});
observer.observe(target, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
observers.set(target, { callbacks, observer });
}
return function disconnect() {
if (!observers.has(target)) {
return;
}
const { callbacks, observer } = observers.get(target);
callbacks.delete(callback);
if (!callbacks.size) {
observer.disconnect();
observers.delete(target);
}
};
}
/**
* Returns a list of nodes matching the given {@link Target}.
* This function can either be used as a **template literal tag** (only supports
* string selector without options) or invoked the usual way.
*
* The target can be:
* - a {@link Node} (or an iterable of nodes), or {@link Window} object;
* - a {@link Document} object (which will be converted to its body);
* - a string representing a *custom selector* (which will be queried in the `root` option);
*
* This function allows all string selectors supported by the native {@link Element.querySelector}
* along with some additional custom pseudo-classes:
*
* - `:contains(text)`: matches nodes whose *content* matches the given *text*;
* * given *text* supports regular expression syntax (e.g. `:contains(/^foo.+/)`)
* and is case-insensitive;
* * given *text* will be matched against:
* - an `<input>`, `<textarea>` or `<select>` element's **value**;
* - or any other element's **inner text**.
* - `:displayed`: matches nodes that are "displayed" (see {@link isDisplayed});
* - `:empty`: matches nodes that have an empty *content* (**value** or **inner text**);
* - `:eq(n)`: matches the *nth* node (0-based index);
* - `:first`: matches the first node matching the selector (regardless of its actual
* DOM siblings);
* - `:focusable`: matches nodes that can be focused (see {@link isFocusable});
* - `:hidden`: matches nodes that are **not** "visible" (see {@link isVisible});
* - `:iframe`: matches nodes that are `<iframe>` elements, and returns their `body`
* if it is ready;
* - `:last`: matches the last node matching the selector (regardless of its actual
* DOM siblings);
* - `:selected`: matches nodes that are selected (e.g. `<option>` elements);
* - `:shadow`: matches nodes that have shadow roots, and returns their shadow root;
* - `:scrollable`: matches nodes that are scrollable (see {@link isScrollable});
* - `:visible`: matches nodes that are "visible" (see {@link isVisible});
*
* An `options` object can be specified to filter[1] the results:
* - `displayed`: whether the nodes must be "displayed" (see {@link isDisplayed});
* - `exact`: the exact number of nodes to match (throws an error if the number of
* nodes doesn't match);
* - `focusable`: whether the nodes must be "focusable" (see {@link isFocusable});
* - `root`: the root node to query the selector in (defaults to the current fixture);
* - `viewPort`: whether the nodes must be partially visible in the current viewport
* (see {@link isInViewPort});
* - `visible`: whether the nodes must be "visible" (see {@link isVisible}).
* * This option implies `displayed`
*
* [1] these filters (except for `exact` and `root`) achieve the same result as
* using their homonym pseudo-classes on the final group of the given selector
* string (e.g. ```queryAll`ul > li:visible`;``` = ```queryAll("ul > li", { visible: true })```).
*
* @param {Target} target
* @param {QueryOptions} [options]
* @returns {Element[]}
* @example
* // regular selectors
* queryAll`window`; // -> []
* queryAll`input#name`; // -> [input]
* queryAll`div`; // -> [div, div, ...]
* queryAll`ul > li`; // -> [li, li, ...]
* @example
* // custom selectors
* queryAll`div:visible:contains(Lorem ipsum)`; // -> [div, div, ...]
* queryAll`div:visible:contains(${/^L\w+\si.*m$/})`; // -> [div, div, ...]
* queryAll`:focusable`; // -> [a, button, input, ...]
* queryAll`.o_iframe:iframe p`; // -> [p, p, ...] (inside iframe)
* queryAll`#editor:shadow div`; // -> [div, div, ...] (inside shadow DOM)
* @example
* // with options
* queryAll(`div:first`, { exact: 1 }); // -> [div]
* queryAll(`div`, { root: queryOne`iframe` }); // -> [div, div, ...]
* // redundant, but possible
* queryAll(`button:visible`, { visible: true }); // -> [button, button, ...]
*/
export function queryAll(target, options) {
if (!target) {
return [];
}
if (target.raw) {
return queryAll(String.raw(...arguments));
}
const { exact, displayed, root, viewPort, visible } = options || {};
/** @type {Node[]} */
let nodes = [];
let selector;
if (typeof target === "string") {
nodes = root ? queryAll(root) : [getDefaultRoot()];
selector = target.trim();
// HTMLSelectElement is iterable ¯\_(ツ)_/¯
} else if (isIterable(target) && !isNode(target)) {
nodes = filterUniqueNodes(target);
} else {
nodes = filterUniqueNodes([target]);
}
if (selector && nodes.length) {
if (rCustomPseudoClass.test(selector)) {
nodes = queryWithCustomSelector(nodes, selector);
} else {
nodes = filterUniqueNodes(nodes.flatMap((node) => DESCENDANTS(node, selector)));
}
}
/** @type {string} */
let prefix, suffix;
if (visible + displayed > 1) {
throw new HootDomError(
`cannot use more than one visibility modifier ('visible' implies 'displayed')`
);
}
if (viewPort) {
nodes = nodes.filter(isNodeInViewPort);
suffix = "in viewport";
} else if (visible) {
nodes = nodes.filter(isNodeVisible);
prefix = "visible";
} else if (displayed) {
nodes = nodes.filter(isNodeDisplayed);
prefix = "displayed";
}
const count = nodes.length;
if ($isInteger(exact) && count !== exact) {
const s = count === 1 ? "" : "s";
const strPrefix = prefix ? `${prefix} ` : "";
const strSuffix = suffix ? ` ${suffix}` : "";
const strSelector = typeof target === "string" ? `(selector: "${target}")` : "";
throw new HootDomError(
`found ${count} ${strPrefix}node${s}${strSuffix} instead of ${exact} ${strSelector}`
);
}
return nodes;
}
/**
* Performs a {@link queryOne} with the given arguments and returns the value of
* the given *attribute* of the matching node.
*
* @param {Target} target
* @param {string} attribute
* @param {QueryOptions} [options]
* @returns {string | null}
*/
export function queryAttribute(target, attribute, options) {
return getNodeAttribute(queryOne(target, options), attribute);
}
/**
* Performs a {@link queryAll} with the given arguments and returns a list of the
* *attribute values* of the matching nodes.
*
* @param {Target} target
* @param {string} attribute
* @param {QueryOptions} [options]
* @returns {string[]}
*/
export function queryAllAttributes(target, attribute, options) {
return queryAll(target, options).map((node) => getNodeAttribute(node, attribute));
}
/**
* Performs a {@link queryAll} with the given arguments and returns a list of the
* *properties* of the matching nodes.
*
* @param {Target} target
* @param {string} property
* @param {QueryOptions} [options]
* @returns {any[]}
*/
export function queryAllProperties(target, property, options) {
return queryAll(target, options).map((node) => node[property]);
}
/**
* Performs a {@link queryAll} with the given arguments and returns a list of the
* {@link DOMRect} of the matching nodes.
*
* There are a few differences with the native {@link Element.getBoundingClientRect}:
* - rects take their positions relative to the top window element (instead of their
* parent `<iframe>` if any);
* - they can be trimmed to remove padding with the `trimPadding` option.
*
* @param {Target} target
* @param {QueryOptions & QueryRectOptions} [options]
* @returns {DOMRect[]}
*/
export function queryAllRects(target, options) {
return queryAll(...arguments).map(getNodeRect);
}
/**
* Performs a {@link queryAll} with the given arguments and returns a list of the
* *texts* of the matching nodes.
*
* @param {Target} target
* @param {QueryOptions & QueryTextOptions} [options]
* @returns {string[]}
*/
export function queryAllTexts(target, options) {
return queryAll(...arguments).map((node) => getNodeText(node, options));
}
/**
* Performs a {@link queryAll} with the given arguments and returns a list of the
* *values* of the matching nodes.
*
* @param {Target} target
* @param {QueryOptions} [options]
* @returns {NodeValue[]}
*/
export function queryAllValues(target, options) {
return queryAll(...arguments).map(getNodeValue);
}
/**
* Performs a {@link queryAll} with the given arguments and returns the first result
* or `null`.
*
* @param {Target} target
* @param {QueryOptions} options
* @returns {Element | null}
*/
export function queryFirst(target, options) {
return queryAll(...arguments)[0] || null;
}
/**
* Performs a {@link queryAll} with the given arguments, along with a forced `exact: 1`
* option to ensure only one node matches the given {@link Target}.
*
* The returned value is a single node instead of a list of nodes.
*
* @param {Target} target
* @param {Omit<QueryOptions, "exact">} [options]
* @returns {Element}
*/
export function queryOne(target, options) {
if (target.raw) {
return queryOne(String.raw(...arguments));
}
if ($isInteger(options?.exact)) {
throw new HootDomError(
`cannot call \`queryOne\` with 'exact'=${options.exact}: did you mean to use \`queryAll\`?`
);
}
return queryAll(target, { ...options, exact: 1 })[0];
}
/**
* Performs a {@link queryOne} with the given arguments and returns the {@link DOMRect}
* of the matching node.
*
* There are a few differences with the native {@link Element.getBoundingClientRect}:
* - rects take their positions relative to the top window element (instead of their
* parent `<iframe>` if any);
* - they can be trimmed to remove padding with the `trimPadding` option.
*
* @param {Target} target
* @param {QueryOptions & QueryRectOptions} [options]
* @returns {DOMRect}
*/
export function queryRect(target, options) {
return getNodeRect(queryOne(...arguments), options);
}
/**
* Performs a {@link queryOne} with the given arguments and returns the *text* of
* the matching node.
*
* @param {Target} target
* @param {QueryOptions & QueryTextOptions} [options]
* @returns {string}
*/
export function queryText(target, options) {
return getNodeText(queryOne(...arguments), options);
}
/**
* Performs a {@link queryOne} with the given arguments and returns the *value* of
* the matching node.
*
* @param {Target} target
* @param {QueryOptions} [options]
* @returns {NodeValue}
*/
export function queryValue(target, options) {
return getNodeValue(queryOne(...arguments));
}
/**
* Combination of {@link queryAll} and {@link waitUntil}: waits for a given target
* to match elements in the DOM and returns the first matching node when it appears
* (or immediately if it is already present).
*
* @see {@link queryAll}
* @see {@link waitUntil}
* @param {Target} target
* @param {QueryOptions & WaitOptions} [options]
* @returns {Promise<Element>}
* @example
* const button = await waitFor(`button`);
* button.click();
*/
export function waitFor(target, options) {
return waitUntil(() => queryFirst(...arguments), {
message: `Could not find elements matching "${target}" within %timeout% milliseconds`,
...options,
});
}
/**
* Opposite of {@link waitFor}: waits for a given target to disappear from the DOM
* (resolves instantly if the selector is already missing).
*
* @see {@link waitFor}
* @param {Target} target
* @param {QueryOptions & WaitOptions} [options]
* @returns {Promise<number>}
* @example
* await waitForNone(`button`);
*/
export function waitForNone(target, options) {
let count = 0;
return waitUntil(
() => {
count = queryAll(...arguments).length;
return !count;
},
{
message: () =>
`Could still find ${count} elements matching "${target}" after %timeout% milliseconds`,
...options,
}
);
}