diff --git a/apps/bridge/package.json b/apps/bridge/package.json index a59f98a3..2e56c9ba 100644 --- a/apps/bridge/package.json +++ b/apps/bridge/package.json @@ -25,6 +25,7 @@ "ethers": "^5.7.2", "framer-motion": "^10.12.10", "lodash": "^4.17.21", + "lodash.debounce": "^4.0.8", "mongodb": "^5.6.0", "next": "13.3.0", "react": "18.2.0", @@ -41,6 +42,7 @@ "@mantle/tsconfig": "workspace:*", "@types/ethereum-block-by-date": "^1.4.1", "@types/lodash": "^4.14.194", + "@types/lodash.debounce": "^4.0.7", "@types/node": "18.11.3", "@types/react": "18.0.26", "@types/react-dom": "18.0.10", diff --git a/apps/bridge/src/components/bridge/TokenSelect.tsx b/apps/bridge/src/components/bridge/TokenSelect.tsx index c12476b7..db2b89b5 100644 --- a/apps/bridge/src/components/bridge/TokenSelect.tsx +++ b/apps/bridge/src/components/bridge/TokenSelect.tsx @@ -1,14 +1,12 @@ /* eslint-disable react/require-default-props */ -import { Fragment, useContext, useEffect, useMemo, useState } from "react"; -import { Listbox, Transition } from "@headlessui/react"; +import { useContext, useEffect, useMemo, useState } from "react"; import { HiChevronDown } from "react-icons/hi"; import { SiEthereum } from "react-icons/si"; - +import { Dialog } from "@headlessui/react"; +import debounce from "lodash.debounce"; import StateContext from "@providers/stateContext"; -import clsx from "clsx"; import Image from "next/image"; - import { Token, Direction, @@ -18,10 +16,13 @@ import { } from "@config/constants"; import { formatUnits, parseUnits } from "ethers/lib/utils.js"; import { formatBigNumberString, localeZero } from "@mantle/utils"; -import { Button } from "@mantle/ui"; +import { Button, Typography, DividerCaret } from "@mantle/ui"; import DirectionLabel from "@components/bridge/utils/DirectionLabel"; import { MantleLogo } from "@components/bridge/utils/MantleLogo"; import KindReminder from "@components/bridge/utils/KindReminder"; +import { searchTokensByNameAndSymbol } from "@utils/searchTokens"; + +const POPULAR_TOKEN_SYMBOLS = ["ETH", "MNT", "USDT"]; export default function TokenSelect({ direction: givenDirection, @@ -59,6 +60,51 @@ export default function TokenSelect({ // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => setSelected(givenSelected), [givenSelected]); + // control if token selection dialog opens + const [isOpen, setIsOpen] = useState(false); + + // get popular token info from token list + const [popularTokenMap, setPopularTokenMap] = useState<{ + [key in string]: Token; + }>({}); + + useEffect(() => { + if (Object.values(popularTokenMap).length < 1) { + const mapping: { [key in string]: Token } = {}; + const popularTokens = tokens.filter((t) => + POPULAR_TOKEN_SYMBOLS.includes(t.symbol) + ); + popularTokens.forEach((t) => { + mapping[t.symbol] = t; + }); + setPopularTokenMap(mapping); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tokens]); + + const [searchResult, setSearchResult] = useState([]); + + const selectBtnClicked = () => { + setIsOpen(true); + // move selected token to the front of the token list + const selectedIndex = tokens.findIndex((t) => t.symbol === selected.symbol); + if (selectedIndex !== -1) { + const reorderedTokens = [ + tokens[selectedIndex], + ...tokens.slice(0, selectedIndex), + ...tokens.slice(selectedIndex + 1), + ]; + setSearchResult(reorderedTokens); + } else { + setSearchResult(tokens); + } + }; + + const handleSearch = debounce((e: { target: { value: any } }) => { + const searched = searchTokensByNameAndSymbol(tokens, e.target.value); + setSearchResult(searched); + }, 300); + return (
- { - setSelectedToken(direction, selection.name); - }} +
- {({ open }) => ( -
-
- { - let amount = e.currentTarget.value; - const amountExpo = e.currentTarget.value +
+ { + let amount = e.currentTarget.value; + const amountExpo = e.currentTarget.value + .replace(/[^0-9.]/g, "") + .replace(/(?<=(.*\..*))\./gm, "") + .split(".")[1]; + + try { + if (amount) { + // clean the amount string of non nums + amount = amount .replace(/[^0-9.]/g, "") - .replace(/(?<=(.*\..*))\./gm, "") - .split(".")[1]; + .replace(/(?<=(.*\..*))\./gm, ""); - try { - if (amount) { - // clean the amount string of non nums - amount = amount - .replace(/[^0-9.]/g, "") - .replace(/(?<=(.*\..*))\./gm, ""); + // if the decimals exceed 18dps we need to lose any additional digits + const amounts = amount.split("."); + if ( + (amounts?.[1] || "")?.length >= (selected?.decimals || 18) + ) { + // lock to tokens decimals + amounts[1] = amounts[1].substring( + 0, + selected?.decimals || 18 + ); + amount = amounts.join("."); + } - // if the decimals exceed 18dps we need to lose any additional digits - const amounts = amount.split("."); - if ( - (amounts?.[1] || "")?.length >= - (selected?.decimals || 18) - ) { - // lock to tokens decimals - amounts[1] = amounts[1].substring( - 0, - selected?.decimals || 18 - ); - amount = amounts.join("."); - } + // set max at a visibly acceptable level + const max = "9999999999999999999999999999999999999999999"; // constants.MaxUint256 - // set max at a visibly acceptable level - const max = "9999999999999999999999999999999999999999999"; // constants.MaxUint256 + // fix the number to no greater than constants.MaxUint256 (with tokens decimals parsed) + const bnAmount = parseUnits( + amount || "0", + selected?.decimals || 18 + ).gt(parseUnits(max, selected?.decimals || 18)) + ? parseUnits(max, selected?.decimals || 18) + : parseUnits(amount || "0", selected?.decimals || 18); - // fix the number to no greater than constants.MaxUint256 (with tokens decimals parsed) - const bnAmount = parseUnits( - amount || "0", - selected?.decimals || 18 - ).gt(parseUnits(max, selected?.decimals || 18)) - ? parseUnits(max, selected?.decimals || 18) - : parseUnits(amount || "0", selected?.decimals || 18); + // ensure the number is positive + amount = formatUnits( + bnAmount.lt(0) ? bnAmount.mul(-1) : bnAmount, + selected?.decimals || 18 + ); - // ensure the number is positive - amount = formatUnits( - bnAmount.lt(0) ? bnAmount.mul(-1) : bnAmount, - selected?.decimals || 18 - ); + // correct the decimal component + amount = + /* eslint-disable-next-line no-nested-ternary */ + amount.match(/\.0$/) !== null && + e.currentTarget.value.match(/\.0/) === null + ? amount.replace(/\.0$/, "") + : amount.split(".").length > 1 + ? `${amount.split(".")[0]}.${ + amountExpo.length >= (selected?.decimals || 18) + ? amount.split(".")[1] + : amountExpo || + amount.split(".")[1].replace(/[^0-9.]/g, "") + }` + : amount; - // correct the decimal component - amount = - /* eslint-disable-next-line no-nested-ternary */ - amount.match(/\.0$/) !== null && - e.currentTarget.value.match(/\.0/) === null - ? amount.replace(/\.0$/, "") - : amount.split(".").length > 1 - ? `${amount.split(".")[0]}.${ - amountExpo.length >= (selected?.decimals || 18) - ? amount.split(".")[1] - : amountExpo || - amount.split(".")[1].replace(/[^0-9.]/g, "") - }` - : amount; + // retain decimal while it's being added + amount = + amount.indexOf(".") === -1 && + e.currentTarget.value.match(/\.$/) !== null + ? `${amount}.` + : amount; + } + } catch (err) { + // eslint-disable-next-line no-console + console.log(err); + } + setSelectedTokenAmount(amount); + setDestinationTokenAmount(amount); + }} + // disable key inputs which dont make sense + onKeyDown={(e) => { + if (e.key === "-" || e.key === "+" || e.key === "e") { + e.preventDefault(); + } + }} + onPaste={(e) => { + // clean the pasted text + let clean = e.clipboardData + .getData("text") + .replace(/[^0-9.]/g, "") + .replace(/(?<=(.*\..*))\./gm, ""); - // retain decimal while it's being added - amount = - amount.indexOf(".") === -1 && - e.currentTarget.value.match(/\.$/) !== null - ? `${amount}.` - : amount; - } - } catch (err) { - // eslint-disable-next-line no-console - console.log(err); - } - setSelectedTokenAmount(amount); - setDestinationTokenAmount(amount); - }} - // disable key inputs which dont make sense - onKeyDown={(e) => { - if (e.key === "-" || e.key === "+" || e.key === "e") { - e.preventDefault(); - } - }} - onPaste={(e) => { - // clean the pasted text - let clean = e.clipboardData - .getData("text") - .replace(/[^0-9.]/g, "") - .replace(/(?<=(.*\..*))\./gm, ""); + // get rid of the surplus decimal if theres already one in the value + if (e.currentTarget.value.indexOf(".") !== -1) { + clean = clean.replace(".", ""); + } else if (clean === "." || parseInt(clean, 10) === 0) { + clean = ""; + } - // get rid of the surplus decimal if theres already one in the value - if (e.currentTarget.value.indexOf(".") !== -1) { - clean = clean.replace(".", ""); - } else if (clean === "." || parseInt(clean, 10) === 0) { - clean = ""; - } - - // abort if empty - if (!clean) { - e.preventDefault(); - e.stopPropagation(); - } - }} - type="number" - placeholder="0" - className="grow border-0 focus:outline-none rounded-tl-lg rounded-bl-lg bg-black py-1.5 px-3 focus:ring-0 focus:ring-white/70 appearance-none" + // abort if empty + if (!clean) { + e.preventDefault(); + e.stopPropagation(); + } + }} + type="number" + placeholder="0" + className="grow border-0 focus:outline-none rounded-tl-lg rounded-bl-lg bg-black py-1.5 px-3 focus:ring-0 focus:ring-white/70 appearance-none" + /> +
+ +
+ -
- - - {selected?.logoURI && ( - {`Logo - )} - - {selected?.symbol} - - - - - -
+ + +
- - - {tokens.map((token) => ( - - clsx( - active - ? "bg-white/[0.12] text-white transition-all" - : "text-type-secondary", - "relative cursor-default select-none py-4 pl-3 pr-9" - ) - } - value={token} + setIsOpen(false)} + className="relative z-50" + > + {/* The backdrop, rendered as a fixed sibling to the panel container */} + - )} - + +
+ {!hasBalance && !isLoadingBalances ? (
diff --git a/apps/bridge/src/utils/searchTokens.ts b/apps/bridge/src/utils/searchTokens.ts new file mode 100644 index 00000000..db6b6228 --- /dev/null +++ b/apps/bridge/src/utils/searchTokens.ts @@ -0,0 +1,15 @@ +import { Token } from "@config/constants"; + +export const searchTokensByNameAndSymbol = (tokens: Token[], query: string) => { + const normalizedQuery = query.toLowerCase(); + + return tokens.filter((token) => { + const normalizedTokenName = token.name.toLowerCase(); + const normalizedTokenSymbol = token.symbol.toLowerCase(); + + return ( + normalizedTokenName.includes(normalizedQuery) || + normalizedTokenSymbol.includes(normalizedQuery) + ); + }); +}; diff --git a/packages/ui/src/base/Icons.tsx b/packages/ui/src/base/Icons.tsx index 67cda9f8..f3d8e0c7 100644 --- a/packages/ui/src/base/Icons.tsx +++ b/packages/ui/src/base/Icons.tsx @@ -212,7 +212,8 @@ export const DividerCaret = (props: IconProps) => ( > ) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5bdbdc9..2a4cea9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -132,6 +132,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + lodash.debounce: + specifier: ^4.0.8 + version: 4.0.8 mongodb: specifier: ^5.6.0 version: 5.6.0 @@ -175,6 +178,9 @@ importers: '@types/lodash': specifier: ^4.14.194 version: 4.14.195 + '@types/lodash.debounce': + specifier: ^4.0.7 + version: 4.0.7 '@types/node': specifier: 18.11.3 version: 18.11.3 @@ -4925,6 +4931,12 @@ packages: '@types/node': registry.npmjs.org/@types/node@20.5.4 dev: true + /@types/lodash.debounce@4.0.7: + resolution: {integrity: sha512-X1T4wMZ+gT000M2/91SYj0d/7JfeNZ9PeeOldSNoE/lunLeQXKvkmIumI29IaKMotU/ln/McOIvgzZcQ/3TrSA==} + dependencies: + '@types/lodash': 4.14.195 + dev: true + /@types/lodash@4.14.195: resolution: {integrity: sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==} dev: true @@ -10649,6 +10661,10 @@ packages: resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} dev: true + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: false + /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} dev: false