Skip to content

Forms and binding

data-bind connects an element’s value to a signal in both directions and creates the signal from the element if it does not exist yet.

Live

<div data-signals="{ f: { name: '', age: 30, agree: false, colour: 'teal', picks: [], size: 'm' } }">
<div class="row"><input data-bind:f.name placeholder="name"> <input type="number" data-bind:f.age> <input type="range" min="0" max="100" data-bind:f.age></div>
<div class="row">
<label><input type="checkbox" data-bind:f.agree> agree</label>
<label><input type="checkbox" value="red" data-bind:f.picks> red</label>
<label><input type="checkbox" value="blue" data-bind:f.picks> blue</label>
<label><input type="radio" value="s" data-bind:f.size> S</label>
<label><input type="radio" value="m" data-bind:f.size> M</label>
<select data-bind:f.colour><option>teal</option><option>amber</option></select>
</div>
<pre data-text="JSON.stringify($f, null, 1)"></pre>
</div>
element signal value events
text, email, url, … string input
number, range number ('' when empty) input
checkbox without value boolean input
checkbox with value the value or ''; when the signal is an array, membership in it input
radio the checked value; the group gets name from the signal path input
select string; select multiple gives an array change
file array of { name, type, size, contents } with base64 contents change
textarea, custom elements with a value property string input, change

Numbers stay numbers: if the signal currently holds a number, the bound string is converted back.

Modifiers: __event.blur picks the events to listen for, __case.kebab changes the signal name casing.

data-custom-validity sets the field’s validity message from an expression; an empty string means valid. The browser then blocks submission and shows the message.

Live
<form data-signals="{ p1: '', p2: '' }" data-on:submit="alert('valid!')">
<div class="row">
<input type="password" placeholder="password" data-bind:p1 required>
<input type="password" placeholder="repeat" data-bind:p2 data-custom-validity="$p1 === $p2 ? '' : 'Passwords differ.'">
<button>Submit</button>
</div>
</form>

With contentType: 'form' the request serialises the closest form (or the one matched by selector) instead of the signals: URL-encoded, or multipart when the form says so, exactly as a browser would submit it, so any server stack reads the fields with what it already has. Native validation runs first. The server validates again and answers with patches; in JavaScript, sigmx/server reads the fields as signals and builds the reply.

Live
Validated on the server; errors come back as a patch.
<div class="ex contact" data-signals="{ sent: false }">
<form data-on:submit="@post('/api/form', { contentType: 'form' })" data-show="!$sent" data-indicator:sending>
<div class="grid">
<label>Email <input class="field" name="email" type="email" placeholder="you@example.com"></label>
<label>Message <input class="field" name="message" placeholder="At least five characters"></label>
</div>
<div id="form-status"></div>
<div class="actions"><span class="muted small">Validated on the server; errors come back as a patch.</span><button class="btn" data-attr:disabled="$sending">Send</button></div>
</form>
<div class="thanks" data-show="$sent" style="display:none">
<span class="big" aria-hidden="true">✓</span>
<p>Sent. <button class="btn ghost" type="button" data-on:click="$sent = false">Send another</button></p>
</div>
</div>
<style is:global>
.contact { padding: 1rem; background: var(--ex-surface); }
.contact .grid { display: grid; grid-template-columns: 1fr 1fr; gap: .7rem; }
@media (max-width: 40rem) { .contact .grid { grid-template-columns: 1fr; } }
.contact label { display: grid; gap: .25rem; font-size: .85rem; }
.contact #form-status { margin: .6rem 0; }
.contact #form-status ul { margin: 0; padding: .6rem .9rem .6rem 1.8rem; border-radius: 10px; background: color-mix(in srgb, var(--ex-bad) 12%, transparent); color: var(--ex-bad); font-size: .85rem; }
.contact .actions { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.contact .small { font-size: .78rem; }
.contact .thanks { display: grid; justify-items: center; gap: .3rem; padding: .5rem; text-align: center; }
.contact .big { display: grid; place-items: center; width: 3rem; height: 3rem; border-radius: 50%; background: color-mix(in srgb, var(--ex-good) 16%, transparent); color: var(--ex-good); font-size: 1.5rem; }
.contact .thanks p { margin: 0; display: flex; align-items: center; gap: .6rem; }
</style>
// any handler that takes a Request and returns a Response
import { patchElements, patchSignals, readSignals, sse } from 'sigmx/server'
export const POST = async (request: Request) => {
try {
const { email } = await readSignals(request, schema) // any Standard Schema validator
return sse(patchElements(`<p id="form-status">Thanks, ${email}.</p>`), patchSignals({ sent: true }))
} catch (e) {
const items = e.issues.map((i) => `<li>${i.message}</li>`).join('')
return sse(patchElements(`<div id="form-status" class="error"><ul>${items}</ul></div>`))
}
}