Widgets
Bind FormFieldBinding to your inputs, build a type dispatcher, and split long forms into steps.
dimah-form does not ship CSS or widgets. q.field(id) returns a FormFieldBinding you pass to your own <input>, shadcn/ui, Radix, or design-system control.
Anatomy of FormFieldBinding
When you call q.field(field.id), useFormResponse returns a binding object tailored for input components:
type FormFieldBinding = {
field?: FormField; // Field schema definition
value: unknown; // Current value from answers map
error?: string; // Active error message string
errorCode?: string; // Machine-readable issue code (e.g., "REQUIRED")
errorParams?: Record<string, string | number>; // Interpolation params
disabled: boolean; // true if locked, pending, or disabled
required: boolean; // true if required by schema
onChange: (value: unknown) => void; // Value updater (pass null to clear)
onBlur: () => void; // Blur handler for touch tracking
};Building a Unified Widget Dispatcher
A common and scalable pattern is to create a single FormFieldControl component that branches on field.type:
"use client";
import {
emptyToNull,
fieldLabel,
fieldOptions,
type FormFieldBinding,
} from "@dimah-form/react";
export function FormFieldControl(binding: FormFieldBinding) {
const { field, value, error, disabled, required, onChange } = binding;
if (!field) return null;
const label = (
<label className="block text-sm font-semibold text-gray-800">
{fieldLabel(field)}
{required && <span className="ml-1 text-red-500">*</span>}
</label>
);
// 1. Boolean (Checkbox)
if (field.type === "boolean") {
return (
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={value === true}
disabled={disabled}
onChange={(e) => onChange(e.target.checked ? true : null)}
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
/>
<span className="text-sm text-gray-800">{fieldLabel(field)}</span>
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
// 2. Select (Dropdown)
if (field.type === "select") {
return (
<div className="space-y-1">
{label}
<select
value={typeof value === "string" ? value : ""}
disabled={disabled}
onChange={(e) => onChange(emptyToNull(e.target.value))}
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 focus:ring-2 focus:ring-black focus:outline-none"
>
<option value="">Select an option...</option>
{fieldOptions(field).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
// 3. Multi-Select (Checkbox Group)
if (field.type === "multiSelect") {
const selected = Array.isArray(value) ? (value as string[]) : [];
const toggleOption = (optValue: string) => {
const next = selected.includes(optValue)
? selected.filter((v) => v !== optValue)
: [...selected, optValue];
onChange(next.length > 0 ? next : null);
};
return (
<div className="space-y-2">
{label}
<div className="grid grid-cols-2 gap-2">
{fieldOptions(field).map((opt) => (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2 text-sm text-gray-700"
>
<input
type="checkbox"
checked={selected.includes(opt.value)}
disabled={disabled}
onChange={() => toggleOption(opt.value)}
className="h-4 w-4 rounded border-gray-300 text-black focus:ring-black"
/>
{opt.label}
</label>
))}
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
// 4. Number Input
if (field.type === "number") {
return (
<div className="space-y-1">
{label}
<input
type="number"
value={typeof value === "number" ? value : ""}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
onChange(raw === "" ? null : Number(raw));
}}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-black focus:outline-none"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
// 5. Date Input (YYYY-MM-DD)
if (field.type === "date") {
return (
<div className="space-y-1">
{label}
<input
type="date"
value={typeof value === "string" ? value : ""}
disabled={disabled}
onChange={(e) => onChange(emptyToNull(e.target.value))}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-black focus:outline-none"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
// 6. Text & Email Input (Default)
return (
<div className="space-y-1">
{label}
<input
type={field.type === "email" ? "email" : "text"}
value={typeof value === "string" ? value : ""}
disabled={disabled}
onChange={(e) => onChange(emptyToNull(e.target.value))}
className="w-full rounded-md border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-black focus:outline-none"
/>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}Utility Functions
@dimah-form/react and @dimah-form/core export helpful formatting utilities:
| Helper | Signature | Description |
|---|---|---|
fieldLabel(field) | (field: FormField) => string | Returns field.label ?? field.id. |
fieldOptions(field) | (field: FormField) => FieldOption[] | Normalizes { value, label?, meta? } arrays with label fallbacks. |
emptyToNull(value) | (value: unknown) => unknown | Converts empty strings "" to null so draft patches delete empty values. |
formatAnswer(field, value) | (field: FormField, value: unknown) => string | Returns a human-friendly formatted string representation for summary views. |
Advanced Pattern: Multi-Step Questionnaire Wizard
You can organize long questionnaires into multi-step wizards using field.meta.step or splitting q.visibleFields:
"use client";
import { useState } from "react";
import { useFormResponse } from "@/lib/form-client";
import { FormFieldControl } from "./form-field-control";
import type { FormSnapshot } from "@dimah-form/react";
export function QuestionnaireWizard({ form }: { form: FormSnapshot }) {
const [currentStep, setCurrentStep] = useState(1);
const q = useFormResponse({ snapshot: form });
// Filter visible fields belonging to the current step (default step: 1)
const stepFields = q.visibleFields.filter(
(f) => (Number(f.meta?.step) || 1) === currentStep,
);
const totalSteps = Math.max(
...q.visibleFields.map((f) => Number(f.meta?.step) || 1),
);
return (
<div className="mx-auto max-w-lg space-y-6">
{/* Progress Bar */}
<div>
<div className="mb-1 flex justify-between text-xs text-gray-500">
<span>
Step {currentStep} of {totalSteps}
</span>
<span>{q.completion.percent}% Complete</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
<div
className="h-full bg-black transition-all duration-300"
style={{ width: `${q.completion.percent}%` }}
/>
</div>
</div>
{/* Current Step Fields */}
<div className="space-y-4">
{stepFields.map((field) => (
<FormFieldControl key={field.id} {...q.field(field.id)} />
))}
</div>
{/* Navigation Controls */}
<div className="flex justify-between border-t pt-4">
<button
type="button"
disabled={currentStep === 1}
onClick={() => setCurrentStep((s) => s - 1)}
className="rounded border px-4 py-2 disabled:opacity-30"
>
Previous
</button>
{currentStep < totalSteps ? (
<button
type="button"
onClick={() => setCurrentStep((s) => s + 1)}
className="rounded bg-black px-5 py-2 text-white"
>
Next Step
</button>
) : (
<button
type="button"
disabled={q.pending !== undefined}
onClick={() => void q.submit()}
className="rounded bg-green-600 px-5 py-2 text-white"
>
{q.pending === "submit" ? "Submitting..." : "Submit Questionnaire"}
</button>
)}
</div>
</div>
);
}