From 4f3d7eaaa191a9933404c9dbdc868c46cfad2295 Mon Sep 17 00:00:00 2001 From: Florent Date: Thu, 23 Jun 2022 17:31:11 +0200 Subject: [PATCH] Return `AbortSignal` instead of `{destroy()}` + support `signal` (#28) Co-authored-by: Federico Brigante --- index.ts | 71 +++++++++++++++++++----------------------- package.json | 2 +- readme.md | 16 ++++++++-- test.js | 88 +++++++++++++++++++++++++++++++++++++++------------- 4 files changed, 113 insertions(+), 64 deletions(-) diff --git a/index.ts b/index.ts index 2c4a553..33521d9 100644 --- a/index.ts +++ b/index.ts @@ -5,10 +5,6 @@ export type EventType = keyof GlobalEventHandlersEventMap; type GlobalEvent = Event; namespace delegate { - export type Subscription = { - destroy: VoidFunction; - }; - export type EventHandler< TEvent extends GlobalEvent = GlobalEvent, TElement extends Element = Element, @@ -95,7 +91,7 @@ function delegate< type: TEventType, callback: delegate.EventHandler, options?: DelegateOptions -): delegate.Subscription; +): AbortController; function delegate< TElement extends Element = HTMLElement, @@ -106,7 +102,7 @@ function delegate< type: TEventType, callback: delegate.EventHandler, options?: DelegateOptions -): delegate.Subscription; +): AbortController; // This type isn't exported as a declaration, so it needs to be duplicated above function delegate< @@ -118,7 +114,25 @@ function delegate< type: TEventType, callback: delegate.EventHandler, 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 +140,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(); - } - }, - }; + Array.prototype.forEach.call(base, element => { + 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" @@ -159,25 +160,17 @@ function delegate< } }; - // 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..acd8889 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "devDependencies": { "@sindresorhus/tsconfig": "^2.0.0", "ava": "^4.2.0", - "jsdom": "^19.0.0", + "jsdom": "^20.0.0", "npm-run-all": "^4.1.5", "sinon": "^14.0.0", "typescript": "^4.6.4", diff --git a/readme.md b/readme.md index 4b292ea..5f73478 100644 --- a/readme.md +++ b/readme.md @@ -10,6 +10,7 @@ This is a fork of the popular [`delegate`](https://github.com/zenorocha/delegate - modern: ES6, TypeScript, Edge 15+ (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..d0b5f81 100644 --- a/test.js +++ b/test.js @@ -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});