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:
@@ -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
14
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user