'use client'; import * as React from 'react'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { currencySymbol } from '@/lib/utils/currency'; interface CurrencyInputProps extends Omit< React.ComponentProps<'input'>, 'value' | 'onChange' | 'type' > { /** Controlled raw numeric value. `null` / `undefined` render empty. */ value: number | string | null | undefined; /** Fires with a raw number (or `null` if cleared). */ onChange: (value: number | null) => void; /** ISO currency code; renders as a leading symbol prefix. */ currency?: string; className?: string; } const groupFormatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2, useGrouping: true, }); function formatGrouped(value: number | string): string { const n = typeof value === 'number' ? value : Number(value); if (!Number.isFinite(n)) return ''; return groupFormatter.format(n); } function parseTyped(raw: string): { display: string; numeric: number | null } { // Strip everything except digits, '.', '-'. Commas are formatting noise from // our own display and are removed before re-grouping. (Locale note: this // assumes '.' as decimal separator, matching the en-US formatter below.) let cleaned = raw.replace(/[^\d.-]/g, ''); // Keep only the first '.' (additional dots are dropped). const firstDot = cleaned.indexOf('.'); if (firstDot !== -1) { cleaned = cleaned.slice(0, firstDot + 1) + cleaned.slice(firstDot + 1).replace(/\./g, ''); } // Sign: only honour a leading '-'; strip any others. const negative = cleaned.startsWith('-'); cleaned = (negative ? '-' : '') + cleaned.replace(/-/g, ''); if (cleaned === '' || cleaned === '-') return { display: cleaned, numeric: null }; const dot = cleaned.indexOf('.'); const intPart = dot === -1 ? cleaned : cleaned.slice(0, dot); const fracPart = dot === -1 ? null : cleaned.slice(dot + 1); const intDigitsOnly = intPart.replace('-', ''); const intNumeric = intDigitsOnly === '' ? 0 : Number(intDigitsOnly); const numeric = (negative ? -1 : 1) * (intNumeric + (fracPart ? Number(`0.${fracPart}`) || 0 : 0)); const intDisplay = intDigitsOnly === '' ? negative ? '-' : '' : (negative ? '-' : '') + groupFormatter.format(intNumeric); const display = fracPart === null ? intDisplay : `${intDisplay}.${fracPart}`; return { display, numeric: Number.isFinite(numeric) ? numeric : null }; } /** * Numeric input pre-decorated with a currency symbol and thousand-separator * grouping (e.g. `3,528,000.50`). Uses `type="text"` + `inputMode="decimal"` * so we can render commas (HTML `type="number"` strips them) while still * surfacing the decimal keypad on iOS/Android. The parent receives a raw * number via `onChange`; the formatted string is local UI state. */ export const CurrencyInput = React.forwardRef( ({ value, onChange, currency = 'USD', className, onBlur, onFocus, ...props }, ref) => { const symbol = currencySymbol(currency); const [display, setDisplay] = React.useState(() => value === null || value === undefined || value === '' ? '' : formatGrouped(value), ); const focusedRef = React.useRef(false); // Re-sync the display when the controlled value changes externally (form // reset, parent-driven update). Skip while the input is focused so we // don't fight the user's keystrokes. React.useEffect(() => { if (focusedRef.current) return; if (value === null || value === undefined || value === '') { setDisplay(''); } else { setDisplay(formatGrouped(value)); } }, [value]); return (
{symbol} { const { display: nextDisplay, numeric } = parseTyped(e.target.value); setDisplay(nextDisplay); onChange(numeric); }} onFocus={(e) => { focusedRef.current = true; onFocus?.(e); }} onBlur={(e) => { focusedRef.current = false; // On blur, canonicalize to a clean grouped representation so the // user sees the final value rather than any half-typed state. if (value === null || value === undefined || value === '') { setDisplay(''); } else { setDisplay(formatGrouped(value)); } onBlur?.(e); }} className={cn('pl-9 tabular-nums', className)} {...props} />
); }, ); CurrencyInput.displayName = 'CurrencyInput';