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:
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()def checkbox(checkedValue: String = ..., invalidIssue: FieldIssue = ..., duplicateIssue: FieldIssue = ...): FieldInput[Boolean]A strict checkbox codec: absence is false; exactly one checkedValue is true. to pair
strict decoding with typed initialization and updates:
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")
)View source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:69-89)
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:
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 TopicFormView source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:93-123)
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 FormSubmitterclass FormSubmitter[Owner, Schema, Action <: reflect.Enum]A finite, definition-owned contract for rendering and decoding form submit controls.
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:
enum Intent(val wireValue: String):
case Preview extends Intent("preview")
case Save extends Intent("save")
val Submitter = Definition.submitter(Intent.values)(_.wireValue)View source (documentation/site/src/scalive/docs/examples/ProfileFormExample.scala:143-147)
Bind the submitter once on the form, so the submit message receives the decoded action alongside the event:
Profile.Definition.onSubmit(Profile.Submitter)(Msg.Submit.apply),View source (documentation/site/src/scalive/docs/examples/ProfileFormExample.scala:59-59)
Render each button from the same submitter:
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")
)View source (documentation/site/src/scalive/docs/examples/ProfileFormExample.scala:96-104)
Then handle each decoded action:
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))View source (documentation/site/src/scalive/docs/examples/ProfileFormExample.scala:18-25)
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:
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:
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 QuantityFormView source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:28-65)
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:
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]")
)View source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:205-215)
Related tasks
Choose when each control's errors appear with Form feedback and recovery.
Repeat a group of controls with Repeated form rows.
Accept files with File uploads.