diff --git a/src/core/utils/__test__/numbers.test.ts b/src/core/utils/__test__/numbers.test.ts new file mode 100644 index 0000000000..1505472349 --- /dev/null +++ b/src/core/utils/__test__/numbers.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from 'vitest'; + +import { isExceedingMaxCharacters, truncateNumber } from '../numbers'; + +describe('numbers', () => { + describe('truncateNumber', () => { + test('should return maximum 10 characters from amount', () => { + const numbers = [ + { + input: 123456789.1234567, + output: '123456789.1', + }, + { + input: 123456.123456789, + output: '123456.1234', + }, + { + input: 1.111111111111111, + output: '1.111111111', + }, + { + input: 33.33333333333333, + output: '33.33333333', + }, + { + input: 10000000000000000, + output: '1000000000', + }, + { + input: 9000000000000000000, + output: '9000000000', + }, + ]; + + for (const { input, output } of numbers) { + // Check both number and string number + expect(truncateNumber(input)).toBe(output); + expect(truncateNumber(input.toString())).toBe(output); + } + }); + + test('should return existing number if not exceeding 10 character limit', () => { + const numbers = [ + { + input: 33.33, + output: '33.33', + }, + { + input: 20.22, + output: '20.22', + }, + { + input: 45555.9999, + output: '45555.9999', + }, + { + input: 2000.0, + output: '2000', + }, + { + input: 3999.999, + output: '3999.999', + }, + { + input: 123456789.123, + output: '123456789.1', + }, + ]; + + for (const { input, output } of numbers) { + // Check both number and string number + expect(truncateNumber(input)).toBe(output); + expect(truncateNumber(input.toString())).toBe(output); + } + }); + + test('should handle decimal points', () => { + expect(truncateNumber('33.')).toBe('33.'); + expect(truncateNumber('333.333')).toBe('333.333'); + expect(truncateNumber('.')).toBe('0.'); + }); + + test('should handle empty string', () => { + expect(truncateNumber('')).toBe(''); + }); + }); + + describe('isExceedingMaxCharacters', () => { + test('should return false if max characters exceeded', () => { + const MAX_CHARS = 5; + + expect(isExceedingMaxCharacters('1.123', MAX_CHARS)).toBe(false); + expect(isExceedingMaxCharacters('12.12', MAX_CHARS)).toBe(false); + expect(isExceedingMaxCharacters('123.1', MAX_CHARS)).toBe(false); + }); + + test('should return true if max characters exceeded', () => { + const MAX_CHARS = 6; + + expect(isExceedingMaxCharacters('123.4567', MAX_CHARS)).toBe(true); + expect(isExceedingMaxCharacters('1234567', MAX_CHARS)).toBe(true); + expect(isExceedingMaxCharacters('12345.67', MAX_CHARS)).toBe(true); + }); + }); +}); diff --git a/src/core/utils/numbers.ts b/src/core/utils/numbers.ts index 282a470d84..8d4a91671b 100644 --- a/src/core/utils/numbers.ts +++ b/src/core/utils/numbers.ts @@ -4,6 +4,7 @@ import currency from 'currency.js'; import { isNil } from 'lodash'; import { supportedCurrencies } from '~/core/references'; +import { maskInput } from '~/entries/popup/components/InputMask/utils'; import { formatCurrency } from './formatNumber'; import { BigNumberish } from './hex'; @@ -503,3 +504,30 @@ export const processExchangeRateArray = (arr: string[]): string[] => { return item; }); }; + +export const truncateNumber = (n: string | number, maxChars = 10): string => { + const value = typeof n === 'number' ? n.toString() : n; + + if (!value) return ''; + + const parts = value.replace(/,/g, '').split('.'); + const integers = parts[0] || ''; + + if (integers.length > maxChars) { + return maskInput({ + inputValue: value, + decimals: 0, + integers: maxChars, + }); + } + + return maskInput({ + inputValue: value, + decimals: maxChars - integers.length, + integers: maxChars, + }); +}; + +export const isExceedingMaxCharacters = (value: string, maxChars: number) => { + return value.replace('.', '').length > maxChars; +}; diff --git a/src/entries/popup/components/Tooltip/CursorTooltip.tsx b/src/entries/popup/components/Tooltip/CursorTooltip.tsx index 6c62f07210..6b212b35dd 100644 --- a/src/entries/popup/components/Tooltip/CursorTooltip.tsx +++ b/src/entries/popup/components/Tooltip/CursorTooltip.tsx @@ -30,6 +30,7 @@ export const CursorTooltip = ({ textWeight, textSize, textColor, + hideTooltipOnMouseDown, children, hint, }: { @@ -42,6 +43,7 @@ export const CursorTooltip = ({ textSize?: TextStyles['fontSize']; textWeight?: TextStyles['fontWeight']; textColor?: TextStyles['color']; + hideTooltipOnMouseDown?: boolean; hint?: string; }) => { const [open, setOpen] = useState(false); @@ -117,6 +119,13 @@ export const CursorTooltip = ({ setOpen(false); showTimer.current && clearTimeout(showTimer.current); }} + onMouseDown={() => { + if (hideTooltipOnMouseDown) { + setIsHovering(false); + setOpen(false); + showTimer.current && clearTimeout(showTimer.current); + } + }} > {children} diff --git a/src/entries/popup/hooks/swap/useSwapInputs.ts b/src/entries/popup/hooks/swap/useSwapInputs.ts index 6ff60b7123..d4bf1b8b53 100644 --- a/src/entries/popup/hooks/swap/useSwapInputs.ts +++ b/src/entries/popup/hooks/swap/useSwapInputs.ts @@ -12,13 +12,19 @@ import { convertAmountToRawAmount, convertRawAmountToBalance, handleSignificantDecimals, + isExceedingMaxCharacters, lessThan, minus, + truncateNumber, } from '~/core/utils/numbers'; import { isLowerCaseMatch } from '~/core/utils/strings'; import { TokenInputRef } from '../../pages/swap/SwapTokenInput/TokenInput'; +// The maximum number of characters for the input field. +// This does not include the decimal point. +const MAX_INPUT_CHARACTERS = 11; + const focusOnInput = (inputRef: React.RefObject) => { setTimeout(() => { inputRef?.current?.focus(); @@ -55,9 +61,22 @@ export const useSwapInputs = ({ const [assetToBuyDropdownClosed, setAssetToBuyDropdownClosed] = useState( inputToOpenOnMount !== 'buy', ); - const [assetToSellValue, setAssetToSellValue] = useState(''); + const [assetToSellValue, setAssetToSellValueState] = useState(''); + const [assetToBuyValue, setAssetToBuyValueState] = useState(''); const [assetToSellNativeValue, setAssetToSellNativeValue] = useState(''); - const [assetToBuyValue, setAssetToBuyValue] = useState(''); + + // Rounded input values (maximum 12 characters including decimal point) + const [assetToSellValueRounded, setAssetToSellValueRounded] = useState(''); + const [assetToBuyValueRounded, setAssetToBuyValueRounded] = useState(''); + + const maxCharacters = useMemo( + () => + (assetToSell?.symbol?.length || 0) > 3 + ? // In case an asset symbol is 4 characters or more (e.g WETH) + MAX_INPUT_CHARACTERS - 1 + : MAX_INPUT_CHARACTERS, + [assetToSell], + ); const { saveSwapAmount, @@ -85,6 +104,22 @@ export const useSwapInputs = ({ [assetToBuy, assetToSell, saveSwapField], ); + const setAssetToSellValue = useCallback( + (value: string) => { + setAssetToSellValueRounded(truncateNumber(value, maxCharacters)); + setAssetToSellValueState(value); + }, + [maxCharacters], + ); + + const setAssetToBuyValue = useCallback( + (value: string) => { + setAssetToBuyValueRounded(truncateNumber(value, maxCharacters)); + setAssetToBuyValueState(value); + }, + [maxCharacters], + ); + useEffect(() => { if (savedSwapField) { setIndependentField(savedSwapField); @@ -93,21 +128,30 @@ export const useSwapInputs = ({ }, []); const setAssetToSellInputValue = useCallback( - (value: string) => { + (value: string, isInput = true) => { setAssetToSellDropdownClosed(true); - saveSwapAmount({ amount: value }); - setAssetToSellValue(value); setIndependentFieldIfOccupied('sellField'); - setIndependentValue(value); + let inputValue = value; + if (isInput && isExceedingMaxCharacters(value, maxCharacters)) { + inputValue = truncateNumber(value, maxCharacters); + } + saveSwapAmount({ amount: inputValue }); + setAssetToSellValue(inputValue); + setIndependentValue(inputValue); }, - [saveSwapAmount, setIndependentFieldIfOccupied], + [ + maxCharacters, + saveSwapAmount, + setAssetToSellValue, + setIndependentFieldIfOccupied, + ], ); const setAssetToSellInputNativeValue = useCallback( (value: string) => { setAssetToSellDropdownClosed(true); - setAssetToSellNativeValue(value); setIndependentFieldIfOccupied('sellNativeField'); + setAssetToSellNativeValue(value); setIndependentValue(value); setAssetToSellValue( value @@ -124,19 +168,29 @@ export const useSwapInputs = ({ assetToSell?.decimals, assetToSell?.price?.value, saveSwapAmount, + setAssetToSellValue, setIndependentFieldIfOccupied, ], ); const setAssetToBuyInputValue = useCallback( - (value: string) => { + (value: string, isInput = true) => { setAssetToBuyDropdownClosed(true); - setAssetToBuyValue(value); setIndependentFieldIfOccupied('buyField'); - setIndependentValue(value); - saveSwapAmount({ amount: value }); + let inputValue = value; + if (isInput && isExceedingMaxCharacters(value, maxCharacters)) { + inputValue = truncateNumber(value, maxCharacters); + } + setAssetToBuyValue(inputValue); + setIndependentValue(inputValue); + saveSwapAmount({ amount: inputValue }); }, - [saveSwapAmount, setIndependentFieldIfOccupied], + [ + maxCharacters, + saveSwapAmount, + setAssetToBuyValue, + setIndependentFieldIfOccupied, + ], ); const onAssetToSellInputOpen = useCallback( @@ -185,6 +239,7 @@ export const useSwapInputs = ({ setIndependentFieldIfOccupied('sellField'); }, [ assetToSellMaxValue.amount, + setAssetToSellValue, saveSwapAmount, setIndependentFieldIfOccupied, ]); @@ -226,26 +281,28 @@ export const useSwapInputs = ({ setAssetToSellDropdownClosed(true); setAssetToBuyDropdownClosed(true); }, [ - assetToBuy, - assetToBuyValue, + bridge, assetToSell, - assetToSellValue, + assetToBuy, independentField, - independentValue, - saveSwapField, setAssetToBuy, setAssetToSell, - setIndependentField, - bridge, + setAssetToBuyValue, + setAssetToSellValue, + assetToBuyValue, + saveSwapField, + independentValue, + assetToSellValue, ]); - const assetToSellDisplay = useMemo( - () => + const assetToSellDisplay = useMemo(() => { + const amount = independentField === 'buyField' ? assetToSellValue && handleSignificantDecimals(assetToSellValue, 5) - : assetToSellValue, - [assetToSellValue, independentField], - ); + : assetToSellValue; + + return { amount, display: truncateNumber(amount, maxCharacters) }; + }, [maxCharacters, assetToSellValue, independentField]); const assetToBuyDisplay = useMemo( () => @@ -333,6 +390,8 @@ export const useSwapInputs = ({ assetToSellDisplay, assetToBuyDisplay, assetToSellDropdownClosed, + assetToSellValueRounded, + assetToBuyValueRounded, assetToBuyDropdownClosed, independentField, flipAssets, diff --git a/src/entries/popup/pages/swap/SwapTokenInput/TokenInput.tsx b/src/entries/popup/pages/swap/SwapTokenInput/TokenInput.tsx index d5f6c23eaa..ed44f975c9 100644 --- a/src/entries/popup/pages/swap/SwapTokenInput/TokenInput.tsx +++ b/src/entries/popup/pages/swap/SwapTokenInput/TokenInput.tsx @@ -22,28 +22,47 @@ import { SwapInputActionButton } from '../SwapInputActionButton'; const SwapInputMaskWrapper = ({ inputDisabled, + value, + symbol, children, }: { inputDisabled?: boolean; + value?: string; + symbol?: string; children: ReactElement; }) => { - return inputDisabled ? ( + if (inputDisabled) { + return ( + + {children} + + ); + } + + return ( {children} - ) : ( - children ); }; interface TokenInputProps { accentCaretColor?: boolean; asset: ParsedSearchAsset | null; + assetTooltipValue?: string; assetFilter: string; dropdownHeight?: number; dropdownComponent: ReactElement; @@ -74,6 +93,7 @@ export const TokenInput = React.forwardRef< { accentCaretColor, asset, + assetTooltipValue, assetFilter, dropdownHeight, dropdownComponent, @@ -97,6 +117,7 @@ export const TokenInput = React.forwardRef< forwardedRef, ) { const [dropdownVisible, setDropdownVisible] = useState(false); + const prevDropdownVisible = usePrevious(dropdownVisible); useImperativeHandle(forwardedRef, () => ({ @@ -191,7 +212,11 @@ export const TokenInput = React.forwardRef< ) : ( - +