feat(deps): react-number-format replaces hand-rolled CurrencyInput parser

The old CurrencyInput had ~100 LOC of regex-based parsing,
display-state syncing, and caret/focus juggling. react-number-format
ships a 17-LOC equivalent (NumericFormat with customInput pointing
at our shared Input shell) that handles the edge cases the hand-
rolled version missed: paste sanitisation, IME composition,
selection-caret preservation, locale separator switching.

Same external API on CurrencyInput so all 3 call sites
(berth-form, invoice-line-items, expense-form-dialog) keep working
without changes.

Verified: tsc clean, vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 22:53:18 +02:00
parent 9868c68f8f
commit 75920a2540
3 changed files with 39 additions and 92 deletions

View File

@@ -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",

14
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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 `<Input>` shell via `customInput` so border /
* focus-ring / dark-mode styles stay consistent with the rest of the kit.
*/
export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
({ 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<string>(() =>
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 (
<div className="relative">
@@ -101,31 +45,19 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
>
{symbol}
</span>
<Input
ref={ref}
type="text"
<NumericFormat
getInputRef={ref}
customInput={Input}
value={formatValue}
thousandSeparator=","
decimalSeparator="."
decimalScale={2}
allowNegative
inputMode="decimal"
autoComplete="off"
value={display}
onChange={(e) => {
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}