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-email": "^6.1.3",
"react-hook-form": "^7.75.0", "react-hook-form": "^7.75.0",
"react-image-crop": "^11.0.10", "react-image-crop": "^11.0.10",
"react-number-format": "^5.4.5",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",

14
pnpm-lock.yaml generated
View File

@@ -229,6 +229,9 @@ importers:
react-image-crop: react-image-crop:
specifier: ^11.0.10 specifier: ^11.0.10
version: 11.0.10(react@19.2.6) 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: react-resizable-panels:
specifier: ^3.0.6 specifier: ^3.0.6
version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.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: react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} 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: react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies: peerDependencies:
@@ -12814,6 +12823,11 @@ snapshots:
react-is@18.3.1: {} 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): react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
dependencies: dependencies:
'@types/use-sync-external-store': 0.0.6 '@types/use-sync-external-store': 0.0.6

View File

@@ -1,14 +1,15 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { NumericFormat, type NumericFormatProps } from 'react-number-format';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { currencySymbol } from '@/lib/utils/currency'; import { currencySymbol } from '@/lib/utils/currency';
interface CurrencyInputProps extends Omit< interface CurrencyInputProps extends Omit<
React.ComponentProps<'input'>, NumericFormatProps,
'value' | 'onChange' | 'type' 'value' | 'onChange' | 'customInput'
> { > {
/** Controlled raw numeric value. `null` / `undefined` render empty. */ /** Controlled raw numeric value. `null` / `undefined` render empty. */
value: number | string | null | undefined; value: number | string | null | undefined;
@@ -19,79 +20,22 @@ interface CurrencyInputProps extends Omit<
className?: 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 * Numeric input pre-decorated with a currency symbol and thousand-separator
* grouping (e.g. `3,528,000.50`). Uses `type="text"` + `inputMode="decimal"` * grouping (e.g. `3,528,000.50`). Built on react-number-format which handles
* so we can render commas (HTML `type="number"` strips them) while still * the inputMode/IME edge cases, decimal/group localisation, paste sanitization,
* surfacing the decimal keypad on iOS/Android. The parent receives a raw * and selection caret preservation that hand-rolled parsers miss.
* number via `onChange`; the formatted string is local UI state. *
* 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>( 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 symbol = currencySymbol(currency);
const [display, setDisplay] = React.useState<string>(() => // react-number-format wants undefined (not null) to render empty.
value === null || value === undefined || value === '' ? '' : formatGrouped(value), const formatValue =
); value === null || value === undefined || value === '' ? undefined : Number(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 ( return (
<div className="relative"> <div className="relative">
@@ -101,31 +45,19 @@ export const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputPro
> >
{symbol} {symbol}
</span> </span>
<Input <NumericFormat
ref={ref} getInputRef={ref}
type="text" customInput={Input}
value={formatValue}
thousandSeparator=","
decimalSeparator="."
decimalScale={2}
allowNegative
inputMode="decimal" inputMode="decimal"
autoComplete="off" autoComplete="off"
value={display} onValueChange={(values) => {
onChange={(e) => { // floatValue is undefined when input is empty.
const { display: nextDisplay, numeric } = parseTyped(e.target.value); onChange(values.floatValue ?? null);
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)} className={cn('pl-9 tabular-nums', className)}
{...props} {...props}