Bindings and form controls
Sumi renders the HTML form controls — <input>, <textarea>, <select>, checkboxes and radios — on the cell grid, with a user-agent
stylesheet and the editing behaviour you’d expect. This chapter covers
how to read and write their values, and the constraints that apply.
Two-way binding with bind:
The primary way to connect a native control to a signal is bind: — bind:value on text inputs, textareas, and selects, and bind:checked on checkboxes and radios. It is two-way: the control writes the signal as
the user edits, and an external write to the signal updates the rendered
control.
<script>
name := sumi.New("")
</script>
<input type="text" bind:value={name} />
<span>Hello, {name}</span> bind:value expects a *sumi.Signal[string]; bind:checked expects a *sumi.Signal[bool]. A mismatch is a plain Go compile error.
The event escape hatch
bind: is sugar over the control’s own DOM events, and you can wire those
directly when you need more than mirroring a value — for example to
validate, transform, or react to the change without holding a signal:
<script>
name := sumi.New("")
func nameInput(evt *sumi.DOMEvent) {
name.Set(evt.Data["value"].(string))
}
</script>
<input type="text" oninput={nameInput} /> The control owns its editing state (text buffer, cursor, view offset) in
both cases; bind: just writes the mirroring handler and the display sync
for you. The sections below give the event name and Data keys each
control fires, which are what bind: is built on.
Text input
<input> and <textarea> hold an edit buffer. Typing, Backspace/Delete,
Home/End, and the readline motions (Ctrl+A/E/B/F) move and edit it.
Each edit that changes the value fires an input event whose Data["value"] is the new string and Data["cursor"] the caret index.
- Initial value:
bind:value={sig}seeds the buffer from the signal and keeps it in sync thereafter. Without a binding,value="..."seeds it from a string literal only — a bare expression value (value={x}, nobind:) is treated as empty, because such values are meant to be pushed in from your own handler, not read back by the control. - Windowing: when the value is wider than the laid-out control, the view slides to keep the caret visible. This happens against the real laid-out width, so it is correct even for flex-sized inputs.
<textarea>: multi-line. Enter inserts a newline, Up/Down move between lines, and the value keeps its line structure (white-space: pre).
Constraints
Attributes limit what editing can do:
maxlength="20"caps the value at 20 runes. Typing past the cap is ignored; a paste is truncated to the room left.readonlyblocks edits but still allows caret movement. A key that would have edited is swallowed (it doesn’t leak to other handlers) rather than passed through.type="password"masks the display with bullets; the underlying value and theinputevent still carry the real text.disabledremoves the control from the focus order entirely, and matches:disabled(enabled controls match:enabled) in CSS.
Checkboxes and radios
<input type="checkbox"> and <input type="radio"> render a glyph and
fire a change event (and an input event) carrying Data["checked"] (bool) and Data["value"] (the value attribute).
Bind the checked state with bind:checked:
<script>
notify := sumi.New(false)
</script>
<input type="checkbox" bind:checked={notify} /> The event escape hatch is onchange reading evt.Data["checked"].(bool).
Space toggles the focused checkable; Enter and a mouse click do too. bind:checked seeds the initial state from the signal; without a binding, checked="true" sets it. Radios group by name:
checking one unchecks the others with the same name across the tree, and
a checked radio does not toggle itself off. :checked selects the
checked state in CSS.
Select
<select> shows the current option’s label followed by ▾ and fires a change with Data["value"] set to the selected option’s value. Bind
the selected value with bind:value:
<script>
plan := sumi.New("free")
</script>
<select bind:value={plan}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select> The binding selects the option whose value matches the signal, so an
external plan.Set("pro") moves the selection. The event escape hatch is onchange reading evt.Data["value"].(string). The arrow keys move the
selection while the select is focused; a click advances it. Without a
binding, selected="true" marks the initial option.
Focus and editing
Editing is always directed at the focused control. Only the focused
input receives typed keys, and its caret is shown only while it holds
focus — blur hides the caret. Tab and Shift-Tab move focus between
controls (disabled controls are skipped); a click focuses the control
under the cursor. Because typing into a focused input is a keydown
default action, it runs before the component’s raw handleKey — a
component’s handleKey won’t see the letters typed into one of its
inputs. See events for the full dispatch order.
Paste and clipboard
A bracketed paste into a focused input inserts the pasted text at the
caret, honouring maxlength (the paste is trimmed to fit). Selection and
copy/cut over the painted frame go through the terminal clipboard (OSC 52
where the terminal supports it); this is terminal-level behaviour rather
than a per-control attribute, covered in terminals.
Binding to a child component
The same bind: syntax also connects a parent signal to a child component whose prop is a signal:
<Field bind:count={clicks} /> Here the parent and child share one *sumi.Signal directly — a write on
either side is visible to both — rather than the control-value mirroring
that native bind: performs. See components.
Limitations
A binding inside {if} or {for} content wires the update half only:
typing still writes the signal, but an external Set does not
re-project onto the control (the same limitation as dynamic state
attributes). Bindings on statically-placed controls reflect both
directions. A control cannot declare both a binding and a handler for
the binding’s own event (bind:value + oninput) — that is a
generation error; use one or the other.