dimah-formv0.2.0

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:

components/form-field-control.tsx
"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:

HelperSignatureDescription
fieldLabel(field)(field: FormField) => stringReturns field.label ?? field.id.
fieldOptions(field)(field: FormField) => FieldOption[]Normalizes { value, label?, meta? } arrays with label fallbacks.
emptyToNull(value)(value: unknown) => unknownConverts empty strings "" to null so draft patches delete empty values.
formatAnswer(field, value)(field: FormField, value: unknown) => stringReturns 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:

components/questionnaire-wizard.tsx
"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>
  );
}

Next Steps

On this page