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 name | Listens to | Payload |
|---|---|---|
on<Event> | the component's own element | the 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
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:
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 readevent.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.
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:
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:
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.targetup tothis.$el, reads the instance map of each element, and callson<Name><Event>for the first mounted instance that matches. - A child inserted later needs no new binding.
- Events that do not bubble,
mouseenterandmouseleaveincluded, 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:
static config = {
name: 'Accordion',
components: { AccordionItem: () => import('./AccordionItem.js') },
};Global handlers
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.
onDocumentClickhears whatdocument.addEventListener('click', …)hears. To hear a descendant's non-bubbling event, useon<Ref><Event>. - The two prefixes are reserved, and they match before children and refs.
onWindowResizebinds towindoweven in a component whoseconfig.componentsholds aWindow. To reach a child with that name, use@on('Window', 'resize'). - The rule is about method names only.
onClickandonDocumentClickare different names and both can exist; a click on the element fires both. - The payload is
{ event, target }, wheretargetis the global the handler names. There is nopayloadand noindex.
$on() and $off()
For an event whose name is data rather than a method name, listen by hand and return the cleanup:
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:
| mode | asks for | registers with | keeps | on failure |
|---|---|---|---|---|
| take over | the action | wrap(runner) | one runner, last wins | the mutation is applied anyway |
| delay | the moment | waitUntil(x) | many, all are awaited | the step happens anyway |
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();
}
}defaultPreventedis 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
applyloses 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::
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.