Joy DOM

Events

Bind a node's onclick/onfocus/onblur/onchange and custom emits into a single .onEvent handler.

Built-in nodes carry event bindings; custom components call events.emit(_:). When a component's node declares a binding under the event's exact name, emit("<name>") dispatches that binding's name as event.name. The binding's static params arrive in event.params, and the dynamic emit payload in event.payload. Either way, the event reaches your single .onEvent handler.

Event names are never rewritten — the four built-in events keep their spec spelling, so emit("onclick") fires the click binding while emit("click") is just an ordinary lookup of a "click" prop. The strict "type": "event" marker is what distinguishes a binding from an ordinary data prop:

{
  "type": "rating-stars",
  "props": {
    "id": "stars",
    "rate": { "type": "event", "name": "set-rating", "params": { "max": 5 } }
  }
}

One handler receives everything

A single .onEvent handler receives every event; switch on event.name, or on the semantic event.type (.click / .blur / .focus / .change / .custom(name:)), to single out the one you want:

JoyDom(spec: spec, components: registry)
    .onEvent { event in
        guard event.name == "set-rating" else { return }
        let value = event.payload.getInt("value") ?? 0   // from emit(...)
        let max   = event.params?.getInt("max") ?? 5     // from the binding
    }

Each event also carries the spec Node it fired on (event.node), the DOM-event.target shape (event.targetid / className / type), and the native interaction (event.nativeEvent). Read params/payload values with the typed accessors (getInt, getString, getBool, getDouble).

This handler is the only place events are delivered — they don't travel from one node to another, so do all your branching here. It's also required: without an .onEvent handler the document still renders, but taps and other interactions do nothing — there's nowhere for the events to go.

For the built-in bindings (onclick/onfocus/onblur/onchange), event.payload mirrors event.params for backward-compat; the clean params-vs-payload split only applies to a custom emit. Prefer reading static config from event.params.

Emitting from a custom component

Custom components reach the same handler through context.events.emit:

registry.register("rating-stars") { context in
    .custom {
        StarRow { value in
            context.events.emit("rate", payload: ["value": .number(Double(value))])
        }
    }
}

emit("rate") looks up the node's "rate" prop. Because that prop is a { "type": "event", "name": "set-rating", … } binding, the handler sees event.name == "set-rating" with the binding's params merged in. An emit with no matching binding stays a raw custom event (event.type == .custom(name:), no binding params) — it still reaches your single .onEvent handler.

Where next

On this page