Skip to content

Commit

Permalink
Add promised oneEvent listener (#41)
Browse files Browse the repository at this point in the history
  • Loading branch information
fregante authored Apr 22, 2023
1 parent 39dcce7 commit 5697313
Show file tree
Hide file tree
Showing 10 changed files with 317 additions and 204 deletions.
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
node_modules
index.js
index.d.ts
*.js
!*.test.js
!*.setup.js
*.d.ts
*.map
21 changes: 21 additions & 0 deletions ava.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {JSDOM} from 'jsdom';

const {window} = new JSDOM(`
<ul>
<li><a>Item 1</a></li>
<li><a>Item 2</a></li>
<li><a>Item 3</a></li>
<li><a>Item 4</a></li>
<li><a>Item 5</a></li>
</ul>
`);

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');
32 changes: 4 additions & 28 deletions test.js → delegate.test.js
Original file line number Diff line number Diff line change
@@ -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(`
<ul>
<li><a>Item 1</a></li>
<li><a>Item 2</a></li>
<li><a>Item 3</a></li>
<li><a>Item 4</a></li>
<li><a>Item 5</a></li>
</ul>
`);

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);
Expand All @@ -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);
});
Expand Down
170 changes: 170 additions & 0 deletions delegate.ts
Original file line number Diff line number Diff line change
@@ -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<TEvent, TElement>) => 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<DelegateEventHandler, Set<string>>
>();

function editLedger(
wanted: boolean,
baseElement: EventTarget | Document,
callback: DelegateEventHandler<any, any>,
setup: string,
): boolean {
if (!wanted && !ledger.has(baseElement)) {
return false;
}

const elementMap
= ledger.get(baseElement)
?? new WeakMap<DelegateEventHandler, Set<string>>();
ledger.set(baseElement, elementMap);

const setups = elementMap.get(callback) ?? new Set<string>();
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<Element> | 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<Selector, HTMLElement>,
TEventType extends EventType = EventType,
>(
base: EventTarget | Document | Iterable<Element> | string,
selector: Selector,
type: TEventType,
callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEventType], TElement>,
options?: DelegateOptions
): void;

function delegate<
TElement extends Element = HTMLElement,
TEventType extends EventType = EventType,
>(
base: EventTarget | Document | Iterable<Element> | string,
selector: string,
type: TEventType,
callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEventType], TElement>,
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<Element> | string,
selector: string,
type: TEventType,
callback: DelegateEventHandler<GlobalEventHandlersEventMap[TEventType], TElement>,
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<GlobalEventHandlersEventMap[TEventType], TElement>);
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;
Loading

0 comments on commit 5697313

Please sign in to comment.