5. Forms#
form-associated on the host makes the element a real form control: it submits a value under its name, takes part in validation, resets with the form, and is disabled by a disabled <fieldset>.
<!-- quantity-stepper.zb -->
<script>
import { validity } from '@zubra/runtime'
validity(() => (value > max ? `At most ${max}` : ""))
</script>
<quantity-stepper form-associated value:model={1} min={0} max={10}>
<button type="button" disabled={value <= min} onclick={() => value--}>−</button>
<output>{value}</output>
<button type="button" disabled={value >= max} onclick={() => value++}>+</button>
</quantity-stepper>
<form>
<label>Tickets <quantity-stepper name="tickets" max="4"></quantity-stepper></label>
<button>Book</button>
<button type="reset">Start over</button>
</form>
- With one
:modelprop, that prop is the form value: the form submitstickets=1, and a reset puts it back to its initial value.null,falseand""submit nothing;truesubmits"on"; objects are submitted as JSON. formValue(() => …)submits something else instead (say, a date range as"2026-01-02/2026-01-05").validity(() => message)blocks submission while it returns a message, and sets:invalid.""means valid.- The element has the native control API:
form,labels,validity,checkValidity(),reportValidity().<label>works with it like with an<input>.
Forms that work before JavaScript#
On a server-rendered page, the same form should work with JavaScript off and get better with it on. Zubra gives you the pieces:
<script>
import { hydrated } from '@zubra/runtime'
const live = hydrated()
</script>
<ticket-filters status="">
<form method="get">
<select name="status" value={status} onchange.submit>
<option value="">Any</option>
<option value="open">Open</option>
</select>
<textarea name="note" onkeydown.mod.enter.prevent.submit></textarea>
<zb-if test={!live}><button>Apply</button></zb-if>
</form>
</ticket-filters>
valueon a<select>or<textarea>is right in the server's HTML: the matching<option>isselected, and the textarea holds the text..submitsubmits the form after any handler, with validation.onchange.submitneeds no handler at all..modis ⌘ on a Mac and Ctrl elsewhere, soonkeydown.mod.enteris the usual "send" shortcut.hydrated()isfalsein the server's HTML andtrueonce the element has started. Here the Apply button exists only until then. The host also gets:state(hydrated)for CSS.
Native controls (inputs, selects, checkboxes) in light DOM submit before any JavaScript. A form-associated element submits only once it has started, so keep a native control inside it when the page must work without JavaScript. (In a meta-framework, @zubra/server's injectShadowRoots covers this: it gives a form-associated element with a name a hidden input holding its server-rendered value.) zubra check --server tells you where the server's HTML would leave a control's state out.
In frameworks it's an ordinary control too: Vue's v-model, React's controlled pattern (value + onValueChange), and in Angular, ZubraModel with ngModel or formControlName.
Next: Async data