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

2773 lines
81 KiB
JavaScript

/** @odoo-module */
import { HootDomError, getTag, isFirefox, isIterable } from "../hoot_dom_utils";
import {
getActiveElement,
getDocument,
getNextFocusableElement,
getNodeRect,
getNodeValue,
getParentFrame,
getPreviousFocusableElement,
getStyle,
getWindow,
isCheckable,
isEditable,
isEventTarget,
isNode,
isNodeFocusable,
parseDimensions,
parsePosition,
queryAll,
queryFirst,
setDimensions,
toSelector,
} from "./dom";
import { microTick } from "./time";
/**
* @typedef {Target | Promise<Target>} AsyncTarget
*
* @typedef {"auto" | "blur" | "enter" | "tab" | false} ConfirmAction
*
* @typedef {{
* cancel: (options?: EventOptions) => Promise<EventList>;
* drop: (to?: AsyncTarget, options?: PointerOptions) => Promise<EventList>;
* moveTo: (to?: AsyncTarget, options?: PointerOptions) => Promise<DragHelpers>;
* }} DragHelpers
*
* @typedef {import("./dom").Position} Position
*
* @typedef {import("./dom").Dimensions} Dimensions
*
* @typedef {((ev: Event) => boolean) | EventType} EventListPredicate
*
* @typedef {{
* eventInit?: EventInit;
* }} EventOptions generic event options
*
* @typedef {{
* clientX: number;
* clientY: number;
* pageX: number;
* pageY: number;
* screenX: number;
* screenY: number;
* }} EventPosition
*
* @typedef {keyof HTMLElementEventMap | keyof WindowEventMap} EventType
*
* @typedef {EventOptions & {
* confirm?: ConfirmAction;
* composition?: boolean;
* instantly?: boolean;
* }} FillOptions
*
* @typedef {string | number | MaybeIterable<File>} InputValue
*
* @typedef {EventOptions & KeyboardEventInit} KeyboardOptions
*
* @typedef {string | string[]} KeyStrokes
*
* @typedef {EventOptions & QueryOptions & {
* button?: number,
* position?: Side | `${Side}-${Side}` | Position;
* relative?: boolean;
* }} PointerOptions
*
* @typedef {import("./dom").QueryOptions} QueryOptions
*
* @typedef {EventOptions & QueryOptions & {
* force?: boolean;
* initiator?: "keyboard" | "scrollbar" | "wheel" | null;
* relative?: boolean;
* }} ScrollOptions
*
* @typedef {EventOptions & {
* target: AsyncTarget;
* }} SelectOptions
*
* @typedef {"bottom" | "left" | "right" | "top"} Side
*/
/**
* @template [T=EventInit]
* @typedef {T & {
* target: EventTarget;
* type: EventType;
* }} FullEventInit
*/
/**
* @template T
* @typedef {T | Iterable<T>} MaybeIterable
*/
/**
* @template [T=Node]
* @typedef {import("./dom").Target<T>} Target
*/
//-----------------------------------------------------------------------------
// Global
//-----------------------------------------------------------------------------
const {
AnimationEvent,
ClipboardEvent,
CompositionEvent,
console: { dir: $dir, groupCollapsed: $groupCollapsed, groupEnd: $groupEnd, log: $log },
DataTransfer,
document,
DragEvent,
ErrorEvent,
Event,
FocusEvent,
KeyboardEvent,
Math: { ceil: $ceil, max: $max, min: $min },
MouseEvent,
Number: { isInteger: $isInteger, isNaN: $isNaN, parseFloat: $parseFloat },
Object: { assign: $assign, values: $values },
PointerEvent,
PromiseRejectionEvent,
String,
SubmitEvent,
Touch,
TouchEvent,
TypeError,
WheelEvent,
} = globalThis;
/** @type {Document["createRange"]} */
const $createRange = document.createRange.bind(document);
//-----------------------------------------------------------------------------
// Internal
//-----------------------------------------------------------------------------
/**
* @param {Event} ev
*/
const cancelTrustedEvent = (ev) => {
if (ev.isTrusted && runTime.eventsToIgnore.includes(ev.type)) {
runTime.eventsToIgnore.splice(runTime.eventsToIgnore.indexOf(ev.type), 1);
ev.stopPropagation();
ev.stopImmediatePropagation();
ev.preventDefault();
}
};
/**
* @param {HTMLElement} target
* @param {number} start
* @param {number} end
*/
const changeSelection = async (target, start, end) => {
if (!isNil(start) && !isNil(target.selectionStart)) {
target.selectionStart = start;
}
if (!isNil(end) && !isNil(target.selectionEnd)) {
target.selectionEnd = end;
}
};
/**
* @param {HTMLElement} target
* @param {number} x
*/
const constrainScrollX = (target, x) => {
let { offsetWidth, scrollWidth } = target;
const document = getDocument(target);
if (target === document || target === document.documentElement) {
// <html> elements in iframes consider the width of the <iframe> element
const iframe = getParentFrame(target);
if (iframe) {
({ offsetWidth } = iframe);
}
}
const maxScrollLeft = scrollWidth - offsetWidth;
const { direction } = getStyle(target);
const [min, max] = direction === "rtl" ? [-maxScrollLeft, 0] : [0, maxScrollLeft];
return $min($max(x, min), max);
};
/**
* @param {HTMLElement} target
* @param {number} y
*/
const constrainScrollY = (target, y) => {
let { offsetHeight, scrollHeight } = target;
const document = getDocument(target);
if (target === document || target === document.documentElement) {
// <html> elements in iframes consider the height of the <iframe> element
const iframe = getParentFrame(target);
if (iframe) {
({ offsetHeight } = iframe);
}
}
return $min($max(y, 0), scrollHeight - offsetHeight);
};
/**
* @param {HTMLInputElement | HTMLTextAreaElement} target
*/
const deleteSelection = (target) => {
const { selectionStart, selectionEnd, value } = target;
return value.slice(0, selectionStart) + value.slice(selectionEnd);
};
/**
* @template {EventTarget} T
* @param {{
* target: T;
* events: EventType[];
* additionalEvents?: EventType[];
* callback?: (target: T) => any;
* options?: EventInit;
* }} params
*/
const dispatchAndIgnore = async ({ target, events, additionalEvents = [], callback, options }) => {
for (const eventType of [...events, ...additionalEvents]) {
runTime.eventsToIgnore.push(eventType);
}
if (callback) {
callback(target);
}
for (const eventType of events) {
await dispatch(target, eventType, options);
}
};
/**
*
* @param {EventTarget} target
* @param {EventType} eventType
* @param {PointerEventInit} eventInit
* @param {{
* mouse?: [EventType, MouseEventInit];
* touch?: [EventType, TouchEventInit];
* }} additionalEvents
*/
const dispatchPointerEvent = async (target, eventType, eventInit, { mouse, touch }) => {
const pointerEvent = await dispatch(target, eventType, eventInit);
let prevented = isPrevented(pointerEvent);
if (hasTouch()) {
if (touch && runTime.pointerDownTarget) {
const [touchEventType, touchEventInit] = touch;
await dispatch(runTime.pointerDownTarget, touchEventType, touchEventInit || eventInit);
}
} else {
if (mouse && !prevented) {
const [mouseEventType, mouseEventInit] = mouse;
const mouseEvent = await dispatch(target, mouseEventType, mouseEventInit || eventInit);
prevented = isPrevented(mouseEvent);
}
}
return prevented;
};
/**
* @param {Iterable<Event>} events
* @param {EventType} eventType
* @param {EventInit} eventInit
*/
const dispatchRelatedEvents = async (events, eventType, eventInit) => {
for (const event of events) {
if (!event.target || isPrevented(event)) {
break;
}
await dispatch(event.target, eventType, eventInit);
}
};
/**
* @template T
* @param {MaybeIterable<T>} value
* @returns {T[]}
*/
const ensureArray = (value) => (isIterable(value) ? [...value] : [value]);
const getCurrentEvents = () => {
const eventType = currentEventTypes.at(-1);
if (!eventType) {
return [];
}
currentEvents[eventType] ||= [];
return currentEvents[eventType];
};
const getDefaultRunTimeValue = () => ({
// Composition
isComposing: false,
// Drag & drop
canStartDrag: false,
isDragging: false,
lastDragOverCancelled: false,
// Pointer
clickCount: 0,
key: null,
pointerDownTarget: null,
pointerDownTimeout: 0,
pointerTarget: null,
/** @type {EventPosition | {}} */
position: {},
previousPointerDownTarget: null,
previousPointerTarget: null,
/** @type {EventPosition | {}} */
touchStartPosition: {},
// File
fileInput: null,
// Buttons
buttons: 0,
// Modifier keys
modifierKeys: {},
/**
* Ignored events ("select" by default since it is sometimes dispatched by
* focusing an input).
* @type {EventType[]}
*/
eventsToIgnore: [],
});
/**
* Returns the list of nodes containing n2 (included) that do not contain n1.
*
* @param {Element} [el1]
* @param {Element} [el2]
*/
const getDifferentParents = (el1, el2) => {
if (!el1 && !el2) {
// No given elements => no parents
return [];
} else if (!el1 && el2) {
// No first element => only parents of second element
[el1, el2] = [el2, el1];
}
const parents = [el2 || el1];
while (parents[0].parentElement) {
const parent = parents[0].parentElement;
if (el2 && parent.contains(el1)) {
break;
}
parents.unshift(parent);
}
return parents;
};
/**
* @template {typeof Event} T
* @param {EventType} eventType
* @returns {[T, ((attrs: FullEventInit) => EventInit), number]}
*/
const getEventConstructor = (eventType) => {
switch (eventType) {
// Mouse events
case "dblclick":
case "mousedown":
case "mouseup":
case "mousemove":
case "mouseover":
case "mouseout":
return [MouseEvent, mapMouseEvent, BUBBLES | CANCELABLE | VIEW];
case "mouseenter":
case "mouseleave":
return [MouseEvent, mapMouseEvent, VIEW];
// Pointer events
case "auxclick":
case "click":
case "contextmenu":
case "pointerdown":
case "pointerup":
case "pointermove":
case "pointerover":
case "pointerout":
return [PointerEvent, mapPointerEvent, BUBBLES | CANCELABLE | VIEW];
case "pointerenter":
case "pointerleave":
case "pointercancel":
return [PointerEvent, mapPointerEvent, VIEW];
// Focus events
case "blur":
case "focus":
return [FocusEvent, mapEvent];
case "focusin":
case "focusout":
return [FocusEvent, mapEvent, BUBBLES];
// Clipboard events
case "cut":
case "copy":
case "paste":
return [ClipboardEvent, mapEvent, BUBBLES];
// Keyboard events
case "keydown":
case "keyup":
return [KeyboardEvent, mapKeyboardEvent, BUBBLES | CANCELABLE | VIEW];
// Drag events
case "drag":
case "dragend":
case "dragenter":
case "dragstart":
case "dragleave":
case "dragover":
case "drop":
return [DragEvent, mapEvent, BUBBLES];
// Input events
case "beforeinput":
return [InputEvent, mapInputEvent, BUBBLES | CANCELABLE | VIEW];
case "input":
return [InputEvent, mapInputEvent, BUBBLES | VIEW];
// Composition events
case "compositionstart":
case "compositionend":
return [CompositionEvent, mapEvent, BUBBLES];
// Selection events
case "select":
case "selectionchange":
return [Event, mapEvent, BUBBLES];
// Touch events
case "touchstart":
case "touchend":
case "touchmove":
return [TouchEvent, mapTouchEvent, BUBBLES | CANCELABLE | VIEW];
case "touchcancel":
return [TouchEvent, mapTouchEvent, BUBBLES | VIEW];
// Resize events
case "resize":
return [Event, mapEvent];
// Submit events
case "submit":
return [SubmitEvent, mapEvent, BUBBLES | CANCELABLE];
// Wheel events
case "wheel":
return [WheelEvent, mapWheelEvent, BUBBLES | VIEW];
// Animation events
case "animationcancel":
case "animationend":
case "animationiteration":
case "animationstart": {
return [AnimationEvent, mapEvent, BUBBLES | CANCELABLE];
}
// Error events
case "error":
return [ErrorEvent, mapEvent];
case "unhandledrejection":
return [PromiseRejectionEvent, mapEvent, CANCELABLE];
// Unload events (BeforeUnloadEvent cannot be constructed)
case "beforeunload":
return [Event, mapEvent, CANCELABLE];
case "unload":
return [Event, mapEvent];
// Default: base Event constructor
default:
return [Event, mapEvent, BUBBLES];
}
};
/**
* @param {Node} [a]
* @param {Node} [b]
*/
const getFirstCommonParent = (a, b) => {
if (!a || !b || a.ownerDocument !== b.ownerDocument) {
return null;
}
const range = document.createRange();
range.setStart(a, 0);
range.setEnd(b, 0);
if (range.collapsed) {
// Re-arranges range if the first node comes after the second
range.setStart(b, 0);
range.setEnd(a, 0);
}
return range.commonAncestorContainer;
};
/**
* @param {HTMLElement} element
* @param {PointerOptions} [options]
*/
const getPosition = (element, options) => {
const { position, relative } = options || {};
const isString = typeof position === "string";
const [posX, posY] = parsePosition(position);
if (!isString && !relative && !$isNaN(posX) && !$isNaN(posY)) {
// Absolute position
return toEventPosition(posX, posY, position);
}
const { x, y, width, height } = getNodeRect(element);
let clientX = x;
let clientY = y;
if (isString) {
const positions = position.split("-");
// X position
if (positions.includes("left")) {
clientX -= 1;
} else if (positions.includes("right")) {
clientX += $ceil(width) + 1;
} else {
clientX += width / 2;
}
// Y position
if (positions.includes("top")) {
clientY -= 1;
} else if (positions.includes("bottom")) {
clientY += $ceil(height) + 1;
} else {
clientY += height / 2;
}
} else {
// X position
if ($isNaN(posX)) {
clientX += width / 2;
} else {
if (relative) {
clientX += posX || 0;
} else {
clientX = posX || 0;
}
}
// Y position
if ($isNaN(posY)) {
clientY += height / 2;
} else {
if (relative) {
clientY += posY || 0;
} else {
clientY = posY || 0;
}
}
}
return toEventPosition(clientX, clientY, position);
};
/**
* @param {Node} target
*/
const getStringSelection = (target) =>
$isInteger(target.selectionStart) &&
$isInteger(target.selectionEnd) &&
[target.selectionStart, target.selectionEnd].join(",");
/**
* @param {Node} node
* @param {...string} tagNames
*/
const hasTagName = (node, ...tagNames) => tagNames.includes(getTag(node));
const hasTouch = () =>
globalThis.ontouchstart !== undefined || globalThis.matchMedia("(pointer:coarse)").matches;
/**
* @param {EventTarget | EventPosition} target
* @param {PointerOptions} [options]
*/
const isDifferentPosition = (target, options) => {
const previous = runTime.position;
const next = isNode(target) ? getPosition(target, options) : target;
for (const key in next) {
if (previous[key] !== next[key]) {
return true;
}
}
return false;
};
/**
* @param {unknown} value
*/
const isNil = (value) => value === null || value === undefined;
/**
* @param {Event} event
*/
const isPrevented = (event) => event && event.defaultPrevented;
/**
* @param {KeyStrokes} keyStrokes
* @param {KeyboardEventInit} [options]
* @returns {KeyboardEventInit}
*/
const parseKeyStrokes = (keyStrokes, options) =>
(isIterable(keyStrokes) ? [...keyStrokes] : [keyStrokes]).map((key) => {
const lower = key.toLowerCase();
return {
...options,
key: lower.length === 1 ? key : KEY_ALIASES[lower] || key,
};
});
/**
* Redirects all 'submit' events to explicit network requests.
*
* This allows the `mockFetch` helper to take control over submit requests.
*
* @param {SubmitEvent} ev
*/
const redirectSubmit = (ev) => {
if (isPrevented(ev)) {
return;
}
ev.preventDefault();
/** @type {HTMLFormElement} */
const form = ev.target;
globalThis.fetch(form.action, {
method: form.method,
body: new FormData(form, ev.submitter),
});
};
/**
* @param {PointerEventInit} eventInit
* @param {boolean} toggle
*/
const registerButton = (eventInit, toggle) => {
let value = 0;
switch (eventInit.button) {
case btn.LEFT: {
// Main button (left button)
value = 1;
break;
}
case btn.MIDDLE: {
// Auxiliary button (middle button)
value = 4;
break;
}
case btn.RIGHT: {
// Secondary button (right button)
value = 2;
break;
}
case btn.BACK: {
// Fourth button (Browser Back)
value = 8;
break;
}
case btn.FORWARD: {
// Fifth button (Browser Forward)
value = 16;
break;
}
}
runTime.buttons = $max(runTime.buttons + (toggle ? value : -value), 0);
};
/**
* @param {Event} ev
*/
const registerFileInput = ({ target }) => {
if (getTag(target) === "input" && target.type === "file") {
runTime.fileInput = target;
} else {
runTime.fileInput = null;
}
};
/**
* @param {EventTarget} target
* @param {string} initialValue
* @param {ConfirmAction} confirmAction
*/
const registerForChange = async (target, initialValue, confirmAction) => {
const dispatchChange = () => target.value !== initialValue && dispatch(target, "change");
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") {
throw new HootDomError(`"enter" confirm action is only supported on <input/> elements`);
}
changeTargetListeners.push(
on(target, "blur", () => {
removeChangeTargetListeners();
dispatchChange();
}),
on(target, "change", removeChangeTargetListeners)
);
switch (confirmAction) {
case "blur": {
await _click(getDocument(target).body, {
position: { x: 0, y: 0 },
});
break;
}
case "enter": {
await _press(target, { key: "Enter" });
break;
}
case "tab": {
await _press(target, { key: "Tab" });
break;
}
}
};
/**
* @param {KeyboardEventInit} eventInit
* @param {boolean} toggle
*/
const registerSpecialKey = (eventInit, toggle) => {
switch (eventInit.key) {
case "Alt": {
runTime.modifierKeys.altKey = toggle;
break;
}
case "Control": {
runTime.modifierKeys.ctrlKey = toggle;
break;
}
case "Meta": {
runTime.modifierKeys.metaKey = toggle;
break;
}
case "Shift": {
runTime.modifierKeys.shiftKey = toggle;
break;
}
}
};
const removeChangeTargetListeners = () => {
while (changeTargetListeners.length) {
changeTargetListeners.pop()();
}
};
/**
* @param {HTMLElement | null} target
*/
const setPointerDownTarget = (target) => {
if (runTime.pointerDownTarget) {
runTime.previousPointerDownTarget = runTime.pointerDownTarget;
}
runTime.pointerDownTarget = target;
runTime.canStartDrag = false;
};
/**
* @param {HTMLElement | null} target
* @param {PointerOptions} [options]
*/
const setPointerTarget = async (target, options) => {
runTime.previousPointerTarget = runTime.pointerTarget;
runTime.pointerTarget = target;
if (runTime.pointerTarget !== runTime.previousPointerTarget && runTime.canStartDrag) {
/**
* Special action: drag start
* On: unprevented 'pointerdown' on a draggable element (DESKTOP ONLY)
* Do: triggers a 'dragstart' event
*/
const dragStartEvent = await dispatch(runTime.previousPointerTarget, "dragstart");
runTime.isDragging = !isPrevented(dragStartEvent);
runTime.canStartDrag = false;
}
runTime.position = target && getPosition(target, options);
};
/**
* @param {string} type
* @param {EventOptions} type
*/
const setupEvents = (type, options) => {
currentEventTypes.push(type);
$assign(currentEventInit, options?.eventInit);
return async () => {
for (const eventType in currentEventInit) {
delete currentEventInit[eventType];
}
const events = new EventList(getCurrentEvents());
const currentType = currentEventTypes.pop();
delete currentEvents[currentType];
if (!allowLogs) {
return events;
}
const groupName = [`${type}: dispatched`, events.length, `events`];
$groupCollapsed(...groupName);
for (const event of events) {
/** @type {(keyof typeof LOG_COLORS)[]} */
const colors = ["blue"];
const typeList = [event.type];
if (event.key) {
typeList.push(event.key);
} else if (event.button) {
typeList.push(event.button);
}
[...Array(typeList.length)].forEach(() => colors.push("orange"));
const typeString = typeList.map((t) => `%c"${t}"%c`).join(", ");
let message = `%c${event.constructor.name}%c<${typeString}>`;
if (event.__bubbleCount) {
message += ` (${event.__bubbleCount})`;
}
const target = event.__originalTarget || event.target;
if (isNode(target)) {
const targetParts = toSelector(target, { object: true });
colors.push("blue");
if (targetParts.id) {
colors.push("orange");
}
if (targetParts.class) {
colors.push("lightBlue");
}
const targetString = $values(targetParts)
.map((part) => `%c${part}%c`)
.join("");
message += ` @${targetString}`;
}
const messageColors = colors.flatMap((color) => [
`color: ${LOG_COLORS[color]}; font-weight: normal`,
`color: ${LOG_COLORS.reset}`,
]);
$groupCollapsed(message, ...messageColors);
$dir(event);
$log(target);
$groupEnd();
}
$groupEnd();
return events;
};
};
/**
* @param {number} clientX
* @param {number} clientY
* @param {Partial<EventPosition>} [position]
*/
const toEventPosition = (clientX, clientY, position) => {
clientX ||= 0;
clientY ||= 0;
return {
clientX,
clientY,
pageX: position?.pageX ?? clientX,
pageY: position?.pageY ?? clientY,
screenX: position?.screenX ?? clientX,
screenY: position?.screenY ?? clientY,
};
};
/**
* @param {EventTarget} target
* @param {PointerEventInit} pointerInit
*/
const triggerClick = async (target, pointerInit) => {
if (target.disabled) {
return;
}
const eventType = (pointerInit.button ?? 0) === btn.LEFT ? "click" : "auxclick";
const clickEvent = await dispatch(target, eventType, pointerInit);
if (isPrevented(clickEvent)) {
return;
}
if (isFirefox()) {
// Thanks Firefox
switch (getTag(target)) {
case "label": {
/**
* @firefox
* Special action: label 'Click'
* On: unprevented 'click' on a <label/>
* Do: triggers a 'click' event on the first <input/> descendant
*/
target = target.control;
if (target) {
await triggerClick(target, pointerInit);
}
break;
}
case "option": {
/**
* @firefox
* Special action: option 'Click'
* On: unprevented 'click' on an <option/>
* Do: triggers a 'change' event on the parent <select/>
*/
const parent = target.parentElement;
if (parent && getTag(parent) === "select") {
await dispatch(parent, "change");
}
break;
}
}
}
};
/**
* @param {EventTarget} target
* @param {DragEventInit} eventInit
*/
const triggerDrag = async (target, eventInit) => {
await dispatch(target, "drag", eventInit);
// Only "dragover" being prevented is taken into account for "drop" events
const dragOverEvent = await dispatch(target, "dragover", eventInit);
runTime.lastDragOverCancelled = isPrevented(dragOverEvent);
};
/**
* @param {EventTarget} target
*/
const triggerFocus = async (target) => {
const previous = getActiveElement(target);
if (previous === target) {
return;
}
if (previous !== target.ownerDocument.body) {
await dispatchAndIgnore({
target: previous,
events: ["blur", "focusout"],
callback: (el) => el.blur(),
options: { relatedTarget: target },
});
}
if (isNodeFocusable(target)) {
const previousSelection = getStringSelection(target);
await dispatchAndIgnore({
target,
events: ["focus", "focusin"],
additionalEvents: ["select"],
callback: (el) => el.focus(),
options: { relatedTarget: previous },
});
if (previousSelection && previousSelection === getStringSelection(target)) {
changeSelection(target, target.value.length, target.value.length);
}
}
};
/**
* @param {EventTarget} target
* @param {FillOptions} [options]
*/
const _clear = async (target, options) => {
// Inputs and text areas
const initialValue = target.value;
// 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 registerForChange(target, initialValue, options?.confirm);
};
/**
* @param {EventTarget} target
* @param {PointerOptions} [options]
*/
const _click = async (target, options) => {
await _pointerDown(target, options);
await _pointerUp(target, options);
};
/**
* @param {EventTarget} target
* @param {InputValue} value
* @param {FillOptions} [options]
*/
const _fill = async (target, value, options) => {
const initialValue = target.value;
if (getTag(target) === "input") {
switch (target.type) {
case "color": {
target.value = String(value);
await dispatch(target, "input");
await dispatch(target, "change");
return;
}
case "file": {
const dataTransfer = new DataTransfer();
const files = ensureArray(value);
if (files.length > 1 && !target.multiple) {
throw new HootDomError(`input[type="file"] does not support multiple files`);
}
for (const file of files) {
if (!(file instanceof File)) {
throw new TypeError(`file input only accept 'File' objects`);
}
dataTransfer.items.add(file);
}
target.files = dataTransfer.files;
await dispatch(target, "change");
return;
}
case "range": {
const numberValue = $parseFloat(value);
if ($isNaN(numberValue)) {
throw new TypeError(`input[type="range"] only accept 'number' values`);
}
target.value = String(numberValue);
await dispatch(target, "input");
await dispatch(target, "change");
return;
}
}
}
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" });
} else {
if (options?.composition) {
runTime.isComposing = true;
// Simulates the start of a composition
await dispatch(target, "compositionstart");
}
for (const char of String(value)) {
const key = char.toLowerCase();
await _press(target, { key, shiftKey: key !== char });
}
if (options?.composition) {
runTime.isComposing = false;
// Simulates the end of a composition
await dispatch(target, "compositionend");
}
}
await registerForChange(target, initialValue, options?.confirm);
};
/**
* @param {EventTarget} target
* @param {PointerOptions} [options]
*/
const _hover = async (target, options) => {
const isDifferentTarget = target !== runTime.pointerTarget;
const previousPosition = runTime.position;
await setPointerTarget(target, options);
const { previousPointerTarget: previous, pointerTarget: current } = runTime;
if (isDifferentTarget && previous && (!current || !previous.contains(current))) {
// Leaves previous target
const leaveEventInit = {
...previousPosition,
relatedTarget: current,
};
if (runTime.isDragging) {
// If dragging, only drag events are triggered
await triggerDrag(previous, leaveEventInit);
await dispatch(previous, "dragleave", leaveEventInit);
} else {
// Regular case: pointer events are triggered
await dispatchPointerEvent(previous, "pointermove", leaveEventInit, {
mouse: ["mousemove"],
touch: ["touchmove"],
});
await dispatchPointerEvent(previous, "pointerout", leaveEventInit, {
mouse: ["mouseout"],
});
const leaveEvents = await Promise.all(
getDifferentParents(current, previous).map((element) =>
dispatch(element, "pointerleave", leaveEventInit)
)
);
if (!hasTouch()) {
await dispatchRelatedEvents(leaveEvents, "mouseleave", leaveEventInit);
}
}
}
if (current) {
const enterEventInit = {
...runTime.position,
relatedTarget: previous,
};
if (runTime.isDragging) {
// If dragging, only drag events are triggered
if (isDifferentTarget) {
await dispatch(target, "dragenter", enterEventInit);
}
await triggerDrag(target, enterEventInit);
} else {
// Regular case: pointer events are triggered
if (isDifferentTarget) {
await dispatchPointerEvent(target, "pointerover", enterEventInit, {
mouse: ["mouseover"],
});
const enterEvents = await Promise.all(
getDifferentParents(previous, current).map((element) =>
dispatch(element, "pointerenter", enterEventInit)
)
);
if (!hasTouch()) {
await dispatchRelatedEvents(enterEvents, "mouseenter", enterEventInit);
}
}
await dispatchPointerEvent(target, "pointermove", enterEventInit, {
mouse: ["mousemove"],
touch: ["touchmove"],
});
}
}
};
/**
* @param {EventTarget} target
* @param {PointerOptions} [options]
*/
const _implicitHover = async (target, options) => {
if (runTime.pointerTarget !== target || isDifferentPosition(target, options)) {
await _hover(target, options);
}
};
/**
* @param {EventTarget} target
* @param {KeyboardEventInit} eventInit
*/
const _keyDown = async (target, eventInit) => {
eventInit = { ...eventInit, ...currentEventInit.keydown };
registerSpecialKey(eventInit, true);
const repeat =
typeof eventInit.repeat === "boolean" ? eventInit.repeat : runTime.key === eventInit.key;
runTime.key = eventInit.key;
const keyDownEvent = await dispatch(target, "keydown", { ...eventInit, repeat });
if (isPrevented(keyDownEvent)) {
return;
}
/**
* @param {string} toInsert
* @param {string} type
*/
const insertValue = (toInsert, type) => {
const { selectionStart, selectionEnd, value } = target;
inputData = toInsert;
inputType = type;
if (isNil(selectionStart) && isNil(selectionEnd)) {
nextValue += toInsert;
} else {
nextValue = value.slice(0, selectionStart) + toInsert + value.slice(selectionEnd);
if (selectionStart === selectionEnd) {
nextSelectionStart = nextSelectionEnd = selectionStart + 1;
}
}
};
const { ctrlKey, key, shiftKey } = keyDownEvent;
let inputData = null;
let inputType = null;
let nextSelectionEnd = null;
let nextSelectionStart = null;
let nextValue = target.value;
let triggerSelect = false;
if (isEditable(target)) {
switch (key) {
case "ArrowDown":
case "ArrowLeft":
case "ArrowUp":
case "ArrowRight": {
const { selectionStart, selectionEnd, value } = target;
if (isNil(selectionStart) || isNil(selectionEnd)) {
break;
}
const start = key === "ArrowLeft" || key === "ArrowUp";
let selectionTarget;
if (ctrlKey) {
// Move to the start/end of the line
selectionTarget = start ? 0 : value.length;
} else {
// Move the cursor left or right
selectionTarget = start ? selectionStart - 1 : selectionEnd + 1;
}
nextSelectionStart = nextSelectionEnd = $max(
$min(selectionTarget, value.length),
0
);
triggerSelect = shiftKey;
break;
}
case "Backspace": {
const { selectionStart, selectionEnd, value } = target;
if (fullClear) {
// Remove all characters
nextValue = "";
} else if (isNil(selectionStart) || isNil(selectionEnd)) {
// Remove last character
nextValue = value.slice(0, -1);
} else if (selectionStart === selectionEnd) {
// Remove previous character from target value
nextValue = value.slice(0, selectionStart - 1) + value.slice(selectionEnd);
} else {
// Remove current selection from target value
nextValue = deleteSelection(target);
}
inputType = "deleteContentBackward";
break;
}
case "Delete": {
const { selectionStart, selectionEnd, value } = target;
if (fullClear) {
// Remove all characters
nextValue = "";
} else if (isNil(selectionStart) || isNil(selectionEnd)) {
// Remove first character
nextValue = value.slice(1);
} else if (selectionStart === selectionEnd) {
// Remove next character from target value
nextValue = value.slice(0, selectionStart) + value.slice(selectionEnd + 1);
} else {
// Remove current selection from target value
nextValue = deleteSelection(target);
}
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
// ! TODO: Doesn't work with non-roman locales
insertValue(
shiftKey ? key.toUpperCase() : key.toLowerCase(),
runTime.isComposing ? "insertCompositionText" : "insertText"
);
}
}
}
}
switch (key) {
case "a": {
if (ctrlKey) {
// Select all
if (isEditable(target)) {
nextSelectionStart = 0;
nextSelectionEnd = target.value.length;
triggerSelect = true;
} else {
const selection = globalThis.getSelection();
const range = $createRange();
range.selectNodeContents(target);
selection.removeAllRanges();
selection.addRange(range);
}
}
break;
}
/**
* Special action: copy
* On: unprevented 'Control + c' keydown
* Do: copy current selection to clipboard
*/
case "c": {
if (ctrlKey) {
// Get selection from window
const text = globalThis.getSelection().toString();
globalThis.navigator.clipboard.writeText(text).catch();
await dispatch(target, "copy", {
clipboardData: eventInit.dataTransfer || new DataTransfer(),
});
}
break;
}
case "Enter": {
const tag = getTag(target);
const parentForm = target.closest("form");
if (parentForm && target.type !== "button") {
/**
* Special action: <form> 'Enter'
* On: unprevented 'Enter' keydown on any element that
* is not a <button type="button"/> in a form element
* Do: triggers a 'submit' event on the form
*/
await dispatch(parentForm, "submit");
} else if (
!keyDownEvent.repeat &&
(tag === "a" || tag === "button" || (tag === "input" && target.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 });
}
break;
}
case "Escape": {
runTime.isDragging = false;
break;
}
/**
* Special action: shift focus
* On: unprevented 'Tab' keydown
* Do: focus next (or previous with 'Shift') focusable element
*/
case "Tab": {
const next = shiftKey
? getPreviousFocusableElement({ tabbable: true })
: getNextFocusableElement({ tabbable: true });
if (next) {
await triggerFocus(next);
}
break;
}
/**
* Special action: paste
* On: unprevented 'Control + v' keydown on editable element
* Do: paste current clipboard content to current element
*/
case "v": {
if (ctrlKey && isEditable(target)) {
// Set target value (if possible)
try {
nextValue = await globalThis.navigator.clipboard.readText();
} catch (err) {}
inputType = "insertFromPaste";
await dispatch(target, "paste", {
clipboardData: eventInit.dataTransfer || new DataTransfer(),
});
}
break;
}
/**
* Special action: cut
* On: unprevented 'Control + x' keydown on editable element
* Do: cut current selection to clipboard and remove selection
*/
case "x": {
if (ctrlKey && isEditable(target)) {
// Get selection from window
const text = globalThis.getSelection().toString();
globalThis.navigator.clipboard.writeText(text).catch();
nextValue = deleteSelection(target);
inputType = "deleteByCut";
await dispatch(target, "cut", {
clipboardData: eventInit.dataTransfer || new DataTransfer(),
});
}
break;
}
}
if (target.value !== nextValue) {
target.value = nextValue;
const inputEventInit = {
data: inputData,
inputType,
};
const beforeInputEvent = await dispatch(target, "beforeinput", inputEventInit);
if (!isPrevented(beforeInputEvent)) {
await dispatch(target, "input", inputEventInit);
}
}
changeSelection(target, nextSelectionStart, nextSelectionEnd);
if (triggerSelect) {
await dispatchAndIgnore({
target,
events: ["select"],
});
}
};
/**
* @param {EventTarget} target
* @param {KeyboardEventInit} eventInit
*/
const _keyUp = async (target, eventInit) => {
eventInit = { ...eventInit, ...currentEventInit.keyup };
await dispatch(target, "keyup", eventInit);
runTime.key = null;
registerSpecialKey(eventInit, false);
if (eventInit.key === " " && getTag(target) === "input" && target.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 });
}
};
/**
* @param {EventTarget} target
* @param {PointerOptions} [options]
*/
const _pointerDown = async (target, options) => {
setPointerDownTarget(target);
const pointerDownTarget = runTime.pointerDownTarget;
const eventInit = {
...runTime.position,
...currentEventInit.pointerdown,
button: options?.button || btn.LEFT,
};
registerButton(eventInit, true);
if (pointerDownTarget !== runTime.previousPointerDownTarget) {
runTime.clickCount = 0;
}
runTime.touchStartPosition = { ...runTime.position };
runTime.touchStartTimeOffset = globalThis.Date.now();
const prevented = await dispatchPointerEvent(pointerDownTarget, "pointerdown", eventInit, {
mouse: !pointerDownTarget.disabled && [
"mousedown",
{ ...eventInit, detail: runTime.clickCount + 1 },
],
touch: ["touchstart"],
});
if (prevented) {
return;
}
// Focus the element (if focusable)
await triggerFocus(target);
if (eventInit.button === btn.LEFT && !hasTouch() && pointerDownTarget.draggable) {
runTime.canStartDrag = true;
} else if (eventInit.button === btn.RIGHT) {
/**
* Special action: context menu
* On: unprevented 'pointerdown' with right click and its related
* event on an element
* Do: triggers a 'contextmenu' event
*/
await dispatch(target, "contextmenu", eventInit);
}
};
/**
* @param {EventTarget} target
* @param {PointerOptions} [options]
*/
const _pointerUp = async (target, options) => {
const isLongTap = globalThis.Date.now() - runTime.touchStartTimeOffset > LONG_TAP_DELAY;
const pointerDownTarget = runTime.pointerDownTarget;
const eventInit = {
...runTime.position,
...currentEventInit.pointerup,
button: options?.button || btn.LEFT,
};
registerButton(eventInit, false);
if (runTime.isDragging) {
// If dragging, only drag events are triggered
runTime.isDragging = false;
if (runTime.lastDragOverCancelled) {
/**
* Special action: drop
* - On: prevented 'dragover'
* - Do: triggers a 'drop' event on the target
*/
await dispatch(target, "drop", eventInit);
}
await dispatch(target, "dragend", eventInit);
return;
}
const mouseEventInit = {
...eventInit,
detail: runTime.clickCount + 1,
};
await dispatchPointerEvent(target, "pointerup", eventInit, {
mouse: !target.disabled && ["mouseup", mouseEventInit],
touch: ["touchend"],
});
const touchStartPosition = runTime.touchStartPosition;
runTime.touchStartPosition = {};
if (hasTouch() && (isDifferentPosition(touchStartPosition) || isLongTap)) {
// No further event is triggered:
// there was a swiping motion since the "touchstart" event
// or a long press was detected.
return;
}
let actualTarget;
if (hasTouch()) {
actualTarget = pointerDownTarget === target && target;
} else {
actualTarget = getFirstCommonParent(target, pointerDownTarget);
}
if (actualTarget) {
await triggerClick(actualTarget, mouseEventInit);
if (mouseEventInit.button === btn.LEFT) {
runTime.clickCount++;
if (!hasTouch() && runTime.clickCount % 2 === 0) {
await dispatch(actualTarget, "dblclick", mouseEventInit);
}
}
}
setPointerDownTarget(null);
if (runTime.pointerDownTimeout) {
globalThis.clearTimeout(runTime.pointerDownTimeout);
}
runTime.pointerDownTimeout = globalThis.setTimeout(() => {
// Use `globalThis.setTimeout` to potentially make use of the mock timeouts
// since the events run in the same temporal context as the tests
runTime.clickCount = 0;
runTime.pointerDownTimeout = 0;
}, DOUBLE_CLICK_DELAY);
};
/**
* @param {EventTarget} target
* @param {KeyboardEventInit} eventInit
*/
const _press = async (target, eventInit) => {
await _keyDown(target, eventInit);
await _keyUp(target, eventInit);
};
/**
* @param {EventTarget} target
* @param {string | number | (string | number)[]} value
*/
const _select = async (target, value) => {
const values = ensureArray(value).map(String);
let found = false;
for (const option of target.options) {
option.selected = values.includes(option.value);
found ||= option.selected;
}
if (!value) {
target.selectedIndex = -1;
} else if (!found) {
throw new HootDomError(
`error when calling \`select()\`: no option found with value "${values.join(", ")}"`
);
}
await dispatch(target, "change");
};
const btn = {
LEFT: 0,
MIDDLE: 1,
RIGHT: 2,
BACK: 3,
FORWARD: 4,
};
const CAPTURE = { capture: true };
const DEPRECATED_EVENT_PROPERTIES = {
keyCode: "key",
which: "key",
};
const DEPRECATED_EVENTS = {
keypress: "keydown",
mousewheel: "wheel",
};
const DOUBLE_CLICK_DELAY = 500;
/**
* Ignore certain trusted events (dispatched by `focus()`, `scroll()`, etc.)
* @type {[EventType, (event: Event) => any, AddEventListenerOptions][]}
*/
const GLOBAL_TRUSTED_EVENTS_CANCELERS = [
["blur", cancelTrustedEvent, CAPTURE],
["focus", cancelTrustedEvent, CAPTURE],
["focusin", cancelTrustedEvent, CAPTURE],
["focusout", cancelTrustedEvent, CAPTURE],
["scroll", cancelTrustedEvent, CAPTURE],
["scrollend", cancelTrustedEvent, CAPTURE],
["select", cancelTrustedEvent, CAPTURE],
];
/**
* Register file input on click & focus events
* @type {[EventType, (event: Event) => any, AddEventListenerOptions][]}
*/
const GLOBAL_FILE_INPUT_REGISTERERS = [
["click", registerFileInput, CAPTURE],
["focus", registerFileInput, CAPTURE],
];
/**
* Redirect events to other features
* @type {[EventType, (event: Event) => any, AddEventListenerOptions][]}
*/
const GLOBAL_SUBMIT_FORWARDERS = [["submit", redirectSubmit]];
const KEY_ALIASES = {
// case insensitive aliases
alt: "Alt",
arrowdown: "ArrowDown",
arrowleft: "ArrowLeft",
arrowright: "ArrowRight",
arrowup: "ArrowUp",
backspace: "Backspace",
control: "Control",
delete: "Delete",
enter: "Enter",
escape: "Escape",
meta: "Meta",
shift: "Shift",
tab: "Tab",
// Other aliases
caps: "Shift",
cmd: "Meta",
command: "Meta",
ctrl: "Control",
del: "Delete",
down: "ArrowDown",
esc: "Escape",
left: "ArrowLeft",
right: "ArrowRight",
space: " ",
up: "ArrowUp",
win: "Meta",
};
const LOG_COLORS = {
blue: "#5db0d7",
orange: "#f29364",
lightBlue: "#9bbbdc",
reset: "inherit",
};
const LONG_TAP_DELAY = 500;
/** @type {Record<string, Event[]>} */
const currentEvents = Object.create(null);
/** @type {Record<EventType, EventInit>} */
const currentEventInit = Object.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();
//-----------------------------------------------------------------------------
// Event init attributes mappers
//-----------------------------------------------------------------------------
const BUBBLES = 0b1;
const CANCELABLE = 0b10;
const VIEW = 0b100;
// Generic mappers
// ---------------
/**
* - does not bubble
* - cannot be canceled
* @param {FullEventInit} eventInit
*/
const mapEvent = (eventInit) => eventInit;
// Pointer, mouse & wheel event mappers
// ------------------------------------
/**
* @param {FullEventInit<MouseEventInit>} eventInit
*/
const mapMouseEvent = (eventInit) => ({
button: -1,
buttons: runTime.buttons,
clientX: eventInit.clientX ?? eventInit.pageX ?? eventInit.screenX ?? 0,
clientY: eventInit.clientY ?? eventInit.pageY ?? eventInit.screenY ?? 0,
...runTime.modifierKeys,
...eventInit,
});
/**
* @param {FullEventInit<PointerEventInit>} eventInit
*/
const mapPointerEvent = (eventInit) => ({
...mapMouseEvent(eventInit),
button: btn.LEFT,
pointerId: 1,
pointerType: hasTouch() ? "touch" : "mouse",
...eventInit,
});
/**
* @param {FullEventInit<WheelEventInit>} eventInit
*/
const mapWheelEvent = (eventInit) => ({
...mapMouseEvent(eventInit),
button: btn.LEFT,
...eventInit,
});
// Touch event mappers
// -------------------
/**
* @param {FullEventInit<TouchEventInit>} eventInit
*/
const mapTouchEvent = (eventInit) => {
const touches = eventInit.targetTouches ||
eventInit.touches || [new Touch({ identifier: 0, ...eventInit })];
return {
...eventInit,
changedTouches: eventInit.changedTouches || touches,
target: eventInit.target,
targetTouches: eventInit.targetTouches || touches,
touches: eventInit.touches || (eventInit.type === "touchend" ? [] : touches),
};
};
// Keyboard & input event mappers
// ------------------------------
/**
* @param {FullEventInit<InputEventInit>} eventInit
*/
const mapInputEvent = (eventInit) => ({
data: null,
isComposing: Boolean(runTime.isComposing),
...eventInit,
});
/**
* @param {FullEventInit<KeyboardEventInit>} eventInit
*/
const mapKeyboardEvent = (eventInit) => ({
isComposing: Boolean(runTime.isComposing),
...runTime.modifierKeys,
...eventInit,
});
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Ensures that the given {@link AsyncTarget} is checked.
*
* If it is not checked, a click is simulated on the input.
* If the input is still not checked after the click, an error is thrown.
*
* @see {@link click}
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* check("input[type=checkbox]"); // Checks the first <input> checkbox element
*/
export async function check(target, options) {
const finalizeEvents = setupEvents("check", options);
const element = queryFirst(await target, options);
if (!isCheckable(element)) {
throw new HootDomError(
`cannot call \`check()\`: target should be a checkbox or radio input`
);
}
const checkTarget = getTag(element) === "label" ? element.control : element;
if (!checkTarget.checked) {
await _implicitHover(element, options);
await _click(element, options);
if (!checkTarget.checked) {
throw new HootDomError(
`error when calling \`check()\`: target is not checked after interaction`
);
}
}
return finalizeEvents();
}
/**
* Clears the **value** of the current **active element**.
*
* This is done using the following sequence:
* - pressing "Control" + "A" to select the whole value;
* - pressing "Backspace" to delete the value;
* - (optional) triggering a "change" event by pressing "Enter".
*
* @param {FillOptions} [options]
* @returns {Promise<EventList>}
* @example
* clear(); // Clears the value of the current active element
*/
export async function clear(options) {
const finalizeEvents = setupEvents("clear", options);
const element = getActiveElement();
if (!hasTagName(element, "select") && !isEditable(element)) {
throw new HootDomError(
`cannot call \`clear()\`: target should be editable or a <select> element`
);
}
if (isEditable(element)) {
await _clear(element, options);
} else {
// Selects
await _select(element, "");
}
return finalizeEvents();
}
/**
* Performs a click sequence on the given {@link AsyncTarget}.
*
* The event sequence is as follows:
* - `pointerdown`
* - [desktop] `mousedown`
* - [touch] `touchstart`
* - [target is not active element] `blur`
* - [target is focusable] `focus`
* - `pointerup`
* - [desktop] `mouseup`
* - [touch] `touchend`
* - `click`
* - `dblclick` if click is not prevented & current click count is even
*
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* click("button"); // Clicks on the first <button> element
*/
export async function click(target, options) {
const finalizeEvents = setupEvents("click", options);
const element = queryFirst(await target, options);
await _implicitHover(element, options);
await _click(element, options);
return finalizeEvents();
}
/**
* Performs two click sequences on the given {@link AsyncTarget}.
*
* @see {@link click}
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* dblclick("button"); // Double-clicks on the first <button> element
*/
export async function dblclick(target, options) {
const finalizeEvents = setupEvents("dblclick", options);
const element = queryFirst(await target, options);
options = { ...options, button: btn.LEFT };
await _implicitHover(element, options);
await _click(element, options);
await _click(element, options);
return finalizeEvents();
}
/**
* Creates a new DOM {@link Event} of the given type and dispatches it on the given
* {@link Target}.
*
* Note that this function is free of side-effects and does not trigger any other
* event or special action. It also only supports standard DOM events, and will
* crash when trying to dispatch a non-standard or deprecated event.
*
* @template {EventType} T
* @template {HTMLBodyElementEventMap[T]} I
* @param {EventTarget} target
* @param {T} type
* @param {Partial<I> | { eventInit: Record<T, Partial<I>> }} [eventInit]
* @example
* await dispatch(document.querySelector("input"), "paste"); // Dispatches a "paste" event on the given <input>
* @returns {Promise<I>}
*/
export async function dispatch(target, type, eventInit) {
if (type in DEPRECATED_EVENTS) {
throw new HootDomError(
`cannot dispatch "${type}" event: this event type is deprecated, use "${DEPRECATED_EVENTS[type]}" instead`
);
}
if (type !== type.toLowerCase()) {
throw new HootDomError(
`cannot dispatch "${type}" event: this event type is either non-standard or deprecated`
);
}
eventInit = { ...eventInit, ...currentEventInit[type] };
for (const key in eventInit) {
if (key in DEPRECATED_EVENT_PROPERTIES) {
throw new HootDomError(
`cannot dispatch "${type}" event: property "${key}" is deprecated, use "${DEPRECATED_EVENT_PROPERTIES[key]}" instead`
);
}
}
const [Constructor, processParams, flags] = getEventConstructor(type);
const params = processParams({
composed: true,
...eventInit,
target,
type,
});
if (flags & BUBBLES) {
params.bubbles = true;
}
if (flags & CANCELABLE) {
params.cancelable = true;
}
if (flags & VIEW) {
params.view ||= getWindow(target);
}
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;
}
/**
* Starts a drag sequence on the given {@link AsyncTarget}.
*
* Returns a set of helper functions to direct the sequence:
* - `moveTo`: moves the pointer to the given target;
* - `drop`: drops the dragged element on the given target (if any);
* - `cancel`: cancels the drag sequence.
*
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<DragHelpers>}
* @example
* drag(".card:first").drop(".card:last"); // Drags the first card onto the last one
* @example
* drag(".card:first").moveTo(".card:last").drop(); // Same as above
* @example
* const { cancel, moveTo } = await drag(".card:first"); // Starts the drag sequence
* moveTo(".card:eq(3)"); // Moves the dragged card to the 4th card
* cancel(); // Cancels the drag sequence
*/
export async function drag(target, options) {
/**
* @template T
* @param {T} fn
* @param {boolean} endDrag
* @returns {T}
*/
const expectIsDragging = (fn, endDrag) => {
return {
async [fn.name](...args) {
if (dragEndReason) {
throw new HootDomError(
`cannot execute drag helper \`${fn.name}\`: drag sequence has been ended by \`${dragEndReason}\``
);
}
const result = await fn(...args);
if (endDrag) {
dragEndReason = fn.name;
}
return result;
},
}[fn.name];
};
const cancel = expectIsDragging(
/** @type {DragHelpers["cancel"]} */
async function cancel(options) {
const finalizeEvents = setupEvents("drag & drop: cancel", options);
const element = getDocument().body;
// Reset buttons
runTime.buttons = 0;
await _press(element, { key: "Escape" });
dragEvents.push(...(await finalizeEvents()));
return dragEvents;
},
true
);
const drop = expectIsDragging(
/** @type {DragHelpers["drop"]} */
async function drop(to, options) {
if (to) {
await moveTo(to, options);
}
const finalizeEvents = setupEvents("drag & drop: drop", options);
await _pointerUp(runTime.pointerTarget, options);
dragEvents.push(...(await finalizeEvents()));
return dragEvents;
},
true
);
const moveTo = expectIsDragging(
/** @type {DragHelpers["moveTo"]} */
async function moveTo(to, options) {
const finalizeEvents = setupEvents("drag & drop: move", options);
await _hover(queryFirst(await to), options);
dragEvents.push(...(await finalizeEvents()));
return dragHelpers;
},
false
);
const finalizeEvents = setupEvents("drag & drop: start", options);
const dragHelpers = { cancel, drop, moveTo };
const element = queryFirst(await target);
let dragEndReason = null;
// Pointer down on main target
await _implicitHover(element, options);
await _pointerDown(element, options);
const dragEvents = await finalizeEvents();
return dragHelpers;
}
/**
* Combination of {@link clear} and {@link fill}:
* - first, clears the input value (if any)
* - then fills the input with the given value
*
* @see {@link clear}
* @see {@link fill}
* @param {InputValue} value
* @param {FillOptions} options
* @returns {Promise<EventList>}
* @example
* fill("foo"); // Types "foo" in the active element
* edit("Hello World"); // Replaces "foo" by "Hello World"
*/
export async function edit(value, options) {
const finalizeEvents = setupEvents("edit", options);
const element = getActiveElement();
if (!isEditable(element)) {
throw new HootDomError(`cannot call \`edit()\`: target should be editable`);
}
if (getNodeValue(element)) {
await _clear(element);
}
await _fill(element, value, options);
return finalizeEvents();
}
/**
* @param {boolean} toggle
*/
export function enableEventLogs(toggle) {
allowLogs = toggle ?? true;
}
/**
* Fills the current **active element** with the given `value`. This helper is intended
* for `<input>` and `<textarea>` elements, with the exception of `"checkbox"` and
* `"radio"` types, which should be selected using the {@link check} helper.
*
* If the target is an editable input, its string `value` will be input one character
* at a time, each generating its corresponding keyboard event sequence. This behavior
* can be overriden by passing the `instantly` option, which will instead simulate
* a `control` + `v` keyboard sequence, resulting in the whole text being pasted.
*
* Note that the given value is **appended** to the current value of the element.
*
* If the active element is a `<input type="file"/>`, the `value` should be a
* `File`/list of `File` object(s).
*
* @param {InputValue} value
* @param {FillOptions} [options]
* @returns {Promise<EventList>}
* @example
* fill("Hello World"); // Types "Hello World" in the active element
* @example
* fill("Hello World", { instantly: true }); // Pastes "Hello World" in the active element
* @example
* fill(new File(["Hello World"], "hello.txt")); // Uploads a file named "hello.txt" with "Hello World" as content
*/
export async function fill(value, options) {
const finalizeEvents = setupEvents("fill", options);
const element = getActiveElement();
if (!isEditable(element)) {
throw new HootDomError(`cannot call \`fill()\`: target should be editable`);
}
await _fill(element, value, options);
return finalizeEvents();
}
/**
* Performs a hover sequence on the given {@link AsyncTarget}.
*
* The event sequence is as follows:
* - `pointerover`
* - [desktop] `mouseover`
* - `pointerenter`
* - [desktop] `mouseenter`
* - `pointermove`
* - [desktop] `mousemove`
* - [touch] `touchmove`
*
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* hover("button"); // Hovers the first <button> element
*/
export async function hover(target, options) {
const finalizeEvents = setupEvents("hover", options);
const element = queryFirst(await target, options);
await _hover(element, options);
return finalizeEvents();
}
/**
* Performs a key down sequence on the current **active element**.
*
* The event sequence is as follows:
* - `keydown`
*
* Additional actions will be performed depending on the key pressed:
* - `Tab`: focus next (or previous with `shift`) focusable element;
* - `c`: copy current selection to clipboard;
* - `v`: paste current clipboard content to current element;
* - `Enter`: submit the form if the target is a `<button type="button">` or
* a `<form>` element, or trigger a `change` event on the target if it is
* an `<input>` element;
* - `Space`: trigger a `click` event on the target if it is an `<input type="checkbox">`
* element.
*
* @param {KeyStrokes} keyStrokes
* @param {KeyboardOptions} [options]
* @returns {Promise<EventList>}
* @example
* keyDown(" "); // Space key
*/
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);
}
return finalizeEvents();
}
/**
* Performs a key up sequence on the current **active element**.
*
* The event sequence is as follows:
* - `keyup`
*
* @param {KeyStrokes} keyStrokes
* @param {KeyboardOptions} [options]
* @returns {Promise<EventList>}
* @example
* keyUp("Enter");
*/
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);
}
return finalizeEvents();
}
/**
* Performs a leave sequence on the current **window**.
*
* The event sequence is as follows:
* - `pointermove`
* - [desktop] `mousemove`
* - [touch] `touchmove`
* - `pointerout`
* - [desktop] `mouseout`
* - `pointerleave`
* - [desktop] `mouseleave`
*
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* leave("button"); // Moves out of <button>
*/
export async function leave(options) {
const finalizeEvents = setupEvents("leave", options);
await _hover(null, options);
return finalizeEvents();
}
/**
* Performs a middle-click sequence on the given {@link AsyncTarget}.
*
* @see {@link click}
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* middleClick("button"); // Middle-clicks on the first <button> element
*/
export async function middleClick(target, options) {
const finalizeEvents = setupEvents("middleClick", options);
const element = queryFirst(await target, options);
options = { ...options, button: btn.MIDDLE };
await _implicitHover(element, options);
await _click(element, options);
return finalizeEvents();
}
/**
* Shorthand helper to attach an event listener to the given {@link Target}, and
* returning a function to remove the listener.
*
* @template {EventType} T
* @param {Target<EventTarget>} target
* @param {T} type
* @param {(event: GlobalEventHandlersEventMap[T]) => any} listener
* @param {boolean | AddEventListenerOptions} [options]
* @returns {() => void}
* @example
* const off = on("button", "click", onClick);
* after(off);
*/
export function on(target, type, listener, options) {
const targets = isEventTarget(target) ? [target] : queryAll(target);
if (!targets.length) {
throw new HootDomError(`expected at least 1 event target, got none`);
}
for (const eventTarget of targets) {
eventTarget.addEventListener(type, listener, options);
}
return function off() {
for (const eventTarget of targets) {
eventTarget.removeEventListener(type, listener, options);
}
};
}
/**
* Performs a pointer down on the given {@link AsyncTarget}.
*
* The event sequence is as follows:
* - `pointerdown`
* - [desktop] `mousedown`
* - [touch] `touchstart`
* - [target is not active element] `blur`
* - [target is focusable] `focus`
*
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* pointerDown("button"); // Focuses to the first <button> element
*/
export async function pointerDown(target, options) {
const finalizeEvents = setupEvents("pointerDown", options);
const element = queryFirst(await target, options);
await _implicitHover(element, options);
await _pointerDown(element, options);
return finalizeEvents();
}
/**
* Performs a pointer up on the given {@link AsyncTarget}.
*
* The event sequence is as follows:
* - `pointerup`
* - [desktop] `mouseup`
* - [touch] `touchend`
*
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* pointerUp("body"); // Triggers a pointer up on the <body> element
*/
export async function pointerUp(target, options) {
const finalizeEvents = setupEvents("pointerUp", options);
const element = queryFirst(await target, options);
await _implicitHover(element, options);
await _pointerUp(element, options);
return finalizeEvents();
}
/**
* Performs a keyboard event sequence on the current **active element**.
*
* The event sequence is as follows:
* - `keydown`
* - `keyup`
*
* @param {KeyStrokes} keyStrokes
* @param {KeyboardOptions} [options]
* @returns {Promise<EventList>}
* @example
* pointerDown("button[type=submit]"); // Moves focus to <button>
* keyDown("Enter"); // Submits the form
* @example
* keyDown("Shift+Tab"); // Focuses previous focusable element
* @example
* keyDown(["ctrl", "v"]); // Pastes current clipboard content
*/
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);
}
for (const eventInit of eventInits.reverse()) {
await _keyUp(activeElement, eventInit);
}
return finalizeEvents();
}
/**
* Performs a resize event sequence on the current **window**.
*
* The event sequence is as follows:
* - `resize`
*
* The target will be resized to the given dimensions, enforced by `!important` style
* attributes.
*
* @param {Dimensions} dimensions
* @param {EventOptions} [options]
* @returns {Promise<EventList>}
* @example
* resize("body", { width: 1000, height: 500 }); // Resizes <body> to 1000x500
*/
export async function resize(dimensions, options) {
const finalizeEvents = setupEvents("resize", options);
const [width, height] = parseDimensions(dimensions);
setDimensions(width, height);
await dispatch(getWindow(), "resize");
return finalizeEvents();
}
/**
* Performs a right-click sequence on the given {@link AsyncTarget}.
*
* @see {@link click}
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* rightClick("button"); // Middle-clicks on the first <button> element
*/
export async function rightClick(target, options) {
const finalizeEvents = setupEvents("rightClick", options);
const element = queryFirst(await target, options);
options = { ...options, button: btn.RIGHT };
await _implicitHover(element, options);
await _click(element, options);
return finalizeEvents();
}
/**
* Performs a scroll event sequence on the given {@link AsyncTarget}.
*
* The event sequence is as follows:
* - [desktop] `wheel`
* - `scroll`
*
* @param {AsyncTarget} target
* @param {Position} position
* @param {ScrollOptions} [options]
* @returns {Promise<EventList>}
* @example
* scroll("body", { y: 0 }); // Scrolls to the top of <body>
*/
export async function scroll(target, position, options) {
const finalizeEvents = setupEvents("scroll", options);
const { force, initiator = "wheel", relative } = options || {};
/** @type {ScrollToOptions} */
const scrollTopOptions = {};
const element = queryFirst(await target, { scrollable: true, ...options });
let [x, y] = parsePosition(position);
if (relative) {
x += element.scrollLeft;
y += element.scrollTop;
}
if (!$isNaN(x)) {
const targetX = force ? x : constrainScrollX(element, x);
if (targetX !== element.scrollLeft) {
scrollTopOptions.left = targetX;
}
}
if (!$isNaN(y)) {
const targetY = force ? y : constrainScrollY(element, y);
if (targetY !== element.scrollTop) {
scrollTopOptions.top = targetY;
}
}
const keys = [];
if (initiator === "keyboard") {
if (x < element.scrollLeft) {
keys.push("ArrowRight");
} else if (x > element.scrollLeft) {
keys.push("ArrowLeft");
}
if (y < element.scrollTop) {
keys.push("ArrowDown");
} else if (y > element.scrollTop) {
keys.push("ArrowUp");
}
await Promise.all(keys.map((key) => _keyDown(key)));
} else if (!hasTouch() && initiator === "wheel") {
/** @type {WheelEventInit} */
const wheelEventInit = {};
if (!$isNaN(x)) {
wheelEventInit.deltaX = x - element.scrollLeft;
}
if (!$isNaN(y)) {
wheelEventInit.deltaY = y - element.scrollTop;
}
await dispatch(element, "wheel", wheelEventInit);
}
if (force || $values(scrollTopOptions).length) {
await dispatchAndIgnore({
target: element,
events: ["scroll", "scrollend"],
callback: (el) => el.scrollTo(scrollTopOptions),
});
}
if (initiator === "keyboard") {
await Promise.all(keys.map((key) => _keyUp(key)));
}
return finalizeEvents();
}
/**
* Performs a selection event sequence current **active element**. This helper is
* intended for `<select>` elements only.
*
* The event sequence is as follows:
* - `change`
*
* @param {string | number | (string | number)[]} value
* @param {SelectOptions} [options]
* @returns {Promise<EventList>}
* @example
* click("select[name=country]"); // Focuses <select> element
* select("belgium"); // Selects the <option value="belgium"> element
*/
export async function select(value, options) {
const finalizeEvents = setupEvents("select", options);
const element = options?.target ? queryFirst(await options.target) : getActiveElement();
if (!hasTagName(element, "select")) {
throw new HootDomError(`cannot call \`select()\`: target should be a <select> element`);
}
if (options?.target) {
await _implicitHover(element);
await _pointerDown(element);
}
await _select(element, value);
if (options?.target) {
await _pointerUp(element);
}
return finalizeEvents();
}
/**
* Gives the given {@link File} list to the current file input. This helper only
* works if a file input has been previously interacted with (by clicking on it).
*
* @param {MaybeIterable<File>} files
* @param {EventOptions} [options]
* @returns {Promise<EventList>}
*/
export async function setInputFiles(files, options) {
if (!runTime.fileInput) {
throw new HootDomError(
`cannot call \`setInputFiles()\`: no file input has been interacted with`
);
}
const finalizeEvents = setupEvents("setInputFiles", options);
await _fill(runTime.fileInput, files, options);
runTime.fileInput = null;
return finalizeEvents();
}
/**
* Sets the given value to the given "input[type=range]" {@link AsyncTarget}.
*
* The event sequence is as follows:
* - `pointerdown`
* - `input`
* - `change`
* - `pointerup`
*
* @param {AsyncTarget} target
* @param {number} value
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
*/
export async function setInputRange(target, value, options) {
const finalizeEvents = setupEvents("setInputRange", options);
const element = queryFirst(await target, options);
await _implicitHover(element, options);
await _pointerDown(element, options);
await _fill(element, value, options);
await _pointerUp(element, options);
return finalizeEvents();
}
/**
* @param {HTMLElement} target
* @param {{
* allowSubmit?: boolean;
* allowTrustedEvents?: boolean;
* noFileInputRegistration?: boolean;
* }} [options]
*/
export function setupEventActions(target, options) {
const eventHandlers = [];
if (!options?.allowTrustedEvents) {
eventHandlers.push(...GLOBAL_TRUSTED_EVENTS_CANCELERS);
}
if (!options?.noFileInputRegistration) {
eventHandlers.push(...GLOBAL_FILE_INPUT_REGISTERERS);
}
if (!options?.allowSubmit) {
eventHandlers.push(...GLOBAL_SUBMIT_FORWARDERS);
}
for (const [eventType, handler, options] of eventHandlers) {
window.addEventListener(eventType, handler, options);
}
const processedIframes = new WeakSet();
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (!mutation.addedNodes) {
continue;
}
for (const iframe of target.getElementsByTagName("iframe")) {
if (processedIframes.has(iframe)) {
continue;
}
processedIframes.add(iframe);
for (const [eventType, handler, options] of eventHandlers) {
iframe.contentWindow.addEventListener(eventType, handler, options);
}
}
}
});
observer.observe(target, { childList: true, subtree: true });
return function cleanupEventActions() {
observer.disconnect();
if (runTime.pointerDownTimeout) {
globalThis.clearTimeout(runTime.pointerDownTimeout);
}
removeChangeTargetListeners();
for (const [eventType, handler, options] of eventHandlers) {
window.removeEventListener(eventType, handler, options);
}
// Runtime global variables
$assign(runTime, getDefaultRunTimeValue());
};
}
/**
* Ensures that the given {@link AsyncTarget} is unchecked.
*
* If it is checked, a click is triggered on the input.
* If the input is still checked after the click, an error is thrown.
*
* @see {@link click}
* @param {AsyncTarget} target
* @param {PointerOptions} [options]
* @returns {Promise<EventList>}
* @example
* uncheck("input[type=checkbox]"); // Unchecks the first <input> checkbox element
*/
export async function uncheck(target, options) {
const finalizeEvents = setupEvents("uncheck", options);
const element = queryFirst(await target, options);
if (!isCheckable(element)) {
throw new HootDomError(
`cannot call \`uncheck()\`: target should be a checkbox or radio input`
);
}
const checkTarget = getTag(element) === "label" ? element.control : element;
if (checkTarget.checked) {
await _implicitHover(element, options);
await _click(element, options);
if (checkTarget.checked) {
throw new HootDomError(
`error when calling \`uncheck()\`: target is still checked after interaction`
);
}
}
return finalizeEvents();
}
/**
* Triggers a "beforeunload" event the current **window**.
*
* @param {EventOptions} [options]
* @returns {Promise<EventList>}
*/
export async function unload(options) {
const finalizeEvents = setupEvents("unload", options);
await dispatch(getWindow(), "beforeunload");
return finalizeEvents();
}
/** @extends {Array<Event>} */
export class EventList extends Array {
constructor(...args) {
super(...args.flat());
}
/**
* @param {EventListPredicate} predicate
*/
get(predicate) {
return this.getAll(predicate)[0] || null;
}
/**
* @param {EventListPredicate} predicate
*/
getAll(predicate) {
if (typeof predicate !== "function") {
const type = predicate;
predicate = (ev) => ev.type === type;
}
return this.filter(predicate);
}
}