{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "currency-input",
  "title": "Currency Input",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A locale-aware money field that formats on blur while keeping editing and caret behavior simple.",
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/currency-input.tsx",
      "content": "// Ballmac UI: Currency Input. https://ui.ballmac.com/components/currency-input\n\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { useLocale } from \"@/lib/ballmac/i18n\"\n\ntype CurrencyInputProps = Omit<\n  React.ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"type\"\n> & {\n  /** Visible and accessible input label. */\n  label: string\n  /** ISO 4217 currency code. */\n  currency?: string\n  /** Fixed locale for formatting and parsing. */\n  locale?: string\n  /** Controlled numeric amount, or null when empty. */\n  value?: number | null\n  /** Initial numeric amount. */\n  defaultValue?: number | null\n  /** Called with the parsed numeric amount while editing. */\n  onValueChange?: (value: number | null) => void\n  /** Smallest allowed amount. */\n  min?: number\n  /** Largest allowed amount. */\n  max?: number\n}\nfunction CurrencyInput({\n  className,\n  label,\n  currency = \"USD\",\n  locale,\n  value,\n  defaultValue = null,\n  onValueChange,\n  min,\n  max,\n  onFocus,\n  onBlur,\n  ...props\n}: CurrencyInputProps) {\n  const defaultLocale = useLocale()\n  locale ??= defaultLocale\n  const [internal, setInternal] = React.useState<number | null>(defaultValue)\n  const [editing, setEditing] = React.useState(false)\n  const [draft, setDraft] = React.useState(\"\")\n  const current = value === undefined ? internal : value\n  const formatter = new Intl.NumberFormat(locale, {\n    style: \"currency\",\n    currency,\n  })\n  const decimal =\n    formatter.formatToParts(1.1).find((part) => part.type === \"decimal\")\n      ?.value ?? \".\"\n  const group =\n    formatter.formatToParts(1000).find((part) => part.type === \"group\")\n      ?.value ?? \",\"\n  const digits = formatter.resolvedOptions().maximumFractionDigits\n  const shown = editing\n    ? draft\n    : current === null\n      ? \"\"\n      : formatter.format(current)\n  function report(next: number | null) {\n    if (value === undefined) setInternal(next)\n    onValueChange?.(next)\n  }\n  function parse(raw: string) {\n    const compact = raw\n      .replaceAll(group, \"\")\n      .replaceAll(decimal, \".\")\n      .replace(/[^\\d.\\-]/g, \"\")\n    if (!compact || compact === \"-\" || compact === \".\" || compact === \"-.\")\n      return null\n    const parsed = Number(compact)\n    return Number.isFinite(parsed) ? parsed : null\n  }\n  return (\n    <label data-slot=\"currency-input\" className=\"flex min-w-0 flex-col gap-1.5\">\n      <span className=\"text-sm font-medium\">{label}</span>\n      <span className=\"relative\">\n        <input\n          {...props}\n          type=\"text\"\n          inputMode=\"decimal\"\n          aria-label={label}\n          value={shown}\n          onFocus={(event) => {\n            setEditing(true)\n            setDraft(\n              current === null ? \"\" : String(current).replace(\".\", decimal),\n            )\n            onFocus?.(event)\n          }}\n          onChange={(event) => {\n            const raw = event.target.value\n            setDraft(raw)\n            if (raw.trim() === \"\") report(null)\n            else {\n              const parsed = parse(raw)\n              if (parsed !== null) report(parsed)\n            }\n          }}\n          onBlur={(event) => {\n            setEditing(false)\n            const parsed = parse(draft)\n            if (parsed !== null) {\n              const bounded = Math.min(\n                max ?? Infinity,\n                Math.max(min ?? -Infinity, parsed),\n              )\n              const rounded = Number(bounded.toFixed(digits))\n              if (rounded !== current) report(rounded)\n            }\n            onBlur?.(event)\n          }}\n          className={cn(\n            \"border-input bg-background text-foreground h-9 w-full min-w-0 rounded-md border px-3 pe-14 text-sm tabular-nums outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20\",\n            className,\n          )}\n        />\n        <span\n          aria-hidden=\"true\"\n          className=\"text-muted-foreground pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-xs font-medium\"\n        >\n          {currency}\n        </span>\n      </span>\n    </label>\n  )\n}\nexport { CurrencyInput, type CurrencyInputProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/currency-input.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "currency",
      "money",
      "input"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "A locale-aware money field that formats on blur while keeping editing and caret behavior simple.",
      "whenToUse": [
        "Collect prices, budgets, or payment amounts"
      ],
      "whenNotToUse": [
        "Use Number Input for unitless quantities"
      ],
      "composesWith": [
        "number-input"
      ],
      "a11y": [
        {
          "keys": "Tab / typing",
          "action": "Enters a numeric amount and formats it on blur"
        }
      ],
      "customization": [
        "Controlled and uncontrolled value",
        "Tokens and state styling"
      ]
    },
    "examples": [
      "currency-input-demo",
      "currency-input-states"
    ],
    "url": "https://ui.ballmac.com/components/currency-input"
  },
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}