SwitchAssetInput.tsx
1 import { isAddress } from '@ethersproject/address'; 2 import { formatUnits } from '@ethersproject/units'; 3 import { ExclamationIcon } from '@heroicons/react/outline'; 4 import { XCircleIcon } from '@heroicons/react/solid'; 5 import { Trans } from '@lingui/macro'; 6 import { ExpandLess, ExpandMore } from '@mui/icons-material'; 7 import { 8 Box, 9 Button, 10 CircularProgress, 11 IconButton, 12 InputBase, 13 ListItemText, 14 Menu, 15 MenuItem, 16 SvgIcon, 17 Typography, 18 useTheme, 19 } from '@mui/material'; 20 import React, { useRef, useState } from 'react'; 21 import NumberFormat, { NumberFormatProps } from 'react-number-format'; 22 import { TokenInfoWithBalance } from 'src/hooks/generic/useTokensBalance'; 23 import { useRootStore } from 'src/store/root'; 24 import { useSharedDependencies } from 'src/ui-config/SharedDependenciesProvider'; 25 26 import { COMMON_SWAPS } from '../../../ui-config/TokenList'; 27 import { FormattedNumber } from '../../primitives/FormattedNumber'; 28 import { ExternalTokenIcon } from '../../primitives/TokenIcon'; 29 import { SearchInput } from '../../SearchInput'; 30 31 interface CustomProps { 32 onChange: (event: { target: { name: string; value: string } }) => void; 33 name: string; 34 value: string; 35 } 36 37 export const NumberFormatCustom = React.forwardRef<NumberFormatProps, CustomProps>( 38 function NumberFormatCustom(props, ref) { 39 const { onChange, ...other } = props; 40 41 return ( 42 <NumberFormat 43 {...other} 44 getInputRef={ref} 45 onValueChange={(values) => { 46 if (values.value !== props.value) 47 onChange({ 48 target: { 49 name: props.name, 50 value: values.value || '', 51 }, 52 }); 53 }} 54 thousandSeparator 55 isNumericString 56 allowNegative={false} 57 /> 58 ); 59 } 60 ); 61 62 export interface AssetInputProps { 63 value: string; 64 usdValue: string; 65 chainId: number; 66 onChange?: (value: string) => void; 67 disabled?: boolean; 68 disableInput?: boolean; 69 onSelect?: (asset: TokenInfoWithBalance) => void; 70 assets: TokenInfoWithBalance[]; 71 maxValue?: string; 72 isMaxSelected?: boolean; 73 loading?: boolean; 74 selectedAsset: TokenInfoWithBalance; 75 } 76 77 export const SwitchAssetInput = ({ 78 value, 79 usdValue, 80 onChange, 81 disabled, 82 disableInput, 83 onSelect, 84 assets, 85 maxValue, 86 isMaxSelected, 87 loading = false, 88 chainId, 89 selectedAsset, 90 }: AssetInputProps) => { 91 const theme = useTheme(); 92 const handleSelect = (asset: TokenInfoWithBalance) => { 93 onSelect && onSelect(asset); 94 onChange && onChange(''); 95 handleClose(); 96 }; 97 98 const { erc20Service } = useSharedDependencies(); 99 100 const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); 101 const inputRef = useRef<HTMLDivElement>(null); 102 const open = Boolean(anchorEl); 103 104 const handleClick = () => { 105 setAnchorEl(inputRef.current); 106 }; 107 const handleClose = () => { 108 setAnchorEl(null); 109 handleCleanSearch(); 110 }; 111 112 const [filteredAssets, setFilteredAssets] = useState(assets); 113 const [loadingNewAsset, setLoadingNewAsset] = useState(false); 114 const user = useRootStore((store) => store.account); 115 116 const popularAssets = assets.filter((asset) => COMMON_SWAPS.includes(asset.symbol)); 117 const handleSearchAssetChange = (value: string) => { 118 const searchQuery = value.trim().toLowerCase(); 119 const matchingAssets = assets.filter( 120 (asset) => 121 asset.symbol.toLowerCase().includes(searchQuery) || 122 asset.name.toLowerCase().includes(searchQuery) || 123 asset.address.toLowerCase() === searchQuery 124 ); 125 if (matchingAssets.length === 0 && isAddress(value)) { 126 setLoadingNewAsset(true); 127 Promise.all([ 128 erc20Service.getTokenInfo(value, chainId), 129 erc20Service.getBalance(value, user, chainId), 130 ]) 131 .then(([tokenMetadata, userBalance]) => { 132 const tokenInfo = { 133 chainId: chainId, 134 balance: formatUnits(userBalance, tokenMetadata.decimals), 135 extensions: { 136 isUserCustom: true, 137 }, 138 ...tokenMetadata, 139 }; 140 setFilteredAssets([tokenInfo]); 141 }) 142 .catch(() => setFilteredAssets([])) 143 .finally(() => setLoadingNewAsset(false)); 144 } else { 145 setFilteredAssets(matchingAssets); 146 } 147 }; 148 149 const handleCleanSearch = () => { 150 setFilteredAssets(assets); 151 setLoadingNewAsset(false); 152 }; 153 154 return ( 155 <Box 156 ref={inputRef} 157 sx={(theme) => ({ 158 p: '8px 12px', 159 border: `1px solid ${theme.palette.divider}`, 160 borderRadius: '6px', 161 width: '100%', 162 mb: 1, 163 })} 164 > 165 <Box sx={{ display: 'flex', alignItems: 'center', mb: 0.5 }}> 166 {loading ? ( 167 <Box sx={{ flex: 1 }}> 168 <CircularProgress color="inherit" size="16px" /> 169 </Box> 170 ) : ( 171 <InputBase 172 sx={{ flex: 1 }} 173 placeholder="0.00" 174 disabled={disabled || disableInput} 175 value={value} 176 autoFocus 177 onChange={(e) => { 178 if (!onChange) return; 179 if (Number(e.target.value) > Number(maxValue)) { 180 onChange('-1'); 181 } else { 182 onChange(e.target.value); 183 } 184 }} 185 inputProps={{ 186 'aria-label': 'amount input', 187 style: { 188 fontSize: '21px', 189 lineHeight: '28,01px', 190 padding: 0, 191 height: '28px', 192 textOverflow: 'ellipsis', 193 whiteSpace: 'nowrap', 194 overflow: 'hidden', 195 }, 196 }} 197 // eslint-disable-next-line 198 inputComponent={NumberFormatCustom as any} 199 /> 200 )} 201 {value !== '' && !disableInput && ( 202 <IconButton 203 sx={{ 204 minWidth: 0, 205 p: 0, 206 left: 8, 207 zIndex: 1, 208 color: 'text.muted', 209 '&:hover': { 210 color: 'text.secondary', 211 }, 212 }} 213 onClick={() => { 214 onChange && onChange(''); 215 }} 216 disabled={disabled} 217 > 218 <XCircleIcon height={16} /> 219 </IconButton> 220 )} 221 <Button 222 disableRipple 223 onClick={handleClick} 224 data-cy={`assetSelect`} 225 sx={{ p: 0, '&:hover': { backgroundColor: 'transparent' } }} 226 endIcon={open ? <ExpandLess /> : <ExpandMore />} 227 > 228 <ExternalTokenIcon 229 symbol={selectedAsset.symbol} 230 logoURI={selectedAsset.logoURI} 231 sx={{ mr: 2, ml: 3 }} 232 /> 233 <Typography 234 data-cy={`assetsSelectedOption_${selectedAsset.symbol.toUpperCase()}`} 235 variant="main16" 236 color="text.primary" 237 > 238 {selectedAsset.symbol} 239 </Typography> 240 {selectedAsset.extensions?.isUserCustom && ( 241 <SvgIcon sx={{ fontSize: 14, ml: 1 }} color="warning"> 242 <ExclamationIcon /> 243 </SvgIcon> 244 )} 245 </Button> 246 <Menu 247 anchorEl={anchorEl} 248 open={open} 249 onClose={handleClose} 250 PaperProps={{ 251 sx: { 252 width: inputRef.current?.offsetWidth, 253 border: theme.palette.mode === 'dark' ? '1px solid #EBEBED1F' : 'unset', 254 boxShadow: '0px 2px 10px 0px #0000001A', 255 overflow: 'hidden', 256 }, 257 }} 258 anchorOrigin={{ 259 vertical: 'bottom', 260 horizontal: 'right', 261 }} 262 transformOrigin={{ 263 vertical: 'top', 264 horizontal: 'right', 265 }} 266 > 267 <Box 268 sx={{ 269 p: 2, 270 px: 3, 271 borderBottom: `1px solid ${theme.palette.divider}`, 272 top: 0, 273 zIndex: 2, 274 }} 275 > 276 <SearchInput 277 onSearchTermChange={handleSearchAssetChange} 278 placeholder="Search name or paste address" 279 disableFocus={true} 280 /> 281 <Box 282 sx={{ 283 display: 'flex', 284 justifyContent: 'flex-start', 285 overfloyY: 'auto', 286 alignItems: 'flex-start', 287 flexWrap: 'wrap', 288 mt: 2, 289 gap: 2, 290 }} 291 > 292 {popularAssets.map((asset) => ( 293 <Box 294 key={asset.symbol} 295 sx={{ 296 display: 'flex', 297 flexDirection: 'row', 298 alignItems: 'center', 299 p: 1, 300 borderRadius: '16px', 301 border: '1px solid', 302 borderColor: theme.palette.divider, 303 cursor: 'pointer', 304 '&:hover': { 305 backgroundColor: theme.palette.divider, 306 }, 307 }} 308 onClick={() => handleSelect(asset)} 309 > 310 <ExternalTokenIcon 311 logoURI={asset.logoURI} 312 symbol={asset.symbol} 313 sx={{ width: 24, height: 24, mr: 1 }} 314 /> 315 <Typography variant="main14" color="text.primary" sx={{ mr: 1 }}> 316 {asset.symbol} 317 </Typography> 318 </Box> 319 ))} 320 </Box> 321 </Box> 322 <Box sx={{ overflow: 'auto', maxHeight: '200px' }}> 323 {loadingNewAsset ? ( 324 <Box 325 sx={{ 326 maxHeight: '178px', 327 overflowY: 'auto', 328 display: 'flex', 329 flexDirection: 'column', 330 minHeight: '60px', 331 }} 332 > 333 <CircularProgress sx={{ mx: 'auto', my: 'auto' }} /> 334 </Box> 335 ) : filteredAssets.length > 0 ? ( 336 filteredAssets.map((asset) => ( 337 <MenuItem 338 key={asset.symbol} 339 value={asset.symbol} 340 data-cy={`assetsSelectOption_${asset.symbol.toUpperCase()}`} 341 sx={{ 342 backgroundColor: theme.palette.background.paper, 343 }} 344 onClick={() => handleSelect(asset)} 345 > 346 <ExternalTokenIcon symbol={asset.symbol} logoURI={asset.logoURI} sx={{ mr: 2 }} /> 347 <ListItemText sx={{ flexGrow: 0 }}>{asset.symbol}</ListItemText> 348 {asset.extensions?.isUserCustom && ( 349 <SvgIcon sx={{ fontSize: 14, ml: 1 }} color="warning"> 350 <ExclamationIcon /> 351 </SvgIcon> 352 )} 353 {asset.balance && ( 354 <FormattedNumber sx={{ ml: 'auto' }} value={asset.balance} compact /> 355 )} 356 </MenuItem> 357 )) 358 ) : ( 359 <Typography 360 variant="main14" 361 color="text.primary" 362 sx={{ width: 'auto', textAlign: 'center', m: 4 }} 363 > 364 <Trans> 365 No results found. You can import a custom token with a contract address 366 </Trans> 367 </Typography> 368 )} 369 </Box> 370 </Menu> 371 </Box> 372 373 <Box sx={{ display: 'flex', alignItems: 'center', height: '16px' }}> 374 {loading ? ( 375 <Box sx={{ flex: 1 }} /> 376 ) : ( 377 <FormattedNumber 378 value={isNaN(Number(usdValue)) ? 0 : Number(usdValue)} 379 compact 380 symbol="USD" 381 variant="secondary12" 382 color="text.muted" 383 symbolsColor="text.muted" 384 flexGrow={1} 385 /> 386 )} 387 388 {selectedAsset.balance && onChange && ( 389 <> 390 <Typography component="div" variant="secondary12" color="text.secondary"> 391 <Trans>Balance</Trans> 392 <FormattedNumber 393 value={selectedAsset.balance} 394 compact 395 variant="secondary12" 396 color="text.secondary" 397 symbolsColor="text.disabled" 398 sx={{ ml: 1 }} 399 /> 400 </Typography> 401 {!disableInput && ( 402 <Button 403 size="small" 404 sx={{ minWidth: 0, ml: '7px', p: 0 }} 405 onClick={() => { 406 onChange('-1'); 407 }} 408 disabled={disabled || isMaxSelected} 409 > 410 <Trans>Max</Trans> 411 </Button> 412 )} 413 </> 414 )} 415 </Box> 416 </Box> 417 ); 418 };