dodil/erp-ui

public/r/money-input.json

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