Skip to content

Commit

Permalink
feat: numericalinput allow decimals typing
Browse files Browse the repository at this point in the history
  • Loading branch information
fairlighteth committed Jan 2, 2025
1 parent abb823f commit 169ba62
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -151,7 +162,7 @@ export function CurrencyInputPanel(props: CurrencyInputPanelProps) {
<styledEl.NumericalInput
className="token-amount-input"
prependSymbol={isUsdValuesMode ? '$' : ''}
value={isChainIdUnsupported ? '' : isUsdValuesMode ? viewAmount : typedValue}
value={isChainIdUnsupported ? '' : typedValue}
readOnly={inputDisabled}
onUserInput={onUserInputDispatch}
$loading={areCurrenciesLoading}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import React from 'react'

import { escapeRegExp } from '@cowprotocol/common-utils'
import { UI } from '@cowprotocol/ui'

import styled, { css } from 'styled-components/macro'
Expand Down Expand Up @@ -55,15 +54,15 @@ const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: s
}
`

const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group
// Allow decimal point at any position, including at the end
const inputRegex = /^(\d*\.?\d*)?$/

export const Input = React.memo(function InnerInput({
value,
readOnly,
onUserInput,
placeholder,
prependSymbol,
type,
onFocus,
...rest
}: {
Expand All @@ -75,9 +74,38 @@ export const Input = React.memo(function InnerInput({
align?: 'right' | 'left'
prependSymbol?: string | undefined
} & Omit<React.HTMLProps<HTMLInputElement>, '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<HTMLInputElement>) => {
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)
}
}

Expand All @@ -90,43 +118,40 @@ export const Input = React.memo(function InnerInput({
)}
<StyledInput
{...rest}
value={value}
value={stringValue}
readOnly={readOnly}
onFocus={(event) => {
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"
/>
</>
)
})

export default Input

// const inputRegex = RegExp(`^\\d*(?:\\\\[.])?\\d*$`) // match escaped "." characters via in a non-capturing group
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down

0 comments on commit 169ba62

Please sign in to comment.