diff --git a/package.json b/package.json index ac267139..b21f53cb 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "react-email": "^6.1.3", "react-hook-form": "^7.75.0", "react-image-crop": "^11.0.10", + "react-number-format": "^5.4.5", "react-resizable-panels": "^3.0.6", "recharts": "^3.8.1", "sharp": "^0.34.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f1c25c5..3a443638 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -229,6 +229,9 @@ importers: react-image-crop: specifier: ^11.0.10 version: 11.0.10(react@19.2.6) + react-number-format: + specifier: ^5.4.5 + version: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-resizable-panels: specifier: ^3.0.6 version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -6049,6 +6052,12 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-number-format@5.4.5: + resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -12814,6 +12823,11 @@ snapshots: react-is@18.3.1: {} + react-number-format@5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 diff --git a/src/components/shared/currency-input.tsx b/src/components/shared/currency-input.tsx index c7c31a9e..2853632e 100644 --- a/src/components/shared/currency-input.tsx +++ b/src/components/shared/currency-input.tsx @@ -1,14 +1,15 @@ 'use client'; import * as React from 'react'; +import { NumericFormat, type NumericFormatProps } from 'react-number-format'; 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' + NumericFormatProps, + 'value' | 'onChange' | 'customInput' > { /** Controlled raw numeric value. `null` / `undefined` render empty. */ value: number | string | null | undefined; @@ -19,79 +20,22 @@ interface CurrencyInputProps extends Omit< 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. + * grouping (e.g. `3,528,000.50`). Built on react-number-format which handles + * the inputMode/IME edge cases, decimal/group localisation, paste sanitization, + * and selection caret preservation that hand-rolled parsers miss. + * + * Renders inside the shared `` shell via `customInput` so border / + * focus-ring / dark-mode styles stay consistent with the rest of the kit. */ export const CurrencyInput = React.forwardRef( - ({ value, onChange, currency = 'USD', className, onBlur, onFocus, ...props }, ref) => { + ({ value, onChange, currency = 'USD', className, ...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]); + // react-number-format wants undefined (not null) to render empty. + const formatValue = + value === null || value === undefined || value === '' ? undefined : Number(value); return (
@@ -101,31 +45,19 @@ export const CurrencyInput = React.forwardRef {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); + onValueChange={(values) => { + // floatValue is undefined when input is empty. + onChange(values.floatValue ?? null); }} className={cn('pl-9 tabular-nums', className)} {...props}