diff --git a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx index 5074642baa..3c45a3a833 100644 --- a/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx +++ b/apps/cowswap-frontend/src/common/pure/CurrencyInputPanel/CurrencyInputPanel.tsx @@ -107,13 +107,20 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { const onUserInputDispatch = useCallback( (typedValue: string) => { - const value = convertUsdToTokenValue(typedValue, isUsdValuesMode) + // Always pass through empty string to allow clearing + if (typedValue === '') { + setTypedValue('') + onUserInput(field, '') + return + } - setTypedValue(value) + const value = convertUsdToTokenValue(typedValue, isUsdValuesMode) + setTypedValue(typedValue) onUserInput(field, value) }, - [onUserInput, field, viewAmount, convertUsdToTokenValue, isUsdValuesMode], + [onUserInput, field, convertUsdToTokenValue, isUsdValuesMode], ) + const handleMaxInput = useCallback(() => { if (!maxBalance) { return @@ -125,16 +132,20 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { onUserInputDispatch(value.toExact()) setMaxSellTokensAnalytics() } - }, [maxBalance, onUserInputDispatch, convertUsdToTokenValue, isUsdValuesMode, maxBalanceUsdAmount]) + }, [maxBalance, onUserInputDispatch, isUsdValuesMode, maxBalanceUsdAmount]) useEffect(() => { - const areValuesSame = parseFloat(viewAmount) === parseFloat(typedValue) + // Compare the actual string values to preserve trailing decimals + if (viewAmount === typedValue) return + + // Don't override empty input + if (viewAmount === '' && typedValue === '') return - // Don't override typedValue when, for example: viewAmount = 5 and typedValue = 5. - if (areValuesSame) return + // Don't override when typing a decimal + if (typedValue.endsWith('.')) return - // Don't override typedValue, when viewAmount from props and typedValue are zero (0 or 0. or 0.000) - if (!viewAmount && (!typedValue || parseFloat(typedValue) === 0)) return + // Don't override when the values are numerically equal (e.g., "5." and "5") + if (parseFloat(viewAmount || '0') === parseFloat(typedValue || '0')) return setTypedValue(viewAmount) // We don't need triggering from typedValue changes @@ -151,7 +162,7 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) { , 'ref' | 'onChange' | 'as'>) { + // Keep the input strictly as a string + const stringValue = typeof value === 'string' ? value : String(value) + const enforcer = (nextUserInput: string) => { - if (nextUserInput === '' || inputRegex.test(escapeRegExp(nextUserInput))) { - onUserInput(nextUserInput) + // Always allow empty input + if (nextUserInput === '') { + onUserInput('') + return + } + + // Convert commas to dots + const sanitizedValue = nextUserInput.replace(/,/g, '.') + + // Allow the value if it matches our number format + if (inputRegex.test(sanitizedValue)) { + onUserInput(sanitizedValue) + } + } + + const handlePaste = (event: React.ClipboardEvent) => { + const pastedText = event.clipboardData.getData('text') + + // Clean up pasted content - only allow numbers and single decimal + const cleanedText = pastedText + .replace(/,/g, '.') // Convert commas to dots + .replace(/[^\d.]/g, '') // Remove non-numeric/dot chars + .replace(/(\..*)\./g, '$1') // Keep only the first decimal point + .replace(/\.$/, '') // Remove trailing decimal point + + if (inputRegex.test(cleanedText)) { + event.preventDefault() + enforcer(cleanedText) } } @@ -90,37 +118,36 @@ export const Input = React.memo(function InnerInput({ )} { autofocus(event) onFocus?.(event) }} onChange={(event) => { - if (prependSymbol) { - const value = event.target.value + const rawValue = event.target.value - // cut off prepended symbol - const formattedValue = value.toString().includes(prependSymbol) - ? value.toString().slice(1, value.toString().length + 1) - : value - - // replace commas with periods, because uniswap exclusively uses period as the decimal separator - enforcer(formattedValue.replace(/,/g, '.')) + if (prependSymbol) { + // Remove prepended symbol if it appears in rawValue + const formattedValue = rawValue.includes(prependSymbol) ? rawValue.slice(prependSymbol.length) : rawValue + enforcer(formattedValue) } else { - enforcer(event.target.value.replace(/,/g, '.')) + enforcer(rawValue) } }} - // universal input options + onPaste={handlePaste} + // Use text inputMode so decimals can be typed inputMode="decimal" autoComplete="off" autoCorrect="off" - // text-specific options - type={type || 'text'} - pattern="^[0-9]*[.,]?[0-9]*$" - placeholder={placeholder || '0.0'} - minLength={1} - maxLength={32} + // Keep type="text" to preserve trailing decimals + type="text" + // Remove pattern to prevent browser validation interference + pattern="" + placeholder={placeholder || '0'} + // minLength to 0 so empty strings are always valid + minLength={0} + maxLength={79} spellCheck="false" /> @@ -128,5 +155,3 @@ export const Input = React.memo(function InnerInput({ }) export default Input - -// const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group diff --git a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx index e68bdaab89..249fd8fdb4 100644 --- a/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx +++ b/apps/cowswap-frontend/src/modules/limitOrders/containers/RateInput/index.tsx @@ -74,21 +74,52 @@ export function RateInput() { // Handle rate input const handleUserInput = useCallback( (typedValue: string) => { + // Always pass through empty string to allow clearing + if (typedValue === '') { + setTypedTrailingZeros('') + updateLimitRateState({ typedValue: '' }) + updateRate({ + activeRate: null, + isTypedValue: true, + isRateFromUrl: false, + isAlternativeOrderRate: false, + }) + return + } + + // Keep the trailing decimal point or zeros const trailing = typedValue.slice(displayedRate.length) + const hasTrailingDecimal = typedValue.endsWith('.') const onlyTrailingZeroAdded = typedValue.includes('.') && /^0+$/.test(trailing) /** - * Since we convert USD to token value, we need to handle trailing zeros separately, otherwise we will lose them + * Since we convert USD to token value, we need to handle trailing zeros and decimal points separately. + * If we don't, they will be lost during the conversion between USD and token values. */ - if (onlyTrailingZeroAdded) { + if (hasTrailingDecimal || onlyTrailingZeroAdded) { setTypedTrailingZeros(trailing) + + // For trailing decimal, we also need to update the base value + if (hasTrailingDecimal && !onlyTrailingZeroAdded) { + const baseValue = typedValue.slice(0, -1) // Remove the trailing decimal for conversion + const value = convertUsdToTokenValue(baseValue, isUsdRateMode) + updateLimitRateState({ typedValue: value }) + updateRate({ + activeRate: toFraction(value, isInverted), + isTypedValue: true, + isRateFromUrl: false, + isAlternativeOrderRate: false, + }) + } return } setTypedTrailingZeros('') + // Convert to token value if in USD mode const value = convertUsdToTokenValue(typedValue, isUsdRateMode) + // Update the rate state with the new value updateLimitRateState({ typedValue: value }) updateRate({ activeRate: toFraction(value, isInverted),