/** @odoo-module */ import { HootDomError, getTag, isFirefox, isIterable } from "../hoot_dom_utils"; import { getActiveElement, getDocument, getNextFocusableElement, getNodeRect, getNodeValue, getPreviousFocusableElement, getWindow, isCheckable, isEditable, isEventTarget, isNode, isNodeFocusable, isNodeVisible, parseDimensions, parsePosition, queryAll, queryFirst, setDimensions, toSelector, } from "./dom"; /** * @typedef {Target | Promise} AsyncTarget * * @typedef {"auto" | "blur" | "enter" | "tab" | false} ConfirmAction * * @typedef {{ * cancel: (options?: EventOptions) => Promise; * drop: (to?: AsyncTarget, options?: PointerOptions) => Promise; * moveTo: (to?: AsyncTarget, options?: PointerOptions) => Promise; * }} DragHelpers * * @typedef {import("./dom").Position} Position * * @typedef {import("./dom").Dimensions} Dimensions * * @typedef {((ev: Event) => boolean) | EventType} EventListPredicate * * @typedef {{}} 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} 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 & { * target: AsyncTarget; * }} SelectOptions * * @typedef {"bottom" | "left" | "right" | "top"} Side */ /** * @template [T=EventInit] * @typedef {T & { * target: EventTarget; * type: EventType; * }} FullEventInit */ /** * @template T * @typedef {T | Iterable} MaybeIterable */ /** * @template [T=Node] * @typedef {import("./dom").Target} 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); /** @type {Document["hasFocus"]} */ const $hasFocus = document.hasFocus.bind(document); //----------------------------------------------------------------------------- // Internal //----------------------------------------------------------------------------- /** * @param {EventTarget} target * @param {EventType} type */ const catchNextEvent = (target, type) => new Promise((resolve) => { target.addEventListener( type, (event) => { getCurrentEvents().push(event); resolve(event); }, { once: true } ); }); /** * @param {HTMLInputElement | HTMLTextAreaElement} target */ const deleteSelection = (target) => { const { selectionStart, selectionEnd, value } = target; return value.slice(0, selectionStart) + value.slice(selectionEnd); }; /** * * @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} 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} 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: { altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, }, }); /** * 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]; case "mouseenter": case "mouseleave": return [MouseEvent, mapMouseEvent]; // Pointer events case "auxclick": case "click": case "contextmenu": case "pointerdown": case "pointerup": case "pointermove": case "pointerover": case "pointerout": return [PointerEvent, mapPointerEvent, BUBBLES | CANCELABLE]; case "pointerenter": case "pointerleave": case "pointercancel": return [PointerEvent, mapPointerEvent]; // 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]; // 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]; case "input": return [InputEvent, mapInputEvent, BUBBLES]; // 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]; case "touchcancel": return [TouchEvent, mapTouchEvent, BUBBLES]; // Resize events case "resize": return [Event, mapEvent]; // Submit events case "submit": return [SubmitEvent, mapEvent, BUBBLES | CANCELABLE]; // Wheel events case "wheel": return [WheelEvent, mapWheelEvent, BUBBLES]; // 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 triggerChange = () => { removeChangeTargetListeners(); if (target.value !== initialValue) { afterNextDispatch = () => dispatch(target, "change"); } }; confirmAction &&= confirmAction.toLowerCase(); if (confirmAction === "auto") { confirmAction = getTag(target) === "input" ? "enter" : "blur"; } if (confirmAction === "enter") { if (getTag(target) === "input") { changeTargetListeners.push( on( target, "keydown", (ev) => !isPrevented(ev) && ev.key === "Enter" && triggerChange() ) ); } else { throw new HootDomError(`"enter" confirm action is only supported on elements`); } } changeTargetListeners.push( on(target, "blur", triggerChange), 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 */ const setupEvents = (type) => { currentEventTypes.push(type); return async () => { 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} [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