19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -59,10 +59,10 @@ import { waitUntil } from "./time";
*
* @typedef {{
* contains?: string;
* count?: number;
* displayed?: boolean;
* empty?: boolean;
* eq?: number;
* exact?: number;
* first?: boolean;
* focusable?: boolean;
* has?: boolean;
@ -90,6 +90,10 @@ import { waitUntil } from "./time";
* raw?: boolean;
* }} QueryTextOptions
*
* @typedef {{
* raw?: boolean;
* }} QueryValueOptions
*
* @typedef {"both" | "x" | "y"} ScrollAxis
*
* @typedef {import("./time").WaitOptions} WaitOptions
@ -116,11 +120,9 @@ const {
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;
@ -326,6 +328,13 @@ function getFiltersDescription(modifierInfo) {
return description;
}
/**
* @param {Node} node
*/
function getInlineNodeText(node) {
return getNodeText(node, { inline: true });
}
/**
* @param {Node} node
* @returns {NodeValue}
@ -337,7 +346,7 @@ function getNodeContent(node) {
case "textarea":
return getNodeValue(node);
case "select":
return [...node.selectedOptions].map(getNodeValue).join(",");
return [...node.selectedOptions].map((node) => getNodeValue(node)).join(",");
}
return getNodeText(node);
}
@ -355,6 +364,30 @@ function getNodeShadowRoot(node) {
return node.shadowRoot;
}
/**
* @param {string} pseudoClass
*/
function getQueryFilter(pseudoClass, content) {
const makeQueryFilter = customPseudoClasses.get(pseudoClass);
try {
return makeQueryFilter(content);
} catch (err) {
let message = `error while parsing pseudo-class ':${pseudoClass}'`;
const cause = String(err?.message || err);
if (cause) {
message += `: ${cause}`;
}
throw new HootDomError(message);
}
}
/**
* @param {Node} node
*/
function getRawValue(node) {
return node.value;
}
/**
* @param {string} string
*/
@ -374,6 +407,17 @@ function getWaitForNoneMessage() {
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]
*/
@ -477,28 +521,27 @@ function isWhiteSpace(char) {
}
/**
* @param {string} pseudoClass
* @param {(node: Node) => NodeValue} getContent
* @param {(node: Node) => string} getContent
* @param {boolean} exact
*/
function makePatternBasedPseudoClass(pseudoClass, getContent) {
return (content) => {
let regex;
try {
regex = parseRegExp(content);
} catch (err) {
throw selectorError(pseudoClass, err.message);
}
function makePseudoClassMatcher(getContent, exact) {
return function makePartialMatcher(content) {
const regex = parseRegExp(content);
if (isInstanceOf(regex, RegExp)) {
return function containsRegExp(node) {
return regex.test(String(getContent(node)));
return function stringMatches(node) {
return regex.test(getContent(node));
};
} else {
const lowerContent = content.toLowerCase();
return function containsString(node) {
return getStringContent(String(getContent(node)))
.toLowerCase()
.includes(lowerContent);
};
if (exact) {
return function stringEquals(node) {
return getContent(node).toLowerCase() === lowerContent;
};
} else {
return function stringContains(node) {
return getContent(node).toLowerCase().includes(lowerContent);
};
}
}
};
}
@ -709,7 +752,6 @@ function parseSelector(selector) {
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.
@ -717,7 +759,7 @@ function parseSelector(selector) {
// but this pseudo won't work on non-iframe elements anyway.
currentPart[0] = `iframe${currentPart[0]}`;
}
const filter = makeFilter(getStringContent(content));
const filter = getQueryFilter(pseudo, getStringContent(content));
selectorFilterDescriptors.set(filter, [pseudo, content]);
currentPart.push(filter);
currentPseudo = null;
@ -872,14 +914,6 @@ function registerQueryMessage(filteredNodes, expectedCount) {
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.
@ -906,7 +940,10 @@ function _guardedQueryAll(target, options) {
function _queryAll(target, options) {
queryAllLevel++;
const { exact, root, ...modifiers } = options || {};
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 = [];
@ -946,15 +983,14 @@ function _queryAll(target, options) {
if (content === false || !customPseudoClasses.has(modifier)) {
continue;
}
const makeFilter = customPseudoClasses.get(modifier);
const filter = makeFilter(content);
const filter = getQueryFilter(modifier, 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, exact);
const message = registerQueryMessage(filteredNodes, count);
if (message) {
throw new HootDomError(message);
}
@ -969,7 +1005,7 @@ function _queryAll(target, options) {
* @param {QueryOptions} options
*/
function _queryOne(target, options) {
return _guardedQueryAll(target, { ...options, exact: 1 })[0];
return _guardedQueryAll(target, { ...options, count: 1 })[0];
}
/**
@ -1081,13 +1117,20 @@ let queryAllLevel = 0;
const customPseudoClasses = new Map();
customPseudoClasses
.set("contains", makePatternBasedPseudoClass("contains", getNodeText))
.set("contains", makePseudoClassMatcher(getInlineNodeText, false))
.set("count", (strCount) => {
const count = $parseInt(strCount);
if (!$isInteger(count) || count <= 0) {
throw new HootDomError(`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})`);
throw new HootDomError(`expected index to be an integer (got "${strIndex}")`);
}
return index;
})
@ -1103,7 +1146,8 @@ customPseudoClasses
.set("scrollable", (axis) => isNodeScrollable.bind(null, axis))
.set("selected", () => isNodeSelected)
.set("shadow", () => getNodeShadowRoot)
.set("value", makePatternBasedPseudoClass("value", getNodeValue))
.set("text", makePseudoClassMatcher(getInlineNodeText, true))
.set("value", makePseudoClassMatcher(getRawValue, false))
.set("viewPort", () => isNodeInViewPort)
.set("visible", () => isNodeVisible);
@ -1167,9 +1211,13 @@ export function getNodeAttribute(node, attribute) {
/**
* @param {Node} node
* @param {QueryValueOptions} [options]
* @returns {NodeValue}
*/
export function getNodeValue(node) {
export function getNodeValue(node, options) {
if (options?.raw) {
return getRawValue(node);
}
switch (node.type) {
case "checkbox":
case "radio":
@ -1185,8 +1233,9 @@ export function getNodeValue(node) {
case "time":
case "week":
return node.valueAsDate.toISOString();
default:
return node.value || "";
}
return node.value;
}
/**
@ -1801,51 +1850,6 @@ 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
@ -1859,12 +1863,11 @@ export function observe(target, callback) {
* 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**.
* - `:contains(text)`: matches nodes whose *text content* includes the given *text*.
* * The match is **partial** and **case-insensitive**;
* * Given *text* also supports regular expressions (e.g. `:contains(/^foo.+/)`).
* - `: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);
@ -1880,22 +1883,23 @@ export function observe(target, callback) {
* - `: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});
* - `:text(text)`: matches nodes whose *content* is strictly equal to the given *text*;
* * The match is **exact**, and **case-insensitive**;
* * Given *text* also supports regular expressions (e.g. `:text(/^foo.+/)`).
* - `:value(value)`: matches nodes whose *value* is strictly equal to the given *value*;
* * The match is **partial**, and **case-insensitive**;
* * Given *value* also supports regular expressions (e.g. `:value(/^foo.+/)`).
* - `: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:
* - `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`
* - any of the *custom pseudo-classes* can be given as an option, with the value
* being a boolean for standalone pseudo-classes (e.g. `{ empty: true }`), or a
* string for the others (e.g. `{ contains: "text" }`).
*
* [1] these filters (except for `exact` and `root`) achieve the same result as
* [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 })```).
*
@ -1917,10 +1921,11 @@ export function observe(target, callback) {
* queryAll`#editor:shadow div`; // -> [div, div, ...] (inside shadow DOM)
* @example
* // with options
* queryAll(`div:first`, { exact: 1 }); // -> [div]
* queryAll(`div:first`, { count: 1 }); // -> [div]
* queryAll(`div`, { root: queryOne`iframe` }); // -> [div, div, ...]
* // redundant, but possible
* queryAll(`button:visible`, { visible: true }); // -> [button, button, ...]
* // the next 2 queries will return the same results
* queryAll(`button:visible`); // -> [button, button, ...]
* queryAll(`button`, { visible: true }); // -> [button, button, ...]
*/
export function queryAll(target, options) {
[target, options] = parseRawArgs(arguments);
@ -1989,12 +1994,12 @@ export function queryAllTexts(target, options) {
* *values* of the matching nodes.
*
* @param {Target} target
* @param {QueryOptions} [options]
* @param {QueryOptions & QueryValueOptions} [options]
* @returns {NodeValue[]}
*/
export function queryAllValues(target, options) {
[target, options] = parseRawArgs(arguments);
return _guardedQueryAll(target, options).map(getNodeValue);
return _guardedQueryAll(target, options).map((node) => getNodeValue(node, options));
}
/**
@ -2039,20 +2044,20 @@ export function queryFirst(target, options) {
}
/**
* Performs a {@link queryAll} with the given arguments, along with a forced `exact: 1`
* 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, "exact">} [options]
* @param {Omit<QueryOptions, "count">} [options]
* @returns {Element}
*/
export function queryOne(target, options) {
[target, options] = parseRawArgs(arguments);
if ($isInteger(options?.exact)) {
if ($isInteger(options?.count)) {
throw new HootDomError(
`cannot call \`queryOne\` with 'exact'=${options.exact}: did you mean to use \`queryAll\`?`
`cannot call \`queryOne\` with 'count'=${options.count}: did you mean to use \`queryAll\`?`
);
}
return _queryOne(target, options);
@ -2094,12 +2099,12 @@ export function queryText(target, options) {
* the matching node.
*
* @param {Target} target
* @param {QueryOptions} [options]
* @param {QueryOptions & QueryValueOptions} [options]
* @returns {NodeValue}
*/
export function queryValue(target, options) {
[target, options] = parseRawArgs(arguments);
return getNodeValue(_queryOne(target, options));
return getNodeValue(_queryOne(target, options), options);
}
/**

View file

@ -24,7 +24,6 @@ import {
setDimensions,
toSelector,
} from "./dom";
import { microTick } from "./time";
/**
* @typedef {Target | Promise<Target>} AsyncTarget
@ -78,6 +77,10 @@ import { microTick } from "./time";
*
* @typedef {EventOptions & KeyboardEventInit} KeyboardOptions
*
* @typedef {{
* fullClear?: boolean;
* }} KeyDownOptions
*
* @typedef {string | string[]} KeyStrokes
*
* @typedef {EventOptions & QueryOptions & {
@ -101,6 +104,11 @@ import { microTick } from "./time";
* @typedef {"bottom" | "left" | "right" | "top"} Side
*/
/**
* @template T
* @typedef {T | () => T} Resolver
*/
/**
* @template [T=EventInit]
* @typedef {T & {
@ -137,6 +145,7 @@ const {
File,
FocusEvent,
HashChangeEvent,
HTMLElement,
KeyboardEvent,
Math: { ceil: $ceil, max: $max, min: $min },
MouseEvent,
@ -160,6 +169,22 @@ const {
const $createRange = document.createRange.bind(document);
const $toString = Object.prototype.toString;
const $blur = HTMLElement.prototype.blur;
HTMLElement.prototype.blur = function mockBlur() {
if (runTime.changeTarget && runTime.changeTarget === this) {
triggerChange();
}
return $blur.call(this, ...arguments);
};
const $focus = HTMLElement.prototype.focus;
HTMLElement.prototype.focus = function mockFocus() {
if (runTime.changeTarget && runTime.changeTarget !== this) {
triggerChange();
}
return $focus.call(this, ...arguments);
};
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
@ -266,7 +291,7 @@ function deleteSelection(target) {
* target: T;
* events: EventType[];
* additionalEvents?: EventType[];
* callback?: (target: T) => any;
* callback?: () => any;
* options?: EventInit;
* }} params
*/
@ -274,9 +299,7 @@ async function dispatchAndIgnore({ target, events, additionalEvents = [], callba
for (const eventType of [...events, ...additionalEvents]) {
runTime.eventsToIgnore.push(eventType);
}
if (callback) {
callback(target);
}
callback?.();
for (const eventType of events) {
await _dispatch(target, eventType, options);
}
@ -325,12 +348,19 @@ async function dispatchRelatedEvents(events, eventType, eventInit) {
}
/**
* @template T
* @template T, [R=T]
* @param {MaybeIterable<T>} value
* @param {(item: T) => R} [mapFn]
* @returns {T[]}
*/
function ensureArray(value) {
return isIterable(value) ? [...value] : [value];
function ensureArray(value, mapFn) {
if (Array.isArray(value)) {
return mapFn ? value.map(mapFn) : value;
}
if (isIterable(value)) {
return Array.from(value, mapFn);
}
return [mapFn ? mapFn(value) : value];
}
function getCurrentEvents() {
@ -344,13 +374,20 @@ function getCurrentEvents() {
function getDefaultRunTimeValue() {
return {
isComposing: false,
// Data transfers
// Keyboard & data transfers
/** @type {HTMLElement | null} */
changeTarget: null,
/** @type {(() => any)[]} */
changeTargetListeners: [],
/** @type {DataTransfer | null} */
clipboardData: null,
/** @type {DataTransfer | null} */
dataTransfer: null,
/** @type {string | null} */
initialValue: null,
isComposing: false,
/** @type {string | null} */
key: null,
// Drag & drop
canStartDrag: false,
@ -359,7 +396,6 @@ function getDefaultRunTimeValue() {
// Pointer
clickCount: 0,
key: null,
/** @type {HTMLElement | null} */
pointerDownTarget: null,
pointerDownTimeout: 0,
@ -567,18 +603,22 @@ function getFirstCommonParent(a, b) {
}
/**
* Returns the interactive pointer target from a given element, unless the element
* is falsy, or the 'interactive' option is set to `false`.
*
* If an 'originalTarget' is given, the helper will deliberately throw an error if
* no interactive elements are found.
*
* @param {HTMLElement} element
* @param {Target} originalTarget
* @param {QueryOptions} options
* @param {AsyncTarget} [originalTarget]
*/
function getPointerTarget(element, originalTarget, options) {
if (options?.interactive === false) {
// Explicit 'interactive: false' option
// -> element can be a non-interactive element
function getPointerTarget(element, options, originalTarget) {
if (!element || options?.interactive === false) {
return element;
}
const interactiveElement = getInteractiveNode(element);
if (!interactiveElement) {
if (!interactiveElement && originalTarget) {
queryAny(originalTarget, { ...options, interactive: true }); // Will throw if no elements are found
}
return interactiveElement;
@ -662,7 +702,7 @@ function getStringSelection(target) {
/**
* @param {Node} node
* @param {...string} tagNames
* @param {...string} tagNames
*/
function hasTagName(node, ...tagNames) {
return tagNames.includes(getTag(node));
@ -716,7 +756,7 @@ function isPrevented(event) {
* @returns {KeyboardEventInit}
*/
function parseKeyStrokes(keyStrokes, options) {
return (isIterable(keyStrokes) ? [...keyStrokes] : [keyStrokes]).map((key) => {
return ensureArray(keyStrokes, (key) => {
const lower = key.toLowerCase();
return {
...options,
@ -789,8 +829,9 @@ function registerButton(eventInit, toggle) {
* @param {Event} ev
*/
function registerFileInput({ target }) {
if (getTag(target) === "input" && target.type === "file") {
runTime.fileInput = target;
const actualTarget = target.shadowRoot ? target.shadowRoot.activeElement : target;
if (getTag(actualTarget) === "input" && actualTarget.type === "file") {
runTime.fileInput = actualTarget;
} else {
runTime.fileInput = null;
}
@ -798,46 +839,32 @@ function registerFileInput({ target }) {
/**
* @param {EventTarget} target
* @param {string} initialValue
* @param {ConfirmAction} confirmAction
*/
async function registerForChange(target, initialValue, confirmAction) {
function dispatchChange() {
return target.value !== initialValue && _dispatch(target, "change");
}
async function registerForChange(target, confirmAction) {
confirmAction &&= confirmAction.toLowerCase();
if (confirmAction === "auto") {
confirmAction = getTag(target) === "input" ? "enter" : "blur";
}
if (getTag(target) === "input") {
changeTargetListeners.push(
on(target, "keydown", (ev) => {
if (isPrevented(ev) || ev.key !== "Enter") {
return;
}
removeChangeTargetListeners();
afterNextDispatch = dispatchChange;
})
);
} else if (confirmAction === "enter") {
const parentDocument = getDocument(target);
const parentView = parentDocument.defaultView;
if (confirmAction === "enter" && getTag(target) !== "input") {
throw new HootInteractionError(
`"enter" confirm action is only supported on <input/> elements`
);
}
changeTargetListeners.push(
on(target, "blur", () => {
removeChangeTargetListeners();
dispatchChange();
}),
on(target, "change", removeChangeTargetListeners)
runTime.changeTarget = target;
runTime.changeTargetListeners.push(
on(parentView, "focusout", triggerChange),
on(parentView, "change", removeChangeTargetListeners)
);
switch (confirmAction) {
case "blur": {
await _hover(
getDocument(target).body,
parentDocument.body,
{ position: { x: 0, y: 0 } },
{ originalTarget: target }
);
@ -881,19 +908,31 @@ function registerSpecialKey(eventInit, toggle) {
}
function removeChangeTargetListeners() {
while (changeTargetListeners.length) {
changeTargetListeners.pop()();
for (const listener of runTime.changeTargetListeners) {
listener();
}
runTime.changeTarget = null;
runTime.changeTargetListeners = [];
}
/**
* @template T
* @param {Resolver<T>} resolver
* @returns {T}
*/
function resolve(resolver) {
return typeof resolver === "function" ? resolver() : resolver;
}
/**
* @param {HTMLElement | null} target
* @param {QueryOptions} [options]
*/
function setPointerDownTarget(target) {
function setPointerDownTarget(target, options) {
if (runTime.pointerDownTarget) {
runTime.previousPointerDownTarget = runTime.pointerDownTarget;
}
runTime.pointerDownTarget = target;
runTime.pointerDownTarget = getPointerTarget(target, options);
runTime.canStartDrag = false;
}
@ -991,6 +1030,16 @@ function toEventPosition(clientX, clientY, position) {
};
}
async function triggerChange() {
const target = runTime.changeTarget;
const hasValueChanged = runTime.initialValue !== target.value;
runTime.initialValue = null;
removeChangeTargetListeners();
if (target && hasValueChanged) {
return _dispatch(target, "change");
}
}
/**
* @param {EventTarget} target
* @param {PointerEventInit} pointerInit
@ -1057,10 +1106,13 @@ async function triggerFocus(target) {
return;
}
if (previous !== target.ownerDocument.body) {
if (runTime.changeTarget) {
await triggerChange();
}
await dispatchAndIgnore({
target: previous,
events: ["blur", "focusout"],
callback: (el) => el.blur(),
callback: $blur.bind(previous),
options: { relatedTarget: target },
});
}
@ -1070,7 +1122,7 @@ async function triggerFocus(target) {
target,
events: ["focus", "focusin"],
additionalEvents: ["select"],
callback: (el) => el.focus(),
callback: $focus.bind(target),
options: { relatedTarget: previous },
});
if (previousSelection && previousSelection === getStringSelection(target)) {
@ -1080,22 +1132,17 @@ async function triggerFocus(target) {
}
/**
* @param {EventTarget} target
* @param {Resolver<EventTarget>} targetResolver
* @param {FillOptions} [options]
*/
async function _clear(target, options) {
// Inputs and text areas
const initialValue = target.value;
async function _clear(targetResolver, options) {
// Simulates 2 key presses:
// - Control + A: selects all the text
// - Backspace: deletes the text
fullClear = true;
await _press(target, { ctrlKey: true, key: "a" });
await _press(target, { key: "Backspace" });
fullClear = false;
await _press(targetResolver, { ctrlKey: true, key: "a" });
await _press(targetResolver, { key: "Backspace" }, { fullClear: true });
await registerForChange(target, initialValue, options?.confirm);
await registerForChange(resolve(targetResolver), options?.confirm);
}
/**
@ -1143,45 +1190,41 @@ async function _dispatch(target, type, eventInit) {
const event = new Constructor(type, params);
target.dispatchEvent(event);
await Promise.resolve();
getCurrentEvents().push(event);
if (afterNextDispatch) {
const callback = afterNextDispatch;
afterNextDispatch = null;
await microTick().then(callback);
}
return event;
}
/**
* @param {EventTarget} target
* @param {Resolver<EventTarget>} targetResolver
* @param {InputValue} value
* @param {FillOptions} [options]
*/
async function _fill(target, value, options) {
const initialValue = target.value;
async function _fill(targetResolver, value, options) {
const initialTarget = resolve(targetResolver);
if (getTag(target) === "input") {
switch (target.type) {
case "color": {
target.value = String(value);
await _dispatch(target, "input");
await _dispatch(target, "change");
if (getTag(initialTarget) === "input") {
switch (initialTarget.type) {
case "color":
case "time": {
initialTarget.value = String(value);
await _dispatch(initialTarget, "input");
await _dispatch(initialTarget, "change");
return;
}
case "file": {
const files = ensureArray(value);
if (files.length > 1 && !target.multiple) {
if (files.length > 1 && !initialTarget.multiple) {
throw new HootInteractionError(
`input[type="file"] does not support multiple files`
);
}
target.files = createDataTransfer({ files }).files;
initialTarget.files = createDataTransfer({ files }).files;
await _dispatch(target, "change");
await _dispatch(initialTarget, "change");
return;
}
case "range": {
@ -1190,9 +1233,9 @@ async function _fill(target, value, options) {
throw new TypeError(`input[type="range"] only accept 'number' values`);
}
target.value = String(numberValue);
await _dispatch(target, "input");
await _dispatch(target, "change");
initialTarget.value = String(numberValue);
await _dispatch(initialTarget, "input");
await _dispatch(initialTarget, "change");
return;
}
}
@ -1201,25 +1244,25 @@ async function _fill(target, value, options) {
if (options?.instantly) {
// Simulates filling the clipboard with the value (can be from external source)
globalThis.navigator.clipboard.writeText(value).catch();
await _press(target, { ctrlKey: true, key: "v" });
await _press(targetResolver, { ctrlKey: true, key: "v" });
} else {
if (options?.composition) {
runTime.isComposing = true;
// Simulates the start of a composition
await _dispatch(target, "compositionstart");
await _dispatch(initialTarget, "compositionstart");
}
for (const char of String(value)) {
const key = char.toLowerCase();
await _press(target, { key, shiftKey: key !== char });
await _press(targetResolver, { key, shiftKey: key !== char });
}
if (options?.composition) {
runTime.isComposing = false;
// Simulates the end of a composition
await _dispatch(target, "compositionend");
await _dispatch(initialTarget, "compositionend");
}
}
await registerForChange(target, initialValue, options?.confirm);
await registerForChange(resolve(targetResolver), options?.confirm);
}
/**
@ -1228,7 +1271,7 @@ async function _fill(target, value, options) {
* @param {{ implicit?: boolean, originalTarget: AsyncTarget }} hoverOptions
*/
async function _hover(target, options, hoverOptions) {
const pointerTarget = target && getPointerTarget(target, hoverOptions.originalTarget, options);
const pointerTarget = getPointerTarget(target, options, hoverOptions.originalTarget);
const position = target && getPosition(target, options);
const previousPT = runTime.pointerTarget;
@ -1340,17 +1383,22 @@ async function _hover(target, options, hoverOptions) {
}
/**
* @param {EventTarget} target
* @param {Resolver<EventTarget>} targetResolver
* @param {KeyboardEventInit} eventInit
* @param {KeyDownOptions} [options]
*/
async function _keyDown(target, eventInit) {
async function _keyDown(targetResolver, eventInit, options) {
eventInit = { ...eventInit, ...currentEventInit.keydown };
registerSpecialKey(eventInit, true);
const keyDownTarget = resolve(targetResolver);
const repeat =
typeof eventInit.repeat === "boolean" ? eventInit.repeat : runTime.key === eventInit.key;
runTime.key = eventInit.key;
const keyDownEvent = await _dispatch(target, "keydown", { ...eventInit, repeat });
const keyDownEvent = await _dispatch(keyDownTarget, "keydown", {
...eventInit,
repeat,
});
if (isPrevented(keyDownEvent)) {
return;
@ -1361,10 +1409,11 @@ async function _keyDown(target, eventInit) {
* @param {string} type
*/
function insertValue(toInsert, type) {
const { selectionStart, selectionEnd, value } = target;
const { selectionStart, selectionEnd, value } = inputTarget;
inputData = toInsert;
inputType = type;
if (isNil(selectionStart) && isNil(selectionEnd)) {
nextValue ||= inputTarget.value;
nextValue += toInsert;
} else {
nextValue = value.slice(0, selectionStart) + toInsert + value.slice(selectionEnd);
@ -1375,21 +1424,27 @@ async function _keyDown(target, eventInit) {
}
const { ctrlKey, key, shiftKey } = keyDownEvent;
const initialValue = target.value;
const inputTarget = resolve(targetResolver);
let inputData = null;
let inputType = null;
let nextSelectionEnd = null;
let nextSelectionStart = null;
let nextValue = initialValue;
let nextValue = null;
let triggerSelect = false;
if (isEditable(target)) {
if (runTime.initialValue === null || keyDownTarget !== inputTarget) {
// If the 'keydown' event changes the target:
// -> initial value needs to be re-evaluated from the new target
runTime.initialValue = inputTarget.value;
}
if (isEditable(inputTarget)) {
switch (key) {
case "ArrowDown":
case "ArrowLeft":
case "ArrowUp":
case "ArrowRight": {
const { selectionStart, selectionEnd, value } = target;
const { selectionStart, selectionEnd, value } = inputTarget;
if (isNil(selectionStart) || isNil(selectionEnd)) {
break;
}
@ -1410,9 +1465,11 @@ async function _keyDown(target, eventInit) {
break;
}
case "Backspace": {
const { selectionStart, selectionEnd, value } = target;
if (fullClear) {
// Remove all characters
const { selectionStart, selectionEnd, value } = inputTarget;
if (options?.fullClear) {
// Remove all characters, regardless of the selection. This
// is to be used to ensure that an input without selection properties
// (e.g. input[type="number"]) can still be properly cleared.
nextValue = "";
} else if (isNil(selectionStart) || isNil(selectionEnd)) {
// Remove last character
@ -1422,17 +1479,14 @@ async function _keyDown(target, eventInit) {
nextValue = value.slice(0, selectionStart - 1) + value.slice(selectionEnd);
} else {
// Remove current selection from target value
nextValue = deleteSelection(target);
nextValue = deleteSelection(inputTarget);
}
inputType = "deleteContentBackward";
break;
}
case "Delete": {
const { selectionStart, selectionEnd, value } = target;
if (fullClear) {
// Remove all characters
nextValue = "";
} else if (isNil(selectionStart) || isNil(selectionEnd)) {
const { selectionStart, selectionEnd, value } = inputTarget;
if (isNil(selectionStart) || isNil(selectionEnd)) {
// Remove first character
nextValue = value.slice(1);
} else if (selectionStart === selectionEnd) {
@ -1440,18 +1494,11 @@ async function _keyDown(target, eventInit) {
nextValue = value.slice(0, selectionStart) + value.slice(selectionEnd + 1);
} else {
// Remove current selection from target value
nextValue = deleteSelection(target);
nextValue = deleteSelection(inputTarget);
}
inputType = "deleteContentForward";
break;
}
case "Enter": {
if (target.tagName === "TEXTAREA") {
// Insert new line
insertValue("\n", "insertLineBreak");
}
break;
}
default: {
if (key.length === 1 && !ctrlKey) {
// Character coming from the keystroke
@ -1469,14 +1516,14 @@ async function _keyDown(target, eventInit) {
case "a": {
if (ctrlKey) {
// Select all
if (isEditable(target)) {
if (isEditable(inputTarget)) {
nextSelectionStart = 0;
nextSelectionEnd = target.value.length;
nextSelectionEnd = inputTarget.value.length;
triggerSelect = true;
} else {
const selection = globalThis.getSelection();
const range = $createRange();
range.selectNodeContents(target);
range.selectNodeContents(inputTarget);
selection.removeAllRanges();
selection.addRange(range);
}
@ -1495,14 +1542,21 @@ async function _keyDown(target, eventInit) {
globalThis.navigator.clipboard.writeText(text).catch();
runTime.clipboardData = createDataTransfer(eventInit);
await _dispatch(target, "copy", { clipboardData: runTime.clipboardData });
await _dispatch(inputTarget, "copy", { clipboardData: runTime.clipboardData });
}
break;
}
case "Enter": {
const tag = getTag(target);
const parentForm = target.closest("form");
if (parentForm && target.type !== "button") {
if (runTime.changeTarget) {
await triggerChange();
}
if (inputTarget.tagName === "TEXTAREA" && isEditable(inputTarget)) {
// Insert new line
insertValue("\n", "insertLineBreak");
}
const tag = getTag(inputTarget);
const parentForm = inputTarget.closest("form");
if (parentForm && inputTarget.type !== "button") {
/**
* Special action: <form> 'Enter'
* On: unprevented 'Enter' keydown on any element that
@ -1512,14 +1566,16 @@ async function _keyDown(target, eventInit) {
await _dispatch(parentForm, "submit");
} else if (
!keyDownEvent.repeat &&
(tag === "a" || tag === "button" || (tag === "input" && target.type === "button"))
(tag === "a" ||
tag === "button" ||
(tag === "input" && inputTarget.type === "button"))
) {
/**
* Special action: <a>, <button> or <input type="button"> 'Enter'
* On: unprevented and unrepeated 'Enter' keydown on mentioned elements
* Do: triggers a 'click' event on the element
*/
await _dispatch(target, "click", { button: btn.LEFT });
await _dispatch(inputTarget, "click", { button: btn.LEFT });
}
break;
}
@ -1548,7 +1604,7 @@ async function _keyDown(target, eventInit) {
* Do: paste current clipboard content to current element
*/
case "v": {
if (ctrlKey && isEditable(target)) {
if (ctrlKey && isEditable(inputTarget)) {
// Set target value (if possible)
try {
nextValue = await globalThis.navigator.clipboard.readText();
@ -1557,7 +1613,7 @@ async function _keyDown(target, eventInit) {
}
inputType = "insertFromPaste";
await _dispatch(target, "paste", {
await _dispatch(inputTarget, "paste", {
clipboardData: runTime.clipboardData || createDataTransfer(eventInit),
});
runTime.clipboardData = null;
@ -1570,59 +1626,64 @@ async function _keyDown(target, eventInit) {
* Do: cut current selection to clipboard and remove selection
*/
case "x": {
if (ctrlKey && isEditable(target)) {
if (ctrlKey && isEditable(inputTarget)) {
// Get selection from window
const text = globalThis.getSelection().toString();
globalThis.navigator.clipboard.writeText(text).catch();
nextValue = deleteSelection(target);
nextValue = deleteSelection(inputTarget);
inputType = "deleteByCut";
runTime.clipboardData = createDataTransfer(eventInit);
await _dispatch(target, "cut", { clipboardData: runTime.clipboardData });
await _dispatch(inputTarget, "cut", { clipboardData: runTime.clipboardData });
}
break;
}
}
if (initialValue !== nextValue) {
target.value = nextValue;
if (nextValue !== null) {
inputTarget.value = nextValue;
const inputEventInit = {
data: inputData,
inputType,
};
const beforeInputEvent = await _dispatch(target, "beforeinput", inputEventInit);
const beforeInputEvent = await _dispatch(inputTarget, "beforeinput", inputEventInit);
if (!isPrevented(beforeInputEvent)) {
await _dispatch(target, "input", inputEventInit);
await _dispatch(inputTarget, "input", inputEventInit);
}
}
changeSelection(target, nextSelectionStart, nextSelectionEnd);
changeSelection(inputTarget, nextSelectionStart, nextSelectionEnd);
if (triggerSelect) {
await dispatchAndIgnore({
target,
target: inputTarget,
events: ["select"],
});
}
}
/**
* @param {EventTarget} target
* @param {Resolver<EventTarget>} targetResolver
* @param {KeyboardEventInit} eventInit
*/
async function _keyUp(target, eventInit) {
async function _keyUp(targetResolver, eventInit) {
eventInit = { ...eventInit, ...currentEventInit.keyup };
await _dispatch(target, "keyup", eventInit);
await _dispatch(resolve(targetResolver), "keyup", eventInit);
runTime.key = null;
registerSpecialKey(eventInit, false);
if (eventInit.key === " " && getTag(target) === "input" && target.type === "checkbox") {
const finalTarget = resolve(targetResolver);
if (
eventInit.key === " " &&
getTag(finalTarget) === "input" &&
finalTarget.type === "checkbox"
) {
/**
* Special action: input[type=checkbox] 'Space'
* On: unprevented ' ' keydown on an <input type="checkbox"/>
* Do: triggers a 'click' event on the input
*/
await triggerClick(target, { button: btn.LEFT });
await triggerClick(finalTarget, { button: btn.LEFT });
}
}
@ -1630,7 +1691,7 @@ async function _keyUp(target, eventInit) {
* @param {DragOptions} [options]
*/
async function _pointerDown(options) {
setPointerDownTarget(runTime.pointerTarget);
setPointerDownTarget(runTime.pointerTarget, options);
if (options?.dataTransfer || options?.files || options?.items) {
runTime.dataTransfer = createDataTransfer(options);
@ -1690,6 +1751,7 @@ async function _pointerUp(options) {
const target = runTime.pointerTarget;
const isLongTap = globalThis.Date.now() - runTime.touchStartTimeOffset > LONG_TAP_DELAY;
const pointerDownTarget = runTime.pointerDownTarget;
const pointerUpTarget = getPointerTarget(target, options);
const eventInit = {
...runTime.position,
...currentEventInit.pointerup,
@ -1709,10 +1771,10 @@ async function _pointerUp(options) {
* - On: pointer up after a prevented 'dragover' or 'dragenter'
* - Do: triggers a 'drop' event on the target
*/
await _dispatch(target, "drop", eventInitWithDT);
await _dispatch(pointerUpTarget, "drop", eventInitWithDT);
}
await _dispatch(target, "dragend", eventInitWithDT);
await _dispatch(pointerUpTarget, "dragend", eventInitWithDT);
return;
}
@ -1720,8 +1782,8 @@ async function _pointerUp(options) {
...eventInit,
detail: runTime.clickCount + 1,
};
await dispatchPointerEvent(target, "pointerup", eventInit, {
mouse: !target.disabled && ["mouseup", mouseEventInit],
await dispatchPointerEvent(pointerUpTarget, "pointerup", eventInit, {
mouse: !pointerUpTarget.disabled && ["mouseup", mouseEventInit],
touch: ["touchend"],
});
@ -1737,9 +1799,9 @@ async function _pointerUp(options) {
let clickTarget;
if (hasTouch()) {
clickTarget = pointerDownTarget === target && target;
clickTarget = pointerDownTarget === pointerUpTarget && pointerUpTarget;
} else {
clickTarget = getFirstCommonParent(target, pointerDownTarget);
clickTarget = getFirstCommonParent(pointerUpTarget, pointerDownTarget);
}
if (clickTarget) {
await triggerClick(clickTarget, mouseEventInit);
@ -1751,7 +1813,7 @@ async function _pointerUp(options) {
}
}
setPointerDownTarget(null);
setPointerDownTarget(null, options);
if (runTime.pointerDownTimeout) {
globalThis.clearTimeout(runTime.pointerDownTimeout);
}
@ -1764,12 +1826,13 @@ async function _pointerUp(options) {
}
/**
* @param {EventTarget} target
* @param {Resolver<EventTarget>} targetResolver
* @param {KeyboardEventInit} eventInit
* @param {KeyDownOptions} [options]
*/
async function _press(target, eventInit) {
await _keyDown(target, eventInit);
await _keyUp(target, eventInit);
async function _press(targetResolver, eventInit, options) {
await _keyDown(targetResolver, eventInit, options);
await _keyUp(targetResolver, eventInit);
}
/**
@ -1777,7 +1840,7 @@ async function _press(target, eventInit) {
* @param {string | number | (string | number)[]} value
*/
async function _select(target, value) {
const values = ensureArray(value).map(String);
const values = ensureArray(value, String);
let found = false;
for (const option of target.options) {
option.selected = values.includes(option.value);
@ -1880,13 +1943,7 @@ const currentEvents = $create(null);
const currentEventInit = $create(null);
/** @type {string[]} */
const currentEventTypes = [];
/** @type {(() => Promise<void>) | null} */
let afterNextDispatch = null;
let allowLogs = false;
let fullClear = false;
// Keyboard global variables
const changeTargetListeners = [];
// Other global variables
const runTime = getDefaultRunTimeValue();
@ -2063,19 +2120,19 @@ export function cleanupEvents() {
*/
export async function clear(options) {
const finalizeEvents = setupEvents("clear", options);
const element = getActiveElement();
const activeElement = getActiveElement();
if (!hasTagName(element, "select") && !isEditable(element)) {
if (!hasTagName(activeElement, "select") && !isEditable(activeElement)) {
throw new HootInteractionError(
`cannot call \`clear()\`: target should be editable or a <select> element`
);
}
if (isEditable(element)) {
await _clear(element, options);
if (isEditable(activeElement)) {
await _clear(getActiveElement, options);
} else {
// Selects
await _select(element, "");
await _select(activeElement, "");
}
return finalizeEvents();
@ -2311,15 +2368,15 @@ export async function drag(target, options) {
*/
export async function edit(value, options) {
const finalizeEvents = setupEvents("edit", options);
const element = getActiveElement();
if (!isEditable(element)) {
const activeElement = getActiveElement();
if (!isEditable(activeElement)) {
throw new HootInteractionError(`cannot call \`edit()\`: target should be editable`);
}
if (getNodeValue(element)) {
await _clear(element);
if (getNodeValue(activeElement)) {
await _clear(getActiveElement);
}
await _fill(element, value, options);
await _fill(getActiveElement, value, options);
return finalizeEvents();
}
@ -2358,13 +2415,12 @@ export function enableEventLogs(toggle) {
*/
export async function fill(value, options) {
const finalizeEvents = setupEvents("fill", options);
const element = getActiveElement();
if (!isEditable(element)) {
if (!isEditable(getActiveElement())) {
throw new HootInteractionError(`cannot call \`fill()\`: target should be editable`);
}
await _fill(element, value, options);
await _fill(getActiveElement, value, options);
return finalizeEvents();
}
@ -2422,7 +2478,7 @@ export async function keyDown(keyStrokes, options) {
const finalizeEvents = setupEvents("keyDown", options);
const eventInits = parseKeyStrokes(keyStrokes, options);
for (const eventInit of eventInits) {
await _keyDown(getActiveElement(), eventInit);
await _keyDown(getActiveElement, eventInit);
}
return finalizeEvents();
@ -2444,7 +2500,7 @@ export async function keyUp(keyStrokes, options) {
const finalizeEvents = setupEvents("keyUp", options);
const eventInits = parseKeyStrokes(keyStrokes, options);
for (const eventInit of eventInits) {
await _keyUp(getActiveElement(), eventInit);
await _keyUp(getActiveElement, eventInit);
}
return finalizeEvents();
@ -2597,13 +2653,12 @@ export async function pointerUp(target, options) {
export async function press(keyStrokes, options) {
const finalizeEvents = setupEvents("press", options);
const eventInits = parseKeyStrokes(keyStrokes, options);
const activeElement = getActiveElement();
for (const eventInit of eventInits) {
await _keyDown(activeElement, eventInit);
await _keyDown(getActiveElement, eventInit);
}
for (const eventInit of eventInits.reverse()) {
await _keyUp(activeElement, eventInit);
await _keyUp(getActiveElement, eventInit);
}
return finalizeEvents();
@ -2729,7 +2784,7 @@ export async function scroll(target, position, options) {
await dispatchAndIgnore({
target: element,
events: ["scroll", "scrollend"],
callback: (el) => el.scrollTo(scrollTopOptions),
callback: () => element.scrollTo(scrollTopOptions),
});
}
if (initiator === "keyboard") {
@ -2740,8 +2795,8 @@ export async function scroll(target, position, options) {
}
/**
* Performs a selection event sequence current **active element**. This helper is
* intended for `<select>` elements only.
* Performs a selection event sequence on the current **active element**. This helper
* is intended for `<select>` elements only.
*
* The event sequence is as follows:
* - `change`
@ -2755,8 +2810,8 @@ export async function scroll(target, position, options) {
*/
export async function select(value, options) {
const finalizeEvents = setupEvents("select", options);
const target = options?.target || getActiveElement();
const element = queryAny(await target);
const manualTarget = options?.target;
const element = manualTarget ? queryAny(await manualTarget) : getActiveElement();
if (!hasTagName(element, "select")) {
throw new HootInteractionError(
@ -2764,12 +2819,12 @@ export async function select(value, options) {
);
}
if (options?.target) {
await _hover(element, null, { implicit: true, originalTarget: target });
if (manualTarget) {
await _hover(element, null, { implicit: true, originalTarget: manualTarget });
await _pointerDown();
}
await _select(element, value);
if (options?.target) {
if (manualTarget) {
await _pointerUp();
}
@ -2896,7 +2951,7 @@ export async function uncheck(target, options) {
}
/**
* Triggers a "beforeunload" event the current **window**.
* Triggers a "beforeunload" event on the current **window**.
*
* @param {EventOptions} [options]
* @returns {Promise<EventList>}

View file

@ -71,7 +71,6 @@ export {
//-----------------------------------------------------------------------------
// DOM
export const observe = interactor("query", dom.observe);
export const waitFor = interactor("query", dom.waitFor);
export const waitForNone = interactor("query", dom.waitForNone);

View file

@ -17,7 +17,7 @@
* | "symbol"
* | "undefined"} ArgumentPrimitive
*
* @typedef {[string, any[], any]} InteractionDetails
* @typedef {[string, string | undefined, any[], any]} InteractionDetails
*
* @typedef {"interaction" | "query" | "server" | "time"} InteractionType
*/
@ -55,27 +55,28 @@ const $toString = Object.prototype.toString;
* @param {InteractionType} type
* @param {T} fn
* @param {string} name
* @param {string} [alias]
* @returns {T}
*/
function makeInteractorFn(type, fn, name) {
function makeInteractorFn(type, fn, name, alias) {
return {
[name](...args) {
[alias || name](...args) {
const result = fn(...args);
if (isInstanceOf(result, Promise)) {
if (isPromise(result)) {
for (let i = 0; i < args.length; i++) {
if (isInstanceOf(args[i], Promise)) {
if (isPromise(args[i])) {
// Get promise result for async arguments if possible
args[i].then((result) => (args[i] = result));
}
}
return result.then((promiseResult) =>
dispatchInteraction(type, name, args, promiseResult)
dispatchInteraction(type, name, alias, args, promiseResult)
);
} else {
return dispatchInteraction(type, name, args, result);
return dispatchInteraction(type, name, alias, args, result);
}
},
}[name];
}[alias || name];
}
function polyfillIsError(value) {
@ -237,20 +238,21 @@ export function addInteractionListener(types, callback) {
/**
* @param {InteractionType} type
* @param {string} name
* @param {string | undefined} alias
* @param {any[]} args
* @param {any} returnValue
*/
export function dispatchInteraction(type, name, args, returnValue) {
export function dispatchInteraction(type, name, alias, args, returnValue) {
interactionBus.dispatchEvent(
new CustomEvent(type, {
detail: [name, args, returnValue],
detail: [name, alias, args, returnValue],
})
);
return returnValue;
}
/**
* @param {...any} helpers
* @param {...any} helpers
*/
export function exposeHelpers(...helpers) {
let nameSpaceIndex = 1;
@ -299,7 +301,7 @@ export function getTag(node) {
export function interactor(type, fn) {
return $assign(makeInteractorFn(type, fn, fn.name), {
as(alias) {
return makeInteractorFn(type, fn, alias);
return makeInteractorFn(type, fn, fn.name, alias);
},
get silent() {
return fn;
@ -314,6 +316,16 @@ export function isFirefox() {
return /firefox/i.test($userAgent);
}
Array.isArray;
/**
* @param {any} instance
* @returns {instance is Promise<any>}
*/
export function isPromise(instance) {
return instance && typeof instance.then === "function";
}
/**
* Cross-realm equivalent to 'instanceof'.
* Can be called with multiple constructors, and will return true if the given object
@ -412,7 +424,7 @@ export function toSelector(node, options) {
export class HootDebugHelpers {
/**
* @param {...any} helpers
* @param {...any} helpers
*/
constructor(...helpers) {
$assign(this, ...helpers);