lily/event

Event handlers wire browser events onto DOM elements through CSS selectors. Each one turns whatever the user does, a click, a keypress, a form submission, into a Message that flows into your Store, so the rest of your app only ever deals in tidy little message values.

The core API is a pair of scoped binders, on() and on_decoded(), their page-level counterparts on_global() and on_global_decoded(), and one constant per DOM event (event.click, event.mouse_down, event.key_down, and so on). The constant pins down the payload type, so the compiler makes sure your handler actually matches the event. Bindings live on the Component you pipe them onto and are registered once at component.mount().

Locality is behavioural. on() confines its listener to the component’s own subtree, matched against the component’s scope selector (its id, recorded with component.scoped). For listeners that belong to the page rather than any one component (a document keydown, a window resize, a root data-message click decoder), reach for on_global() with an explicit selector. Either way the listener delegates from document, so one registration keeps working as you patch the DOM underneath it. As with components, bindings declared inside each and each_live item bodies aren’t collected, so put them on the each/each_live wrapper or any static ancestor.

import lily/client
import lily/component
import lily/event
import lily/store

fn app(_model: Model) {
  component.fragment([
    component.simple(
      slice: fn(model: Model) { model.search },
      render: fn(value, _) { html.input([attribute.value(value)]) },
    )
    |> component.scoped("#search")
    |> event.on(event: event.input, handler: Search),
  ])
  |> event.on_global_decoded(
    event: event.click,
    selector: "#app",
    decoder: parse_click,
  )
  |> event.on_global(
    event: event.key_down,
    selector: "document",
    handler: fn(ke) { KeyPressed(ke.key) },
  )
}

pub fn main() {
  let runtime =
    store.new(Model(count: 0), with: update)
    |> client.start(store.wiring())

  runtime
  |> component.mount(
    selector: "#app",
    to_html: element.to_string,
    to_slot: fn() { element.element("lily-slot", [], []) },
    view: app,
  )
}

fn parse_click(message_name: String) -> Result(Message, Nil) {
  case message_name {
    "increment" -> Ok(Increment)
    "decrement" -> Ok(Decrement)
    _ -> Error(Nil)
  }
}

When an event needs debouncing, throttling, or preventDefault, build an EventOptions with options() and the builder functions, then bind with on_with_options() or on_decoded_with_options():

component.simple(slice: ..., render: ...)
|> component.scoped("#search")
|> event.on_with_options(
  event: event.input,
  options: event.options() |> event.debounce_milliseconds(200),
  handler: Search,
)

A handful of helpers cover keyboard accessibility. Because they act on the live DOM imperatively, they take a Runtime and are driven from a client.on_message hook rather than piped onto a component. focus moves focus to a selector, focus_trap confines Tab cycling to an overlay (stacked, so a dialog inside a drawer each keep their own scope), and arrow_group / arrow_grid turn a set of elements into an arrow-navigable roving-tabindex group or grid. These scope their items relative to the component like on() does and re-query on every keypress, so content that shows up later just works.

All event handlers are JavaScript-only (@target(javascript)).

To validate a form submission with the formal library, pass form_submit to on_decoded() with a decoder that builds the form, adds the submitted values, and calls form.run. The error branch carries the whole Form(model) back so the view can render field-level errors via form.field_error_messages:

import formal/form

fn login_schema() -> form.Schema(Login) {
  use email <- form.field("email", form.parse_email)
  use password <- form.field(
    "password",
    form.parse_string |> form.check_string_length_more_than(7),
  )
  form.success(Login(email:, password:))
}

fn login_decoder(
  fields: List(#(String, String)),
) -> Result(Message, Nil) {
  let submitted = form.new(login_schema()) |> form.add_values(fields)
  case form.run(submitted) {
    Ok(login) -> Ok(LoginSubmitted(login))
    Error(invalid_form) -> Ok(LoginFailed(invalid_form))
  }
}

component.simple(slice: ..., render: ...)
|> component.scoped("#login-form")
|> event.on_decoded(event: event.form_submit, decoder: login_decoder)

Store the returned Form(model) in your model on the error branch; the view calls form.field_error_messages(invalid_form, "email") to render error text next to each field. Run the same schema inside your server-side update function to re-validate untrusted input.

Types

Data extracted from the DOM element that matched the event handler’s selector. dataset contains all data-* attributes as name/value pairs using their original kebab-case names (e.g., data-card-id"card-id").

pub type ElementData {
  ElementData(dataset: List(#(String, String)))
}

Constructors

  • ElementData(dataset: List(#(String, String)))

A typed handle for a DOM event. The payload parameter is fixed by each constant (e.g. mouse_down is Event(#(Int, Int, ElementData))), so the handler signature is checked at compile time. Pass these constants to on() and friends.

pub opaque type Event(payload)

Optional modifiers for an event handler: debounce, throttle, fire-once, stop-propagation, prevent-default. Build with options() and the dedicated builder functions.

event.options()
|> event.debounce_milliseconds(200)
|> event.stop_propagation
pub opaque type EventOptions

Data extracted from a keyboard event. key is the key name (e.g., "Enter", "ArrowUp", "a"). The modifier flags match the corresponding browser event properties.

pub type KeyEvent {
  KeyEvent(
    key: String,
    ctrl: Bool,
    shift: Bool,
    alt: Bool,
    meta: Bool,
  )
}

Constructors

  • KeyEvent(
      key: String,
      ctrl: Bool,
      shift: Bool,
      alt: Bool,
      meta: Bool,
    )

Axis a arrow_group navigates with the arrow keys: Horizontal listens to Left and Right, Vertical to Up and Down, and Both to all four.

pub type Orientation {
  Horizontal
  Vertical
  Both
}

Constructors

  • Horizontal
  • Vertical
  • Both

Values

pub fn arrow_grid(
  component: component.Component(model, message, html),
  items items: String,
  columns columns: Int,
  wrap wrap: Bool,
) -> component.Component(model, message, html)

Erlang no-op twin of arrow_grid: returns the component unchanged so shared views compile on the server, where focus groups never register (mount is JavaScript-only). Make a set of elements an arrow-navigable grid of columns columns (the roving-tabindex pattern in two dimensions). Left and right move focus by one cell, up and down by a full row. wrap decides whether moving past an edge loops around.

Pipe-friendly and scoped like on: items is a selector relative to the component’s scope (its id), so "[role=gridcell]" composes to #<id> [role=gridcell]. The group registers at component.mount() and re-queries its items on every keypress, so items rendered later (an overlay opening) are picked up automatically.

pub fn arrow_group(
  component: component.Component(model, message, html),
  items items: String,
  orientation orientation: Orientation,
  wrap wrap: Bool,
) -> component.Component(model, message, html)

Erlang no-op twin of arrow_group: returns the component unchanged so shared views compile on the server, where focus groups never register (mount is JavaScript-only). Make a set of sibling elements a single arrow-navigable group (the “roving tabindex” accessibility pattern). While focus is on one of them, the arrow keys allowed by orientation (plus Home and End) move focus to the previous or next item. wrap decides whether moving past either end loops around.

Pipe-friendly and scoped like on: items is a selector relative to the component’s scope (its id), so "[role=menuitem]" composes to #<id> [role=menuitem]. The group registers at component.mount() and re-queries its items on every keypress, so a transient overlay’s items are handled the moment it opens, with no per-open wiring.

Unlike focus_trap, which is a stack with one active entry, several groups coexist; the active group on any keypress is the one whose items include the focused element. Focus moves with element.focus(), so the non-active items may carry tabindex="-1" (the usual roving-tabindex render) and still be reached by the arrows.

pub const blur: Event(ElementData)

blur event, fires when an element loses focus.

pub const change: Event(String)

change event, fires when an input value is committed (after blur). For real-time updates, use input.

pub const click: Event(String)

click event, uses delegation against the data-message attribute. The payload is the matched element’s data-message value. Pair with on_decoded() so unknown messages can be skipped via Error(Nil).

pub const context_menu: Event(#(Int, Int, ElementData))

contextmenu event, fires on right-click. Payload is (x, y, element) relative to the viewport.

pub const copy: Event(Nil)

copy event, fires when text is copied to the clipboard.

pub const cut: Event(Nil)

cut event, fires when text is cut to the clipboard.

pub fn debounce_milliseconds(
  options: EventOptions,
  value: Int,
) -> EventOptions

Set the debounce delay in milliseconds. Multiple events within the window collapse to a single dispatch fired after the gap.

pub fn decode_message(encoded: String) -> Result(message, Nil)

Recover a message encoded by encode_message. Returns Error(Nil) for any string this module did not produce, so it drops straight into on_decoded() as the decoder and coexists with handlers that read readable data-message tags (those decline here and are handled by their own listeners).

root
|> event.on_decoded(
  event: event.click,
  selector: ".lily-ui-root",
  decoder: event.decode_message,
)
pub const double_click: Event(ElementData)

dblclick event, fires on a double click.

pub const drag: Event(#(Int, Int))

drag event, fires repeatedly while an element is being dragged. Consider pairing with throttle_milliseconds.

pub const drag_end: Event(ElementData)

dragend event, fires once when a drag operation ends.

pub const drag_over: Event(#(Int, Int, ElementData))

dragover event, fires repeatedly while a dragged element is over a valid drop target. Pair with prevent_default(options) to enable dropping.

pub const drag_start: Event(#(Int, Int, ElementData))

dragstart event, fires once when a drag operation starts.

pub const drop: Event(#(Int, Int, ElementData))

drop event, fires when a dragged element is dropped on a valid target.

pub fn encode_message(message: a) -> String

Serialise message into an attribute-safe string for a data-message attribute, so a stateless element can carry a typed message that decode_message() recovers at the root. The value round-trips through Lily’s reflection codec, the same one transport uses on the wire, so no separate encoder is needed and the constructor is cached at encode time.

html.button(
  [attribute.data("message", event.encode_message(SaveDraft))],
  [html.text("Save")],
)
pub fn focus(
  runtime: client.Runtime(model, message),
  selector: String,
) -> Nil

Programmatically move focus to the first element matching selector. Runs after the next paint so the call is safe from a client.on_message hook whose dispatch may have just rendered the target element. No-op if the selector matches nothing.

client.on_message(runtime, fn(message, _model) {
  case message {
    OpenDialog -> event.focus(runtime, "#dialog-cancel")
    CloseDialog -> event.focus(runtime, "#dialog-trigger")
    _ -> Nil
  }
})
pub const focus_event: Event(ElementData)

focus event, fires when an element receives focus. Named focus_event to avoid collision with the focus() function.

pub fn focus_trap(
  runtime: client.Runtime(model, message),
  within within: String,
  release_on release_on: fn(String) -> Bool,
  on_exit on_exit: fn() -> message,
) -> Nil

Confine Tab and Shift+Tab cycling to focusable descendants of the element matching within. Pushes a new trap onto a stack so nested overlays (a Combobox inside a Dialog inside a Drawer) each keep their own keyboard scope. Focusable elements are re-enumerated on every Tab press so dynamic content inside the container is handled.

While this trap is the top of the stack, release_on runs on every keydown; returning True pops the trap and dispatches the message produced by on_exit. Opening another trap on top suspends this one (Tab cycles within the new trap, release_on and on_exit come from the new trap); popping the top trap restores the one below.

Pair with focus to seed initial focus inside the trapped region, and release_focus_trap for imperative release (e.g. clicking Cancel rather than pressing the exit key).

pub const form_change: Event(List(#(String, String)))

input on a form, payload is the current FormData as a list of name/value pairs. The uncontrolled-form counterpart of input. Pair with on_decoded() to skip dispatch on validation failure.

pub const form_submit: Event(List(#(String, String)))

submit on a form, payload is the submitted FormData as a list of name/value pairs. Prevents the browser’s default form submission and resets the form after the handler runs. Pair with on_decoded() to skip dispatch on validation failure.

pub const input: Event(String)

input event, fires immediately when an input value changes. Payload is the current value. For delayed updates use change.

pub const key_down: Event(KeyEvent)

keydown event, fires when a key is pressed.

pub const key_up: Event(KeyEvent)

keyup event, fires when a key is released.

pub const mouse_down: Event(#(Int, Int, ElementData))

mousedown event, fires when a mouse button is pressed. Payload is (x, y, element).

pub const mouse_enter: Event(ElementData)

mouseenter event, fires when the mouse enters an element’s boundary. Mapped to mouseover with a relatedTarget guard so delegation works.

pub const mouse_leave: Event(ElementData)

mouseleave event, fires when the mouse leaves an element’s boundary. Mapped to mouseout with a relatedTarget guard so delegation works.

pub const mouse_move: Event(#(Int, Int))

mousemove event, fires repeatedly while the mouse moves. Consider pairing with throttle_milliseconds.

pub const mouse_up: Event(#(Int, Int, ElementData))

mouseup event, fires when a mouse button is released.

pub const offline: Event(Nil)

offline event (on window), fires when the browser loses connectivity.

pub fn on(
  component: component.Component(model, message, html),
  event event: Event(payload),
  handler handler: fn(payload) -> message,
) -> component.Component(model, message, html)

Bind an event handler to a component. The handler always dispatches a message; the event argument fixes the payload type so the handler signature is checked at compile time. The binding is registered during component.mount().

The listener is scoped to the component’s own subtree: it fires only for events inside the element matched by the component’s scope selector (its id, recorded via component.scoped). To narrow, attach to a narrower component; to widen, attach to a wider ancestor. For page-level listeners (document keydown, window resize, a root data-message click decoder) use on_global.

A component with no scope still works: the binding falls back to a document-wide listener and a development warning nudges you to add an id (or call component.scoped). Bindings declared inside each and each_live item bodies are ignored, attach them to the each/each_live wrapper or any static ancestor.

component.simple(slice: ..., render: ...)
|> component.scoped("#search")
|> event.on(event: event.input, handler: Search)
pub fn on_decoded(
  component: component.Component(model, message, html),
  event event: Event(payload),
  decoder decoder: fn(payload) -> Result(message, Nil),
) -> component.Component(model, message, html)

Bind an event handler whose decoder may decline to dispatch by returning Error(Nil). Useful for click (the data-message attribute may not match a known message) and form events (validation failure should skip dispatching).

component.fragment([...])
|> component.scoped("#todo-form")
|> event.on_decoded(event: event.form_submit, decoder: submit_todo)
pub fn on_decoded_with_options(
  component: component.Component(model, message, html),
  event event: Event(payload),
  options options: EventOptions,
  decoder decoder: fn(payload) -> Result(message, Nil),
) -> component.Component(model, message, html)

Like on_decoded with an extra EventOptions parameter. See options().

pub fn on_global(
  component: component.Component(model, message, html),
  event event: Event(payload),
  selector selector: String,
  handler handler: fn(payload) -> message,
) -> component.Component(model, message, html)

The page-level counterpart to on: bind a handler with an explicit, verbatim selector rather than scoping to the component’s subtree. Use it for listeners that are genuinely tied to the page and not to any one component: document keydown, window resize, or the app-root data-message click decoder.

It still takes a component first so it composes into the view tree the same way (pipe it onto your root component); the component’s scope is deliberately ignored, only the mount-time tree walk that collects the binding matters. selector is used exactly as given ("document", "window", or any CSS selector), so the listener survives arbitrary DOM churn.

root
|> event.on_global(
  event: event.key_down,
  selector: "document",
  handler: fn(ke) { KeyPressed(ke.key) },
)
pub fn on_global_decoded(
  component: component.Component(model, message, html),
  event event: Event(payload),
  selector selector: String,
  decoder decoder: fn(payload) -> Result(message, Nil),
) -> component.Component(model, message, html)

The on_decoded counterpart to on_global: a page-level binding whose decoder may decline with Error(Nil). The canonical use is the app-root click decoder that recovers every data-message dispatch.

root
|> event.on_global_decoded(
  event: event.click,
  selector: ".lily-ui-root",
  decoder: event.decode_message,
)
pub fn on_global_decoded_with_options(
  component: component.Component(model, message, html),
  event event: Event(payload),
  selector selector: String,
  options options: EventOptions,
  decoder decoder: fn(payload) -> Result(message, Nil),
) -> component.Component(model, message, html)

Like on_global_decoded with an extra EventOptions parameter. See options().

pub fn on_global_with_options(
  component: component.Component(model, message, html),
  event event: Event(payload),
  selector selector: String,
  options options: EventOptions,
  handler handler: fn(payload) -> message,
) -> component.Component(model, message, html)

Like on_global with an extra EventOptions parameter. See options().

pub fn on_with_options(
  component: component.Component(model, message, html),
  event event: Event(payload),
  options options: EventOptions,
  handler handler: fn(payload) -> message,
) -> component.Component(model, message, html)

Like on with an extra EventOptions parameter. See options().

component.simple(slice: ..., render: ...)
|> component.scoped("#search")
|> event.on_with_options(
  event: event.input,
  options: event.options() |> event.debounce_milliseconds(200),
  handler: Search,
)
pub fn once(options: EventOptions) -> EventOptions

Set the handler to fire only the first time. After that, all matching events are ignored.

pub const online: Event(Nil)

online event (on window), fires when the browser regains connectivity.

pub fn options() -> EventOptions

Build an EventOptions with all modifiers off: no debounce, no throttle, fires every time, does not stop propagation or prevent default. Compose with the builder functions to enable modifiers.

pub const paste: Event(Nil)

paste event, fires when text is pasted from the clipboard.

pub const pointer_down: Event(#(Int, Int))

pointerdown event, unifies mouse, touch, and pen.

pub const pointer_move: Event(#(Int, Int))

pointermove event, unifies mouse, touch, and pen.

pub const pointer_up: Event(#(Int, Int))

pointerup event, unifies mouse, touch, and pen.

pub fn prevent_default(options: EventOptions) -> EventOptions

Set event.preventDefault() to fire on every matching event, regardless of debounce or throttle. Use to suppress browser defaults (e.g. drop-target behaviour, native form submission).

pub fn release_arrow_group(
  runtime: client.Runtime(model, message),
  items items: String,
) -> Nil

Remove a focus group registered with arrow_group, matched by the same items selector. No-op if no such group is registered. Call when a transient group (a menu, say) closes; persistent groups (a radio group always on the page) need never release.

pub fn release_focus_trap(
  runtime: client.Runtime(model, message),
) -> Nil

Pop the top focus trap from the stack. If another trap was below it, that trap becomes active again. No-op when the stack is empty. Does not dispatch the popped trap’s on_exit message, call this when the caller is already running its own close logic and just needs the trap unhooked (e.g. a click on a Cancel button that dispatches CloseDialog and restores focus separately).

pub const resize: Event(Nil)

resize event, fires when an element (or window) resizes. Typically used with selector: "window".

pub const scroll: Event(#(Int, Int))

scroll event, payload is (scroll_top, scroll_left). Consider pairing with throttle_milliseconds.

pub fn stop_propagation(options: EventOptions) -> EventOptions

Set event.stopPropagation() to fire before the inner handler. Useful for delegated events that should not bubble further up.

pub const submit: Event(Nil)

submit event, fires when a form is submitted, with no payload. Prevents the browser’s default form submission. Use for controlled forms (input state already in the model). For uncontrolled forms, use form_submit.

pub fn throttle_milliseconds(
  options: EventOptions,
  value: Int,
) -> EventOptions

Set the throttle interval in milliseconds. Events fire at most once per interval; subsequent events within the window are dropped.

pub const touch_end: Event(ElementData)

touchend event, fires when all touches are removed.

pub const touch_move: Event(#(Int, Int))

touchmove event, fires repeatedly while a touch point moves. Consider pairing with throttle_milliseconds.

pub const touch_start: Event(#(Int, Int))

touchstart event, fires when a touch point is placed.

pub fn watch_escape_dismiss() -> Nil

Install a document-level Escape-to-dismiss handler. On Escape, the topmost element carrying data-lily-escape-dismiss (a CSS selector) has that selector’s target clicked, so dismissal flows through the ordinary data-message delegation. Unlike watch_focus_traps it does not trap focus, so it suits non-modal overlays (popover, menu, select, date picker) that should close on Escape while staying non-modal.

A component library renders the data attribute on its overlays and calls this once at startup. Idempotent: repeated calls install a single handler.

pub fn watch_file_drops() -> Nil

Install a document-level drag-and-drop handler for file dropzones. A dropzone opts in with data-lily-file-drop="<input selector>": dropping files onto it assigns them to that input and fires a change event, so drops flow through the same path as picking files. While a drag is over the zone it carries a data-lily-file-dragover attribute for styling.

A component library renders the data attribute on its dropzones and calls this once at startup. Idempotent: repeated calls install a single handler.

pub fn watch_focus_traps() -> Nil

Install a document-level observer that activates a focus trap on any element carrying data-lily-focus-trap, declaratively, for as long as it is in the DOM. This is the hands-off counterpart to focus_trap: rather than trapping imperatively from an update hook, an element declares its own trap and this manages the lifecycle. While the element is present, Tab and Shift+Tab are confined to it and focus is seeded to data-lily-focus-trap-initial (or the first focusable descendant); when it leaves the DOM, focus returns to whatever was focused before. If it also carries data-lily-focus-trap-dismiss (a CSS selector), the Escape key clicks that element, so dismissal flows through the ordinary data-message delegation; without it Escape is inert.

The primitive has no notion of dialogs or modals: any element can declare a trap (a wizard step, a command palette, a modal recipe). A component library renders the data attributes and calls this once at startup. Idempotent: repeated calls install a single observer.

pub const wheel: Event(#(Float, Float))

wheel event, payload is (delta_x, delta_y). Useful for scroll hijacking and zoom controls.

Search Document