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