{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "money-input",
  "type": "registry:ui",
  "title": "Money input",
  "description": "A currency field with live grouping, stable width, rolling digits, and minor-unit output.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/money-input/money-input.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useReducer,\n  useRef,\n  useState,\n  useSyncExternalStore,\n} from \"react\";\nimport type {\n  ClipboardEvent as ReactClipboardEvent,\n  FocusEvent as ReactFocusEvent,\n  KeyboardEvent as ReactKeyboardEvent,\n} from \"react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport type { Transition, Variants } from \"motion/react\";\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface MoneyInputDetails {\n  currency: string;\n  /** The amount in major units, such as 12.5 for $12.50. */\n  major: number | null;\n  /** The amount as a full currency string, such as \"$12.50\". */\n  formatted: string;\n}\n\nexport interface MoneyInputProps {\n  label: string;\n  hideLabel?: boolean;\n  /** Amount in minor units (cents for USD, yen for JPY). `null` is empty. */\n  value?: number | null;\n  defaultValue?: number | null;\n  onValueChange?: (value: number | null, details: MoneyInputDetails) => void;\n  /** ISO 4217 code, such as \"USD\". */\n  currency?: string;\n  /** Initial currency when uncontrolled. Defaults to the first of `currencies`. */\n  defaultCurrency?: string;\n  onCurrencyChange?: (currency: string) => void;\n  /** Codes offered by the currency menu. Pass one code to hide the menu. */\n  currencies?: string[];\n  /** Lowest amount in minor units. Checked when the field loses focus. */\n  min?: number;\n  /** Highest amount in minor units. Typing past it is refused with a nudge. */\n  max?: number;\n  /** Quick add chips in major units. Pass an empty array to hide them. */\n  quickAdd?: number[];\n  /** Arrow keys move by this many minor units. Defaults to one major unit; Shift moves ten times as far. */\n  step?: number;\n  /** Formatting locale. Fixed by default so server and client render the same digits. */\n  locale?: string;\n  description?: string;\n  /** Replaces the built-in range message. */\n  error?: string;\n  disabled?: boolean;\n  /** Adds a hidden input carrying the amount in minor units. */\n  name?: string;\n  id?: string;\n  className?: string;\n  onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;\n}\n\ntype Source = \"type\" | \"chip\" | \"key\" | \"external\";\ntype Part = { key: string; char: string; ghost: boolean };\ntype CurrencyInfo = {\n  code: string;\n  symbol: string;\n  position: \"prefix\" | \"suffix\";\n  digits: number;\n  name: string;\n  whole: Intl.NumberFormat;\n  full: Intl.NumberFormat;\n};\n\ntype Bezier = [number, number, number, number];\nconst enterEase = [...motionTokens.ease.enter] as Bezier;\nconst standardEase = [...motionTokens.ease.standard] as Bezier;\nconst { spring, blur, duration } = motionTokens;\nconst physical = (visualDuration: number, bounce: number): Transition => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return { type: \"spring\", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };\n};\nconst GLIDE = physical(0.28, 0);\n/** A sideways kick when a limit refuses the change: the amount strains toward the press and springs home. */\nconst KICK = physical(0.42, 0);\nconst MAX_INTEGER_DIGITS = 12;\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nconst infoCache = new Map<string, CurrencyInfo>();\nfunction currencyInfo(code: string, locale: string): CurrencyInfo {\n  const cacheKey = `${locale}:${code}`;\n  const cached = infoCache.get(cacheKey);\n  if (cached) return cached;\n  const full = new Intl.NumberFormat(locale, {\n    style: \"currency\",\n    currency: code,\n    currencyDisplay: \"narrowSymbol\",\n    numberingSystem: \"latn\",\n  });\n  const parts = full.formatToParts(1);\n  const symbolAt = parts.findIndex((part) => part.type === \"currency\"),\n    numberAt = parts.findIndex((part) => part.type === \"integer\");\n  const digits = full.resolvedOptions().maximumFractionDigits ?? 2;\n  let name = code;\n  try {\n    name = new Intl.DisplayNames([locale], { type: \"currency\" }).of(code) ?? code;\n  } catch {\n    /* older engines keep the code */\n  }\n  const info: CurrencyInfo = {\n    code,\n    symbol: parts[symbolAt]?.value ?? code,\n    position: symbolAt < numberAt ? \"prefix\" : \"suffix\",\n    digits,\n    name: name.charAt(0).toUpperCase() + name.slice(1),\n    full,\n    whole: new Intl.NumberFormat(locale, {\n      style: \"currency\",\n      currency: code,\n      currencyDisplay: \"narrowSymbol\",\n      maximumFractionDigits: 0,\n      numberingSystem: \"latn\",\n    }),\n  };\n  infoCache.set(cacheKey, info);\n  return info;\n}\n\nfunction localeSymbols(locale: string) {\n  const parts = new Intl.NumberFormat(locale, { numberingSystem: \"latn\" }).formatToParts(12345.6);\n  return {\n    group: parts.find((part) => part.type === \"group\")?.value ?? \",\",\n    decimal: parts.find((part) => part.type === \"decimal\")?.value ?? \".\",\n  };\n}\n\n/* A draft is the amount as typed in a canonical form: digits with an optional \".\", such as \"1234.5\". */\nconst pow10 = (digits: number) => 10 ** digits;\nfunction toMinor(draft: string, digits: number): number | null {\n  if (!draft) return null;\n  const [whole, fraction = \"\"] = draft.split(\".\");\n  return Number(whole || \"0\") * pow10(digits) + Number(fraction.padEnd(digits, \"0\").slice(0, digits) || \"0\");\n}\nfunction fromMinor(minor: number | null | undefined, digits: number) {\n  if (minor === null || minor === undefined || !Number.isFinite(minor)) return \"\";\n  const text = String(Math.round(Math.abs(minor))).padStart(digits + 1, \"0\");\n  return digits ? `${text.slice(0, text.length - digits)}.${text.slice(text.length - digits)}` : text;\n}\n/** Settles a draft the way it will be read back: fraction padded to the currency, leading zeros gone. */\nfunction settle(draft: string, digits: number) {\n  return draft ? fromMinor(toMinor(draft, digits), digits) : \"\";\n}\n/** Carries an amount across currencies with different minor units, keeping the major amount. */\nfunction convertDraft(draft: string, digits: number) {\n  if (!draft) return \"\";\n  const [whole = \"\", fraction] = draft.split(\".\");\n  if (!digits) return whole || \"0\";\n  return fraction === undefined ? whole : `${whole}.${fraction.slice(0, digits)}`;\n}\n\nfunction groupInteger(whole: string, group: string) {\n  return whole.replace(/\\B(?=(\\d{3})+(?!\\d))/g, group);\n}\nfunction displayOf(draft: string, symbols: { group: string; decimal: string }) {\n  if (!draft) return \"\";\n  const [whole = \"\", fraction] = draft.split(\".\");\n  return groupInteger(whole, symbols.group) + (fraction !== undefined ? symbols.decimal + fraction : \"\");\n}\n\n/** Columns keyed by place value, so 9 → 10 keeps the ones column the ones column. Missing fraction digits are ghosts that hold their width. */\nfunction partsOf(draft: string, digits: number, symbols: { group: string; decimal: string }): Part[] {\n  const [whole = \"\", fraction] = draft ? draft.split(\".\") : [];\n  const parts: Part[] = [];\n  const intDigits = whole || \"0\";\n  const ghostInt = !whole;\n  intDigits.split(\"\").forEach((char, index) => {\n    const place = intDigits.length - 1 - index;\n    parts.push({ key: `i${place}`, char, ghost: ghostInt });\n    if (place > 0 && place % 3 === 0) parts.push({ key: `g${place}`, char: symbols.group, ghost: ghostInt });\n  });\n  if (digits) {\n    parts.push({ key: \"d\", char: symbols.decimal, ghost: fraction === undefined });\n    for (let index = 0; index < digits; index++)\n      parts.push({\n        key: `f${index}`,\n        char: fraction?.[index] ?? \"0\",\n        ghost: fraction === undefined || index >= fraction.length,\n      });\n  }\n  return parts;\n}\n\n/* Columns open their width while the digit rises in the direction of change. Typing cuts straight to the result so the caret is never behind. */\nconst column: Variants = {\n  enter: { width: 0, opacity: 0 },\n  center: { width: \"auto\", opacity: 1, transition: { width: spring.morph, opacity: { duration: duration.fast } } },\n  exit: { width: 0, opacity: 0, transition: { width: spring.smooth, opacity: { duration: duration.instant } } },\n};\nconst columnCut: Variants = {\n  enter: { width: \"auto\", opacity: 1 },\n  center: { width: \"auto\", opacity: 1, transition: { duration: 0 } },\n  exit: { width: 0, opacity: 0, transition: { duration: 0 } },\n};\nconst glyph: Variants = {\n  enter: (dir: number) => ({ y: `${dir * 0.55}em`, opacity: 0, filter: `blur(${blur.soft}px)` }),\n  center: {\n    y: 0,\n    opacity: 1,\n    filter: \"blur(0px)\",\n    transitionEnd: { filter: \"none\" },\n    transition: {\n      y: spring.snappy,\n      opacity: { duration: duration.fast, ease: enterEase },\n      filter: { duration: duration.exit, ease: enterEase },\n    },\n  },\n  exit: (dir: number) => ({\n    y: `${dir * -0.55}em`,\n    opacity: 0,\n    filter: `blur(${blur.subtle}px)`,\n    transition: { duration: duration.instant, ease: standardEase },\n  }),\n};\nconst glyphCut: Variants = {\n  enter: { y: 0, opacity: 1, filter: \"none\" },\n  center: { y: 0, opacity: 1, filter: \"none\", transition: { duration: 0 } },\n  exit: { opacity: 0, transition: { duration: 0 } },\n};\nconst glyphFade: Variants = {\n  enter: { y: 0, opacity: 0, filter: \"none\" },\n  center: { y: 0, opacity: 1, filter: \"none\", transition: { duration: duration.instant } },\n  exit: { opacity: 0, transition: { duration: 0 } },\n};\n\nfunction Glyphs({\n  parts,\n  direction,\n  instant,\n  reduced,\n}: {\n  parts: Part[];\n  direction: number;\n  instant: boolean;\n  reduced: boolean;\n}) {\n  const columnVariants = instant || reduced ? columnCut : column;\n  const glyphVariants = instant ? glyphCut : reduced ? glyphFade : glyph;\n  return (\n    <AnimatePresence initial={false} custom={direction}>\n      {parts.map((part) => (\n        <motion.span\n          key={part.key}\n          className=\"relative inline-block overflow-x-visible overflow-y-clip [--feather:.18em] -my-(--feather) py-(--feather) [mask-image:linear-gradient(to_bottom,transparent,#000_calc(var(--feather)_*_1.4),#000_calc(100%_-_var(--feather)_*_1.4),transparent)] transition-[color] duration-standard ease-standard data-ghost:text-[color-mix(in_oklab,var(--ink-3)_55%,transparent)] motion-reduce:duration-0\"\n          data-ghost={part.ghost || undefined}\n          custom={direction}\n          variants={columnVariants}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n        >\n          <span className=\"invisible whitespace-pre\">{part.char}</span>\n          <AnimatePresence initial={false} custom={direction}>\n            <motion.span\n              key={part.char}\n              className=\"absolute inset-x-0 inset-y-(--feather) text-center whitespace-pre\"\n              custom={direction}\n              variants={glyphVariants}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n            >\n              {part.char}\n            </motion.span>\n          </AnimatePresence>\n        </motion.span>\n      ))}\n    </AnimatePresence>\n  );\n}\n\n/** The currency symbol swaps with a short roll while its slot springs to the new width. */\nfunction SymbolSlot({ symbol, reduced }: { symbol: string; reduced: boolean }) {\n  const sizerRef = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  const measured = useRef(false);\n  useLayoutEffect(() => {\n    const node = sizerRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      if (!measured.current || reduced) width.jump(node.offsetWidth);\n      else animate(width, node.offsetWidth, spring.morph);\n      measured.current = true;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  return (\n    <motion.span\n      className=\"relative mr-[.06em] inline-block whitespace-pre text-ink-2 last:mr-0 last:ml-[.18em]\"\n      style={{ width }}\n      aria-hidden=\"true\"\n    >\n      <span ref={sizerRef} className=\"invisible absolute top-0 left-0 whitespace-pre\">\n        {symbol}\n      </span>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={symbol}\n          className=\"inline-block whitespace-pre\"\n          initial={reduced ? { opacity: 0 } : { opacity: 0, y: \".4em\", filter: `blur(${blur.soft}px)` }}\n          animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n          exit={{ opacity: 0, y: reduced ? 0 : \"-.3em\", transition: { duration: duration.instant } }}\n          transition={\n            reduced\n              ? { duration: duration.instant }\n              : { y: spring.snappy, opacity: { duration: duration.fast }, filter: { duration: duration.exit } }\n          }\n        >\n          {symbol}\n        </motion.span>\n      </AnimatePresence>\n    </motion.span>\n  );\n}\n\nconst isSignificant = (char: string, decimal: string) => /\\d/.test(char) || char === decimal;\nfunction positionAfter(text: string, count: number, decimal: string) {\n  if (count <= 0) return 0;\n  let seen = 0;\n  for (let index = 0; index < text.length; index++)\n    if (isSignificant(text[index]!, decimal) && ++seen === count) return index + 1;\n  return text.length;\n}\n\n/**\n * A currency amount field. Digits group as they are typed without moving the caret, the missing cents wait as faint ghosts\n * so the amount never changes width when it settles, long amounts scale down to fit instead of scrolling, and quick add chips\n * and arrow keys roll the digits. The value comes out in minor units, so $12.50 is 1250.\n */\nexport const MoneyInput = forwardRef<HTMLInputElement, MoneyInputProps>(function MoneyInput(\n  {\n    label,\n    hideLabel = false,\n    value,\n    defaultValue = null,\n    onValueChange,\n    currency: currencyProp,\n    defaultCurrency,\n    onCurrencyChange,\n    currencies = [\"USD\", \"EUR\", \"GBP\", \"JPY\", \"CAD\", \"AUD\", \"CHF\", \"INR\"],\n    min,\n    max,\n    quickAdd = [10, 50, 100],\n    step: stepProp,\n    locale = \"en-US\",\n    description,\n    error,\n    disabled = false,\n    name,\n    id,\n    className,\n    onBlur,\n  },\n  forwardedRef,\n) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const inputId = id ?? `${uid}-amount`;\n  const hintId = `${inputId}-hint`,\n    errorId = `${inputId}-error`,\n    rangeId = `${inputId}-range`;\n\n  const [innerCurrency, setInnerCurrency] = useState(() => defaultCurrency ?? currencies[0] ?? \"USD\");\n  const code = currencyProp ?? innerCurrency;\n  const info = useMemo(() => currencyInfo(code, locale), [code, locale]);\n  const symbols = useMemo(() => localeSymbols(locale), [locale]);\n  const step = stepProp && stepProp > 0 ? stepProp : pow10(info.digits);\n\n  const [draft, setDraft] = useState(() => fromMinor(value !== undefined ? value : defaultValue, info.digits));\n  const minor = toMinor(draft, info.digits);\n\n  const [seenValue, setSeenValue] = useState(value);\n  const [source, setSource] = useState<Source>(\"external\");\n  const [direction, setDirection] = useState(1);\n  if (value !== seenValue) {\n    setSeenValue(value);\n    if (value !== undefined && value !== minor) {\n      setDraft(fromMinor(value, info.digits));\n      setSource(\"external\");\n      setDirection((value ?? 0) >= (minor ?? 0) ? 1 : -1);\n    }\n  }\n\n  const [focused, setFocused] = useState(false);\n  const [touched, setTouched] = useState(false);\n  const [limitFlash, setLimitFlash] = useState(0);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const [, rerender] = useReducer((count: number) => count + 1, 0);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement);\n  const pendingCaret = useRef<number | null>(null);\n  const latest = useRef({ minor, draft });\n  useLayoutEffect(() => {\n    latest.current = { minor, draft };\n  });\n\n  const money = useCallback(\n    (amount: number | null) => (amount === null ? \"\" : info.full.format(amount / pow10(info.digits))),\n    [info],\n  );\n  const display = displayOf(draft, symbols);\n  const parts = useMemo(() => partsOf(draft, info.digits, symbols), [draft, info.digits, symbols]);\n\n  /* ---------------------------------------------- Limits and feedback ---------------------------------------------- */\n\n  const bumpX = useMotionValue(0);\n  const flashTimer = useRef<number | undefined>(undefined);\n  useEffect(() => () => window.clearTimeout(flashTimer.current), []);\n  function refuse(toward: 1 | -1, limit: number) {\n    if (!reduced) animate(bumpX, 0, { ...KICK, velocity: toward * 160 });\n    setLimitFlash((count) => count + 1);\n    window.clearTimeout(flashTimer.current);\n    flashTimer.current = window.setTimeout(() => setLimitFlash(0), 1400);\n    setAnnouncement(`${toward > 0 ? \"Maximum\" : \"Minimum\"} is ${money(limit)}`);\n  }\n\n  function emit(nextMinor: number | null, nextInfo = info) {\n    onValueChange?.(nextMinor, {\n      currency: nextInfo.code,\n      major: nextMinor === null ? null : nextMinor / pow10(nextInfo.digits),\n      formatted: nextMinor === null ? \"\" : nextInfo.full.format(nextMinor / pow10(nextInfo.digits)),\n    });\n  }\n\n  function setAmount(nextDraft: string, nextSource: Source) {\n    const nextMinor = toMinor(nextDraft, info.digits);\n    setDirection((nextMinor ?? 0) >= (latest.current.minor ?? 0) ? 1 : -1);\n    setSource(nextSource);\n    setDraft(nextDraft);\n    latest.current = { minor: nextMinor, draft: nextDraft };\n    if (nextMinor !== minor) emit(nextMinor);\n    if (nextSource !== \"type\") setAnnouncement(money(nextMinor));\n  }\n\n  /* ---------------------------------------------- Typing ---------------------------------------------- */\n\n  /** Reads what the input holds into a draft and counts the digits and decimal before the caret, so the caret can return to the same spot. */\n  function sanitize(raw: string, caret: number) {\n    let out = \"\",\n      before = 0,\n      dot = false;\n    for (let index = 0; index < raw.length; index++) {\n      const char = raw[index]!;\n      let kept = \"\";\n      if (/\\d/.test(char)) kept = char;\n      else if ((char === symbols.decimal || (char === \".\" && symbols.group !== \".\")) && info.digits > 0 && !dot) {\n        kept = \".\";\n        dot = true;\n      }\n      if (!kept) continue;\n      out += kept;\n      if (index < caret) before++;\n    }\n    const pieces = out.split(\".\");\n    let whole = pieces[0]!;\n    const fraction = pieces[1];\n    const stripped = whole.length - whole.replace(/^0+/, \"\").length;\n    whole = whole.replace(/^0+/, \"\");\n    let shift = -Math.min(stripped, before);\n    if (fraction !== undefined && !whole) {\n      whole = \"0\";\n      shift += 1;\n    }\n    if (fraction === undefined && !whole && stripped) {\n      whole = \"0\";\n      shift += 1;\n    }\n    if (fraction !== undefined && fraction.length > info.digits) return null;\n    if (whole.length > MAX_INTEGER_DIGITS) return null;\n    return { draft: fraction !== undefined ? `${whole}.${fraction}` : whole, before: Math.max(0, before + shift) };\n  }\n\n  function applyRaw(raw: string, caret: number) {\n    const result = sanitize(raw, caret);\n    // A refused keystroke keeps the caret where it was: one significant character before where the browser left it.\n    if (!result) {\n      pendingCaret.current = Math.max(\n        0,\n        raw\n          .slice(0, caret)\n          .split(\"\")\n          .filter((char) => isSignificant(char, symbols.decimal) || char === \".\").length - 1,\n      );\n      rerender();\n      return;\n    }\n    const nextMinor = toMinor(result.draft, info.digits);\n    if (max !== undefined && nextMinor !== null && nextMinor > max) {\n      pendingCaret.current = Math.max(0, result.before - 1);\n      refuse(1, max);\n      rerender();\n      return;\n    }\n    pendingCaret.current = result.before;\n    setAmount(result.draft, \"type\");\n  }\n\n  useLayoutEffect(() => {\n    const input = inputRef.current,\n      count = pendingCaret.current;\n    pendingCaret.current = null;\n    if (!input || count === null || document.activeElement !== input) return;\n    const position = positionAfter(input.value, count, symbols.decimal);\n    input.setSelectionRange(position, position);\n  });\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLInputElement>) {\n    const input = event.currentTarget;\n    const moves: Record<string, number> = { ArrowUp: 1, ArrowDown: -1, PageUp: 10, PageDown: -10 };\n    if (event.key in moves) {\n      event.preventDefault();\n      const amount = moves[event.key]! * step * (event.shiftKey && Math.abs(moves[event.key]!) === 1 ? 10 : 1);\n      nudge(amount, \"key\");\n      return;\n    }\n    if (event.key === \"Enter\") {\n      setDraft(settle(draft, info.digits));\n      pendingCaret.current = Number.MAX_SAFE_INTEGER;\n      return;\n    }\n    const start = input.selectionStart ?? 0,\n      end = input.selectionEnd ?? 0;\n    if (start !== end || event.metaKey || event.ctrlKey || event.altKey) return;\n    // Deleting a group separator deletes the digit before it instead of doing nothing.\n    if (event.key === \"Backspace\" && start > 1 && input.value[start - 1] === symbols.group) {\n      event.preventDefault();\n      applyRaw(input.value.slice(0, start - 2) + input.value.slice(start - 1), start - 2);\n    }\n    if (event.key === \"Delete\" && input.value[start] === symbols.group) {\n      event.preventDefault();\n      applyRaw(input.value.slice(0, start + 1) + input.value.slice(start + 2), start);\n    }\n  }\n\n  function onPaste(event: ReactClipboardEvent<HTMLInputElement>) {\n    const text = event.clipboardData.getData(\"text\").trim();\n    if (!text) return;\n    event.preventDefault();\n    // \"$1,234.56\", \"1.234,56 €\", \"1234.5\": a final separator followed by one or two digits is the decimal; the rest are groups.\n    const match = /[.,](\\d{1,2})\\s*\\D*$/.exec(text);\n    const whole = (match ? text.slice(0, match.index) : text).replace(/\\D/g, \"\");\n    const pasted = match && info.digits ? `${whole}.${match[1]}` : whole;\n    if (!/\\d/.test(pasted)) return;\n    const input = event.currentTarget;\n    const start = input.selectionStart ?? 0,\n      end = input.selectionEnd ?? 0;\n    const inserted = pasted.replace(\".\", symbols.decimal);\n    const replaceAll = (start === 0 && end === input.value.length) || !input.value;\n    const raw = replaceAll ? inserted : input.value.slice(0, start) + inserted + input.value.slice(end);\n    applyRaw(raw, replaceAll ? raw.length : start + inserted.length);\n  }\n\n  /* ---------------------------------------------- Steps and chips ---------------------------------------------- */\n\n  function nudge(amount: number, nextSource: Source) {\n    const from = latest.current.minor ?? 0;\n    const floor = min ?? 0,\n      ceiling = max ?? Number.MAX_SAFE_INTEGER;\n    let next = from + amount;\n    if (next > ceiling) {\n      if (from >= ceiling) {\n        refuse(1, ceiling);\n        return;\n      }\n      next = ceiling;\n      refuse(1, ceiling);\n    }\n    if (next < floor) {\n      if (from <= floor && latest.current.minor !== null) {\n        refuse(-1, floor);\n        return;\n      }\n      next = floor;\n    }\n    pendingCaret.current = Number.MAX_SAFE_INTEGER;\n    setAmount(fromMinor(next, info.digits), nextSource);\n  }\n\n  /* ---------------------------------------------- Fit to width ---------------------------------------------- */\n\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const groupRef = useRef<HTMLDivElement>(null);\n  const scale = useMotionValue(1);\n  useLayoutEffect(() => {\n    const wrap = wrapRef.current,\n      group = groupRef.current;\n    if (!wrap || !group || typeof ResizeObserver === \"undefined\") return;\n    let first = true;\n    const fit = () => {\n      const natural = group.offsetWidth,\n        room = wrap.clientWidth;\n      const next = natural > 0 && room > 0 ? Math.min(1, room / natural) : 1;\n      if (first || reduced) scale.jump(next);\n      else animate(scale, next, spring.smooth);\n      first = false;\n    };\n    const observer = new ResizeObserver(fit);\n    observer.observe(wrap);\n    observer.observe(group);\n    return () => observer.disconnect();\n  }, [reduced, scale]);\n\n  /* ---------------------------------------------- Currency menu ---------------------------------------------- */\n\n  const [menuOpen, setMenuOpen] = useState(false);\n  const [activeCode, setActiveCode] = useState(code);\n  const menuRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const menuButtonRef = useRef<HTMLButtonElement>(null);\n  const optionRefs = useRef(new Map<string, HTMLElement>());\n  const options = useMemo(() => currencies.map((entry) => currencyInfo(entry, locale)), [currencies, locale]);\n  const canSwitch = options.length > 1;\n\n  const openMenu = () => {\n    if (disabled) return;\n    setActiveCode(code);\n    setMenuOpen(true);\n  };\n  const closeMenu = useCallback((focusButton: boolean) => {\n    setMenuOpen(false);\n    if (focusButton) menuButtonRef.current?.focus({ preventScroll: true });\n  }, []);\n  useLayoutEffect(() => {\n    if (menuOpen) listRef.current?.focus({ preventScroll: true });\n  }, [menuOpen]);\n  useEffect(() => {\n    if (!menuOpen) return;\n    const down = (event: PointerEvent) => {\n      if (!menuRef.current?.contains(event.target as Node)) closeMenu(false);\n    };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [closeMenu, menuOpen]);\n\n  const hy = useMotionValue(0),\n    hh = useMotionValue(0),\n    ho = useMotionValue(0);\n  useLayoutEffect(() => {\n    const node = menuOpen ? optionRefs.current.get(activeCode) : undefined;\n    if (!node) {\n      ho.jump(0);\n      return;\n    }\n    if (ho.get() < 0.05 || reduced) {\n      hy.jump(node.offsetTop);\n      hh.jump(node.offsetHeight);\n    } else {\n      animate(hy, node.offsetTop, GLIDE);\n      animate(hh, node.offsetHeight, GLIDE);\n    }\n    ho.jump(1);\n    node.scrollIntoView?.({ block: \"nearest\" });\n  }, [activeCode, hh, ho, hy, menuOpen, reduced]);\n\n  function chooseCurrency(next: CurrencyInfo) {\n    closeMenu(true);\n    if (next.code === code) return;\n    const nextDraft = focused\n      ? convertDraft(draft, next.digits)\n      : settle(convertDraft(draft, next.digits), next.digits);\n    const nextMinor = toMinor(nextDraft, next.digits);\n    if (currencyProp === undefined) setInnerCurrency(next.code);\n    onCurrencyChange?.(next.code);\n    setSource(\"external\");\n    setDraft(nextDraft);\n    latest.current = { minor: nextMinor, draft: nextDraft };\n    emit(nextMinor, next);\n    setAnnouncement(`${next.name}${nextMinor === null ? \"\" : `, ${next.full.format(nextMinor / pow10(next.digits))}`}`);\n  }\n\n  const typeahead = useRef({ buffer: \"\", at: 0 });\n  function onMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    const at = options.findIndex((option) => option.code === activeCode);\n    const go = (index: number) => {\n      event.preventDefault();\n      setActiveCode(options[Math.max(0, Math.min(options.length - 1, index))]!.code);\n    };\n    switch (event.key) {\n      case \"ArrowDown\":\n        go(at + 1);\n        return;\n      case \"ArrowUp\":\n        go(at - 1);\n        return;\n      case \"Home\":\n        go(0);\n        return;\n      case \"End\":\n        go(options.length - 1);\n        return;\n      case \"Enter\":\n      case \" \":\n        event.preventDefault();\n        if (options[at]) chooseCurrency(options[at]);\n        return;\n      case \"Escape\":\n        event.preventDefault();\n        event.stopPropagation();\n        closeMenu(true);\n        return;\n      case \"Tab\":\n        closeMenu(false);\n        return;\n    }\n    if (event.key.length === 1 && /\\w/.test(event.key)) {\n      const now = performance.now(),\n        state = typeahead.current;\n      state.buffer = now - state.at > 600 ? event.key.toLowerCase() : state.buffer + event.key.toLowerCase();\n      state.at = now;\n      const hit = options.find(\n        (option) =>\n          option.code.toLowerCase().startsWith(state.buffer) || option.name.toLowerCase().startsWith(state.buffer),\n      );\n      if (hit) {\n        event.preventDefault();\n        setActiveCode(hit.code);\n      }\n    }\n  }\n\n  /* ---------------------------------------------- Messages ---------------------------------------------- */\n\n  const range =\n    min !== undefined && max !== undefined\n      ? `${money(min)} to ${money(max)}`\n      : max !== undefined\n        ? `Up to ${money(max)}`\n        : min !== undefined\n          ? `At least ${money(min)}`\n          : \"\";\n  const outOfRange = minor !== null && ((min !== undefined && minor < min) || (max !== undefined && minor > max));\n  const belowMin = minor !== null && min !== undefined && minor < min;\n  const builtIn =\n    touched && !focused && outOfRange\n      ? belowMin\n        ? `The minimum is ${money(min ?? 0)}`\n        : `The maximum is ${money(max ?? 0)}`\n      : undefined;\n  const message = error ?? builtIn;\n  const invalid = !!message;\n  const describedBy =\n    [range ? rangeId : null, description ? hintId : null, message ? errorId : null].filter(Boolean).join(\" \") ||\n    undefined;\n  const instant = source === \"type\";\n  const chips = quickAdd.filter((amount) => amount > 0);\n\n  // No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead.\n  return (\n    <div\n      className={cn(\"group/field grid min-w-0 font-body tracking-body\", className)}\n      data-disabled={disabled || undefined}\n    >\n      <label\n        htmlFor={inputId}\n        className={hideLabel ? \"sr-only\" : \"mb-2 justify-self-start text-sm leading-body font-medium text-ink\"}\n      >\n        {label}\n      </label>\n\n      {/* One tall shell: the amount on the left, the currency on the right. Set --money-input-height to resize it. */}\n      <div\n        className=\"relative box-border flex h-(--money-input-height,76px) min-w-0 items-center gap-2 rounded-panel border border-line-strong bg-surface pr-2 pl-[18px] transition-[border-color] duration-fast ease-standard hover:not-data-invalid:border-ink data-focused:not-data-invalid:border-ink data-invalid:border-red group-data-disabled/field:bg-hover group-data-disabled/field:opacity-50 motion-reduce:duration-0 [@media(max-width:420px)]:pl-3.5\"\n        data-invalid={invalid || undefined}\n        data-focused={focused || undefined}\n      >\n        {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- a pointer shortcut: clicking beside the digits focuses the input, which is itself in the tab order */}\n        <div\n          ref={wrapRef}\n          className=\"relative flex h-full min-w-0 flex-1 cursor-text items-center overflow-hidden group-data-disabled/field:cursor-not-allowed\"\n          onMouseDown={(event) => {\n            if (event.target !== inputRef.current) event.preventDefault();\n          }}\n          onClick={(event) => {\n            if (!disabled && event.target !== inputRef.current) {\n              const input = inputRef.current;\n              input?.focus();\n              input?.setSelectionRange(input.value.length, input.value.length);\n            }\n          }}\n        >\n          <motion.div\n            ref={groupRef}\n            className=\"inline-flex flex-none origin-[0_50%] items-baseline font-display text-3xl/[1.2] font-medium tracking-display whitespace-nowrap text-ink tabular-nums [font-kerning:none] [@media(max-width:420px)]:text-2xl/[1.2]\"\n            style={{ scale, x: bumpX }}\n          >\n            {info.position === \"prefix\" && <SymbolSlot symbol={info.symbol} reduced={reduced} />}\n            <span className=\"relative inline-flex\">\n              <span className=\"inline-flex\" aria-hidden=\"true\" data-empty={!draft || undefined}>\n                <Glyphs parts={parts} direction={direction} instant={instant} reduced={reduced} />\n              </span>\n              <input\n                ref={inputRef}\n                id={inputId}\n                className=\"absolute top-0 left-0 z-1 m-0 h-full w-[calc(100%+.5em)] border-0 bg-transparent p-0 leading-[inherit] text-transparent caret-ink outline-none [font-kerning:none] [font-variant-numeric:inherit] selection:bg-[color-mix(in_oklab,var(--ink)_16%,transparent)] selection:text-transparent disabled:cursor-not-allowed\"\n                type=\"text\"\n                role=\"spinbutton\"\n                inputMode={info.digits ? \"decimal\" : \"numeric\"}\n                autoComplete=\"off\"\n                spellCheck={false}\n                value={display}\n                disabled={disabled}\n                aria-invalid={invalid || undefined}\n                aria-describedby={describedBy}\n                aria-label={hideLabel ? label : undefined}\n                aria-valuenow={minor === null ? undefined : minor / pow10(info.digits)}\n                aria-valuetext={minor === null ? \"Empty\" : money(minor)}\n                aria-valuemin={min === undefined ? undefined : min / pow10(info.digits)}\n                aria-valuemax={max === undefined ? undefined : max / pow10(info.digits)}\n                onChange={(event) =>\n                  applyRaw(\n                    event.currentTarget.value,\n                    event.currentTarget.selectionStart ?? event.currentTarget.value.length,\n                  )\n                }\n                onKeyDown={onKeyDown}\n                onPaste={onPaste}\n                onFocus={() => setFocused(true)}\n                onBlur={(event) => {\n                  setFocused(false);\n                  setTouched(true);\n                  if (draft) setDraft(settle(draft, info.digits));\n                  onBlur?.(event);\n                }}\n              />\n            </span>\n            {info.position === \"suffix\" && <SymbolSlot symbol={info.symbol} reduced={reduced} />}\n          </motion.div>\n        </div>\n\n        {canSwitch ? (\n          <div ref={menuRef} className=\"relative flex-none\">\n            <button\n              ref={menuButtonRef}\n              type=\"button\"\n              className=\"inline-flex h-9 cursor-pointer items-center gap-1 rounded-button border-0 bg-transparent pr-2 pl-3 text-sm/[inherit] font-medium text-ink [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-fast ease-standard hover:enabled:bg-hover active:enabled:bg-hover data-open:bg-hover disabled:cursor-not-allowed\"\n              disabled={disabled}\n              aria-haspopup=\"listbox\"\n              aria-expanded={menuOpen}\n              aria-controls={`${uid}-currencies`}\n              aria-label={`Currency, ${info.name}`}\n              data-open={menuOpen || undefined}\n              onClick={() => (menuOpen ? closeMenu(true) : openMenu())}\n              onKeyDown={(event) => {\n                if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n                  event.preventDefault();\n                  openMenu();\n                }\n              }}\n            >\n              <span className=\"tabular-nums\">{code}</span>\n              <motion.span\n                className=\"grid size-4 place-items-center text-ink-3\"\n                aria-hidden=\"true\"\n                initial={false}\n                animate={{ rotate: menuOpen ? 180 : 0 }}\n                transition={reduced ? { duration: 0 } : GLIDE}\n              >\n                <ChevronDown size={16} strokeWidth={1.75} />\n              </motion.span>\n            </button>\n            <AnimatePresence>\n              {/* The menu grows out of the currency button's corner and opens 8px below the shell, whatever its height. */}\n              {menuOpen && (\n                <motion.div\n                  key=\"menu\"\n                  className=\"absolute top-[calc(100%_+_var(--drop))] -right-2 z-30 box-border w-[min(256px,calc(100vw_-_2rem))] origin-[calc(100%_-_44px)_calc(var(--drop)_*_-1_-_18px)] rounded-panel border border-line bg-surface p-1.5 shadow-floating [--drop:calc((var(--money-input-height,76px)_-_36px)_/_2_+_var(--space-2))]\"\n                  initial={\n                    reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: -4, filter: `blur(${blur.subtle}px)` }\n                  }\n                  animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={\n                    reduced\n                      ? { opacity: 0, transition: { duration: duration.instant } }\n                      : {\n                          opacity: 0,\n                          scale: 0.97,\n                          y: -2,\n                          filter: `blur(${blur.subtle}px)`,\n                          transition: { duration: duration.instant, ease: standardEase },\n                        }\n                  }\n                  transition={\n                    reduced\n                      ? { duration: duration.instant }\n                      : {\n                          ...spring.snappy,\n                          opacity: { duration: duration.fast, ease: enterEase },\n                          filter: { duration: duration.fast, ease: enterEase },\n                        }\n                  }\n                >\n                  <div\n                    ref={listRef}\n                    id={`${uid}-currencies`}\n                    className=\"relative grid max-h-[288px] gap-0.5 overflow-y-auto overscroll-contain outline-none [scrollbar-width:thin]\"\n                    role=\"listbox\"\n                    tabIndex={-1}\n                    aria-label=\"Currency\"\n                    aria-activedescendant={`${uid}-cur-${activeCode}`}\n                    onKeyDown={onMenuKeyDown}\n                  >\n                    <motion.span\n                      className=\"pointer-events-none absolute top-0 right-0 left-0 rounded-[14px] bg-[color-mix(in_oklab,var(--ink)_6.5%,transparent)]\"\n                      style={{ y: hy, height: hh, opacity: ho }}\n                      aria-hidden=\"true\"\n                    />\n                    {options.map((option) => (\n                      // oxlint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events -- options are reached through aria-activedescendant; the listbox handles the keys, and <option> only works inside <select>\n                      <div\n                        key={option.code}\n                        id={`${uid}-cur-${option.code}`}\n                        role=\"option\"\n                        aria-selected={option.code === code}\n                        className=\"relative box-border flex min-h-9 cursor-pointer items-center gap-2.5 rounded-[14px] px-2.5 text-sm/[inherit] text-ink-2 transition-[color] duration-fast ease-standard select-none aria-selected:text-ink data-active:text-ink\"\n                        data-active={option.code === activeCode || undefined}\n                        ref={(node) => {\n                          if (node) optionRefs.current.set(option.code, node);\n                          else optionRefs.current.delete(option.code);\n                        }}\n                        onPointerMove={(event) => {\n                          if (event.pointerType === \"mouse\" && option.code !== activeCode) setActiveCode(option.code);\n                        }}\n                        onPointerDown={(event) => event.preventDefault()}\n                        onClick={() => chooseCurrency(option)}\n                      >\n                        <span className=\"w-[22px] flex-none text-center text-ink-3\" aria-hidden=\"true\">\n                          {option.symbol}\n                        </span>\n                        <span className=\"flex-none font-medium tabular-nums\">{option.code}</span>\n                        <span className=\"min-w-0 flex-1 truncate text-xs/[inherit] text-ink-3\">{option.name}</span>\n                        <span\n                          className=\"grid size-4 flex-none place-items-center text-ink opacity-0 [transform:scale(.6)] [transition:opacity_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] data-on:opacity-100 data-on:[transform:none] motion-reduce:duration-0\"\n                          data-on={option.code === code || undefined}\n                          aria-hidden=\"true\"\n                        >\n                          <Check size={16} strokeWidth={1.75} />\n                        </span>\n                      </div>\n                    ))}\n                  </div>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          </div>\n        ) : (\n          <span className=\"flex-none px-2.5 text-sm/[inherit] font-medium text-ink-2\">{code}</span>\n        )}\n      </div>\n\n      {/* Quick add chips and the allowed range share one row under the shell. */}\n      {(chips.length > 0 || range) && (\n        <div className=\"flex flex-wrap items-center justify-between gap-x-3 gap-y-2 pt-3\">\n          {chips.length > 0 && (\n            <div className=\"flex flex-wrap gap-1.5\" role=\"group\" aria-label=\"Quick add\">\n              {chips.map((amount) => {\n                const add = amount * pow10(info.digits);\n                const atMax = max !== undefined && (minor ?? 0) >= max;\n                return (\n                  <button\n                    key={amount}\n                    type=\"button\"\n                    className=\"h-[30px] cursor-pointer touch-manipulation rounded-button border border-line bg-surface px-3 text-xs/[inherit] font-medium text-ink-2 tabular-nums [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),border-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-spring)_var(--ease-spring)] hover:enabled:bg-hover hover:enabled:text-ink active:enabled:[transform:scale(.96)] active:enabled:[transition-duration:var(--duration-fast),var(--duration-fast),var(--duration-fast),100ms] disabled:cursor-not-allowed disabled:opacity-45 aria-disabled:opacity-45 motion-reduce:duration-0 motion-reduce:active:enabled:[transform:none]\"\n                    disabled={disabled}\n                    aria-disabled={atMax || undefined}\n                    aria-controls={inputId}\n                    aria-label={`Add ${info.whole.format(amount)}`}\n                    onClick={() => nudge(add, \"chip\")}\n                  >\n                    +{info.whole.format(amount)}\n                  </button>\n                );\n              })}\n            </div>\n          )}\n          {range && (\n            <span\n              id={rangeId}\n              className=\"text-xs/[inherit] text-ink-3 tabular-nums transition-[color] duration-standard ease-standard data-flash:text-orange data-flash:duration-instant motion-reduce:duration-0\"\n              data-flash={limitFlash > 0 || undefined}\n            >\n              {range}\n            </span>\n          )}\n        </div>\n      )}\n\n      <AnimatePresence initial={false}>\n        {(message || description) && (\n          <motion.span\n            key=\"message\"\n            className=\"relative block overflow-hidden\"\n            initial={reduced ? false : { height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{\n              height: 0,\n              opacity: 0,\n              transition: reduced\n                ? { duration: 0 }\n                : { height: spring.smooth, opacity: { duration: duration.instant } },\n            }}\n            transition={reduced ? { duration: 0 } : { height: spring.smooth, opacity: { duration: duration.fast } }}\n          >\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={message ?? description}\n                id={message ? errorId : hintId}\n                className={cn(\"block pt-2 text-xs leading-body\", message ? \"text-red\" : \"text-ink-3\")}\n                role={message ? \"alert\" : undefined}\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${blur.soft}px)` }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, transition: { duration: duration.instant } }}\n                transition={{ duration: reduced ? duration.instant : duration.standard, ease: enterEase }}\n              >\n                {message ?? description}\n              </motion.span>\n            </AnimatePresence>\n          </motion.span>\n        )}\n      </AnimatePresence>\n\n      {name && <input type=\"hidden\" name={name} value={minor ?? \"\"} />}\n      <output className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </output>\n    </div>\n  );\n});\n\nMoneyInput.displayName = \"MoneyInput\";\n\nexport default MoneyInput;\n",
      "type": "registry:component",
      "target": "@components/iris/money-input/money-input.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/money-input",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "MoneyInput",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/money-input"
  }
}
