odoo18/addons/web/static/lib/hoot-dom/helpers/dom.js

2171 lines
63 KiB
JavaScript

/** @odoo-module */
import { getTag, isFirefox, isInstanceOf, 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, index: number, nodes: Node[]) => boolean | Node} NodeFilter
*
* @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) => QueryFilter} PseudoClassPredicateBuilder
*
* @typedef {string | number | NodeFilter} QueryFilter
*
* @typedef {{
* contains?: string;
* count?: number;
* displayed?: boolean;
* empty?: boolean;
* eq?: number;
* first?: boolean;
* focusable?: boolean;
* has?: boolean;
* hidden?: boolean;
* iframe?: boolean;
* interactive?: boolean;
* last?: boolean;
* not?: boolean;
* only?: boolean;
* root?: HTMLElement;
* scrollable?: ScrollAxis;
* selected?: boolean;
* shadow?: boolean;
* value?: boolean;
* viewPort?: boolean;
* visible?: boolean;
* }} QueryOptions
*
* @typedef {{
* trimPadding?: boolean;
* }} QueryRectOptions
*
* @typedef {{
* inline?: boolean;
* raw?: boolean;
* }} QueryTextOptions
*
* @typedef {"both" | "x" | "y"} ScrollAxis
*
* @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 {
document,
DOMParser,
Error,
innerWidth,
innerHeight,
Map,
MutationObserver,
Number: { isInteger: $isInteger, isNaN: $isNaN, parseInt: $parseInt, parseFloat: $parseFloat },
Object: { entries: $entries, keys: $keys, values: $values },
RegExp,
Set,
String: { raw: $raw },
window,
} = globalThis;
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {Iterable<QueryFilter>} filters
* @param {Node[]} nodes
*/
function applyFilters(filters, nodes) {
for (const filter of filters) {
const filteredGroupNodes = [];
for (let i = 0; i < nodes.length; i++) {
const result = matchFilter(filter, nodes, i);
if (result === true) {
filteredGroupNodes.push(nodes[i]);
} else if (result) {
filteredGroupNodes.push(result);
}
}
nodes = filteredGroupNodes;
if (globalFilterDescriptors.has(filter)) {
globalFilterDescriptors.get(filter).push(nodes.length);
} else if (selectorFilterDescriptors.has(filter)) {
selectorFilterDescriptors.get(filter).push(nodes.length);
}
}
return nodes;
}
function compilePseudoClassRegex() {
const customKeys = [...customPseudoClasses.keys()].filter((k) => k !== "has" && k !== "not");
return new RegExp(`:(${customKeys.join("|")})`);
}
/**
* @param {Element[]} elements
* @param {string} selector
*/
function 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 {QueryOptions} options
*/
function ensureCount(options) {
options = { ...options };
if (!("eq" in options || "first" in options || "last" in options)) {
options.first = true;
}
return options;
}
/**
* @param {Node} node
* @returns {Element | null}
*/
function 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]
*/
function 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
*/
function 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
*/
function 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 {[string, string, number][]} modifierInfo
*/
function getFiltersDescription(modifierInfo) {
const description = [];
for (const [modifier, content, count = 0] of modifierInfo) {
const makeLabel = MODIFIER_SUFFIX_LABELS[modifier];
const elements = plural("element", count);
if (typeof makeLabel === "function") {
description.push(`${count} ${elements} ${makeLabel(content)}`);
} else {
description.push(`${count} ${modifier} ${elements}`);
}
if (!count) {
// Stop at first null count to avoid situations like:
// "found 0 elements, including 0 visible elements, including 0 ..."
break;
}
}
return description;
}
/**
* @param {Node} node
* @returns {NodeValue}
*/
function 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);
}
/** @type {NodeFilter} */
function getNodeIframe(node) {
// Note: should only apply on `iframe` elements
/** @see parseSelector */
const doc = node.contentDocument;
return doc && doc.readyState !== "loading" ? doc : false;
}
/** @type {NodeFilter} */
function getNodeShadowRoot(node) {
return node.shadowRoot;
}
/**
* @param {string} string
*/
function getStringContent(string) {
return string.match(R_QUOTE_CONTENT)?.[2] || string;
}
function getWaitForMessage() {
const message = `expected at least 1 element after %timeout%ms and ${lastQueryMessage}`;
lastQueryMessage = "";
return message;
}
function getWaitForNoneMessage() {
const message = `expected 0 elements after %timeout%ms and ${lastQueryMessage}`;
lastQueryMessage = "";
return message;
}
/**
*
* @param {number} count
* @param {Parameters<NodeFilter>[0]} _node
* @param {Parameters<NodeFilter>[1]} _i
* @param {Parameters<NodeFilter>[2]} nodes
*/
function hasNodeCount(count, _node, _i, nodes) {
return count === nodes.length;
}
/**
* @param {string} [char]
*/
function isChar(char) {
return !!char && R_CHAR.test(char);
}
/**
* @template T
* @param {T} object
* @returns {T extends Document ? true : false}
*/
function isDocument(object) {
return object?.nodeType === Node.DOCUMENT_NODE;
}
/**
* @template T
* @param {T} object
* @returns {T extends Element ? true: false}
*/
function isElement(object) {
return object?.nodeType === Node.ELEMENT_NODE;
}
/**
* @param {string} selector
* @param {Node} node
*/
function isNodeHaving(selector, node) {
return !!_queryAll(selector, { root: node }).length;
}
/** @type {NodeFilter} */
function isNodeHidden(node) {
return !isNodeVisible(node);
}
/** @type {NodeFilter} */
function isNodeInteractive(node) {
return (
getStyle(node).pointerEvents !== "none" &&
!node.closest?.("[inert]") &&
!getParentFrame(node)?.inert
);
}
/**
* @param {string} selector
* @param {Node} node
*/
function isNodeNotMatching(selector, node) {
return !matches(node, selector);
}
/** @type {NodeFilter} */
function isNodeSelected(node) {
return !!node.selected;
}
/** @type {NodeFilter} */
function isOnlyNode(_node, _i, nodes) {
return nodes.length === 1;
}
/**
* @param {Node} node
*/
function isQueryableNode(node) {
return QUERYABLE_NODE_TYPES.includes(node.nodeType);
}
/**
* @param {Element} [el]
*/
function isRootElement(el) {
return el && R_ROOT_ELEMENT.test(el.nodeName || "");
}
/**
* @param {Element} el
*/
function isShadowRoot(el) {
return el.nodeType === Node.DOCUMENT_FRAGMENT_NODE && !!el.host;
}
/**
* @template T
* @param {T} object
* @returns {T extends Window ? true : false}
*/
function isWindow(object) {
return object?.window === object && object.constructor.name === "Window";
}
/**
* @param {string} [char]
*/
function isWhiteSpace(char) {
return !!char && R_HORIZONTAL_WHITESPACE.test(char);
}
/**
* @param {string} pseudoClass
* @param {(node: Node) => NodeValue} getContent
*/
function makePatternBasedPseudoClass(pseudoClass, getContent) {
return (content) => {
let regex;
try {
regex = parseRegExp(content);
} catch (err) {
throw selectorError(pseudoClass, err.message);
}
if (isInstanceOf(regex, 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 {QueryFilter} filter
* @param {Node[]} nodes
* @param {number} index
*/
function matchFilter(filter, nodes, index) {
if (typeof filter === "number") {
if (filter < 0) {
return filter + nodes.length === index;
} else {
return filter === index;
}
}
const node = nodes[index];
if (typeof filter === "function") {
return filter(node, index, nodes);
} else {
return !!node.matches?.(String(filter));
}
}
/**
* flatMap implementation supporting NodeList iterables.
*
* @param {Iterable<Node>} nodes
* @param {(node: Node) => Node | Iterable<Node> | null | undefined} flatMapFn
*/
function nodeFlatMap(nodes, flatMapFn) {
/** @type {Node[]} */
const result = [];
for (const node of nodes) {
const nodeList = flatMapFn(node);
if (isNode(nodeList)) {
result.push(nodeList);
} else if (isIterable(nodeList)) {
result.push(...nodeList);
}
}
return result;
}
/**
* @template T
* @param {T} value
* @param {(keyof T)[]} propsA
* @param {(keyof T)[]} propsB
* @returns {[number, number]}
*/
function 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);
}
/**
* @template {any[]} T
* @param {T} args
* @returns {string | T}
*/
function parseRawArgs(args) {
return args[0]?.raw ? [$raw(...args)] : args;
}
/**
* 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
*/
function parseSelector(selector) {
/**
* @param {string} selector
*/
function 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]}`;
}
const filter = makeFilter(getStringContent(content));
selectorFilterDescriptors.set(filter, [pseudo, content]);
currentPart.push(filter);
currentPseudo = null;
} else if (registerChar) {
currentPseudo[1] += selector[i];
}
} else if (registerChar) {
addToSelector(selector[i]);
}
}
return groups;
}
/**
* @param {string} xmlString
* @param {"html" | "xml"} type
*/
function parseXml(xmlString, type) {
const wrapperTag = type === "html" ? "body" : "templates";
const doc = parser.parseFromString(
`<${wrapperTag}>${xmlString}</${wrapperTag}>`,
`text/${type}`
);
if (doc.getElementsByTagName("parsererror").length) {
const trimmed = xmlString.length > 80 ? xmlString.slice(0, 80) + "…" : xmlString;
throw new HootDomError(
`error while parsing ${trimmed}: ${getNodeText(
doc.getElementsByTagName("parsererror")[0]
)}`
);
}
return doc.getElementsByTagName(wrapperTag)[0].childNodes;
}
/**
* Converts a CSS pixel value to a number, removing the 'px' part.
*
* @param {string} val
*/
function pixelValueToNumber(val) {
return $parseFloat(val.endsWith("px") ? val.slice(0, -2) : val);
}
/**
* @param {string} word
* @param {number} count
*/
function plural(word, count) {
return count === 1 ? word : `${word}s`;
}
/**
* @param {Node[]} nodes (assumed not empty)
* @param {string} selector
*/
function queryWithCustomSelector(nodes, selector) {
const selectorGroups = parseSelector(selector);
const foundNodes = [];
for (const selectorParts of selectorGroups) {
let groupNodes = nodes;
for (const selectorPart of selectorParts) {
let baseSelector = selectorPart[0];
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);
}
nodeGetter ||= DESCENDANTS;
// Retrieve nodes from current group nodes
const currentGroupNodes = nodeFlatMap(groupNodes, (node) =>
nodeGetter(node, baseSelector)
);
// Filter/replace nodes based on custom pseudo-classes
groupNodes = applyFilters(selectorPart.slice(1), currentGroupNodes);
}
foundNodes.push(...groupNodes);
}
return filterUniqueNodes(foundNodes);
}
/**
* Creates a query message if needed, with all the information available used to
* gather the given nodes (base selector and count of nodes matching it, then each
* modifier applied as a filter with each associated count).
*
* Returns the resulting message only if the final count of nodes doesn't match
* the given expected count.
*
* @param {Node[]} filteredNodes
* @param {number} [expectedCount]
*/
function registerQueryMessage(filteredNodes, expectedCount) {
lastQueryMessage = "";
const filteredCount = filteredNodes.length;
const invalidCount = $isInteger(expectedCount) && filteredCount !== expectedCount;
if (shouldRegisterQueryMessage || invalidCount) {
const globalModifierInfo = [...globalFilterDescriptors.values()];
// First message part: final count
lastQueryMessage += `found ${filteredCount} ${plural("element", filteredCount)}`;
if (invalidCount) {
lastQueryMessage += ` instead of ${expectedCount}`;
}
// Next message part: initial element count (with selector if string)
const rootModifierInfo = globalModifierInfo.shift();
const [, rootContent, initialCount = 0] = rootModifierInfo;
if (typeof rootContent === "string") {
lastQueryMessage += `: ${initialCount} matching ${JSON.stringify(rootContent)}`;
if (selectorFilterDescriptors.size) {
// Selector filters will only be available with a custom selector
const selectorModifierInfo = [...selectorFilterDescriptors.values()];
lastQueryMessage += ` (${getFiltersDescription(selectorModifierInfo).join(" > ")})`;
}
} else if (filteredCount !== initialCount) {
// Do not report count if same as announced initially
lastQueryMessage += `: ${initialCount} ${plural("element", initialCount)}`;
}
if (initialCount) {
// Next message parts: each count associated with each modifier
lastQueryMessage += getFiltersDescription(globalModifierInfo)
.map((part) => `, including ${part}`)
.join("");
}
} else {
lastQueryMessage = "";
}
if (queryAllLevel <= 1) {
globalFilterDescriptors.clear();
selectorFilterDescriptors.clear();
}
return invalidCount ? lastQueryMessage : "";
}
/**
* @param {string} pseudoClass
* @param {string} message
*/
function selectorError(pseudoClass, message) {
return new HootDomError(`invalid selector \`:${pseudoClass}\`: ${message}`);
}
/**
* Wrapper around '_queryAll' calls to ensure global variables are properly cleaned
* up on any thrown error.
*
* @param {Target} target
* @param {QueryOptions} options
*/
function _guardedQueryAll(target, options) {
try {
return _queryAll(target, options);
} catch (error) {
queryAllLevel = 0;
shouldRegisterQueryMessage = false;
globalFilterDescriptors.clear();
selectorFilterDescriptors.clear();
throw error;
}
}
/**
* @param {Target} target
* @param {QueryOptions} options
*/
function _queryAll(target, options) {
queryAllLevel++;
const { count, root, ...modifiers } = options || {};
if (count !== null && count !== undefined && (!$isInteger(count) || count <= 0)) {
throw new HootDomError(`invalid 'count' option: should be a positive integer`);
}
/** @type {Node[]} */
let nodes = [];
let selector;
if (typeof target === "string") {
if (target) {
nodes = root ? _queryAll(root) : [getDefaultRoot()];
}
selector = target.trim();
// HTMLSelectElement is iterable ¯\_(ツ)_/¯
} else if (isIterable(target) && !isNode(target)) {
nodes = filterUniqueNodes(target);
} else if (target) {
nodes = filterUniqueNodes([target]);
}
globalFilterDescriptors.set("root", ["", target]);
if (selector && nodes.length) {
if (rCustomPseudoClass.test(selector)) {
nodes = queryWithCustomSelector(nodes, selector);
} else {
nodes = filterUniqueNodes(nodeFlatMap(nodes, (node) => DESCENDANTS(node, selector)));
}
}
globalFilterDescriptors.get("root").push(nodes.length);
if (modifiers.visible && modifiers.displayed) {
throw new HootDomError(
`cannot use more than one visibility modifier ('visible' implies 'displayed')`
);
}
// Apply option modifiers on matching nodes
const modifierFilters = [];
for (const [modifier, content] of $entries(modifiers)) {
if (content === false || !customPseudoClasses.has(modifier)) {
continue;
}
const makeFilter = customPseudoClasses.get(modifier);
const filter = makeFilter(content);
modifierFilters.push(filter);
globalFilterDescriptors.set(filter, [modifier, content]);
}
const filteredNodes = applyFilters(modifierFilters, nodes);
// Register query message (if needed), and/or throw an error accordingly
const message = registerQueryMessage(filteredNodes, count);
if (message) {
throw new HootDomError(message);
}
queryAllLevel--;
return filteredNodes;
}
/**
* @param {Target} target
* @param {QueryOptions} options
*/
function _queryOne(target, options) {
return _guardedQueryAll(target, { ...options, count: 1 })[0];
}
/**
* @param {Target} target
* @param {QueryOptions} options
* @param {boolean} isLast
*/
function _waitForFirst(target, options, isLast) {
shouldRegisterQueryMessage = isLast;
const result = _guardedQueryAll(target, options)[0];
shouldRegisterQueryMessage = false;
return result;
}
/**
* @param {Target} target
* @param {QueryOptions} options
* @param {boolean} isLast
*/
function _waitForNone(target, options, isLast) {
shouldRegisterQueryMessage = isLast;
const result = _guardedQueryAll(target, options).length === 0;
shouldRegisterQueryMessage = false;
return result;
}
class HootDomError extends Error {
name = "HootDomError";
}
// Regexes
const R_CHAR = /[\w-]/;
/** \s without \n and \v */
const R_HORIZONTAL_WHITESPACE =
/[\r\t\f \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+/g;
const R_LINEBREAK = /\s*\n+\s*/g;
const R_QUOTE_CONTENT = /^\s*(['"])?([^]*?)\1\s*$/;
const R_ROOT_ELEMENT = /^(HTML|HEAD|BODY)$/;
const R_SCROLLABLE_OVERFLOW = /\bauto\b|\bscroll\b/;
const MODIFIER_SUFFIX_LABELS = {
contains: (content) => `with text "${content}"`,
eq: (content) => `at index ${content}`,
has: (content) => `containing selector "${content}"`,
not: (content) => `not matching "${content}"`,
value: (content) => `with value "${content}"`,
viewPort: () => "in viewport",
};
const QUERYABLE_NODE_TYPES = [Node.ELEMENT_NODE, Node.DOCUMENT_NODE, Node.DOCUMENT_FRAGMENT_NODE];
const parser = new DOMParser();
// Node getters
/** @type {NodeGetter} */
function DIRECT_CHILDREN(node, selector) {
const children = [];
for (const childNode of node.childNodes) {
if (childNode.matches?.(selector)) {
children.push(childNode);
}
}
return children;
}
/** @type {NodeGetter} */
function DESCENDANTS(node, selector) {
return node.querySelectorAll?.(selector || "*");
}
/** @type {NodeGetter} */
function NEXT_SIBLING(node, selector) {
const sibling = node.nextElementSibling;
return sibling?.matches?.(selector) && sibling;
}
/** @type {NodeGetter} */
function NEXT_SIBLINGS(node, selector) {
const siblings = [];
while ((node = node.nextElementSibling)) {
if (node.matches?.(selector)) {
siblings.push(node);
}
}
return siblings;
}
/** @type {Map<QueryFilter, [string, string | null, number]>} */
const globalFilterDescriptors = new Map();
/** @type {Map<QueryFilter, [string, string | null, number]>} */
const selectorFilterDescriptors = new Map();
/** @type {Map<HTMLElement, { callbacks: Set<MutationCallback>, observer: MutationObserver }>} */
const observers = new Map();
const currentDimensions = {
width: innerWidth,
height: innerHeight,
};
let getDefaultRoot = () => document;
let lastQueryMessage = "";
let shouldRegisterQueryMessage = false;
let queryAllLevel = 0;
//-----------------------------------------------------------------------------
// Pseudo classes
//-----------------------------------------------------------------------------
/** @type {Map<string, PseudoClassPredicateBuilder>} */
const customPseudoClasses = new Map();
customPseudoClasses
.set("contains", makePatternBasedPseudoClass("contains", getNodeText))
.set("count", (strCount) => {
const count = $parseInt(strCount);
if (!$isInteger(count) || count <= 0) {
throw selectorError(
"count",
`expected count to be a positive integer (got ${strCount})`
);
}
return hasNodeCount.bind(null, count);
})
.set("displayed", () => isNodeDisplayed)
.set("empty", () => isEmpty)
.set("eq", (strIndex) => {
const index = $parseInt(strIndex);
if (!$isInteger(index)) {
throw selectorError("eq", `expected index to be an integer (got ${strIndex})`);
}
return index;
})
.set("first", () => 0)
.set("focusable", () => isNodeFocusable)
.set("has", (selector) => isNodeHaving.bind(null, selector))
.set("hidden", () => isNodeHidden)
.set("iframe", () => getNodeIframe)
.set("interactive", () => isNodeInteractive)
.set("last", () => -1)
.set("not", (selector) => isNodeNotMatching.bind(null, selector))
.set("only", () => isOnlyNode)
.set("scrollable", (axis) => isNodeScrollable.bind(null, axis))
.set("selected", () => isNodeSelected)
.set("shadow", () => getNodeShadowRoot)
.set("value", makePatternBasedPseudoClass("value", getNodeValue))
.set("viewPort", () => isNodeInViewPort)
.set("visible", () => isNodeVisible);
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) {
if (!node) {
return document;
}
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) {
content = content.replace(R_HORIZONTAL_WHITESPACE, " ").trim();
}
if (options?.inline) {
content = content.replace(R_LINEBREAK, " ");
}
return content;
}
/**
* @param {Node} node
* @returns {Node | null}
*/
export function getInteractiveNode(node) {
let currentEl = ensureElement(node);
if (!currentEl) {
return null;
}
while (currentEl && !isNodeInteractive(currentEl)) {
currentEl = currentEl.parentElement;
}
return currentEl;
}
/**
* @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) {
if (!node) {
return window;
}
return isWindow(node) ? node : 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} [options]
*/
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 {ScrollAxis} axis
* @param {Window | Node} node
*/
export function isNodeScrollable(axis, node) {
if (!isElement(node)) {
return false;
}
const isScrollableX = node.clientWidth < node.scrollWidth;
const isScrollableY = node.clientHeight < node.scrollHeight;
switch (axis) {
case "both": {
if (!isScrollableX || !isScrollableY) {
return false;
}
break;
}
case "x": {
if (!isScrollableX) {
return false;
}
break;
}
case "y": {
if (!isScrollableY) {
return false;
}
break;
}
default: {
// Check for any scrollable axis
if (!isScrollableX && !isScrollableY) {
return false;
}
}
}
const overflow = getStyle(node).getPropertyValue("overflow");
if (R_SCROLLABLE_OVERFLOW.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 doc = getDocument(node);
const view = doc.defaultView;
const { activeElement } = doc;
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 === doc.body && view !== view.parent) {
// Active element is the body of an iframe:
// -> get the active element of its parent frame (recursively)
return getActiveElement(view.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 doc = getDocument(node);
if (!doc) {
return null;
}
const view = doc.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 _guardedQueryAll(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
* @returns {boolean}
*/
export function isFocusable(target) {
return _guardedQueryAll(target, { focusable: true }).length > 0;
}
/**
* 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 _guardedQueryAll(target, { viewPort: true }).length > 0;
}
/**
* Returns whether an element is scrollable.
*
* @param {Target} target
* @param {ScrollAxis} [axis]
* @returns {boolean}
*/
export function isScrollable(target, axis) {
return _guardedQueryAll(target, { scrollable: axis }).length > 0;
}
/**
* 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 _guardedQueryAll(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(_guardedQueryAll(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**.
* - `:count`: return nodes if their count match the given *count*.
* If not matching, an error is thrown;
* - `: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});
* - `:interactive`: matches nodes that are not affected by 'pointer-events: none'
* - `: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(axis)`: matches nodes that are scrollable (see {@link isScrollable});
* - `:viewPort`: matches nodes that are contained in the current view port (see
* {@link isInViewPort});
* - `:visible`: matches nodes that are "visible" (see {@link isVisible});
*
* An `options` object can be specified to filter[1] the results:
* - `count`: the exact number of nodes to match (throws an error if the number of
* nodes doesn't match);
* - `displayed`: whether the nodes must be "displayed" (see {@link isDisplayed});
* - `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 `count` 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`, { count: 1 }); // -> [div]
* queryAll(`div`, { root: queryOne`iframe` }); // -> [div, div, ...]
* // redundant, but possible
* queryAll(`button:visible`, { visible: true }); // -> [button, button, ...]
*/
export function queryAll(target, options) {
[target, options] = parseRawArgs(arguments);
return _guardedQueryAll(target, options);
}
/**
* 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 _guardedQueryAll(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 _guardedQueryAll(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) {
[target, options] = parseRawArgs(arguments);
return _guardedQueryAll(target, options).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) {
[target, options] = parseRawArgs(arguments);
return _guardedQueryAll(target, options).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) {
[target, options] = parseRawArgs(arguments);
return _guardedQueryAll(target, options).map(getNodeValue);
}
/**
* Performs a {@link queryOne} with the given arguments, with a default 'first'
* option, to ensure that *at least* one element is returned.
*
* 'first' can be overridden by 'last' or 'eq' if needed.
*
* @param {Target} target
* @param {QueryOptions} [options]
* @returns {Node}
*/
export function queryAny(target, options) {
[target, options] = parseRawArgs(arguments);
return _queryOne(target, ensureCount(options));
}
/**
* 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 the first result
* or `null`.
*
* @param {Target} target
* @param {QueryOptions} options
* @returns {Element | null}
*/
export function queryFirst(target, options) {
[target, options] = parseRawArgs(arguments);
return _guardedQueryAll(target, options)[0] || null;
}
/**
* Performs a {@link queryAll} with the given arguments, along with a forced `count: 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, "count">} [options]
* @returns {Element}
*/
export function queryOne(target, options) {
[target, options] = parseRawArgs(arguments);
if ($isInteger(options?.count)) {
throw new HootDomError(
`cannot call \`queryOne\` with 'count'=${options.count}: did you mean to use \`queryAll\`?`
);
}
return _queryOne(target, options);
}
/**
* 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) {
[target, options] = parseRawArgs(arguments);
return getNodeRect(_queryOne(target, options), 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) {
[target, options] = parseRawArgs(arguments);
return getNodeText(_queryOne(target, options), 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) {
[target, options] = parseRawArgs(arguments);
return getNodeValue(_queryOne(target, options));
}
/**
* 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) {
[target, options] = parseRawArgs(arguments);
return waitUntil(_waitForFirst.bind(null, target, options), {
message: getWaitForMessage,
...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) {
[target, options] = parseRawArgs(arguments);
return waitUntil(_waitForNone.bind(null, target, options), {
message: getWaitForNoneMessage,
...options,
});
}