Skip to content

Commit 5697313

Browse files
authored
Add promised oneEvent listener (#41)
1 parent 39dcce7 commit 5697313

10 files changed

+317
-204
lines changed

Diff for: .gitignore

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
node_modules
2-
index.js
3-
index.d.ts
2+
*.js
3+
!*.test.js
4+
!*.setup.js
5+
*.d.ts
46
*.map

Diff for: ava.setup.js

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import {JSDOM} from 'jsdom';
2+
3+
const {window} = new JSDOM(`
4+
<ul>
5+
<li><a>Item 1</a></li>
6+
<li><a>Item 2</a></li>
7+
<li><a>Item 3</a></li>
8+
<li><a>Item 4</a></li>
9+
<li><a>Item 5</a></li>
10+
</ul>
11+
`);
12+
13+
global.Text = window.Text;
14+
global.Event = window.Event;
15+
global.Element = window.Element;
16+
global.Document = window.Document;
17+
global.MouseEvent = window.MouseEvent;
18+
global.AbortController = window.AbortController;
19+
global.document = window.document;
20+
export const container = window.document.querySelector('ul');
21+
export const anchor = window.document.querySelector('a');

Diff for: test.js renamed to delegate.test.js

+4-28
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,7 @@
11
import test from 'ava';
22
import sinon from 'sinon';
3-
import {JSDOM} from 'jsdom';
4-
import delegate from './index.js';
5-
6-
const {window} = new JSDOM(`
7-
<ul>
8-
<li><a>Item 1</a></li>
9-
<li><a>Item 2</a></li>
10-
<li><a>Item 3</a></li>
11-
<li><a>Item 4</a></li>
12-
<li><a>Item 5</a></li>
13-
</ul>
14-
`);
15-
16-
global.Text = window.Text;
17-
global.Event = window.Event;
18-
global.Element = window.Element;
19-
global.Document = window.Document;
20-
global.MouseEvent = window.MouseEvent;
21-
global.AbortController = window.AbortController;
22-
global.document = window.document;
23-
const container = window.document.querySelector('ul');
24-
const anchor = window.document.querySelector('a');
3+
import {container, anchor} from './ava.setup.js';
4+
import delegate from './delegate.js';
255

266
test.serial('should add an event listener', t => {
277
delegate(container, 'a', 'click', t.pass);
@@ -39,18 +19,14 @@ test.serial('should remove an event listener', t => {
3919
delegate(container, 'a', 'click', spy, {signal: controller.signal});
4020
controller.abort();
4121

42-
const anchor = document.querySelector('a');
4322
anchor.click();
4423
t.true(spy.notCalled);
4524
});
4625

47-
test.serial('should pass an AbortSignal to an event listener', t => {
26+
test.serial('should not add an event listener of the controller has already aborted', t => {
4827
const spy = sinon.spy();
49-
const controller = new AbortController();
50-
delegate(container, 'a', 'click', spy, {signal: controller.signal});
51-
controller.abort();
28+
delegate(container, 'a', 'click', spy, {signal: AbortSignal.abort()});
5229

53-
const anchor = document.querySelector('a');
5430
anchor.click();
5531
t.true(spy.notCalled);
5632
});

Diff for: delegate.ts

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import type {ParseSelector} from 'typed-query-selector/parser.d.js';
2+
3+
export type DelegateOptions = boolean | AddEventListenerOptions;
4+
export type EventType = keyof GlobalEventHandlersEventMap;
5+
6+
export type DelegateEventHandler<
7+
TEvent extends Event = Event,
8+
TElement extends Element = Element,
9+
> = (event: DelegateEvent<TEvent, TElement>) => void;
10+
11+
export type DelegateEvent<
12+
TEvent extends Event = Event,
13+
TElement extends Element = Element,
14+
> = TEvent & {
15+
delegateTarget: TElement;
16+
};
17+
18+
export function castAddEventListenerOptions(
19+
options: DelegateOptions | undefined,
20+
): AddEventListenerOptions {
21+
return typeof options === 'object' ? options : {capture: options};
22+
}
23+
24+
/** Keeps track of raw listeners added to the base elements to avoid duplication */
25+
const ledger = new WeakMap<
26+
EventTarget,
27+
WeakMap<DelegateEventHandler, Set<string>>
28+
>();
29+
30+
function editLedger(
31+
wanted: boolean,
32+
baseElement: EventTarget | Document,
33+
callback: DelegateEventHandler<any, any>,
34+
setup: string,
35+
): boolean {
36+
if (!wanted && !ledger.has(baseElement)) {
37+
return false;
38+
}
39+
40+
const elementMap
41+
= ledger.get(baseElement)
42+
?? new WeakMap<DelegateEventHandler, Set<string>>();
43+
ledger.set(baseElement, elementMap);
44+
45+
const setups = elementMap.get(callback) ?? new Set<string>();
46+
elementMap.set(callback, setups);
47+
48+
const existed = setups.has(setup);
49+
if (wanted) {
50+
setups.add(setup);
51+
} else {
52+
setups.delete(setup);
53+
}
54+
55+
return existed && wanted;
56+
}
57+
58+
function isEventTarget(
59+
elements: EventTarget | Document | Iterable<Element> | string,
60+
): elements is EventTarget {
61+
return typeof (elements as EventTarget).addEventListener === 'function';
62+
}
63+
64+
function safeClosest(event: Event, selector: string): Element | void {
65+
let target = event.target;
66+
if (target instanceof Text) {
67+
target = target.parentElement;
68+
}
69+
70+
if (target instanceof Element && event.currentTarget instanceof Element) {
71+
// `.closest()` may match ancestors of `currentTarget` but we only need its children
72+
const closest = target.closest(selector);
73+
if (closest && event.currentTarget.contains(closest)) {
74+
return closest;
75+
}
76+
}
77+
}
78+
79+
/**
80+
* Delegates event to a selector.
81+
* @param options A boolean value setting options.capture or an options object of type AddEventListenerOptions
82+
*/
83+
function delegate<
84+
Selector extends string,
85+
TElement extends Element = ParseSelector<Selector, HTMLElement>,
86+
TEventType extends EventType = EventType,
87+
>(
88+
base: EventTarget | Document | Iterable<Element> | string,
89+
selector: Selector,
90+
type: TEventType,
91+
callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEventType], TElement>,
92+
options?: DelegateOptions
93+
): void;
94+
95+
function delegate<
96+
TElement extends Element = HTMLElement,
97+
TEventType extends EventType = EventType,
98+
>(
99+
base: EventTarget | Document | Iterable<Element> | string,
100+
selector: string,
101+
type: TEventType,
102+
callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEventType], TElement>,
103+
options?: DelegateOptions
104+
): void;
105+
106+
// This type isn't exported as a declaration, so it needs to be duplicated above
107+
function delegate<
108+
TElement extends Element,
109+
TEventType extends EventType = EventType,
110+
>(
111+
base: EventTarget | Document | Iterable<Element> | string,
112+
selector: string,
113+
type: TEventType,
114+
callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEventType], TElement>,
115+
options?: DelegateOptions,
116+
): void {
117+
const listenerOptions = castAddEventListenerOptions(options);
118+
119+
const {signal} = listenerOptions;
120+
121+
if (signal?.aborted) {
122+
return;
123+
}
124+
125+
// Handle Selector-based usage
126+
if (typeof base === 'string') {
127+
base = document.querySelectorAll(base);
128+
}
129+
130+
// Handle Array-like based usage
131+
if (!isEventTarget(base)) {
132+
for (const element of base) {
133+
delegate(element, selector, type, callback, listenerOptions);
134+
}
135+
136+
return;
137+
}
138+
139+
// Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it`
140+
const {once, ...nativeListenerOptions} = listenerOptions;
141+
142+
// `document` should never be the base, it's just an easy way to define "global event listeners"
143+
const baseElement = base instanceof Document ? base.documentElement : base;
144+
145+
// Handle the regular Element usage
146+
const capture = Boolean(typeof options === 'object' ? options.capture : options);
147+
const listenerFn = (event: Event): void => {
148+
const delegateTarget = safeClosest(event, selector);
149+
if (delegateTarget) {
150+
const delegateEvent = Object.assign(event, {delegateTarget});
151+
callback.call(baseElement, delegateEvent as DelegateEvent<GlobalEventHandlersEventMap[TEventType], TElement>);
152+
if (once) {
153+
baseElement.removeEventListener(type, listenerFn, nativeListenerOptions);
154+
editLedger(false, baseElement, callback, setup);
155+
}
156+
}
157+
};
158+
159+
const setup = JSON.stringify({selector, type, capture});
160+
const isAlreadyListening = editLedger(true, baseElement, callback, setup);
161+
if (!isAlreadyListening) {
162+
baseElement.addEventListener(type, listenerFn, nativeListenerOptions);
163+
}
164+
165+
signal?.addEventListener('abort', () => {
166+
editLedger(false, baseElement, callback, setup);
167+
});
168+
}
169+
170+
export default delegate;

0 commit comments

Comments
 (0)