Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/limit UI upgrade 2 #5267

Draft
wants to merge 4 commits into
base: feat/limit-ui-upgrade
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand Up @@ -48,7 +48,6 @@ export const CurrencyInputBox = styled.div`

> div {
display: flex;
flex-flow: row wrap;
align-items: center;
color: inherit;
}
Expand Down
140 changes: 92 additions & 48 deletions apps/cowswap-frontend/src/legacy/components/NumericalInput/index.tsx
Original file line number Diff line number Diff line change
@@ -1,29 +1,40 @@
import React from 'react'

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

import styled from 'styled-components/macro'
import styled, { css } from 'styled-components/macro'

import { autofocus } from 'common/utils/autofocus'

const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: string }>`
const textStyle = css<{ error?: boolean; fontSize?: string }>`
color: ${({ error }) => (error ? `var(${UI.COLOR_DANGER})` : 'inherit')};
font-size: ${({ fontSize }) => fontSize ?? '28px'};
font-weight: 500;
`

const PrependSymbol = styled.span<{ error?: boolean; fontSize?: string }>`
display: flex;
align-items: center;
justify-content: center;
height: 100%;
user-select: none;
${textStyle}
`

const StyledInput = styled.input<{ error?: boolean; fontSize?: string; align?: string }>`
${textStyle}
width: 0;
position: relative;
font-weight: 500;
outline: none;
border: none;
flex: 1 1 auto;
background-color: var(${UI.COLOR_PAPER});
font-size: ${({ fontSize }) => fontSize ?? '28px'};
text-align: ${({ align }) => align && align};
text-align: ${({ align }) => align || 'right'};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0px;
appearance: textfield;
text-align: right;

::-webkit-search-decoration {
-webkit-appearance: none;
Expand All @@ -43,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 @@ -63,51 +74,84 @@ 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)
}
}

return (
<StyledInput
{...rest}
value={prependSymbol && value ? prependSymbol + value : value}
readOnly={readOnly}
onFocus={(event) => {
autofocus(event)
onFocus?.(event)
}}
onChange={(event) => {
if (prependSymbol) {
const value = 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, '.'))
} else {
enforcer(event.target.value.replace(/,/g, '.'))
}
}}
// universal input options
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}
spellCheck="false"
/>
<>
{prependSymbol && (
<PrependSymbol error={rest.error} fontSize={rest.fontSize}>
{prependSymbol}
</PrependSymbol>
)}
<StyledInput
{...rest}
value={stringValue}
readOnly={readOnly}
onFocus={(event) => {
autofocus(event)
onFocus?.(event)
}}
onChange={(event) => {
const rawValue = event.target.value

if (prependSymbol) {
// Remove prepended symbol if it appears in rawValue
const formattedValue = rawValue.includes(prependSymbol) ? rawValue.slice(prependSymbol.length) : rawValue
enforcer(formattedValue)
} else {
enforcer(rawValue)
}
}}
onPaste={handlePaste}
// Use text inputMode so decimals can be typed
inputMode="decimal"
autoComplete="off"
autoCorrect="off"
// 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
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export const Body = styled.div`
justify-content: space-between;
width: 100%;
max-width: 100%;
gap: 8px;
gap: 0;
padding: 12px 0 4px;
color: inherit;
`
Expand All @@ -109,6 +109,7 @@ export const CurrencyToggleGroup = styled.div`
align-items: center;
background: transparent;
overflow: hidden;
margin: 0 0 0 8px;
`

export const ActiveCurrency = styled.button<{ $active?: boolean }>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,15 @@ const COLUMN_LAYOUT_LABELS = {
}

export function Settings({ state, onStateChanged }: SettingsProps) {
const { showRecipient, partialFillsEnabled, limitPricePosition, limitPriceLocked, columnLayout, ordersTableOnLeft } =
state
const {
showRecipient,
partialFillsEnabled,
limitPricePosition,
limitPriceLocked,
columnLayout,
ordersTableOnLeft,
isUsdValuesMode,
} = state
const [isOpen, setIsOpen] = useState(false)
const [isColumnLayoutOpen, setIsColumnLayoutOpen] = useState(false)

Expand Down Expand Up @@ -182,6 +189,13 @@ export function Settings({ state, onStateChanged }: SettingsProps) {
toggle={() => onStateChanged({ limitPriceLocked: !limitPriceLocked })}
/>

<SettingsBox
title="Global USD Mode"
tooltip="When enabled, all prices will be displayed in USD by default."
value={isUsdValuesMode}
toggle={() => onStateChanged({ isUsdValuesMode: !isUsdValuesMode })}
/>

<SettingsBox
title={ORDERS_TABLE_SETTINGS.LEFT_ALIGNED.title}
tooltip={ORDERS_TABLE_SETTINGS.LEFT_ALIGNED.tooltip}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,13 @@ export function OrdersTableWidget({
}, [ordersList, currentTabId])

const tabs = useMemo(() => {
return ORDERS_TABLE_TABS.map((tab) => {
return ORDERS_TABLE_TABS.filter((tab) => {
// Only include the unfillable tab if there are unfillable orders
if (tab.id === 'unfillable') {
return getOrdersListByIndex(ordersList, tab.id).length > 0
}
return true
}).map((tab) => {
return { ...tab, isActive: tab.id === currentTabId, count: getOrdersListByIndex(ordersList, tab.id).length }
})
}, [currentTabId, ordersList])
Expand Down
Loading