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
A named event handler takes the onX prefix of the attribute it is bound to:
onSubmit, onClick, onInput, onPointerDown. When one page has two of a
kind, qualify it (onSignupSubmit, onFilterClick), and keep the on at the
front so the handlers still sort and read together.
An @rpc function is named for what it does, as short as the verb allows:
login, logout, signup, savePost, setRole. It is an operation on your
data, not a UI event, and nothing about the call site needs the name to say so.
The two conventions are what keeps them from colliding. Both a handler and an RPC want to be called "signup", and with the prefix they can be:
/** @rpc */
export function signup(handle: string, password: string) { ... }
function onSubmit(handle: string, password: string): string {
try {
signup(handle, password);
return "";
} catch (err: any) {
return err.message;
}
}
Without it one of them has to give, and the name that gives is always worse
than the one it replaced: signupUser for the RPC, or attemptSignup for the
handler. Neither says anything the shorter name did not.
Where the code goes
Start everything in template.ehtml. Interfaces, helpers, event handlers,
@rpc functions, LiveTables, and Channels all live in the page's template file
alongside the markup that uses them, and index.ts imports what the route
needs by name:
import page, { posts, Post } from "./template";
A sibling test.ts imports from ./template the same way. Keeping the page in
one file is the default, not a shortcut for small pages: a page you can read
top to bottom is easier to change than the same code spread over three files.
Split out of template.ehtml when the markup gets hard to find in the file. The
sibling is services.ts, and it takes the RPC functions and the interfaces
first, because those are the part with no markup near it.
Move to app/shared/services/<topic>.ts when a second consumer appears: a
second page, a job, an email, a route handler in app/routes/. One page plus
one job counts. One page on its own does not, however large the module gets.
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.
A handler attribute holds a call, not a body. Anything with a branch, a try/catch, more than one statement, or a sequence of assignments goes in a named function above the template, and the attribute calls it with the state it needs. Markup stays presentation.
function onSubmit(form: SigninForm) {
try {
login(form.handle, form.password);
form.error = "";
redirect("/");
} catch (err: any) {
form.error = err.message;
}
}
<form onsubmit={() => onSubmit(form)}>
Not this. The logic cannot be tested, reused, or read past, and the markup it sits in stops being scannable:
<!-- wrong: a body inline in the attribute -->
<form onsubmit={() => {
error = attemptSignin(handle, password);
if (!error) {
handle = "";
password = "";
}
}}>
Group the fields into one object and pass that. A handler can mutate an
object's fields and the change is live; it cannot reassign a plain attribute
the caller holds (see reactivity). One form object is also one
constructor attribute instead of one per field:
<html class="page-signin" (
private form: SigninForm = { handle: "", password: "", error: "" },
)>
An inline arrow is fine when it is one short expression:
onclick={() => count++}, onclick={() => logout()},
onclick={() => onEdit(edit, todo)}.
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. You do
still need the cast: e.target is typed Element, because an event can be
delegated from anywhere, so narrow it to the element you are actually on before
reaching for focus(), value, or anything else specific to it.
<input oninsert={(e) => (e.target as HTMLInputElement).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 onDelete(m: Message) {
m.leaving = true;
}
function onAnimationEnd(messages: LiveView<Message>, m: Message) {
if (m.leaving) {
messages.delete(m);
}
}
<html (messages: LiveView<Message>)>
<ul>
<li e:for={m of messages}
class={["message", m.leaving && "is-leaving"]}
onanimationend={() => onAnimationEnd(messages, m)}>
{m.body}
<button onclick={() => onDelete(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 in onAnimationEnd 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();
}