HTML and event bindings
Prerequisites
Start with the LiveView model and messages introduced in
Models and messages. The DSL uses ordinary
Scala expressions and collections rather than introducing a template language.
Build An HTML Tree
Import scalive.*, then call tag values such as divlazy val div: HtmlTagRepresents a generic container with no special meaning.,
buttonlazy val button: HtmlTagA button, and tablelazy val table: HtmlTagRepresents data with more than one dimension..
Pass attributes and children in document order:
def view(model: Signal[Model]): HtmlElement[Msg] =
sectionTag(
cls := "cart",
aria.label := "Shopping cart",
h1("Cart"),
p(model.map(model => s"${model.itemCount} items"))
)A tag call produces an HtmlElement[Msg]class HtmlElement[+Msg](tag: HtmlTag, mods: Vector[Mod[Msg]])An immutable element in Scalive's typed, protocol-neutral HTML algebra.. Strings
become escaped text content, nested elements become child content, and an
IterableOnce of static modifiers can be passed directly. Derive dynamic text
and attributes with signal .map; use staged signal operators such as
choose, option, and splitBy for conditional and repeated content. The
view method constructs this signal-backed view graph once per graph lifetime
rather than rebuilding the tree after every model update.
Use the named tag definitions when they exist. The DSL gives Scala-safe names to
HTML names that would otherwise conflict with Scala or another exported symbol:
for example, sectionTaglazy val sectionTag: HtmlTagRepresents a generic section of a document, i.e., a thematic grouping of
content, typically with a heading.,
headerTaglazy val headerTag: HtmlTagDefines the header of a page or section. It often contains a logo, the
title of the Web site, and a navigational table of content.,
htmlRootTaglazy val htmlRootTag: HtmlTagRepresents the root of an HTML or XHTML document. All other elements must
be descendants of this element.,
headTaglazy val headTag: HtmlTagRepresents a collection of metadata about the document, including links to,
or definitions of, scripts and style sheets., and
idAttrlazy val idAttr: HtmlAttr[String]This attribute defines a unique identifier (ID) which must be unique in
the whole document. Its purpose is to identify the element when linking
(using a fragment identifier), scripting, or styling (with CSS)..
Use htmlTag(name)def htmlTag(name: String, void: Boolean = ...): HtmlTagCreates a reusable HTML tag definition. only when the framework does not
provide the element you need.
Move Content And Wrap Focus
Use portaldef portal[Msg](id: String, target: DomSelector, container: String = ..., wrapperClass: Option[String] = ...)(mods: |[Mod[Msg], IterableOnce[Mod[Msg]]]*): HtmlElement[Msg] when content must remain owned by its
LiveView but appear elsewhere in the document, such as below a root-level modal
container:
portal("cart-dialog", target = DomSelector.css("#modal-root"))(
sectionTag(aria.label := "Cart", "...")
)The helper renders a source <template> and moves one generated wrapper to the
explicit CSS target in the browser. Keep id stable and unique, ensure the target
exists, and use container and wrapperClass only to customize that wrapper.
DomSelector.current and invalid container tag names are rejected, but selector
syntax and target existence are not checked server-side. A portal preserves event,
hook, component, and nested LiveView ownership; it is not a security boundary and
does not make untrusted HTML safe.
Use focusWrapdef focusWrap[Msg](id: String, mods: Mod[Msg]*)(content: Mod[Msg]*): HtmlElement[Msg]Renders a keyboard-focus boundary using Phoenix's Phoenix.FocusWrap hook. for content whose keyboard focus
should cycle at its boundaries:
focusWrap("cart-dialog-focus", cls := "dialog-body")(
button(on.click(Msg.Close), "Close")
)Keep its id stable and unique. Pass only wrapper attributes and bindings in
mods; do not override id or phx-hook, and put all child content in the second
argument list so the generated focus sentinels remain first and last. The helper
depends on the Phoenix client hook. It does not add dialog roles, labels, background
inertness, authorization, or a no-JavaScript focus trap; provide those separately.
Set Typed Attributes
Assign attributes with :=def :=(value: V): Mod.Attr[Nothing]. Each HtmlAttr[V]class HtmlAttr[V](name: String, codec: codecs.Encoder[V, String])A typed, validated HTML attribute definition.
accepts its declared Scala value type, so disabled := model.lines.isEmpty takes
a Boolean while cls := "cart" takes a String. Boolean presence attributes
are emitted when true and omitted when false.
Use dataAttr(name)def dataAttr(suffix: String): HtmlAttr[String]Creates a custom data-* string attribute from suffix. for application data-* attributes and
the ariaobject ariaARIA attribute definitions. namespace for ARIA attributes:
button(
typ := "button",
dataAttr("product") := product.sku,
aria.label := s"Add ${product.name}",
disabled := !product.available,
on.click(Msg.Add(product)),
product.name
)For an attribute absent from the DSL, use htmlAttr(name, encoder)def htmlAttr[V](name: String, encoder: codecs.Encoder[V, String]): HtmlAttr[V]Creates a typed HTML attribute definition. with
an explicit encoder rather than assembling rendered HTML:
private val popover = htmlAttr("popover", scalive.codecs.StringAsIsEncoder)
div(popover := "manual", "Details")Avoid rawHtmldef rawHtml(html: String): Mod[Nothing] for ordinary content. It bypasses escaping and should be limited
to HTML that the application already trusts.
Bind Events To Messages
Use the onobject on bindings to produce the view's message
type. A constant binding is enough when the event carries no application value:
enum Msg:
case Add(product: Product)
case Clear
button(on.click(Msg.Add(product)), "Add")
button(on.click(Msg.Clear), "Clear")The message type remains part of the whole tree. If
viewdef view(model: Signal[Model]): HtmlElement[Msg] returns
HtmlElement[Msg], a binding that produces another message type does not
compile. The shopping cart uses this directly for product-specific add and
remove messages.
Use withValuedef withValue[Msg](f: String => Msg): Mod.Attr[Msg] when an event's value
should construct the message, and use
withValueOptiondef withValueOption[Msg](f: Option[String] => Msg): Mod.Attr[Msg] when a missing value is
meaningful:
enum Msg:
case SearchChanged(value: String)
input(
typ := "search",
on.blur.withValue(Msg.SearchChanged.apply)
)withValuedef withValue[Msg](f: String => Msg): Mod.Attr[Msg] supplies an empty string when the payload has no value.
withValueOptiondef withValueOption[Msg](f: Option[String] => Msg): Mod.Attr[Msg] preserves that case as None. The lower-level function form of
on.clicklazy val click: HtmlAttrBinding receives the
binding payload as Map[String, String].
Configure rate limiting with debounce(duration)def debounce(duration: concurrent.duration.FiniteDuration): HtmlAttrBinding
before supplying the message. Durations are rendered in milliseconds, and
negative durations are rejected:
import scala.concurrent.duration.*
input(on.blur.debounce(300.millis).withValue(Msg.SearchChanged.apply))Key Repeated Content
Use splitBy(key) { ... }extension def splitBy[A, Items <: Iterable[A]](items: Signal[Items])[Key, Msg](key: A => Key)(project: (Key, Signal[A]) => HtmlElement[Msg]): Mod[Msg] on a collection
signal when entries have stable domain identity. Choose a key that is unique
within that collection and
does not change while the entry represents the same entity:
tbody(
model.map(_.lines).splitBy(_.product.sku) { (sku, line) =>
tr(
dataAttr("cart-line") := sku,
td(line.map(_.product.name)),
td(line.map(_.quantity.toString))
)
}
)Scalive's keyed diff uses the keys to match entries between updates. Current
tests cover unchanged keyed subtrees producing no diff, reorders producing
index changes without resending unchanged entry payloads, changed entries being
merged into reorder payloads, and deletion reducing the keyed count. Prefer a
SKU, database identifier, or another domain key. Use
splitByIndexextension def splitByIndex[A, Items <: Iterable[A]](items: Signal[Items])[Msg](project: (Int, Signal[A]) => HtmlElement[Msg]): Mod[Msg] only when
position is the identity and reordering is not meaningful.
The splitBy key remains server-side and is not rendered as an HTML id.
Phoenix's client merges the compact keyed payload before patching the resulting
HTML. Add a stable HTML id to each repeated root when the corresponding browser
node itself must survive a move, such as a row containing focused input or
browser-managed state.
The shopping cart example combines typed attributes, product-specific event messages, staged conditional content, and SKU-keyed rows in one view graph:
final class ShoppingCartExample
extends LiveView[ShoppingCartExample.Msg, ShoppingCartExample.Model]:
import ShoppingCartExample.*
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model.empty)
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Add(product) => ZIO.succeed(model.add(product))
case Msg.Remove(product) => ZIO.succeed(model.remove(product))
case Msg.Clear => ZIO.succeed(Model.empty)
override def view(model: Signal[Model]): HtmlElement[Msg] =
div(
cls := "docs-cart",
fieldSet(
cls := "docs-cart-products",
dataAttr("example-controls") := "",
legend("Products"),
div(
cls := "docs-cart-product-grid",
Product.all.map { product =>
button(
typ := "button",
dataAttr("product") := product.sku,
on.click(Msg.Add(product)),
span(cls := "docs-cart-product-name", product.name),
span(cls := "docs-cart-product-price", money(product.priceInCents))
)
}
)
),
sectionTag(
cls := "docs-cart-summary",
aria.label := "Shopping cart",
headerTag(
div(
h4("Cart"),
p(
dataAttr("cart-item-count") := "",
role := "status",
aria.live := "polite",
aria.atomic := true,
model.map(model => itemCountLabel(model.itemCount))
)
),
button(
typ := "button",
dataAttr("cart-clear") := "",
disabled := model.map(_.lines.isEmpty),
on.click(Msg.Clear),
"Clear"
)
),
model
.map(_.lines.isEmpty).choose(
p(dataAttr("cart-empty") := "", cls := "docs-cart-empty", "Add a product to begin."),
div(
cls := "docs-cart-table-scroll",
table(
cls := "docs-cart-table",
aria.label := "Cart contents",
thead(
tr(
th("Product"),
th("Quantity"),
th("Subtotal"),
th(cls := "docs-visually-hidden", "Actions")
)
),
tbody(
model.map(_.lines).splitBy(_.product.sku) { (sku, line) =>
tr(
dataAttr("cart-line") := sku,
td(
strong(line.map(_.product.name)),
span(
cls := "docs-cart-unit-price",
line.map(line => money(line.product.priceInCents))
)
),
td(dataAttr("cart-quantity") := "", line.map(_.quantity.toString)),
td(
dataAttr("cart-subtotal") := "",
line.map(line => money(line.subtotalInCents))
),
td(
button(
typ := "button",
dataAttr("remove-product") := sku,
aria.label := line.map(line => s"Remove one ${line.product.name}"),
on.click(line.map(line => Msg.Remove(line.product))),
"Remove one"
)
)
)
}
),
tfoot(
tr(
th("Total"),
td(),
td(
dataAttr("cart-total") := "",
model.map(model => money(model.totalInCents))
),
td()
)
)
)
)
)
)
)
private def money(cents: Int): String =
val dollars = cents / 100
val remainder = cents % 100
f"$$$dollars%d.$remainder%02d"
private def itemCountLabel(count: Int): String =
if count == 1 then "1 item" else s"$count items"
end ShoppingCartExample
object ShoppingCartExample:
enum Product(val sku: String, val name: String, val priceInCents: Int):
case Coffee extends Product("coffee", "Coffee beans", 1299)
case Notebook extends Product("notebook", "Notebook", 850)
case Sticker extends Product("sticker", "Scalive sticker", 250)
object Product:
val all = Vector(Product.Coffee, Product.Notebook, Product.Sticker)
final case class Line(product: Product, quantity: Int):
def subtotalInCents: Int = product.priceInCents * quantity
final case class Model(lines: Vector[Line]):
def add(product: Product): Model =
lines.indexWhere(_.product == product) match
case -1 => copy(lines = lines :+ Line(product, quantity = 1))
case index =>
val current = lines(index)
copy(lines = lines.updated(index, current.copy(quantity = current.quantity + 1)))
def remove(product: Product): Model =
copy(lines = lines.flatMap { line =>
if line.product != product then Some(line)
else if line.quantity > 1 then Some(line.copy(quantity = line.quantity - 1))
else None
})
def itemCount: Int = lines.map(_.quantity).sum
def totalInCents: Int = lines.map(_.subtotalInCents).sum
object Model:
val empty = Model(Vector.empty)
enum Msg:
case Add(product: Product)
case Remove(product: Product)
case Clear
end ShoppingCartExampleView source (documentation/site/src/scalive/docs/examples/ShoppingCartExample.scala:8-170)
For the model and handler behind this tree, read Models, messages, and effects. For the diffing model, read Rendering, bindings, and diffs. For explicit ID-addressed inserts and deletes in frequently changing collections, read Streams and collection updates.
Related Tasks
Give frequently changing rows targeted updates with Streams and collection updates.
Build checked links and destinations with Routes, parameters, and navigation.
Assert rendered forms and markup with Testing LiveViews.