diff --git a/index.ts b/index.ts index 2c4a553..ea2f8e3 100644 --- a/index.ts +++ b/index.ts @@ -2,36 +2,29 @@ import type {ParseSelector} from 'typed-query-selector/parser'; export type DelegateOptions = boolean | Omit; export type EventType = keyof GlobalEventHandlersEventMap; -type GlobalEvent = Event; -namespace delegate { - export type Subscription = { - destroy: VoidFunction; - }; +export type DelegateEventHandler< + TEvent extends Event = Event, + TElement extends Element = Element, +> = (event: DelegateEvent) => void; - export type EventHandler< - TEvent extends GlobalEvent = GlobalEvent, - TElement extends Element = Element, - > = (event: Event) => void; - - export type Event< - TEvent extends GlobalEvent = GlobalEvent, - TElement extends Element = Element, - > = TEvent & { - delegateTarget: TElement; - }; -} +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> +WeakMap> >(); function editLedger( wanted: boolean, baseElement: EventTarget | Document, - callback: delegate.EventHandler, + callback: DelegateEventHandler, setup: string, ): boolean { if (!wanted && !ledger.has(baseElement)) { @@ -40,7 +33,7 @@ function editLedger( const elementMap = ledger.get(baseElement) - ?? new WeakMap>(); + ?? new WeakMap>(); ledger.set(baseElement, elementMap); if (!wanted && !ledger.has(baseElement)) { @@ -61,7 +54,7 @@ function editLedger( } function isEventTarget( - elements: EventTarget | Document | ArrayLike | string, + elements: EventTarget | Document | Iterable | string, ): elements is EventTarget { return typeof (elements as EventTarget).addEventListener === 'function'; } @@ -90,35 +83,53 @@ function delegate< TElement extends Element = ParseSelector, TEventType extends EventType = EventType, >( - base: EventTarget | Document | ArrayLike | string, + base: EventTarget | Document | Iterable | string, selector: Selector, type: TEventType, - callback: delegate.EventHandler, + callback: DelegateEventHandler, options?: DelegateOptions -): delegate.Subscription; +): AbortController; function delegate< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( - base: EventTarget | Document | ArrayLike | string, + base: EventTarget | Document | Iterable | string, selector: string, type: TEventType, - callback: delegate.EventHandler, + callback: DelegateEventHandler, options?: DelegateOptions -): delegate.Subscription; +): AbortController; // This type isn't exported as a declaration, so it needs to be duplicated above function delegate< TElement extends Element, TEventType extends EventType = EventType, >( - base: EventTarget | Document | ArrayLike | string, + base: EventTarget | Document | Iterable | string, selector: string, type: TEventType, - callback: delegate.EventHandler, + callback: DelegateEventHandler, options?: DelegateOptions, -): delegate.Subscription { +): AbortController { + const internalController = new AbortController(); + const listenerOptions: AddEventListenerOptions = typeof options === 'object' ? options : {capture: options}; + // Drop unsupported `once` option https://github.com/fregante/delegate-it/pull/28#discussion_r863467939 + delete listenerOptions.once; + + if (listenerOptions.signal) { + if (listenerOptions.signal.aborted) { + internalController.abort(); + return internalController; + } + + listenerOptions.signal.addEventListener('abort', () => { + internalController.abort(); + }); + } else { + listenerOptions.signal = internalController.signal; + } + // Handle Selector-based usage if (typeof base === 'string') { base = document.querySelectorAll(base); @@ -126,24 +137,11 @@ function delegate< // Handle Array-like based usage if (!isEventTarget(base)) { - const subscriptions = Array.prototype.map.call( - base, - (element: EventTarget) => delegate( - element, - selector, - type, - callback, - options, - ), - ) as delegate.Subscription[]; - - return { - destroy(): void { - for (const subscription of subscriptions) { - subscription.destroy(); - } - }, - }; + for (const element of base) { + delegate(element, selector, type, callback, listenerOptions); + } + + return internalController; } // `document` should never be the base, it's just an easy way to define "global event listeners" @@ -151,33 +149,25 @@ function delegate< // Handle the regular Element usage const capture = Boolean(typeof options === 'object' ? options.capture : options); - const listenerFn: EventListener = (event: Event): void => { + const listenerFn = (event: Event): void => { const delegateTarget = safeClosest(event, selector); if (delegateTarget) { - (event as any).delegateTarget = delegateTarget; - callback.call(baseElement, event as delegate.Event); + const delegateEvent = Object.assign(event, {delegateTarget}); + callback.call(baseElement, delegateEvent as DelegateEvent); } }; - // Drop unsupported `once` option https://github.com/fregante/delegate-it/pull/28#discussion_r863467939 - if (typeof options === 'object') { - delete (options as AddEventListenerOptions).once; - } - const setup = JSON.stringify({selector, type, capture}); const isAlreadyListening = editLedger(true, baseElement, callback, setup); - const delegateSubscription = { - destroy() { - baseElement.removeEventListener(type, listenerFn, options); - editLedger(false, baseElement, callback, setup); - }, - }; - if (!isAlreadyListening) { - baseElement.addEventListener(type, listenerFn, options); + baseElement.addEventListener(type, listenerFn, listenerOptions); } - return delegateSubscription; + internalController.signal.addEventListener('abort', () => { + editLedger(false, baseElement, callback, setup); + }); + + return internalController; } export default delegate; diff --git a/package.json b/package.json index 714e5cd..73b9ca7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "delegate-it", - "version": "3.0.1", + "version": "4.0.0", "description": "Lightweight and modern event delegation in the browser", "keywords": [ "delegate", @@ -39,7 +39,6 @@ ], "rules": { "max-params": "off", - "@typescript-eslint/no-namespace": "off", "@typescript-eslint/naming-convention": "off" } }, @@ -48,11 +47,11 @@ }, "devDependencies": { "@sindresorhus/tsconfig": "^2.0.0", - "ava": "^4.2.0", - "jsdom": "^19.0.0", + "ava": "^4.3.0", + "jsdom": "^20.0.0", "npm-run-all": "^4.1.5", "sinon": "^14.0.0", - "typescript": "^4.6.4", - "xo": "^0.48.0" + "typescript": "^4.7.4", + "xo": "^0.50.0" } } diff --git a/readme.md b/readme.md index 4b292ea..3ba3d0d 100644 --- a/readme.md +++ b/readme.md @@ -7,9 +7,10 @@ This is a fork of the popular [`delegate`](https://github.com/zenorocha/delegate) with some improvements: -- modern: ES6, TypeScript, Edge 15+ (it uses `WeakMap` and `Element.closest()`) +- modern: ES2021, TypeScript, Edge 16+ (it uses `WeakMap` and `Element.closest()`) - idempotent: identical listeners aren't added multiple times, just like the native `addEventListener` - debugged ([2d54c11](https://github.com/fregante/delegate-it/commit/2d54c1182aefd3ec9d8250fda76290971f5d7166), [c6bb88c](https://github.com/fregante/delegate-it/commit/c6bb88c2aa8097b25f22993a237cf09c96bcbfb8)) +- supports [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) If you need IE support, you can keep using [`delegate`](https://github.com/zenorocha/delegate) @@ -72,11 +73,22 @@ delegate(document.body, '.btn', 'click', event => { ### Remove event delegation ```js -const delegation = delegate(document.body, '.btn', 'click', event => { +const controller = delegate(document.body, '.btn', 'click', event => { console.log(event.delegateTarget); }); -delegation.destroy(); +controller.abort(); +``` + +```js +const controller = new AbortController(); +delegate(document.body, '.btn', 'click', event => { + console.log(event.delegateTarget); +}, { + signal: controller.signal, +}); + +controller.abort(); ``` ### Custom event types in Typescript diff --git a/test.js b/test.js index 6431658..c64e0f4 100644 --- a/test.js +++ b/test.js @@ -4,13 +4,13 @@ import {JSDOM} from 'jsdom'; import delegate from './index.js'; const {window} = new JSDOM(` - + `); global.Text = window.Text; @@ -18,6 +18,7 @@ global.Event = window.Event; global.Element = window.Element; global.Document = window.Document; global.MouseEvent = window.MouseEvent; +global.AbortController = window.AbortController; global.document = window.document; const container = window.document.querySelector('ul'); const anchor = window.document.querySelector('a'); @@ -51,13 +52,24 @@ test.serial('should handle events on text nodes', t => { }); test.serial('should remove an event listener', t => { - const spy = sinon.spy(container, 'removeEventListener'); + const spy = sinon.spy(); + const controller = delegate(container, 'a', 'click', spy); + controller.abort(); + + const anchor = document.querySelector('a'); + anchor.click(); + t.true(spy.notCalled); +}); - const delegation = delegate(container, 'a', 'click', () => {}); - delegation.destroy(); +test.serial('should pass an AbortSignal to an event listener', t => { + const spy = sinon.spy(); + const controller = new AbortController(); + delegate(container, 'a', 'click', spy, {signal: controller.signal}); + controller.abort(); - t.true(spy.calledOnce); - spy.restore(); + const anchor = document.querySelector('a'); + anchor.click(); + t.true(spy.notCalled); }); test.serial('should add event listeners to all the elements in a base selector', t => { @@ -71,17 +83,28 @@ test.serial('should add event listeners to all the elements in a base selector', }); test.serial('should remove the event listeners from all the elements in a base selector', t => { - const items = document.querySelectorAll('li'); - const spies = Array.prototype.map.call(items, li => sinon.spy(li, 'removeEventListener')); + const spy = sinon.spy(); + const controller = delegate('li', 'a', 'click', spy); + controller.abort(); - const delegation = delegate('li', 'a', 'click', () => {}); - delegation.destroy(); + for (const anchor of document.querySelectorAll('a')) { + anchor.click(); + } - t.true(spies.every(spy => { - const success = spy.calledOnce; - spy.restore(); - return success; - })); + t.true(spy.notCalled); +}); + +test.serial('should pass an AbortSignal to the event listeners on all the elements in a base selector', t => { + const spy = sinon.spy(); + const controller = new AbortController(); + delegate('li', 'a', 'click', spy, {signal: controller.signal}); + controller.abort(); + + for (const anchor of document.querySelectorAll('a')) { + anchor.click(); + } + + t.true(spy.notCalled); }); test.serial('should add event listeners to all the elements in a base array', t => { @@ -96,17 +119,30 @@ test.serial('should add event listeners to all the elements in a base array', t }); test.serial('should remove the event listeners from all the elements in a base array', t => { + const spy = sinon.spy(); const items = document.querySelectorAll('li'); - const spies = Array.prototype.map.call(items, li => sinon.spy(li, 'removeEventListener')); + const controller = delegate(items, 'a', 'click', spy); + controller.abort(); - const delegation = delegate(items, 'a', 'click', () => {}); - delegation.destroy(); + for (const anchor of document.querySelectorAll('a')) { + anchor.click(); + } - t.true(spies.every(spy => { - const success = spy.calledOnce; - spy.restore(); - return success; - })); + t.true(spy.notCalled); +}); + +test.serial('should pass an AbortSignal to the event listeners on all the elements in a base array', t => { + const spy = sinon.spy(); + const items = document.querySelectorAll('li'); + const controller = new AbortController(); + delegate(items, 'a', 'click', spy, {signal: controller.signal}); + controller.abort(); + + for (const anchor of document.querySelectorAll('a')) { + anchor.click(); + } + + t.true(spy.notCalled); }); test.serial('should not fire when the selector matches an ancestor of the base element', t => { @@ -117,6 +153,14 @@ test.serial('should not fire when the selector matches an ancestor of the base e t.true(spy.notCalled); }); +test.serial('should not add an event listener when passed an already aborted signal', t => { + const spy = sinon.spy(container, 'addEventListener'); + delegate(container, 'a', 'click', spy, {signal: AbortSignal.abort()}); + + anchor.click(); + t.true(spy.notCalled); +}); + test.serial('should not consider the `once` option', t => { const spy = sinon.spy(); delegate(container, 'a', 'click', spy, {once: true}); diff --git a/tsconfig.json b/tsconfig.json index 8fc0bbe..a50022b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,7 @@ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { - "target": "es2015", - "lib": [ - "DOM" - ] + "target": "es2021" }, "files": [ "index.ts"