# event-handler

Stitch behaviors of your app together, primarily through events.


```html
<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-name` invokes the HTML Command API — built-in verbs (`show-modal`) and the `--verb` commands 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 to `event.detail` JSON

> 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"))); }` — `@on` options cover `selector-filter` / `keycode-filter` / `is-debounced` / `host-ref`, and `@dispatch` / `@command` cover `fire-event` / `target-ref` / `form-ref` / `command-name`. Keep `<event-handler>` for markup without a sheet.

## Installation


`@excom/event-handler` v0.1.4

```bash
pnpm add @excom/event-handler
```

```bash
npm install @excom/event-handler
```

```bash
yarn add @excom/event-handler
```

### Import

```ts
import "@excom/event-handler";
```



## Usage

Listen (default `click`), then either `fire-event` or `command-name`.

```html
<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.
```html
<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`.


```html
<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`.


```html
<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).


```html
<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.


```html
<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.


```html
<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.


```html
<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}}`.


```html
<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
