| /** |
| * -------------------------------------------------------------------------- |
| * Bootstrap dom/event-handler.ts |
| * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) |
| * -------------------------------------------------------------------------- |
| */ |
| |
| /** |
| * Types |
| */ |
| |
| type BootstrapEvent = Event & Record<string, any> |
| |
| type EventCallable = (this: any, event: BootstrapEvent) => any |
| |
| interface WrappedHandler { |
| (this: Element, event: Event): any |
| oneOff?: boolean |
| uidEvent?: string | number |
| callable?: EventCallable |
| delegationSelector?: string | null |
| handlerTypeEvent?: string |
| } |
| |
| type ElementEvents = Record<string, Record<string, WrappedHandler>> |
| |
| interface NormalizedParameters { |
| isDelegated: boolean |
| callable: EventCallable |
| typeEvent: string |
| handlerTypeEvent: string |
| } |
| |
| /** |
| * Constants |
| */ |
| |
| const namespaceRegex = /[^.]*(?=\..*)\.|.*/ |
| const stripNameRegex = /\..*/ |
| const stripUidRegex = /::\d+$/ |
| const eventRegistry: Record<string | number, ElementEvents> = {} // Events storage |
| let uidEvent = 1 |
| const customEvents: Record<string, string> = { |
| mouseenter: 'mouseover', |
| mouseleave: 'mouseout' |
| } |
| |
| const nativeEvents = new Set([ |
| 'click', |
| 'dblclick', |
| 'mouseup', |
| 'mousedown', |
| 'contextmenu', |
| 'mousewheel', |
| 'DOMMouseScroll', |
| 'mouseover', |
| 'mouseout', |
| 'mousemove', |
| 'selectstart', |
| 'selectend', |
| 'keydown', |
| 'keypress', |
| 'keyup', |
| 'orientationchange', |
| 'touchstart', |
| 'touchmove', |
| 'touchend', |
| 'touchcancel', |
| 'pointerdown', |
| 'pointermove', |
| 'pointerup', |
| 'pointerleave', |
| 'pointercancel', |
| 'gesturestart', |
| 'gesturechange', |
| 'gestureend', |
| 'focus', |
| 'blur', |
| 'change', |
| 'reset', |
| 'select', |
| 'submit', |
| 'focusin', |
| 'focusout', |
| 'load', |
| 'unload', |
| 'beforeunload', |
| 'resize', |
| 'move', |
| 'DOMContentLoaded', |
| 'readystatechange', |
| 'error', |
| 'abort', |
| 'scroll', |
| 'scrollend' |
| ]) |
| |
| /** |
| * Private methods |
| */ |
| |
| function makeEventUid(element: any, uid?: string): string | number { |
| return (uid && `${uid}::${uidEvent++}`) || element.uidEvent || uidEvent++ |
| } |
| |
| function getElementEvents(element: Element & { uidEvent?: string | number }): ElementEvents { |
| const uid = makeEventUid(element) |
| |
| element.uidEvent = uid |
| eventRegistry[uid] = eventRegistry[uid] || {} |
| |
| return eventRegistry[uid] |
| } |
| |
| // `mouseenter` and `mouseleave` ride on `mouseover` and `mouseout`, which also fire when |
| // the pointer moves between descendants of the listening element. Drop those events, so the |
| // handler only sees the pointer entering or leaving `delegateTarget` itself. |
| // `Node.contains()` is inclusive, so this also covers `relatedTarget === delegateTarget`. |
| function isMouseEventWithinTarget(event: BootstrapEvent): boolean { |
| const { delegateTarget, relatedTarget } = event |
| |
| return Boolean(relatedTarget && delegateTarget.contains(relatedTarget)) |
| } |
| |
| function bootstrapHandler(element: Element, fn: EventCallable, handlerTypeEvent: string): WrappedHandler { |
| const isCustomMouseEvent = handlerTypeEvent in customEvents |
| |
| return function handler(event: Event) { |
| const bootstrapEvent = hydrateObj(event, { delegateTarget: element }) |
| |
| if (isCustomMouseEvent && isMouseEventWithinTarget(bootstrapEvent)) { |
| return |
| } |
| |
| if ((handler as WrappedHandler).oneOff) { |
| EventHandler.off(element, handlerTypeEvent, fn) |
| } |
| |
| return fn.apply(element, [bootstrapEvent]) |
| } as WrappedHandler |
| } |
| |
| function bootstrapDelegationHandler(element: Element, selector: string, fn: EventCallable, handlerTypeEvent: string): WrappedHandler { |
| const isCustomMouseEvent = handlerTypeEvent in customEvents |
| |
| return function handler(this: Element, event: Event) { |
| const domElements = element.querySelectorAll(selector) |
| |
| for (let { target } = event; target && target !== this; target = (target as Node).parentNode) { |
| for (const domElement of domElements) { |
| if (domElement !== target) { |
| continue |
| } |
| |
| const bootstrapEvent = hydrateObj(event, { delegateTarget: target }) |
| |
| if (isCustomMouseEvent && isMouseEventWithinTarget(bootstrapEvent)) { |
| return |
| } |
| |
| if ((handler as WrappedHandler).oneOff) { |
| EventHandler.off(element, handlerTypeEvent, selector, fn) |
| } |
| |
| return fn.apply(target, [bootstrapEvent]) |
| } |
| } |
| } as WrappedHandler |
| } |
| |
| function findHandler(events: Record<string, WrappedHandler>, callable: EventCallable, handlerTypeEvent: string, delegationSelector: string | null = null): WrappedHandler | undefined { |
| return Object.values(events) |
| .find(event => event.callable === callable && event.handlerTypeEvent === handlerTypeEvent && event.delegationSelector === delegationSelector) |
| } |
| |
| // `typeEvent` is the DOM event type the listener is registered under. `handlerTypeEvent` is |
| // the type the caller asked for. The two differ only for `mouseenter` and `mouseleave`, which |
| // the registry must keep apart from the `mouseover` and `mouseout` listeners they share. |
| function normalizeParameters(originalTypeEvent: string, handler?: string | EventCallable, delegationFunction?: EventCallable): NormalizedParameters { |
| const isDelegated = typeof handler === 'string' |
| const callable = (isDelegated ? delegationFunction : (handler || delegationFunction)) as EventCallable |
| // Strip the namespace to get the plain event ('click.bs.button' --> 'click') |
| const baseTypeEvent = originalTypeEvent.replace(stripNameRegex, '') |
| let typeEvent = customEvents[baseTypeEvent] || baseTypeEvent |
| |
| if (!nativeEvents.has(typeEvent)) { |
| typeEvent = originalTypeEvent |
| } |
| |
| const handlerTypeEvent = baseTypeEvent in customEvents ? baseTypeEvent : typeEvent |
| |
| return { |
| isDelegated, callable, typeEvent, handlerTypeEvent |
| } |
| } |
| |
| function addHandler(element: Element, originalTypeEvent: string, handler?: string | EventCallable, delegationFunction?: EventCallable, oneOff?: boolean): void { |
| if (typeof originalTypeEvent !== 'string' || !element) { |
| return |
| } |
| |
| const { isDelegated, callable, typeEvent, handlerTypeEvent } = normalizeParameters(originalTypeEvent, handler, delegationFunction) |
| const events = getElementEvents(element) |
| const handlers = events[typeEvent] || (events[typeEvent] = {}) |
| const previousFunction = findHandler(handlers, callable, handlerTypeEvent, isDelegated ? (handler as string) : null) |
| |
| if (previousFunction) { |
| previousFunction.oneOff = previousFunction.oneOff && oneOff |
| |
| return |
| } |
| |
| const uid = makeEventUid(callable, originalTypeEvent.replace(namespaceRegex, '')) |
| const fn = isDelegated ? |
| bootstrapDelegationHandler(element, handler as string, callable, handlerTypeEvent) : |
| bootstrapHandler(element, callable, handlerTypeEvent) |
| |
| fn.delegationSelector = isDelegated ? (handler as string) : null |
| fn.callable = callable |
| fn.handlerTypeEvent = handlerTypeEvent |
| fn.oneOff = oneOff |
| fn.uidEvent = uid |
| handlers[uid] = fn |
| |
| element.addEventListener(typeEvent, fn, isDelegated) |
| } |
| |
| function removeHandler(element: Element, events: ElementEvents, typeEvent: string, handler: WrappedHandler): void { |
| element.removeEventListener(typeEvent, handler, Boolean(handler.delegationSelector)) |
| delete events[typeEvent][handler.uidEvent!] |
| } |
| |
| function removeNamespacedHandlers(element: Element, events: ElementEvents, typeEvent: string, namespace: string): void { |
| const storeElementEvent = events[typeEvent] || {} |
| |
| for (const [handlerKey, event] of Object.entries(storeElementEvent)) { |
| if (handlerKey.includes(namespace)) { |
| removeHandler(element, events, typeEvent, event) |
| } |
| } |
| } |
| |
| function trigger(element: EventTarget, event: string, args?: Record<string, unknown>): BootstrapEvent |
| function trigger(element: EventTarget | null, event: string, args?: Record<string, unknown>): BootstrapEvent | null |
| function trigger(element: EventTarget | null, event: string, args?: Record<string, unknown>): BootstrapEvent | null { |
| if (typeof event !== 'string' || !element) { |
| return null |
| } |
| |
| const evt = hydrateObj(new Event(event, { bubbles: true, cancelable: true }), args) |
| element.dispatchEvent(evt) |
| return evt |
| } |
| |
| const EventHandler = { |
| on(element: EventTarget | null, event: string, handler?: string | EventCallable, delegationFunction?: EventCallable): void { |
| addHandler(element as Element, event, handler, delegationFunction, false) |
| }, |
| |
| one(element: EventTarget | null, event: string, handler?: string | EventCallable, delegationFunction?: EventCallable): void { |
| addHandler(element as Element, event, handler, delegationFunction, true) |
| }, |
| |
| off(element: EventTarget | null, originalTypeEvent: string, handler?: string | EventCallable, delegationFunction?: EventCallable): void { |
| if (typeof originalTypeEvent !== 'string' || !element) { |
| return |
| } |
| |
| const { isDelegated, callable, typeEvent, handlerTypeEvent } = normalizeParameters(originalTypeEvent, handler, delegationFunction) |
| // The caller gave a namespace when neither event type matches what they passed in. |
| // `handlerTypeEvent` must take part, or plain `mouseenter` looks namespaced next to `mouseover`. |
| const inNamespace = typeEvent !== originalTypeEvent && handlerTypeEvent !== originalTypeEvent |
| const events = getElementEvents(element as Element) |
| const storeElementEvent = events[typeEvent] || {} |
| const isNamespace = originalTypeEvent.startsWith('.') |
| |
| if (typeof callable !== 'undefined') { |
| // Simplest case: handler is passed, remove that listener ONLY. |
| if (!Object.keys(storeElementEvent).length) { |
| return |
| } |
| |
| const fn = findHandler(storeElementEvent, callable, handlerTypeEvent, isDelegated ? (handler as string) : null) |
| |
| if (fn) { |
| removeHandler(element as Element, events, typeEvent, fn) |
| } |
| |
| return |
| } |
| |
| if (isNamespace) { |
| for (const elementEvent of Object.keys(events)) { |
| removeNamespacedHandlers(element as Element, events, elementEvent, originalTypeEvent.slice(1)) |
| } |
| } |
| |
| for (const [keyHandlers, event] of Object.entries(storeElementEvent)) { |
| const handlerKey = keyHandlers.replace(stripUidRegex, '') |
| |
| if (event.handlerTypeEvent === handlerTypeEvent && (!inNamespace || originalTypeEvent.includes(handlerKey))) { |
| removeHandler(element as Element, events, typeEvent, event) |
| } |
| } |
| }, |
| |
| trigger |
| } |
| |
| function hydrateObj<T extends object>(obj: T, meta: Record<string, unknown> = {}): T & Record<string, any> { |
| for (const [key, value] of Object.entries(meta)) { |
| try { |
| (obj as Record<string, unknown>)[key] = value |
| } catch { |
| Object.defineProperty(obj, key, { |
| configurable: true, |
| get() { |
| return value |
| } |
| }) |
| } |
| } |
| |
| return obj |
| } |
| |
| export default EventHandler |
| export type { BootstrapEvent, EventCallable } |