{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "money-input",
  "type": "registry:component",
  "title": "Money Input",
  "description": "An amount field that groups digits as you type without ever losing your caret, follows each currency's own rules (yes, lakh grouping and zero-decimal yen), and keeps the limits in view.",
  "categories": [
    "component",
    "Forms"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/menu.json"
  ],
  "files": [
    {
      "path": "registry/devl/money-input.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ChevronDownIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ChangeEvent, type ReactNode, useId, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Menu, MenuGroup, MenuGroupLabel, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from \"@/components/ui/menu\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface Currency {\n  code: string;\n  locale: string;\n  name: string;\n}\n\nexport const CURRENCIES: Currency[] = [\n  { code: \"USD\", locale: \"en-US\", name: \"US dollar\" },\n  { code: \"EUR\", locale: \"de-DE\", name: \"Euro\" },\n  { code: \"GBP\", locale: \"en-GB\", name: \"British pound\" },\n  { code: \"JPY\", locale: \"ja-JP\", name: \"Japanese yen\" },\n  { code: \"INR\", locale: \"en-IN\", name: \"Indian rupee\" },\n];\n\nconst TYPED_DECIMAL_MARK = \"\\u0000\";\n\nfunction describe(currency: Currency) {\n  const nf = new Intl.NumberFormat(currency.locale, { style: \"currency\", currency: currency.code });\n  const parts = nf.formatToParts(1234567.89);\n  return {\n    symbol: parts.find((p) => p.type === \"currency\")?.value ?? currency.code,\n    group: parts.find((p) => p.type === \"group\")?.value ?? \",\",\n    decimal: parts.find((p) => p.type === \"decimal\")?.value ?? \".\",\n    fraction: nf.resolvedOptions().maximumFractionDigits ?? 2,\n    symbolAfter: parts.findIndex((p) => p.type === \"currency\") > parts.findIndex((p) => p.type === \"integer\"),\n  };\n}\n\nfunction display(raw: string, currency: Currency, d: ReturnType<typeof describe>) {\n  if (!raw) return \"\";\n  const [int, frac] = raw.split(\".\");\n  const grouped = new Intl.NumberFormat(currency.locale, { maximumFractionDigits: 0 }).format(BigInt(int || \"0\"));\n  return frac === undefined ? grouped : `${grouped}${d.decimal}${frac}`;\n}\n\nexport function formatMoney(value: number, currency: Currency) {\n  return new Intl.NumberFormat(currency.locale, { style: \"currency\", currency: currency.code }).format(value);\n}\n\nexport interface MoneyInputProps {\n  value?: number | null;\n  defaultValue?: number | null;\n  onValueChange?: (value: number | null) => void;\n  currency?: string;\n  defaultCurrency?: string;\n  onCurrencyChange?: (code: string) => void;\n  currencies?: Currency[];\n  min?: number;\n  max?: number;\n  label?: ReactNode;\n  hintAction?: ReactNode;\n  className?: string;\n}\n\nconst toRaw = (value: number | null | undefined, fraction: number) =>\n  value === null || value === undefined ? \"\" : value.toFixed(fraction);\n\nexport function MoneyInput({\n  value: valueProp,\n  defaultValue = null,\n  onValueChange,\n  currency: currencyProp,\n  defaultCurrency = \"USD\",\n  onCurrencyChange,\n  currencies = CURRENCIES,\n  min,\n  max,\n  label = \"Amount\",\n  hintAction,\n  className,\n}: MoneyInputProps) {\n  const [innerCode, setInnerCode] = useState(defaultCurrency);\n  const code = currencyProp ?? innerCode;\n  const currency = currencies.find((c) => c.code === code) ?? currencies[0];\n  const d = useMemo(() => describe(currency), [currency]);\n  const [raw, setRaw] = useState(() => toRaw(valueProp ?? defaultValue, d.fraction));\n  const inputRef = useRef<HTMLInputElement>(null);\n  const caret = useRef<number | null>(null);\n  const [focused, setFocused] = useState(false);\n  const reduce = useReducedMotion();\n  const id = useId();\n\n  const rawNumber = raw ? Number(raw) : null;\n  if (valueProp !== undefined) {\n    const expected = toRaw(valueProp, d.fraction);\n    if ((expected === \"\" ? null : Number(expected)) !== rawNumber) setRaw(expected);\n  }\n\n  const shown = display(raw, currency, d);\n\n  useLayoutEffect(() => {\n    if (caret.current === null || !inputRef.current) return;\n    inputRef.current.setSelectionRange(caret.current, caret.current);\n    caret.current = null;\n  });\n\n  const commit = (next: string) => {\n    setRaw(next);\n    onValueChange?.(next === \"\" ? null : Number(next));\n  };\n\n  const onChange = (event: ChangeEvent<HTMLInputElement>) => {\n    const el = event.target;\n    let text = el.value;\n    let at = el.selectionStart ?? text.length;\n    const native = event.nativeEvent as InputEvent;\n    const typed = native.data;\n    const insertedAt = typed && typed.length === 1 ? at - 1 : -1;\n    const typedAnyDecimalKey = typed === \".\" || typed === \",\";\n    if (typedAnyDecimalKey && d.fraction > 0 && !raw.includes(\".\")) {\n      text = `${text.slice(0, at - 1)}${TYPED_DECIMAL_MARK}${text.slice(at)}`;\n    }\n    let int = \"\";\n    let frac: string | null = null;\n    let before = 0;\n    for (let i = 0; i < text.length; i++) {\n      const ch = text[i];\n      const isDecimal = ch === TYPED_DECIMAL_MARK || (ch === d.decimal && i !== insertedAt);\n      if (/\\d/.test(ch)) {\n        if (frac === null) int += ch;\n        else if (frac.length < d.fraction) frac += ch;\n        else {\n          if (i < at) at -= 1;\n          continue;\n        }\n        if (i < at) before++;\n      } else if (isDecimal && frac === null && d.fraction > 0) {\n        frac = \"\";\n        if (i < at) before++;\n      }\n    }\n    const trimmed = int.replace(/^0+(?=\\d)/, \"\");\n    if (trimmed.length < int.length) before = Math.max(0, before - (int.length - trimmed.length));\n    int = trimmed;\n    if (int.length > 13) return;\n    let next = frac === null ? int : `${int || \"0\"}.${frac}`;\n    const amountUnchanged = next === raw;\n    if (amountUnchanged && native.inputType === \"deleteContentBackward\" && before > 0) {\n      next = next.slice(0, before - 1) + next.slice(before);\n      before -= 1;\n    } else if (amountUnchanged && native.inputType === \"deleteContentForward\") {\n      next = next.slice(0, before) + next.slice(before + 1);\n    }\n    next = next.replace(/^0+(?=\\d)/, \"\").replace(/^\\./, \"0.\");\n    const formatted = display(next, currency, d);\n    let pos = 0;\n    let seen = 0;\n    while (pos < formatted.length && seen < before) {\n      if (/\\d/.test(formatted[pos]) || formatted[pos] === d.decimal) seen++;\n      pos++;\n    }\n    caret.current = pos;\n    commit(next);\n  };\n\n  const switchCurrency = (next: string) => {\n    const target = currencies.find((c) => c.code === next);\n    if (!target) return;\n    const fraction = describe(target).fraction;\n    if (raw.includes(\".\") && fraction === 0) commit(raw.split(\".\")[0]);\n    else if (raw.includes(\".\")) commit(`${raw.split(\".\")[0]}.${raw.split(\".\")[1].slice(0, fraction)}`);\n    if (currencyProp === undefined) setInnerCode(next);\n    onCurrencyChange?.(next);\n  };\n\n  const amount = raw ? Number(raw) : null;\n  const over = amount !== null && max !== undefined && amount > max;\n  const under = !focused && amount !== null && amount > 0 && min !== undefined && amount < min;\n  const hint = over\n    ? `That's over the ${formatMoney(max, currency)} limit`\n    : under\n      ? `The minimum is ${formatMoney(min, currency)}`\n      : [min !== undefined && `Min ${formatMoney(min, currency)}`, max !== undefined && `Max ${formatMoney(max, currency)}`]\n          .filter(Boolean)\n          .join(\" · \");\n\n  const symbol = (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        aria-hidden\n        className=\"shrink-0 text-muted-foreground\"\n        exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\" }}\n        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n        key={d.symbol}\n        transition={{ duration: 0.16, ease: [0.23, 1, 0.32, 1] }}\n      >\n        {d.symbol}\n      </motion.span>\n    </AnimatePresence>\n  );\n\n  return (\n    <div className={cn(\"grid w-full gap-2\", className)}>\n      <label className=\"font-medium text-sm\" htmlFor={id}>\n        {label}\n      </label>\n      <div\n        className={cn(\n          \"flex h-14 items-center gap-1 rounded-xl border border-input bg-background not-dark:bg-clip-padding pr-1.5 pl-3.5 shadow-xs/5 ring-ring/24 transition-shadow has-focus-visible:border-ring has-focus-visible:ring-[3px] dark:bg-input/32\",\n          (over || under) && \"border-destructive/40 has-focus-visible:border-destructive/64 has-focus-visible:ring-destructive/16\",\n        )}\n      >\n        <div className=\"flex min-w-0 flex-1 items-baseline gap-1 font-semibold text-2xl tabular-nums tracking-tight\">\n          {!d.symbolAfter && symbol}\n          <input\n            aria-describedby={`${id}-hint`}\n            aria-invalid={over || under || undefined}\n            autoComplete=\"transaction-amount\"\n            className={cn(\"min-w-0 bg-transparent outline-none placeholder:text-muted-foreground/40\", d.symbolAfter ? \"max-w-full [field-sizing:content]\" : \"w-0 flex-1\")}\n            id={id}\n            inputMode={d.fraction ? \"decimal\" : \"numeric\"}\n            onFocus={() => setFocused(true)}\n            onBlur={() => {\n              setFocused(false);\n              if (!raw.includes(\".\")) return;\n              const [int, frac] = raw.split(\".\");\n              const tidy = frac ? `${int}.${frac.padEnd(d.fraction, \"0\")}` : int;\n              if (tidy !== raw) setRaw(tidy);\n            }}\n            onChange={onChange}\n            placeholder={d.fraction ? `0${d.decimal}${\"0\".repeat(d.fraction)}` : \"0\"}\n            ref={inputRef}\n            value={shown}\n          />\n          {d.symbolAfter && symbol}\n        </div>\n        <Menu>\n          <MenuTrigger\n            aria-label={`Currency: ${currency.name}`}\n            className=\"flex h-9 shrink-0 cursor-pointer items-center gap-1 rounded-lg px-2.5 font-medium font-mono text-muted-foreground text-xs outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring data-popup-open:bg-accent data-popup-open:text-foreground\"\n          >\n            {currency.code}\n            <ChevronDownIcon className=\"size-3.5\" />\n          </MenuTrigger>\n          <MenuPopup align=\"end\" className=\"min-w-52\">\n            <MenuGroup>\n            <MenuGroupLabel>Currency</MenuGroupLabel>\n            <MenuRadioGroup onValueChange={(v) => switchCurrency(String(v))} value={currency.code}>\n              {currencies.map((c) => (\n                <MenuRadioItem closeOnClick key={c.code} value={c.code}>\n                  <span className=\"flex w-full items-center justify-between gap-4\">\n                    <span>{c.name}</span>\n                    <span className=\"font-mono text-muted-foreground text-xs\">{c.code}</span>\n                  </span>\n                </MenuRadioItem>\n              ))}\n            </MenuRadioGroup>\n            </MenuGroup>\n          </MenuPopup>\n        </Menu>\n      </div>\n      <div className=\"flex min-h-6 items-center justify-between gap-3\">\n        <p\n          aria-live=\"polite\"\n          className={cn(\"truncate text-xs tabular-nums transition-colors\", over || under ? \"text-destructive-foreground\" : \"text-muted-foreground\")}\n          id={`${id}-hint`}\n        >\n          {hint}\n        </p>\n        {hintAction}\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}