{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/money-input/money-input.tsx","type":"registry:component","target":"@components/arc/money-input/money-input.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { ClipboardEvent as ReactClipboardEvent, FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent } 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 styles from \"./money-input.module.css\";\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  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 = { code: string; symbol: string; position: \"prefix\" | \"suffix\"; digits: number; name: string; whole: Intl.NumberFormat; full: Intl.NumberFormat };\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(.28, 0);\n/** A sideways kick when a limit refuses the change: the amount strains toward the press and springs home. */\nconst KICK = physical(.42, 0);\nconst MAX_INTEGER_DIGITS = 12;\n\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\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, { style: \"currency\", currency: code, currencyDisplay: \"narrowSymbol\", numberingSystem: \"latn\" });\n  const parts = full.formatToParts(1);\n  const symbolAt = parts.findIndex(part => part.type === \"currency\"), numberAt = parts.findIndex(part => part.type === \"integer\");\n  const digits = full.resolvedOptions().maximumFractionDigits ?? 2;\n  let name = code;\n  try { name = new Intl.DisplayNames([locale], { type: \"currency\" }).of(code) ?? code; } catch { /* older engines keep the code */ }\n  const info: CurrencyInfo = {\n    code, symbol: parts[symbolAt]?.value ?? code, position: symbolAt < numberAt ? \"prefix\" : \"suffix\", digits, name: name.charAt(0).toUpperCase() + name.slice(1), full,\n    whole: new Intl.NumberFormat(locale, { style: \"currency\", currency: code, currencyDisplay: \"narrowSymbol\", maximumFractionDigits: 0, numberingSystem: \"latn\" }),\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 { group: parts.find(part => part.type === \"group\")?.value ?? \",\", decimal: parts.find(part => part.type === \"decimal\")?.value ?? \".\" };\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) { return draft ? fromMinor(toMinor(draft, digits), digits) : \"\"; }\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) { return whole.replace(/\\B(?=(\\d{3})+(?!\\d))/g, group); }\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].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++) parts.push({ key: `f${index}`, char: fraction?.[index] ?? \"0\", ghost: fraction === undefined || index >= fraction.length });\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 = { enter: { width: \"auto\", opacity: 1 }, center: { width: \"auto\", opacity: 1, transition: { duration: 0 } }, exit: { width: 0, opacity: 0, transition: { duration: 0 } } };\nconst glyph: Variants = {\n  enter: (dir: number) => ({ y: `${dir * .55}em`, opacity: 0, filter: `blur(${blur.soft}px)` }),\n  center: { y: 0, opacity: 1, filter: \"blur(0px)\", transitionEnd: { filter: \"none\" }, transition: { y: spring.snappy, opacity: { duration: duration.fast, ease: enterEase }, filter: { duration: duration.exit, ease: enterEase } } },\n  exit: (dir: number) => ({ y: `${dir * -.55}em`, opacity: 0, filter: `blur(${blur.subtle}px)`, transition: { duration: duration.instant, ease: standardEase } }),\n};\nconst glyphCut: Variants = { enter: { y: 0, opacity: 1, filter: \"none\" }, center: { y: 0, opacity: 1, filter: \"none\", transition: { duration: 0 } }, exit: { opacity: 0, transition: { duration: 0 } } };\nconst glyphFade: Variants = { enter: { y: 0, opacity: 0, filter: \"none\" }, center: { y: 0, opacity: 1, filter: \"none\", transition: { duration: duration.instant } }, exit: { opacity: 0, transition: { duration: 0 } } };\n\nfunction Glyphs({ parts, direction, instant, reduced }: { parts: Part[]; direction: number; instant: boolean; reduced: boolean }) {\n  const columnVariants = instant || reduced ? columnCut : column;\n  const glyphVariants = instant ? glyphCut : reduced ? glyphFade : glyph;\n  return <AnimatePresence initial={false} custom={direction}>\n    {parts.map(part => <motion.span key={part.key} className={styles.column} data-ghost={part.ghost || undefined} custom={direction} variants={columnVariants} initial=\"enter\" animate=\"center\" exit=\"exit\">\n      <span className={styles.sizer}>{part.char}</span>\n      <AnimatePresence initial={false} custom={direction}>\n        <motion.span key={part.char} className={styles.glyph} custom={direction} variants={glyphVariants} initial=\"enter\" animate=\"center\" exit=\"exit\">{part.char}</motion.span>\n      </AnimatePresence>\n    </motion.span>)}\n  </AnimatePresence>;\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); 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 <motion.span className={styles.symbol} style={{ width }} aria-hidden=\"true\">\n    <span ref={sizerRef} className={styles.symbolSizer}>{symbol}</span>\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.span key={symbol} className={styles.symbolGlyph} initial={reduced ? { opacity: 0 } : { opacity: 0, y: \".4em\", filter: `blur(${blur.soft}px)` }}\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={{ opacity: 0, y: reduced ? 0 : \"-.3em\", transition: { duration: duration.instant } }}\n        transition={reduced ? { duration: duration.instant } : { y: spring.snappy, opacity: { duration: duration.fast }, filter: { duration: duration.exit } }}>{symbol}</motion.span>\n    </AnimatePresence>\n  </motion.span>;\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++) 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  label, hideLabel = false, value, defaultValue = null, onValueChange, currency: currencyProp, defaultCurrency = \"USD\", onCurrencyChange,\n  currencies = [\"USD\", \"EUR\", \"GBP\", \"JPY\", \"CAD\", \"AUD\", \"CHF\", \"INR\"], min, max, quickAdd = [10, 50, 100], step: stepProp, locale = \"en-US\",\n  description, error, disabled = false, name, id, className, onBlur,\n}, forwardedRef) {\n  const reduced = useReducedFlag();\n  const uid = useId();\n  const inputId = id ?? `${uid}-amount`;\n  const hintId = `${inputId}-hint`, errorId = `${inputId}-error`, rangeId = `${inputId}-range`;\n\n  const [innerCurrency, setInnerCurrency] = useState(defaultCurrency);\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(() => { latest.current = { minor, draft }; });\n\n  const money = useCallback((amount: number | null) => amount === null ? \"\" : info.full.format(amount / pow10(info.digits)), [info]);\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, { currency: nextInfo.code, major: nextMinor === null ? null : nextMinor / pow10(nextInfo.digits), formatted: nextMinor === null ? \"\" : nextInfo.full.format(nextMinor / pow10(nextInfo.digits)) });\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 = \"\", before = 0, 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) { kept = \".\"; dot = true; }\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) { whole = \"0\"; shift += 1; }\n    if (fraction === undefined && !whole && stripped) { whole = \"0\"; shift += 1; }\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) { pendingCaret.current = Math.max(0, [...raw.slice(0, caret)].filter(char => isSignificant(char, symbols.decimal) || char === \".\").length - 1); rerender(); return; }\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, 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\") { setDraft(settle(draft, info.digits)); pendingCaret.current = Number.MAX_SAFE_INTEGER; return; }\n    const start = input.selectionStart ?? 0, 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) { event.preventDefault(); applyRaw(input.value.slice(0, start - 2) + input.value.slice(start - 1), start - 2); }\n    if (event.key === \"Delete\" && input.value[start] === symbols.group) { event.preventDefault(); applyRaw(input.value.slice(0, start + 1) + input.value.slice(start + 2), start); }\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, 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, ceiling = max ?? Number.MAX_SAFE_INTEGER;\n    let next = from + amount;\n    if (next > ceiling) { if (from >= ceiling) { refuse(1, ceiling); return; } next = ceiling; refuse(1, ceiling); }\n    if (next < floor) { if (from <= floor && latest.current.minor !== null) { refuse(-1, floor); return; } next = floor; }\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, group = groupRef.current;\n    if (!wrap || !group || typeof ResizeObserver === \"undefined\") return;\n    let first = true;\n    const fit = () => {\n      const natural = group.offsetWidth, room = wrap.clientWidth;\n      const next = natural > 0 && room > 0 ? Math.min(1, room / natural) : 1;\n      if (first || reduced) scale.jump(next); else animate(scale, next, spring.smooth);\n      first = false;\n    };\n    const observer = new ResizeObserver(fit);\n    observer.observe(wrap); 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 = () => { if (disabled) return; setActiveCode(code); setMenuOpen(true); };\n  const closeMenu = useCallback((focusButton: boolean) => { setMenuOpen(false); if (focusButton) menuButtonRef.current?.focus({ preventScroll: true }); }, []);\n  useLayoutEffect(() => { if (menuOpen) listRef.current?.focus({ preventScroll: true }); }, [menuOpen]);\n  useEffect(() => {\n    if (!menuOpen) return;\n    const down = (event: PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) closeMenu(false); };\n    document.addEventListener(\"pointerdown\", down);\n    return () => document.removeEventListener(\"pointerdown\", down);\n  }, [closeMenu, menuOpen]);\n\n  const hy = useMotionValue(0), hh = useMotionValue(0), ho = useMotionValue(0);\n  useLayoutEffect(() => {\n    const node = menuOpen ? optionRefs.current.get(activeCode) : undefined;\n    if (!node) { ho.jump(0); return; }\n    if (ho.get() < .05 || reduced) { hy.jump(node.offsetTop); hh.jump(node.offsetHeight); } else { animate(hy, node.offsetTop, GLIDE); animate(hh, node.offsetHeight, GLIDE); }\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 ? convertDraft(draft, next.digits) : 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) => { event.preventDefault(); setActiveCode(options[Math.max(0, Math.min(options.length - 1, index))]!.code); };\n    switch (event.key) {\n      case \"ArrowDown\": go(at + 1); return;\n      case \"ArrowUp\": go(at - 1); return;\n      case \"Home\": go(0); return;\n      case \"End\": go(options.length - 1); return;\n      case \"Enter\": case \" \": event.preventDefault(); if (options[at]) chooseCurrency(options[at]); return;\n      case \"Escape\": event.preventDefault(); event.stopPropagation(); closeMenu(true); return;\n      case \"Tab\": closeMenu(false); return;\n    }\n    if (event.key.length === 1 && /\\w/.test(event.key)) {\n      const now = performance.now(), 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(option => option.code.toLowerCase().startsWith(state.buffer) || option.name.toLowerCase().startsWith(state.buffer));\n      if (hit) { event.preventDefault(); setActiveCode(hit.code); }\n    }\n  }\n\n  /* ---------------------------------------------- Messages ---------------------------------------------- */\n\n  const range = min !== undefined && max !== undefined ? `${money(min)} to ${money(max)}` : max !== undefined ? `Up to ${money(max)}` : min !== undefined ? `At least ${money(min)}` : \"\";\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 = touched && !focused && outOfRange ? belowMin ? `The minimum is ${money(min ?? 0)}` : `The maximum is ${money(max ?? 0)}` : undefined;\n  const message = error ?? builtIn;\n  const invalid = !!message;\n  const describedBy = [range ? rangeId : null, description ? hintId : null, message ? errorId : null].filter(Boolean).join(\" \") || undefined;\n  const instant = source === \"type\";\n  const chips = quickAdd.filter(amount => amount > 0);\n\n  return <div className={[styles.field, className].filter(Boolean).join(\" \")} data-disabled={disabled || undefined}>\n    <label htmlFor={inputId} className={hideLabel ? styles.srOnly : styles.label}>{label}</label>\n\n    <div className={styles.control} data-invalid={invalid || undefined} data-focused={focused || undefined}>\n      <div ref={wrapRef} className={styles.amountWrap} onMouseDown={event => { if (event.target !== inputRef.current) event.preventDefault(); }}\n        onClick={event => { if (!disabled && event.target !== inputRef.current) { const input = inputRef.current; input?.focus(); input?.setSelectionRange(input.value.length, input.value.length); } }}>\n        <motion.div ref={groupRef} className={styles.group} style={{ scale, x: bumpX }}>\n          {info.position === \"prefix\" && <SymbolSlot symbol={info.symbol} reduced={reduced} />}\n          <span className={styles.number}>\n            <span className={styles.mirror} aria-hidden=\"true\" data-empty={!draft || undefined}><Glyphs parts={parts} direction={direction} instant={instant} reduced={reduced} /></span>\n            <input ref={inputRef} id={inputId} className={styles.input} type=\"text\" role=\"spinbutton\" inputMode={info.digits ? \"decimal\" : \"numeric\"} autoComplete=\"off\" spellCheck={false}\n              value={display} disabled={disabled} aria-invalid={invalid || undefined} aria-describedby={describedBy} aria-label={hideLabel ? label : undefined}\n              aria-valuenow={minor === null ? undefined : minor / pow10(info.digits)} aria-valuetext={minor === null ? \"Empty\" : money(minor)}\n              aria-valuemin={min === undefined ? undefined : min / pow10(info.digits)} aria-valuemax={max === undefined ? undefined : max / pow10(info.digits)}\n              onChange={event => applyRaw(event.currentTarget.value, event.currentTarget.selectionStart ?? event.currentTarget.value.length)}\n              onKeyDown={onKeyDown} onPaste={onPaste} onFocus={() => setFocused(true)}\n              onBlur={event => { setFocused(false); setTouched(true); if (draft) setDraft(settle(draft, info.digits)); onBlur?.(event); }} />\n          </span>\n          {info.position === \"suffix\" && <SymbolSlot symbol={info.symbol} reduced={reduced} />}\n        </motion.div>\n      </div>\n\n      {canSwitch ? <div ref={menuRef} className={styles.menuAnchor}>\n        <button ref={menuButtonRef} type=\"button\" className={styles.currencyButton} disabled={disabled} aria-haspopup=\"listbox\" aria-expanded={menuOpen} aria-controls={`${uid}-currencies`}\n          aria-label={`Currency, ${info.name}`} data-open={menuOpen || undefined} onClick={() => (menuOpen ? closeMenu(true) : openMenu())}\n          onKeyDown={event => { if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") { event.preventDefault(); openMenu(); } }}>\n          <span className={styles.code}>{code}</span>\n          <motion.span className={styles.chevron} aria-hidden=\"true\" initial={false} animate={{ rotate: menuOpen ? 180 : 0 }} transition={reduced ? { duration: 0 } : GLIDE}><ChevronDown size={16} strokeWidth={1.75} /></motion.span>\n        </button>\n        <AnimatePresence>\n          {menuOpen && <motion.div key=\"menu\" className={styles.menu}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: -4, filter: `blur(${blur.subtle}px)` }} animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduced ? { opacity: 0, transition: { duration: duration.instant } } : { opacity: 0, scale: .97, y: -2, filter: `blur(${blur.subtle}px)`, transition: { duration: duration.instant, ease: standardEase } }}\n            transition={reduced ? { duration: duration.instant } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enterEase }, filter: { duration: duration.fast, ease: enterEase } }}>\n            <div ref={listRef} id={`${uid}-currencies`} className={styles.list} role=\"listbox\" tabIndex={-1} aria-label=\"Currency\" aria-activedescendant={`${uid}-cur-${activeCode}`} onKeyDown={onMenuKeyDown}>\n              <motion.span className={styles.highlight} style={{ y: hy, height: hh, opacity: ho }} aria-hidden=\"true\" />\n              {options.map(option => <div key={option.code} id={`${uid}-cur-${option.code}`} role=\"option\" aria-selected={option.code === code} className={styles.option}\n                data-active={option.code === activeCode || undefined} ref={node => { if (node) optionRefs.current.set(option.code, node); else optionRefs.current.delete(option.code); }}\n                onPointerMove={event => { if (event.pointerType === \"mouse\" && option.code !== activeCode) setActiveCode(option.code); }}\n                onPointerDown={event => event.preventDefault()} onClick={() => chooseCurrency(option)}>\n                <span className={styles.optionSymbol} aria-hidden=\"true\">{option.symbol}</span>\n                <span className={styles.optionCode}>{option.code}</span>\n                <span className={styles.optionName}>{option.name}</span>\n                <span className={styles.check} data-on={option.code === code || undefined} aria-hidden=\"true\"><Check size={16} strokeWidth={1.75} /></span>\n              </div>)}\n            </div>\n          </motion.div>}\n        </AnimatePresence>\n      </div> : <span className={styles.codeStatic}>{code}</span>}\n    </div>\n\n    {(chips.length > 0 || range) && <div className={styles.toolbar}>\n      {chips.length > 0 && <div className={styles.chips} 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 <button key={amount} type=\"button\" className={styles.chip} disabled={disabled} aria-disabled={atMax || undefined} aria-controls={inputId}\n            aria-label={`Add ${info.whole.format(amount)}`} onClick={() => nudge(add, \"chip\")}>+{info.whole.format(amount)}</button>;\n        })}\n      </div>}\n      {range && <span id={rangeId} className={styles.range} data-flash={limitFlash > 0 || undefined}>{range}</span>}\n    </div>}\n\n    <AnimatePresence initial={false}>\n      {(message || description) && <motion.span key=\"message\" className={styles.messageSlot} initial={reduced ? false : { height: 0, opacity: 0 }} animate={{ height: \"auto\", opacity: 1 }}\n        exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: spring.smooth, opacity: { duration: duration.instant } } }}\n        transition={reduced ? { duration: 0 } : { height: spring.smooth, opacity: { duration: duration.fast } }}>\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span key={message ?? description} id={message ? errorId : hintId} className={message ? styles.error : styles.hint} role={message ? \"alert\" : undefined}\n            initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, transition: { duration: duration.instant } }} transition={{ duration: reduced ? duration.instant : duration.standard, ease: enterEase }}>{message ?? description}</motion.span>\n        </AnimatePresence>\n      </motion.span>}\n    </AnimatePresence>\n\n    {name && <input type=\"hidden\" name={name} value={minor ?? \"\"} />}\n    <span className={styles.srOnly} role=\"status\" aria-live=\"polite\">{announcement}</span>\n  </div>;\n});\n\nMoneyInput.displayName = \"MoneyInput\";\n\nexport default MoneyInput;\n"},{"path":"registry/components/money-input/money-input.module.css","type":"registry:component","target":"@components/arc/money-input/money-input.module.css","content":"/* 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.field { display: grid; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n.label { justify-self: start; margin-bottom: var(--space-2); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n\n/* One tall shell: the amount on the left, the currency on the right. Set --money-input-height to resize it. */\n.control { position: relative; display: flex; min-width: 0; height: var(--money-input-height, 76px); box-sizing: border-box; align-items: center; gap: var(--space-2); padding: 0 8px 0 18px; border: 1px solid var(--border-strong); border-radius: var(--radius-panel); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .control:hover:not([data-invalid]) { border-color: var(--foreground); } }\n.control[data-focused] { border-color: var(--foreground); }\n.control[data-invalid] { border-color: var(--danger); }\n.field[data-disabled] .control { opacity: .5; background: var(--surface-muted); }\n\n/* The amount scales down to fit rather than scroll, so a long number is always read whole. */\n.amountWrap { position: relative; display: flex; min-width: 0; height: 100%; flex: 1; align-items: center; overflow: hidden; cursor: text; }\n.field[data-disabled] .amountWrap { cursor: not-allowed; }\n.group { display: inline-flex; flex: none; align-items: baseline; transform-origin: 0 50%; white-space: nowrap; color: var(--foreground); font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; line-height: 1.2; letter-spacing: var(--tracking-display); font-variant-numeric: tabular-nums; font-kerning: none; }\n\n.symbol { position: relative; display: inline-block; margin-right: .06em; color: var(--text-secondary); white-space: pre; }\n.group > .symbol:last-child { margin-right: 0; margin-left: .18em; }\n.symbolSizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: pre; }\n.symbolGlyph { display: inline-block; white-space: pre; }\n\n/* The input only draws the caret and selection. Its text is always painted by the mirror, so settled, rolling, and typed amounts share one position. */\n.number { position: relative; display: inline-flex; }\n.mirror { display: inline-flex; }\n.input { position: absolute; z-index: 1; top: 0; left: 0; width: calc(100% + .5em); height: 100%; margin: 0; padding: 0; border: 0; background: transparent; color: transparent; caret-color: var(--foreground); font: inherit; font-variant-numeric: inherit; font-kerning: none; letter-spacing: inherit; line-height: inherit; outline: none; }\n.input::selection { background: color-mix(in oklab, var(--foreground) 16%, transparent); color: transparent; }\n.input:disabled { cursor: not-allowed; }\n\n/* Each character is a column: a hidden sizer holds its width and the glyph rolls inside, feathered at the top and bottom edge. */\n.column { --feather: .18em; position: relative; display: inline-block; overflow-x: visible; overflow-y: clip; margin-block: calc(var(--feather) * -1); padding-block: var(--feather); mask-image: linear-gradient(to bottom, transparent, #000 calc(var(--feather) * 1.4), #000 calc(100% - var(--feather) * 1.4), transparent); transition: color var(--duration-standard) var(--ease-standard); }\n/* Missing cents wait as ghosts, so settling the amount only changes their color. */\n.column[data-ghost] { color: color-mix(in oklab, var(--text-muted) 55%, transparent); }\n.sizer { visibility: hidden; white-space: pre; }\n.glyph { position: absolute; inset: var(--feather) 0; text-align: center; white-space: pre; }\n\n/* Currency switch: plain text on the shell, a quiet fill on hover and while open. It answers a press with color, since it anchors a menu. */\n.menuAnchor { position: relative; flex: none; }\n.currencyButton { display: inline-flex; height: 36px; align-items: center; gap: 4px; padding: 0 8px 0 12px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--foreground); cursor: pointer; font: inherit; font-size: var(--text-sm); font-weight: 500; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .currencyButton:hover:not(:disabled) { background: var(--surface-muted); } }\n.currencyButton:active:not(:disabled), .currencyButton[data-open] { background: var(--surface-muted); }\n.currencyButton:disabled { cursor: not-allowed; }\n.code { font-variant-numeric: tabular-nums; }\n.codeStatic { flex: none; padding: 0 10px; color: var(--text-secondary); font-size: var(--text-sm); font-weight: 500; }\n.chevron { display: grid; width: 16px; height: 16px; place-items: center; color: var(--text-muted); }\n\n/* The menu grows out of the currency button's corner and opens 8px below the shell, whatever its height. */\n.menu { --drop: calc((var(--money-input-height, 76px) - 36px) / 2 + var(--space-2)); position: absolute; z-index: 30; top: calc(100% + var(--drop)); right: -8px; width: min(256px, calc(100vw - 2rem)); box-sizing: border-box; padding: 6px; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); transform-origin: calc(100% - 44px) calc(var(--drop) * -1 - 18px); }\n.list { position: relative; display: grid; max-height: 288px; gap: 2px; overflow-y: auto; overscroll-behavior: contain; outline: none; scrollbar-width: thin; }\n.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: 14px; background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }\n.option { position: relative; display: flex; min-height: 36px; box-sizing: border-box; align-items: center; gap: 10px; padding: 0 10px; border-radius: 14px; color: var(--text-secondary); cursor: pointer; font-size: var(--text-sm); user-select: none; -webkit-user-select: none; transition: color var(--duration-fast) var(--ease-standard); }\n.option[data-active], .option[aria-selected=\"true\"] { color: var(--foreground); }\n.optionSymbol { width: 22px; flex: none; color: var(--text-muted); text-align: center; }\n.optionCode { flex: none; font-weight: 500; font-variant-numeric: tabular-nums; }\n.optionName { min-width: 0; flex: 1; overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }\n.check { display: grid; width: 16px; height: 16px; flex: none; place-items: center; color: var(--foreground); opacity: 0; transform: scale(.6); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }\n.check[data-on] { opacity: 1; transform: none; }\n\n/* Quick add chips and the allowed range share one row under the shell. */\n.toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2) var(--space-3); padding-top: var(--space-3); }\n.chips { display: flex; flex-wrap: wrap; gap: 6px; }\n.chip { height: 30px; padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-xs); font-weight: 500; font-variant-numeric: tabular-nums; -webkit-tap-highlight-color: transparent; touch-action: manipulation; 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); }\n@media (hover: hover) and (pointer: fine) { .chip:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); } }\n.chip:active:not(:disabled) { transform: scale(.96); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), 100ms; }\n.chip[aria-disabled=\"true\"] { opacity: .45; }\n.chip:disabled { opacity: .45; cursor: not-allowed; }\n.range { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; transition: color var(--duration-standard) var(--ease-standard); }\n.range[data-flash] { color: var(--warning); transition-duration: var(--duration-instant); }\n\n.messageSlot { position: relative; display: block; overflow: hidden; }\n.hint, .error { display: block; padding-top: var(--space-2); font-size: var(--text-xs); line-height: var(--leading-body); }\n.hint { color: var(--text-muted); }\n.error { color: var(--danger); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n\n@media (max-width: 420px) { .control { padding-left: 14px; } .group { font-size: var(--text-2xl); } }\n@media (prefers-reduced-motion: reduce) { .control, .column, .check, .chip, .range { transition-duration: 0ms; } .chip:active:not(:disabled) { transform: none; } }\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/money-input","meta":{"tier":"free","kind":"component","tags":["inputs","new"],"docs":"https://uiarc.dev/components/money-input","markdown":"https://uiarc.dev/components/money-input/markdown"}}