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