Live validation — form() exposes value/validity, derived() computes the error message, touched gates when errors show.
stateformderivedactionifgroupon:inputon:blurinterpolationThis recipe doesn't have a hydrated island in the demo gallery yet. An in-browser, WASM-compiled playground (edit this source and re-render live) is planned as a follow-up — read the real source below.
// form-validation — form() with value+validity for form-associated custom element
@state {
let value = state('')
let touched = state(false)
form({
value: () => value,
validity: () => ({ valueMissing: !value.trim(), tooShort: value.trim().length < 2 }),
})
const errorMessage = derived(() => {
if (!touched) return ''
if (!value.trim()) return 'This field is required.'
if (value.trim().length < 2) return 'Must be at least 2 characters.'
return ''
})
const isValid = derived(() => value.trim().length >= 2)
const onInput = action((e: Event) => {
value = (e.target as HTMLInputElement).value
touched = true
})
const onBlur = action(() => { touched = true })
}
@template {
<label class="form-field">
<span class="field-label">Name <span aria-hidden="true">*</span></span>
<input
type="text"
class={['field-input', touched && !isValid && 'invalid']}
value={value}
on:input={onInput}
on:blur={onBlur}
required
minlength="2"
/>
<group if={errorMessage}>
<span class="error-msg" role="alert">{errorMessage}</span>
</group>
<group if={isValid}>
<span class="valid-msg">Looks good!</span>
</group>
</label>
}
@style {
.form-field { display: flex; flex-direction: column; gap: 0.3rem; max-width: 20rem; }
.field-label { font-size: 0.9rem; font-weight: 600; }
.field-input { padding: 0.5rem; border: 1px solid var(--border, #ccc); border-radius: 4px; font-size: 1rem; }
.field-input.invalid { border-color: var(--error, #c00); }
.error-msg { font-size: 0.8rem; color: var(--error, #c00); }
.valid-msg { font-size: 0.8rem; color: var(--success, #080); }
}statea11y