Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Form controls

Before you start

This guide extends the profile form from Typed forms and validation, which renders text and email inputs with their errors. It covers the other controls a form needs.

Render richer controls

Ask the form for a field view, then render the control you need. Field views provide text, email, tel, date, search, password, hidden, checkbox, textarea, and select helpers. Normal attributes such as autocomplete, maxlength, required, multiple, or CSS classes remain available:

Fragment
scala
val bio  = profileForm.field(Profile.Bio)
val role = profileForm.field(Profile.Role)

bio.textarea(rows := 6, bio.validationAttributes)
role.select(List("reader" -> "Reader", "editor" -> "Editor"), role.validationAttributes)

Every helper renders the retained raw value the visitor typed, not the decoded domain value, so a control keeps showing malformed input while its error is visible. The native tel, date, and search helpers do not introduce parsing, trimming, or domain validation. ARIA feedback stays opt-in through validationAttributes.

A select marks every option found in rawValues; for a multiple select, use a repeated-value field such as Root.texts.

There are no input rendering helpers for numeric, radio-group, or file controls. Use Root.field plus ordinary HTML controls for custom decoding, and use liveFileInput with the upload API for files.

An unfocused native date input can receive an invalid value attribute from a server update while exposing an empty DOM input.value. A later form snapshot contains the browser's current value, not the original attribute, and can replace the retained server value. Use a text input when arbitrary malformed date text must remain editable and visible.

Decode a Boolean checkbox

For a Boolean field, use FieldInput.checkbox() to pair strict decoding with typed initialization and updates:

Source
scala
object NotificationForm:
  final case class Preferences(enabled: Boolean)

  val Root       = FormRoot("notifications")
  val Enabled    = Root.field("enabled", FieldInput.checkbox())
  val Definition = Root.product[Preferences](Enabled)
  val initial    = Definition.initial(Enabled.initial(true))

  enum Msg:
    case Updated(update: Definition.Update)
    case Submitted(event: Definition.Event)

  def render(form: Signal[Definition.Form]): HtmlElement[Msg] =
    val binding = form.bind(DomRef("notifications-form"), Msg.Updated(_), Msg.Submitted(_))
    val enabled = binding.field(Enabled)
    binding.render(
      label(forId := enabled.id, "Enable notifications"),
      enabled.checkbox(enabled.validationAttributes),
      enabled.errorFeedback(error => error.map(_.message)),
      button(typ := "submit", "Save")
    )

A checkbox is checked when its submitted value occurs in rawValues. Its default checked value is "true", or pass an explicit value. The helper does not generate a hidden unchecked value.

The codec decodes absence as false and exactly one "true" value as true. Any other single value produces invalid_checkbox; multiple values produce duplicate_value, even when identical. Encoding false omits the value, while encoding true emits the checked token. Both issues are configurable through invalidIssue and duplicateIssue.

Custom tokens must match on both sides: pair FieldInput.checkbox("yes") with control.checkbox("yes"). Matching is exact, without trimming or case folding; an explicitly configured empty token is distinct from absence. The default codec rejects "on" and "false", and does not accept hidden unchecked fallback inputs. In change and submit payloads, omitted or disabled controls decode as false; the codec does not preserve their previous values. A checkbox-only repeated row still needs its row.presence() control to retain the row when unchecked. Requiring a checked value is a separate semantic rule, expressible with field.validate(issue)(identity).

Offer several checkbox choices

For repeated checkbox choices, use Root.texts, not the Boolean FieldInput.checkbox codec. A bound FormControl provides item(key: String): FormControlItem[Msg] so each checkbox has its own label target while all items submit under the same field name:

Source
scala
object TopicForm:
  final case class Preferences(topics: Vector[String])

  val Root       = FormRoot("subscriptions")
  val Topics     = Root.texts("topics")
  val Definition = Root.product[Preferences](Topics)

  enum Msg:
    case Updated(update: Definition.Update)
    case Submitted(event: Definition.Event)

  def render(
    form: Signal[Definition.Form],
    researchToken: Signal[String]
  ): HtmlElement[Msg] =
    val binding  = form.bind(DomRef("subscriptions-form"), Msg.Updated(_), Msg.Submitted(_))
    val topics   = binding.field(Topics)
    val news     = topics.item("news-choice")
    val research = topics.item("research-choice")
    binding.render(
      fieldSet(
        legend("Topics"),
        news.checkbox("news", topics.validationAttributes),
        label(forId := news.id, "News"),
        research.checkbox(researchToken, topics.validationAttributes),
        label(forId := research.id, "Research"),
        topics.errorFeedback(error => error.map(_.message))
      ),
      button(typ := "submit", "Save")
    )
end TopicForm

The recipe uses a static "news" token and a Signal[String] research token; both item.checkbox(checkedValue, mods*) overloads require an explicit token. Each item's id and name are Signal[String]. Here every name is exactly subscriptions[topics]: no item key, index, or extra [] is appended. A submission with no successful items decodes to Vector.empty.

An item key is an immutable, stable identity, independent of its submitted token; it is not a signal. Arbitrary strings, including the empty string, are allowed; null is rejected. Keys are encoded losslessly into ASCII IDs scoped to the form instance, field, and repeated-row identity when present. The exact ID format is private: use item.id, not a reconstructed ID. Keep keys unique among items of the same rendered control.

Checked state uses exact membership of the current token in the parent's rawValues. Items with different keys but the same token are checked together; their submitted values do not distinguish them. Changing a reactive token updates the checkbox projection but does not rewrite those raw values or migrate an old selection. Native successful-control rules still apply: unchecked, disabled, and removed checkboxes are omitted from the next form snapshot. There are no hidden unchecked fallbacks or automatic preservation of omitted selections.

Use a fieldset and legend for the group and a separate label targeting each item's ID. Pass the parent's validationAttributes to each checkbox explicitly and render one parent errorFeedback for the group. Configure any application .onBlur(...) callback on the parent before calling .item(...). With FormFeedback.AfterBlur, blurring any item makes the parent's feedback eligible, including when focus moves to another item in the same group. This is not whole-group blur; custom composite widgets still own that boundary.

For dynamic choices, use .splitBy with stable choice keys and create each item inside the keyed rendering scope. Do not use changing tokens or display indexes as identity. Choice keys distinguish checkboxes for one repeated-value field; they are not form-row keys.

Route multiple submit actions

Use one definition-owned FormSubmitter when several native submit buttons send the same typed form to different operations, such as Preview profile and Save profile. Give each enum case an explicit wire value so renaming a Scala case does not silently change the browser contract:

Source
scala
enum Intent(val wireValue: String):
  case Preview extends Intent("preview")
  case Save    extends Intent("save")

val Submitter = Definition.submitter(Intent.values)(_.wireValue)

Bind the submitter once on the form, so the submit message receives the decoded action alongside the event:

Source
scala
Profile.Definition.onSubmit(Profile.Submitter)(Msg.Submit.apply),

Render each button from the same submitter:

Source
scala
div(
  cls := "docs-profile-actions",
  Profile.Submitter.button(Profile.Intent.Preview)("Preview profile"),
  Profile.Submitter.button(Profile.Intent.Save)(
    submission.replaceTextWith("Saving..."),
    "Save profile"
  ),
  button(typ := "button", on.click(Msg.Reset), "Reset form")
)

Then handle each decoded action:

Source
scala
case Msg.Submit(event, intent) =>
  intent match
    case Right(Profile.Intent.Preview) =>
      ZIO.succeed(model.copy(form = event.form, previewed = event.valueOption, saved = None))
    case Right(Profile.Intent.Save) =>
      ZIO.succeed(model.copy(form = event.form, previewed = None, saved = event.valueOption))
    case Left(_) =>
      ZIO.succeed(model.copy(form = event.form, previewed = None, saved = None))

The binding decodes exactly one value from lossless FormData. Missing, unknown, and duplicate values remain distinct DecodeError cases. There is no implicit default for an Enter-triggered or programmatic submission without a named submitter. Every decoded action remains untrusted and must be authorized before performing state-changing work.

The action result and event.form.result are deliberately independent. A preview can inspect the rebuilt field state even when the complete domain product is invalid, while Save can require a valid value. All buttons still submit the form, so all validation errors become visible. Keep continuous validation on onChange; use an ordinary typed click message for an operation that does not need the submitted form.

The default browser name is generated below the form root in Scalive's reserved namespace. Supply a checked custom name only when an existing HTTP or Phoenix boundary requires one:

Fragment
scala
val CompatibleSubmitter =
  Definition.submitter(Intent.values, wireName = "action")(_.wireValue)

Use Submitter.attributes(intent) for a native submit input, a form-associated external control, or other custom markup. The runnable typed profile form demonstrates both buttons.

Write a custom input

A custom FieldInput defines both structural decoding and the inverse used by typed server updates. The field view continues to render the retained raw values rather than the decoded domain value, so a text control can show malformed input even when decoding fails:

Source
scala
object QuantityForm:
  final case class Order(quantity: Int)

  val PositiveInteger = FieldInput[Int](
    {
      case Vector(value) =>
        value.toIntOption
          .filter(_ > 0).toRight(
            FieldIssues.one(FieldIssue("validation.quantity.positive", Some("positive_integer")))
          )
      case Vector() =>
        Left(FieldIssues.one(FieldIssue("validation.quantity.required", Some("required"))))
      case _ =>
        Left(
          FieldIssues.one(FieldIssue("validation.quantity.duplicate", Some("duplicate_value")))
        )
    },
    quantity => Vector(quantity.toString)
  )

  val Root       = FormRoot("order")
  val Quantity   = Root.field("quantity", PositiveInteger)
  val Definition = Root.product[Order](Quantity)

  def quantityControl(form: Definition.Form): HtmlElement[Nothing] =
    val quantity = form.field(Quantity)
    div(
      label(forId := quantity.id, "Quantity"),
      input(
        typ      := "text",
        idAttr   := quantity.id,
        nameAttr := quantity.name,
        value    := quantity.fieldValue,
        quantity.validationAttributes
      ),
      quantity.errorFeedback(error => error.message)
    )
end QuantityForm

Use the lower-level FormCodec only when a custom control or transport adapter does not need definition-owned state, bounded projection, or interaction-aware errors:

Source
scala
object RawPickerControl:
  val Codec = FormCodec.requiredString("picker[value]")

  enum Msg:
    case Changed(event: RawFormEvent[String])

  def render: HtmlElement[Msg] =
    form(
      on.change.form(Codec)(Msg.Changed(_)),
      input(typ := "text", nameAttr := "picker[value]")
    )