From 3564f44a104f3a130b7558949539aceca0196a8a Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 17:29:40 +0300 Subject: [PATCH 1/9] feat: enhance ControlledCurrencyInput to preserve decimal values during input - Introduced a draft display value mechanism in ControlledCurrencyInput to maintain intermediate decimal inputs (e.g., "19.") while typing. - Updated the input handling to ensure valid decimal values are not truncated or blocked during editing. - Added new stories to demonstrate and test the preservation of decimal values in various scenarios, including typing and editing existing values. - Implemented normalization of currency input to improve user experience and input accuracy. --- .changeset/decimal-currency-input.md | 7 + .../ControlledCurrencyInput.stories.tsx | 187 ++++++++++++++++++ .../controlled/ControlledCurrencyInput.tsx | 106 ++++++++-- 3 files changed, 280 insertions(+), 20 deletions(-) create mode 100644 .changeset/decimal-currency-input.md diff --git a/.changeset/decimal-currency-input.md b/.changeset/decimal-currency-input.md new file mode 100644 index 0000000..b3b92dd --- /dev/null +++ b/.changeset/decimal-currency-input.md @@ -0,0 +1,7 @@ +--- +'@lambdacurry/medusa-forms': patch +--- + +Preserve decimal currency input while typing and editing. + +ControlledCurrencyInput kept a draft display value so intermediate decimals like `19.` and values like `19.99` survive `valueAsNumber` / `setValueAs` coercion without truncating or blocking valid input. diff --git a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx index 897a70c..2287c5b 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 = /19\./; + // 1. Different Currency Symbols export const USDCurrency: Story = { args: { @@ -342,6 +344,191 @@ 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"'); + }); + }, +}; + +/** + * 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.type(input, '99'); + + 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"'); + }); + }, +}; + +/** + * 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"'); + }); + }, +}; + 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..2eb4571 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -1,5 +1,13 @@ import type * as React from 'react'; -import { Controller, type ControllerProps, type FieldValues, type Path, useFormContext } from 'react-hook-form'; +import { useEffect, 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 { type ControlledRules, serializeDisplayValue, splitTransformRules, transformValue } from './valueTransforms'; @@ -7,12 +15,79 @@ import { type ControlledRules, serializeDisplayValue, splitTransformRules, trans const NUMERIC_VALUE_REGEX = /^-?\d*\.?\d*/; const NON_NUMERIC_REGEX = /[^0-9.-]/g; +export const normalizeCurrencyInputValue = (raw: string): string => { + const cleaned = raw.replace(NON_NUMERIC_REGEX, ''); + return cleaned.match(NUMERIC_VALUE_REGEX)?.[0] ?? ''; +}; + +const toDisplayValue = ( + value: unknown, + rules: ControlledRules | undefined, + hasTransform: boolean, +) => (hasTransform ? serializeDisplayValue(value, rules) : String(value ?? '')); + export type ControlledCurrencyInputProps = CurrencyInputProps & Omit, 'render' | 'control' | 'rules'> & { name: Path; rules?: ControlledRules; }; +type CurrencyFieldRenderProps = { + field: ControllerRenderProps>; + inputProps: Omit, 'name' | 'rules' | 'onChange'>; + 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)); + + // While focused, draft is the source of truth so intermediate values like "19." survive + // valueAsNumber / setValueAs coercion. Sync from the field when blurred (resets/defaults). + useEffect(() => { + if (!isFocused) { + setDraft(toDisplayValue(field.value, rules, hasTransform)); + } + }, [field.value, hasTransform, isFocused, rules]); + + const { onFocus, onBlur, ...restProps } = inputProps; + + return ( + ) => { + setIsFocused(true); + onFocus?.(event); + }} + onBlur={(event: React.FocusEvent) => { + setIsFocused(false); + field.onBlur(); + onBlur?.(event); + }} + onChange={(event: React.ChangeEvent) => { + onChange?.(event); + + const value = normalizeCurrencyInputValue(event.target.value); + setDraft(value); + field.onChange(hasTransform ? transformValue(value, rules) : value); + }} + /> + ); +}; + export const ControlledCurrencyInput = ({ name, rules, @@ -30,25 +105,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 }) => ( + + )} /> ); }; From 385c577e2374651b45ef28a5e078b2823385fc6a Mon Sep 17 00:00:00 2001 From: LC Mohsen Date: Wed, 29 Jul 2026 18:33:55 +0300 Subject: [PATCH 2/9] Update packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/controlled/ControlledCurrencyInput.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx index 2eb4571..92871c3 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -11,13 +11,15 @@ import { import { CurrencyInput, type CurrencyInputProps } from '../ui/CurrencyInput'; 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*/; const NON_NUMERIC_REGEX = /[^0-9.-]/g; export const normalizeCurrencyInputValue = (raw: string): string => { const cleaned = raw.replace(NON_NUMERIC_REGEX, ''); - return cleaned.match(NUMERIC_VALUE_REGEX)?.[0] ?? ''; + const isNegative = cleaned.startsWith('-'); + const unsigned = cleaned.replace(/-/g, ''); + const [whole, ...rest] = unsigned.split('.'); + const value = rest.length > 0 ? `${whole}.${rest.join('')}` : whole; + return isNegative ? `-${value}` : value; }; const toDisplayValue = ( From 580b0fdc86e3ab2fdacb0f79cf98788067475a80 Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 19:07:44 +0300 Subject: [PATCH 3/9] fix: refine currency input handling and improve decimal preservation - Updated regex for trailing decimal display to ensure proper validation of decimal inputs. - Enhanced test scenarios in stories to validate the preservation of decimal values during user interactions. - Adjusted input handling to maintain intermediate values while typing, ensuring a smoother user experience. --- .../ControlledCurrencyInput.stories.tsx | 45 ++++++++++++++++++- .../controlled/ControlledCurrencyInput.tsx | 25 +++++------ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx index 2287c5b..c56bef0 100644 --- a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx +++ b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx @@ -141,7 +141,7 @@ const getInputByName = (canvasElement: HTMLElement, name: string) => { return input; }; -const TRAILING_DECIMAL_DISPLAY = /19\./; +const TRAILING_DECIMAL_DISPLAY = /\d+\.$/; // 1. Different Currency Symbols export const USDCurrency: Story = { @@ -403,6 +403,14 @@ export const DecimalValueSupport: Story = { 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"'); + }); }, }; @@ -433,7 +441,24 @@ export const DecimalTypingIntermediate: Story = { expect(state).toHaveTextContent('"type": "number"'); }); - await userEvent.type(input, '99'); + 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'); @@ -498,6 +523,14 @@ export const EditPreservesDecimals: Story = { 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"'); + }); }, }; @@ -526,6 +559,14 @@ export const SetValueAsPreservesDecimals: Story = { 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"'); + }); }, }; diff --git a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx index 2eb4571..7e373b4 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -1,5 +1,5 @@ import type * as React from 'react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Controller, type ControllerProps, @@ -11,13 +11,16 @@ import { import { CurrencyInput, type CurrencyInputProps } from '../ui/CurrencyInput'; 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; export const normalizeCurrencyInputValue = (raw: string): string => { const cleaned = raw.replace(NON_NUMERIC_REGEX, ''); - return cleaned.match(NUMERIC_VALUE_REGEX)?.[0] ?? ''; + const isNegative = cleaned.startsWith('-'); + const unsigned = cleaned.replace(/-/g, ''); + const [whole, ...rest] = unsigned.split('.'); + const value = rest.length > 0 ? `${whole}.${rest.join('')}` : whole; + return isNegative ? `-${value}` : value; }; const toDisplayValue = ( @@ -52,23 +55,19 @@ const ControlledCurrencyInputField = ({ const [isFocused, setIsFocused] = useState(false); const [draft, setDraft] = useState(() => toDisplayValue(field.value, rules, hasTransform)); - // While focused, draft is the source of truth so intermediate values like "19." survive - // valueAsNumber / setValueAs coercion. Sync from the field when blurred (resets/defaults). - useEffect(() => { - if (!isFocused) { - setDraft(toDisplayValue(field.value, rules, hasTransform)); - } - }, [field.value, hasTransform, isFocused, rules]); - const { onFocus, onBlur, ...restProps } = inputProps; + // While focused, draft preserves intermediate text (e.g. "19."). When blurred, derive + // from field.value so resets/defaults stay in sync without effect-driven mirroring. + const displayValue = isFocused ? draft : toDisplayValue(field.value, rules, hasTransform); return ( ) => { + setDraft(toDisplayValue(field.value, rules, hasTransform)); setIsFocused(true); onFocus?.(event); }} From c6e019bb539fe786a8c11cc08d765a2b05d8923c Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 23:09:36 +0300 Subject: [PATCH 4/9] feat: enhance ControlledCurrencyInput for precise decimal handling - Improved decimal input preservation during typing and editing, eliminating caret jumps and float rounding issues. - Implemented a precision-safe currency field that maintains high-magnitude values as strings to prevent IEEE-754 precision loss. - Added new stories to validate the behavior of the currency input under various scenarios, including handling multiple decimal points and large values. - Introduced helper functions for better currency formatting and validation without coercing through Number(). --- .changeset/decimal-currency-input.md | 4 +- .../ControlledCurrencyInput.stories.tsx | 157 ++++++++++++++++++ .../controlled/ControlledCurrencyInput.tsx | 111 +++++++++++-- .../src/controlled/currencyPrecision.ts | 52 ++++++ .../src/controlled/valueTransforms.ts | 24 ++- .../medusa-forms/src/ui/CurrencyInput.tsx | 98 ++++++++++- packages/medusa-forms/src/ui/types.d.ts | 10 ++ 7 files changed, 435 insertions(+), 21 deletions(-) create mode 100644 packages/medusa-forms/src/controlled/currencyPrecision.ts diff --git a/.changeset/decimal-currency-input.md b/.changeset/decimal-currency-input.md index b3b92dd..9dbe0b8 100644 --- a/.changeset/decimal-currency-input.md +++ b/.changeset/decimal-currency-input.md @@ -2,6 +2,6 @@ '@lambdacurry/medusa-forms': patch --- -Preserve decimal currency input while typing and editing. +Preserve exact decimal currency digits while typing and editing, without caret jumps or float rounding. -ControlledCurrencyInput kept a draft display value so intermediate decimals like `19.` and values like `19.99` survive `valueAsNumber` / `setValueAs` coercion without truncating or blocking valid input. +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 c56bef0..8884a47 100644 --- a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx +++ b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx @@ -570,6 +570,163 @@ export const SetValueAsPreservesDecimals: Story = { }, }; +/** + * 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 7e373b4..05446e6 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -1,5 +1,5 @@ import type * as React from 'react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Controller, type ControllerProps, @@ -9,20 +9,37 @@ import { useFormContext, } from 'react-hook-form'; import { CurrencyInput, type CurrencyInputProps } from '../ui/CurrencyInput'; +import { formatCurrencyGroups } from './currencyPrecision'; import { type ControlledRules, serializeDisplayValue, splitTransformRules, transformValue } from './valueTransforms'; /** Strip non-numeric characters; keep a leading minus and at most one decimal point. */ const NON_NUMERIC_REGEX = /[^0-9.-]/g; +const DECIMAL_POINT = '.'; -export const normalizeCurrencyInputValue = (raw: string): string => { +const stripToNumeric = (raw: string): { isNegative: boolean; unsigned: string } => { const cleaned = raw.replace(NON_NUMERIC_REGEX, ''); const isNegative = cleaned.startsWith('-'); - const unsigned = cleaned.replace(/-/g, ''); - const [whole, ...rest] = unsigned.split('.'); - const value = rest.length > 0 ? `${whole}.${rest.join('')}` : whole; + 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).replaceAll(DECIMAL_POINT, '')}`; return isNegative ? `-${value}` : value; }; +const isDecimalKey = (key: string) => key === DECIMAL_POINT || key === 'Decimal'; + const toDisplayValue = ( value: unknown, rules: ControlledRules | undefined, @@ -37,7 +54,7 @@ export type ControlledCurrencyInputProps = CurrencyInputP type CurrencyFieldRenderProps = { field: ControllerRenderProps>; - inputProps: Omit, 'name' | 'rules' | 'onChange'>; + inputProps: Omit, 'name' | 'rules' | 'onChange' | 'onValueChange'>; rules: ControlledRules | undefined; hasTransform: boolean; formErrors: ReturnType['formState']['errors']; @@ -54,21 +71,71 @@ const ControlledCurrencyInputField = ({ }: 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, onValueChange: _, ...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 { onFocus, onBlur, ...restProps } = inputProps; - // While focused, draft preserves intermediate text (e.g. "19."). When blurred, derive - // from field.value so resets/defaults stay in sync without effect-driven mirroring. - const displayValue = isFocused ? draft : toDisplayValue(field.value, rules, hasTransform); + 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; + if (typeof field.ref === 'function') { + field.ref(node); + } else if (field.ref) { + field.ref.current = 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) => { @@ -76,12 +143,27 @@ const ControlledCurrencyInputField = ({ 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.FormEvent) => { + const data = (event.nativeEvent as InputEvent).data; + if (typeof data === 'string' && data.includes(DECIMAL_POINT)) { + blockExtraDecimal(event); + } + onBeforeInput?.(event); + }} onChange={(event: React.ChangeEvent) => { onChange?.(event); - - const value = normalizeCurrencyInputValue(event.target.value); - setDraft(value); - field.onChange(hasTransform ? transformValue(value, rules) : value); + commitValue(event.target.value); }} /> ); @@ -91,6 +173,7 @@ export const ControlledCurrencyInput = ({ name, rules, onChange, + onValueChange: _consumerOnValueChange, ...props }: ControlledCurrencyInputProps) => { const { diff --git a/packages/medusa-forms/src/controlled/currencyPrecision.ts b/packages/medusa-forms/src/controlled/currencyPrecision.ts new file mode 100644 index 0000000..5e1d948 --- /dev/null +++ b/packages/medusa-forms/src/controlled/currencyPrecision.ts @@ -0,0 +1,52 @@ +/** Group separator helper that never coerces through Number (IEEE-754 safe). */ +const GROUP_EVERY_THREE_DIGITS = /\B(?=(\d{3})+(?!\d))/g; + +/** + * 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 unsigned = value.startsWith('-') ? value.slice(1) : value; + const [wholeRaw = '0', fraction = ''] = unsigned.split('.'); + const whole = wholeRaw === '' ? '0' : wholeRaw; + const wholeWithoutLeadingZeros = whole.replace(/^0+/, '') || (fraction ? '' : '0'); + const significant = `${wholeWithoutLeadingZeros}${fraction}`.replace(/^0+/, '') || '0'; + + // float64 uniquely represents roughly 15–16 significant decimal digits + if (significant.length > 15) { + 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..38c1fc2 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) { @@ -24,7 +34,11 @@ export const transformValue = (value: string, rules: Cont } if (typeof rules?.setValueAs === 'function') { - return rules.setValueAs(value); + const next = rules.setValueAs(value); + if (typeof next === 'number' && Number.isFinite(next) && value !== '' && isNumberConversionLossy(value)) { + return value; + } + return next; } return value; @@ -38,7 +52,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..d23503c 100644 --- a/packages/medusa-forms/src/ui/CurrencyInput.tsx +++ b/packages/medusa-forms/src/ui/CurrencyInput.tsx @@ -1,14 +1,108 @@ -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'; diff --git a/packages/medusa-forms/src/ui/types.d.ts b/packages/medusa-forms/src/ui/types.d.ts index 2ddd712..1572750 100644 --- a/packages/medusa-forms/src/ui/types.d.ts +++ b/packages/medusa-forms/src/ui/types.d.ts @@ -25,12 +25,22 @@ export type TextAreaProps = Omit< > & React.RefAttributes; +type CurrencyInputValueChangeValues = { + float: number | null; + formatted: string; + value: string; +}; + export type MedusaCurrencyInputProps = Omit, 'defaultValue' | 'step'> & { symbol: string; code: string; size?: 'small' | 'base'; defaultValue?: string | number; step?: number; + /** Passed through to react-currency-input-field */ + disableGroupSeparators?: boolean; + /** Passed through to react-currency-input-field (raw unformatted value) */ + onValueChange?: (value: string | undefined, name?: string, values?: CurrencyInputValueChangeValues) => void; }; export type MedusaInputProps = React.InputHTMLAttributes & { From 1971723308a59d9b695c8b45a312d9ea267a45f9 Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 23:21:56 +0300 Subject: [PATCH 5/9] refactor: improve currency precision handling in ControlledCurrencyInput - Introduced constants for leading and trailing zero regex patterns to enhance readability and maintainability. - Updated normalization logic to handle leading and trailing zeros more effectively, ensuring accurate representation of decimal values. - Adjusted round-trip validation to account for negative values, improving precision checks during input processing. --- .../src/controlled/currencyPrecision.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/medusa-forms/src/controlled/currencyPrecision.ts b/packages/medusa-forms/src/controlled/currencyPrecision.ts index 5e1d948..d1c69a2 100644 --- a/packages/medusa-forms/src/controlled/currencyPrecision.ts +++ b/packages/medusa-forms/src/controlled/currencyPrecision.ts @@ -1,5 +1,7 @@ /** 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). @@ -15,14 +17,18 @@ export const isNumberConversionLossy = (value: string): boolean => { return true; } - const unsigned = value.startsWith('-') ? value.slice(1) : value; + const negative = value.startsWith('-'); + const unsigned = negative ? value.slice(1) : value; const [wholeRaw = '0', fraction = ''] = unsigned.split('.'); const whole = wholeRaw === '' ? '0' : wholeRaw; - const wholeWithoutLeadingZeros = whole.replace(/^0+/, '') || (fraction ? '' : '0'); - const significant = `${wholeWithoutLeadingZeros}${fraction}`.replace(/^0+/, '') || '0'; - - // float64 uniquely represents roughly 15–16 significant decimal digits - if (significant.length > 15) { + 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; } From 5fdf8134c44727d5fa7424b25303a7a6b6dd342d Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 23:24:08 +0300 Subject: [PATCH 6/9] refactor: simplify value transformation logic in controlled input - Streamlined the value transformation process by directly returning the result of the setValueAs function, enhancing code clarity and maintainability. - Removed unnecessary checks for number conversion loss, focusing on the primary transformation logic. --- packages/medusa-forms/src/controlled/valueTransforms.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/medusa-forms/src/controlled/valueTransforms.ts b/packages/medusa-forms/src/controlled/valueTransforms.ts index 38c1fc2..31bd980 100644 --- a/packages/medusa-forms/src/controlled/valueTransforms.ts +++ b/packages/medusa-forms/src/controlled/valueTransforms.ts @@ -34,11 +34,7 @@ export const transformValue = (value: string, rules: Cont } if (typeof rules?.setValueAs === 'function') { - const next = rules.setValueAs(value); - if (typeof next === 'number' && Number.isFinite(next) && value !== '' && isNumberConversionLossy(value)) { - return value; - } - return next; + return rules.setValueAs(value); } return value; From 6eea2e12edc77b00df3f1ec49e28ff765833f62a Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 23:36:08 +0300 Subject: [PATCH 7/9] refactor: enhance ControlledCurrencyInput functionality and type definitions - Updated normalization logic to improve currency input handling by replacing `replaceAll` with `split` and `join` for better performance. - Enhanced the `toDisplayValue` function to handle serialized values more robustly, ensuring proper formatting for arrays. - Introduced a new type definition for `CurrencyInputValueChangeValues` to improve type safety and clarity. - Added a new prop `disableGroupSeparators` to `ControlledCurrencyInputProps` for better control over formatting behavior. - Cleaned up unused code and improved input event handling for better performance and readability. --- .../controlled/ControlledCurrencyInput.tsx | 36 ++++++++++++------- packages/medusa-forms/src/ui/types.d.ts | 10 ------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx index 05446e6..009d3d3 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -34,7 +34,7 @@ export const normalizeCurrencyInputValue = (raw: string): string => { const value = decimalIndex === -1 ? unsigned - : `${unsigned.slice(0, decimalIndex + 1)}${unsigned.slice(decimalIndex + 1).replaceAll(DECIMAL_POINT, '')}`; + : `${unsigned.slice(0, decimalIndex + 1)}${unsigned.slice(decimalIndex + 1).split(DECIMAL_POINT).join('')}`; return isNegative ? `-${value}` : value; }; @@ -44,12 +44,28 @@ const toDisplayValue = ( value: unknown, rules: ControlledRules | undefined, hasTransform: boolean, -) => (hasTransform ? serializeDisplayValue(value, rules) : String(value ?? '')); +): 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 = { @@ -74,12 +90,11 @@ const ControlledCurrencyInputField = ({ const inputRef = useRef(null); const selectionRef = useRef({ start: 0, end: 0 }); - const { onFocus, onBlur, onKeyDown, onBeforeInput, disableGroupSeparators, onValueChange: _, ...restProps } = - inputProps; + 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 displayValue = formatCurrencyGroups(rawDisplay, !(isFocused || disableGroupSeparators)); const rememberSelection = (el: HTMLInputElement) => { selectionRef.current = { @@ -124,11 +139,7 @@ const ControlledCurrencyInputField = ({ {...restProps} ref={(node) => { inputRef.current = node; - if (typeof field.ref === 'function') { - field.ref(node); - } else if (field.ref) { - field.ref.current = node; - } + field.ref(node); }} formErrors={formErrors} value={displayValue} @@ -154,9 +165,8 @@ const ControlledCurrencyInputField = ({ } onKeyDown?.(event); }} - onBeforeInput={(event: React.FormEvent) => { - const data = (event.nativeEvent as InputEvent).data; - if (typeof data === 'string' && data.includes(DECIMAL_POINT)) { + onBeforeInput={(event: React.InputEvent) => { + if (typeof event.data === 'string' && event.data.includes(DECIMAL_POINT)) { blockExtraDecimal(event); } onBeforeInput?.(event); diff --git a/packages/medusa-forms/src/ui/types.d.ts b/packages/medusa-forms/src/ui/types.d.ts index 1572750..2ddd712 100644 --- a/packages/medusa-forms/src/ui/types.d.ts +++ b/packages/medusa-forms/src/ui/types.d.ts @@ -25,22 +25,12 @@ export type TextAreaProps = Omit< > & React.RefAttributes; -type CurrencyInputValueChangeValues = { - float: number | null; - formatted: string; - value: string; -}; - export type MedusaCurrencyInputProps = Omit, 'defaultValue' | 'step'> & { symbol: string; code: string; size?: 'small' | 'base'; defaultValue?: string | number; step?: number; - /** Passed through to react-currency-input-field */ - disableGroupSeparators?: boolean; - /** Passed through to react-currency-input-field (raw unformatted value) */ - onValueChange?: (value: string | undefined, name?: string, values?: CurrencyInputValueChangeValues) => void; }; export type MedusaInputProps = React.InputHTMLAttributes & { From b90a10e6973fdc40c191ddcced4ed455fd24b321 Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 23:47:15 +0300 Subject: [PATCH 8/9] test: enhance stories for ControlledCurrencyInput with focused input validation - Added waitFor checks in stories to validate input behavior while focused, ensuring caret position and value representation are correct. - Improved user interaction scenarios by verifying state updates for both string and number types during input and tabbing events. - Enhanced test coverage for currency input formatting and state representation after user interactions. --- .../ControlledCurrencyInput.stories.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx index 8884a47..06aaf3a 100644 --- a/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx +++ b/apps/docs/src/medusa-forms/ControlledCurrencyInput.stories.tsx @@ -306,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"'); @@ -328,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(() => { From e46bb5946236d2fd5247a0a26a59bd602c99272a Mon Sep 17 00:00:00 2001 From: Mohsen Ghaemaghami Date: Wed, 29 Jul 2026 23:53:43 +0300 Subject: [PATCH 9/9] refactor: improve formatting and readability in ControlledCurrencyInput and CurrencyInput - Enhanced the normalization logic in ControlledCurrencyInput for better clarity and performance by adjusting string manipulation methods. - Simplified the props destructuring in CurrencyFieldShell for improved readability. - Cleaned up className definitions to streamline the component's styling logic. --- .../controlled/ControlledCurrencyInput.tsx | 5 +++- .../medusa-forms/src/ui/CurrencyInput.tsx | 24 ++++--------------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx index 009d3d3..96b149e 100644 --- a/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx +++ b/packages/medusa-forms/src/controlled/ControlledCurrencyInput.tsx @@ -34,7 +34,10 @@ export const normalizeCurrencyInputValue = (raw: string): string => { const value = decimalIndex === -1 ? unsigned - : `${unsigned.slice(0, decimalIndex + 1)}${unsigned.slice(decimalIndex + 1).split(DECIMAL_POINT).join('')}`; + : `${unsigned.slice(0, decimalIndex + 1)}${unsigned + .slice(decimalIndex + 1) + .split(DECIMAL_POINT) + .join('')}`; return isNegative ? `-${value}` : value; }; diff --git a/packages/medusa-forms/src/ui/CurrencyInput.tsx b/packages/medusa-forms/src/ui/CurrencyInput.tsx index d23503c..9d19fdc 100644 --- a/packages/medusa-forms/src/ui/CurrencyInput.tsx +++ b/packages/medusa-forms/src/ui/CurrencyInput.tsx @@ -15,18 +15,7 @@ type CurrencyFieldShellProps = Omit & * silently corrupts high-precision decimals (IEEE-754). */ const CurrencyFieldShell = forwardRef( - ( - { - symbol, - code, - size = 'base', - disabled, - className, - onInvalid, - ...props - }, - ref, - ) => { + ({ symbol, code, size = 'base', disabled, className, onInvalid, ...props }, ref) => { return (
className="h-full min-w-0 flex-1 appearance-none bg-transparent text-right outline-none disabled:cursor-not-allowed" />