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"
}