Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline

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 div, button, and table. Pass attributes and children in document order:

scala
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]. 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, sectionTag, headerTag, htmlRootTag, headTag, and idAttr. Use htmlTag(name) only when the framework does not provide the element you need.

Move Content And Wrap Focus

Use portal when content must remain owned by its LiveView but appear elsewhere in the document, such as below a root-level modal container:

scala
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 focusWrap for content whose keyboard focus should cycle at its boundaries:

scala
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 :=. Each HtmlAttr[V] 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) for application data-* attributes and the aria namespace for ARIA attributes:

scala
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) with an explicit encoder rather than assembling rendered HTML:

scala
private val popover = htmlAttr("popover", scalive.codecs.StringAsIsEncoder)

div(popover := "manual", "Details")

Avoid rawHtml for ordinary content. It bypasses escaping and should be limited to HTML that the application already trusts.

Bind Events To Messages

Use the on bindings to produce the view's message type. A constant binding is enough when the event carries no application value:

scala
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 view 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 withValue when an event's value should construct the message, and use withValueOption when a missing value is meaningful:

scala
enum Msg:
  case SearchChanged(value: String)

input(
  typ := "search",
  on.blur.withValue(Msg.SearchChanged.apply)
)

withValue supplies an empty string when the payload has no value. withValueOption preserves that case as None. The lower-level function form of on.click receives the binding payload as Map[String, String].

Configure rate limiting with debounce(duration) before supplying the message. Durations are rendered in milliseconds, and negative durations are rejected:

scala
import scala.concurrent.duration.*

input(on.blur.debounce(300.millis).withValue(Msg.SearchChanged.apply))

Key Repeated Content

Use splitBy(key) { ... } 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:

scala
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 splitByIndex 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:

Source
scala
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 ShoppingCartExample

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.