Forms and Validation
Kumiki forms come in two styles: "bind individual inputs directly to a slot via bind," and "receive via ui.submit on a dedicated tile." The former is for reactive, incremental reflection; the latter is for transactional, committed submission.
Event selectors are always written as tile names (CSS attribute selectors are abolished). If you want to receive events directly on a built-in element (form, input, etc.), create a small tile that wraps that element.
5.1 Two-Way Binding of Individual Inputs
slot draft : Text where len-lt(280) = ""
tile Compose = column(
textarea(bind=draft, placeholder="What's on your mind?") {rows: 3},
text(draft.length.show + "/280") {color: "muted"},
button(text="Post", onClick=post) {disabled: draft.is-empty})bind=drafttwo-way binds the slotdraft- User input updates the slot → the tile re-renders
- Type and refinement are checked on each input
5.1.1 Elements That Support bind
| Element | Acceptable types |
|---|---|
input | Text (type=text/email/password/url/search/tel, or any other field whose value is the text typed: number/date/datetime-local/time/month/week/color), Int/Float (type=number), Time (type=date/datetime-local) |
textarea | Text |
select | Any (same type as the value of options) |
slider | Int / Float |
check / switch | Bool |
radio | One of a union type |
An input bound to an Int, Float or Time reads its text the way Int.parse / Float.parse / Time.parse do (Standard Library §2.4.3) and writes the value it reads. The type is the bound position's base: the slot's type followed through a record field, or through an Option's or a Result's payload with .get, and unaliased, so type Qty = Int where positive and a nominal Int read as an Int. The reading is T.parse's exactly — ".5", which a number field can hold, is no Float, and "1e3" is a Float and no Int. Text that spells no value of the base ("", or "1.5" for an Int) is refused as a refinement violation is (§5.1.2): the slot keeps the last value it accepted, the field keeps what was typed, and error(field=…) names the reading it failed (§5.7.2). A Time is shown to a type="date" field as yyyy-MM-dd, and to a type="datetime-local" one as yyyy-MM-ddTHH:mm, on the local clock Time.parse reads a zone-less string on, so what the field shows reads back as the same day, or the same minute. A Text is written as typed, so it goes with any field whose value is the text typed; an Int, Float or Time goes only with the field kinds in the table, whose text it round-trips through. Any other pairing — a Time in a type="time" field, an Int in a text field, a Bool, or an Option bound whole rather than through .get — is reported by kumiki check (E0226). (type="datetime" is obsolete in HTML and renders as a text field.)
check / switch show the bound Bool and write the box's new state back when it is ticked; radio(group=…, bind=b, value=V) is selected exactly when b == V and writes V when it is chosen. All three go through the same write-back as input — the refinement refusal of §5.1.2 included — and it runs before the control's own onClick / onChange, so a handler reads the slot already written: moving check(value=b, onClick=toggle) to check(bind=b, onClick=toggle) inverts b twice, and the onClick has to go. With a bind=, the argument that decides the selection of an unbound control — value= on check / switch, selected= on radio — is not read (W0216); a radio's own value= is still what it writes. A bind of another type on check / switch, or a radio value that is not a value of the bound type, is reported by kumiki check (E0201, or E0216 for a variant of another union), and so is a bound radio with no value= to write (E0225).
5.1.2 Handling of refinement
For slot draft : Text where len-lt(280), when the input exceeds 280 characters the value is refused: the slot keeps the last value it accepted. There is one mode, and it is deliberately quiet — a half-typed value is expected, not a defect, so nothing is reported. A refinement violation on the assignment path (draft := … inside a reducer) is the opposite case — it discards the whole reducer batch and is reported, see batching.
The control keeps what was typed, so the field and the slot disagree until the field is edited to a value the slot accepts. error(field=draft) speaks for what the field shows (§5.7.1): while the field shows a value the refinement refused, the message is that value's. A reducer that rewrites the slot moves the field with it, and the message then follows the slot again. The same holds for text a bound Int, Float or Time cannot read at all (§5.1.1), on a slot with a refinement or without one: its message is the reading's, and comes before any refinement's. So the field shows a value the slot does not hold only together with the message saying why. Two refinements of that rule: the field in question is the one in the same view — with one app mounted into several hosts (runtime.md §10.9), each view's error(field=…) speaks for its own view's field, which in every other view still shows the slot's value; and during an IME composition the message is settled once, when the composition ends, not re-derived for every intermediate value the composition passes through.
input(bind=draft)
error(field=draft)A strict prop, which an earlier revision of this section specified as a second mode (take the refused value and turn a form-level valid flag false), is E0219. Nothing ever read that flag, and a slot that holds a value its own type refuses is what every other path to it is built to prevent.
5.2 Form Elements
When you want to commit-submit multiple inputs together, create a tile that wraps the form:
slot loginEmail : Text = ""
slot loginPassword : Text volatile = ""
slot loginError : Option(HttpError) = None
slot loginPending : Bool = false
tile LoginForm
= form(
column(
label(text="Email") {for: "loginEmail"},
input(bind=loginEmail, type="email", id="loginEmail", required=true),
label(text="Password") {for: "loginPw"},
input(bind=loginPassword, type="password", id="loginPw", required=true,
auto-complete="current-password"),
when(loginError.is-some,
text(loginError.get.message) {color: "danger"}),
button(text="Log in", type="submit", loading=loginPending) {bg: "primary"}
) {gap: "sm"}
)
reducer doLogin
on=ui.submit(LoginForm)
do= loginError := None
loginPending := true
emit login({email: loginEmail, password: loginPassword})
effect login cap=http.post
in={email: Text, password: Text}
out=Result(SessionId, HttpError)
policy=latest
map-request={url: "/api/auth/login", body: Json($1), decode: Decoder.Json(SessionId)}5.2.1 form props
| prop | Type | Meaning |
|---|---|---|
auto-complete | Bool | Browser autocomplete |
novalidate | Bool | Suppress HTML5 standard validation |
Do not write onSubmit on the form itself. For the submit handler, write ui.submit(WrapperTile) in the reducer's on=, using the name of the tile that wraps the form.
5.2.2 Submit Behavior
- If every slot a control inside the form binds passes validation, the
ui.submit(WrapperTile)reducer is called - If even one fails, it is not called (individual error displays do appear)
- What is judged is what each control shows — the judgement
error(field=…)makes (§5.1.2), in its order: text anInt/Float/Timefield shows that reads as no value of that type at all; else a value the refinement refused that a control inside the form still shows; else the slot's own value. So a pristine field whose declared default fails its refinement holds the form back (§5.6) — with or without anerror(field=…)tile to say why, so a form holding such a field should carry one — and so does a field showing a refused edit or unreadable text: the reducer would otherwise read the slot's last accepted value, which is not what the field shows - Only slots bound inside the form count, and only the form's own controls speak for them. A slot the form's controls do not bind is not asked about, and a refused value shown by a control outside the form does not hold it back: the form's own control shows the slot's value, which is what the reducer reads.
error(field=…)speaks for the whole view being rendered, so anerrortile inside the form can show a message for that outside edit while the form submits - Fires by clicking
button(type="submit"), or by pressing the Enter key in aninput - A click reducer on that button —
ui.clickon it, one lifted onto it from a tile that wraps it, or anonClick=argument — is independent of the submit: clicking the button runs the click reducer and submits the form. Nothing a click reducer is bound to cancels the click, sotypeis the one thing here a click reducer can't replace: a click reducer never keeps a button inside a form from submitting it, andtype="button"does. (Adisabledorloadingbutton does not submit either: it is disabled, so it is never activated.) typeis one ofsubmit/button/reset, written through to the DOM verbatim, and is only meaningful inside a form. A button that does not write one keeps the HTML default, which issubmit— so a button inside a form that is not meant to submit it must saytype="button". A literal outside the three is E0201: an invalidtypeattribute resolves tosubmit, so the typo submits- A
type="reset"button with a click reducer runs the reducer and resets the form, which puts the fields' DOM values back to their defaults but leaves thebinded slots as they were — nothing listens forreset. To clear a form, write the slots in a reducer on atype="button"button
5.3 Common props for Input Elements
| prop | Type | Meaning |
|---|---|---|
bind | slot name | Two-way binding |
value | expr | One-way value (instead of bind; updated in a reducer) |
onChange | reducer name | Reducer called when the value changes |
onInput | reducer name | Called on the input event (more frequent than onChange) |
placeholder | Text | Placeholder |
disabled | Bool | Disable |
readonly | Bool | Read-only (a control that has no read-only state, such as a select, ignores it) |
required | Bool | Required |
auto-focus | Bool | Focus on mount |
auto-complete | Text | email / current-password / new-password / off, etc. |
id | Text | HTML id (referenced by a label's for) |
5.3.1 By input type
input(bind=email, type="email", auto-complete="email")
input(bind=password, type="password", auto-complete="current-password")
input(bind=age, type="number", min=0, max=120)
input(bind=birthday, type="date", min="1900-01-01")
input(bind=search, type="search")
input(bind=phone, type="tel", pattern="[0-9-]+")5.4 Delivering Individual Input Events to a reducer
When bind is not enough (e.g., you want to run custom processing on every input), wrap that input in a dedicated small tile and receive ui.input / ui.change:
slot pw : Text = ""
slot pw2 : Text = ""
slot pwError : Option(Text) = None
tile Pw1Input = input(bind=pw, type="password")
tile Pw2Input = input(bind=pw2, type="password")
reducer validatePw
on=ui.input(Pw2Input)
do= pwError := if pw == pw2 then None else Some("Passwords don't match")ui.input(TileName) receives events from the root element rendered by the TileName tile. For a composite tile, to target something other than the root element, split it into finer tiles.
5.5 select / radio
5.5.1 select
type Filter = All | Active | Done
slot filter : Filter = All
tile FilterSelect = select(
bind=filter,
options=[
{label: "All", value: All},
{label: "Active", value: Active},
{label: "Done", value: Done}
],
placeholder="Filter")Three value/state Binding Forms
| Form | Example | Purpose |
|---|---|---|
bind=<slot> | bind=filter | Directly tied to a single slot. Updates the slot automatically on change |
bind=<slot.field> | bind=draft.priority | Bind to a record's field path. Immutable update via _setPath |
value=<expr> | value=issues[id].status | Read-only display. Handle change yourself in a ui.change(SelectTile) reducer |
In the value= form, on a change event the reducer subscribing to ui.change(<SelectTile>) is called, and you can receive the selected variant value via $event.value:
tile StatusSelect = select(value=issues[iid].status,
options=statusOptions(),
placeholder="Status")
reducer updateStatus
on=ui.change(StatusSelect)
do= match routeIssueId(route) with
| Some(iid) -> { issues[iid].status := $event.value;
issues[iid].updatedAt := now }
| None -> ()Change Detection for input / textarea
In addition to updating a slot via bind=, input/textarea can also fire via the ui.change(InputTile) / ui.input(InputTile) reducers. $event.value holds the current text.
5.5.2 radio
radio has a group prop for grouping (corresponding to CSS's name attribute):
tile FilterRadioAll = radio(group="filter", value=All, selected=(filter == All)) {label: "All"}
tile FilterRadioActive = radio(group="filter", value=Active, selected=(filter == Active)) {label: "Active"}
tile FilterRadioDone = radio(group="filter", value=Done, selected=(filter == Done)) {label: "Done"}
tile FilterRadioGroup = column(FilterRadioAll, FilterRadioActive, FilterRadioDone)
reducer setFilterAll on=ui.change(FilterRadioAll) do= filter := All
reducer setFilterActive on=ui.change(FilterRadioActive) do= filter := Active
reducer setFilterDone on=ui.change(FilterRadioDone) do= filter := DoneAlternatively, if you receive a union type directly via bind, a single reducer is unnecessary:
tile FilterRadioGroup = column(
radio(group="filter", bind=filter, value=All) {label: "All"},
radio(group="filter", bind=filter, value=Active) {label: "Active"},
radio(group="filter", bind=filter, value=Done) {label: "Done"})This is the recommended approach.
5.6 Validation Strategy
Kumiki validation has three layers:
| Layer | Responsible for | Example |
|---|---|---|
| Type | Compiler | You cannot put a string into slot age : Int |
| refinement | Runtime | age : Int where between(0, 120) |
| Cross-form | reducer / fn | "password and password-confirm match" |
The refinement layer covers every predicate §1.3.3 registers, including the ones the standard library's domain types are declared with — Email, Url, Uuid and HttpStatus are refined nominals (Standard Library §2.1.3), so a slot declared with one is checked exactly as a slot written Text where email is. A predicate the toolchain cannot lower to a check is a build error (E0803), never a check that passes.
The two write paths differ in how loud they are, and a bind is further judged only at the path it writes (below). An assignment is judged on the whole value, even one that writes a single field (signup.name := …):
- Assignment (
age := …in a reducer) discards the whole batch and reports it — no slot written, no effect emitted (Runtime §10.3.3). bindrefuses the value for that field alone and reports nothing, because a half-typed value is expected rather than a defect. The field keeps showing it, anderror(field=…)renders its message (§5.1.2).
Neither gates the declared default: slot email : Email = "" starts out holding a value its own refinement rejects, which is what puts a message on a pristine form (§5.7.1).
A bind into part of a slot — input(bind=form.age), input(bind=draft.nick.get) — is judged at the path it writes: the predicates along that path, on the slot's own type included, and every one below where it ends. A predicate on a sibling is not on that path, so a sibling that fails does not refuse the write. That is what lets a record whose default fails several fields be filled in any order. The field that shows a refused value is laid over the slot as it is now when error(field=…) judges it, so a sibling written afterwards does not bring back a message that field no longer deserves. A control bound to the whole slot shows the value the fields bound into it sit in, so when both show refused values the slot's is laid first and the fields' over it.
5.6.1 Cross-Form Example
slot pw : Text = ""
slot pw2 : Text = ""
slot pwError : Option(Text) = None
fn validatePassword(p1: Text, p2: Text) -> Option(Text)
= if p1 == p2 then None else Some("Passwords don't match")
tile Pw2Input = input(bind=pw2, type="password")
reducer onPw2Change
on=ui.input(Pw2Input)
do= pwError := validatePassword(pw, pw2)
tile SignupForm
= form(
column(
input(bind=pw, type="password"),
Pw2Input,
when(pwError.is-some,
text(pwError.get) {color: "danger"}),
button(text="Sign up", type="submit", disabled=pwError.is-some)))
reducer doSignup on=ui.submit(SignupForm) do= ...5.7 Error Display
5.7.1 refinement Violation of an Individual Field
Display via the error element:
input(bind=email, type="email")
error(field=email)error(field=...) is a built-in tile that renders the target field's current validation error: it reads the predicate off the slot and renders the message for it whenever the value the field shows fails, and nothing otherwise. That is the slot's current value, except while a bound control shows a value the refinement refused (§5.1.2), which is judged instead. A slot whose type carries no refinement therefore has no message for any value — which is a statement about the slot, not about the value in it. What it can still show is the one message that is not about a value: while a bound input shows text that does not read as the slot's Int, Float or Time at all (§5.1.1), the message is that reading's (int / float / time below), on any slot and before any refinement — "1.5" into an Int where between(0, 120) is not a whole number, not a number out of range.
A type carrying several predicates (§1.3.1) renders the message of the first one the current value fails, in the order §1.3.1 gives them. On slot draft : Text where nonempty where len-lt(7) = "" a pristine field reads "Required", not a bound the empty value is well inside.
5.7.2 Standard Messages
| Predicate | Default |
|---|---|
email | "Invalid email format" |
url | "Invalid URL" |
uuid | "Invalid identifier" |
nonempty | "Required" |
len-eq(N) | "Must be exactly N characters" |
len-lt(N) / len-gt(N) | "Must be less than / more than N characters" |
between(A, B) | "Must be between A and B" |
positive / negative | "Must be positive" / "Must be negative" |
regex(P) | "Does not match pattern" |
one-of(...) | "Must be one of: ..." |
int (text that is no Int, §5.1.1) | "Must be a whole number" |
float (text that is no Float) | "Must be a number" |
time (text that is no Time) | "Must be a date" |
Override custom messages via theme.errors, keyed by the table's first column — int / float / time included:
theme MyTheme = {
...,
errors: {
email: "Please enter a valid email address",
nonempty: "This field is required"
}
}5.8 UI During Submission
slot loginPending : Bool = false
reducer doLogin
on=ui.submit(LoginForm)
do= loginPending := true
emit login({email: loginEmail, password: loginPassword})
reducer loginOk
on=login.ok($s, _)
do= loginPending := false
session := Some($s)
emit navigate({path: "/app", params: {}, query: {}})
reducer loginErr
on=login.err($e, _)
do= loginPending := false
loginError := Some($e)button.loading automatically shows a spinner and disables the button.
5.9 Multi-step Forms
type Step = Account | Profile | Confirm
slot step : Step = Account
slot acct : {email: Text, pw: Text} = {email: "", pw: ""}
slot prof : {name: Text, bio: Text} = {name: "", bio: ""}
fn nextStep(s: Step) -> Step = match s with | Account -> Profile | Profile -> Confirm | Confirm -> Confirm
fn prevStep(s: Step) -> Step = match s with | Profile -> Account | Confirm -> Profile | Account -> Account
tile NextBtn = button(text="Next") {bg: "primary"}
tile PrevBtn = button(text="Back") {variant: "ghost"}
reducer next on=ui.click(NextBtn) do= step := nextStep(step)
reducer prev on=ui.click(PrevBtn) do= step := prevStep(step)
tile Wizard = column(
ProgressIndicator(step),
match step with
| Account -> AcctStep
| Profile -> ProfStep
| Confirm -> ConfirmStep,
row(PrevBtn, NextBtn) {gap: "sm"})Splitting each step into an independent tile makes it easier for the AI to track as well.
5.10 File Upload
slot avatar : Option(File) = None
tile AvatarPicker = input(type="file", accept="image/*")
reducer pickFile
on=ui.change(AvatarPicker)
do= avatar := $event.files.head
tile UploadBtn = button(text="Upload")
reducer upload
on=ui.click(UploadBtn)
do= match avatar with
| Some(f) -> emit uploadFile({file: f})
| None -> ()
tile AvatarUpload = column(
AvatarPicker,
when(avatar.is-some,
image(src=file-url(avatar.get)) {w: 100, h: 100, aspect: "1/1"}),
UploadBtn)
effect uploadFile cap=http.post
in={file: File}
out=Result({url: Url}, HttpError)
policy=latest
map-request={url: "/api/upload", body: Multipart({file: FileV($1.file)}), decode: Decoder.Json({url: Url})}file-url(file) is a built-in equivalent to URL.createObjectURL (with automatic release).
accept and multiple are only valid when type="file". Used on any other type — or with type omitted (which defaults to "text") — the typecheck rejects them with E0206.
5.11 Design Decision Record
| Decision | Rationale |
|---|---|
Tie slots directly via bind | Eliminates the dual controlled/uncontrolled model |
| Event selectors are tile names only | Removes dependence on CSS knowledge; consistent with Kumiki's layer separation |
| Bind the form's submit handler to the wrapper tile, not the form itself | "Which reducer receives it" is visible in one place in the tile tree |
| Type-level validation via refinement | "If the type passes, the value is valid" |
| Centralize error messages in the theme | i18n and consistency |
| Express multi-step via slots | Avoids adding a dedicated wizard DSL |
Files are a File type rather than Bytes | Structures size, MIME, and name |
radio's group prop | Wraps the HTML name attribute, kept self-contained within Kumiki |
5.12 Next
- HTTP details → HTTP / Storage
- Lifecycle → Lifecycle