UI Patches & Form Fields
@domphy/form manages state; @domphy/ui provides styled input patches. This page shows the binding pattern for every common input type.
All examples assume:
import { createForm } from "@domphy/form/domphy"
import { themeSpacing } from "@domphy/theme"
import { button } from "@domphy/ui"Text input
inputText() applies to <input>. Bind value reactively, forward onInput and onBlur:
import { inputText, label, formGroup } from "@domphy/ui"
const form = createForm<{ email: string }>({
defaultValues: { email: "" },
onSubmit: ({ value }) => save(value),
})
const emailField = form.field<string>("email", {
validators: {
onChange: ({ value }) => value.includes("@") ? undefined : "Invalid email",
},
})
const EmailInput = {
fieldset: [
{ label: "Email", $: [label()] },
{
input: null,
type: "email",
$: [inputText()],
value: (l) => emailField.value(l),
onInput: (e) => emailField.handleChange((e.target as HTMLInputElement).value),
onBlur: () => emailField.handleBlur(),
dataStatus: (l) => emailField.errors(l).length > 0 ? "error" : undefined,
},
{
p: (l) => String(emailField.errors(l)[0] ?? ""),
hidden: (l) => emailField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
}formGroup() must be applied to a <fieldset>. It lays out labels, inputs, and helper <p> tags in a grid. The helper paragraph gets smaller text styling automatically.
Setting dataStatus: "error" on the input triggers a red outline from inputText().
Textarea
textarea() applies to <textarea>. Use the value property for controlled binding and optionally pass autoResize: true to grow the height to content:
import { textarea, label, formGroup } from "@domphy/ui"
const bioField = form.field<string>("bio", {
validators: {
onChange: ({ value }) => value.length <= 500 ? undefined : "Maximum 500 characters",
},
})
const BioInput = {
fieldset: [
{ label: "Bio", $: [label()] },
{
textarea: null,
$: [textarea({ autoResize: true })],
value: (l) => bioField.value(l),
onInput: (e) => bioField.handleChange((e.target as HTMLTextAreaElement).value),
onBlur: () => bioField.handleBlur(),
},
{
p: (l) => String(bioField.errors(l)[0] ?? ""),
hidden: (l) => bioField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
}Number input
inputNumber() applies to <input type="number">. Read e.target.valueAsNumber — it returns NaN for empty input:
import { inputNumber, label, formGroup } from "@domphy/ui"
const quantityField = form.field<number>("quantity", {
validators: {
onChange: ({ value }) => {
if (!value || isNaN(value)) return "Required"
if (value < 1) return "Minimum 1"
},
},
})
const QuantityInput = {
fieldset: [
{ label: "Quantity", $: [label()] },
{
input: null,
type: "number",
min: "1",
$: [inputNumber()],
value: (l) => String(quantityField.value(l) ?? ""),
onInput: (e) => {
const raw = (e.target as HTMLInputElement).valueAsNumber
quantityField.handleChange(isNaN(raw) ? 0 : raw)
},
onBlur: () => quantityField.handleBlur(),
},
{
p: (l) => String(quantityField.errors(l)[0] ?? ""),
hidden: (l) => quantityField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
}Select (native dropdown)
select() applies to a native <select>. Use onChange (not onInput) and read e.target.value:
import { select as selectPatch, label, formGroup } from "@domphy/ui"
const roleField = form.field<string>("role", {
validators: {
onChange: ({ value }) => value ? undefined : "Select a role",
},
})
const RoleSelect = {
fieldset: [
{ label: "Role", $: [label()] },
{
select: [
{ option: "-- Select --", value: "", disabled: true },
{ option: "Admin", value: "admin" },
{ option: "Editor", value: "editor" },
{ option: "Viewer", value: "viewer" },
],
$: [selectPatch()],
value: (l) => roleField.value(l),
onChange: (e) => roleField.handleChange((e.target as HTMLSelectElement).value),
onBlur: () => roleField.handleBlur(),
},
{
p: (l) => String(roleField.errors(l)[0] ?? ""),
hidden: (l) => roleField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
}Checkbox
inputCheckbox() applies to <input type="checkbox">. Bind to checked (boolean), not value:
import { inputCheckbox, label, formGroup } from "@domphy/ui"
const agreeField = form.field<boolean>("agreeToTerms", {
validators: {
onSubmit: ({ value }) => value ? undefined : "You must agree to the terms",
},
})
const AgreeCheckbox = {
fieldset: [
{
input: null,
type: "checkbox",
$: [inputCheckbox()],
checked: (l) => agreeField.value(l) ?? false,
onChange: (e) => agreeField.handleChange((e.target as HTMLInputElement).checked),
onBlur: () => agreeField.handleBlur(),
},
{ label: "I agree to the terms", $: [label()] },
{
p: (l) => String(agreeField.errors(l)[0] ?? ""),
hidden: (l) => agreeField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "horizontal" })],
}Toggle switch
inputSwitch() is a styled checkbox that renders as a toggle track. Wire it exactly like a checkbox:
import { inputSwitch, label, formGroup } from "@domphy/ui"
const notificationsField = form.field<boolean>("notifications", {})
const NotificationsToggle = {
fieldset: [
{ label: "Email notifications", $: [label()] },
{
input: null,
type: "checkbox",
$: [inputSwitch()],
checked: (l) => notificationsField.value(l) ?? false,
onChange: (e) => notificationsField.handleChange((e.target as HTMLInputElement).checked),
},
],
$: [formGroup({ layout: "horizontal" })],
}Radio group
Render multiple inputRadio() inputs sharing the same name. Check against the current field value:
import { inputRadio, label, formGroup } from "@domphy/ui"
const planField = form.field<"free" | "pro" | "team">("plan", {
validators: {
onSubmit: ({ value }) => value ? undefined : "Select a plan",
},
})
const PLANS = [
{ value: "free" as const, label: "Free" },
{ value: "pro" as const, label: "Pro ($9/mo)" },
{ value: "team" as const, label: "Team ($29/mo)" },
]
const PlanGroup = {
fieldset: [
{ legend: "Choose a plan" },
...PLANS.map(({ value, label: planLabel }) => ({
_key: value,
div: [
{
input: null,
type: "radio",
name: "plan",
id: `plan-${value}`,
$: [inputRadio()],
checked: (l) => planField.value(l) === value,
onChange: () => planField.handleChange(value),
onBlur: () => planField.handleBlur(),
},
{ label: planLabel, htmlFor: `plan-${value}`, $: [label()] },
],
style: { display: "flex", alignItems: "center", gap: themeSpacing(2) },
})),
{
p: (l) => String(planField.errors(l)[0] ?? ""),
hidden: (l) => planField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
}Async validation indicator
Show a spinner while meta.isValidating is true:
import { spinner } from "@domphy/ui"
const usernameField = form.field<string>("username", {
validators: {
onChangeAsync: async ({ value }) => {
if (!value) return undefined
const taken = await checkUsername(value)
return taken ? undefined : "Username already taken"
},
onChangeAsyncDebounceMs: 400,
},
})
const UsernameInput = {
fieldset: [
{ label: "Username", $: [label()] },
{
div: [
{
input: null,
type: "text",
$: [inputText()],
value: (l) => usernameField.value(l),
onInput: (e) => usernameField.handleChange((e.target as HTMLInputElement).value),
onBlur: () => usernameField.handleBlur(),
style: { width: "100%" },
},
{
span: null,
$: [spinner()],
hidden: (l) => !usernameField.meta(l).isValidating,
style: { position: "absolute", right: themeSpacing(2), top: "50%", transform: "translateY(-50%)" },
},
],
style: { position: "relative", display: "flex", alignItems: "center" },
},
{
p: (l) => String(usernameField.errors(l)[0] ?? ""),
hidden: (l) => usernameField.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
}Reusable field factory
Build a factory function to avoid repeating the binding pattern:
import type { FieldHandle } from "@domphy/form/domphy"
import { inputText, label, formGroup } from "@domphy/ui"
function textInput(
labelText: string,
field: FieldHandle<string>,
extra: Record<string, unknown> = {},
) {
return {
fieldset: [
{ label: labelText, $: [label()] },
{
input: null,
type: "text",
$: [inputText()],
value: (l) => field.value(l),
onInput: (e) => field.handleChange((e.target as HTMLInputElement).value),
onBlur: () => field.handleBlur(),
dataStatus: (l) => field.errors(l).length > 0 ? "error" : undefined,
...extra,
},
{
p: (l) => String(field.errors(l)[0] ?? ""),
hidden: (l) => field.errors(l).length === 0,
},
],
$: [formGroup({ layout: "vertical" })],
}
}
// Usage:
const form = createForm<{ email: string; name: string }>({
defaultValues: { email: "", name: "" },
onSubmit: ({ value }) => signup(value),
})
const emailField = form.field<string>("email", {
validators: { onChange: ({ value }) => value.includes("@") ? undefined : "Invalid email" },
})
const nameField = form.field<string>("name", {
validators: { onChange: ({ value }) => value ? undefined : "Required" },
})
const SignupForm = {
form: [
textInput("Email", emailField, { type: "email", autocomplete: "email" }),
textInput("Full name", nameField, { autocomplete: "name" }),
{
button: (l) => form.isSubmitting(l) ? "Creating account…" : "Sign up",
type: "submit",
$: [button({ color: "primary" })],
disabled: (l) => !form.canSubmit(l),
},
],
onSubmit: (e) => { (e as Event).preventDefault(); form.handleSubmit() },
_onRemove: () => form.destroy(),
style: {
display: "flex",
flexDirection: "column",
gap: themeSpacing(3),
maxWidth: themeSpacing(80),
},
}formGroup layout options
formGroup() applies to <fieldset> and arranges its children in a CSS Grid:
| Prop | Default | Description |
|---|---|---|
layout | "horizontal" | "horizontal" — label beside control; "vertical" — label above control |
color | "neutral" | Theme color for background and text |
Grid rules:
<legend>spans the full width- In horizontal:
<label>goes in column 1, inputs in column 2,<p>appears below the input in column 2 - In vertical: all children span the full width
<p>elements get a smaller text size automatically (helper / error text)