@excom/event-handler
v0.1.4
Published
<event-handler> custom element
Maintainers
Readme
event-handler
Stitch behaviors of your app together, primarily through events.
<section>
<event-handler target-ref=":scope + content-drawer" command-name="--toggle">
Toggle drawer
</event-handler>
<content-drawer class="absolute">
<h2>Open!</h2>
</content-drawer>
</section>Features
- Fire custom events Map any input event, such as clicks, or lifecycles to custom output events
- Commands
command-nameinvokes the HTML Command API — built-in verbs (show-modal) and the--verbcommands elements accept (--submit,--close) - Retarget Aim events/commands at any selector (
target-ref) — where a<button commandfor>needs an id - Global / host listening Listen to events globally or on any element. Helpful for: escape to dismiss, global shortcuts, etc.
- Keycode filter Escape to dismiss, Shift+K shortcuts — keys / modifier chords
- Listen filters Debounce, selector, and pathname gates
- Custom Event Payloads
detail-*attributes and forms convert toevent.detailJSON
In a view that already has a
<quark-sheet>, the same wiring is a rule:@on click (target: "[data-add]") { @dispatch cart-add (detail: (sku: attr("data-sku"))); }—@onoptions coverselector-filter/keycode-filter/is-debounced/host-ref, and@dispatch/@commandcoverfire-event/target-ref/form-ref/command-name. Keep<event-handler>for markup without a sheet.
Installation
@excom/event-handler v0.1.4
pnpm add @excom/event-handlernpm install @excom/event-handleryarn add @excom/event-handlerImport
import "@excom/event-handler";Usage
Listen (default click), then either fire-event or command-name.
<event-handler fire-event="cart-add">
Add to cart
</event-handler>Or, for example: a successful form submit re-fetches the related data by invoking the provider's --fetch command.
<event-handler listen-for="super-form-success" target-ref="#fetch-todos" command-name="--fetch">
<super-form>
<form>
<!-- form to create a new todo -->
</form>
</super-form>
</event-handler>In a Nucleus Stack application, this will be one of the most heavily used elements. It is the primary method of linking a functional cause and effect.
API Reference
Attributes
| Name | Surface | Type | Default | Values | Description | Inherited from |
| --- | --- | --- | --- | --- | --- | --- |
| target-ref | option | string | | <CSS Selector> | Where outgoing events / mutations / commands apply. Unset = this element. Supports :scope for relative targeting (e.g. :scope ~ dialog). | |
| fire-event | option | tokenlist | | <EventName>… | Space-separated event names to dispatch on the target. Each name gets the same merged detail. Ignored when mutate-target is set. Pair with detail-* attributes for static detail fields. | |
| command-name | option | tokenlist | | <command>… | Space-separated commands to invoke on the target: custom --verb commands (--submit, --close) dispatch a command event, as a <button command commandfor> would; built-in verbs (show-modal, close, toggle-popover) run through the platform. | |
| not-bubbles | option | boolean | | | Outgoing events use bubbles: false (default: bubble). | |
| not-cancelable | option | boolean | | | Outgoing events use cancelable: false (default: cancelable). | |
| not-composed | option | boolean | | | Outgoing events use composed: false (default: composed / cross shadow roots). | |
| form-ref | option | string | | <CSS Selector> | <form> whose fields merge into event detail, or become attributes when mutate-target is set. | |
| mutate-target | option | boolean | | | @deprecated Write attributes on the target instead of firing events (sources: attr-* on this element plus form-ref fields). Use a Quark @on <event> { … } block instead — it writes State from the event without one element mutating another. Kept for compatibility; will be removed in a future major. | |
| host-ref | option | string | | <CSS Selector> | "window" | "document" | "html" | "body" | "head" | Listen on another element / window / document — e.g. Escape to dismiss a dialog from a global keydown. Defaults to :scope. Used with listen-for. Not compatible with listen-for-lifecycle. The selector MUST resolve when host-ref is set — it will not wait for a match to appear. | @excom/listenable-element |
| listen-for | option | tokenlist | | <EventName>… | Space-separated event names to listen for. Defaults to click when unset (and no lifecycle list is set). | @excom/listenable-element |
| listen-for-lifecycle | option | tokenlist | | "connected" | "disconnected" | "adopted" | Space-separated element lifecycles to handle. | @excom/listenable-element |
| listen-once | option | boolean | | | Handle each distinct event name / lifecycle at most once. | @excom/listenable-element |
| selector-filter | option | string | | <CSS Selector> | Only handle events whose event.target matches this CSS selector. Does not support :scope in the selector. | @excom/listenable-element |
| keycode-filter | option | tokenlist | | <key | mod+key>… | Space-separated key filters (OR). Join modifiers with + (AND, any order): shift+k tab → Shift+K or Tab. Modifiers: shift, alt, ctrl/control, meta/cmd. Name the space bar space / spacebar and the plus key plus (shift+space). Case-insensitive. | @excom/listenable-element |
| pathname-filter | option | tokenlist | | <pathname>… | Only handle when location.pathname is one of these values — route-aware behaviors without a separate router element. | @excom/listenable-element |
| prevent-default | option | boolean | | | Call preventDefault() on matched events (ignored for lifecycles). | @excom/listenable-element |
| stop-propagation | option | boolean | | | Call stopPropagation() on matched events (ignored for lifecycles). | @excom/listenable-element |
| stop-immediate-propagation | option | boolean | | | Call stopImmediatePropagation() on matched events (ignored for lifecycles). | @excom/listenable-element |
| vibrate-ms | option | number | "20 (when attribute is present with no value)" | | Vibrate on handle (navigator.vibrate). Empty / 0 uses a 20ms pulse. | @excom/listenable-element |
| delay-ms | option | number | | | Delay handling by this many milliseconds. | @excom/listenable-element |
| is-debounced | option | boolean | | | With delay-ms, coalesce bursts into one trailing call (debounce). | @excom/listenable-element |
CSS Classes
| Name | Description |
| --- | --- |
| .unstyled | Skip the pointer cursor from the listenable mixin. |
CSS Aliases
| Alias | Kind | Matches | Description |
| --- | --- | --- | --- |
| :--event-handler | element | event-handler, .tag-event-handler | |
Examples
Fire a custom event
Click dispatches cart-add event with { sku: "sku-1" } in event.detail.
<div>
<event-handler fire-event="cart-add" detail-sku="sku-1">
Add to cart
</event-handler>
<output>Waiting…</output>
<quark-sheet>
@use "quark:util" as *;
:scope {
@on cart-add { output { content: to-json(event.detail); } }
}
</quark-sheet>
</div>Open a dialog
command-name invokes the HTML Command API — here the built-in show-modal opens the sibling <dialog>. Custom commands (command-name="--close") reach any element that handles them, such as <content-drawer>, through a relative target-ref.
<section>
<event-handler target-ref=":scope + dialog" command-name="show-modal">
Open dialog
</event-handler>
<dialog>
Hello!
<form method="dialog"><button>Close</button></form>
</dialog>
</section>Close on Enter
keycode-filter gates keyboard handling — here Enter, focused in the
input, invokes the drawer's --close command (local / bubbling events only).
<section>
<event-handler target-ref=":scope + content-drawer" command-name="--toggle">
Toggle drawer
</event-handler>
<content-drawer id="demo-enter-close-drawer" is-open class="absolute">
<event-handler listen-for="keydown" keycode-filter="enter" target-ref="#demo-enter-close-drawer" command-name="--close">
<label>
Favorite color
<input type="text" placeholder="Type something, then press Enter to close drawer" autofocus />
</label>
</event-handler>
</content-drawer>
</section>Escape to dismiss (global)
host-ref="window" listens for keydown on the window — Escape closes
the dialog even when focus is outside it.
<section>
<event-handler target-ref=":scope ~ dialog" command-name="show-modal">
Open dialog
</event-handler>
<event-handler
host-ref="window"
listen-for="keydown"
keycode-filter="escape"
target-ref=":scope ~ dialog"
command-name="close"
></event-handler>
<dialog>
Press Escape anywhere to close.
<form method="dialog"><button>Close</button></form>
</dialog>
</section>Cancel a link click
prevent-default cancels the native action — here the wrapped <a> never navigates.
Use stop-propagation / stop-immediate-propagation the same way when you need to stop bubbling.
<event-handler prevent-default>
<a href="https://example.com">This native link does nothing</a>
</event-handler>Retarget
target-ref aims the outgoing event at another element — useful when the target sits outside of the bubble path.
<div>
<output>Waiting…</output>
<event-handler target-ref="output:has(+ :scope)" fire-event="demo-ping"
detail-label="pong">
Ping previous output
</event-handler>
<quark-sheet>
output {
@on demo-ping { content: event.detail.label; }
}
</quark-sheet>
</div>Debounced input / form data
Inherited delay-ms + is-debounced coalesce noisy input into one search-query event. Demo below is debounced every 200ms.
Also demonstrated is form conversion into JSON: text -> string, checkbox -> boolean, etc. Including the data structure: detail.strict -> {detail: {strict}}.
<div>
<event-handler listen-for="input" delay-ms="200" is-debounced fire-event="search-query" form-ref=":scope form">
<form>
<label>
<input name="detail.q" type="search" placeholder="Type to search…" />
</label>
<label>
Strict search
<input name="detail.strict" type="checkbox" role="switch" />
</label>
</form>
</event-handler>
<output>Waiting…</output>
<quark-sheet>
@use "quark:util" as *;
:scope {
@on search-query { output { content: to-json(event.detail); } }
}
</quark-sheet>
</div>Release notes
0.1.1 (2026-09-23)
- Ship only dist (and declared extras) in the npm tarball; drop build logs, tests and sources
Full documentation: https://nucleus.excom.dev/packages/event-handler
