From e412983f097ddfdccdb23bad6c5eb742e9da963a Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Fri, 18 Nov 2022 14:25:30 +0700 Subject: [PATCH 01/11] Fix readme example Closes # --- readme.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/readme.md b/readme.md index 3ba3d0d..1bf638c 100644 --- a/readme.md +++ b/readme.md @@ -72,14 +72,6 @@ delegate(document.body, '.btn', 'click', event => { ### Remove event delegation -```js -const controller = delegate(document.body, '.btn', 'click', event => { - console.log(event.delegateTarget); -}); - -controller.abort(); -``` - ```js const controller = new AbortController(); delegate(document.body, '.btn', 'click', event => { From bc232e62f1da85d92da066b1ed85f7a705c9f0ad Mon Sep 17 00:00:00 2001 From: ambujsahu81 <118078892+ambujsahu81@users.noreply.github.com> Date: Tue, 27 Dec 2022 10:47:47 +0530 Subject: [PATCH 02/11] Remove redundant if statement (#38) --- index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/index.ts b/index.ts index 6ed69f6..d091a09 100644 --- a/index.ts +++ b/index.ts @@ -36,10 +36,6 @@ function editLedger( ?? new WeakMap>(); ledger.set(baseElement, elementMap); - if (!wanted && !ledger.has(baseElement)) { - return false; - } - const setups = elementMap.get(callback) ?? new Set(); elementMap.set(callback, setups); From a968bc393923191180d62dc9da75f817301d4741 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 22 Apr 2023 11:49:15 +0800 Subject: [PATCH 03/11] Update dependencies and add `exports` field (#39) --- .github/workflows/ci.yml | 6 +++--- package.json | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3a863..6ae9263 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,14 @@ jobs: Lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - run: npm install - run: npx xo Build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - run: npm install - run: npm run build @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest container: node:latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - run: npm install - run: npm run build - run: npx ava diff --git a/package.json b/package.json index fa0efe4..0accf22 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,13 @@ "event" ], "repository": "fregante/delegate-it", + "funding": "https://github.com/sponsors/fregante", "license": "MIT", "author": "Federico Brigante (https://fregante.com)", "type": "module", - "main": "index.js", - "module": "index.js", + "exports": "./index.js", + "main": "./index.js", + "types": "./index.d.ts", "files": [ "index.js", "index.d.ts" @@ -43,15 +45,15 @@ } }, "dependencies": { - "typed-query-selector": "^2.6.1" + "typed-query-selector": "^2.10.0" }, "devDependencies": { "@sindresorhus/tsconfig": "^3.0.1", - "ava": "^4.3.0", - "jsdom": "^20.0.0", + "ava": "^5.2.0", + "jsdom": "^21.1.1", "npm-run-all": "^4.1.5", - "sinon": "^14.0.0", - "typescript": "^4.7.4", - "xo": "^0.50.0" + "sinon": "^15.0.4", + "typescript": "^5.0.4", + "xo": "^0.54.1" } } From 39dcce775c012a4e9440593276a37ff0f677ed31 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 22 Apr 2023 13:21:00 +0800 Subject: [PATCH 04/11] Add support for `once` (#40) --- index.ts | 15 +++++++++----- readme.md | 10 +++++++++- test.js | 60 ++++++++++++++++++++++++++++++++++++------------------- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/index.ts b/index.ts index d091a09..94288dc 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,6 @@ import type {ParseSelector} from 'typed-query-selector/parser.d.js'; -export type DelegateOptions = boolean | Omit; +export type DelegateOptions = boolean | AddEventListenerOptions; export type EventType = keyof GlobalEventHandlersEventMap; export type DelegateEventHandler< @@ -72,7 +72,7 @@ function safeClosest(event: Event, selector: string): Element | void { /** * Delegates event to a selector. - * @param options A boolean value setting options.capture or an options object of type AddEventListenerOptions without the `once` option + * @param options A boolean value setting options.capture or an options object of type AddEventListenerOptions */ function delegate< Selector extends string, @@ -109,8 +109,6 @@ function delegate< options?: DelegateOptions, ): void { 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; const {signal} = listenerOptions; @@ -132,6 +130,9 @@ function delegate< return; } + // Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it` + const {once, ...nativeListenerOptions} = listenerOptions; + // `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; @@ -142,13 +143,17 @@ function delegate< if (delegateTarget) { const delegateEvent = Object.assign(event, {delegateTarget}); callback.call(baseElement, delegateEvent as DelegateEvent); + if (once) { + baseElement.removeEventListener(type, listenerFn, 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, listenerFn, listenerOptions); + baseElement.addEventListener(type, listenerFn, nativeListenerOptions); } signal?.addEventListener('abort', () => { diff --git a/readme.md b/readme.md index 1bf638c..726432a 100644 --- a/readme.md +++ b/readme.md @@ -68,7 +68,15 @@ delegate(document.body, '.btn', 'click', event => { }); ``` -**Note:** the `once` option is currently not supported. +#### Listen to one event only + +```js +delegate(document.body, '.btn', 'click', event => { + console.log('This will only be called once'); +}, { + once: true +}); +``` ### Remove event delegation diff --git a/test.js b/test.js index 5a58120..be3a085 100644 --- a/test.js +++ b/test.js @@ -28,24 +28,6 @@ test.serial('should add an event listener', t => { anchor.click(); }); -test.serial('should add an event listener only once', t => { - t.plan(2); - - // Only deduplicates the `capture` flag - // https://github.com/fregante/delegate-it/pull/11#discussion_r285481625 - - // Capture: false - delegate(container, 'a', 'click', t.pass); - delegate(container, 'a', 'click', t.pass, {passive: true}); - delegate(container, 'a', 'click', t.pass, {capture: false}); - - // Capture: true - delegate(container, 'a', 'click', t.pass, true); - delegate(container, 'a', 'click', t.pass, {capture: true}); - - anchor.click(); -}); - test.serial('should handle events on text nodes', t => { delegate(container, 'a', 'click', t.pass); anchor.firstChild.dispatchEvent(new MouseEvent('click', {bubbles: true})); @@ -164,11 +146,49 @@ test.serial('should not add an event listener when passed an already aborted sig t.true(spy.notCalled); }); -test.serial('should not consider the `once` option', t => { +test.serial('should call the listener once with the `once` option', t => { const spy = sinon.spy(); delegate(container, 'a', 'click', spy, {once: true}); + container.click(); + t.true(spy.notCalled, 'It should not be called on the container'); anchor.click(); + t.true(spy.calledOnce, 'It should be called on the delegate target'); anchor.click(); - t.true(spy.calledTwice); + t.true(spy.calledOnce, 'It should not be called again on the delegate target'); +}); + +test.serial('should add a specific event listener only once', t => { + t.plan(2); + + // Only deduplicates the `capture` flag + // https://github.com/fregante/delegate-it/pull/11#discussion_r285481625 + + // Capture: false + delegate(container, 'a', 'click', t.pass); + delegate(container, 'a', 'click', t.pass, {passive: true}); + delegate(container, 'a', 'click', t.pass, {capture: false}); + + // Capture: true + delegate(container, 'a', 'click', t.pass, true); + delegate(container, 'a', 'click', t.pass, {capture: true}); + + // Once + delegate(container, 'a', 'click', t.pass, {once: true}); + delegate(container, 'a', 'click', t.pass, {once: false}); + + anchor.click(); +}); + +test.serial('should deduplicate identical listeners added after `once:true`', t => { + const spy = sinon.spy(); + delegate(container, 'a', 'click', spy, {once: true}); + delegate(container, 'a', 'click', spy, {once: false}); + + container.click(); + t.true(spy.notCalled, 'It should not be called on the container'); + anchor.click(); + t.true(spy.calledOnce, 'It should be called on the delegate target'); + anchor.click(); + t.true(spy.calledOnce, 'It should not be called again on the delegate target'); }); From 5697313e3d1cddd2fd72fb45b7b1a64e0185d93c Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 22 Apr 2023 13:27:07 +0800 Subject: [PATCH 05/11] Add promised `oneEvent` listener (#41) --- .gitignore | 6 +- ava.setup.js | 21 +++++ test.js => delegate.test.js | 32 +------ delegate.ts | 170 ++++++++++++++++++++++++++++++++++++ index.ts | 166 +---------------------------------- one-event.test.js | 25 ++++++ one-event.ts | 66 ++++++++++++++ package.json | 6 +- readme.md | 25 ++++-- tsconfig.json | 4 +- 10 files changed, 317 insertions(+), 204 deletions(-) create mode 100644 ava.setup.js rename test.js => delegate.test.js (84%) create mode 100644 delegate.ts create mode 100644 one-event.test.js create mode 100644 one-event.ts diff --git a/.gitignore b/.gitignore index c04c388..8c8cea2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules -index.js -index.d.ts +*.js +!*.test.js +!*.setup.js +*.d.ts *.map diff --git a/ava.setup.js b/ava.setup.js new file mode 100644 index 0000000..1f709d5 --- /dev/null +++ b/ava.setup.js @@ -0,0 +1,21 @@ +import {JSDOM} from 'jsdom'; + +const {window} = new JSDOM(` + +`); + +global.Text = window.Text; +global.Event = window.Event; +global.Element = window.Element; +global.Document = window.Document; +global.MouseEvent = window.MouseEvent; +global.AbortController = window.AbortController; +global.document = window.document; +export const container = window.document.querySelector('ul'); +export const anchor = window.document.querySelector('a'); diff --git a/test.js b/delegate.test.js similarity index 84% rename from test.js rename to delegate.test.js index be3a085..0c130a9 100644 --- a/test.js +++ b/delegate.test.js @@ -1,27 +1,7 @@ import test from 'ava'; import sinon from 'sinon'; -import {JSDOM} from 'jsdom'; -import delegate from './index.js'; - -const {window} = new JSDOM(` - -`); - -global.Text = window.Text; -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'); +import {container, anchor} from './ava.setup.js'; +import delegate from './delegate.js'; test.serial('should add an event listener', t => { delegate(container, 'a', 'click', t.pass); @@ -39,18 +19,14 @@ test.serial('should remove an event listener', t => { delegate(container, 'a', 'click', spy, {signal: controller.signal}); controller.abort(); - const anchor = document.querySelector('a'); anchor.click(); t.true(spy.notCalled); }); -test.serial('should pass an AbortSignal to an event listener', t => { +test.serial('should not add an event listener of the controller has already aborted', t => { const spy = sinon.spy(); - const controller = new AbortController(); - delegate(container, 'a', 'click', spy, {signal: controller.signal}); - controller.abort(); + delegate(container, 'a', 'click', spy, {signal: AbortSignal.abort()}); - const anchor = document.querySelector('a'); anchor.click(); t.true(spy.notCalled); }); diff --git a/delegate.ts b/delegate.ts new file mode 100644 index 0000000..0c2e2e1 --- /dev/null +++ b/delegate.ts @@ -0,0 +1,170 @@ +import type {ParseSelector} from 'typed-query-selector/parser.d.js'; + +export type DelegateOptions = boolean | AddEventListenerOptions; +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; +}; + +export function castAddEventListenerOptions( + options: DelegateOptions | undefined, +): AddEventListenerOptions { + return typeof options === 'object' ? options : {capture: options}; +} + +/** 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 | Document, + 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 isEventTarget( + elements: EventTarget | Document | Iterable | string, +): elements is EventTarget { + return typeof (elements as EventTarget).addEventListener === 'function'; +} + +function safeClosest(event: Event, selector: string): Element | void { + let target = event.target; + if (target instanceof Text) { + target = target.parentElement; + } + + if (target instanceof Element && event.currentTarget instanceof Element) { + // `.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, +>( + base: EventTarget | Document | Iterable | string, + selector: Selector, + type: TEventType, + callback: DelegateEventHandler, + options?: DelegateOptions +): void; + +function delegate< + TElement extends Element = HTMLElement, + TEventType extends EventType = EventType, +>( + base: EventTarget | Document | Iterable | string, + selector: 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, +>( + base: EventTarget | Document | Iterable | string, + selector: string, + type: TEventType, + callback: DelegateEventHandler, + options?: DelegateOptions, +): void { + const listenerOptions = castAddEventListenerOptions(options); + + const {signal} = listenerOptions; + + if (signal?.aborted) { + return; + } + + // Handle Selector-based usage + if (typeof base === 'string') { + base = document.querySelectorAll(base); + } + + // Handle Array-like based usage + if (!isEventTarget(base)) { + for (const element of base) { + delegate(element, selector, type, callback, listenerOptions); + } + + return; + } + + // Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it` + const {once, ...nativeListenerOptions} = listenerOptions; + + // `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 listenerFn = (event: Event): void => { + const delegateTarget = safeClosest(event, selector); + if (delegateTarget) { + const delegateEvent = Object.assign(event, {delegateTarget}); + callback.call(baseElement, delegateEvent as DelegateEvent); + if (once) { + baseElement.removeEventListener(type, listenerFn, 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, listenerFn, nativeListenerOptions); + } + + signal?.addEventListener('abort', () => { + editLedger(false, baseElement, callback, setup); + }); +} + +export default delegate; diff --git a/index.ts b/index.ts index 94288dc..a741818 100644 --- a/index.ts +++ b/index.ts @@ -1,164 +1,2 @@ -import type {ParseSelector} from 'typed-query-selector/parser.d.js'; - -export type DelegateOptions = boolean | AddEventListenerOptions; -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 | Document, - 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 isEventTarget( - elements: EventTarget | Document | Iterable | string, -): elements is EventTarget { - return typeof (elements as EventTarget).addEventListener === 'function'; -} - -function safeClosest(event: Event, selector: string): Element | void { - let target = event.target; - if (target instanceof Text) { - target = target.parentElement; - } - - if (target instanceof Element && event.currentTarget instanceof Element) { - // `.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, ->( - base: EventTarget | Document | Iterable | string, - selector: Selector, - type: TEventType, - callback: DelegateEventHandler, - options?: DelegateOptions -): void; - -function delegate< - TElement extends Element = HTMLElement, - TEventType extends EventType = EventType, ->( - base: EventTarget | Document | Iterable | string, - selector: 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, ->( - base: EventTarget | Document | Iterable | string, - selector: string, - type: TEventType, - callback: DelegateEventHandler, - options?: DelegateOptions, -): void { - const listenerOptions: AddEventListenerOptions = typeof options === 'object' ? options : {capture: options}; - - const {signal} = listenerOptions; - - if (signal?.aborted) { - return; - } - - // Handle Selector-based usage - if (typeof base === 'string') { - base = document.querySelectorAll(base); - } - - // Handle Array-like based usage - if (!isEventTarget(base)) { - for (const element of base) { - delegate(element, selector, type, callback, listenerOptions); - } - - return; - } - - // Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it` - const {once, ...nativeListenerOptions} = listenerOptions; - - // `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 listenerFn = (event: Event): void => { - const delegateTarget = safeClosest(event, selector); - if (delegateTarget) { - const delegateEvent = Object.assign(event, {delegateTarget}); - callback.call(baseElement, delegateEvent as DelegateEvent); - if (once) { - baseElement.removeEventListener(type, listenerFn, 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, listenerFn, nativeListenerOptions); - } - - signal?.addEventListener('abort', () => { - editLedger(false, baseElement, callback, setup); - }); -} - -export default delegate; +export * from './delegate.js'; +export {default as oneEvent} from './one-event.js'; diff --git a/one-event.test.js b/one-event.test.js new file mode 100644 index 0000000..37ca9a2 --- /dev/null +++ b/one-event.test.js @@ -0,0 +1,25 @@ +import test from 'ava'; +import {container, anchor} from './ava.setup.js'; +import oneEvent from './one-event.js'; + +test.serial('should resolve after one event', async t => { + const promise = oneEvent(container, 'a', 'click'); + anchor.click(); + const event = await promise; + t.true(event instanceof MouseEvent); +}); + +test.serial('should resolve with `undefined` after it’s aborted', async t => { + const controller = new AbortController(); + const promise = oneEvent(container, 'a', 'click', {signal: controller.signal}); + controller.abort(); + + const event = await promise; + t.is(event, undefined); +}); + +test.serial('should resolve with `undefined` if the signal has already aborted', async t => { + const promise = oneEvent(container, 'a', 'click', {signal: AbortSignal.abort()}); + const event = await promise; + t.is(event, undefined); +}); diff --git a/one-event.ts b/one-event.ts new file mode 100644 index 0000000..4603a5a --- /dev/null +++ b/one-event.ts @@ -0,0 +1,66 @@ +import type {ParseSelector} from 'typed-query-selector/parser.d.js'; +import delegate, { + castAddEventListenerOptions, + type DelegateEvent, + type DelegateOptions, + type EventType, +} from './delegate.js'; + +/** + * Delegates event to a selector and resolves after the first event + */ +async function oneEvent< + Selector extends string, + TElement extends Element = ParseSelector, + TEventType extends EventType = EventType, +>( + base: EventTarget | Document | Iterable | string, + selector: Selector, + type: TEventType, + options?: DelegateOptions +): Promise>; + +async function oneEvent< + TElement extends Element = HTMLElement, + TEventType extends EventType = EventType, +>( + base: EventTarget | Document | Iterable | string, + selector: string, + type: TEventType, + options?: DelegateOptions +): Promise>; + +// This type isn't exported as a declaration, so it needs to be duplicated above +async function oneEvent< + TElement extends Element, + TEventType extends EventType = EventType, +>( + base: EventTarget | Document | Iterable | string, + selector: string, + type: TEventType, + options?: DelegateOptions, +): Promise | undefined> { + return new Promise(resolve => { + const listenerOptions = castAddEventListenerOptions(options); + listenerOptions.once = true; + + if (listenerOptions.signal?.aborted) { + resolve(undefined); + } + + listenerOptions.signal?.addEventListener('abort', () => { + resolve(undefined); + }); + + delegate( + base, + selector, + type, + // @ts-expect-error Seems to work fine + resolve, + listenerOptions, + ); + }); +} + +export default oneEvent; diff --git a/package.json b/package.json index 0accf22..32e7f08 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,11 @@ "types": "./index.d.ts", "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "delegate.js", + "delegate.d.ts", + "one-event.js", + "one-event.d.ts" ], "scripts": { "build": "tsc", diff --git a/readme.md b/readme.md index 726432a..867bd54 100644 --- a/readme.md +++ b/readme.md @@ -68,27 +68,36 @@ delegate(document.body, '.btn', 'click', event => { }); ``` -#### Listen to one event only +### Remove event delegation ```js +const controller = new AbortController(); delegate(document.body, '.btn', 'click', event => { - console.log('This will only be called once'); + console.log(event.delegateTarget); }, { - once: true + signal: controller.signal, }); + +controller.abort(); ``` -### Remove event delegation +#### Listen to one event only ```js -const controller = new AbortController(); delegate(document.body, '.btn', 'click', event => { - console.log(event.delegateTarget); + console.log('This will only be called once'); }, { - signal: controller.signal, + once: true }); +``` -controller.abort(); +#### Listen to one event only, with a promise + +```js +import {oneEvent} from 'delegate-it'; + +await oneEvent(document.body, '.btn', 'click'); +console.log('The body was clicked'); ``` ### Custom event types in Typescript diff --git a/tsconfig.json b/tsconfig.json index a50022b..466046c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,8 @@ "target": "es2021" }, "files": [ - "index.ts" + "index.ts", + "delegate.ts", + "one-event.ts" ] } From a13cd0e1c7b2fd67a4222a1128e23e469b560447 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Sat, 22 Apr 2023 13:33:44 +0800 Subject: [PATCH 06/11] Switch from AVA to Vitest (#42) --- .github/workflows/ci.yml | 3 +- delegate.test.js | 170 ------------------------ delegate.test.ts | 174 +++++++++++++++++++++++++ one-event.test.js => one-event.test.ts | 16 +-- package.json | 12 +- ava.setup.js => vitest.setup.ts | 4 +- 6 files changed, 190 insertions(+), 189 deletions(-) delete mode 100644 delegate.test.js create mode 100644 delegate.test.ts rename one-event.test.js => one-event.test.ts (51%) rename ava.setup.js => vitest.setup.ts (78%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ae9263..9d6b2d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,8 @@ jobs: Test: runs-on: ubuntu-latest - container: node:latest steps: - uses: actions/checkout@v3 - run: npm install - run: npm run build - - run: npx ava + - run: npx vitest diff --git a/delegate.test.js b/delegate.test.js deleted file mode 100644 index 0c130a9..0000000 --- a/delegate.test.js +++ /dev/null @@ -1,170 +0,0 @@ -import test from 'ava'; -import sinon from 'sinon'; -import {container, anchor} from './ava.setup.js'; -import delegate from './delegate.js'; - -test.serial('should add an event listener', t => { - delegate(container, 'a', 'click', t.pass); - anchor.click(); -}); - -test.serial('should handle events on text nodes', t => { - delegate(container, 'a', 'click', t.pass); - anchor.firstChild.dispatchEvent(new MouseEvent('click', {bubbles: true})); -}); - -test.serial('should remove an event listener', t => { - const spy = sinon.spy(); - const controller = new AbortController(); - delegate(container, 'a', 'click', spy, {signal: controller.signal}); - controller.abort(); - - anchor.click(); - t.true(spy.notCalled); -}); - -test.serial('should not add an event listener of the controller has already aborted', t => { - const spy = sinon.spy(); - delegate(container, 'a', 'click', spy, {signal: AbortSignal.abort()}); - - anchor.click(); - t.true(spy.notCalled); -}); - -test.serial('should add event listeners to all the elements in a base selector', t => { - const spy = sinon.spy(); - delegate('li', 'a', 'click', spy); - - const anchors = document.querySelectorAll('a'); - anchors[0].click(); - anchors[1].click(); - t.true(spy.calledTwice); -}); - -test.serial('should remove the event listeners from 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 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 => { - const spy = sinon.spy(); - const items = document.querySelectorAll('li'); - delegate(items, 'a', 'click', spy); - - const anchors = document.querySelectorAll('a'); - anchors[0].click(); - anchors[1].click(); - t.true(spy.calledTwice); -}); - -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 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 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 => { - const spy = sinon.spy(); - delegate(container, 'body', 'click', spy); - - anchor.click(); - 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 call the listener once with the `once` option', t => { - const spy = sinon.spy(); - delegate(container, 'a', 'click', spy, {once: true}); - - container.click(); - t.true(spy.notCalled, 'It should not be called on the container'); - anchor.click(); - t.true(spy.calledOnce, 'It should be called on the delegate target'); - anchor.click(); - t.true(spy.calledOnce, 'It should not be called again on the delegate target'); -}); - -test.serial('should add a specific event listener only once', t => { - t.plan(2); - - // Only deduplicates the `capture` flag - // https://github.com/fregante/delegate-it/pull/11#discussion_r285481625 - - // Capture: false - delegate(container, 'a', 'click', t.pass); - delegate(container, 'a', 'click', t.pass, {passive: true}); - delegate(container, 'a', 'click', t.pass, {capture: false}); - - // Capture: true - delegate(container, 'a', 'click', t.pass, true); - delegate(container, 'a', 'click', t.pass, {capture: true}); - - // Once - delegate(container, 'a', 'click', t.pass, {once: true}); - delegate(container, 'a', 'click', t.pass, {once: false}); - - anchor.click(); -}); - -test.serial('should deduplicate identical listeners added after `once:true`', t => { - const spy = sinon.spy(); - delegate(container, 'a', 'click', spy, {once: true}); - delegate(container, 'a', 'click', spy, {once: false}); - - container.click(); - t.true(spy.notCalled, 'It should not be called on the container'); - anchor.click(); - t.true(spy.calledOnce, 'It should be called on the delegate target'); - anchor.click(); - t.true(spy.calledOnce, 'It should not be called again on the delegate target'); -}); diff --git a/delegate.test.ts b/delegate.test.ts new file mode 100644 index 0000000..58f808a --- /dev/null +++ b/delegate.test.ts @@ -0,0 +1,174 @@ +import {test, vi, expect} from 'vitest'; +import {container, anchor} from './vitest.setup.js'; +import delegate from './delegate.js'; + +test('should add an event listener', () => { + const spy = vi.fn(); + delegate(container, 'a', 'click', spy); + anchor.click(); + expect(spy).toHaveBeenCalledTimes(1); +}); + +test('should handle events on text nodes', () => { + const spy = vi.fn(); + delegate(container, 'a', 'click', spy); + anchor.firstChild!.dispatchEvent(new MouseEvent('click', {bubbles: true})); + expect(spy).toHaveBeenCalledTimes(1); +}); + +test('should remove an event listener', () => { + const spy = vi.fn(); + const controller = new AbortController(); + delegate(container, 'a', 'click', spy, {signal: controller.signal}); + controller.abort(); + + anchor.click(); + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should not add an event listener of the controller has already aborted', () => { + const spy = vi.fn(); + delegate(container, 'a', 'click', spy, {signal: AbortSignal.abort()}); + + anchor.click(); + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should add event listeners to all the elements in a base selector', () => { + const spy = vi.fn(); + delegate('li', 'a', 'click', spy); + + const anchors = document.querySelectorAll('a'); + anchors[0].click(); + anchors[1].click(); + expect(spy).toHaveBeenCalledTimes(2); +}); + +test('should remove the event listeners from all the elements in a base selector', () => { + const spy = vi.fn(); + const controller = new AbortController(); + delegate('li', 'a', 'click', spy, {signal: controller.signal}); + controller.abort(); + + for (const anchor of document.querySelectorAll('a')) { + anchor.click(); + } + + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should pass an AbortSignal to the event listeners on all the elements in a base selector', () => { + const spy = vi.fn(); + const controller = new AbortController(); + delegate('li', 'a', 'click', spy, {signal: controller.signal}); + controller.abort(); + + for (const anchor of document.querySelectorAll('a')) { + anchor.click(); + } + + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should add event listeners to all the elements in a base array', () => { + const spy = vi.fn(); + const items = document.querySelectorAll('li'); + delegate(items, 'a', 'click', spy); + + const anchors = document.querySelectorAll('a'); + anchors[0].click(); + anchors[1].click(); + expect(spy).toHaveBeenCalledTimes(2); +}); + +test('should remove the event listeners from all the elements in a base array', () => { + const spy = vi.fn(); + 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(); + } + + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should pass an AbortSignal to the event listeners on all the elements in a base array', () => { + const spy = vi.fn(); + 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(); + } + + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should not fire when the selector matches an ancestor of the base element', () => { + const spy = vi.fn(); + delegate(container, 'body', 'click', spy); + + anchor.click(); + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should not add an event listener when passed an already aborted signal', () => { + const spy = vi.spyOn(container, 'addEventListener'); + delegate(container, 'a', 'click', () => ({}), {signal: AbortSignal.abort()}); + + anchor.click(); + expect(spy).toHaveBeenCalledTimes(0); +}); + +test('should call the listener once with the `once` option', () => { + const spy = vi.fn(); + delegate(container, 'a', 'click', spy, {once: true}); + + container.click(); + expect(spy).toHaveBeenCalledTimes(0); // It should not be called on the container + anchor.click(); + expect(spy).toHaveBeenCalledTimes(1); // It should be called on the delegate target + anchor.click(); + expect(spy).toHaveBeenCalledTimes(1); // It should not be called again on the delegate target +}); + +test('should add a specific event listener only once', () => { + const spy = vi.fn(); + + // Only deduplicates the `capture` flag + // https://github.com/fregante/delegate-it/pull/11#discussion_r285481625 + + // Capture: false + delegate(container, 'a', 'click', spy); + delegate(container, 'a', 'click', spy, {passive: true}); + delegate(container, 'a', 'click', spy, {capture: false}); + + // Capture: true + delegate(container, 'a', 'click', spy, true); + delegate(container, 'a', 'click', spy, {capture: true}); + + // Once + delegate(container, 'a', 'click', spy, {once: true}); + delegate(container, 'a', 'click', spy, {once: false}); + + anchor.click(); + expect(spy).toHaveBeenCalledTimes(2); +}); + +test('should deduplicate identical listeners added after `once:true`', () => { + const spy = vi.fn(); + delegate(container, 'a', 'click', spy, {once: true}); + delegate(container, 'a', 'click', spy, {once: false}); + + container.click(); + expect(spy).toHaveBeenCalledTimes(0); // It should not be called on the container + anchor.click(); + expect(spy).toHaveBeenCalledTimes(1); // It should be called on the delegate target + anchor.click(); + expect(spy).toHaveBeenCalledTimes(1); // It should not be called again on the delegate target +}); diff --git a/one-event.test.js b/one-event.test.ts similarity index 51% rename from one-event.test.js rename to one-event.test.ts index 37ca9a2..a153c77 100644 --- a/one-event.test.js +++ b/one-event.test.ts @@ -1,25 +1,25 @@ -import test from 'ava'; -import {container, anchor} from './ava.setup.js'; +import {test, expect} from 'vitest'; +import {container, anchor} from './vitest.setup.js'; import oneEvent from './one-event.js'; -test.serial('should resolve after one event', async t => { +test('should resolve after one event', async t => { const promise = oneEvent(container, 'a', 'click'); anchor.click(); const event = await promise; - t.true(event instanceof MouseEvent); + expect(event).toBeInstanceOf(MouseEvent); }); -test.serial('should resolve with `undefined` after it’s aborted', async t => { +test('should resolve with `undefined` after it’s aborted', async t => { const controller = new AbortController(); const promise = oneEvent(container, 'a', 'click', {signal: controller.signal}); controller.abort(); const event = await promise; - t.is(event, undefined); + expect(event).toBeUndefined(); }); -test.serial('should resolve with `undefined` if the signal has already aborted', async t => { +test('should resolve with `undefined` if the signal has already aborted', async t => { const promise = oneEvent(container, 'a', 'click', {signal: AbortSignal.abort()}); const event = await promise; - t.is(event, undefined); + expect(event).toBeUndefined(); }); diff --git a/package.json b/package.json index 32e7f08..1ca0afa 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,9 @@ "scripts": { "build": "tsc", "prepack": "tsc --sourceMap false", - "test": "tsc && xo && ava", - "watch": "run-p --silent watch:*", - "watch:build": "tsc --watch", - "watch:test": "ava --watch" + "test": "tsc && xo && vitest run", + "watch": "tsc --watch", + "watch:test": "vitest" }, "xo": { "envs": [ @@ -53,11 +52,10 @@ }, "devDependencies": { "@sindresorhus/tsconfig": "^3.0.1", - "ava": "^5.2.0", + "@types/jsdom": "^21.1.1", "jsdom": "^21.1.1", - "npm-run-all": "^4.1.5", - "sinon": "^15.0.4", "typescript": "^5.0.4", + "vitest": "^0.30.1", "xo": "^0.54.1" } } diff --git a/ava.setup.js b/vitest.setup.ts similarity index 78% rename from ava.setup.js rename to vitest.setup.ts index 1f709d5..0505ddc 100644 --- a/ava.setup.js +++ b/vitest.setup.ts @@ -17,5 +17,5 @@ global.Document = window.Document; global.MouseEvent = window.MouseEvent; global.AbortController = window.AbortController; global.document = window.document; -export const container = window.document.querySelector('ul'); -export const anchor = window.document.querySelector('a'); +export const container = window.document.querySelector('ul')!; +export const anchor = window.document.querySelector('a')!; From 5b5138bbb9877d94fc7b31973e391229acc6cf83 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Tue, 25 Apr 2023 19:40:36 +0800 Subject: [PATCH 07/11] Drop support for selectors and arrays as "base" (#45) --- delegate.test.ts | 75 ------------------------------------------------ delegate.ts | 28 +++--------------- one-event.ts | 6 ++-- readme.md | 28 ++++-------------- 4 files changed, 13 insertions(+), 124 deletions(-) diff --git a/delegate.test.ts b/delegate.test.ts index 58f808a..31cdba5 100644 --- a/delegate.test.ts +++ b/delegate.test.ts @@ -34,81 +34,6 @@ test('should not add an event listener of the controller has already aborted', ( expect(spy).toHaveBeenCalledTimes(0); }); -test('should add event listeners to all the elements in a base selector', () => { - const spy = vi.fn(); - delegate('li', 'a', 'click', spy); - - const anchors = document.querySelectorAll('a'); - anchors[0].click(); - anchors[1].click(); - expect(spy).toHaveBeenCalledTimes(2); -}); - -test('should remove the event listeners from all the elements in a base selector', () => { - const spy = vi.fn(); - const controller = new AbortController(); - delegate('li', 'a', 'click', spy, {signal: controller.signal}); - controller.abort(); - - for (const anchor of document.querySelectorAll('a')) { - anchor.click(); - } - - expect(spy).toHaveBeenCalledTimes(0); -}); - -test('should pass an AbortSignal to the event listeners on all the elements in a base selector', () => { - const spy = vi.fn(); - const controller = new AbortController(); - delegate('li', 'a', 'click', spy, {signal: controller.signal}); - controller.abort(); - - for (const anchor of document.querySelectorAll('a')) { - anchor.click(); - } - - expect(spy).toHaveBeenCalledTimes(0); -}); - -test('should add event listeners to all the elements in a base array', () => { - const spy = vi.fn(); - const items = document.querySelectorAll('li'); - delegate(items, 'a', 'click', spy); - - const anchors = document.querySelectorAll('a'); - anchors[0].click(); - anchors[1].click(); - expect(spy).toHaveBeenCalledTimes(2); -}); - -test('should remove the event listeners from all the elements in a base array', () => { - const spy = vi.fn(); - 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(); - } - - expect(spy).toHaveBeenCalledTimes(0); -}); - -test('should pass an AbortSignal to the event listeners on all the elements in a base array', () => { - const spy = vi.fn(); - 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(); - } - - expect(spy).toHaveBeenCalledTimes(0); -}); - test('should not fire when the selector matches an ancestor of the base element', () => { const spy = vi.fn(); delegate(container, 'body', 'click', spy); diff --git a/delegate.ts b/delegate.ts index 0c2e2e1..4c843c2 100644 --- a/delegate.ts +++ b/delegate.ts @@ -29,7 +29,7 @@ WeakMap> function editLedger( wanted: boolean, - baseElement: EventTarget | Document, + baseElement: EventTarget, callback: DelegateEventHandler, setup: string, ): boolean { @@ -55,12 +55,6 @@ function editLedger( return existed && wanted; } -function isEventTarget( - elements: EventTarget | Document | Iterable | string, -): elements is EventTarget { - return typeof (elements as EventTarget).addEventListener === 'function'; -} - function safeClosest(event: Event, selector: string): Element | void { let target = event.target; if (target instanceof Text) { @@ -85,7 +79,7 @@ function delegate< TElement extends Element = ParseSelector, TEventType extends EventType = EventType, >( - base: EventTarget | Document | Iterable | string, + base: EventTarget, selector: Selector, type: TEventType, callback: DelegateEventHandler, @@ -96,7 +90,7 @@ function delegate< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( - base: EventTarget | Document | Iterable | string, + base: EventTarget, selector: string, type: TEventType, callback: DelegateEventHandler, @@ -108,7 +102,7 @@ function delegate< TElement extends Element, TEventType extends EventType = EventType, >( - base: EventTarget | Document | Iterable | string, + base: EventTarget, selector: string, type: TEventType, callback: DelegateEventHandler, @@ -122,20 +116,6 @@ function delegate< return; } - // Handle Selector-based usage - if (typeof base === 'string') { - base = document.querySelectorAll(base); - } - - // Handle Array-like based usage - if (!isEventTarget(base)) { - for (const element of base) { - delegate(element, selector, type, callback, listenerOptions); - } - - return; - } - // Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it` const {once, ...nativeListenerOptions} = listenerOptions; diff --git a/one-event.ts b/one-event.ts index 4603a5a..ba6fa4a 100644 --- a/one-event.ts +++ b/one-event.ts @@ -14,7 +14,7 @@ async function oneEvent< TElement extends Element = ParseSelector, TEventType extends EventType = EventType, >( - base: EventTarget | Document | Iterable | string, + base: EventTarget, selector: Selector, type: TEventType, options?: DelegateOptions @@ -24,7 +24,7 @@ async function oneEvent< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( - base: EventTarget | Document | Iterable | string, + base: EventTarget, selector: string, type: TEventType, options?: DelegateOptions @@ -35,7 +35,7 @@ async function oneEvent< TElement extends Element, TEventType extends EventType = EventType, >( - base: EventTarget | Document | Iterable | string, + base: EventTarget, selector: string, type: TEventType, options?: DelegateOptions, diff --git a/readme.md b/readme.md index 867bd54..a1e9a28 100644 --- a/readme.md +++ b/readme.md @@ -32,23 +32,7 @@ import delegate from 'delegate-it'; #### With an element as base ```js -delegate(document.body, '.btn', 'click', event => { - console.log(event.delegateTarget); -}); -``` - -#### With a selector (of existing elements) as base - -```js -delegate('.container', '.btn', 'click', event => { - console.log(event.delegateTarget); -}); -``` - -#### With an array/array-like of elements as base - -```js -delegate(document.querySelectorAll('.container'), '.btn', 'click', event => { +delegate(document, '.btn', 'click', event => { console.log(event.delegateTarget); }); ``` @@ -56,12 +40,12 @@ delegate(document.querySelectorAll('.container'), '.btn', 'click', event => { #### With listener options ```js -delegate(document.body, '.btn', 'click', event => { +delegate(document, '.btn', 'click', event => { console.log(event.delegateTarget); }, true); // Or equivalent: -delegate(document.body, '.btn', 'click', event => { +delegate(document, '.btn', 'click', event => { console.log(event.delegateTarget); }, { capture: true @@ -72,7 +56,7 @@ delegate(document.body, '.btn', 'click', event => { ```js const controller = new AbortController(); -delegate(document.body, '.btn', 'click', event => { +delegate(document, '.btn', 'click', event => { console.log(event.delegateTarget); }, { signal: controller.signal, @@ -84,7 +68,7 @@ controller.abort(); #### Listen to one event only ```js -delegate(document.body, '.btn', 'click', event => { +delegate(document, '.btn', 'click', event => { console.log('This will only be called once'); }, { once: true @@ -96,7 +80,7 @@ delegate(document.body, '.btn', 'click', event => { ```js import {oneEvent} from 'delegate-it'; -await oneEvent(document.body, '.btn', 'click'); +await oneEvent(document, '.btn', 'click'); console.log('The body was clicked'); ``` From 7b3dc8a66f4960181075b8a36dc715e06c548ec8 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Tue, 25 Apr 2023 19:44:13 +0800 Subject: [PATCH 08/11] Drop support for boolean-only options (#44) --- delegate.test.ts | 1 - delegate.ts | 16 ++++------------ one-event.ts | 12 +++++------- readme.md | 17 +++++------------ 4 files changed, 14 insertions(+), 32 deletions(-) diff --git a/delegate.test.ts b/delegate.test.ts index 31cdba5..54b15be 100644 --- a/delegate.test.ts +++ b/delegate.test.ts @@ -74,7 +74,6 @@ test('should add a specific event listener only once', () => { delegate(container, 'a', 'click', spy, {capture: false}); // Capture: true - delegate(container, 'a', 'click', spy, true); delegate(container, 'a', 'click', spy, {capture: true}); // Once diff --git a/delegate.ts b/delegate.ts index 4c843c2..218db3a 100644 --- a/delegate.ts +++ b/delegate.ts @@ -1,6 +1,6 @@ import type {ParseSelector} from 'typed-query-selector/parser.d.js'; -export type DelegateOptions = boolean | AddEventListenerOptions; +export type DelegateOptions = AddEventListenerOptions; export type EventType = keyof GlobalEventHandlersEventMap; export type DelegateEventHandler< @@ -15,12 +15,6 @@ export type DelegateEvent< delegateTarget: TElement; }; -export function castAddEventListenerOptions( - options: DelegateOptions | undefined, -): AddEventListenerOptions { - return typeof options === 'object' ? options : {capture: options}; -} - /** Keeps track of raw listeners added to the base elements to avoid duplication */ const ledger = new WeakMap< EventTarget, @@ -106,18 +100,16 @@ function delegate< selector: string, type: TEventType, callback: DelegateEventHandler, - options?: DelegateOptions, + options: DelegateOptions = {}, ): void { - const listenerOptions = castAddEventListenerOptions(options); - - const {signal} = listenerOptions; + const {signal} = options; if (signal?.aborted) { return; } // Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it` - const {once, ...nativeListenerOptions} = listenerOptions; + 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; diff --git a/one-event.ts b/one-event.ts index ba6fa4a..a0b2ea6 100644 --- a/one-event.ts +++ b/one-event.ts @@ -1,6 +1,5 @@ import type {ParseSelector} from 'typed-query-selector/parser.d.js'; import delegate, { - castAddEventListenerOptions, type DelegateEvent, type DelegateOptions, type EventType, @@ -38,17 +37,16 @@ async function oneEvent< base: EventTarget, selector: string, type: TEventType, - options?: DelegateOptions, + options: DelegateOptions = {}, ): Promise | undefined> { return new Promise(resolve => { - const listenerOptions = castAddEventListenerOptions(options); - listenerOptions.once = true; + options.once = true; - if (listenerOptions.signal?.aborted) { + if (options.signal?.aborted) { resolve(undefined); } - listenerOptions.signal?.addEventListener('abort', () => { + options.signal?.addEventListener('abort', () => { resolve(undefined); }); @@ -58,7 +56,7 @@ async function oneEvent< type, // @ts-expect-error Seems to work fine resolve, - listenerOptions, + options, ); }); } diff --git a/readme.md b/readme.md index a1e9a28..7cbe967 100644 --- a/readme.md +++ b/readme.md @@ -5,7 +5,7 @@ > Lightweight event delegation -This is a fork of the popular [`delegate`](https://github.com/zenorocha/delegate) with some improvements: +This is a fork of the popular but abandoned [`delegate`](https://github.com/zenorocha/delegate) with some improvements: - modern: ES2021, TypeScript, Edge 16+ (it uses `WeakMap` and `Element.closest()`) - idempotent: identical listeners aren't added multiple times, just like the native `addEventListener` @@ -29,22 +29,15 @@ import delegate from 'delegate-it'; ### Add event delegation -#### With an element as base - ```js delegate(document, '.btn', 'click', event => { console.log(event.delegateTarget); }); ``` -#### With listener options +### With listener options ```js -delegate(document, '.btn', 'click', event => { - console.log(event.delegateTarget); -}, true); - -// Or equivalent: delegate(document, '.btn', 'click', event => { console.log(event.delegateTarget); }, { @@ -65,7 +58,7 @@ delegate(document, '.btn', 'click', event => { controller.abort(); ``` -#### Listen to one event only +### Listen to one event only ```js delegate(document, '.btn', 'click', event => { @@ -75,7 +68,7 @@ delegate(document, '.btn', 'click', event => { }); ``` -#### Listen to one event only, with a promise +### Listen to one event only, with a promise ```js import {oneEvent} from 'delegate-it'; @@ -84,7 +77,7 @@ await oneEvent(document, '.btn', 'click'); console.log('The body was clicked'); ``` -### Custom event types in Typescript +## TypeScript If you're using TypeScript and have event types that are custom, you can override the global `GlobalEventHandlersEventMap` interface via declaration merging. e.g. say you have a `types/globals.d.ts` file, you can add the following. From 247ac8757e70753d86bdfea8bb698d5a1718b0c2 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Tue, 25 Apr 2023 19:57:19 +0800 Subject: [PATCH 09/11] Move `base` to options (#46) --- delegate.test.ts | 38 +++++++++++++++++++------------------- delegate.ts | 7 ++----- one-event.test.ts | 8 ++++---- one-event.ts | 4 ---- readme.md | 22 +++++++++++++++++----- vitest.setup.ts | 2 +- 6 files changed, 43 insertions(+), 38 deletions(-) diff --git a/delegate.test.ts b/delegate.test.ts index 54b15be..aa68b09 100644 --- a/delegate.test.ts +++ b/delegate.test.ts @@ -1,17 +1,17 @@ import {test, vi, expect} from 'vitest'; -import {container, anchor} from './vitest.setup.js'; +import {base, anchor} from './vitest.setup.js'; import delegate from './delegate.js'; test('should add an event listener', () => { const spy = vi.fn(); - delegate(container, 'a', 'click', spy); + delegate('a', 'click', spy); anchor.click(); expect(spy).toHaveBeenCalledTimes(1); }); test('should handle events on text nodes', () => { const spy = vi.fn(); - delegate(container, 'a', 'click', spy); + delegate('a', 'click', spy); anchor.firstChild!.dispatchEvent(new MouseEvent('click', {bubbles: true})); expect(spy).toHaveBeenCalledTimes(1); }); @@ -19,7 +19,7 @@ test('should handle events on text nodes', () => { test('should remove an event listener', () => { const spy = vi.fn(); const controller = new AbortController(); - delegate(container, 'a', 'click', spy, {signal: controller.signal}); + delegate('a', 'click', spy, {signal: controller.signal}); controller.abort(); anchor.click(); @@ -28,7 +28,7 @@ test('should remove an event listener', () => { test('should not add an event listener of the controller has already aborted', () => { const spy = vi.fn(); - delegate(container, 'a', 'click', spy, {signal: AbortSignal.abort()}); + delegate('a', 'click', spy, {signal: AbortSignal.abort()}); anchor.click(); expect(spy).toHaveBeenCalledTimes(0); @@ -36,15 +36,15 @@ test('should not add an event listener of the controller has already aborted', ( test('should not fire when the selector matches an ancestor of the base element', () => { const spy = vi.fn(); - delegate(container, 'body', 'click', spy); + delegate('body', 'click', spy, {base}); anchor.click(); expect(spy).toHaveBeenCalledTimes(0); }); test('should not add an event listener when passed an already aborted signal', () => { - const spy = vi.spyOn(container, 'addEventListener'); - delegate(container, 'a', 'click', () => ({}), {signal: AbortSignal.abort()}); + const spy = vi.spyOn(base, 'addEventListener'); + delegate('a', 'click', () => ({}), {base, signal: AbortSignal.abort()}); anchor.click(); expect(spy).toHaveBeenCalledTimes(0); @@ -52,9 +52,9 @@ test('should not add an event listener when passed an already aborted signal', ( test('should call the listener once with the `once` option', () => { const spy = vi.fn(); - delegate(container, 'a', 'click', spy, {once: true}); + delegate('a', 'click', spy, {base, once: true}); - container.click(); + base.click(); expect(spy).toHaveBeenCalledTimes(0); // It should not be called on the container anchor.click(); expect(spy).toHaveBeenCalledTimes(1); // It should be called on the delegate target @@ -69,16 +69,16 @@ test('should add a specific event listener only once', () => { // https://github.com/fregante/delegate-it/pull/11#discussion_r285481625 // Capture: false - delegate(container, 'a', 'click', spy); - delegate(container, 'a', 'click', spy, {passive: true}); - delegate(container, 'a', 'click', spy, {capture: false}); + delegate('a', 'click', spy); + delegate('a', 'click', spy, {passive: true}); + delegate('a', 'click', spy, {capture: false}); // Capture: true - delegate(container, 'a', 'click', spy, {capture: true}); + delegate('a', 'click', spy, {capture: true}); // Once - delegate(container, 'a', 'click', spy, {once: true}); - delegate(container, 'a', 'click', spy, {once: false}); + delegate('a', 'click', spy, {once: true}); + delegate('a', 'click', spy, {once: false}); anchor.click(); expect(spy).toHaveBeenCalledTimes(2); @@ -86,10 +86,10 @@ test('should add a specific event listener only once', () => { test('should deduplicate identical listeners added after `once:true`', () => { const spy = vi.fn(); - delegate(container, 'a', 'click', spy, {once: true}); - delegate(container, 'a', 'click', spy, {once: false}); + delegate('a', 'click', spy, {once: true}); + delegate('a', 'click', spy, {once: false}); - container.click(); + base.click(); expect(spy).toHaveBeenCalledTimes(0); // It should not be called on the container anchor.click(); expect(spy).toHaveBeenCalledTimes(1); // It should be called on the delegate target diff --git a/delegate.ts b/delegate.ts index 218db3a..0ea558b 100644 --- a/delegate.ts +++ b/delegate.ts @@ -1,6 +1,6 @@ import type {ParseSelector} from 'typed-query-selector/parser.d.js'; -export type DelegateOptions = AddEventListenerOptions; +export type DelegateOptions = AddEventListenerOptions & {base?: EventTarget}; export type EventType = keyof GlobalEventHandlersEventMap; export type DelegateEventHandler< @@ -73,7 +73,6 @@ function delegate< TElement extends Element = ParseSelector, TEventType extends EventType = EventType, >( - base: EventTarget, selector: Selector, type: TEventType, callback: DelegateEventHandler, @@ -84,7 +83,6 @@ function delegate< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( - base: EventTarget, selector: string, type: TEventType, callback: DelegateEventHandler, @@ -96,13 +94,12 @@ function delegate< TElement extends Element, TEventType extends EventType = EventType, >( - base: EventTarget, selector: string, type: TEventType, callback: DelegateEventHandler, options: DelegateOptions = {}, ): void { - const {signal} = options; + const {signal, base = document} = options; if (signal?.aborted) { return; diff --git a/one-event.test.ts b/one-event.test.ts index a153c77..7a491d9 100644 --- a/one-event.test.ts +++ b/one-event.test.ts @@ -1,9 +1,9 @@ import {test, expect} from 'vitest'; -import {container, anchor} from './vitest.setup.js'; +import {anchor} from './vitest.setup.js'; import oneEvent from './one-event.js'; test('should resolve after one event', async t => { - const promise = oneEvent(container, 'a', 'click'); + const promise = oneEvent('a', 'click'); anchor.click(); const event = await promise; expect(event).toBeInstanceOf(MouseEvent); @@ -11,7 +11,7 @@ test('should resolve after one event', async t => { test('should resolve with `undefined` after it’s aborted', async t => { const controller = new AbortController(); - const promise = oneEvent(container, 'a', 'click', {signal: controller.signal}); + const promise = oneEvent('a', 'click', {signal: controller.signal}); controller.abort(); const event = await promise; @@ -19,7 +19,7 @@ test('should resolve with `undefined` after it’s aborted', async t => { }); test('should resolve with `undefined` if the signal has already aborted', async t => { - const promise = oneEvent(container, 'a', 'click', {signal: AbortSignal.abort()}); + const promise = oneEvent('a', 'click', {signal: AbortSignal.abort()}); const event = await promise; expect(event).toBeUndefined(); }); diff --git a/one-event.ts b/one-event.ts index a0b2ea6..d07ae32 100644 --- a/one-event.ts +++ b/one-event.ts @@ -13,7 +13,6 @@ async function oneEvent< TElement extends Element = ParseSelector, TEventType extends EventType = EventType, >( - base: EventTarget, selector: Selector, type: TEventType, options?: DelegateOptions @@ -23,7 +22,6 @@ async function oneEvent< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( - base: EventTarget, selector: string, type: TEventType, options?: DelegateOptions @@ -34,7 +32,6 @@ async function oneEvent< TElement extends Element, TEventType extends EventType = EventType, >( - base: EventTarget, selector: string, type: TEventType, options: DelegateOptions = {}, @@ -51,7 +48,6 @@ async function oneEvent< }); delegate( - base, selector, type, // @ts-expect-error Seems to work fine diff --git a/readme.md b/readme.md index 7cbe967..fe07dd0 100644 --- a/readme.md +++ b/readme.md @@ -30,7 +30,7 @@ import delegate from 'delegate-it'; ### Add event delegation ```js -delegate(document, '.btn', 'click', event => { +delegate('.btn', 'click', event => { console.log(event.delegateTarget); }); ``` @@ -38,18 +38,30 @@ delegate(document, '.btn', 'click', event => { ### With listener options ```js -delegate(document, '.btn', 'click', event => { +delegate('.btn', 'click', event => { console.log(event.delegateTarget); }, { capture: true }); ``` +### On a custom base + +Use this option if you don't want to have a global listener attached on `html`, it improves performance: + +```js +delegate('.btn', 'click', event => { + console.log(event.delegateTarget); +}, { + base: document.querySelector('main') +}); +``` + ### Remove event delegation ```js const controller = new AbortController(); -delegate(document, '.btn', 'click', event => { +delegate('.btn', 'click', event => { console.log(event.delegateTarget); }, { signal: controller.signal, @@ -61,7 +73,7 @@ controller.abort(); ### Listen to one event only ```js -delegate(document, '.btn', 'click', event => { +delegate('.btn', 'click', event => { console.log('This will only be called once'); }, { once: true @@ -73,7 +85,7 @@ delegate(document, '.btn', 'click', event => { ```js import {oneEvent} from 'delegate-it'; -await oneEvent(document, '.btn', 'click'); +await oneEvent('.btn', 'click'); console.log('The body was clicked'); ``` diff --git a/vitest.setup.ts b/vitest.setup.ts index 0505ddc..11ab9d2 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -17,5 +17,5 @@ global.Document = window.Document; global.MouseEvent = window.MouseEvent; global.AbortController = window.AbortController; global.document = window.document; -export const container = window.document.querySelector('ul')!; +export const base = window.document.querySelector('ul')!; export const anchor = window.document.querySelector('a')!; From b883c236535e54c36bd7eb575a652d808d7c31e8 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Tue, 25 Apr 2023 19:58:34 +0800 Subject: [PATCH 10/11] Set target to ES2022 No changes at this point --- readme.md | 2 +- tsconfig.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index fe07dd0..05d89f4 100644 --- a/readme.md +++ b/readme.md @@ -7,7 +7,7 @@ This is a fork of the popular but abandoned [`delegate`](https://github.com/zenorocha/delegate) with some improvements: -- modern: ES2021, TypeScript, Edge 16+ (it uses `WeakMap` and `Element.closest()`) +- modern: ES2022, 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) diff --git a/tsconfig.json b/tsconfig.json index 466046c..9966a6a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { - "target": "es2021" + "target": "es2022" }, "files": [ "index.ts", From 83dc31bba7a0aeaa666eb1aa234161da43d53ef5 Mon Sep 17 00:00:00 2001 From: Federico Brigante Date: Tue, 25 Apr 2023 19:59:16 +0800 Subject: [PATCH 11/11] 6.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1ca0afa..930d48a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "delegate-it", - "version": "5.0.0", + "version": "6.0.0", "description": "Lightweight and modern event delegation in the browser", "keywords": [ "delegate",