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:

  1. When a field mounts, it sends a FormFieldMountedMessage up the tree. The FormController registers the field.
  2. When a field value changes, it sends a FormFieldInputChanged message. The FormController re-evaluates validity.
  3. 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 detectiondraft != profile tells 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

See it in action