pax_global_header00006660000000000000000000000064151750556340014525gustar00rootroot0000000000000052 comment=81d2037c62d957425eb77e5cd3ad9874ba0047f1 fregante-delegate-it-39afafd/000077500000000000000000000000001517505563400162615ustar00rootroot00000000000000fregante-delegate-it-39afafd/.editorconfig000066400000000000000000000006221517505563400207360ustar00rootroot00000000000000# EditorConfig helps developers define and maintain consistent # coding styles between different editors and IDEs # http://editorconfig.org root = true [*] # Change these settings to your own preference indent_style = tab # We recommend you to keep these unchanged end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.md] trim_trailing_whitespace = false fregante-delegate-it-39afafd/.github/000077500000000000000000000000001517505563400176215ustar00rootroot00000000000000fregante-delegate-it-39afafd/.github/workflows/000077500000000000000000000000001517505563400216565ustar00rootroot00000000000000fregante-delegate-it-39afafd/.github/workflows/ci.yml000066400000000000000000000006661517505563400230040ustar00rootroot00000000000000name: Test on: - pull_request - push jobs: Lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: npm install - run: npx xo Build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: npm install - run: npm run build Test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - run: npm install - run: npx vitest fregante-delegate-it-39afafd/.gitignore000066400000000000000000000000661517505563400202530ustar00rootroot00000000000000node_modules *.js !*.test.js !*.setup.js *.d.ts *.map fregante-delegate-it-39afafd/.npmrc000066400000000000000000000000231517505563400173740ustar00rootroot00000000000000package-lock=false fregante-delegate-it-39afafd/delegate.test.ts000066400000000000000000000101331517505563400213570ustar00rootroot00000000000000import {test, vi, expect} from 'vitest'; import {base, anchor, custom} from './vitest.setup.js'; import delegate from './delegate.js'; test('should add an event listener', () => { const spy = vi.fn(); delegate('a', 'click', spy); anchor.click(); expect(spy).toHaveBeenCalledTimes(1); }); test('should handle events on text nodes', () => { const spy = vi.fn(); delegate('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('a', 'click', spy, {signal: controller.signal}); controller.abort(); anchor.click(); expect(spy).toHaveBeenCalledTimes(0); }); test('should handle multiple selectors', () => { const spy = vi.fn(); delegate(['a', 'b'], 'click', spy); anchor.click(); expect(spy).toHaveBeenCalledTimes(1); }); test('should not add an event listener of the controller has already aborted', () => { const spy = vi.fn(); delegate('a', 'click', spy, {signal: AbortSignal.abort()}); 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('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(base, 'addEventListener'); delegate('a', 'click', () => ({}), {base, signal: AbortSignal.abort()}); anchor.click(); expect(spy).toHaveBeenCalledTimes(0); }); test('should call the listener once with the `once` option', () => { const spy = vi.fn(); delegate('a', 'click', spy, {base, once: true}); 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 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('a', 'click', spy); delegate('a', 'click', spy, {passive: true}); delegate('a', 'click', spy, {capture: false}); // Capture: true delegate('a', 'click', spy, {capture: true}); // Once delegate('a', 'click', spy, {once: true}); delegate('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('a', 'click', spy, {once: true}); delegate('a', 'click', spy, {once: false}); 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 anchor.click(); expect(spy).toHaveBeenCalledTimes(1); // It should not be called again on the delegate target }); test('should allow using a ShadowRoot as delegate target', () => { custom.clickLinks(); expect(custom.linksClicked).toBe(2); }); test('should handle multiple event types', () => { const spy = vi.fn(); delegate('a', ['click', 'keypress'], spy); anchor.click(); anchor.dispatchEvent(new KeyboardEvent('keypress', {bubbles: true})); expect(spy).toHaveBeenCalledTimes(2); }); test('should deduplicate listeners across multiple event types', () => { const spy = vi.fn(); delegate('a', ['click', 'keypress'], spy); delegate('a', ['click', 'keypress'], spy); anchor.click(); expect(spy).toHaveBeenCalledTimes(1); }); test('should remove multiple event type listeners via signal', () => { const spy = vi.fn(); const controller = new AbortController(); delegate('a', ['click', 'keypress'], spy, {signal: controller.signal}); controller.abort(); anchor.click(); anchor.dispatchEvent(new KeyboardEvent('keypress', {bubbles: true})); expect(spy).toHaveBeenCalledTimes(0); }); fregante-delegate-it-39afafd/delegate.ts000066400000000000000000000112201517505563400203770ustar00rootroot00000000000000import type {ParseSelector} from 'typed-query-selector/parser.d.js'; export type DelegateOptions = AddEventListenerOptions & {base?: EventTarget}; 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, 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 safeClosest(event: Event, selector: string): Element | void { let target = event.target; if (target instanceof Text) { target = target.parentElement; } // The currentTarget could be an Element or e.g. a ShadowRoot if (target instanceof Element && event.currentTarget instanceof Node) { // `.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, >( selector: Selector | readonly Selector[], type: TEventType | readonly TEventType[], callback: DelegateEventHandler, options?: DelegateOptions ): void; function delegate< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( selector: string | readonly string[], type: TEventType | readonly 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, >( selector: string | readonly string[], type: TEventType | readonly TEventType[], callback: DelegateEventHandler, options: DelegateOptions = {}, ): void { if (Array.isArray(type)) { for (const t of type as readonly TEventType[]) { delegate(selector, t, callback, options); } return; } // After the Array.isArray early-return above, `type` is a single event type string. // TypeScript doesn't narrow generic union types through Array.isArray, so we cast here. const singleType = type as TEventType; const {signal, base = document} = options; if (signal?.aborted) { return; } // Don't pass `once` to `addEventListener` because it needs to be handled in `delegate-it` 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; // Handle the regular Element usage const capture = Boolean(typeof options === 'object' ? options.capture : options); const listenerFunction = (event: Event): void => { const delegateTarget = safeClosest(event, String(selector)); if (delegateTarget) { const delegateEvent = Object.assign(event, {delegateTarget}); callback.call(baseElement, delegateEvent as DelegateEvent); if (once) { baseElement.removeEventListener(singleType, listenerFunction, nativeListenerOptions); editLedger(false, baseElement, callback, setup); } } }; const setup = JSON.stringify({selector, type: singleType, capture}); const isAlreadyListening = editLedger(true, baseElement, callback, setup); if (!isAlreadyListening) { baseElement.addEventListener(singleType, listenerFunction, nativeListenerOptions); } signal?.addEventListener('abort', () => { editLedger(false, baseElement, callback, setup); }); } export default delegate; fregante-delegate-it-39afafd/index.ts000066400000000000000000000002321517505563400177350ustar00rootroot00000000000000export * from './delegate.js'; export * from './one-event.js'; export {default} from './delegate.js'; export {default as oneEvent} from './one-event.js'; fregante-delegate-it-39afafd/license000066400000000000000000000021261517505563400176270ustar00rootroot00000000000000MIT License Copyright (c) Federico Brigante (https://fregante.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. fregante-delegate-it-39afafd/one-event.test.ts000066400000000000000000000032221517505563400215060ustar00rootroot00000000000000import {test, expect} from 'vitest'; import {anchor} from './vitest.setup.js'; import oneEvent from './one-event.js'; test('should resolve after one event', async t => { const promise = oneEvent('a', 'click'); anchor.click(); const event = await promise; expect(event).toBeInstanceOf(MouseEvent); }); test('should resolve with `undefined` after it’s aborted', async t => { const controller = new AbortController(); const promise = oneEvent('a', 'click', {signal: controller.signal}); controller.abort(); const event = await promise; expect(event).toBeUndefined(); }); test('should resolve with `undefined` if the signal has already aborted', async t => { const promise = oneEvent('a', 'click', {signal: AbortSignal.abort()}); const event = await promise; expect(event).toBeUndefined(); }); test('should accept an array of selectors', async t => { const promise = oneEvent(['a', 'b'], 'click'); anchor.click(); const event = await promise; expect(event).toBeInstanceOf(MouseEvent); }); test('should resolve only when filter returns true', async t => { let callCount = 0; const promise = oneEvent('a', 'click', { filter(event) { callCount++; return callCount >= 3; }, }); anchor.click(); anchor.click(); anchor.click(); const event = await promise; expect(event).toBeInstanceOf(MouseEvent); expect(callCount).toBe(3); }); test('should resolve with `undefined` when aborted before filter passes', async t => { const controller = new AbortController(); const promise = oneEvent('a', 'click', { signal: controller.signal, filter: () => false, }); controller.abort(); const event = await promise; expect(event).toBeUndefined(); }); fregante-delegate-it-39afafd/one-event.ts000066400000000000000000000044461517505563400205410ustar00rootroot00000000000000import type {ParseSelector} from 'typed-query-selector/parser.d.js'; import delegate, { type DelegateEvent, type DelegateOptions, type EventType, } from './delegate.js'; export type OneEventOptions< TEvent extends Event = Event, TElement extends Element = Element, > = Omit & { filter?: (event: DelegateEvent) => boolean; }; /** * 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, >( selector: Selector | Selector[], type: TEventType, options?: OneEventOptions ): Promise>; async function oneEvent< TElement extends Element = HTMLElement, TEventType extends EventType = EventType, >( selector: string | string[], type: TEventType, options?: OneEventOptions ): 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, >( selector: string | string[], type: TEventType, options: OneEventOptions = {}, ): Promise | undefined> { return new Promise(resolve => { const {filter, ...delegateOptions} = options; if (delegateOptions.signal?.aborted) { resolve(undefined); return; } if (filter) { const controller = new AbortController(); delegateOptions.signal?.addEventListener('abort', () => { controller.abort(); resolve(undefined); }); delegate( selector, type, event => { if (filter(event)) { controller.abort(); resolve(event); } }, {...delegateOptions, signal: controller.signal}, ); } else { delegateOptions.signal?.addEventListener('abort', () => { resolve(undefined); }); delegate( selector, type, resolve, {...delegateOptions, once: true}, ); } }); } export default oneEvent; fregante-delegate-it-39afafd/package.json000066400000000000000000000022711517505563400205510ustar00rootroot00000000000000{ "name": "delegate-it", "version": "6.4.0", "description": "Lightweight and modern event delegation in the browser", "keywords": [ "delegate", "browser", "dom", "live", "selector", "delegation", "chrome", "electron", "firefox", "safari", "event" ], "repository": "fregante/delegate-it", "funding": "https://github.com/sponsors/fregante", "license": "MIT", "author": "Federico Brigante (https://fregante.com)", "type": "module", "exports": "./index.js", "main": "./index.js", "types": "./index.d.ts", "files": [ "index.js", "index.d.ts", "delegate.js", "delegate.d.ts", "one-event.js", "one-event.d.ts" ], "scripts": { "build": "tsc", "prepack": "tsc --sourceMap false", "test": "tsc && xo && vitest run", "watch": "tsc --watch", "watch:test": "vitest" }, "xo": { "envs": [ "browser" ], "rules": { "max-params": "off", "@typescript-eslint/naming-convention": "off" } }, "dependencies": { "typed-query-selector": "^2.11.2" }, "devDependencies": { "@sindresorhus/tsconfig": "^5.0.0", "@types/jsdom": "^21.1.6", "jsdom": "^24.0.0", "typescript": "^5.4.2", "vitest": "^1.3.1", "xo": "^0.58.0" } } fregante-delegate-it-39afafd/readme.md000066400000000000000000000166761517505563400200600ustar00rootroot00000000000000# delegate-it [![][badge-gzip]][link-bundlephobia] [badge-gzip]: https://img.shields.io/bundlephobia/minzip/delegate-it.svg?label=gzipped [link-bundlephobia]: https://bundlephobia.com/result?p=delegate-it > Lightweight event delegation This is a fork of the popular but abandoned [`delegate`](https://github.com/zenorocha/delegate) with some improvements: - 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) ## Install ``` npm install delegate-it ``` ```js // This module is only offered as a ES Module import delegate from 'delegate-it'; ``` ## Usage ### Add event delegation ```js delegate('.btn', 'click', event => { console.log(event.delegateTarget); // The element matching '.btn' that was clicked }); ``` ### Multiple selectors or event types ```js // Listen to multiple selectors delegate(['.btn', '.link'], 'click', event => { console.log(event.delegateTarget); }); // Listen to multiple event types delegate('.btn', ['click', 'keypress'], event => { console.log(event.delegateTarget); }); ``` ### With listener options ```js 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('.btn', 'click', event => { console.log(event.delegateTarget); }, { signal: controller.signal, }); controller.abort(); ``` ### Listen to one event only ```js delegate('.btn', 'click', event => { console.log('This will only be called once'); }, { once: true }); ``` ### Listen to one event only, with a promise ```js import {oneEvent} from 'delegate-it'; const event = await oneEvent('.btn', 'click'); console.log(event.delegateTarget); // The element matching '.btn' that was clicked ``` ### Wait for a specific event with a filter ```js import {oneEvent} from 'delegate-it'; // Resolves only when a .btn with data-id="42" is clicked const event = await oneEvent('.btn', 'click', { filter: event => event.delegateTarget.dataset.id === '42', }); ``` ## API ### `delegate(selector, type, callback, options?)` Attaches a delegated event listener. The actual listener is added to the `base` element (defaults to `document.documentElement`) and the `callback` is only called when the event's target matches `selector`. Unlike raw `addEventListener`, identical listeners (same `selector`, `type`, `callback`, and `capture` value) are not added multiple times. #### `selector` Type: `string | string[]` A CSS selector string or array of CSS selector strings to match against. The `callback` is called when the event target (or one of its ancestors) matches the selector and is a descendant of `base`. #### `type` Type: `string | string[]` The event type (e.g. `'click'`) or array of event types to listen for. #### `callback` Type: `(event: DelegateEvent) => void` The function to call when the event is triggered. Receives a [`DelegateEvent`](#delegateevent) — a standard `Event` with an added `delegateTarget` property. #### `options` Type: `DelegateOptions` Optional object extending [`AddEventListenerOptions`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#options) with one extra field: | Option | Type | Description | |---|---|---| | `base` | `EventTarget` | The element to attach the listener to. Defaults to `document.documentElement`. Use a specific element for better performance. | | `capture` | `boolean` | Whether to use capture phase. Default: `false`. | | `once` | `boolean` | If `true`, the listener is removed after its first invocation. | | `signal` | `AbortSignal` | If provided, the listener is removed when the signal is aborted. | --- ### `oneEvent(selector, type, options?)` Returns a `Promise` that resolves with the first matching `DelegateEvent`. Useful as an alternative to `delegate` with `{once: true}`. If the signal is already aborted when `oneEvent` is called, or is aborted before the event fires, the promise resolves with `undefined`. ```js import {oneEvent} from 'delegate-it'; const event = await oneEvent('.btn', 'click'); // event is a DelegateEvent, or undefined if the signal was aborted ``` #### `selector` Type: `string | string[]` A CSS selector string or array of CSS selector strings. #### `type` Type: `string` The event type to listen for. #### `options` Type: `OneEventOptions` Same as `delegate` options, minus `once` (which is always set automatically), plus: | Option | Type | Description | |---|---|---| | `base` | `EventTarget` | The element to attach the listener to. Defaults to `document.documentElement`. | | `capture` | `boolean` | Whether to use capture phase. Default: `false`. | | `signal` | `AbortSignal` | If provided, the promise resolves with `undefined` when the signal is aborted. | | `filter` | `(event: DelegateEvent) => boolean` | If provided, the promise only resolves when this function returns `true`. Events that don't pass the filter are ignored and the listener stays active. | --- ### `DelegateEvent` A regular DOM [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event) extended with one additional property: #### `delegateTarget` Type: `Element` The element that matched the selector. This is different from `event.target`, which is the innermost element that was actually interacted with (e.g. a `` inside a `