mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 23:52:09 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue