Events
elements man html/events Read as markdownEvent handlers and their naming conventions, template lifecycle, animating removal, focus management, and flushing pending DOM updates.
Event handlers are on-prefixed attributes. Any TS expression that resolves to
a function works.
<button onclick={() => count++}>+1</button>
<button onclick={onClick}>click</button>
<form onsubmit={() => onSubmit(form, () => form = empty())}>...</form>
Event handler parameters are typed per element and per event. Writing
<button onclick={(e) => e.shiftKey}> resolves e as MouseEvent and
e.shiftKey as boolean. <input oninput={(e) => e.data}> resolves e as
InputEvent. Hover and autocomplete in the LSP show the per-tag attribute set
and the event-specific handler signatures.
Naming and file conventions
By convention, named event handlers use the onX prefix (onSubmit, onType,
onPointerDown, onIncoming). The prefix distinguishes them at a glance from
regular helpers and from rpc functions, and makes the call sites read uniformly
with the HTML attribute they're bound to.
Event handlers live in template.html next to the markup that fires them. They
share the file with the template's reactive state, which is what they typically
read and mutate.
Rpc functions and the shared interfaces both the route and the template need go
in services.ts, a sibling you add next to index.ts and template.html once
the page's template.html grows too large to hold them. The page's own index.ts imports the route default and any
shared types; the template imports rpc functions and interfaces; nothing imports
the rpc from index.ts (that would create a cycle).
Services that span multiple pages (a LiveTable two pages both watch, a channel
several pages publish to, a helper module like isUserAdmin called from many
places) go in app/shared/services/<topic>.ts. Cross-page templates go in
app/shared/templates/<name>/, and cross-page styles in app/shared/styles/.
app/jobs/, app/emails/, and app/migrations/ stay at the top level because
they have their own runtime lifecycle.
Forms do not submit over HTTP. The runtime calls e.preventDefault() on every
form submit event automatically, which suppresses the native form-action POST.
Submission triggers the onsubmit handler you provide, and from the handler you
can call anything: an @rpc function, a client-side helper that calls an
@rpc, or something that doesn't touch rpc at all.
For non-trivial handlers, extract a named function and pass template state through the attribute. Mutating data inside the closure keeps reactivity intact.
function onSubmit(form: Form, resetUI: () => void) {
resetUI();
saveUser(form);
}
<form onsubmit={() => onSubmit(form, () => form = { name: "", age: 0 })}>
Lifecycle
Three lifecycle handlers fire on a template instance or element. All three fire on the browser, including on the first page render.
oninit: template instance created, params set, before DOM insertoninsert: after DOM insertonremove: fires synchronously, then the node is detached in the same tick. Use it for cleanup (clear timers, remove listeners). It is not a hook for exit animations: the node is gone before an animation could play. To animate a removal, see below.
Each handler receives a LifecycleEvent, exported from @elements/app. It
extends the DOM Event, so instanceof Event holds and a handler typed
(e: Event) => void still compiles. event.type is "init", "insert", or
"remove", and event.target is the element the handler is bound to, typed
Element rather than the DOM's nullable EventTarget.
// app/shared/services/ui.ts
import { LifecycleEvent } from "@elements/app";
export function focusFirst(e: LifecycleEvent): void {
(e.target as HTMLInputElement).focus();
}
Inline, the parameter type is inferred and you do not need the import:
<input oninsert={(e) => e.target.focus()}>
<!-- oninit captures the timer, onremove clears it -->
<div oninit={() => timer = setInterval(tick, 1000)}
onremove={() => clearInterval(timer)}>
Animating removal
The runtime detaches a node synchronously, so a leave animation is driven by
reactive state, not by onremove. Keep the row in the list, flip a reactive
flag that toggles a CSS class, let the animation play, and do the real removal
when it ends.
function startLeaving(m: Message) {
m.leaving = true;
}
<html (messages: LiveTable<Message>)>
<ul>
<li e:for={m of messages}
class={["message", m.leaving && "is-leaving"]}
onanimationend={() => { if (m.leaving) { messages.delete(m); } }}>
{m.body}
<button onclick={() => startLeaving(m)}>delete</button>
</li>
</ul>
</html>
.message.is-leaving {
animation: leave 200ms ease forwards;
}
@keyframes leave {
to {
opacity: 0;
transform: translateX(1rem);
}
}
m.leaving is transient client-side UI state (declare it as leaving?: boolean
on the row type). Setting it re-runs the class binding, so is-leaving lands
and the CSS animation plays. onanimationend then performs the real removal:
messages.delete(m) for a LiveTable, or a splice for a plain array. The row
stays mounted the whole time because it is still in the collection. The
if (m.leaving) guard keeps an enter animation on the same element from
triggering the delete.
Use element-attribute handlers (onclick={...}) over addEventListener for
events Elements supports as attributes. The attribute path goes through the
runtime's event wiring; addEventListener bypasses it and can surface as inert
behavior under iOS quirks like passive-listener and capture-vs-bubble. Reach for
addEventListener only for events not supported as attributes (popstate,
hashchange) or page-wide listeners that must live above any single template.
Don't put ontouch* on <body> or other broad ancestors. When iOS Safari
sees a non-passive touch listener on an ancestor, it delays or eats clicks on
child elements while waiting to see if the listener will preventDefault. Bind on
the smallest reasonable target.
Clipboard UI must not gate on the write succeeding.
navigator.clipboard.writeText throws synchronously on insecure origin (iOS
Safari over LAN). A .catch() won't catch it. Set the "copied!" UI state first,
then attempt the write inside try/catch.
Focus
focus={expr} is a reactive focus binding.
<input focus={isOpen}>
On initial render, truthy translates to autofocus. Subsequent flips call
focus() or blur().
iOS focus footgun. iOS Safari only opens the soft keyboard when focus()
runs on an input that was already in the DOM at the start of the user gesture.
Inputs mounted via e:if mid-gesture are out-of-gesture and silently denied.
Two fixes:
- Always-mount the input. Hide it with
visibility: hidden. Don't usedisplay: none, which also disqualifies focus. - Use
flush()to mount the input synchronously inside the gesture.
Focusable elements only. onfocus/onblur won't fire on a <div> or
<li> unless tabindex={0} is set.
Flush
flush() synchronously drains pending reactive updates. Use only when a browser
API requires the DOM to update mid-handler. The most common case is iOS
soft-keyboard focus inside a click handler.
import { flush } from "@elements/app";
function openSearch() {
open = true;
flush();
inputEl.focus();
}