Manual HTML Events

Events

elements man html/events Read as markdown

Event 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 insert
  • oninsert: after DOM insert
  • onremove: 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:

  1. Always-mount the input. Hide it with visibility: hidden. Don't use display: none, which also disqualifies focus.
  2. 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();
}