dimah-formv0.3.1

UI

Optional shadcn renderer on top of useFormResponse. npm or the registry. Headless react is unchanged.

@dimah-form/ui is optional. @dimah-form/react stays headless. The renderer takes a useFormResponse return as form — it does not call the hook.

Built-in types (text, email, date, number, boolean, select, multiSelect) have widgets. Custom defineFieldType types register once on FormUiProvider with the same type string — or a meta.widget key to swap one field without replacing the type.


Install

npm:

pnpm add @dimah-form/ui @dimah-form/react
@import "@dimah-form/ui/styles.css";

Or copy source into your app with the shadcn CLI (HTTP registry after docs deploy, or GitHub):

pnpm dlx shadcn@latest add https://form.dimah.dev/r/form.json
pnpm dlx shadcn@latest add dimah-kz/dimah-form/form

Template

import { FormUiProvider, FormView } from "@dimah-form/ui";

const widgets = { rating: StarRatingField, "text.mask": MaskedTextField };
const form = useFormResponse({ snapshot });

return (
  <FormUiProvider translations={fa} widgets={widgets}>
    <FormView form={form} />
  </FormUiProvider>
);

FormView is the default layout. layout="auto" (default) uses a wizard when meta.step spans more than one page on the snapshot, and widgets with mode="review" when the response is locked. Pass layout="fill" | "steps" | "review" — or form.meta.layout — to force one. Compact FormReview (shadcn Item list of formatted answers) is opt-in: review={<FormReview />} or components={{ Review: FormReview }}.

Slot props take false to hide, a node to replace, or a function to wrap the default (header={({ default: node }) => <Card>{node}</Card>}): header, progress, status, errorSummary, error, saveState, actions, stepList, stepHeading, stepNav, review, inactive. The default header includes the save-state badge. Stepped layouts show FormStepList and omit the progress bar and step heading unless you pass stepList={false} (then you get heading + step progress). Pass progress={<FormProgress variant="required" />} to keep a completion bar in a wizard.

render replaces the whole composition — keep FormRoot so Enter / submit still work:

<FormView
  form={form}
  header={false}
  render={({ fields, actions, error, saveState }) => (
    <FormRoot>
      <Card>
        {fields}
        {error}
        {saveState}
        {actions}
      </Card>
    </FormRoot>
  )}
/>

Failed submit (and failed Next) focuses the first invalid control. Enter on a non-last step advances instead of submitting.

Required labels render an asterisk. Pass your own mark — or the whole field frame — on the provider:

function RequiredMark() {
  return (
    <span className="ms-1 text-dimah-form-destructive" aria-hidden>
      required
    </span>
  );
}

<FormUiProvider
  components={{ RequiredMark, FieldFrame: MyFieldFrame }}
  formatIssue={(binding) =>
    binding.errorCode === "TOO_SMALL" ? "Pick a higher score" : undefined
  }
>
  <FormView form={form} />
</FormUiProvider>;

Custom widgets should wrap the control in FormFieldFrame so components.FieldFrame applies.


Compose

Every chrome piece is a primitive. FormScope provides the session so they do not need a form prop:

<FormScope form={form}>
  <FormRoot>
    <FormHeader />
    <FormProgress />
    <FormFields />
    <FormActions before={<CancelButton />} />
  </FormRoot>
</FormScope>
  • FormField — looks up meta.widget (when registered and not a built-in radio / switch / chips variant) then field.type. Pass children to replace that widget. Widgets receive mode: "edit" | "review".
  • FormFieldFrame — label / description / help / issue chrome. layout="stack" (default), "choice" (checkbox), or "group" (fieldset). data-field-type, data-widget, data-required, data-invalid are set for CSS hooks.
  • FormFields — maps visibleFields. fields / filter restrict the loop; meta.section groups into FormSection (or groupBy={false} to opt out). meta.width "half" / "third" shares a row. renderField={(binding, { defaultField }) => …} keeps the loop.
  • FormProgress / FormSaveState / FormReview — shadcn Progress (auto is wizard page when inside FormSteps, otherwise required-field completion), Badge for dirty/saving/saved drafts, compact Item list (renderValue to swap one row). Locked FormView review uses FormFields / widgets (mode="review") unless you opt into FormReview.
  • FormHeader — title / description. Omit saveState for FormSaveState (hidden while locked); components.Header / components.SaveState swap either piece.
  • FormErrorSummary — links to invalid fields after validate/submit.
  • FormSteps — groups snapshot meta.step (number or string, default "1"). Hidden fields drop out of the page; the current page is a key, not an index (step / defaultStep / onStepChange). Titles come from form.meta.steps. Wrap around FormRoot so Enter advances. FormStepFields + FormStepHeading + FormStepList (ToggleGroup) + FormStepNav (Next validates the step; last step renders children, usually FormActions).
  • FormActions — default save / submit / edit sit in a shadcn ButtonGroup. save="auto" hides Save draft when the session autosaves. before / after add controls without replacing Submit. abandon is opt-in. saveProps / submitProps merge onto the shadcn buttons (Base UI render included). sticky pins the bar. FormRoot / FormActions also take Base UI render to replace the host element.
  • Built-in widgets (TextField, BooleanField, …) are usable on their own: <EmailField binding={form.field("email")} />. ChoiceOption and StringField are exported for custom widgets. Prefix / suffix / character count use shadcn InputGroup.

A widget receives { binding, className, mode } — not a spread FormFieldBinding. Presentation stays on meta. Type-check the document with satisfies FormDefinitionUi so this package's keys autocomplete; extra meta keys are allowed. For saveForm rows, pass a Zod object to dimahForm({ metaSchema }).

import { defineForm } from "@dimah-form/server";
import type { FormDefinitionUi } from "@dimah-form/ui/types";

export const onboarding = defineForm({
  title: "Onboarding",
  meta: { layout: "steps", steps: { "1": "About you", "2": "Work" } },
  fields: [
    {
      id: "name",
      type: "text",
      required: true,
      meta: { placeholder: "Ada", step: 1 },
    },
    {
      id: "role",
      type: "select",
      meta: { widget: "radio", step: 2 },
      options: [{ value: "eng", label: "Engineer" }],
    },
  ],
} satisfies FormDefinitionUi);
KeyEffect
multilinetext → textarea
rowstextarea rows
placeholderinput / select placeholder
widgetcustom registry key, or built-in variant radio / switch / chips (not registry keys)
sectionheading in FormFields
steppage in FormSteps
width: "half" / "third"share a row
prefix / suffixshadcn InputGroup addon on the control
helppersistent hint under the control
autocompletenative autoComplete (email defaults to email)
inputModenative inputMode
orientationFormFieldFrame orientation
option.meta.descriptionhelper under a radio / checkbox option
form.meta.layoutauto / fill / steps / review
form.meta.stepsstep key → heading
form.meta.submitLabelsubmit button copy
unsetOnOff (boolean)field key, not meta: off → null (consent)

RTL: logical CSS from day one (text-start, ms-*, ps-*). Colors: *-dimah-form-* (defaults to your shadcn theme). Chrome roots expose data-slot="form-root" / form-actions / … for CSS.


Next Steps

On this page