{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "money-input",
"title": "MoneyInput",
"description": "An amount typed by a person, kept as exact decimal text; invalid input is an error, never rounded.",
"dependencies": [
"@radix-ui/themes"
],
"files": [
{
"path": "src/components/form/MoneyInput.tsx",
"content": "/**\n * MoneyInput — an amount typed by a person, kept as exact text.\n *\n * What is typed is never turned into a float: \"12 000,5\"? No — \"12,000.50\" or \"12000.5\" are read as the\n * decimal 12000.50 by removing grouping and checking the shape, and the value handed back is the decimal\n * STRING (\"12000.50\") the API takes. Anything else is shown as an error under the field, and the value is\n * null so a form cannot submit it.\n */\nimport { Flex, Text, TextField } from \"@radix-ui/themes\";\nimport { useId, useState } from \"react\";\n\n/** \"12,000.5\" → \"12000.50\"; \"\" → \"\"; not an amount → null. At most `decimals` places, never rounded. */\nexport function parseMoneyInput(text: string, decimals = 2): string | null {\n const t = text.trim().replace(/[\\s,_']/g, \"\");\n if (t === \"\") return \"\";\n const m = /^(\\d+)(?:\\.(\\d*))?$/.exec(t);\n if (!m) return null;\n const frac = m[2] ?? \"\";\n if (frac.length > decimals) return null;\n return `${m[1].replace(/^0+(?=\\d)/, \"\")}${decimals ? \".\" + frac.padEnd(decimals, \"0\") : \"\"}`;\n}\n\nexport interface MoneyInputProps {\n label: string;\n /** The decimal string, e.g. \"12000.50\", or \"\" for empty. */\n value: string;\n /** The parsed decimal (null when what is typed is not an amount) and the text as typed. */\n onChange: (value: string | null, text: string) => void;\n currency?: string;\n required?: boolean;\n disabled?: boolean;\n /** Why it is disabled (shown under the field). */\n disabledReason?: string;\n hint?: string;\n decimals?: number;\n name?: string;\n}\n\nexport function MoneyInput({ label, value, onChange, currency, required, disabled, disabledReason, hint, decimals = 2, name }: MoneyInputProps) {\n const id = useId();\n const [text, setText] = useState(value);\n const [invalid, setInvalid] = useState(false);\n // follow a value set from outside (a reset, a loaded record) without fighting what is being typed\n const [last, setLast] = useState(value);\n if (value !== last) {\n setLast(value);\n if (text !== value && parseMoneyInput(text, decimals) !== value) setText(value);\n }\n const note = invalid ? `Enter an amount like 1250.50 (at most ${decimals} decimals)` : disabled ? disabledReason : hint;\n return (\n <Flex direction=\"column\" gap=\"1\">\n <Text as=\"label\" size=\"2\" weight=\"medium\" htmlFor={id}>{label}{required ? \" *\" : \"\"}</Text>\n <TextField.Root\n id={id}\n name={name}\n inputMode=\"decimal\"\n autoComplete=\"off\"\n value={text}\n disabled={disabled}\n required={required}\n aria-invalid={invalid || undefined}\n aria-describedby={note ? `${id}-note` : undefined}\n color={invalid ? \"red\" : undefined}\n onChange={(e) => {\n setText(e.target.value);\n const parsed = parseMoneyInput(e.target.value, decimals);\n setInvalid(parsed === null);\n onChange(parsed, e.target.value);\n }}\n style={{ fontVariantNumeric: \"tabular-nums\" }}\n >\n {currency && <TextField.Slot side=\"right\"><Text size=\"1\" color=\"gray\">{currency}</Text></TextField.Slot>}\n </TextField.Root>\n {note && <Text id={`${id}-note`} size=\"1\" color={invalid ? \"red\" : \"gray\"}>{note}</Text>}\n </Flex>\n );\n}\n",
"type": "registry:component",
"target": "erp-ui/components/form/MoneyInput.tsx"
}
],
"type": "registry:component"
}