Event delegation for browser DOM events. Flexible, cross-browser compatible and Typescript-focused.
Featuring full type inference of the event type, the delegating descendants and the event's currentTarget.
Install the package with npm, and then import the default export:
$ npm install @jjwesterkamp/event-delegation --save
import EventDelegation from '@jjwesterkamp/event-delegation'
UMD bundles are also included in the npm package, you can load them from any CDN that lists npm packages. For example:
<!-- Both URLs below point to the minified UMD bundle (v2 range), the long URL version includes sourcemaps support -->
<script src="https://cdn.jsdelivr.net/npm/@jjwesterkamp/event-delegation@2"></script>
<script src="https://cdn.jsdelivr.net/npm/@jjwesterkamp/event-delegation@2/umd/event-delegation.min.js"></script>
<!-- For a non-minified version: -->
<script src="https://cdn.jsdelivr.net/npm/@jjwesterkamp/event-delegation@2/umd/event-delegation.js"></script>
There are three main functions on the EventDelegation namespace object. All methods are used to start an event listener through the same kind of builder-pattern.
The global()
method is used to attach a global listener on the top-level document.body
element.
The within()
method is used to provide one alternative root element for the listener.
EventDelegation.withinMany(roots)
The withinMany()
method is used to provide multiple alternative roots for creating many listeners at once.
The build process has the following 4 steps in the following order, ultimately returning one single or multiple
EventHandler
instances, depending on the called method:
AskRoot
=> AskEvent
=> AskSelector
=> AskListener
=> EventHandler
First, ask for a root, then an event name, then a descendant selector, and finally a listener callback.
// Pseudo
EventDelegation.global(): AskEvent
The following examples use the global()
method that attaches an event listener globally to document.body
.
The returned builder ultimately creates an EventHandler<HTMLElement>
where HTMLElement
is the type of the root document.body
.
const handler = EventDelegation
.global()
.events('click')
.select('button')
.listen(function(event) {
this.classList.add('button--clicked')
})
Listener callbacks
Inside the event listener callback, this
is the element that matched "button"
. In order for this-binding to work, listener
must be a
regular function. For cases where arrow functions are preferred, the event argument provides an additional property delegator
as an alternative:
EventDelegation
// ...
.listen((event) => event.delegator.classList.add('button--clicked'))
Type inference
This builder pattern allows for automatic type inference of all type information. Each of the above steps implements an interface with multiple overloads. Methods that take CSS selectors will attempt
to parse the selectors and infer the element type from them. The inferred types are then automatically
known in the listener callback provided in the .listen()
step.
In the above example the event type is automatically identified as MouseEvent
, and event.delegator
(or this
) is
identified as HTMLButtonElement
.
Supports complex CSS selectors
Thanks to the great package typed-query-selector you can even supply
complex CSS selectors and the type will automatically be inferred if the selectors are tag-qualified and valid.
Even grouping selectors are supported, hence in the example below event.delegator
is the union type
HTMLButtonElement | HTMLInputElement
:
EventDelegation
.global()
.events('click')
.select('#my-div > button.submit, fieldset input.submit')
.listen((event) => { ... })
// event is DelegationEvent<HTMLButtonElement | HTMLInputElement, MouseEvent, HTMLElement>
Event instance types
DelegationEvent<D, E, R>
- This is the actual type of events passed to the listener functions. It has the type parameters D
for delegator, E
for event and R
for root.
In the above example this means that:
D
- event.delegator
(and this
in regular functions) is HTMLButtonElement | HTMLInputElement
E
- event
is of type MouseEvent
, the type of click eventsR
- event.currentTarget
is of type HTMLElement
, the type of the body elementDefault types
The element types will default to Element
for CSS selectors that are not tag-qualified or are invalid.
See the section Selector matching failure / custom selectors further down for details about overriding
these default types.
EventDelegation
.global()
.events('click')
.select('#my-div > .submit-button, fieldset iput.submit')
// ------------------------ --------------------
// not tag-qualified invalid (iput)
.listen((event) => { ... })
// event is DelegationEvent<Element, MouseEvent, HTMLElement>
// Pseudo
EventDelegation.within(root: Element | string): AskEvent
Alternatively you can add event listeners to other elements with the within
method. It takes either an
element or a selector.
Using elements
In the case of an element its type is preserved and ultimately an EventHandler<T>
is returned:
declare const myRoot: HTMLFormElement
// EventHandler<HTMLFormElement>
const handler = EventDelegation
.within(myRoot)
.events('click')
.select('button')
.listen((event) => { ... })
Using selectors
In the case of a selector the type is inferred from the given string just as with the .select()
method.
If the root
is a selector, within()
will create one single handler for the first matching element.
It will throw an error if the selector is invalid or if no matching root element is found.
const handler = EventDelegation
.within('form#my-form')
.events('click')
.select('button')
.listen((event) => { ... })
// handler is EventHandler<HTMLFormElement>
// Pseudo
EventDelegation.withinMany(roots: Element[] | string): AskEvent
Finally you can add multiple listeners to many root elements at once. Similarly to within()
, withinMany()
takes either a selector or an array of root element references.
Using selectors
When passing a selector the element type is inferred from the given string if possible. The return value of the listen()
call will be an array of EventHandler<T>
's:
const handlers = EventDelegation
.withinMany('form.my-form')
.events('click')
.select('button')
.listen((event) => { ... })
// event.currentTarget is HTMLFormElement
// handlers is EventHandler<HTMLFormElement>[]
It also copes with complex selectors and grouping selectors that target more than one element type:
const handlers = EventDelegation
.withinMany('form.my-form, #article fieldset')
.events('click')
.select('button')
.listen((event) => { ... })
// event.currentTarget is HTMLFormElement | HTMLFieldSetElement
// handlers is EventHandler<HTMLFormElement | HTMLFieldSetElement>[]
Using elements
Just as with within()
you can pass withinMany()
element references. It takes an array of elements,
and will carry their types along to give full knowledge about the possible types of event.currentTarget
and the created event handlers:
declare const myForm: HTMLFormElement
declare const myFieldset: HTMLFieldSetElement
const handlers = EventDelegation
.withinMany([myForm, myFieldset])
.events('click')
.select('button')
.listen((event) => { ... })
// event.currentTarget is HTMLFormElement | HTMLFieldSetElement
// handlers is EventHandler<HTMLFormElement | HTMLFieldSetElement>[]
All three creation methods return EventHandler instances, which has the following shape:
interface EventHandler<R extends Element> {
isAttached(): boolean
isDestroyed(): boolean
root(): R
eventType(): string
selector(): string
remove(): void
}
The event handler instance exists primarily to later remove the listener:
handler.remove()
It additionally has some methods that might be useful, among which selector()
, eventType()
and root()
providing the input parameters of creation.
isAttached()
will tell whether the listener is active. It'll always return true
until the listener is removed.
isDestroyed()
is the opposite of isAttached()
, and returns false
until the listener is removed.
Methods that take CSS-style selectors might fail to successfully infer an element type for them at some point. It might be an error in the selector syntax itself, but might also be a bug in this package. Another case where the default selector matching fails are selectors for custom web components. For such cases, all methods that take selectors have one final signature overload as a last resort to not ruin your day. They take an explicit type argument for the element type, and any string as their selector argument:
const handler = EventDelegation
.within<CustomComponent>('custom-component')
.events('click')
.select<CustomButton>('custom-button')
.listen((event) => { ... })
// event is DelegationEvent<CustomButton, MouseEvent, CustomComponent>
// handler is EventHandler<CustomComponent>
When using custom event names the event types will by default be considered the base type Event
. You can
however append definitions for your custom events to the GlobalEventHandlersEventMap
:
type MyEvent = Event & { foo: string }
declare global {
interface GlobalEventHandlersEventMap {
'my:event': MyEvent
}
}
EventDelegation
.global()
.events('my:event')
.select('td')
.listen((event) => { console.log(event.foo) }) // works
// event is DelegationEvent<HTMLTableDataCellElement, MyEvent, HTMLElement>
If you do not want to add declarations to the global event map you can alternatively just provide the event type as a type argument:
type MyEvent = Event & { foo: string }
EventDelegation
.global()
.events<MyEvent>('my:event')
.select('td')
.listen((event) => { console.log(event.foo) }) // works too
// event is DelegationEvent<HTMLTableDataCellElement, MyEvent, HTMLElement>
once: true
If you give the listener-option once: true
to addEventListener calls the listener will automatically be removed
after being called once. Since with event delegation events are filtered on the condition of a CSS-selector match
against any ancestor of a respective event target, this package will actually replace once: true
options with
once: false
. This prevents that an event-delegation initialisation turns into a no-op because of events that don't
match. It will then remove the event listener manually after the first matching event occured.
When working in Javascript you can't provide explicit type arguments for function calls. Most typescript-savvy editors
will still give (near) perfect type completion for all cases where the types are
inferred, such as when using tag selectors and standard event names such as 'click'
. This is also true when passing
existing element references if they have the correct type in advance.
In case of non-recognised CSS selectors element types will most of the time default to Element
. In the example below
x
will probably be considered an Element
, as well as e.delegator
:
const handler = EventDelegation
.within('custom-component')
.events('click')
.select('custom-button')
.listen((e) => { ... })
const x = handler.root()
The MIT License (MIT). See license file for more information.