import type {ParseSelector} from 'typed-query-selector/parser.d.js'; export type DelegateOptions = AddEventListenerOptions & {base?: EventTarget}; export type EventType = keyof GlobalEventHandlersEventMap; export type DelegateEventHandler< TEvent extends Event = Event, TElement extends Element = Element, > = (event: DelegateEvent) => void; export type DelegateEvent< TEvent extends Event = Event, TElement extends Element = Element, > = TEvent & { delegateTarget: TElement; }; /** Keeps track of raw listeners added to the base elements to avoid duplication */ const ledger = new WeakMap< EventTarget, WeakMap> >(); function editLedger( wanted: boolean, baseElement: EventTarget, callback: DelegateEventHandler, setup: string, ): boolean { if (!wanted && !ledger.has(baseElement)) { return false; } const elementMap = ledger.get(baseElement) ?? new WeakMap>(); ledger.set(baseElement, elementMap); const setups = elementMap.get(callback) ?? new Set(); elementMap.set(callback, setups); const existed = setups.has(setup); if (wanted) { setups.add(setup); } else { setups.delete(setup); } return existed && wanted; } function safeClosest(event: Event, selector: string): Element | void { let target = event.target; if (target instanceof Text) { target = target.parentElement; } // The currentTarget could be an Element or e.g. a ShadowRoot if (target instanceof Element && event.currentTarget instanceof Node) { // `.closest()` may match ancestors of `currentTarget` but we only need its children const closest = target.closest(selector); if (closest && event.currentTarget.contains(closest)) { return closest; } } } /** * Delegates event to a selector. * @param options A boolean value setting options.capture or an options object of type AddEventListenerOptions */ function delegate< Selector extends string, TElement extends Element = ParseSelector, TEventType extends EventType = EventType, >( selector: Selector | readonly Selector[], type: TEventType, callback: DelegateEventHandler, options?: DelegateOptions ): void; function delegate< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( selector: string | readonly string[], type: TEventType, callback: DelegateEventHandler, options?: DelegateOptions ): void; // This type isn't exported as a declaration, so it needs to be duplicated above function delegate< TElement extends Element, TEventType extends EventType = EventType, >( selector: string | readonly string[], type: TEventType, callback: DelegateEventHandler, options: DelegateOptions = {}, ): void { const {signal, base = document} = options; if (signal?.aborted) { return; } // Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it` const {once, ...nativeListenerOptions} = options; // `document` should never be the base, it's just an easy way to define "global event listeners" const baseElement = base instanceof Document ? base.documentElement : base; // Handle the regular Element usage const capture = Boolean(typeof options === 'object' ? options.capture : options); const listenerFunction = (event: Event): void => { const delegateTarget = safeClosest(event, String(selector)); if (delegateTarget) { const delegateEvent = Object.assign(event, {delegateTarget}); callback.call(baseElement, delegateEvent as DelegateEvent); if (once) { baseElement.removeEventListener(type, listenerFunction, nativeListenerOptions); editLedger(false, baseElement, callback, setup); } } }; const setup = JSON.stringify({selector, type, capture}); const isAlreadyListening = editLedger(true, baseElement, callback, setup); if (!isAlreadyListening) { baseElement.addEventListener(type, listenerFunction, nativeListenerOptions); } signal?.addEventListener('abort', () => { editLedger(false, baseElement, callback, setup); }); } export default delegate;