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" ] }