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 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 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 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 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.