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.
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-causedfocusoutfrom the user’s.