dodil/erp-ui

public/r/form.json

{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "form",
  "title": "FormDialog",
  "description": "Fields as data, required/shape checks before sending, the API's refusal shown in place.",
  "dependencies": [
    "@radix-ui/themes"
  ],
  "registryDependencies": [
    "@erp-ui/money-input"
  ],
  "files": [
    {
      "path": "src/components/form/Form.tsx",
      "content": "/**\n * Forms — fields described as data, a dialog that submits them, and the API's refusal shown in place.\n *\n * Every value is a STRING while it is being edited (an amount is exact text, a date is \"2026-10-17\"), and\n * the screen turns them into the request body. `FormDialog` keeps itself open while the request runs and\n * shows the reason when the API refuses — the person keeps what they typed. Required fields are checked\n * before anything is sent; the rules that matter are the server's, and its words are what the person reads.\n */\nimport { Button, Callout, Dialog, Flex, Select, Text, TextArea, TextField } from \"@radix-ui/themes\";\nimport { useId, useState, type FormEvent, type ReactNode } from \"react\";\n\nimport { MoneyInput, parseMoneyInput } from \"./MoneyInput\";\n\nexport type FieldKind = \"text\" | \"email\" | \"textarea\" | \"select\" | \"date\" | \"integer\" | \"money\";\n\nexport interface FieldSpec {\n  name: string;\n  label: string;\n  kind?: FieldKind;\n  required?: boolean;\n  options?: { value: string; label: string }[];\n  /** money: the currency shown beside the amount. */\n  currency?: string;\n  hint?: string;\n  placeholder?: string;\n  defaultValue?: string;\n}\n\nexport type FormValues = Record<string, string>;\n\n/** Problems before sending: required fields, and the shape of integers and amounts. Empty = fine. */\nexport function validate(fields: FieldSpec[], values: FormValues): Record<string, string> {\n  const problems: Record<string, string> = {};\n  for (const f of fields) {\n    const v = (values[f.name] ?? \"\").trim();\n    if (f.required && v === \"\") problems[f.name] = `${f.label} is required`;\n    else if (v !== \"\" && f.kind === \"integer\" && !/^-?\\d+$/.test(v)) problems[f.name] = `${f.label} must be a whole number`;\n    else if (v !== \"\" && f.kind === \"money\" && parseMoneyInput(v) === null) problems[f.name] = `${f.label} must be an amount`;\n    else if (v !== \"\" && f.kind === \"email\" && !/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(v)) problems[f.name] = `${f.label} must be an email address`;\n  }\n  return problems;\n}\n\nexport function FormField({ field, value, onChange, error }: {\n  field: FieldSpec; value: string; onChange: (v: string) => void; error?: string;\n}) {\n  const id = useId();\n  const kind = field.kind ?? \"text\";\n  const note = error ?? field.hint;\n  const described = note ? `${id}-note` : undefined;\n  if (kind === \"money\") {\n    return (\n      <Flex direction=\"column\" gap=\"1\">\n        <MoneyInput label={field.label} value={value} required={field.required} currency={field.currency}\n                    onChange={(_parsed, text) => onChange(text)} hint={error ? undefined : field.hint} />\n        {error && <Text size=\"1\" color=\"red\">{error}</Text>}\n      </Flex>\n    );\n  }\n  return (\n    <Flex direction=\"column\" gap=\"1\">\n      <Text as=\"label\" size=\"2\" weight=\"medium\" htmlFor={id}>{field.label}{field.required ? \" *\" : \"\"}</Text>\n      {kind === \"textarea\" ? (\n        <TextArea id={id} value={value} placeholder={field.placeholder} onChange={(e) => onChange(e.target.value)}\n                  aria-invalid={!!error || undefined} aria-describedby={described} />\n      ) : kind === \"select\" ? (\n        <Select.Root value={value || undefined} onValueChange={onChange}>\n          <Select.Trigger id={id} placeholder={field.placeholder ?? \"Choose…\"} aria-describedby={described} />\n          <Select.Content>\n            {(field.options ?? []).map((o) => <Select.Item key={o.value} value={o.value}>{o.label}</Select.Item>)}\n          </Select.Content>\n        </Select.Root>\n      ) : (\n        <TextField.Root id={id} value={value} placeholder={field.placeholder}\n                        type={kind === \"date\" ? \"date\" : kind === \"email\" ? \"email\" : \"text\"}\n                        inputMode={kind === \"integer\" ? \"numeric\" : undefined}\n                        onChange={(e) => onChange(e.target.value)}\n                        aria-invalid={!!error || undefined} aria-describedby={described} />\n      )}\n      {note && <Text id={described} size=\"1\" color={error ? \"red\" : \"gray\"}>{note}</Text>}\n    </Flex>\n  );\n}\n\nfunction initial(fields: FieldSpec[]): FormValues {\n  return Object.fromEntries(fields.map((f) => [f.name, f.defaultValue ?? \"\"]));\n}\n\nexport interface FormDialogProps {\n  /** The button that opens it. Omit it and pass `open` to open the dialog from code (a card dropped on a column). */\n  trigger?: ReactNode;\n  open?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  title: string;\n  description?: ReactNode;\n  fields: FieldSpec[];\n  submitLabel: string;\n  /** Throw to keep the dialog open with the error shown. Resolve to close it. */\n  onSubmit: (values: FormValues) => Promise<unknown> | unknown;\n}\n\nexport function FormDialog({ trigger, open: controlled, onOpenChange, title, description, fields, submitLabel, onSubmit }: FormDialogProps) {\n  const [values, setValues] = useState<FormValues>(() => initial(fields));\n  const [problems, setProblems] = useState<Record<string, string>>({});\n  const [error, setError] = useState<string | null>(null);\n  const [busy, setBusy] = useState(false);\n  const [own, setOwn] = useState(false);\n  const open = controlled ?? own;\n  const setOpen = (o: boolean) => { setOwn(o); onOpenChange?.(o); };\n  const [seen, setSeen] = useState(open);\n  if (open !== seen) {                       // opened from outside: start from the defaults, like a click would\n    setSeen(open);\n    if (open) { setValues(initial(fields)); setProblems({}); setError(null); }\n  }\n  const submit = async (e: FormEvent) => {\n    e.preventDefault();\n    const found = validate(fields, values);\n    setProblems(found);\n    if (Object.keys(found).length) return;\n    setBusy(true);\n    setError(null);\n    try {\n      const normalised = Object.fromEntries(fields.map((f) => {\n        const v = (values[f.name] ?? \"\").trim();\n        return [f.name, f.kind === \"money\" && v !== \"\" ? parseMoneyInput(v)! : v];\n      }));\n      await onSubmit(normalised);\n      setOpen(false);\n      setValues(initial(fields));\n    } catch (err) {\n      setError(err instanceof Error ? err.message : String(err));\n    } finally {\n      setBusy(false);\n    }\n  };\n\n  return (\n    <Dialog.Root open={open} onOpenChange={(o) => { setOpen(o); setSeen(o); if (o) { setValues(initial(fields)); setProblems({}); setError(null); } }}>\n      {trigger && <Dialog.Trigger>{trigger}</Dialog.Trigger>}\n      <Dialog.Content maxWidth=\"520px\">\n        <Dialog.Title>{title}</Dialog.Title>\n        <Dialog.Description size=\"2\" mb=\"3\" color=\"gray\">{description ?? \"Fields marked * are required.\"}</Dialog.Description>\n        <form onSubmit={submit} noValidate>\n          <Flex direction=\"column\" gap=\"3\">\n            {fields.map((f) => (\n              <FormField key={f.name} field={f} value={values[f.name] ?? \"\"}\n                         error={problems[f.name]}\n                         onChange={(v) => setValues((s) => ({ ...s, [f.name]: v }))} />\n            ))}\n            {error && <Callout.Root color=\"red\" role=\"alert\" size=\"1\"><Callout.Text>{error}</Callout.Text></Callout.Root>}\n            <Flex gap=\"3\" justify=\"end\" mt=\"2\">\n              <Dialog.Close><Button type=\"button\" variant=\"soft\" color=\"gray\">Cancel</Button></Dialog.Close>\n              <Button type=\"submit\" loading={busy}>{submitLabel}</Button>\n            </Flex>\n          </Flex>\n        </form>\n      </Dialog.Content>\n    </Dialog.Root>\n  );\n}\n",
      "type": "registry:component",
      "target": "erp-ui/components/form/Form.tsx"
    }
  ],
  "type": "registry:component"
}