DOM events and handlers

An event is an on* attribute holding a function. The handler receives the native DOM event. That is the entire model:

html`<button onclick="${() => save()}">Save</button>
     <input onkeydown="${(e) => handle(e)}" />`

Component callbacks are the same thing one level up: lowercase function props:

html`<${Card} onsave="${(v) => api.save(v)}" />`

Event naming is inconsistent across environments, and the failure mode is expensive: a handler that attaches under the wrong name fires silently never. LemonadeJS enforces one rule, that all event and callback names are lowercase, to make that failure mechanically impossible.

Any on* attribute takes a function and receives the native event.

live
import { html } from 'lemonadejs';

const App = (props, { state }) => {
    const log = state([]);
    const add = (msg) => (log.value = [msg, ...log.value].slice(0, 5));

    return html`<div>
        <button onclick="${() => add('click: saved')}">Save</button>
        <input placeholder="type, then press Enter"
            onkeydown="${(e) => e.key === 'Enter' && add('keydown: Enter with "' + e.target.value + '"')}" />
        <div onmouseenter="${() => add('mouseenter: box')}"
             style="margin-top:8px;padding:12px;border:1px dashed #999">hover this box</div>
        <ol>${() => log.value.map((m) => html`<li>${m}</li>`)}</ol>
    </div>`;
};

Mechanics

One casing rule, no exceptions. Every event and callback name is lowercase, HTML-style: onclick, oninput, onchange, onsave, onitemclick, exactly how the platform spells onmousedown. Any other casing warns LJS-305 in dev, on elements and on component props. On native elements the event name is normalized so a stray onMouseDown still attaches (and warns); on components, where a wrong-cased prop would be silently ignored, the tripwire is the whole point.

Functions only, enforced. An on* attribute that does not hold a function fails hard with LJS-301 at mount, in dev and production alike. String handlers do not exist, so templates are CSP-safe by construction, and since the engine contains no eval or new Function anywhere, the zero-build deployment runs under a strict CSP.

Native events, no synthetic layer. The handler receives the browser’s event object. onchange is the DOM change event; oninput is input. What MDN documents is what happens.

Attach once, resolve per dispatch. The engine adds one listener per on* attribute and reads the current handler through the template’s value holder at dispatch time, so when a branch re-renders with a new closure, the handler is swapped without any removeEventListener/addEventListener churn.

Component callbacks

A component event is nothing more than a lowercase function prop: declared in the contract as an event:

export const Datagrid = component('datagrid', {
    // ...
    onchange: Function,           // (row, columnName, value, oldValue)
    onselect: Function,           // (selectedRows)
    onsort: Function,             // (columnName, direction | null)
    oncolumnresize: Function,     // (columnName, widthPx) on handle release
}, (props) => { /* ... */ });

The declaration buys every deployment at once: inside LemonadeJS the callback is called directly; in the custom-element deployment each declared event dispatches a real CustomEvent (bubbling, composed) named after the prop minus on, so addEventListener('change', ...), Vue’s @change, Angular’s (change) all just work, and the React adapter maps it back to a React-style callback. Passing a non-function for a declared event warns LJS-401.

For the two-way onchange protocol (value.set() commits and fires onchange; parent writes never echo back), see Two-way binding. And anything that closes or commits on focusout should check isDisposing(), because the renderer removing a focused element fires focusout exactly like a user leaving; see Destroy.

In practice

The Tabs block header (components/tabs, shipped source): one <ul>, seven native handlers, all plain functions: click, focus-follows-arrows, keyboard, and drag-sorting:

<ul ref="${(el) => (ul = el)}"
    onclick="${onOpenEvent}"
    onfocusin="${onOpenEvent}"
    onkeydown="${onKeydown}"
    ondragstart="${onDragstart}"
    ondragover="${(e) => e.preventDefault()}"
    ondrop="${onDrop}"
    ondragend="${onDragend}">

And the Switch block shows both layers in one line each: the native change event of its real <input type="checkbox"> feeds the bound state, and the component’s onchange fires only for user-initiated commits, because the commit goes through set():

<input type="checkbox" class="lm-switch-input"
    onchange="${(e) => current.set(e.target.checked)}" />

A parent writing enabled.value = true programmatically updates the switch without echoing onchange back, the protocol that prevents update loops between parent and child.

Reference

onclick="${fn}"            // any on* attribute; fn receives the native event
on*function, always    // LJS-301 (throws): strings never
onclick, onchange, onsave  // lowercase, always; LJS-305 warns otherwise
<${C} onsave="${fn}" />    // component callback: a lowercase function prop
onsave: Function           // contract declaration → CustomEvent 'save' on <lm-c>
  • Handlers are attached once per attribute; branch updates swap the function without re-attaching the listener.
  • Declared contract events must be lowercase (they become HTML-facing names); non-function values warn LJS-401.
  • isDisposing() distinguishes renderer-caused focusout from the user’s.