diff --git a/.changeset/decimal-currency-input.md b/.changeset/decimal-currency-input.md new file mode 100644 index 0000000..9dbe0b8 --- /dev/null +++ b/.changeset/decimal-currency-input.md @@ -0,0 +1,7 @@ +--- +'@lambdacurry/medusa-forms': patch +--- + +Preserve exact decimal currency digits while typing and editing, without caret jumps or float rounding. + +ControlledCurrencyInput uses a precision-safe currency field (no Number()-based formatting), keeps a focused draft for intermediate decimals like `19.`, disables group separators while editing, and stores high-magnitude values as strings when `valueAsNumber` / `setValueAs` would lose IEEE-754 precision. diff --git a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx index 897a70c..06aaf3a 100644 --- a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx +++ b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx @@ -141,6 +141,8 @@ const getInputByName = (canvasElement: HTMLElement, name: string) => { return input; }; +const TRAILING_DECIMAL_DISPLAY = /\d+\.$/; + // 1. Different Currency Symbols export const USDCurrency: Story = { args: { @@ -304,6 +306,15 @@ export const DefaultStringValue: Story = { await userEvent.type(input, '1234'); + await waitFor(() => { + // While focused, group separators stay off so the caret is not shoved by commas + expect(input.value).toBe('1234'); + expect(state).toHaveTextContent('"value": "1234"'); + expect(state).toHaveTextContent('"type": "string"'); + }); + + await userEvent.tab(); + await waitFor(() => { expect(input.value).toContain('1,234'); expect(state).toHaveTextContent('"value": "1234"'); @@ -326,12 +337,22 @@ export const ValueAsNumber: Story = { await userEvent.type(input, '1234'); + await waitFor(() => { + // While focused, group separators stay off so the caret is not shoved by commas + expect(input.value).toBe('1234'); + expect(state).toHaveTextContent('"value": 1234'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.tab(); + await waitFor(() => { expect(input.value).toContain('1,234'); expect(state).toHaveTextContent('"value": 1234'); expect(state).toHaveTextContent('"type": "number"'); }); + await userEvent.click(input); await userEvent.clear(input); await waitFor(() => { @@ -342,6 +363,389 @@ export const ValueAsNumber: Story = { }, }; +const CurrencyInputWithSetValueAs = () => { + const form = useForm({ + defaultValues: { price: '' }, + }); + const price = form.watch('price'); + + return ( + +
+ + name="price" + label="Nullable numeric price" + symbol="$" + code="usd" + step={0.01} + rules={{ + setValueAs: (value) => { + if (value == null || value === '') { + return null; + } + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; + }, + }} + /> +
+          {JSON.stringify({ value: price, type: typeof price }, null, 2)}
+        
+
+
+ ); +}; + +/** + * Accept, store, and preserve decimal currency values such as 19.99. + * Reported failure (pre-fix): valueAsNumber coerces "19." → 19 and rewrites the input, + * so typing "19.99" becomes "1999" (or otherwise loses the decimal). + */ +export const DecimalValueSupport: Story = { + tags: ['decimal-currency', 'test'], + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await userEvent.click(input); + await userEvent.type(input, '19.99'); + + await waitFor(() => { + expect(input.value).toContain('19.99'); + expect(state).toHaveTextContent('"value": 19.99'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.tab(); + + await waitFor(() => { + expect(input.value).toContain('19.99'); + expect(state).toHaveTextContent('"value": 19.99'); + expect(state).toHaveTextContent('"type": "number"'); + }); + }, +}; + +/** + * Intermediate decimal point must remain while typing (e.g. "19."). + * Reported failure (pre-fix): trailing "." is stripped as soon as valueAsNumber runs. + */ +export const DecimalTypingIntermediate: Story = { + tags: ['decimal-currency', 'test'], + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await userEvent.click(input); + await userEvent.type(input, '19.'); + + await waitFor(() => { + // Draft display keeps the trailing decimal while focused; form value is coerced to 19 + expect(input.value).toMatch(TRAILING_DECIMAL_DISPLAY); + expect(state).toHaveTextContent('"value": 19'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.tab(); + + await waitFor(() => { + // Blur reconciles draft "19." from the coerced field value + expect(input.value).toBe('19'); + expect(state).toHaveTextContent('"value": 19'); + }); + + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, '19.99'); + + await waitFor(() => { + expect(input.value).toContain('19.99'); + expect(state).toHaveTextContent('"value": 19.99'); + }); + + await userEvent.tab(); + + await waitFor(() => { + expect(input.value).toContain('19.99'); + expect(state).toHaveTextContent('"value": 19.99'); + }); + }, +}; + +const CurrencyInputWithExistingDecimal = () => { + const form = useForm({ + defaultValues: { price: 19.99 }, + }); + const price = form.watch('price'); + + return ( + +
+ + name="price" + label="Existing decimal price" + symbol="$" + code="usd" + step={0.01} + rules={{ valueAsNumber: true }} + /> +
+          {JSON.stringify({ value: price, type: typeof price }, null, 2)}
+        
+
+
+ ); +}; + +/** + * Editing an existing decimal must not truncate or block valid input. + * Reported failure (pre-fix): replacing 19.99 with 20.50 loses the decimal while typing. + */ +export const EditPreservesDecimals: Story = { + tags: ['decimal-currency', 'test'], + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await waitFor(() => { + expect(input.value).toContain('19.99'); + expect(state).toHaveTextContent('"value": 19.99'); + }); + + await userEvent.click(input); + await userEvent.clear(input); + await userEvent.type(input, '20.50'); + + await waitFor(() => { + expect(input.value).toContain('20.5'); + expect(state).toHaveTextContent('"value": 20.5'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.tab(); + + await waitFor(() => { + expect(input.value).toContain('20.5'); + expect(state).toHaveTextContent('"value": 20.5'); + expect(state).toHaveTextContent('"type": "number"'); + }); + }, +}; + +/** + * Same decimal path consumers use (setValueAs → nullable number), e.g. Sezzle min/max. + * Reported failure (pre-fix): setValueAs Number() coercion strips intermediate decimals. + */ +export const SetValueAsPreservesDecimals: Story = { + tags: ['decimal-currency', 'test'], + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await userEvent.click(input); + await userEvent.type(input, '20.50'); + + await waitFor(() => { + expect(input.value).toContain('20.5'); + expect(state).toHaveTextContent('"value": 20.5'); + expect(state).toHaveTextContent('"type": "number"'); + }); + + await userEvent.tab(); + + await waitFor(() => { + expect(input.value).toContain('20.5'); + expect(state).toHaveTextContent('"value": 20.5'); + expect(state).toHaveTextContent('"type": "number"'); + }); + }, +}; + +/** + * Caret must stay put while editing mid-value (no group-separator reformatting on each keystroke). + */ +export const CursorStableWhileEditing: Story = { + tags: ['decimal-currency', 'test'], + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await userEvent.click(input); + await userEvent.type(input, '1999'); + + await waitFor(() => { + // While focused, group separators stay off so the caret is not shoved by commas + expect(input.value).toBe('1999'); + expect(state).toHaveTextContent('"value": 1999'); + }); + + input.setSelectionRange(2, 2); + await userEvent.type(input, '0', { + initialSelectionStart: 2, + initialSelectionEnd: 2, + }); + + await waitFor(() => { + expect(input.value).toBe('19099'); + expect(input.selectionStart).toBe(3); + expect(input.selectionEnd).toBe(3); + expect(state).toHaveTextContent('"value": 19099'); + }); + + await userEvent.tab(); + + await waitFor(() => { + expect(input.value).toContain('19,099'); + expect(state).toHaveTextContent('"value": 19099'); + }); + }, +}; + +const CurrencyInputWithLargeDecimal = () => { + const form = useForm({ + defaultValues: { price: '125560066337.69' }, + }); + const price = form.watch('price'); + + return ( + +
+ + name="price" + label="High precision price" + symbol="$" + code="usd" + rules={{ valueAsNumber: true }} + /> +
+          {JSON.stringify({ value: price, type: typeof price }, null, 2)}
+        
+
+
+ ); +}; + +/** + * High-magnitude decimals must not be rounded by IEEE-754 (e.g. …66337.69 → …66338.69). + */ +export const HighPrecisionDigitsPreserved: Story = { + tags: ['decimal-currency', 'test'], + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await waitFor(() => { + expect(input.value.replace(/,/g, '')).toBe('125560066337.69'); + }); + + await userEvent.click(input); + // Insert digits just after "1255600" so the value grows past float64 precision + input.setSelectionRange(7, 7); + await userEvent.type(input, '11111', { + initialSelectionStart: 7, + initialSelectionEnd: 7, + }); + + const expected = '12556001111166337.69'; + + await waitFor(() => { + expect(input.value.replace(/,/g, '')).toBe(expected); + // Stored as string once Number() would round (not 12556001111166338) + expect(state).toHaveTextContent(`"value": "${expected}"`); + expect(state).toHaveTextContent('"type": "string"'); + }); + + await userEvent.tab(); + + await waitFor(() => { + expect(input.value.replace(/,/g, '')).toBe(expected); + expect(state).toHaveTextContent(`"value": "${expected}"`); + }); + }, +}; + +/** + * A second decimal point must be ignored — it must not remove/move the existing "." . + */ +export const RejectsSecondDecimalPoint: Story = { + tags: ['decimal-currency', 'test'], + args: { + name: 'price', + symbol: '$', + code: 'usd', + }, + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = getInputByName(canvasElement, 'price'); + const state = getStateOutput(canvas); + + await userEvent.click(input); + await userEvent.type(input, '19.99'); + + await waitFor(() => { + expect(input.value).toBe('19.99'); + expect(state).toHaveTextContent('"value": 19.99'); + }); + + // Insert another "." after the leading "1" — must not become "1.999" or jump caret + input.setSelectionRange(1, 1); + await userEvent.type(input, '.', { + initialSelectionStart: 1, + initialSelectionEnd: 1, + }); + + await waitFor(() => { + expect(input.value).toBe('19.99'); + expect(input.selectionStart).toBe(1); + expect(input.selectionEnd).toBe(1); + expect(state).toHaveTextContent('"value": 19.99'); + }); + }, +}; + const customValidationSchema = z.object({ price: z.string().refine((val) => { const num = Number.parseFloat(val); diff --git a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx index 804bb24..96b149e 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -1,22 +1,192 @@ import type * as React from 'react'; -import { Controller, type ControllerProps, type FieldValues, type Path, useFormContext } from 'react-hook-form'; +import { useRef, useState } from 'react'; +import { + Controller, + type ControllerProps, + type ControllerRenderProps, + type FieldValues, + type Path, + useFormContext, +} from 'react-hook-form'; import { CurrencyInput, type CurrencyInputProps } from '../ui/CurrencyInput'; +import { formatCurrencyGroups } from './currencyPrecision'; import { type ControlledRules, serializeDisplayValue, splitTransformRules, transformValue } from './valueTransforms'; -/** Match a valid number: optional leading minus, digits, optional single decimal point + digits */ -const NUMERIC_VALUE_REGEX = /^-?\d*\.?\d*/; +/** Strip non-numeric characters; keep a leading minus and at most one decimal point. */ const NON_NUMERIC_REGEX = /[^0-9.-]/g; +const DECIMAL_POINT = '.'; + +const stripToNumeric = (raw: string): { isNegative: boolean; unsigned: string } => { + const cleaned = raw.replace(NON_NUMERIC_REGEX, ''); + const isNegative = cleaned.startsWith('-'); + return { isNegative, unsigned: cleaned.replace(/-/g, '') }; +}; + +/** True when the input contains more than one decimal point (after stripping junk). */ +export const hasMultipleDecimalPoints = (raw: string): boolean => { + const { unsigned } = stripToNumeric(raw); + return unsigned.indexOf(DECIMAL_POINT) !== unsigned.lastIndexOf(DECIMAL_POINT); +}; + +export const normalizeCurrencyInputValue = (raw: string): string => { + const { isNegative, unsigned } = stripToNumeric(raw); + const decimalIndex = unsigned.indexOf(DECIMAL_POINT); + const value = + decimalIndex === -1 + ? unsigned + : `${unsigned.slice(0, decimalIndex + 1)}${unsigned + .slice(decimalIndex + 1) + .split(DECIMAL_POINT) + .join('')}`; + return isNegative ? `-${value}` : value; +}; + +const isDecimalKey = (key: string) => key === DECIMAL_POINT || key === 'Decimal'; + +const toDisplayValue = ( + value: unknown, + rules: ControlledRules | undefined, + hasTransform: boolean, +): string => { + if (!hasTransform) { + return String(value ?? ''); + } + const serialized = serializeDisplayValue(value, rules); + return Array.isArray(serialized) ? serialized.join('') : serialized; +}; + +type CurrencyInputValueChangeValues = { + float: number | null; + formatted: string; + value: string; +}; export type ControlledCurrencyInputProps = CurrencyInputProps & Omit, 'render' | 'control' | 'rules'> & { name: Path; rules?: ControlledRules; + /** When true, skip thousand separators while blurred. */ + disableGroupSeparators?: boolean; + /** Accepted for API compatibility; ignored (prefer onChange / Controller). */ + onValueChange?: (value: string | undefined, name?: string, values?: CurrencyInputValueChangeValues) => void; + }; + +type CurrencyFieldRenderProps = { + field: ControllerRenderProps>; + inputProps: Omit, 'name' | 'rules' | 'onChange' | 'onValueChange'>; + rules: ControlledRules | undefined; + hasTransform: boolean; + formErrors: ReturnType['formState']['errors']; + onChange?: CurrencyInputProps['onChange']; +}; + +const ControlledCurrencyInputField = ({ + field, + inputProps, + rules, + hasTransform, + formErrors, + onChange, +}: CurrencyFieldRenderProps) => { + const [isFocused, setIsFocused] = useState(false); + const [draft, setDraft] = useState(() => toDisplayValue(field.value, rules, hasTransform)); + const inputRef = useRef(null); + const selectionRef = useRef({ start: 0, end: 0 }); + + const { onFocus, onBlur, onKeyDown, onBeforeInput, disableGroupSeparators, ...restProps } = inputProps; + // While focused, draft preserves intermediate text (e.g. "19.") without group separators. + // When blurred, derive from field.value and optionally group with string-only formatting. + const rawDisplay = isFocused ? draft : toDisplayValue(field.value, rules, hasTransform); + const displayValue = formatCurrencyGroups(rawDisplay, !(isFocused || disableGroupSeparators)); + + const rememberSelection = (el: HTMLInputElement) => { + selectionRef.current = { + start: el.selectionStart ?? 0, + end: el.selectionEnd ?? 0, + }; }; + const restoreSelection = () => { + const el = inputRef.current; + if (!el) { + return; + } + const { start, end } = selectionRef.current; + requestAnimationFrame(() => { + el.setSelectionRange(start, end); + }); + }; + + const commitValue = (raw: string) => { + // Ignore keystrokes/pastes that would introduce a second decimal point (otherwise the + // previous "." is dropped and digits rejoin, which feels like the decimal "moved"). + if (hasMultipleDecimalPoints(raw)) { + restoreSelection(); + return; + } + + const value = normalizeCurrencyInputValue(raw); + setDraft(value); + field.onChange(hasTransform ? transformValue(value, rules) : value); + }; + + const blockExtraDecimal = (event: { preventDefault: () => void }) => { + if (draft.includes(DECIMAL_POINT)) { + event.preventDefault(); + } + }; + + return ( + { + inputRef.current = node; + field.ref(node); + }} + formErrors={formErrors} + value={displayValue} + onFocus={(event: React.FocusEvent) => { + setDraft(toDisplayValue(field.value, rules, hasTransform)); + setIsFocused(true); + rememberSelection(event.currentTarget); + onFocus?.(event); + }} + onBlur={(event: React.FocusEvent) => { + setIsFocused(false); + field.onBlur(); + onBlur?.(event); + }} + onSelect={(event: React.SyntheticEvent) => { + rememberSelection(event.currentTarget); + restProps.onSelect?.(event); + }} + onKeyDown={(event: React.KeyboardEvent) => { + rememberSelection(event.currentTarget); + if (isDecimalKey(event.key)) { + blockExtraDecimal(event); + } + onKeyDown?.(event); + }} + onBeforeInput={(event: React.InputEvent) => { + if (typeof event.data === 'string' && event.data.includes(DECIMAL_POINT)) { + blockExtraDecimal(event); + } + onBeforeInput?.(event); + }} + onChange={(event: React.ChangeEvent) => { + onChange?.(event); + commitValue(event.target.value); + }} + /> + ); +}; + export const ControlledCurrencyInput = ({ name, rules, onChange, + onValueChange: _consumerOnValueChange, ...props }: ControlledCurrencyInputProps) => { const { @@ -30,25 +200,16 @@ export const ControlledCurrencyInput = ({ control={control} name={name} rules={controllerRules} - render={({ field }) => { - return ( - ) => { - if (onChange) { - onChange(e); - } - - const cleaned = e.target.value.replace(NON_NUMERIC_REGEX, ''); - const value = cleaned.match(NUMERIC_VALUE_REGEX)?.[0] ?? ''; - field.onChange(hasTransform ? transformValue(value, rules) : value); - }} - /> - ); - }} + render={({ field }) => ( + + )} /> ); }; diff --git a/packages/medusa-forms/src/controlled/currencyPrecision.ts b/packages/medusa-forms/src/controlled/currencyPrecision.ts new file mode 100644 index 0000000..d1c69a2 --- /dev/null +++ b/packages/medusa-forms/src/controlled/currencyPrecision.ts @@ -0,0 +1,58 @@ +/** Group separator helper that never coerces through Number (IEEE-754 safe). */ +const GROUP_EVERY_THREE_DIGITS = /\B(?=(\d{3})+(?!\d))/g; +const LEADING_ZEROS = /^0+/; +const TRAILING_ZEROS = /0+$/; + +/** + * True when `Number(value)` cannot preserve the exact decimal digits (IEEE-754 float64). + * Large magnitudes quietly round (e.g. 12556001111166337.69 → 12556001111166338). + */ +export const isNumberConversionLossy = (value: string): boolean => { + if (value === '' || value === '-' || value === '.' || value === '-.') { + return false; + } + + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return true; + } + + const negative = value.startsWith('-'); + const unsigned = negative ? value.slice(1) : value; + const [wholeRaw = '0', fraction = ''] = unsigned.split('.'); + const whole = wholeRaw === '' ? '0' : wholeRaw; + const normalizedWhole = whole.replace(LEADING_ZEROS, '') || '0'; + const normalizedFraction = fraction.replace(TRAILING_ZEROS, ''); + const normalized = normalizedFraction + ? `${negative ? '-' : ''}${normalizedWhole}.${normalizedFraction}` + : `${negative ? '-' : ''}${normalizedWhole}`; + // Shortest float64 round-trip (ECMAScript ToString); differs only when digits are lost + const roundTrip = Object.is(numeric, -0) ? '-0' : String(numeric); + if (normalized !== roundTrip) { + return true; + } + + try { + if (BigInt(whole) !== BigInt(Math.trunc(Math.abs(numeric)))) { + return true; + } + } catch { + return true; + } + + return false; +}; + +/** Format a numeric string with group separators without using Number(). */ +export const formatCurrencyGroups = (value: string, enabled: boolean): string => { + if (!enabled || value === '' || value === '-' || value === '.' || value === '-.') { + return value; + } + + const negative = value.startsWith('-'); + const unsigned = negative ? value.slice(1) : value; + const [whole, fraction] = unsigned.split('.'); + const groupedWhole = whole.replace(GROUP_EVERY_THREE_DIGITS, ','); + const body = fraction != null ? `${groupedWhole}.${fraction}` : groupedWhole; + return negative ? `-${body}` : body; +}; diff --git a/packages/medusa-forms/src/controlled/valueTransforms.ts b/packages/medusa-forms/src/controlled/valueTransforms.ts index 345cdb5..31bd980 100644 --- a/packages/medusa-forms/src/controlled/valueTransforms.ts +++ b/packages/medusa-forms/src/controlled/valueTransforms.ts @@ -1,4 +1,5 @@ import type { FieldValues, Path, RegisterOptions } from 'react-hook-form'; +import { isNumberConversionLossy } from './currencyPrecision'; export type ControlledRules = Omit>, 'disabled'>; type ControllerRules = Omit, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>; @@ -14,9 +15,18 @@ export const splitTransformRules = ( }; }; +export { isNumberConversionLossy }; + export const transformValue = (value: string, rules: ControlledRules | undefined) => { if (rules?.valueAsNumber) { - return value === '' ? Number.NaN : +value; + if (value === '') { + return Number.NaN; + } + // Prefer the exact digit string over a rounded float when precision would be lost + if (isNumberConversionLossy(value)) { + return value; + } + return +value; } if (rules?.valueAsDate) { @@ -38,7 +48,11 @@ export const serializeDisplayValue = (value: unknown, rul } if (rules?.valueAsNumber) { - return typeof value === 'number' && Number.isNaN(value) ? '' : String(value); + if (typeof value === 'number' && Number.isNaN(value)) { + return ''; + } + // Keep full digit strings (high-precision currency kept as string when Number is lossy) + return String(value); } if (rules?.valueAsDate) { diff --git a/packages/medusa-forms/src/ui/CurrencyInput.tsx b/packages/medusa-forms/src/ui/CurrencyInput.tsx index c106861..9d19fdc 100644 --- a/packages/medusa-forms/src/ui/CurrencyInput.tsx +++ b/packages/medusa-forms/src/ui/CurrencyInput.tsx @@ -1,14 +1,94 @@ -import { CurrencyInput as MedusaCurrencyInput } from '@medusajs/ui'; +import { Text, clx } from '@medusajs/ui'; import { forwardRef } from 'react'; import { FieldWrapper } from './FieldWrapper'; import type { BasicFieldProps, MedusaCurrencyInputProps } from './types'; export type CurrencyInputProps = MedusaCurrencyInputProps & BasicFieldProps; +type CurrencyFieldShellProps = Omit & { + formErrors?: BasicFieldProps['formErrors']; +}; + +/** + * Currency field chrome that never formats through Number(). + * Medusa's CurrencyInput uses react-currency-input-field, which runs Number(value) and + * silently corrupts high-precision decimals (IEEE-754). + */ +const CurrencyFieldShell = forwardRef( + ({ symbol, code, size = 'base', disabled, className, onInvalid, ...props }, ref) => { + return ( +
+ + + {code} + + + + + + {symbol} + + +
+ ); + }, +); + +CurrencyFieldShell.displayName = 'CurrencyFieldShell'; + const Wrapper = FieldWrapper; +/** + * Precision-safe currency input for form use. Prefer this over Medusa's CurrencyInput when + * values may exceed float64 precision (or always, for controlled forms). + */ export const CurrencyInput = forwardRef((props, ref) => ( - {(inputProps) => } + {(inputProps) => } )); CurrencyInput.displayName = 'CurrencyInput';