Skip to content

Events

Everything a component announces is a real DOM event, and everything it listens to is delegated from its own root element.

The conventions

Method nameListens toPayload
on<Event>the component's own elementthe raw event
on<Ref><Event>a declared ref, delegated{ event, target, index }
on<Child><Event>a component in config.components, delegated{ event, target, payload }
onWindow<Event>window{ event, target }
onDocument<Event>document{ event, target }

Every one of them is bound for the mount cycle and removed by $unmount().

The component's own element

js
import { 
Base
} from '@studiometa/js-toolkit';
class
Toggle
extends
Base
{
static
config
= {
name
: 'Toggle' };
onClick
(
event
) {
event
.preventDefault();
this.
$el
.
classList
.
toggle
('is-active');
} }

Refs

See Refs. The payload's target is the ref element the handler matched, not event.target, and index is its position in a list ref.

$emit() — a native event

$emit(name, payload?) dispatches a bubbling, cancelable CustomEvent. detail is the payload:

js
import { 
Base
} from '@studiometa/js-toolkit';
class
Dialog
extends
Base
{
static
config
= {
name
: 'Dialog' };
open
() {
// No payload: `detail` is the platform value `null`. this.
$emit
('open');
}
goto
(
index
) {
// One optional object, and `detail` is that object. this.
$emit
('goto', {
index
});
} }
  • It bubbles, so any ancestor hears it — a component, or a plain addEventListener.
  • It is cancelable, and $emit() returns the event, so the emitter can read event.defaultPrevented.
  • The payload is one object, or nothing. A value that is not an object is refused by the type and reported at runtime as event.invalid-emit-payload. The event still dispatches.

Nothing in the framework is gated on cancellation. It is a channel for component code.

js
import { 
Base
} from '@studiometa/js-toolkit';
class
Dialog
extends
Base
{
static
config
= {
name
: 'Dialog' };
close
() {
const
event
= this.
$emit
('close');
if (
event
.
defaultPrevented
) return;
this.
$el
.
removeAttribute
('data-option-open');
} }

Typing the events a component emits

$emits in the props type maps each name to its payload object, or to void for an event with no payload:

ts
import { 
Base
} from '@studiometa/js-toolkit';
class
Slider
extends
Base
<{
$emits
: {
goto
: {
index
: number };
stop
: void;
}; }> { static
config
= {
name
: 'Slider' };
mounted
() {
this.
$emit
('goto', {
index
: 2 });
this.
$emit
('stop');
} }

$emits replaces the runtime config.emits of v3. Nothing of it stays in the bundle.

Child events

A parent hears a child through on<Child><Event>, resolved against the names in config.components:

ts
import { 
Base
, type
DelegatedEvent
} from '@studiometa/js-toolkit';
class
AccordionItem
extends
Base
<{
$emits
: {
open
: {
height
: number } } }> {
static
config
= {
name
: 'AccordionItem' };
onClick
() {
this.
$emit
('open', {
height
: this.
$el
.
scrollHeight
});
} } class
Accordion
extends
Base
{
static
config
= {
name
: 'Accordion',
components
: {
AccordionItem
},
};
onAccordionItemOpen
({
target
,
payload
}:
DelegatedEvent
<
AccordionItem
, 'open'>) {
console
.
log
(`${
target
.
$id
} opened to ${
payload
.
height
}px`);
} }

How it works:

  • One listener per event type on the parent's root element.
  • The handler walks from event.target up to this.$el, reads the instance map of each element, and calls on<Name><Event> for the first mounted instance that matches.
  • A child inserted later needs no new binding.
  • Events that do not bubble, mouseenter and mouseleave included, are delegated from the capture phase.

config.components is what disambiguates the name

A method name alone is ambiguous: onSliderDragStart is SliderDrag + start, or Slider + drag-start. The name set from config.components is what decides. A child that is not declared there is not resolved.

A lazy child works the same way — the string key is the name, so nothing is downloaded to resolve a handler:

js
static config = {
  name: 'Accordion',
  components: { AccordionItem: () => import('./AccordionItem.js') },
};

Global handlers

js
import { 
Base
} from '@studiometa/js-toolkit';
class
ClickOutside
extends
Base
{
static
config
= {
name
: 'ClickOutside' };
onDocumentClick
({
event
}) {
if (!
event
.composedPath().includes(this.
$el
)) {
this.
$emit
('click-outside', {
event
});
} }
onWindowResize
() {
this.
$el
.
removeAttribute
('data-option-open');
} }
  • Scope: the mount cycle. $unmount() removes the listener and a new mount binds it again.
  • Phase: bubble, always. onDocumentClick hears what document.addEventListener('click', …) hears. To hear a descendant's non-bubbling event, use on<Ref><Event>.
  • The two prefixes are reserved, and they match before children and refs. onWindowResize binds to window even in a component whose config.components holds a Window. To reach a child with that name, use @on('Window', 'resize').
  • The rule is about method names only. onClick and onDocumentClick are different names and both can exist; a click on the element fires both.
  • The payload is { event, target }, where target is the global the handler names. There is no payload and no index.

$on() and $off()

For an event whose name is data rather than a method name, listen by hand and return the cleanup:

js
import { Base } from '@studiometa/js-toolkit';

class Watcher extends Base {
  static config = { name: 'Watcher', options: { events: Array } };

  mounted() {
    // `$on()` returns its own remover.
    return this.$options.events.map((type) => this.$on(type, () => console.log(type)));
  }
}

Negotiated events

Two helpers let a component announce a step before it happens so an ancestor can take part. They are not Base methods and they are absent from $emits:

modeasks forregisters withkeepson failure
take overthe actionwrap(runner)one runner, last winsthe mutation is applied anyway
delaythe momentwaitUntil(x)many, all are awaitedthe step happens anyway
js
import { Base, EVENTS, domUpdate, emitExtendable, viewTransition } from '@studiometa/js-toolkit';

class Panel extends Base {
  static config = { name: 'Panel' };

  // Take over: the code that mutates announces instead of mutating.
  async render(fragment) {
    await domUpdate(this.$el, () => this.$el.replaceChildren(fragment));
  }

  // Delay: the choreography announces its step and waits.
  async close() {
    await emitExtendable(this.$el, 'close');
    this.$el.removeAttribute('data-option-open');
  }

  mounted() {
    return [
      this.$on(EVENTS.dom.update, (event) => event.detail.wrap(viewTransition)),
      this.$on('close', (event) => event.detail.waitUntil(this.leave())),
    ];
  }

  leave() {
    return Promise.resolve();
  }
}
  • defaultPrevented is ignored. The step is announced, not proposed.
  • A registration is valid only while the event dispatches. A listener that keeps the function and calls it later is warned (protocol.late-registration) and ignored.
  • The work of the emitter always completes. A runner that throws, rejects, or never calls apply loses the animation, never the change.
  • An unclaimed domUpdate() is synchronous. With no listener the mutation runs before the returned promise exists.

See domUpdate() and emitExtendable().

Framework events

EVENTS is a deeply frozen object of the events the framework itself dispatches, all namespaced js-toolkit::

js
import { 
EVENTS
} from '@studiometa/js-toolkit';
EVENTS
.
component
.
mounted
; // 'js-toolkit:component:mounted'
EVENTS
.
component
.
unmounted
; // 'js-toolkit:component:unmounted'
EVENTS
.
dom
.
update
; // 'js-toolkit:dom:update'
EVENTS
.
diagnostic
; // 'js-toolkit:diagnostic'

Component events are typed lower-kebab string literals declared through $emits. Private framework transports — the context request, for one — use module-local constants and are deliberately not in EVENTS.

MIT Licensed