Forms & Validation
Type-safe form fields, built-in validation rules, and the draft/commit pattern.
The basics
Every form field follows the same pattern: current value + onChange callback, with an options builder for configuration.
class ContactForm(ctx: NoProps) : PureComponent(ctx) {
private var name by value("")
private var email by value("")
override fun VDom.render() {
ui.form {
UiInputField(name, { name = it }) {
label("Name")
placeholder("Enter your name")
}
UiInputField(email, { email = it }) {
label("Email")
placeholder("you@example.com")
accepts(notBlank(), validEmail())
}
ui.blue.button {
onClick { submit() }
+"Send"
}
}
}
} Field types
Text input
UiInputField(value, { value = it }) {
label("Username")
placeholder("Choose a username")
rightClearingIcon() // shows an X to clear the field
} Number input
// Int field — type is inferred, renders as type="number"
UiInputField(age, { age = it }) {
label("Age")
accepts(greaterThanOrEqual(0))
}
// Double field with step
UiInputField(price, { price = it }) {
label("Price")
step(0.01)
leftIcon { icon.dollar_sign }
}
// Nullable number — empty input returns null
UiInputField.nullable(weight, { weight = it }) {
label("Weight (optional)")
} The input type is inferred from the value — pass an Int, get an integer field. Pass a
Double, get a decimal field.
Password
UiPasswordField(password, { password = it }) {
label("Password")
revealPasswordIcon() // eye icon to toggle visibility
}
// Or bind directly to a property
UiPasswordField(::password) {
label("Password")
} Date and time
// Date — works with MpLocalDate
UiDateField(birthday, { birthday = it }) {
label("Birthday")
}
// DateTime — works with MpLocalDateTime, MpInstant, MpZonedDateTime
UiDateTimeField(appointmentTime, { appointmentTime = it }) {
label("Appointment")
}
// Time — works with MpLocalTime
UiTimeField(startTime, { startTime = it }) {
label("Start time")
}
// Nullable date — empty returns null
UiDateField.nullable(endDate, { endDate = it }) {
label("End date (optional)")
}
// ZonedDateTime with explicit timezone
UiDateTimeField(timestamp, timezone, { timestamp = it }) {
label("Event time")
} These work with Ultra's multiplatform date types (MpLocalDate, MpLocalDateTime, MpLocalTime,
MpZonedDateTime, MpInstant).
Textarea
UiTextArea(notes, { notes = it }) {
label("Notes")
placeholder("Additional information...")
verticalAutoResize(true) // grows with content (default: true)
} The textarea auto-resizes vertically by default. Use replaceSelection(text) on the component to
insert text at the cursor position.
Checkbox
// Simple boolean
UiCheckboxField(agreed, { agreed = it }) {
label("I agree to the terms")
}
// Toggle style
UiCheckboxField(notifications, { notifications = it }) {
label("Enable notifications")
toggle()
}
// Slider style with custom on/off values
UiCheckboxField(
value = status,
on = "active",
off = "inactive",
onChange = { status = it },
) {
label("Status")
slider()
} Select / Dropdown
SelectField(selectedCountry, { selectedCountry = it }) {
label("Country")
placeholder("Choose a country")
option(realValue = "de", formValue = "de", display = "Germany")
option(realValue = "us", formValue = "us", display = "United States")
option(realValue = "jp", formValue = "jp", display = "Japan")
searchableBy { option, query -> option.formValue.contains(query, ignoreCase = true) }
} Nullable fields
Most field types have a .nullable() variant that returns null when the input is empty:
UiInputField.nullable(middleName, { middleName = it }) {
label("Middle name (optional)")
}
UiDateField.nullable(endDate, { endDate = it }) {
label("End date (optional)")
} List fields
For editing a list of items with add/remove/reorder:
ListField(
items = tags,
onChange = { tags = it },
) {
renderItem { ctx ->
UiInputField(ctx.item, { ctx.modify(it) }) {
label("Tag #${ctx.idx + 1}")
rightIcon({ red.times }) { component ->
component.onClick { ctx.remove() }
}
}
}
renderAdd { ctx ->
ui.button {
onClick { ctx.add("") }
+"Add tag"
}
}
} Each ItemCtx provides: idx, item, all, modify(),
remove(), copy(), and swapWith(idx).
Common field options
All field types share these options from the base FieldOptions interface:
| Option | What it does |
|---|---|
label("Text") | Field label above the input |
placeholder("Text") | Placeholder text inside the input |
name("field_name") | HTML name attribute |
disabled() | Disable the field |
required() | Mark as required |
accepts(rule1, rule2, ...) | Add validation rules |
appear { orange } | SemanticUI appearance/color |
Input-specific options
Available on UiInputField, UiPasswordField, and date/time fields:
| Option | What it does |
|---|---|
autofocus() | Focus on mount |
autofocusOnDesktop(responsive) | Focus on mount only on desktop (avoids mobile keyboard pop-up) |
step(0.01) | Step increment for number inputs |
leftIcon { icon.search } | Icon on the left side |
rightIcon { icon.check } | Icon on the right side |
rightIcon({ icon.times }) { it.onClick { clear() }
} | Right icon with click handler |
rightClearingIcon() | Built-in clear button (grey X icon) |
revealPasswordIcon() | Toggle password visibility (eye icon) |
leftLabel { +"https://" } | Attached label on the left |
rightLabel { +"kg" } | Attached label on the right |
wrapFieldWith { fluid } | Wrap the input in a SemanticUI modifier |
Checkbox-specific options
| Option | What it does |
|---|---|
toggle() | Render as a toggle switch |
slider() | Render as a slider |
style { fitted } | Custom SemanticUI checkbox styling |
autofocus() | Focus on mount |
Textarea-specific options
| Option | What it does |
|---|---|
verticalAutoResize(true) | Auto-grow height with content (default: on) |
customize { rows = 5 } | Custom textarea attributes (rows, cols, etc.) |
autofocus() | Focus on mount |
Select-specific options
| Option | What it does |
|---|---|
option(realValue, formValue, display) | Add a selectable option |
searchableBy { option, query -> ... } | Enable filtering with a predicate |
autoSuggest { query -> ... } | Async option suggestions |
compareBy { a, b -> ... } | Custom equality for selected value |
inverted() | Inverted styling |
css { ... } | Custom CSS |
Validation
Add validation rules with accepts():
UiInputField(username, { username = it }) {
label("Username")
accepts(
notBlank(),
minLength(3),
maxLength(20),
)
}
UiInputField(age, { age = it }) {
label("Age")
accepts(
greaterThanOrEqual(18, "Must be 18 or older"),
lessThan(150),
)
} Validation errors render automatically as red pointing labels below the field, but only after the field has been touched (edited by the user).
Built-in validators
String rules
| Rule | What it checks |
|---|---|
notBlank() | Not empty or whitespace-only |
notEmpty() | Not empty string |
minLength(n) | At least n characters |
maxLength(n) | At most n characters |
exactLength(n) | Exactly n characters |
validEmail() | Valid email format |
validUrlWithProtocol() | Valid URL with protocol |
Number rules
| Rule | What it checks |
|---|---|
greaterThan(n) | Value > n |
greaterThanOrEqual(n) | Value ≥ n |
lessThan(n) | Value < n |
lessThanOrEqual(n) | Value ≤ n |
inRange(from, to) | Value between from and to |
Collection rules
| Rule | What it checks |
|---|---|
notEmpty() | Collection is not empty |
minCount(n) | At least n items |
maxCount(n) | At most n items |
exactCount(n) | Exactly n items |
Generic rules
| Rule | What it checks |
|---|---|
equalTo(value) | Exact equality |
notEqualTo(value) | Not equal |
anyOf(collection) | Value is in the collection |
noneOf(collection) | Value is not in the collection |
given({ check }) { "msg" } | Custom predicate |
nonNull() | Value is not null |
nullOrElse(innerRule) | Null is ok, otherwise must pass inner rule |
Custom messages
Every rule accepts a custom error message — either a static string or a lambda that receives the current value:
accepts(
notBlank("This field is required"),
minLength(3) { "Must be at least 3 characters, got ${it.length}" },
) Composing rules
// OR — passes if either rule passes
accepts(empty() or validEmail())
// Any of — passes if any rule passes
accepts(anyRuleOf(empty(), validEmail(), validUrlWithProtocol()))
// All of — all must pass (same as listing them individually)
accepts(allRulesOf(notBlank(), minLength(3), maxLength(50))) FormController and FormObserver
The FormController tracks validity across all form fields in a component. It works through messaging — form fields send FormFieldMountedMessage, FormFieldUnmountedMessage,
and FormFieldInputChanged messages that bubble up the component tree. The FormController listens
for these messages and tracks each field's state.
formController()
Creates a controller that stops message propagation. Form field messages won't bubble past this component. Use this when the component owns its form:
class RegistrationForm(ctx: NoProps) : PureComponent(ctx) {
private var name by value("")
private var email by value("")
private val formCtrl = formController()
override fun VDom.render() {
ui.form {
UiInputField(name, { name = it }) {
label("Name")
accepts(notBlank())
}
UiInputField(email, { email = it }) {
label("Email")
accepts(notBlank(), validEmail())
}
// Disable button when form is invalid
ui.blue.button.given(!formCtrl.isValid) { disabled }.then {
onClick {
if (formCtrl.validate()) {
submitForm()
}
}
+"Register"
}
}
}
} formObserver()
Creates a controller that does not stop message propagation. Form field messages continue bubbling to parent components. Use this when a parent component also needs to track validity — for example, a page that contains multiple sub-forms:
class FormPage(ctx: NoProps) : PureComponent(ctx) {
// Observes fields from child components without blocking their messages
private val pageFormCtrl = formObserver()
override fun VDom.render() {
AddressForm()
PaymentForm()
// Tracks validity across both sub-forms
ui.green.button.given(!pageFormCtrl.isValid) { disabled }.then {
onClick { pageFormCtrl.ifValidate { submitAll() } }
+"Submit order"
}
}
} FormController API
| Method / Property | What it does |
|---|---|
isValid | True when all tracked fields pass validation |
isNotValid | True when any field has errors |
numErrors | Count of fields with validation errors |
fields | Set of all tracked FormField instances |
validate() | Touch and validate all fields, returns true if all pass |
ifValidate { ... } | Validate, execute callback only if all pass |
resetAllFields() | Reset all fields: clear errors, mark untouched |
How it works under the hood
Form fields communicate with the FormController through component messages:
- When a field mounts, it sends a
FormFieldMountedMessageup the tree. The FormController registers the field. - When a field value changes, it sends a
FormFieldInputChangedmessage. The FormController re-evaluates validity. - When a field unmounts, it sends a
FormFieldUnmountedMessage. The FormController unregisters the field.
This means the FormController automatically discovers fields — you don't register them manually. Any field that's
a descendant in the component tree is tracked. A formController() stops these messages from
propagating further. A formObserver() lets them continue, so a parent controller can also track
them.
The draft/commit pattern
For forms where you want to edit a copy and only commit changes on submit:
class ProfileEditor(ctx: NoProps) : PureComponent(ctx) {
data class Profile(
val name: String = "",
val bio: String = "",
val website: String = "",
)
// The committed state
private var profile by value(Profile())
// The editing copy
private var draft by value(profile)
// Tracks validity
private val formCtrl = formController()
override fun VDom.render() {
ui.form {
UiInputField(draft.name, { draft = draft.copy(name = it) }) {
label("Name")
accepts(notBlank())
}
UiTextArea(draft.bio, { draft = draft.copy(bio = it) }) {
label("Bio")
accepts(maxLength(500))
}
UiInputField(draft.website, { draft = draft.copy(website = it) }) {
label("Website")
accepts(nullOrElse(validUrlWithProtocol()))
}
// Only enable submit when valid AND changed
val canSubmit = formCtrl.isValid && draft != profile
ui.buttons {
ui.blue.button.given(!canSubmit) { disabled }.then {
onClick {
formCtrl.ifValidate {
profile = draft // commit
}
}
+"Save"
}
ui.basic.button {
onClick {
draft = profile // discard
formCtrl.resetAllFields()
}
+"Reset"
}
}
}
}
} This pattern gives you:
- Edit isolation — changes don't affect the real state until committed
- Dirty detection —
draft != profiletells you if anything changed - Easy reset — copy the committed state back to draft
- Structural equality — data classes give you correct comparison for free
Programmatic field access
Each field component exposes methods for direct interaction:
// All input-type fields provide:
field.focus() // set focus
field.hasFocus() // check focus
field.currentValue // get the effective value
field.inputElement // direct DOM element access
// UiTextArea also provides:
textarea.replaceSelection("inserted text") // insert at cursor