/** * Event helpers for dispatching and interpreting browser events. * @module Event utilities */ import { contains, isElement, isNode } from "./dom.ts"; import { isApple } from "./platform.ts"; /** * Returns `true` if `event` has been fired within a React Portal element. */ export function isPortalEvent( event: Pick, ): boolean { const { currentTarget, target } = event; if (!currentTarget) return false; // A non-node target (such as `window` on a programmatically dispatched event) // can't be contained by `currentTarget`, so treat it the same as a target // rendered outside of it (e.g. through a portal) instead of letting // `contains` throw. `isNode` rather than `isElement` keeps the original // behavior for non-element nodes that `contains` handles fine. if (!isNode(target)) return true; return !contains(currentTarget as Node, target); } /** * Returns `true` if `event.target` and `event.currentTarget` are the same. */ export function isSelfTarget( event: Pick, ): boolean { return event.target === event.currentTarget; } function isActivatableNavigationTarget(element: EventTarget | null) { if (!isElement(element)) return false; const target = element as | HTMLAnchorElement | HTMLButtonElement | HTMLInputElement; const tagName = target.tagName.toLowerCase(); if (tagName === "a") return true; if (tagName === "button" && target.type === "submit") return true; if (tagName === "input" && target.type === "submit") return true; return false; } /** * Checks whether the user event is triggering a page navigation in a new tab. */ export function isOpeningInNewTab( event: Pick, ) { const isAppleDevice = isApple(); if (isAppleDevice && !event.metaKey) return false; if (!isAppleDevice && !event.ctrlKey) return false; return isActivatableNavigationTarget(event.currentTarget); } /** * Checks whether the user event is triggering a download. */ export function isDownloading( event: Pick, ) { if (!event.altKey) return false; return isActivatableNavigationTarget(event.currentTarget); } /** * Creates and dispatches an event. * @example * fireEvent(document.getElementById("id"), "blur", { * bubbles: true, * cancelable: true, * }); */ export function fireEvent( element: Element, type: string, eventInit?: EventInit, ) { const event = new Event(type, eventInit); return element.dispatchEvent(event); } /** * Creates and dispatches a blur event. * @example * fireBlurEvent(document.getElementById("id")); */ export function fireBlurEvent(element: Element, eventInit?: FocusEventInit) { const event = new FocusEvent("blur", eventInit); const defaultAllowed = element.dispatchEvent(event); const bubbleInit = { ...eventInit, bubbles: true }; element.dispatchEvent(new FocusEvent("focusout", bubbleInit)); return defaultAllowed; } /** * Creates and dispatches a focus event. * @example * fireFocusEvent(document.getElementById("id")); */ export function fireFocusEvent(element: Element, eventInit?: FocusEventInit) { const event = new FocusEvent("focus", eventInit); const defaultAllowed = element.dispatchEvent(event); const bubbleInit = { ...eventInit, bubbles: true }; element.dispatchEvent(new FocusEvent("focusin", bubbleInit)); return defaultAllowed; } /** * Creates and dispatches a keyboard event. * @example * fireKeyboardEvent(document.getElementById("id"), "keydown", { * key: "ArrowDown", * shiftKey: true, * }); */ export function fireKeyboardEvent( element: Element, type: string, eventInit?: KeyboardEventInit, ) { const event = new KeyboardEvent(type, eventInit); return element.dispatchEvent(event); } /** * Creates and dispatches a click event. * @example * fireClickEvent(document.getElementById("id")); */ export function fireClickEvent(element: Element, eventInit?: PointerEventInit) { const event = new MouseEvent("click", eventInit); return element.dispatchEvent(event); } /** * Checks whether the focus/blur event is happening from/to outside of the * container element. * @example * const element = document.getElementById("id"); * element.addEventListener("blur", (event) => { * if (isFocusEventOutside(event)) { * // ... * } * }); */ export function isFocusEventOutside( event: Pick, container?: Element | null, ) { const containerElement = container || (event.currentTarget as Element); const relatedTarget = event.relatedTarget; // A non-node relatedTarget (such as `window` on a programmatically dispatched // event) can't be inside the container, so it counts as outside instead of // letting `contains` throw. `isNode` rather than `isElement` keeps the // original behavior for non-element nodes that `contains` handles fine. return !isNode(relatedTarget) || !contains(containerElement, relatedTarget); } /** * Returns the `inputType` property of the event, if available. */ export function getInputType(event: Event | { nativeEvent: Event }) { const nativeEvent = "nativeEvent" in event ? event.nativeEvent : event; if (!nativeEvent) return; if (!("inputType" in nativeEvent)) return; if (typeof nativeEvent.inputType !== "string") return; return nativeEvent.inputType; } /** * Checks whether the event is an input event. */ export function isInputEvent(event: Event): event is InputEvent { return event.type === "input"; } /** * Runs a callback on the next animation frame, but before a certain event. */ export function queueBeforeEvent( element: Element, type: string, callback: () => void, timeout?: number, ) { const createTimer = (callback: () => void) => { if (timeout) { const timerId = setTimeout(callback, timeout); return () => clearTimeout(timerId); } const timerId = requestAnimationFrame(callback); return () => cancelAnimationFrame(timerId); }; const cancelTimer = createTimer(() => { element.removeEventListener(type, callSync, true); callback(); }); const callSync = () => { cancelTimer(); callback(); }; // By listening to the event in the capture phase, we make sure the callback // is fired before the respective React events. element.addEventListener(type, callSync, { once: true, capture: true }); return () => { cancelTimer(); element.removeEventListener(type, callSync, true); }; } export function addGlobalEventListener( type: K, listener: (event: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions, scope?: Window, ): () => void; export function addGlobalEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, scope?: Window, ): () => void; /** * Adds a global event listener, including on child frames. */ export function addGlobalEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, scope: Window = window, ) { const children: Array<() => void> = []; // Prevent errors from "sandbox" frames. try { scope.document.addEventListener(type, listener, options); for (const frame of Array.from(scope.frames)) { children.push(addGlobalEventListener(type, listener, options, frame)); } } catch {} const removeEventListener = () => { try { scope.document.removeEventListener(type, listener, options); } catch {} for (const remove of children) { remove(); } }; return removeEventListener; }