diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx index a5f10673af..2134dc4129 100644 --- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx +++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback, useRef } from 'react' +import { ReactNode, useCallback, useLayoutEffect, useRef } from 'react' import { useVirtualizer, VirtualItem } from '@tanstack/react-virtual' import ms from 'ms.macro' @@ -7,15 +7,72 @@ import { ListInner, ListScroller, ListWrapper, LoadingRows } from './styled' const scrollDelay = ms`400ms` -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -const threeDivs = () => ( - <> -
-
-
- -) +const LoadingPlaceholder: () => ReactNode = () => { + return ( + <> +
+
+
+ + ) +} + +interface VirtualListRowProps { + item: VirtualItem + loading?: boolean + items: T[] + getItemView(items: T[], item: VirtualItem): ReactNode + measureElement(element: Element | null): void +} + +function VirtualListRow({ item, loading, items, getItemView, measureElement }: VirtualListRowProps): ReactNode { + if (loading) { + return ( + + + + ) + } + + return ( +
+ {getItemView(items, item)} +
+ ) +} + +interface VirtualListRowsProps { + virtualItems: VirtualItem[] + loading?: boolean + items: T[] + getItemView(items: T[], item: VirtualItem): ReactNode + measureElement(element: Element | null): void +} + +function renderVirtualListRows({ + virtualItems, + loading, + items, + getItemView, + measureElement, +}: VirtualListRowsProps): ReactNode[] { + const elements: ReactNode[] = [] + + for (const item of virtualItems) { + elements.push( + , + ) + } + + return elements +} interface VirtualListProps { id?: string @@ -26,10 +83,9 @@ interface VirtualListProps { loading?: boolean estimateSize?: () => number children?: ReactNode + scrollResetKey?: string | number | boolean } -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export function VirtualList({ id, items, @@ -37,7 +93,8 @@ export function VirtualList({ getItemView, children, estimateSize = () => 56, -}: VirtualListProps) { + scrollResetKey, +}: VirtualListProps): ReactNode { const parentRef = useRef(null) const wrapperRef = useRef(null) const scrollTimeoutRef = useRef(undefined) @@ -53,6 +110,7 @@ export function VirtualList({ }, scrollDelay) }, []) + // eslint-disable-next-line react-hooks/incompatible-library const virtualizer = useVirtualizer({ getScrollElement: () => parentRef.current, count: items.length, @@ -60,24 +118,40 @@ export function VirtualList({ overscan: 5, }) + useLayoutEffect(() => { + if (scrollResetKey === undefined) { + return + } + + const scrollContainer = parentRef.current + + if (scrollContainer) { + scrollContainer.scrollTop = 0 + scrollContainer.scrollLeft = 0 + + if (typeof scrollContainer.scrollTo === 'function') { + scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }) + } + } + + virtualizer.scrollToOffset(0, { align: 'start' }) + }, [scrollResetKey, virtualizer]) + const virtualItems = virtualizer.getVirtualItems() + const virtualRows = renderVirtualListRows({ + virtualItems, + loading, + items, + getItemView, + measureElement: virtualizer.measureElement, + }) return ( {children} - {virtualItems.map((item) => { - if (loading) { - return {threeDivs()} - } - - return ( -
- {getItemView(items, item)} -
- ) - })} + {virtualRows}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenListsWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenListsWidget/index.tsx index bc5b2f8778..32cf898a6b 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenListsWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenListsWidget/index.tsx @@ -1,7 +1,6 @@ import { ReactNode, useMemo } from 'react' import { useTokensBalances } from '@cowprotocol/balances-and-allowances' -import { TokenWithLogo } from '@cowprotocol/common-const' import { getTokenSearchFilter, LP_TOKEN_LIST_CATEGORIES, @@ -21,12 +20,14 @@ import { TabButton, TabsContainer } from './styled' import { LpTokenLists } from '../../pure/LpTokenLists' import { tokensListSorter } from '../../utils/tokensListSorter' +import type { TokenSelectionHandler } from '../../types' + interface LpTokenListsProps { account: string | undefined children: ReactNode search: string disableErc20?: boolean - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler openPoolPage(poolAddress: string): void tokenListCategoryState: [T, (category: T) => void] } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx index 49ce02305c..97602cb1e2 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx @@ -1,6 +1,5 @@ import { ReactNode } from 'react' -import { TokenWithLogo } from '@cowprotocol/common-const' import { ExplorerDataType, getExplorerLink, shortenAddress } from '@cowprotocol/common-utils' import { TokenLogo, useTokensByAddressMap } from '@cowprotocol/tokens' import { ExternalLink, ModalHeader, TokenSymbol } from '@cowprotocol/ui' @@ -20,6 +19,8 @@ import { Wrapper, } from './styled' +import type { TokenSelectionHandler } from '../../types' + function renderValue(value: T | undefined, template: (v: T) => string, defaultValue?: string): string | undefined { return value ? template(value) : defaultValue } @@ -31,7 +32,7 @@ interface LpTokenPageProps { onDismiss(): void - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler } // eslint-disable-next-line max-lines-per-function diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx new file mode 100644 index 0000000000..c85b9f888e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx @@ -0,0 +1,45 @@ +import { MouseEvent, ReactNode } from 'react' + +import { createPortal } from 'react-dom' + +import { MobileChainPanelCard, MobileChainPanelOverlay } from './styled' + +import { ChainPanel } from '../../pure/ChainPanel' + +import type { SelectTokenWidgetViewProps } from './controllerProps' + +interface MobileChainPanelPortalProps { + chainsPanelTitle: string + chainsToSelect: SelectTokenWidgetViewProps['chainsToSelect'] + onSelectChain: SelectTokenWidgetViewProps['onSelectChain'] + onClose(): void +} + +export function MobileChainPanelPortal({ + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onClose, +}: MobileChainPanelPortalProps): ReactNode { + if (typeof document === 'undefined') { + return null + } + + return createPortal( + + ) => event.stopPropagation()}> + { + onSelectChain(chain) + onClose() + }} + variant="fullscreen" + onClose={onClose} + /> + + , + document.body, + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts new file mode 100644 index 0000000000..e064930038 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts @@ -0,0 +1,85 @@ +import { useIsBridgingEnabled } from '@cowprotocol/common-hooks' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { Field } from 'legacy/state/types' + +import { useLpTokensWithBalances } from 'modules/yield/shared' + +import { SelectTokenWidgetViewProps } from './controllerProps' +import { + useManageWidgetVisibility, + useTokenAdminActions, + useTokenDataSources, + useWidgetMetadata, +} from './controllerState' +import { useSelectTokenWidgetViewState } from './controllerViewState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface SelectTokenWidgetProps { + displayLpTokenLists?: boolean + standalone?: boolean +} + +export interface SelectTokenWidgetController { + shouldRender: boolean + hasChainPanel: boolean + viewProps: SelectTokenWidgetViewProps +} + +export function useSelectTokenWidgetController({ + displayLpTokenLists, + standalone, +}: SelectTokenWidgetProps): SelectTokenWidgetController { + const widgetState = useSelectTokenWidgetState() + const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() + const resolvedField = widgetState.field ?? Field.INPUT + const chainsToSelect = useChainsToSelect() + const onSelectChain = useOnSelectChain() + const isBridgeFeatureEnabled = useIsBridgingEnabled() + const manageWidget = useManageWidgetVisibility() + const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const { account, chainId: walletChainId } = useWalletInfo() + const closeTokenSelectWidget = useCloseTokenSelectWidget() + const tokenData = useTokenDataSources() + const onTokenListAddingError = useOnTokenListAddingError() + const tokenAdminActions = useTokenAdminActions() + const widgetMetadata = useWidgetMetadata( + resolvedField, + widgetState.tradeType, + displayLpTokenLists, + widgetState.oppositeToken, + lpTokensWithBalancesCount, + ) + + const { isChainPanelEnabled, viewProps } = useSelectTokenWidgetViewState({ + displayLpTokenLists, + standalone, + widgetState, + chainsToSelect, + onSelectChain, + manageWidget, + updateSelectTokenWidget, + account, + closeTokenSelectWidget, + tokenData, + onTokenListAddingError, + tokenAdminActions, + widgetMetadata, + walletChainId, + isBridgeFeatureEnabled, + }) + + return { + shouldRender: Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)), + hasChainPanel: isChainPanelEnabled, + viewProps, + } +} + +export type { SelectTokenWidgetViewProps } from './controllerProps' diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts new file mode 100644 index 0000000000..76925a3e77 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts @@ -0,0 +1,84 @@ +import { + useDismissHandler, + useImportFlowCallbacks, + useManageWidgetVisibility, + usePoolPageHandlers, + useRecentTokenSection, + useTokenAdminActions, + useTokenDataSources, + useTokenSelectionHandler, +} from './controllerState' + +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface WidgetViewDependenciesResult { + isManageWidgetOpen: boolean + openManageWidget: ReturnType['openManageWidget'] + closeManageWidget: ReturnType['closeManageWidget'] + onDismiss(): void + openPoolPage: ReturnType['openPoolPage'] + closePoolPage: ReturnType['closePoolPage'] + recentTokens: ReturnType['recentTokens'] + handleTokenListItemClick: ReturnType['handleTokenListItemClick'] + clearRecentTokens: ReturnType['clearRecentTokens'] + handleSelectToken: ReturnType + importFlows: ReturnType +} + +interface WidgetViewDependenciesArgs { + manageWidget: ReturnType + closeTokenSelectWidget: ReturnType + updateSelectTokenWidget: ReturnType + tokenData: ReturnType + tokenAdminActions: ReturnType + onTokenListAddingError: ReturnType + widgetState: ReturnType + activeChainId: number | undefined +} + +export function useWidgetViewDependencies({ + manageWidget, + closeTokenSelectWidget, + updateSelectTokenWidget, + tokenData, + tokenAdminActions, + onTokenListAddingError, + widgetState, + activeChainId, +}: WidgetViewDependenciesArgs): WidgetViewDependenciesResult { + const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget + const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget) + const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget) + const { recentTokens, handleTokenListItemClick, clearRecentTokens } = useRecentTokenSection( + tokenData.allTokens, + tokenData.favoriteTokens, + activeChainId, + ) + const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken, widgetState) + const importFlows = useImportFlowCallbacks( + tokenAdminActions.importTokenCallback, + handleSelectToken, + onDismiss, + tokenAdminActions.addCustomTokenLists, + onTokenListAddingError, + updateSelectTokenWidget, + tokenData.favoriteTokens, + ) + + return { + isManageWidgetOpen, + openManageWidget, + closeManageWidget, + onDismiss, + openPoolPage, + closePoolPage, + recentTokens, + handleTokenListItemClick, + clearRecentTokens, + handleSelectToken, + importFlows, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts new file mode 100644 index 0000000000..2ed07f83c6 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts @@ -0,0 +1,214 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { buildSelectTokenModalPropsInput, buildSelectTokenWidgetViewProps, useSelectTokenModalPropsMemo } from './controllerProps' +import { + useManageWidgetVisibility, + usePoolPageHandlers, + useRecentTokenSection, + useTokenDataSources, + useTokenSelectionHandler, + useWidgetMetadata, +} from './controllerState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' + +import type { WidgetViewDependenciesResult } from './controllerDependencies' +import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' + +const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] + +interface WidgetModalPropsArgs { + account: string | undefined + chainsToSelect: ReturnType + displayLpTokenLists?: boolean + widgetDeps: WidgetViewDependenciesResult + hasChainPanel: boolean + onSelectChain: ReturnType + recentTokens: ReturnType['recentTokens'] + standalone?: boolean + tokenData: ReturnType + widgetMetadata: ReturnType + widgetState: ReturnType + isInjectedWidgetMode: boolean +} + +export function useWidgetModalProps({ + account, + chainsToSelect, + displayLpTokenLists, + widgetDeps, + hasChainPanel, + onSelectChain, + recentTokens, + standalone, + tokenData, + widgetMetadata, + widgetState, + isInjectedWidgetMode, +}: WidgetModalPropsArgs): SelectTokenModalProps { + const favoriteTokens = standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens + + return useSelectTokenModalPropsMemo( + createSelectTokenModalProps({ + account, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + chainsState: chainsToSelect, + disableErc20: widgetMetadata.disableErc20, + displayLpTokenLists, + favoriteTokens, + handleSelectToken: widgetDeps.handleSelectToken, + hasChainPanel, + isInjectedWidgetMode, + modalTitle: widgetMetadata.modalTitle, + onDismiss: widgetDeps.onDismiss, + onSelectChain, + onTokenListItemClick: widgetDeps.handleTokenListItemClick, + onClearRecentTokens: widgetDeps.clearRecentTokens, + onOpenManageWidget: widgetDeps.openManageWidget, + openPoolPage: widgetDeps.openPoolPage, + recentTokens, + standalone, + tokenData, + tokenListCategoryState: widgetMetadata.tokenListCategoryState, + widgetState, + }), + ) +} + +interface BuildViewPropsArgs { + allTokenLists: ReturnType['allTokenLists'] + chainsPanelTitle: string + chainsToSelect: ReturnType + closeManageWidget: ReturnType['closeManageWidget'] + closePoolPage: ReturnType['closePoolPage'] + importFlows: WidgetViewDependenciesResult['importFlows'] + isChainPanelEnabled: boolean + onDismiss: () => void + onSelectChain: ReturnType + selectTokenModalProps: ReturnType + selectedPoolAddress: ReturnType['selectedPoolAddress'] + standalone: boolean | undefined + tokenToImport: ReturnType['tokenToImport'] + listToImport: ReturnType['listToImport'] + isManageWidgetOpen: ReturnType['isManageWidgetOpen'] + userAddedTokens: ReturnType['userAddedTokens'] + handleSelectToken: ReturnType +} + +type BuildViewPropsInput = Parameters[0] + +export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): BuildViewPropsInput { + const { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + importFlows, + allTokenLists, + userAddedTokens, + closeManageWidget, + closePoolPage, + selectTokenModalProps, + handleSelectToken, + } = args + + return { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + onBackFromImport: importFlows.resetTokenImport, + onImportTokens: importFlows.importTokenAndClose, + onImportList: importFlows.importListAndBack, + allTokenLists, + userAddedTokens, + onCloseManageWidget: closeManageWidget, + onClosePoolPage: closePoolPage, + selectTokenModalProps, + onSelectToken: handleSelectToken, + } +} + +function createSelectTokenModalProps({ + account, + chainsPanelTitle, + chainsState, + disableErc20, + displayLpTokenLists, + favoriteTokens, + handleSelectToken, + hasChainPanel, + isInjectedWidgetMode, + modalTitle, + onDismiss, + onSelectChain, + onTokenListItemClick, + onClearRecentTokens, + onOpenManageWidget, + openPoolPage, + recentTokens, + standalone, + tokenData, + tokenListCategoryState, + widgetState, +}: { + account: string | undefined + chainsPanelTitle: string + chainsState: ReturnType + disableErc20: boolean + displayLpTokenLists: boolean | undefined + favoriteTokens: TokenWithLogo[] + handleSelectToken: ReturnType + hasChainPanel: boolean + isInjectedWidgetMode: boolean + modalTitle: string + onDismiss: () => void + onSelectChain: ReturnType + onTokenListItemClick: ReturnType['handleTokenListItemClick'] + onClearRecentTokens: ReturnType['clearRecentTokens'] + onOpenManageWidget: ReturnType['openManageWidget'] + openPoolPage: ReturnType['openPoolPage'] + recentTokens: ReturnType['recentTokens'] + standalone: boolean | undefined + tokenData: ReturnType + tokenListCategoryState: ReturnType['tokenListCategoryState'] + widgetState: ReturnType +}): SelectTokenModalProps { + return buildSelectTokenModalPropsInput({ + standalone, + displayLpTokenLists, + tokenData, + widgetState, + favoriteTokens, + recentTokens, + handleSelectToken, + onTokenListItemClick, + onClearRecentTokens, + onDismiss, + onOpenManageWidget, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + hasChainPanel, + chainsState, + chainsPanelTitle, + onSelectChain, + isInjectedWidgetMode, + modalTitle, + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts new file mode 100644 index 0000000000..4223a467d4 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -0,0 +1,252 @@ +import { useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { ListState } from '@cowprotocol/tokens' + +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +import type { TokenDataSources, TokenListCategoryState } from './controllerState' +import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' + +type WidgetState = ReturnType +export interface SelectTokenWidgetViewProps { + standalone?: boolean + tokenToImport?: TokenWithLogo + listToImport?: ListState + isManageWidgetOpen: boolean + selectedPoolAddress?: string + isChainPanelEnabled: boolean + chainsPanelTitle: string + chainsToSelect: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + onDismiss(): void + onBackFromImport(): void + onImportTokens(tokens: TokenWithLogo[]): void + onImportList(list: ListState): void + allTokenLists: ListState[] + userAddedTokens: TokenWithLogo[] + onCloseManageWidget(): void + onClosePoolPage(): void + selectTokenModalProps: SelectTokenModalProps + onSelectToken: TokenSelectionHandler +} + +interface BuildViewPropsArgs { + standalone?: boolean + tokenToImport?: TokenWithLogo + listToImport?: ListState + isManageWidgetOpen: boolean + selectedPoolAddress?: string + isChainPanelEnabled: boolean + chainsPanelTitle: string + chainsToSelect: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + onDismiss(): void + onBackFromImport(): void + onImportTokens(tokens: TokenWithLogo[]): void + onImportList(list: ListState): void + allTokenLists: ListState[] + userAddedTokens: TokenWithLogo[] + onCloseManageWidget(): void + onClosePoolPage(): void + selectTokenModalProps: SelectTokenModalProps + onSelectToken: TokenSelectionHandler +} + +interface BuildModalPropsArgs { + standalone?: boolean + displayLpTokenLists?: boolean + tokenData: TokenDataSources + widgetState: WidgetState + favoriteTokens: TokenWithLogo[] + recentTokens: TokenWithLogo[] + handleSelectToken: TokenSelectionHandler + onTokenListItemClick(token: TokenWithLogo): void + onClearRecentTokens(): void + onDismiss(): void + onOpenManageWidget(): void + openPoolPage(poolAddress: string): void + tokenListCategoryState: TokenListCategoryState + disableErc20: boolean + account: string | undefined + hasChainPanel: boolean + chainsState?: ChainsToSelectState + chainsPanelTitle: string + onSelectChain?(chain: ChainInfo): void + isInjectedWidgetMode: boolean + modalTitle: string +} + +export function buildSelectTokenWidgetViewProps({ + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + onBackFromImport, + onImportTokens, + onImportList, + allTokenLists, + userAddedTokens, + onCloseManageWidget, + onClosePoolPage, + selectTokenModalProps, + onSelectToken, +}: BuildViewPropsArgs): SelectTokenWidgetViewProps { + return { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onDismiss, + onBackFromImport, + onImportTokens, + onImportList, + allTokenLists, + userAddedTokens, + onCloseManageWidget, + onClosePoolPage, + selectTokenModalProps, + onSelectToken, + } +} + +export function buildSelectTokenModalPropsInput({ + standalone, + displayLpTokenLists, + tokenData, + widgetState, + favoriteTokens, + recentTokens, + handleSelectToken, + onTokenListItemClick, + onClearRecentTokens, + onDismiss, + onOpenManageWidget, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + hasChainPanel, + chainsState, + chainsPanelTitle, + onSelectChain, + isInjectedWidgetMode, + modalTitle, +}: BuildModalPropsArgs): SelectTokenModalProps { + return { + standalone, + displayLpTokenLists, + unsupportedTokens: tokenData.unsupportedTokens, + selectedToken: widgetState.selectedToken, + allTokens: tokenData.allTokens, + favoriteTokens, + recentTokens, + balancesState: tokenData.balancesState, + permitCompatibleTokens: tokenData.permitCompatibleTokens, + onSelectToken: handleSelectToken, + onTokenListItemClick, + onInputPressEnter: widgetState.onInputPressEnter, + onDismiss, + onOpenManageWidget, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + areTokensLoading: tokenData.areTokensLoading, + tokenListTags: tokenData.tokenListTags, + areTokensFromBridge: tokenData.areTokensFromBridge, + isRouteAvailable: tokenData.isRouteAvailable, + modalTitle, + hasChainPanel, + mobileChainsLabel: chainsPanelTitle, + hideFavoriteTokensTooltip: isInjectedWidgetMode, + selectedTargetChainId: widgetState.selectedTargetChainId, + mobileChainsState: chainsState, + onSelectChain, + onClearRecentTokens, + } +} + +export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): SelectTokenModalProps { + return useMemo( + () => ({ + standalone: props.standalone, + displayLpTokenLists: props.displayLpTokenLists, + unsupportedTokens: props.unsupportedTokens, + selectedToken: props.selectedToken, + allTokens: props.allTokens, + favoriteTokens: props.favoriteTokens, + recentTokens: props.recentTokens, + balancesState: props.balancesState, + permitCompatibleTokens: props.permitCompatibleTokens, + onSelectToken: props.onSelectToken, + onTokenListItemClick: props.onTokenListItemClick, + onInputPressEnter: props.onInputPressEnter, + onDismiss: props.onDismiss, + onOpenManageWidget: props.onOpenManageWidget, + openPoolPage: props.openPoolPage, + tokenListCategoryState: props.tokenListCategoryState, + disableErc20: props.disableErc20, + account: props.account, + areTokensLoading: props.areTokensLoading, + tokenListTags: props.tokenListTags, + areTokensFromBridge: props.areTokensFromBridge, + isRouteAvailable: props.isRouteAvailable, + modalTitle: props.modalTitle, + hasChainPanel: props.hasChainPanel, + hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip, + selectedTargetChainId: props.selectedTargetChainId, + mobileChainsState: props.mobileChainsState, + mobileChainsLabel: props.mobileChainsLabel, + onSelectChain: props.onSelectChain, + onOpenMobileChainPanel: props.onOpenMobileChainPanel, + onClearRecentTokens: props.onClearRecentTokens, + }), + [ + props.standalone, + props.displayLpTokenLists, + props.unsupportedTokens, + props.selectedToken, + props.allTokens, + props.favoriteTokens, + props.recentTokens, + props.balancesState, + props.permitCompatibleTokens, + props.onSelectToken, + props.onTokenListItemClick, + props.onInputPressEnter, + props.onDismiss, + props.onOpenManageWidget, + props.openPoolPage, + props.tokenListCategoryState, + props.disableErc20, + props.account, + props.areTokensLoading, + props.tokenListTags, + props.areTokensFromBridge, + props.isRouteAvailable, + props.modalTitle, + props.hasChainPanel, + props.hideFavoriteTokensTooltip, + props.selectedTargetChainId, + props.mobileChainsState, + props.mobileChainsLabel, + props.onSelectChain, + props.onOpenMobileChainPanel, + props.onClearRecentTokens, + ], + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts new file mode 100644 index 0000000000..0a4c44ac65 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts @@ -0,0 +1,300 @@ +import { Dispatch, SetStateAction, useCallback, useState } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { + ListState, + TokenListCategory, + useAddList, + useAddUserToken, + useAllListsList, + useTokenListsTags, + useUnsupportedTokens, + useUserAddedTokens, +} from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { Field } from 'legacy/state/types' + +import { useTokensBalancesCombined } from 'modules/combinedBalances' +import { usePermitCompatibleTokens } from 'modules/permit' +import { TradeType } from 'modules/trade/types' + +import { CowSwapAnalyticsCategory } from 'common/analytics/types' +import { useOnSelectNetwork } from 'common/hooks/useOnSelectNetwork' + +import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' + +import { persistRecentTokenSelection, useRecentTokens } from '../../hooks/useRecentTokens' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useTokensToSelect } from '../../hooks/useTokensToSelect' +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +type UpdateSelectTokenWidgetFn = ReturnType + +export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>] + +interface ManageWidgetVisibility { + isManageWidgetOpen: boolean + openManageWidget(): void + closeManageWidget(): void +} + +interface TokenAdminActions { + addCustomTokenLists(list: ListState): void + importTokenCallback(tokens: TokenWithLogo[]): void +} + +export interface TokenDataSources { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + areTokensLoading: boolean + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + userAddedTokens: TokenWithLogo[] + allTokenLists: ListState[] + balancesState: ReturnType + unsupportedTokens: ReturnType + permitCompatibleTokens: ReturnType + tokenListTags: ReturnType +} + +interface WidgetMetadata { + disableErc20: boolean + tokenListCategoryState: TokenListCategoryState + modalTitle: string + chainsPanelTitle: string +} + +interface PoolPageHandlers { + openPoolPage(poolAddress: string): void + closePoolPage(): void +} + +interface ImportFlowCallbacks { + importTokenAndClose(tokens: TokenWithLogo[]): void + importListAndBack(list: ListState): void + resetTokenImport(): void +} + +interface RecentTokenSection { + recentTokens: TokenWithLogo[] + handleTokenListItemClick(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useManageWidgetVisibility(): ManageWidgetVisibility { + const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) + + const openManageWidget = useCallback(() => setIsManageWidgetOpen(true), []) + const closeManageWidget = useCallback(() => setIsManageWidgetOpen(false), []) + + return { isManageWidgetOpen, openManageWidget, closeManageWidget } +} + +export function useTokenAdminActions(): TokenAdminActions { + const cowAnalytics = useCowAnalytics() + + const addCustomTokenLists = useAddList((source) => { + cowAnalytics.sendEvent({ + category: CowSwapAnalyticsCategory.LIST, + action: 'Add List Success', + label: source, + }) + }) + const importTokenCallback = useAddUserToken() + + return { addCustomTokenLists, importTokenCallback } +} + +export function useTokenDataSources(): TokenDataSources { + const tokensState = useTokensToSelect() + const userAddedTokens = useUserAddedTokens() + const allTokenLists = useAllListsList() + const balancesState = useTokensBalancesCombined() + const unsupportedTokens = useUnsupportedTokens() + const permitCompatibleTokens = usePermitCompatibleTokens() + const tokenListTags = useTokenListsTags() + + return { + allTokens: tokensState.tokens, + favoriteTokens: tokensState.favoriteTokens, + areTokensLoading: tokensState.isLoading, + areTokensFromBridge: tokensState.areTokensFromBridge, + isRouteAvailable: tokensState.isRouteAvailable, + userAddedTokens, + allTokenLists, + balancesState, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + } +} + +export function useWidgetMetadata( + field: Field, + tradeType: TradeType | undefined, + displayLpTokenLists: boolean | undefined, + oppositeToken: Parameters[1], + lpTokensWithBalancesCount: number, +): WidgetMetadata { + const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists + const tokenListCategoryState: TokenListCategoryState = useState( + getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), + ) + const modalTitle = resolveModalTitle(field, tradeType) + const chainsPanelTitle = + field === Field.INPUT ? 'From network' : field === Field.OUTPUT ? 'To network' : 'Select network' + + return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle } +} + +function resolveModalTitle(field: Field, tradeType: TradeType | undefined): string { + const isSwapTrade = !tradeType || tradeType === TradeType.SWAP + + if (field === Field.INPUT) { + return isSwapTrade ? 'Swap from' : 'Sell token' + } + + if (field === Field.OUTPUT) { + return isSwapTrade ? 'Swap to' : 'Buy token' + } + + return 'Select token' +} + +export function useDismissHandler( + closeManageWidget: () => void, + closeTokenSelectWidget: (options?: { overrideForceLock?: boolean }) => void, +): () => void { + return useCallback(() => { + closeManageWidget() + closeTokenSelectWidget({ overrideForceLock: true }) + }, [closeManageWidget, closeTokenSelectWidget]) +} + +export function usePoolPageHandlers(updateSelectTokenWidget: UpdateSelectTokenWidgetFn): PoolPageHandlers { + const openPoolPage = useCallback( + (selectedPoolAddress: string) => { + updateSelectTokenWidget({ selectedPoolAddress }) + }, + [updateSelectTokenWidget], + ) + + const closePoolPage = useCallback(() => { + updateSelectTokenWidget({ selectedPoolAddress: undefined }) + }, [updateSelectTokenWidget]) + + return { openPoolPage, closePoolPage } +} + +export function useImportFlowCallbacks( + importTokenCallback: ReturnType, + onSelectToken: TokenSelectionHandler | undefined, + onDismiss: () => void, + addCustomTokenLists: (list: ListState) => void, + onTokenListAddingError: (error: Error) => void, + updateSelectTokenWidget: UpdateSelectTokenWidgetFn, + favoriteTokens: TokenWithLogo[], +): ImportFlowCallbacks { + const importTokenAndClose = useCallback( + (tokens: TokenWithLogo[]) => { + importTokenCallback(tokens) + const [selectedToken] = tokens + + if (selectedToken) { + persistRecentTokenSelection(selectedToken, favoriteTokens) + onSelectToken?.(selectedToken) + } + + onDismiss() + }, + [importTokenCallback, onSelectToken, onDismiss, favoriteTokens], + ) + + const importListAndBack = useCallback( + (list: ListState) => { + try { + addCustomTokenLists(list) + } catch (error) { + onDismiss() + onTokenListAddingError(error as Error) + } + updateSelectTokenWidget({ listToImport: undefined }) + }, + [addCustomTokenLists, onDismiss, onTokenListAddingError, updateSelectTokenWidget], + ) + + const resetTokenImport = useCallback(() => { + updateSelectTokenWidget({ tokenToImport: undefined }) + }, [updateSelectTokenWidget]) + + return { importTokenAndClose, importListAndBack, resetTokenImport } +} + +export function useRecentTokenSection( + allTokens: TokenWithLogo[], + favoriteTokens: TokenWithLogo[], + activeChainId?: number, +): RecentTokenSection { + const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + }) + + const handleTokenListItemClick = useCallback( + (token: TokenWithLogo) => { + addRecentToken(token) + }, + [addRecentToken], + ) + + return { recentTokens, handleTokenListItemClick, clearRecentTokens } +} + +export function useTokenSelectionHandler( + onSelectToken: TokenSelectionHandler | undefined, + widgetState: ReturnType, +): TokenSelectionHandler { + const { chainId: walletChainId } = useWalletInfo() + const onSelectNetwork = useOnSelectNetwork() + + return useCallback( + async (token: TokenWithLogo) => { + const targetChainId = widgetState.selectedTargetChainId + // SELL-side limit/TWAP orders must run on the picked network, + // so nudge the wallet onto that chain before finalizing selection. + const shouldSwitchWalletNetwork = + widgetState.field === Field.INPUT && + (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) && + typeof targetChainId === 'number' && + targetChainId !== walletChainId + + if (shouldSwitchWalletNetwork && targetChainId in SupportedChainId) { + try { + await onSelectNetwork(targetChainId as SupportedChainId, true) + } catch (error) { + console.error('Failed to switch network after token selection', error) + } + } + + onSelectToken?.(token) + }, + [ + onSelectToken, + widgetState.field, + widgetState.tradeType, + widgetState.selectedTargetChainId, + walletChainId, + onSelectNetwork, + ], + ) +} + +export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean { + return Boolean(chainsToSelect) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts new file mode 100644 index 0000000000..7c1e1a1d3a --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts @@ -0,0 +1,136 @@ +import { isInjectedWidget } from '@cowprotocol/common-utils' + +import { TradeType } from 'modules/trade/types' + +import { useWidgetViewDependencies } from './controllerDependencies' +import { getSelectTokenWidgetViewPropsArgs, useWidgetModalProps } from './controllerModalProps' +import { SelectTokenWidgetViewProps, buildSelectTokenWidgetViewProps } from './controllerProps' +import { + hasAvailableChains, + useManageWidgetVisibility, + useTokenAdminActions, + useTokenDataSources, + useWidgetMetadata, +} from './controllerState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface SelectTokenWidgetViewStateArgs { + displayLpTokenLists?: boolean + standalone?: boolean + widgetState: ReturnType + chainsToSelect: ReturnType + onSelectChain: ReturnType + manageWidget: ReturnType + updateSelectTokenWidget: ReturnType + account: string | undefined + closeTokenSelectWidget: ReturnType + tokenData: ReturnType + onTokenListAddingError: ReturnType + tokenAdminActions: ReturnType + widgetMetadata: ReturnType + walletChainId?: number + isBridgeFeatureEnabled: boolean +} + +interface ViewStateResult { + isChainPanelEnabled: boolean + viewProps: SelectTokenWidgetViewProps +} + +// TODO: Re-enable once Yield should support cross-network selection in the modal. +const ENABLE_YIELD_CHAIN_PANEL = false + +export function useSelectTokenWidgetViewState(args: SelectTokenWidgetViewStateArgs): ViewStateResult { + const { + displayLpTokenLists, + standalone, + widgetState, + chainsToSelect, + onSelectChain, + manageWidget, + updateSelectTokenWidget, + account, + closeTokenSelectWidget, + tokenData, + onTokenListAddingError, + tokenAdminActions, + widgetMetadata, + walletChainId, + isBridgeFeatureEnabled, + } = args + + const activeChainId = resolveActiveChainId(widgetState, walletChainId) + const widgetDeps = useWidgetViewDependencies({ + manageWidget, + closeTokenSelectWidget, + updateSelectTokenWidget, + tokenData, + tokenAdminActions, + onTokenListAddingError, + widgetState, + activeChainId, + }) + const shouldDisableChainPanelForYield = widgetState.tradeType === TradeType.YIELD && !ENABLE_YIELD_CHAIN_PANEL + const isChainPanelEnabled = + isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect) && !shouldDisableChainPanelForYield + const selectTokenModalProps = useWidgetModalProps({ + account, + chainsToSelect, + displayLpTokenLists, + widgetDeps, + hasChainPanel: isChainPanelEnabled, + onSelectChain, + recentTokens: widgetDeps.recentTokens, + standalone, + tokenData, + widgetMetadata, + widgetState, + isInjectedWidgetMode: isInjectedWidget(), + }) + + const viewProps = buildSelectTokenWidgetViewProps( + getSelectTokenWidgetViewPropsArgs({ + allTokenLists: tokenData.allTokenLists, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + chainsToSelect, + closeManageWidget: widgetDeps.closeManageWidget, + closePoolPage: widgetDeps.closePoolPage, + importFlows: widgetDeps.importFlows, + isChainPanelEnabled, + onDismiss: widgetDeps.onDismiss, + onSelectChain, + selectTokenModalProps, + selectedPoolAddress: widgetState.selectedPoolAddress, + standalone, + tokenToImport: widgetState.tokenToImport, + listToImport: widgetState.listToImport, + isManageWidgetOpen: widgetDeps.isManageWidgetOpen, + userAddedTokens: tokenData.userAddedTokens, + handleSelectToken: widgetDeps.handleSelectToken, + }), + ) + + return { isChainPanelEnabled, viewProps } +} + +function resolveActiveChainId( + widgetState: ReturnType, + walletChainId?: number, +): number | undefined { + return ( + widgetState.selectedTargetChainId ?? + walletChainId ?? + extractChainId(widgetState.oppositeToken) ?? + extractChainId(widgetState.selectedToken) + ) +} + +function extractChainId(token: { chainId?: number } | undefined | null): number | undefined { + return typeof token?.chainId === 'number' ? token.chainId : undefined +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx index a58d847acc..ff512fb194 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx @@ -1,234 +1,215 @@ -import { ReactNode, useCallback, useState } from 'react' +import { MouseEvent, ReactNode, useEffect, useState } from 'react' + +import { useMediaQuery } from '@cowprotocol/common-hooks' +import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils' +import { Media } from '@cowprotocol/ui' + +import { createPortal } from 'react-dom' -import { useCowAnalytics } from '@cowprotocol/analytics' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { isInjectedWidget } from '@cowprotocol/common-utils' import { - ListState, - TokenListCategory, - useAddList, - useAddUserToken, - useAllListsList, - useTokenListsTags, - useUnsupportedTokens, - useUserAddedTokens, -} from '@cowprotocol/tokens' -import { useWalletInfo } from '@cowprotocol/wallet' - -import styled from 'styled-components/macro' - -import { Field } from 'legacy/state/types' - -import { useTokensBalancesCombined } from 'modules/combinedBalances' -import { usePermitCompatibleTokens } from 'modules/permit' -import { useLpTokensWithBalances } from 'modules/yield/shared' - -import { CowSwapAnalyticsCategory } from 'common/analytics/types' - -import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' - -import { useChainsToSelect } from '../../hooks/useChainsToSelect' -import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' -import { useOnSelectChain } from '../../hooks/useOnSelectChain' -import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' -import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' -import { useTokensToSelect } from '../../hooks/useTokensToSelect' -import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + useSelectTokenWidgetController, + type SelectTokenWidgetProps, + type SelectTokenWidgetViewProps, +} from './controller' +import { MobileChainPanelPortal } from './MobileChainPanelPortal' +import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from './styled' + +import { ChainPanel } from '../../pure/ChainPanel' import { ImportListModal } from '../../pure/ImportListModal' import { ImportTokenModal } from '../../pure/ImportTokenModal' import { SelectTokenModal } from '../../pure/SelectTokenModal' import { LpTokenPage } from '../LpTokenPage' import { ManageListsAndTokens } from '../ManageListsAndTokens' -const Wrapper = styled.div` - width: 100%; +export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode { + const { shouldRender, hasChainPanel, viewProps } = useSelectTokenWidgetController(props) + const isCompactLayout = useMediaQuery(Media.upToMedium(false)) + const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false) + const isChainPanelVisible = hasChainPanel && !isCompactLayout - > div { - height: calc(100vh - 200px); - min-height: 600px; - } -` + useEffect(() => { + if (!shouldRender) { + return + } -const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] + if (isChainPanelVisible) { + setIsMobileChainPanelOpen(false) + } + }, [isChainPanelVisible, shouldRender]) -interface SelectTokenWidgetProps { - displayLpTokenLists?: boolean - standalone?: boolean -} + useEffect(() => { + if (!shouldRender) { + removeBodyClass('noScroll') + return undefined + } -// TODO: Break down this large function into smaller functions -// eslint-disable-next-line max-lines-per-function -export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTokenWidgetProps): ReactNode { - const { - open, - onSelectToken, - tokenToImport, - listToImport, - selectedToken, - onInputPressEnter, - selectedPoolAddress, - field, - oppositeToken, - } = useSelectTokenWidgetState() - const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances() - const chainsToSelect = useChainsToSelect() - const onSelectChain = useOnSelectChain() - - const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) - const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists - - const tokenListCategoryState = useState( - getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), + addBodyClass('noScroll') + return () => removeBodyClass('noScroll') + }, [shouldRender]) + + if (!shouldRender) { + return null + } + + const widgetContent = ( + + + + + ) - const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() - const { account } = useWalletInfo() + const handleOverlayClick = (event: MouseEvent): void => { + if (event.target !== event.currentTarget) { + return + } - const cowAnalytics = useCowAnalytics() - const addCustomTokenLists = useAddList((source) => { - cowAnalytics.sendEvent({ - category: CowSwapAnalyticsCategory.LIST, - action: 'Add List Success', - label: source, - }) - }) - const importTokenCallback = useAddUserToken() + viewProps.onDismiss() + } - const { - tokens: allTokens, - isLoading: areTokensLoading, - favoriteTokens, - areTokensFromBridge, - isRouteAvailable, - } = useTokensToSelect() - - const userAddedTokens = useUserAddedTokens() - const allTokenLists = useAllListsList() - const balancesState = useTokensBalancesCombined() - const unsupportedTokens = useUnsupportedTokens() - const permitCompatibleTokens = usePermitCompatibleTokens() - const tokenListTags = useTokenListsTags() - const onTokenListAddingError = useOnTokenListAddingError() - - const isInjectedWidgetMode = isInjectedWidget() - - const closeTokenSelectWidget = useCloseTokenSelectWidget() - - const openPoolPage = useCallback( - (selectedPoolAddress: string) => { - updateSelectTokenWidget({ selectedPoolAddress }) - }, - [updateSelectTokenWidget], + const overlay = ( + + + {widgetContent} + + ) - const closePoolPage = useCallback(() => { - updateSelectTokenWidget({ selectedPoolAddress: undefined }) - }, [updateSelectTokenWidget]) - - const resetTokenImport = useCallback(() => { - updateSelectTokenWidget({ - tokenToImport: undefined, - }) - }, [updateSelectTokenWidget]) - - const onDismiss = useCallback(() => { - setIsManageWidgetOpen(false) - closeTokenSelectWidget() - }, [closeTokenSelectWidget]) - - const importTokenAndClose = (tokens: TokenWithLogo[]): void => { - importTokenCallback(tokens) - onSelectToken?.(tokens[0]) - onDismiss() + if (typeof document === 'undefined') { + return overlay } - const importListAndBack = (list: ListState): void => { - try { - addCustomTokenLists(list) - } catch (error) { - onDismiss() - onTokenListAddingError(error) - } - updateSelectTokenWidget({ listToImport: undefined }) + return createPortal(overlay, document.body) +} + +function SelectTokenWidgetView( + props: SelectTokenWidgetViewProps & { + isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void + }, +): ReactNode { + const { + isChainPanelVisible, + isCompactLayout, + isMobileChainPanelOpen, + setIsMobileChainPanelOpen, + isChainPanelEnabled, + chainsPanelTitle, + chainsToSelect, + onSelectChain, + selectTokenModalProps, + } = props + + const blockingView = getBlockingView(props) + + if (blockingView) { + return blockingView } - if (!onSelectToken || !open) return null + const closeMobileChainPanel = (): void => setIsMobileChainPanelOpen(false) + const mobileChainsState = isChainPanelEnabled && !isChainPanelVisible ? chainsToSelect : undefined + const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined + const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect + const showMobileChainPanel = !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen return ( - - {(() => { - if (tokenToImport && !standalone) { - return ( - - ) - } - - if (listToImport && !standalone) { - return ( - - ) - } - - if (isManageWidgetOpen && !standalone) { - return ( - setIsManageWidgetOpen(false)} - /> - ) - } - - if (selectedPoolAddress) { - return ( - - ) - } - - return ( - setIsManageWidgetOpen(true)} - hideFavoriteTokensTooltip={isInjectedWidgetMode} - openPoolPage={openPoolPage} - tokenListCategoryState={tokenListCategoryState} - disableErc20={disableErc20} - account={account} - chainsToSelect={chainsToSelect} - onSelectChain={onSelectChain} - areTokensLoading={areTokensLoading} - tokenListTags={tokenListTags} - areTokensFromBridge={areTokensFromBridge} - isRouteAvailable={isRouteAvailable} - /> - ) - })()} - + <> + + + + {showDesktopChainPanel && ( + + )} + {showMobileChainPanel && ( + + )} + ) } + +function getBlockingView( + props: SelectTokenWidgetViewProps & { + isChainPanelVisible: boolean + isCompactLayout: boolean + isMobileChainPanelOpen: boolean + setIsMobileChainPanelOpen(value: boolean): void + }, +): ReactNode | null { + const { + standalone, + tokenToImport, + listToImport, + isManageWidgetOpen, + selectedPoolAddress, + allTokenLists, + userAddedTokens, + onDismiss, + onBackFromImport, + onImportTokens, + onImportList, + onCloseManageWidget, + onClosePoolPage, + onSelectToken, + } = props + + if (tokenToImport && !standalone) { + return ( + + ) + } + + if (listToImport && !standalone) { + return ( + + ) + } + + if (isManageWidgetOpen && !standalone) { + return ( + + ) + } + + if (selectedPoolAddress) { + return ( + + ) + } + + return null +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/renderMobileChainPanel.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/renderMobileChainPanel.tsx new file mode 100644 index 0000000000..d023ba777d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/renderMobileChainPanel.tsx @@ -0,0 +1,43 @@ +import { ReactNode } from 'react' + +import { createPortal } from 'react-dom' + +import { MobileChainPanelCard, MobileChainPanelOverlay } from './styled' + +import { ChainPanel } from '../../pure/ChainPanel' + +import type { SelectTokenWidgetViewProps } from './controllerProps' + +export function renderMobileChainPanel({ + chainsPanelTitle, + chainsToSelect, + onSelectChain, + onClose, +}: { + chainsPanelTitle: string + chainsToSelect: SelectTokenWidgetViewProps['chainsToSelect'] + onSelectChain: SelectTokenWidgetViewProps['onSelectChain'] + onClose(): void +}): ReactNode { + if (typeof document === 'undefined') { + return null + } + + return createPortal( + + event.stopPropagation()}> + { + onSelectChain(chain) + onClose() + }} + variant="fullscreen" + onClose={onClose} + /> + + , + document.body, + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts new file mode 100644 index 0000000000..04eddcd7d8 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts @@ -0,0 +1,92 @@ +import { Media } from '@cowprotocol/ui' + +import styled, { css } from 'styled-components/macro' +import { WIDGET_MAX_WIDTH } from 'theme' + +export const Wrapper = styled.div` + width: 100%; + height: 100%; +` + +export const InnerWrapper = styled.div<{ $hasSidebar: boolean; $isMobileOverlay?: boolean }>` + height: 100%; + min-height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '0' : 'min(600px, 100%)')}; + width: 100%; + margin: 0 auto; + display: flex; + align-items: stretch; + + ${({ $hasSidebar }) => + $hasSidebar && + css` + /* Stack modal + sidebar vertically on narrow screens so neither pane collapses */ + ${Media.upToMedium()} { + flex-direction: column; + height: auto; + min-height: 0; + } + + ${Media.upToSmall()} { + min-height: 0; + } + `} + + ${({ $isMobileOverlay }) => + $isMobileOverlay && + css` + flex-direction: column; + height: 100%; + min-height: 0; + `} +` + +export const ModalContainer = styled.div` + flex: 1; + min-width: 0; + display: flex; + height: 100%; +` + +export const MobileChainPanelOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 1200; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: stretch; + justify-content: center; +` + +export const MobileChainPanelCard = styled.div` + flex: 1; + max-width: 100%; + height: 100%; +` + +export const WidgetOverlay = styled.div` + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + box-sizing: border-box; + + ${Media.upToMedium()} { + padding: 0; + } +` + +export const WidgetCard = styled.div<{ $isCompactLayout: boolean; $hasChainPanel: boolean }>` + width: 100%; + max-width: ${({ $isCompactLayout, $hasChainPanel }) => + $isCompactLayout ? '100%' : $hasChainPanel ? WIDGET_MAX_WIDTH.tokenSelectSidebar : WIDGET_MAX_WIDTH.tokenSelect}; + height: ${({ $isCompactLayout }) => ($isCompactLayout ? '100%' : '90vh')}; + max-height: 100%; + display: flex; + align-items: stretch; + justify-content: center; + box-sizing: border-box; +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx index 4ca54eced2..b43723b9f2 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx @@ -3,15 +3,6 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens' -import { - BannerOrientation, - ExternalLink, - InlineBanner, - LINK_GUIDE_ADD_CUSTOM_TOKEN, - StatusColorVariant, -} from '@cowprotocol/ui' - -import { Trans } from '@lingui/react/macro' import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' @@ -32,7 +23,7 @@ export function TokenSearchResults({ areTokensFromBridge, allTokens, }: TokenSearchResultsProps): ReactNode { - const { onSelectToken } = selectTokenContext + const { onSelectToken, onTokenListItemClick } = selectTokenContext // Do not make search when tokens are from bridge const defaultSearchResults = useSearchToken(areTokensFromBridge ? null : searchInput) @@ -65,9 +56,14 @@ export function TokenSearchResults({ if (!searchInput || !activeListsResult) return if (activeListsResult.length === 1 || matchedTokens.length === 1) { - onSelectToken(matchedTokens[0] || activeListsResult[0]) + const tokenToSelect = matchedTokens[0] || activeListsResult[0] + + if (tokenToSelect) { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + } } - }, [searchInput, activeListsResult, matchedTokens, onSelectToken]) + }, [searchInput, activeListsResult, matchedTokens, onSelectToken, onTokenListItemClick]) useEffect(() => { updateSelectTokenWidget({ @@ -77,18 +73,6 @@ export function TokenSearchResults({ return ( - -

- Can't find your token on the list?{' '} - Read our guide on how to add custom tokens. -

-
- + +export function buildTokensByKey(tokens: TokenWithLogo[]): Map { + const map = new Map() + + for (const token of tokens) { + map.set(getTokenUniqueKey(token), token) + } + + return map +} + +export function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set { + const set = new Set() + + for (const token of tokens) { + set.add(getTokenUniqueKey(token)) + } + + return set +} + +export function hydrateStoredToken(entry: StoredRecentToken, canonical?: TokenWithLogo): TokenWithLogo | null { + if (canonical) { + return canonical + } + + try { + return new TokenWithLogo( + entry.logoURI, + entry.chainId, + entry.address, + entry.decimals, + entry.symbol, + entry.name, + undefined, + entry.tags ?? [], + ) + } catch { + return null + } +} + +export function getStoredTokenKey(token: StoredRecentToken): string { + return getTokenUniqueKey(token) +} + +export function readStoredTokens(limit: number): StoredRecentTokensByChain { + if (!canUseLocalStorage()) { + return {} + } + + try { + const rawValue = window.localStorage.getItem(RECENT_TOKENS_STORAGE_KEY) + + if (!rawValue) { + return {} + } + + const parsed: unknown = JSON.parse(rawValue) + + if (Array.isArray(parsed)) { + return migrateLegacyStoredTokens(parsed, limit) + } + + if (parsed && typeof parsed === 'object') { + return sanitizeStoredTokensMap(parsed as Record, limit) + } + + return {} + } catch { + return {} + } +} + +export function persistStoredTokens(tokens: StoredRecentTokensByChain): void { + if (!canUseLocalStorage()) { + return + } + + try { + window.localStorage.setItem(RECENT_TOKENS_STORAGE_KEY, JSON.stringify(tokens)) + } catch { + // Ignore persistence errors – the feature is best-effort only + } +} + +export function buildNextStoredTokens( + prev: StoredRecentTokensByChain, + token: TokenWithLogo, + maxItems: number, +): StoredRecentTokensByChain { + const chainId = token.chainId + const normalized = toStoredToken(token) + const chainEntries = prev[chainId] ?? [] + const updatedChain = insertToken(chainEntries, normalized, maxItems) + + return { + ...prev, + [chainId]: updatedChain, + } +} + +export function persistRecentTokenSelection( + token: TokenWithLogo, + favoriteTokens: TokenWithLogo[], + maxItems = RECENT_TOKENS_LIMIT, +): void { + const favoriteKeys = buildFavoriteTokenKeys(favoriteTokens) + + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + const current = readStoredTokens(maxItems) + const next = buildNextStoredTokens(current, token, maxItems) + + persistStoredTokens(next) +} + +function sanitizeStoredTokensMap(record: Record, limit: number): StoredRecentTokensByChain { + const entries: StoredRecentTokensByChain = {} + + for (const [chainKey, tokens] of Object.entries(record)) { + const chainId = Number(chainKey) + + if (Number.isNaN(chainId) || !Array.isArray(tokens)) { + continue + } + + const sanitized = tokens + .map((token) => sanitizeStoredToken(token)) + .filter((token): token is StoredRecentToken => Boolean(token)) + + if (sanitized.length) { + entries[chainId] = sanitized.slice(0, limit) + } + } + + return entries +} + +function migrateLegacyStoredTokens(entries: unknown[], limit: number): StoredRecentTokensByChain { + return entries + .map((entry) => sanitizeStoredToken(entry)) + .filter((entry): entry is StoredRecentToken => Boolean(entry)) + .reverse() + .reduce((acc, sanitized) => { + const chainId = sanitized.chainId + const chain = acc[chainId] ?? [] + + acc[chainId] = insertToken(chain, sanitized, limit) + + return acc + }, {}) +} + +function sanitizeStoredToken(token: unknown): StoredRecentToken | null { + if (!token || typeof token !== 'object') { + return null + } + + const { chainId, address, decimals, symbol, name, logoURI, tags } = token as StoredRecentToken + + if (typeof chainId !== 'number' || typeof address !== 'string' || typeof decimals !== 'number') { + return null + } + + return { + chainId, + address: address.toLowerCase(), + decimals, + symbol: typeof symbol === 'string' ? symbol : undefined, + name: typeof name === 'string' ? name : undefined, + logoURI: typeof logoURI === 'string' ? logoURI : undefined, + tags: Array.isArray(tags) ? tags.filter((tag): tag is string => typeof tag === 'string') : undefined, + } +} + +function insertToken(tokens: StoredRecentToken[], token: StoredRecentToken, limit: number): StoredRecentToken[] { + const key = getTokenUniqueKey(token) + const withoutToken = tokens.filter((entry) => getTokenUniqueKey(entry) !== key) + + return [token, ...withoutToken].slice(0, limit) +} + +function toStoredToken(token: TokenWithLogo): StoredRecentToken { + return { + chainId: token.chainId, + address: token.address.toLowerCase(), + decimals: token.decimals, + symbol: token.symbol, + name: token.name, + logoURI: token.logoURI, + tags: token.tags, + } +} + +function canUseLocalStorage(): boolean { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined' +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts new file mode 100644 index 0000000000..5bf6a56e45 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts @@ -0,0 +1,48 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { createInputChainsState, createOutputChainsState } from './useChainsToSelect' + +import { createChainInfoForTests } from '../test-utils/createChainInfoForTests' + +describe('useChainsToSelect state builders', () => { + it('sorts sell-side chains using the canonical order', () => { + const supportedChains = [ + createChainInfoForTests(SupportedChainId.AVALANCHE), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.MAINNET), + ] + + const state = createInputChainsState(SupportedChainId.BASE, supportedChains) + + expect((state.chains ?? []).map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.AVALANCHE, + ]) + }) + + it('sorts bridge destination chains to match the canonical order', () => { + const bridgeChains = [ + createChainInfoForTests(SupportedChainId.AVALANCHE), + createChainInfoForTests(SupportedChainId.BASE), + createChainInfoForTests(SupportedChainId.ARBITRUM_ONE), + createChainInfoForTests(SupportedChainId.MAINNET), + ] + + const state = createOutputChainsState({ + selectedTargetChainId: SupportedChainId.POLYGON, + chainId: SupportedChainId.MAINNET, + currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET), + bridgeSupportedNetworks: bridgeChains, + areUnsupportedChainsEnabled: true, + isLoading: false, + }) + + expect((state.chains ?? []).map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.AVALANCHE, + ]) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts index b781b7c112..41cc4d8d12 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts @@ -9,10 +9,13 @@ import { useBridgeSupportedNetworks } from 'entities/bridgeProvider' import { Field } from 'legacy/state/types' +import { TradeType } from 'modules/trade/types' + import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { ChainsToSelectState } from '../types' import { mapChainInfo } from '../utils/mapChainInfo' +import { sortChainsByDisplayOrder } from '../utils/sortChainsByDisplayOrder' /** * Returns an array of chains to select in the token selector widget. @@ -22,11 +25,12 @@ import { mapChainInfo } from '../utils/mapChainInfo' */ export function useChainsToSelect(): ChainsToSelectState | undefined { const { chainId } = useWalletInfo() - const { field, selectedTargetChainId = chainId } = useSelectTokenWidgetState() + const { field, selectedTargetChainId = chainId, tradeType } = useSelectTokenWidgetState() const { data: bridgeSupportedNetworks, isLoading } = useBridgeSupportedNetworks() const { areUnsupportedChainsEnabled } = useFeatureFlags() const isBridgingEnabled = useIsBridgingEnabled() const availableChains = useAvailableChains() + const isAdvancedTradeType = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS const supportedChains = useMemo(() => { return availableChains.reduce((acc, id) => { @@ -41,42 +45,32 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { }, [availableChains]) return useMemo(() => { - if (!field || !isBridgingEnabled) return undefined + if (!field || !chainId) return undefined - const currentChainInfo = mapChainInfo(chainId, CHAIN_INFO[chainId]) - const isSourceChainSupportedByBridge = Boolean( - bridgeSupportedNetworks?.find((bridgeChain) => bridgeChain.id === chainId), - ) + const chainInfo = CHAIN_INFO[chainId] + if (!chainInfo) return undefined - // For the sell token selector we only display supported chains - if (field === Field.INPUT) { - return { - defaultChainId: selectedTargetChainId, - chains: supportedChains, - isLoading: false, - } - } + const currentChainInfo = mapChainInfo(chainId, chainInfo) + const shouldForceSingleChain = isAdvancedTradeType && field === Field.OUTPUT - /** - * When the source chain is not supported by bridge provider - * We act as non-bridge mode - */ - if (!isSourceChainSupportedByBridge) { - return { - defaultChainId: selectedTargetChainId, - chains: [], - isLoading: false, - } + if (!isBridgingEnabled && !shouldForceSingleChain) return undefined + + if (shouldForceSingleChain) { + return createSingleChainState(chainId, currentChainInfo) } - const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) + if (field === Field.INPUT) { + return createInputChainsState(selectedTargetChainId, supportedChains) + } - return { - defaultChainId: selectedTargetChainId, - // Add the source network to the list if it's not supported by bridge provider - chains: [...(isSourceChainSupportedByBridge ? [] : [currentChainInfo]), ...(destinationChains || [])], + return createOutputChainsState({ + selectedTargetChainId, + chainId, + currentChainInfo, + bridgeSupportedNetworks, + areUnsupportedChainsEnabled, isLoading, - } + }) }, [ field, selectedTargetChainId, @@ -86,6 +80,7 @@ export function useChainsToSelect(): ChainsToSelectState | undefined { isBridgingEnabled, areUnsupportedChainsEnabled, supportedChains, + isAdvancedTradeType, ]) } @@ -101,3 +96,64 @@ function filterDestinationChains( return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId) } } + +// Represents the “non-bridge” UX where only the current chain is available. +function createSingleChainState( + defaultChainId: SupportedChainId | number, + chain: ChainInfo, + isLoading = false, +): ChainsToSelectState { + return { + defaultChainId, + chains: [chain], + isLoading, + } +} + +// Sell-side selector only allows wallet-supported networks. +export function createInputChainsState( + selectedTargetChainId: SupportedChainId | number, + supportedChains: ChainInfo[], +): ChainsToSelectState { + return { + defaultChainId: selectedTargetChainId, + chains: sortChainsByDisplayOrder(supportedChains), + isLoading: false, + } +} + +interface CreateOutputChainsOptions { + selectedTargetChainId: SupportedChainId | number + chainId: SupportedChainId + currentChainInfo: ChainInfo + bridgeSupportedNetworks: ChainInfo[] | undefined + areUnsupportedChainsEnabled: boolean | undefined + isLoading: boolean +} + +export function createOutputChainsState({ + selectedTargetChainId, + chainId, + currentChainInfo, + bridgeSupportedNetworks, + areUnsupportedChainsEnabled, + isLoading, +}: CreateOutputChainsOptions): ChainsToSelectState { + const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) ?? [] + const orderedDestinationChains = sortChainsByDisplayOrder(destinationChains) + const isSourceChainSupportedByBridge = Boolean( + bridgeSupportedNetworks?.some((bridgeChain) => bridgeChain.id === chainId), + ) + + if (!isSourceChainSupportedByBridge) { + // Source chain is unsupported by the bridge provider; fall back to non-bridge behavior. + return createSingleChainState(selectedTargetChainId, currentChainInfo) + } + + return { + defaultChainId: selectedTargetChainId, + // Bridge supports this chain, so expose the provider-supplied destinations. + chains: orderedDestinationChains, + isLoading, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts index 6434545dfa..9f52134ab7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts @@ -1,15 +1,22 @@ import { useCallback } from 'react' +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useCloseTokenSelectWidget() { +type CloseTokenSelectWidget = (options?: { overrideForceLock?: boolean }) => void + +export function useCloseTokenSelectWidget(): CloseTokenSelectWidget { const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const widgetState = useSelectTokenWidgetState() + + return useCallback( + (options?: { overrideForceLock?: boolean }) => { + if (widgetState.forceOpen && !options?.overrideForceLock) return - return useCallback(() => { - updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE) - }, [updateSelectTokenWidget]) + updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE) + }, + [updateSelectTokenWidget, widgetState.forceOpen], + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts new file mode 100644 index 0000000000..34e330b60f --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react' + +interface DeferredVisibilityOptions { + /** + * Expands the observed viewport so we hydrate content slightly before it + * scrolls into view. + */ + rootMargin?: string + /** + * When this key changes we reset the visibility state. Helpful when the same + * virtualized row instance renders different data. + */ + resetKey?: string | number +} + +interface DeferredVisibilityResult { + ref: (element: T | null) => void + isVisible: boolean +} + +const DEFAULT_ROOT_MARGIN = '120px' + +// Lightweight helper to delay hydration of expensive UI until the row is close to the viewport. +export function useDeferredVisibility( + options: DeferredVisibilityOptions = {}, +): DeferredVisibilityResult { + const { rootMargin = DEFAULT_ROOT_MARGIN, resetKey } = options + const [element, setElement] = useState(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (resetKey === undefined) { + return + } + + setIsVisible(false) + }, [resetKey]) + + useEffect(() => { + if (isVisible || !element) { + return undefined + } + + if (typeof IntersectionObserver === 'undefined') { + setIsVisible(true) + return undefined + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setIsVisible(true) + } + }, + { rootMargin }, + ) + + observer.observe(element) + + return () => observer.disconnect() + }, [element, isVisible, rootMargin]) + + const ref = useCallback((node: T | null) => { + setElement(node) + }, []) + + return { ref, isVisible } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts index eaf2b8997f..83c850f714 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts @@ -2,17 +2,30 @@ import { useCallback } from 'react' import { ChainInfo } from '@cowprotocol/cow-sdk' +import { Field } from 'legacy/state/types' + +import { TradeType } from 'modules/trade/types' + +import { useSelectTokenWidgetState } from './useSelectTokenWidgetState' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function useOnSelectChain() { +type OnSelectChainHandler = (chain: ChainInfo) => void + +export function useOnSelectChain(): OnSelectChainHandler { const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() + const widgetState = useSelectTokenWidgetState() + const shouldForceOpen = + widgetState.field === Field.INPUT && + (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) return useCallback( (chain: ChainInfo) => { - updateSelectTokenWidget({ selectedTargetChainId: chain.id }) + updateSelectTokenWidget({ + selectedTargetChainId: chain.id, + open: true, + forceOpen: shouldForceOpen, + }) }, - [updateSelectTokenWidget], + [updateSelectTokenWidget, shouldForceOpen], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts index 2f1a24c8ab..a8f44c89ab 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts @@ -8,6 +8,10 @@ import { Nullish } from 'types' import { Field } from 'legacy/state/types' +import { useTradeTypeInfo } from 'modules/trade/hooks/useTradeTypeInfo' +import { useTradeTypeInfoFromUrl } from 'modules/trade/hooks/useTradeTypeInfoFromUrl' +import { TradeType } from 'modules/trade/types' + import { useCloseTokenSelectWidget } from './useCloseTokenSelectWidget' import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState' @@ -20,28 +24,41 @@ export function useOpenTokenSelectWidget(): ( const updateSelectTokenWidget = useUpdateSelectTokenWidgetState() const closeTokenSelectWidget = useCloseTokenSelectWidget() const isBridgingEnabled = useIsBridgingEnabled() + const tradeTypeInfoFromState = useTradeTypeInfo() + const tradeTypeInfoFromUrl = useTradeTypeInfoFromUrl() + const tradeTypeInfo = tradeTypeInfoFromState ?? tradeTypeInfoFromUrl + const tradeType = tradeTypeInfo?.tradeType + const shouldLockTargetChain = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS return useCallback( (selectedToken, field, oppositeToken, onSelectToken) => { const isOutputField = field === Field.OUTPUT - const selectedTargetChainId = - isOutputField && selectedToken && isBridgingEnabled ? selectedToken.chainId : undefined + const nextSelectedTargetChainId = + isOutputField && selectedToken && isBridgingEnabled && !shouldLockTargetChain + ? selectedToken.chainId + : undefined updateSelectTokenWidget({ selectedToken, field, oppositeToken, open: true, - selectedTargetChainId, + forceOpen: false, + selectedTargetChainId: nextSelectedTargetChainId, + tradeType, onSelectToken: (currency) => { - // Close the token selector regardless of network switching. - // UX: When a user picks a token (even from another network), - // the selector should close as per issue #6251 expected behavior. - closeTokenSelectWidget() + // Keep selector UX consistent with #6251: always close after a selection, even if a chain switch follows. + closeTokenSelectWidget({ overrideForceLock: true }) onSelectToken(currency) }, }) }, - [closeTokenSelectWidget, updateSelectTokenWidget, isBridgingEnabled], + [ + closeTokenSelectWidget, + updateSelectTokenWidget, + isBridgingEnabled, + shouldLockTargetChain, + tradeType, + ], ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts new file mode 100644 index 0000000000..e1ea8a97cf --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts @@ -0,0 +1,136 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' + +import { + RECENT_TOKENS_LIMIT, + buildFavoriteTokenKeys, + buildNextStoredTokens, + buildTokensByKey, + getStoredTokenKey, + hydrateStoredToken, + persistRecentTokenSelection as persistRecentTokenSelectionInternal, + persistStoredTokens, + readStoredTokens, + type StoredRecentTokensByChain, +} from './recentTokensStorage' + +import { getTokenUniqueKey } from '../utils/tokenKey' + +interface UseRecentTokensParams { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + activeChainId?: number + maxItems?: number +} + +export interface RecentTokensState { + recentTokens: TokenWithLogo[] + addRecentToken(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + maxItems = RECENT_TOKENS_LIMIT, +}: UseRecentTokensParams): RecentTokensState { + const [storedTokensByChain, setStoredTokensByChain] = useState(() => + readStoredTokens(maxItems), + ) + + useEffect(() => { + persistStoredTokens(storedTokensByChain) + }, [storedTokensByChain]) + + const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens]) + const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens]) + + useEffect(() => { + setStoredTokensByChain((prev) => { + const nextEntries: StoredRecentTokensByChain = {} + let didChange = false + + for (const [chainKey, tokens] of Object.entries(prev)) { + const chainId = Number(chainKey) + const filtered = tokens.filter((token) => !favoriteKeys.has(getStoredTokenKey(token))) + + if (filtered.length) { + nextEntries[chainId] = filtered + } + + didChange = didChange || filtered.length !== tokens.length + } + + return didChange ? nextEntries : prev + }) + }, [favoriteKeys]) + + const recentTokens = useMemo(() => { + const chainEntries = activeChainId ? storedTokensByChain[activeChainId] ?? [] : [] + const seenKeys = new Set() + const result: TokenWithLogo[] = [] + + for (const entry of chainEntries) { + const key = getStoredTokenKey(entry) + + if (seenKeys.has(key) || favoriteKeys.has(key)) { + continue + } + + const hydrated = hydrateStoredToken(entry, tokensByKey.get(key)) + + if (hydrated) { + result.push(hydrated) + seenKeys.add(key) + } + + if (result.length >= maxItems) { + break + } + } + + return result + }, [activeChainId, favoriteKeys, maxItems, storedTokensByChain, tokensByKey]) + + const addRecentToken = useCallback( + (token: TokenWithLogo) => { + if (favoriteKeys.has(getTokenUniqueKey(token))) { + return + } + + setStoredTokensByChain((prev) => { + const next = buildNextStoredTokens(prev, token, maxItems) + + persistStoredTokens(next) + + return next + }) + }, + [favoriteKeys, maxItems], + ) + + const clearRecentTokens = useCallback(() => { + if (!activeChainId) { + return + } + + setStoredTokensByChain((prev) => { + const chainEntries = prev[activeChainId] + + if (!chainEntries?.length) { + return prev + } + + const next: StoredRecentTokensByChain = { ...prev, [activeChainId]: [] } + persistStoredTokens(next) + + return next + }) + }, [activeChainId]) + + return { recentTokens, addRecentToken, clearRecentTokens } +} + +export { persistRecentTokenSelectionInternal as persistRecentTokenSelection } diff --git a/apps/cowswap-frontend/src/modules/tokensList/index.ts b/apps/cowswap-frontend/src/modules/tokensList/index.ts index c38c9b46b9..648d15da92 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/index.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts @@ -11,3 +11,4 @@ export { useUpdateSelectTokenWidgetState } from './hooks/useUpdateSelectTokenWid export { useOnTokenListAddingError } from './hooks/useOnTokenListAddingError' export { useTokenListAddingError } from './hooks/useTokenListAddingError' export { useSourceChainId } from './hooks/useSourceChainId' +export { useChainsToSelect } from './hooks/useChainsToSelect' diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx index dea219daae..afaf243779 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx @@ -36,7 +36,7 @@ export function AddIntermediateTokenModal({ onDismiss, onBack, onImport }: AddIn importTokenCallback([tokenToImport]) onImport(tokenToImport) // when we import the token from here, we don't need to import it again in the SelectTokenWidget - closeTokenSelectWidget() + closeTokenSelectWidget({ overrideForceLock: true }) } }, [onImport, importTokenCallback, closeTokenSelectWidget, tokenToImport]) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx new file mode 100644 index 0000000000..d0da81a2e8 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx @@ -0,0 +1,118 @@ +import { ReactNode, useMemo, useState } from 'react' + +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { BackButton } from '@cowprotocol/ui' + +import * as styledEl from './styled' + +import { ChainsToSelectState } from '../../types' +import { ChainsSelector } from '../ChainsSelector' + +const EMPTY_CHAINS: ChainInfo[] = [] + +export interface ChainPanelProps { + title: string + chainsState: ChainsToSelectState | undefined + onSelectChain(chain: ChainInfo): void + variant?: 'default' | 'fullscreen' + onClose?(): void +} + +export function ChainPanel({ + title, + chainsState, + onSelectChain, + variant = 'default', + onClose, +}: ChainPanelProps): ReactNode { + const [chainQuery, setChainQuery] = useState('') + const chains = chainsState?.chains ?? EMPTY_CHAINS + const isLoading = chainsState?.isLoading ?? false + const normalizedChainQuery = chainQuery.trim().toLowerCase() + + const filteredChains = useMemo( + () => filterChainsByQuery(chains, normalizedChainQuery), + [chains, normalizedChainQuery], + ) + + const { showSearchEmptyState, showUnavailableState } = getEmptyStateFlags({ + filteredChainsLength: filteredChains.length, + isLoading, + normalizedChainQuery, + totalChains: chains.length, + }) + + return ( + + + + setChainQuery(event.target.value)} + placeholder="Search network" + /> + + + + {showUnavailableState && No networks available for this trade.} + {showSearchEmptyState && No networks match "{chainQuery}".} + + + ) +} + +interface ChainPanelHeaderProps { + title: string + variant: 'default' | 'fullscreen' + onClose?: () => void +} + +function ChainPanelHeader({ title, variant, onClose }: ChainPanelHeaderProps): ReactNode { + const isFullscreen = variant === 'fullscreen' + + return ( + + {isFullscreen && onClose ? : null} + {title} + {isFullscreen && onClose ? : null} + + ) +} + +function filterChainsByQuery(chains: ChainInfo[], normalizedChainQuery: string): ChainInfo[] { + if (!chains.length || !normalizedChainQuery) { + return chains + } + + return chains.filter((chain) => { + const labelMatch = chain.label.toLowerCase().includes(normalizedChainQuery) + const idMatch = String(chain.id).includes(normalizedChainQuery) + + return labelMatch || idMatch + }) +} + +function getEmptyStateFlags({ + filteredChainsLength, + isLoading, + normalizedChainQuery, + totalChains, +}: { + filteredChainsLength: number + isLoading: boolean + normalizedChainQuery: string + totalChains: number +}): { showSearchEmptyState: boolean; showUnavailableState: boolean } { + const hasQuery = Boolean(normalizedChainQuery) + + return { + // When bridge networks are unavailable we still render the panel but show the fallback copy + showUnavailableState: !isLoading && totalChains === 0 && !hasQuery, + showSearchEmptyState: !isLoading && filteredChainsLength === 0 && hasQuery, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts new file mode 100644 index 0000000000..c52a99f92c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts @@ -0,0 +1,122 @@ +import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { IconButton } from '../commonElements' + +export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>` + width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '200px')}; + height: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : 'auto')}; + flex-shrink: 0; + background: var(${UI.COLOR_PAPER_DARKER}); + border-left: ${({ $variant }) => ($variant === 'fullscreen' ? 'none' : `1px solid var(${UI.COLOR_BORDER})`)}; + padding: ${({ $variant }) => ($variant === 'fullscreen' ? '20px 16px' : '16px 10px')}; + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; + border-top-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')}; + border-bottom-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')}; + + ${Media.upToMedium()} { + width: 100%; + border-left: none; + border-top: 1px solid var(${UI.COLOR_BORDER}); + border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')}; + } + + ${Media.upToSmall()} { + padding: ${({ $variant }) => ($variant === 'fullscreen' ? '14px' : '16px')}; + background: var(${UI.COLOR_PAPER}); + } +` + +export const PanelHeader = styled.div<{ $isFullscreen?: boolean }>` + display: flex; + align-items: center; + justify-content: ${({ $isFullscreen }) => ($isFullscreen ? 'space-between' : 'space-between')}; + gap: 12px; + padding: ${({ $isFullscreen }) => ($isFullscreen ? '4px 0' : '0')}; +` + +export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>` + font-size: ${({ $isFullscreen }) => ($isFullscreen ? '18px' : '14px')}; + font-weight: ${({ $isFullscreen }) => ($isFullscreen ? 600 : 500)}; + margin: 0; + flex: 1; + text-align: ${({ $isFullscreen }) => ($isFullscreen ? 'left' : 'center')}; + color: ${({ $isFullscreen }) => ($isFullscreen ? `var(${UI.COLOR_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)}; +` + +export const PanelCloseButton = styled(IconButton)` + flex-shrink: 0; + border-radius: 50%; + width: 32px; + height: 32px; + background: var(${UI.COLOR_PAPER}); +` + +export const PanelSearchInputWrapper = styled.div` + --min-height: 36px; + min-height: var(--min-height); + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + background: transparent; + border-radius: var(--min-height); + padding: 0 10px; + color: var(${UI.COLOR_TEXT}); + + ${Media.upToSmall()} { + --min-height: 46px; + border: none; + padding: 0; + background: transparent; + color: inherit; + + > div { + width: 100%; + background: var(${UI.COLOR_PAPER_DARKER}); + border-radius: var(--min-height); + height: var(--min-height); + display: flex; + align-items: center; + padding: 0 14px; + font-size: 15px; + color: inherit; + } + + input { + background: transparent; + height: 100%; + } + } +` + +export const PanelSearchInput = styled(UISearchInput)` + width: 100%; + color: inherit; + border: none; + background: transparent; + font-size: 14px; + font-weight: 400; + + ${Media.upToSmall()} { + font-size: 16px; + } +` + +export const PanelList = styled.div` + flex: 1; + overflow-y: auto; + padding-right: 8px; + margin-right: -8px; + box-sizing: content-box; + ${({ theme }) => theme.colorScrollbar}; + scrollbar-gutter: stable; +` + +export const EmptyState = styled.div` + text-align: center; + font-size: 14px; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + padding: 32px 8px; +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx index c41203af12..7a12265d19 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx @@ -1,112 +1,188 @@ import { ReactNode } from 'react' -import { useMediaQuery, useTheme } from '@cowprotocol/common-hooks' -import { ChainInfo } from '@cowprotocol/cow-sdk' -import { HoverTooltip, Media } from '@cowprotocol/ui' +import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg' +import { useTheme } from '@cowprotocol/common-hooks' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' +import { UI } from '@cowprotocol/ui' -import { Trans } from '@lingui/react/macro' -import { Menu, MenuButton, MenuItem } from '@reach/menu-button' -import { Check, ChevronDown, ChevronUp } from 'react-feather' +import SVG from 'react-inlinesvg' import * as styledEl from './styled' -// Number of skeleton shimmers to show during loading state +import type { ChainAccentVars } from './styled' + const LOADING_ITEMS_COUNT = 10 +const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index) -const LoadingShimmerElements = ( - - {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => ( - - ))} - -) +const CHAIN_ACCENT_VAR_MAP: Record = { + [SupportedChainId.MAINNET]: { + backgroundVar: UI.COLOR_CHAIN_ETHEREUM_BG, + borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER, + accentColorVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT, + }, + [SupportedChainId.BNB]: { + backgroundVar: UI.COLOR_CHAIN_BNB_BG, + borderVar: UI.COLOR_CHAIN_BNB_BORDER, + accentColorVar: UI.COLOR_CHAIN_BNB_ACCENT, + }, + [SupportedChainId.BASE]: { + backgroundVar: UI.COLOR_CHAIN_BASE_BG, + borderVar: UI.COLOR_CHAIN_BASE_BORDER, + accentColorVar: UI.COLOR_CHAIN_BASE_ACCENT, + }, + [SupportedChainId.ARBITRUM_ONE]: { + backgroundVar: UI.COLOR_CHAIN_ARBITRUM_BG, + borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER, + accentColorVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT, + }, + [SupportedChainId.POLYGON]: { + backgroundVar: UI.COLOR_CHAIN_POLYGON_BG, + borderVar: UI.COLOR_CHAIN_POLYGON_BORDER, + accentColorVar: UI.COLOR_CHAIN_POLYGON_ACCENT, + }, + [SupportedChainId.AVALANCHE]: { + backgroundVar: UI.COLOR_CHAIN_AVALANCHE_BG, + borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER, + accentColorVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT, + }, + [SupportedChainId.GNOSIS_CHAIN]: { + backgroundVar: UI.COLOR_CHAIN_GNOSIS_BG, + borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER, + accentColorVar: UI.COLOR_CHAIN_GNOSIS_ACCENT, + }, + [SupportedChainId.LENS]: { + backgroundVar: UI.COLOR_CHAIN_LENS_BG, + borderVar: UI.COLOR_CHAIN_LENS_BORDER, + accentColorVar: UI.COLOR_CHAIN_LENS_ACCENT, + }, + [SupportedChainId.SEPOLIA]: { + backgroundVar: UI.COLOR_CHAIN_SEPOLIA_BG, + borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER, + accentColorVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT, + }, + [SupportedChainId.LINEA]: { + backgroundVar: UI.COLOR_CHAIN_LINEA_BG, + borderVar: UI.COLOR_CHAIN_LINEA_BORDER, + accentColorVar: UI.COLOR_CHAIN_LINEA_ACCENT, + }, + [SupportedChainId.PLASMA]: { + backgroundVar: UI.COLOR_CHAIN_PLASMA_BG, + borderVar: UI.COLOR_CHAIN_PLASMA_BORDER, + accentColorVar: UI.COLOR_CHAIN_PLASMA_ACCENT, + }, +} export interface ChainsSelectorProps { chains: ChainInfo[] onSelectChain: (chainId: ChainInfo) => void defaultChainId?: ChainInfo['id'] - visibleNetworkIcons?: number // Number of network icons to display before showing "More" dropdown isLoading: boolean } -export function ChainsSelector({ +export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoading }: ChainsSelectorProps): ReactNode { + const { darkMode } = useTheme() + + if (isLoading) { + return + } + + return ( + + ) +} + +function ChainsLoadingList(): ReactNode { + const skeletonRows = renderChainSkeletonRows() + + return {skeletonRows} +} + +function renderChainSkeletonRows(): ReactNode[] { + const elements: ReactNode[] = [] + + for (const index of LOADING_SKELETON_INDICES) { + elements.push( + + + + , + ) + } + + return elements +} + +interface ChainsListProps { + chains: ChainInfo[] + defaultChainId?: ChainInfo['id'] + onSelectChain(chain: ChainInfo): void + isDarkMode: boolean +} + +function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode { + const chainButtons = renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode }) + + return {chainButtons} +} + +interface ChainButtonsRenderProps extends ChainsListProps {} + +function renderChainButtons({ chains, - onSelectChain, defaultChainId, - isLoading, - visibleNetworkIcons = LOADING_ITEMS_COUNT, -}: ChainsSelectorProps): ReactNode { - const isMobile = useMediaQuery(Media.upToSmall(false)) - - const theme = useTheme() + onSelectChain, + isDarkMode, +}: ChainButtonsRenderProps): ReactNode[] { + const elements: ReactNode[] = [] - if (isLoading) { - return LoadingShimmerElements + for (const chain of chains) { + elements.push( + , + ) } - const shouldDisplayMore = !isMobile && chains.length > visibleNetworkIcons - const visibleChains = isMobile ? chains : chains.slice(0, visibleNetworkIcons) - // Find the selected chain that isn't visible in the main row (so we can display it in the dropdown) - const selectedMenuChain = !isMobile && chains.find((i) => i.id === defaultChainId && !visibleChains.includes(i)) + return elements +} + +export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined { + return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId] +} + +interface ChainButtonProps { + chain: ChainInfo + isActive: boolean + isDarkMode: boolean + onSelectChain(chain: ChainInfo): void +} + +function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButtonProps): ReactNode { + const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light + const accent = getChainAccent(chain.id) return ( - - {visibleChains.map((chain) => ( - - onSelectChain(chain)} iconOnly> - {chain.label} - - - ))} - {shouldDisplayMore && ( - - {({ isOpen }) => ( - <> - - {selectedMenuChain ? ( - {selectedMenuChain.label} - ) : isOpen ? ( - - Less - - ) : ( - - More - - )} - {isOpen ? : } - - - {chains.map((chain) => ( - onSelectChain(chain)} - active$={defaultChainId === chain.id} - iconSize={21} - tabIndex={0} - borderless - > - {chain.label} - {chain.label} - {chain.id === defaultChainId && } - - ))} - - - )} - + onSelectChain(chain)} + active$={isActive} + accent$={accent} + aria-pressed={isActive} + > + + + {chain.label} + + {chain.label} + + {isActive && ( + )} - + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx index 7b8260b2e8..9391d6976f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx @@ -1,118 +1,136 @@ import { UI } from '@cowprotocol/ui' -import { Media } from '@cowprotocol/ui' -import { MenuList } from '@reach/menu-button' import styled from 'styled-components/macro' -export const Wrapper = styled.div` - display: flex; - flex-flow: row; - gap: 8px; - width: 100%; +import { blankButtonMixin } from '../commonElements' - ${Media.upToSmall()} { - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ +export interface ChainAccentVars { + backgroundVar: UI + borderVar: UI + accentColorVar?: UI +} - &::-webkit-scrollbar { - display: none; - } - } +const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})` +const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_80})` +const fallbackHoverBorder = `var(${UI.COLOR_PRIMARY_OPACITY_70})` + +const getBackground = (accent$?: ChainAccentVars, fallback = fallbackBackground): string => + accent$ ? `var(${accent$.backgroundVar})` : fallback + +const getBorder = (accent$?: ChainAccentVars, fallback = fallbackBorder): string => + accent$ ? `var(${accent$.borderVar})` : fallback + +const getAccentColor = (accent$?: ChainAccentVars): string | undefined => + accent$?.accentColorVar ? `var(${accent$.accentColorVar})` : undefined + +export const List = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; ` -export const ChainItem = styled.button<{ - active$?: boolean - iconOnly?: boolean - iconSize?: number - borderless?: boolean - isLoading?: boolean -}>` - --itemSize: 38px; - width: ${({ iconOnly }) => (iconOnly ? 'var(--itemSize)' : 'auto')}; - height: var(--itemSize); +export const ChainButton = styled.button<{ active$?: boolean; accent$?: ChainAccentVars }>` + --min-height: 46px; + ${blankButtonMixin}; + + width: 100%; display: flex; align-items: center; - justify-content: ${({ iconOnly }) => (iconOnly ? 'center' : 'flex-start')}; - gap: 4px; - font-weight: 500; - font-size: 13px; - border-radius: 14px; - padding: 6px; - border: ${({ active$, borderless }) => - borderless ? 'none' : `1px solid var(${active$ ? UI.COLOR_PRIMARY_OPACITY_70 : UI.COLOR_TEXT_OPACITY_10})`}; - cursor: ${({ isLoading }) => (isLoading ? 'default' : 'pointer')}; - line-height: 1; - outline: none; - margin: 0; - vertical-align: top; - background: ${({ active$ }) => (active$ ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; - color: var(${UI.COLOR_TEXT_OPACITY_70}); - box-shadow: ${({ active$ }) => - active$ - ? `0px -1px 0px 0px var(${UI.COLOR_TEXT_OPACITY_10}) inset, - 0px 0px 0px 1px var(${UI.COLOR_PRIMARY_OPACITY_10}) inset, - 0px 1px 3px 0px var(${UI.COLOR_TEXT_OPACITY_10})` - : '0'}; + justify-content: space-between; + gap: 16px; + padding: 8px 12px; + min-height: var(--min-height); + border-radius: var(--min-height); + border: 1px solid ${({ active$, accent$ }) => (active$ ? getBorder(accent$) : 'transparent')}; + background: ${({ active$, accent$ }) => (active$ ? getBackground(accent$) : 'transparent')}; + box-shadow: ${({ active$, accent$ }) => (active$ ? `0 0 0 1px ${getBackground(accent$)} inset` : 'none')}; + cursor: pointer; transition: - color 0.2s ease-in-out, - background 0.2s ease-in-out, - box-shadow 0.2s ease-in-out; - overflow: ${({ isLoading }) => (isLoading ? 'hidden' : 'visible')}; - position: relative; + border 0.2s ease, + background 0.2s ease, + box-shadow 0.2s ease; &:hover { - border-color: ${({ isLoading }) => - isLoading ? `var(${UI.COLOR_TEXT_OPACITY_10})` : `var(${UI.COLOR_TEXT_OPACITY_25})`}; - background: ${({ isLoading }) => (isLoading ? 'transparent' : `var(${UI.COLOR_PAPER_DARKER})`)}; - color: ${({ isLoading }) => (isLoading ? `var(${UI.COLOR_TEXT_OPACITY_70})` : `var(${UI.COLOR_TEXT})`)}; + border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)}; + background: ${({ accent$ }) => getBackground(accent$)}; } - > img { - width: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)}; - height: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)}; - border-radius: 100%; + &:focus-visible { + outline: none; + border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)}; } +` - > span { - padding: 0 4px; - } +export const ChainInfo = styled.div` + display: flex; + align-items: center; + gap: 12px; +` + +export const ChainLogo = styled.div` + --size: 28px; + width: var(--size); + height: var(--size); + border-radius: var(--size); + overflow: hidden; + background: var(${UI.COLOR_PAPER}); + display: flex; + align-items: center; + justify-content: center; - &:before { - content: ''; - width: var(--itemSize); - height: var(--itemSize); - display: ${({ isLoading }) => (isLoading ? 'block' : 'none')}; - transform: translateX(-100%); - position: absolute; - left: 0; - top: 0; - ${({ theme, isLoading }) => isLoading && theme.shimmer}; + > img { + width: 100%; + height: 100%; + object-fit: cover; } ` -export const MenuWrapper = styled.div` - position: relative; +export const ChainText = styled.span` + font-weight: 500; + font-size: 15px; + color: var(${UI.COLOR_TEXT}); ` -export const MenuListStyled = styled(MenuList)` +export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: string }>` + width: 20px; + height: 20px; display: flex; - justify-content: flex-start; - align-items: stretch; - flex-direction: column; - gap: 4px; - position: absolute; - right: 0; - top: 40px; - z-index: 12; - border-radius: 12px; - padding: 10px; - background: var(${UI.COLOR_PAPER}); - box-shadow: var(${UI.BOX_SHADOW}); + align-items: center; + justify-content: center; + color: ${({ color$, accent$ }) => getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)}; + + > svg { + width: 16px; + height: 16px; + display: block; + } + + > svg > path { + fill: currentColor; + } +` + +export const LoadingRow = styled.div` + width: 100%; + display: flex; + align-items: center; + gap: 16px; + padding: 10px 14px; + border-radius: 18px; border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10}); - outline: none; - overflow: hidden; - min-width: 200px; +` + +export const LoadingCircle = styled.div` + width: 36px; + height: 36px; + border-radius: 50%; + ${({ theme }) => theme.shimmer}; +` + +export const LoadingBar = styled.div` + flex: 1; + height: 14px; + border-radius: 8px; + ${({ theme }) => theme.shimmer}; ` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx index b7c3623b89..b4c9808b57 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx @@ -1,61 +1,86 @@ import { ReactNode } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' +import { areAddressesEqual, getCurrencyAddress } from '@cowprotocol/common-utils' import { TokenLogo } from '@cowprotocol/tokens' -import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui' +import { TokenSymbol } from '@cowprotocol/ui' import { Trans } from '@lingui/react/macro' import { Link } from 'react-router' import * as styledEl from './styled' +import { SelectTokenContext } from '../../types' export interface FavoriteTokensListProps { tokens: TokenWithLogo[] + selectTokenContext: SelectTokenContext hideTooltip?: boolean - selectedToken?: string - - onSelectToken(token: TokenWithLogo): void } export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode { - const { tokens, hideTooltip, selectedToken, onSelectToken } = props + const { tokens, selectTokenContext, hideTooltip } = props + + if (!tokens.length) { + return null + } return ( -
- -

+ + + Favorite tokens -

- {!hideTooltip && ( - - Your favorite saved tokens. Edit this list in the Tokens page. - - } - /> - )} -
- - {tokens.map((token) => { - const isTokenSelected = token.address.toLowerCase() === selectedToken?.toLowerCase() - - return ( - onSelectToken(token)} - > - - - - ) - })} - -
+ + {!hideTooltip && } + + {renderFavoriteTokenItems(tokens, selectTokenContext)} + + ) +} + +function FavoriteTokensTooltip(): ReactNode { + return ( + + Your favorite saved tokens. Edit this list in the Tokens page. + + } + /> ) } + +function renderFavoriteTokenItems(tokens: TokenWithLogo[], context: SelectTokenContext): ReactNode[] { + const { selectedToken } = context + const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined + + return tokens.map((token) => { + const isSelected = + !!selectedToken && + token.chainId === selectedToken.chainId && + !!selectedAddress && + areAddressesEqual(token.address, selectedAddress) + + const handleClick = (): void => { + if (isSelected) { + return + } + context.onTokenListItemClick?.(token) + context.onSelectToken(token) + } + + return ( + + + + + ) + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts index ee278a509a..71bc8c292c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts @@ -1,18 +1,25 @@ -import { Media, UI } from '@cowprotocol/ui' +import { HelpTooltip, Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' -export const Header = styled.div` +export const Section = styled.div` + padding: 0 14px 14px; + + ${Media.upToSmall()} { + padding: 8px 14px 4px; + } +` + +export const TitleRow = styled.div` display: flex; - gap: 5px; - flex-direction: row; align-items: center; +` - > h4 { - font-size: 14px; - font-weight: 500; - margin: 0; - } +export const Title = styled.h4` + font-size: 14px; + font-weight: 500; + margin: 0; + color: var(${UI.COLOR_TEXT_OPACITY_70}); ` export const List = styled.div` @@ -25,9 +32,8 @@ export const List = styled.div` width: 0; min-width: 100%; flex-wrap: nowrap; - overflow-x: scroll; + overflow-x: auto; overflow-y: hidden; - padding: 10px 0; -webkit-overflow-scrolling: touch; @@ -44,9 +50,8 @@ export const List = styled.div` } ` -export const TokensItem = styled.button` +export const TokenButton = styled.button` display: inline-flex; - flex-direction: row; align-items: center; gap: 6px; justify-content: center; @@ -58,9 +63,9 @@ export const TokensItem = styled.button` border: 1px solid var(${UI.COLOR_PAPER_DARKER}); font-weight: 500; font-size: 16px; - cursor: ${({ disabled }) => (disabled ? '' : 'pointer')}; - background: ${({ disabled }) => disabled && `var(${UI.COLOR_PAPER_DARKER})`}; - opacity: ${({ disabled }) => (disabled ? 0.6 : 1)}; + cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')}; + background: ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')}; + opacity: ${({ disabled }) => (disabled ? 0.65 : 1)}; transition: border var(${UI.ANIMATION_DURATION}) ease-in-out; white-space: nowrap; @@ -72,3 +77,12 @@ export const TokensItem = styled.button` border: 1px solid ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : `var(${UI.COLOR_PRIMARY})`)}; } ` + +export const FavoriteTooltip = styled(HelpTooltip)` + color: var(${UI.COLOR_TEXT_OPACITY_50}); + transition: color 0.2s ease-in-out; + + &:hover { + color: var(${UI.COLOR_TEXT}); + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/index.tsx index bad00ff4e3..aebb2411a7 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/index.tsx @@ -1,3 +1,5 @@ +import { ReactNode } from 'react' + import { TokenWithLogo } from '@cowprotocol/common-const' import { Trans } from '@lingui/react/macro' @@ -13,14 +15,15 @@ export interface ImportTokenItemProps { importToken?(token: TokenWithLogo): void existing?: true shadowed?: boolean + wrapperId?: string + isFirstInSection?: boolean + isLastInSection?: boolean } -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function ImportTokenItem(props: ImportTokenItemProps) { - const { token, importToken, shadowed, existing } = props +export function ImportTokenItem(props: ImportTokenItemProps): ReactNode { + const { token, importToken, shadowed, existing, wrapperId, isFirstInSection, isLastInSection } = props return ( - +
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts index 068ff64547..a1700401b3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts @@ -2,21 +2,19 @@ import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' -export const Wrapper = styled.div` +export const Wrapper = styled.div<{ $isFirst?: boolean; $isLast?: boolean }>` display: flex; flex-direction: row; justify-content: space-between; align-items: center; - padding: 0 20px; - margin-bottom: 20px; + padding: ${({ $isFirst, $isLast }) => + `${$isFirst ? '20px' : '0'} 20px ${$isLast ? '0' : '20px'} 20px`}; ${Media.upToSmall()} { - padding: 0 14px; + padding: ${({ $isFirst, $isLast }) => + `${$isFirst ? '20px' : '0'} 14px ${$isLast ? '0' : '20px'} 14px`}; } - &:last-child { - margin-bottom: 0; - } ` export const ActiveToken = styled.div` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx index 0a742d7f6f..5f63784a94 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx @@ -1,51 +1,20 @@ -import { MouseEventHandler, ReactNode, useCallback } from 'react' +import { ReactNode } from 'react' import { BalancesState } from '@cowprotocol/balances-and-allowances' -import { LpToken, TokenWithLogo } from '@cowprotocol/common-const' +import { LpToken } from '@cowprotocol/common-const' import { useMediaQuery } from '@cowprotocol/common-hooks' -import { TokenLogo } from '@cowprotocol/tokens' -import { LoadingRows, LoadingRowSmall, Media, TokenAmount, TokenName, TokenSymbol } from '@cowprotocol/ui' -import { CurrencyAmount } from '@uniswap/sdk-core' +import { Media } from '@cowprotocol/ui' -import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' -import { VirtualItem } from '@tanstack/react-virtual' -import { Info } from 'react-feather' import { PoolInfoStates } from 'modules/yield/shared' import { VirtualList } from 'common/pure/VirtualList' -import { - CreatePoolLink, - EmptyList, - ListHeader, - ListItem, - LpTokenBalance, - LpTokenInfo, - LpTokenTooltip, - LpTokenWrapper, - LpTokenYieldPercentage, - MobileCard, - MobileCardLabel, - MobileCardRow, - MobileCardValue, - NoPoolWrapper, - Wrapper, -} from './styled' +import { useLpTokenRowRenderer } from './rowRenderer' +import { CreatePoolLink, EmptyList, ListHeader, NoPoolWrapper, Wrapper } from './styled' -const LoadingElement = ( - - - -) - -const MobileCardRowItem: React.FC<{ label: string; value: ReactNode }> = ({ label, value }) => ( - - {label}: - {value} - -) +import type { TokenSelectionHandler } from '../../types' interface LpTokenListsProps { account: string | undefined @@ -53,13 +22,10 @@ interface LpTokenListsProps { balancesState: BalancesState displayCreatePoolBanner: boolean poolsInfo: PoolInfoStates | undefined - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler openPoolPage(poolAddress: string): void } -// TODO: Break down this large function into smaller functions -// TODO: Add proper return type annotation -// eslint-disable-next-line max-lines-per-function, @typescript-eslint/explicit-function-return-type export function LpTokenLists({ account, onSelectToken, @@ -68,89 +34,17 @@ export function LpTokenLists({ balancesState, displayCreatePoolBanner, poolsInfo, -}: LpTokenListsProps) { +}: LpTokenListsProps): ReactNode { const { values: balances } = balancesState const isMobile = useMediaQuery(Media.upToSmall(false)) - - const getItemView = useCallback( - // TODO: Break down this large function into smaller functions - // TODO: Reduce function complexity by extracting logic - // eslint-disable-next-line complexity - (lpTokens: LpToken[], item: VirtualItem) => { - const token = lpTokens[item.index] - - const tokenAddressLower = token.address.toLowerCase() - const balance = balances ? balances[tokenAddressLower] : undefined - const balanceAmount = balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined - const info = poolsInfo?.[tokenAddressLower]?.info - - const onInfoClick: MouseEventHandler = (e) => { - e.stopPropagation() - openPoolPage(tokenAddressLower) - } - - const commonContent = ( - <> - - - - - -

- -

-
- - ) - - const BalanceDisplay = balanceAmount ? : account ? LoadingElement : null - - if (isMobile) { - return ( - onSelectToken(token)} - > - {commonContent} - - - - Pool details - - - } - /> - - ) - } - - return ( - onSelectToken(token)} - > - {commonContent} - {BalanceDisplay} - {info?.apy ? `${info.apy}%` : ''} - - - - - ) - }, - [balances, onSelectToken, poolsInfo, openPoolPage, isMobile, account], - ) + const getItemView = useLpTokenRowRenderer({ + balances, + poolsInfo, + openPoolPage, + onSelectToken, + isMobile, + account, + }) return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/rowRenderer.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/rowRenderer.tsx new file mode 100644 index 0000000000..c1552968ec --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/rowRenderer.tsx @@ -0,0 +1,255 @@ +import { MouseEventHandler, ReactNode, useMemo } from 'react' + +import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { LpToken } from '@cowprotocol/common-const' +import { TokenLogo } from '@cowprotocol/tokens' +import { LoadingRows, LoadingRowSmall, TokenAmount, TokenName, TokenSymbol } from '@cowprotocol/ui' +import { CurrencyAmount } from '@uniswap/sdk-core' + +import { VirtualItem } from '@tanstack/react-virtual' +import { Info } from 'react-feather' + +import { PoolInfoStates } from 'modules/yield/shared' + +import { + ListItem, + LpTokenBalance, + LpTokenInfo, + LpTokenTooltip, + LpTokenWrapper, + LpTokenYieldPercentage, + MobileCard, + MobileCardLabel, + MobileCardRow, + MobileCardValue, +} from './styled' + +import type { TokenSelectionHandler } from '../../types' + +interface LpTokenRowRendererParams { + balances: BalancesState['values'] + poolsInfo: PoolInfoStates | undefined + openPoolPage(poolAddress: string): void + onSelectToken: TokenSelectionHandler + isMobile: boolean + account: string | undefined +} + +const LoadingElement = ( + + + +) + +const MobileCardRowItem: React.FC<{ label: string; value: ReactNode }> = ({ label, value }) => ( + + {label}: + {value} + +) + +export function useLpTokenRowRenderer({ + balances, + poolsInfo, + openPoolPage, + onSelectToken, + isMobile, + account, +}: LpTokenRowRendererParams): (lpTokens: LpToken[], item: VirtualItem) => ReactNode { + return useMemo( + () => + createLpTokenRowRenderer({ + balances, + poolsInfo, + openPoolPage, + onSelectToken, + isMobile, + account, + }), + [balances, poolsInfo, openPoolPage, onSelectToken, isMobile, account], + ) +} + +function createLpTokenRowRenderer(params: LpTokenRowRendererParams): (lpTokens: LpToken[], item: VirtualItem) => ReactNode { + return LpTokenRowRendererFactory(params) +} + +type LpTokenRowRendererFactoryParams = LpTokenRowRendererParams + +function LpTokenRowRendererFactory({ + balances, + poolsInfo, + openPoolPage, + onSelectToken, + isMobile, + account, +}: LpTokenRowRendererFactoryParams): (lpTokens: LpToken[], item: VirtualItem) => ReactNode { + return (lpTokens: LpToken[], item: VirtualItem): ReactNode => + renderLpTokenRow({ + lpTokens, + item, + balances, + poolsInfo, + openPoolPage, + onSelectToken, + isMobile, + account, + }) +} + +interface RenderLpTokenRowParams extends LpTokenRowRendererParams { + lpTokens: LpToken[] + item: VirtualItem +} + +function renderLpTokenRow({ + lpTokens, + item, + balances, + poolsInfo, + openPoolPage, + onSelectToken, + isMobile, + account, +}: RenderLpTokenRowParams): ReactNode { + const token = lpTokens[item.index] + const tokenAddressLower = token.address.toLowerCase() + const balance = balances ? balances[tokenAddressLower] : undefined + const balanceAmount = balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined + const info = poolsInfo?.[tokenAddressLower]?.info + const onInfoClick = createInfoClickHandler(openPoolPage, tokenAddressLower) + const balanceDisplay = balanceAmount ? : account ? LoadingElement : null + + return ( + + ) +} + +function createInfoClickHandler( + openPoolPage: (poolAddress: string) => void, + tokenAddress: string, +): MouseEventHandler { + return (event) => { + event.stopPropagation() + openPoolPage(tokenAddress) + } +} + +interface LpTokenRowProps { + token: LpToken + balanceDisplay: ReactNode + apy?: number + onInfoClick: MouseEventHandler + onSelectToken: TokenSelectionHandler +} + +interface LpTokenRowRendererProps extends LpTokenRowProps { + isMobile: boolean +} + +function LpTokenRowRenderer({ + token, + balanceDisplay, + apy, + onInfoClick, + onSelectToken, + isMobile, +}: LpTokenRowRendererProps): ReactNode { + if (isMobile) { + return ( + + ) + } + + return ( + + ) +} + +function LpTokenDesktopRow({ token, balanceDisplay, apy, onInfoClick, onSelectToken }: LpTokenRowProps): ReactNode { + return ( + onSelectToken(token)} + > + + + + {balanceDisplay} + {formatApy(apy)} + + + + + ) +} + +function LpTokenMobileCard({ token, balanceDisplay, apy, onInfoClick, onSelectToken }: LpTokenRowProps): ReactNode { + return ( + onSelectToken(token)} + > + + + + + + + Pool details + + + } + /> + + ) +} + +function LpTokenSummary({ token, isMobile }: { token: LpToken; isMobile?: boolean }): ReactNode { + return ( + <> + + + + + +

+ +

+
+ + ) +} + +function formatApy(apy: number | undefined): ReactNode { + return apy ? `${apy}%` : '' +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx new file mode 100644 index 0000000000..bae7db6e40 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx @@ -0,0 +1,108 @@ +import { ReactNode, useEffect, useMemo, useRef } from 'react' + +import { useTheme } from '@cowprotocol/common-hooks' +import { ChainInfo } from '@cowprotocol/cow-sdk' + +import { ChevronDown } from 'react-feather' + +import * as styledEl from './mobileChainSelector.styled' + +import { ChainsToSelectState } from '../../types' +import { sortChainsByDisplayOrder } from '../../utils/sortChainsByDisplayOrder' +import { getChainAccent } from '../ChainsSelector' + +interface MobileChainSelectorProps { + chainsState: ChainsToSelectState + label?: string + onSelectChain(chain: ChainInfo): void + onOpenPanel(): void +} + +export function MobileChainSelector({ + chainsState, + label, + onSelectChain, + onOpenPanel, +}: MobileChainSelectorProps): ReactNode { + const scrollRef = useRef(null) + const orderedChains = useMemo( + () => + sortChainsByDisplayOrder(chainsState.chains ?? [], { + pinChainId: chainsState.defaultChainId, + }), + [chainsState.chains, chainsState.defaultChainId], + ) + + const totalChains = chainsState.chains?.length ?? 0 + const canRenderChains = orderedChains.length > 0 + const activeChainLabel = orderedChains.find((chain) => chain.id === chainsState.defaultChainId)?.label + + useEffect(() => { + if (!scrollRef.current) { + return + } + + scrollRef.current.scrollTo({ left: 0, behavior: 'auto' }) + }, [chainsState.defaultChainId]) + + return ( + + {label ? ( + + {label} + {activeChainLabel ? ( + + {activeChainLabel} + + ) : null} + + ) : null} + + {canRenderChains ? ( + + {orderedChains.map((chain) => ( + + ))} + + ) : null} + {totalChains > 0 ? ( + + + View all ({totalChains}) + + + + ) : null} + + + ) +} + +interface ChainChipProps { + chain: ChainInfo + isActive: boolean + onSelectChain(chain: ChainInfo): void +} + +function ChainChip({ chain, isActive, onSelectChain }: ChainChipProps): ReactNode { + const { darkMode } = useTheme() + const accent = getChainAccent(chain.id) + const logoSrc = darkMode ? chain.logo.dark : chain.logo.light + + return ( + onSelectChain(chain)} + $active={isActive} + $accent={accent} + aria-pressed={isActive} + > + {chain.label} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx new file mode 100644 index 0000000000..8793810e45 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -0,0 +1,136 @@ +import { ReactNode, useMemo, useState } from 'react' + +import { BackButton } from '@cowprotocol/ui' + +import { SettingsIcon } from 'modules/trade/pure/Settings' + +import * as styledEl from './styled' + +import { SelectTokenContext } from '../../types' +import { TokensContent } from '../TokensContent' + +import type { SelectTokenModalProps } from './types' + +export function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { + const { + selectedToken, + balancesState, + unsupportedTokens, + permitCompatibleTokens, + onSelectToken, + onTokenListItemClick, + account, + tokenListTags, + } = props + + return useMemo( + () => ({ + balancesState, + selectedToken, + onSelectToken, + onTokenListItemClick, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + isWalletConnected: !!account, + }), + [ + balancesState, + selectedToken, + onSelectToken, + onTokenListItemClick, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + account, + ], + ) +} + +export function useTokenSearchInput(defaultInputValue = ''): [string, (value: string) => void, string] { + const [inputValue, setInputValue] = useState(defaultInputValue) + + return [inputValue, setInputValue, inputValue.trim()] +} + +interface TokensContentSectionProps + extends Pick< + SelectTokenModalProps, + | 'displayLpTokenLists' + | 'favoriteTokens' + | 'recentTokens' + | 'areTokensLoading' + | 'allTokens' + | 'areTokensFromBridge' + | 'hideFavoriteTokensTooltip' + | 'selectedTargetChainId' + | 'onClearRecentTokens' + > { + searchInput: string + selectTokenContext: SelectTokenContext +} + +export function TokensContentSection({ + displayLpTokenLists, + favoriteTokens, + recentTokens, + areTokensLoading, + allTokens, + searchInput, + areTokensFromBridge, + hideFavoriteTokensTooltip, + selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, +}: TokensContentSectionProps): ReactNode { + return ( + + ) +} + +interface TitleBarActionsProps { + showManageButton: boolean + onDismiss(): void + onOpenManageWidget(): void + title: string +} + +export function TitleBarActions({ + showManageButton, + onDismiss, + onOpenManageWidget, + title, +}: TitleBarActionsProps): ReactNode { + return ( + + + + {title} + + {showManageButton && ( + + + + + + )} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 4a432e5c98..08b158b753 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -1,4 +1,5 @@ import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { CHAIN_INFO } from '@cowprotocol/common-const' import { getRandomInt } from '@cowprotocol/common-utils' import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk' import { BigNumber } from '@ethersproject/bignumber' @@ -6,6 +7,8 @@ import { BigNumber } from '@ethersproject/bignumber' import styled from 'styled-components/macro' import { allTokensMock, favoriteTokensMock } from '../../mocks' +import { mapChainInfo } from '../../utils/mapChainInfo' +import { ChainPanel } from '../ChainPanel' import { SelectTokenModal, SelectTokenModalProps } from './index' @@ -13,7 +16,11 @@ const Wrapper = styled.div` max-height: 90vh; margin: 20px auto; display: flex; - width: 450px; + gap: 0; + width: 960px; + border-radius: 20px; + overflow: hidden; + border: 1px solid rgba(0, 0, 0, 0.05); ` const unsupportedTokens = {} @@ -26,19 +33,36 @@ const balances = allTokensMock.reduce((acc, token) => { return acc }, {}) -const defaultProps: SelectTokenModalProps = { +const chainsMock: ChainInfo[] = [ + SupportedChainId.MAINNET, + SupportedChainId.BASE, + SupportedChainId.ARBITRUM_ONE, + SupportedChainId.POLYGON, + SupportedChainId.AVALANCHE, + SupportedChainId.GNOSIS_CHAIN, +].reduce((acc, id) => { + const info = CHAIN_INFO[id] + + if (info) { + acc.push(mapChainInfo(id, info)) + } + + return acc +}, []) + +const favoriteTokenAddresses = new Set(favoriteTokensMock.map((token) => token.address.toLowerCase())) +const recentTokensMock = allTokensMock.filter((token) => !favoriteTokenAddresses.has(token.address.toLowerCase())).slice(0, 3) + +const defaultModalProps: SelectTokenModalProps = { tokenListTags: {}, account: undefined, permitCompatibleTokens: {}, unsupportedTokens, allTokens: allTokensMock, favoriteTokens: favoriteTokensMock, + recentTokens: recentTokensMock, areTokensLoading: false, areTokensFromBridge: false, - chainsToSelect: undefined, - onSelectChain(chain: ChainInfo) { - console.log('onSelectChain', chain) - }, tokenListCategoryState: [null, () => void 0], balancesState: { values: balances, @@ -48,9 +72,13 @@ const defaultProps: SelectTokenModalProps = { }, selectedToken, isRouteAvailable: true, + modalTitle: 'Swap from', onSelectToken() { console.log('onSelectToken') }, + onTokenListItemClick(token) { + console.log('onTokenListItemClick', token.symbol) + }, onOpenManageWidget() { console.log('onOpenManageWidget') }, @@ -62,30 +90,57 @@ const defaultProps: SelectTokenModalProps = { }, } +const defaultChainPanelProps = { + title: 'Cross chain swap', + chainsState: { + defaultChainId: SupportedChainId.MAINNET, + chains: chainsMock, + isLoading: false, + }, + onSelectChain(chain: ChainInfo) { + console.log('onSelectChain', chain) + }, +} + const Fixtures = { default: () => ( - + + + + ), + loadingSidebar: () => ( + + + + + ), + noSidebar: () => ( + + ), importByAddress: () => ( - + ), NoTokenFound: () => ( - + ), searchFromInactiveLists: () => ( - + ), searchFromExternalSources: () => ( - + ), } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index f1092ce7ec..ceb58448b3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,79 +1,19 @@ -import React, { ReactNode, useMemo, useState } from 'react' +import { ComponentProps, ReactNode } from 'react' -import { BalancesState } from '@cowprotocol/balances-and-allowances' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { ChainInfo } from '@cowprotocol/cow-sdk' -import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens' import { SearchInput } from '@cowprotocol/ui' -import { Currency } from '@uniswap/sdk-core' import { t } from '@lingui/core/macro' -import { X } from 'react-feather' -import { Nullish } from 'types' - -import { PermitCompatibleTokens } from 'modules/permit' +import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers' +import { MobileChainSelector } from './MobileChainSelector' import { SelectTokenModalContent } from './SelectTokenModalContent' import * as styledEl from './styled' import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget' -import { ChainsToSelectState, SelectTokenContext } from '../../types' -import { ChainsSelector } from '../ChainsSelector' -import { IconButton } from '../commonElements' -import { TokensContent } from '../TokensContent' - -export interface SelectTokenModalProps { - allTokens: TokenWithLogo[] - favoriteTokens: TokenWithLogo[] - balancesState: BalancesState - unsupportedTokens: UnsupportedTokensState - selectedToken?: Nullish - permitCompatibleTokens: PermitCompatibleTokens - hideFavoriteTokensTooltip?: boolean - displayLpTokenLists?: boolean - disableErc20?: boolean - account: string | undefined - chainsToSelect: ChainsToSelectState | undefined - tokenListCategoryState: [T, (category: T) => void] - defaultInputValue?: string - areTokensLoading: boolean - tokenListTags: TokenListTags - standalone?: boolean - areTokensFromBridge: boolean - isRouteAvailable: boolean | undefined - - onSelectToken(token: TokenWithLogo): void - openPoolPage(poolAddress: string): void - onInputPressEnter?(): void - onOpenManageWidget(): void - onDismiss(): void - onSelectChain(chain: ChainInfo): void -} -function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { - const { - selectedToken, - balancesState, - unsupportedTokens, - permitCompatibleTokens, - onSelectToken, - account, - tokenListTags, - } = props - - return useMemo( - () => ({ - balancesState, - selectedToken, - onSelectToken, - unsupportedTokens, - permitCompatibleTokens, - tokenListTags, - isWalletConnected: !!account, - }), - [balancesState, selectedToken, onSelectToken, unsupportedTokens, permitCompatibleTokens, tokenListTags, account], - ) -} +import type { SelectTokenModalProps } from './types' +import type { TokenSelectionHandler } from '../../types' +export type { SelectTokenModalProps } export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { const { @@ -86,68 +26,207 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { openPoolPage, tokenListCategoryState, disableErc20, - chainsToSelect, - onSelectChain, - areTokensFromBridge, isRouteAvailable, + modalTitle, + hasChainPanel = false, + standalone, + onOpenManageWidget, + favoriteTokens, + recentTokens, + onClearRecentTokens, + areTokensLoading, + allTokens, + areTokensFromBridge, + hideFavoriteTokensTooltip, + selectedTargetChainId, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, + isFullScreenMobile, } = props - const [inputValue, setInputValue] = useState(defaultInputValue) + const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) const selectTokenContext = useSelectTokenContext(props) + const resolvedModalTitle = modalTitle ?? 'Select token' + const mobileChainSelector = getMobileChainSelectorConfig({ + hasChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, + }) - const trimmedInputValue = inputValue.trim() - - const allListsContent = ( - + return ( + + + + + ) +} + +interface TokenColumnContentProps { + displayLpTokenLists?: boolean + account: string | undefined + inputValue: string + onSelectToken: TokenSelectionHandler + openPoolPage(poolAddress: string): void + disableErc20?: boolean + tokenListCategoryState: SelectTokenModalProps['tokenListCategoryState'] + isRouteAvailable: boolean | undefined + children: ReactNode +} +function TokenColumnContent(props: TokenColumnContentProps): ReactNode { + const { + displayLpTokenLists, + account, + inputValue, + onSelectToken, + openPoolPage, + disableErc20, + tokenListCategoryState, + isRouteAvailable, + children, + } = props + + if (displayLpTokenLists) { + return ( + + {children} + + ) + } + + return {children} +} + +interface SelectTokenModalShellProps { + children: ReactNode + hasChainPanel: boolean + isFullScreenMobile?: boolean + title: string + showManageButton: boolean + onDismiss(): void + onOpenManageWidget: () => void + searchValue: string + onSearchChange(value: string): void + onSearchEnter?: () => void + mobileChainSelector?: ComponentProps +} + +function SelectTokenModalShell({ + children, + hasChainPanel, + isFullScreenMobile, + title, + showManageButton, + onDismiss, + onOpenManageWidget, + searchValue, + onSearchChange, + onSearchEnter, + mobileChainSelector, +}: SelectTokenModalShellProps): ReactNode { return ( - - - e.key === 'Enter' && onInputPressEnter?.()} - onChange={(e) => setInputValue(e.target.value)} - placeholder={t`Search name or paste address...`} - /> - - - - - {displayLpTokenLists ? ( - - {allListsContent} - - ) : ( - <> - {!!chainsToSelect?.chains?.length && ( - <> - - - - - )} - {allListsContent} - - )} + + + + + { + if (event.key === 'Enter') { + onSearchEnter?.() + } + }} + onChange={(event) => onSearchChange(event.target.value)} + placeholder={t`Search name or paste address...`} + /> + + + {mobileChainSelector ? : null} + + {children} + ) } + +function getMobileChainSelectorConfig({ + hasChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, +}: { + hasChainPanel: boolean + mobileChainsState: SelectTokenModalProps['mobileChainsState'] + mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel'] + onSelectChain: SelectTokenModalProps['onSelectChain'] + onOpenMobileChainPanel: SelectTokenModalProps['onOpenMobileChainPanel'] +}): ComponentProps | undefined { + const canRender = + !hasChainPanel && + mobileChainsState && + onSelectChain && + onOpenMobileChainPanel && + (mobileChainsState.chains?.length ?? 0) > 0 + + if (!canRender) { + return undefined + } + + return { + chainsState: mobileChainsState, + label: mobileChainsLabel, + onSelectChain, + onOpenPanel: onOpenMobileChainPanel, + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts new file mode 100644 index 0000000000..2f3fd12952 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts @@ -0,0 +1,147 @@ +import { UI } from '@cowprotocol/ui' + +import styled from 'styled-components/macro' + +import { ListTitle } from './styled' + +import type { ChainAccentVars } from '../ChainsSelector/styled' + +const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})` +const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_50})` + +const getBackground = (accent?: ChainAccentVars): string => + accent ? `var(${accent.backgroundVar})` : fallbackBackground +const getBorder = (accent?: ChainAccentVars): string => (accent ? `var(${accent.borderVar})` : fallbackBorder) + +export const MobileSelectorRow = styled.div` + padding: 0 14px 12px; + display: flex; + flex-direction: column; + gap: 8px; +` + +export const MobileSelectorLabel = styled(ListTitle)` + padding: 4px 0; + justify-content: flex-start; + gap: 6px; + flex-wrap: wrap; +` + +export const ActiveChainLabel = styled.span` + color: var(${UI.COLOR_TEXT}); + font-weight: 600; + font-size: 14px; +` + +export const ScrollContainer = styled.div` + --cta-width: min(45vw, 130px); + --fade-width: clamp(14px, 6vw, 32px); + --cta-gap: 2px; + --cta-offset: calc(var(--cta-width) + var(--cta-gap)); + position: relative; + min-height: 44px; + overflow: hidden; + padding-right: var(--cta-offset); +` + +export const ScrollArea = styled.div` + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + overflow-y: hidden; + padding-right: var(--fade-width); + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; + scroll-snap-type: x proximity; + + &::-webkit-scrollbar { + display: none; + } + + mask-image: linear-gradient( + 90deg, + #000 0%, + #000 calc(100% - var(--cta-offset) - var(--fade-width)), + rgba(0, 0, 0, 0) 100% + ); + -webkit-mask-image: linear-gradient( + 90deg, + #000 0%, + #000 calc(100% - var(--cta-offset) - var(--fade-width)), + rgba(0, 0, 0, 0) 100% + ); +` + +export const FixedAllNetworks = styled.div` + pointer-events: none; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: var(--cta-width); + display: flex; + align-items: center; + justify-content: flex-end; + + > button { + pointer-events: auto; + width: 100%; + position: relative; + z-index: 1; + } +` + +export const ChainChipButton = styled.button<{ $active?: boolean; $accent?: ChainAccentVars }>` + --size: 44px; + width: var(--size); + height: var(--size); + border-radius: 10px; + border: 2px solid ${({ $active, $accent }) => ($active ? getBorder($accent) : 'transparent')}; + background: ${({ $active, $accent }) => ($active ? getBackground($accent) : 'transparent')}; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: + border 0.2s ease, + background 0.2s ease; + flex-shrink: 0; + scroll-snap-align: start; + + > img { + --size: 100%; + width: var(--size); + height: var(--size); + object-fit: contain; + } +` + +export const MoreChipButton = styled.button` + --size: 44px; + height: var(--size); + padding: 0 12px; + border-radius: var(--size); + border: 1px solid var(${UI.COLOR_PAPER_DARKEST}); + background: var(${UI.COLOR_PAPER}); + color: var(${UI.COLOR_TEXT}); + font-weight: 600; + font-size: 13px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + + svg { + --size: 18px; + stroke: var(${UI.COLOR_TEXT_OPACITY_50}); + width: var(--size); + height: var(--size); + min-width: var(--size); + min-height: var(--size); + } +` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts index 3016d33f0c..4dae09d3ba 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts @@ -1,67 +1,160 @@ -import { UI } from '@cowprotocol/ui' +import { Media, UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { blankButtonMixin } from '../commonElements' -export const Wrapper = styled.div` +export const Wrapper = styled.div<{ $hasChainPanel?: boolean; $isFullScreen?: boolean }>` display: flex; flex-direction: column; background: var(${UI.COLOR_PAPER}); - border-radius: 20px; + border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')}; width: 100%; -` + overflow: hidden; + border-top-right-radius: ${({ $hasChainPanel, $isFullScreen }) => + $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'}; + border-bottom-right-radius: ${({ $hasChainPanel, $isFullScreen }) => + $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'}; -export const Row = styled.div` - margin: 0 20px 20px; + ${Media.upToMedium()} { + border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')}; + } ` -export const ChainsSelectorWrapper = styled.div` - border-bottom: 1px solid var(${UI.COLOR_BORDER}); - padding: 2px 16px 10px 20px; - margin-bottom: 20px; -` +export const TitleBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + gap: 12px; -export const Separator = styled.div` - width: 100%; - border-bottom: 1px solid var(${UI.COLOR_BORDER}); + ${Media.upToSmall()} { + padding: 14px 14px 8px; + } ` -export const Header = styled.div` +export const TitleGroup = styled.div` display: flex; - flex-direction: row; - padding: 10px 16px; - margin-bottom: 8px; align-items: center; - border-bottom: 1px solid var(${UI.COLOR_BORDER}); + gap: 8px; +` + +export const ModalTitle = styled.h3` + font-size: 20px; + font-weight: 600; + margin: 0; - > h3 { - font-size: 16px; - font-weight: 500; - margin: 0; + ${Media.upToSmall()} { + font-size: 18px; } ` -export const ActionButton = styled.button` +export const TitleActions = styled.div` + display: flex; + align-items: center; + gap: 8px; +` + +export const TitleActionButton = styled.button` ${blankButtonMixin}; display: flex; - width: 100%; align-items: center; - flex-direction: row; justify-content: center; - gap: 10px; + padding: 2px; + border-radius: 8px; cursor: pointer; - padding: 20px 0; - margin: 0; - font-size: 16px; - font-weight: 500; color: inherit; - opacity: 0.6; - transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out; + transition: background var(${UI.ANIMATION_DURATION}) ease-in-out; + + &:hover { + background: var(${UI.COLOR_PAPER_DARKER}); + } +` + +export const SearchRow = styled.div` + padding: 0 14px 14px; + display: flex; + align-items: center; +` + +export const SearchInputWrapper = styled.div` + --input-height: 46px; + width: 100%; + + > div { + width: 100%; + background: var(${UI.COLOR_PAPER_DARKER}); + border-radius: var(--input-height); + height: var(--input-height); + display: flex; + align-items: center; + padding: 0 14px; + font-size: 15px; + } + + input { + background: transparent; + height: 100%; + } +` + +export const Body = styled.div` + display: flex; + flex: 1; + min-height: 0; + + ${Media.upToMedium()} { + flex-direction: column; + } +` + +export const TokenColumn = styled.div` + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 0; +` + +export const Row = styled.div` + padding: 0 24px; + margin-bottom: 16px; + + ${Media.upToSmall()} { + padding: 0 16px; + } +` + +export const Separator = styled.div` + width: 100%; + border-bottom: 1px solid var(${UI.COLOR_BORDER}); + margin: 0 0 16px; +` + +export const ListTitle = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 14px; + font-weight: 500; + color: var(${UI.COLOR_TEXT_OPACITY_70}); + padding: 8px 16px 4px; +` + +export const ListTitleActionButton = styled.button` + ${blankButtonMixin}; + font-size: 13px; + font-weight: 600; + color: var(${UI.COLOR_TEXT}); + padding: 2px 6px; + border-radius: 6px; + transition: color var(${UI.ANIMATION_DURATION}) ease-in-out; &:hover { - opacity: 1; + color: var(${UI.COLOR_TEXT_OPACITY_70}); } ` @@ -69,7 +162,7 @@ export const TokensLoader = styled.div` width: 100%; height: 100%; overflow: auto; - padding: 20px 0; + padding: 40px 0; text-align: center; ` @@ -77,6 +170,6 @@ export const RouteNotAvailable = styled.div` width: 100%; height: 100%; overflow: auto; - padding: 20px 0; + padding: 40px 0; text-align: center; ` diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts new file mode 100644 index 0000000000..5f4c388b31 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -0,0 +1,48 @@ +import { BalancesState } from '@cowprotocol/balances-and-allowances' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { ChainInfo } from '@cowprotocol/cow-sdk' +import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens' +import { Currency } from '@uniswap/sdk-core' + +import { Nullish } from 'types' + +import { PermitCompatibleTokens } from 'modules/permit' + +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +export interface SelectTokenModalProps { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + balancesState: BalancesState + unsupportedTokens: UnsupportedTokensState + selectedToken?: Nullish + permitCompatibleTokens: PermitCompatibleTokens + hideFavoriteTokensTooltip?: boolean + displayLpTokenLists?: boolean + disableErc20?: boolean + account: string | undefined + tokenListCategoryState: [T, (category: T) => void] + defaultInputValue?: string + areTokensLoading: boolean + tokenListTags: TokenListTags + standalone?: boolean + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + modalTitle?: string + hasChainPanel?: boolean + selectedTargetChainId?: number + mobileChainsState?: ChainsToSelectState + mobileChainsLabel?: string + onSelectChain?(chain: ChainInfo): void + onOpenMobileChainPanel?(): void + isFullScreenMobile?: boolean + + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void + onClearRecentTokens?(): void + openPoolPage(poolAddress: string): void + onInputPressEnter?(): void + onOpenManageWidget(): void + onDismiss(): void +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx index 95d0f4ad77..8129de48de 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx @@ -12,10 +12,11 @@ export interface TokenInfoProps { token: TokenWithLogo className?: string tags?: ReactNode + showAddress?: boolean } export function TokenInfo(props: TokenInfoProps): ReactNode { - const { token, className, tags } = props + const { token, className, tags, showAddress = true } = props return ( @@ -23,7 +24,7 @@ export function TokenInfo(props: TokenInfoProps): ReactNode { - + {showAddress ? : null} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx index 5594741567..7f2a196fad 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx @@ -12,9 +12,12 @@ import { Nullish } from 'types' import * as styledEl from './styled' +import { useDeferredVisibility } from '../../hooks/useDeferredVisibility' import { TokenInfo } from '../TokenInfo' import { TokenTags } from '../TokenTags' +import type { TokenSelectionHandler } from '../../types' + const LoadingElement = ( @@ -27,7 +30,7 @@ export interface TokenListItemProps { balance: BigNumber | undefined usdAmount?: CurrencyAmount | null - onSelectToken?(token: TokenWithLogo): void + onSelectToken?: TokenSelectionHandler isWalletConnected: boolean isUnsupported?: boolean @@ -58,6 +61,13 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { className, } = props + const tokenKey = `${token.chainId}:${token.address.toLowerCase()}` + // Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport. + const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility({ + resetKey: tokenKey, + rootMargin: '200px', + }) + const handleClick: MouseEventHandler = (e) => { if (isTokenSelected) { e.preventDefault() @@ -74,11 +84,16 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { ) const isSupportedChain = token.chainId in SupportedChainId + const shouldShowBalances = isWalletConnected && isSupportedChain + // Formatting balances is surprisingly expensive (BigNumber -> CurrencyAmount -> Fiat); only do it once the row is visible. + const shouldFormatBalances = shouldShowBalances && hasIntersected - const balanceAmount = balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined + const balanceAmount = + shouldFormatBalances && balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined return ( + hasIntersected ? ( + + ) : null } /> - {isWalletConnected && ( - - {isSupportedChain ? ( - <> - {balanceAmount ? : LoadingElement} - {usdAmount ? : null} - - ) : null} - - )} + {children} ) } + +interface TokenBalanceColumnProps { + shouldShow: boolean + shouldFormat: boolean + balanceAmount?: CurrencyAmount + usdAmount?: CurrencyAmount | null +} + +function TokenBalanceColumn({ + shouldShow, + shouldFormat, + balanceAmount, + usdAmount, +}: TokenBalanceColumnProps): ReactNode { + if (!shouldShow) { + return null + } + + return ( + + {shouldFormat ? ( + <> + {balanceAmount ? : LoadingElement} + {usdAmount ? : null} + + ) : ( + LoadingElement + )} + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx index 4dd4ca71e4..97208d6eb0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useCallback } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' @@ -14,6 +14,7 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine const { unsupportedTokens, onSelectToken, + onTokenListItemClick, selectedToken, tokenListTags, permitCompatibleTokens, @@ -22,6 +23,13 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine } = context const addressLowerCase = token.address.toLowerCase() + const handleSelectToken = useCallback( + (tokenToSelect: TokenWithLogo) => { + onTokenListItemClick?.(tokenToSelect) + onSelectToken(tokenToSelect) + }, + [onSelectToken, onTokenListItemClick], + ) return ( diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx index 5330d9c277..f42d2996b0 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx @@ -1,12 +1,23 @@ -import { ReactNode, useMemo } from 'react' +import { ReactNode, useCallback, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' import { TokenSearchResponse } from '@cowprotocol/tokens' -import { Loader } from '@cowprotocol/ui' +import { + BannerOrientation, + ExternalLink, + InlineBanner, + LINK_GUIDE_ADD_CUSTOM_TOKEN, + Loader, + StatusColorVariant, +} from '@cowprotocol/ui' import { t } from '@lingui/core/macro' import { Trans } from '@lingui/react/macro' +import { VirtualItem } from '@tanstack/react-virtual' + +import { VirtualList } from 'common/pure/VirtualList' + import * as styledEl from '../../containers/TokenSearchResults/styled' import { SelectTokenContext } from '../../types' @@ -58,59 +69,215 @@ export function TokenSearchContent({ return [matched, remaining] }, [activeListsResult, searchInput]) - return isLoading ? ( - - - - ) : isTokenNotFound ? ( - - No tokens found - - ) : ( - <> - {/*Matched tokens first, followed by tokens from active lists*/} - {matchedTokens.concat(activeList).map((token) => { - return - })} - - {/*Tokens from blockchain*/} - {blockchainResult?.length ? ( - - {blockchainResult.slice(0, SEARCH_RESULTS_LIMIT).map((token) => { - return - })} - - ) : null} - - {/*Tokens from inactive lists*/} - {inactiveListsResult?.length ? ( -
- - {t`Expanded results from inactive Token Lists`} - -
- {inactiveListsResult.slice(0, SEARCH_RESULTS_LIMIT).map((token) => { - return - })} -
-
- ) : null} - - {/*Tokens from external sources*/} - {externalApiResult?.length ? ( -
- - {t`Additional Results from External Sources`} - -
- {externalApiResult.map((token) => { - return - })} -
-
- ) : null} - + const rows = useSearchRows({ + isLoading, + matchedTokens, + activeList, + blockchainResult, + inactiveListsResult, + externalApiResult, + }) + + const renderRow = useCallback( + // Let the virtualizer ask for a specific row to keep render cost O(visible rows) + (items: TokenSearchRow[], virtualItem: VirtualItem) => ( + + ), + [importToken, selectTokenContext], + ) + + if (isLoading) + return ( + + + + ) + + if (isTokenNotFound) + return ( + + No tokens found + + ) + + return +} + +type TokenImportSection = 'blockchain' | 'inactive' | 'external' + +type TokenSearchRow = + | { type: 'banner' } + | { type: 'token'; token: TokenWithLogo } + | { type: 'section-title'; text: string; tooltip?: string } + | { + type: 'import-token' + token: TokenWithLogo + shadowed?: boolean + section: TokenImportSection + isFirstInSection: boolean + isLastInSection: boolean + wrapperId?: string + } + +interface UseSearchRowsParams { + isLoading: boolean + matchedTokens: TokenWithLogo[] + activeList: TokenWithLogo[] + blockchainResult?: TokenWithLogo[] + inactiveListsResult?: TokenWithLogo[] + externalApiResult?: TokenWithLogo[] +} + +function useSearchRows({ + isLoading, + matchedTokens, + activeList, + blockchainResult, + inactiveListsResult, + externalApiResult, +}: UseSearchRowsParams): TokenSearchRow[] { + return useMemo(() => { + if (isLoading) { + // Keep hook order stable while skipping work during the loading state + return [] + } + + const entries: TokenSearchRow[] = [] + + entries.push({ type: 'banner' }) + + for (const token of matchedTokens) { + // Exact matches stay pinned to the top of the results + entries.push({ type: 'token', token }) + } + + for (const token of activeList) { + entries.push({ type: 'token', token }) + } + + appendImportSection(entries, { + tokens: blockchainResult, + section: 'blockchain', + limit: SEARCH_RESULTS_LIMIT, + sectionTitle: undefined, + tooltip: undefined, + shadowed: false, + wrapperId: 'currency-import', + }) + + appendImportSection(entries, { + tokens: inactiveListsResult, + section: 'inactive', + limit: SEARCH_RESULTS_LIMIT, + sectionTitle: t`Expanded results from inactive Token Lists`, + tooltip: t`Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists.`, + shadowed: true, + }) + + appendImportSection(entries, { + tokens: externalApiResult, + section: 'external', + limit: SEARCH_RESULTS_LIMIT, + sectionTitle: t`Additional Results from External Sources`, + tooltip: t`Tokens from external sources.`, + shadowed: true, + }) + + return entries + }, [isLoading, matchedTokens, activeList, blockchainResult, inactiveListsResult, externalApiResult]) +} + +interface AppendImportSectionParams { + tokens?: TokenWithLogo[] + section: TokenImportSection + limit: number + sectionTitle?: string + tooltip?: string + shadowed?: boolean + wrapperId?: string +} + +function appendImportSection(rows: TokenSearchRow[], params: AppendImportSectionParams): void { + const { tokens, section, limit, sectionTitle, tooltip, shadowed, wrapperId } = params + + if (!tokens?.length) { + return + } + + if (sectionTitle) { + // Section headers mirror the legacy markup so tooltips/analytics keep working + rows.push({ type: 'section-title', text: sectionTitle, tooltip }) + } + + const limitedTokens = tokens.slice(0, limit) + + limitedTokens.forEach((token, index) => { + rows.push({ + type: 'import-token', + token, + section, + shadowed, + isFirstInSection: index === 0, + isLastInSection: index === limitedTokens.length - 1, + wrapperId: index === 0 ? wrapperId : undefined, + }) + }) +} + +interface TokenSearchRowRendererProps { + row: TokenSearchRow + selectTokenContext: SelectTokenContext + importToken(token: TokenWithLogo): void +} + +function TokenSearchRowRenderer({ row, selectTokenContext, importToken }: TokenSearchRowRendererProps): ReactNode { + switch (row.type) { + case 'banner': + return + case 'token': + return + case 'section-title': { + const tooltip = row.tooltip ?? '' + return ( + + {row.text} + + ) + } + case 'import-token': + return ( + + ) + default: + return null + } +} + +function GuideBanner(): ReactNode { + return ( + +

+ + Can't find your token on the list? Read our guide{' '} + on how to add custom tokens. + +

+
) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index fa6b1ccf04..2bad71148a 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -1,17 +1,11 @@ -import React, { ReactNode } from 'react' +import React, { ReactNode, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' -import { getCurrencyAddress } from '@cowprotocol/common-utils' -import { Nullish } from '@cowprotocol/types' import { Loader } from '@cowprotocol/ui' -import { Currency } from '@uniswap/sdk-core' - -import { Trans } from '@lingui/react/macro' -import { Edit } from 'react-feather' import { TokenSearchResults } from '../../containers/TokenSearchResults' import { SelectTokenContext } from '../../types' -import { FavoriteTokensList } from '../FavoriteTokensList' +import { getTokenUniqueKey } from '../../utils/tokenKey' import * as styledEl from '../SelectTokenModal/styled' import { TokensVirtualList } from '../TokensVirtualList' @@ -19,47 +13,63 @@ export interface TokensContentProps { displayLpTokenLists?: boolean selectTokenContext: SelectTokenContext favoriteTokens: TokenWithLogo[] - selectedToken?: Nullish - hideFavoriteTokensTooltip?: boolean + recentTokens?: TokenWithLogo[] areTokensLoading: boolean allTokens: TokenWithLogo[] searchInput: string - standalone?: boolean areTokensFromBridge: boolean - - onSelectToken(token: TokenWithLogo): void - onOpenManageWidget(): void + hideFavoriteTokensTooltip?: boolean + selectedTargetChainId?: number + onClearRecentTokens?: () => void } export function TokensContent({ selectTokenContext, - onSelectToken, - onOpenManageWidget, - selectedToken, favoriteTokens, - hideFavoriteTokensTooltip, + recentTokens, areTokensLoading, allTokens, displayLpTokenLists, searchInput, - standalone, areTokensFromBridge, + hideFavoriteTokensTooltip, + selectedTargetChainId, + onClearRecentTokens, }: TokensContentProps): ReactNode { + const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0 + const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0 + + const pinnedTokenKeys = useMemo(() => { + if (!shouldShowFavoritesInline && !shouldShowRecentsInline) { + return undefined + } + + const pinned = new Set() + + if (shouldShowFavoritesInline) { + favoriteTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) + } + + if (shouldShowRecentsInline && recentTokens) { + recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token))) + } + + return pinned + }, [favoriteTokens, recentTokens, shouldShowFavoritesInline, shouldShowRecentsInline]) + + const tokensWithoutPinned = useMemo(() => { + if (!pinnedTokenKeys) { + return allTokens + } + + return allTokens.filter((token) => !pinnedTokenKeys.has(getTokenUniqueKey(token))) + }, [allTokens, pinnedTokenKeys]) + + const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined + const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined + return ( <> - {!areTokensLoading && !!favoriteTokens.length && ( - <> - - - - - - )} {areTokensLoading ? ( @@ -76,25 +86,17 @@ export function TokensContent({ ) : ( )} )} - {!standalone && ( - <> - -
- - {' '} - - Manage Token Lists - - -
- - )} ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index f657eb27c1..4a863f5ec3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo } from 'react' import { TokenWithLogo } from '@cowprotocol/common-const' import { useFeatureFlags } from '@cowprotocol/common-hooks' +import { getIsNativeToken } from '@cowprotocol/common-utils' import { VirtualItem } from '@tanstack/react-virtual' @@ -10,37 +11,143 @@ import { VirtualList } from 'common/pure/VirtualList' import { SelectTokenContext } from '../../types' import { tokensListSorter } from '../../utils/tokensListSorter' +import { FavoriteTokensList } from '../FavoriteTokensList' +import * as modalStyled from '../SelectTokenModal/styled' import { TokenListItemContainer } from '../TokenListItemContainer' export interface TokensVirtualListProps { allTokens: TokenWithLogo[] displayLpTokenLists?: boolean selectTokenContext: SelectTokenContext + favoriteTokens?: TokenWithLogo[] + recentTokens?: TokenWithLogo[] + hideFavoriteTokensTooltip?: boolean + scrollResetKey?: number + onClearRecentTokens?: () => void } +type TokensVirtualRow = + | { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean } + | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void } + | { type: 'token'; token: TokenWithLogo } + export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { - const { allTokens, selectTokenContext, displayLpTokenLists } = props + const { + allTokens, + selectTokenContext, + displayLpTokenLists, + favoriteTokens, + recentTokens, + hideFavoriteTokensTooltip, + scrollResetKey, + onClearRecentTokens, + } = props const { values: balances } = selectTokenContext.balancesState const { isYieldEnabled } = useFeatureFlags() - const sortedTokens = useMemo( - () => (balances ? allTokens.sort(tokensListSorter(balances)) : allTokens), - [allTokens, balances], - ) + const sortedTokens = useMemo(() => { + if (!balances) { + return allTokens + } + + const prioritized: TokenWithLogo[] = [] + const remainder: TokenWithLogo[] = [] + + for (const token of allTokens) { + const hasBalance = Boolean(balances[token.address.toLowerCase()]) + if (hasBalance || getIsNativeToken(token)) { + prioritized.push(token) + } else { + remainder.push(token) + } + } + + // Only sort the handful of tokens the user actually holds (plus natives) so large lists stay cheap to render. + const sortedPrioritized = + prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized + + return [...sortedPrioritized, ...remainder] + }, [allTokens, balances]) + + const rows = useMemo(() => { + const tokenRows = sortedTokens.map((token) => ({ type: 'token', token })) + const composedRows: TokensVirtualRow[] = [] + + if (favoriteTokens?.length) { + composedRows.push({ + type: 'favorite-section', + tokens: favoriteTokens, + hideTooltip: hideFavoriteTokensTooltip, + }) + } - const getItemView = useCallback( - (sortedTokens: TokenWithLogo[], virtualRow: VirtualItem) => { - const token = sortedTokens[virtualRow.index] + if (recentTokens?.length) { + composedRows.push({ + type: 'title', + label: 'Recent', + actionLabel: onClearRecentTokens ? 'Clear' : undefined, + onAction: onClearRecentTokens, + }) + recentTokens.forEach((token) => composedRows.push({ type: 'token', token })) + } - return - }, + if (favoriteTokens?.length || recentTokens?.length) { + composedRows.push({ type: 'title', label: 'All tokens' }) + } + + return [...composedRows, ...tokenRows] + }, [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens]) + + const virtualListKey = scrollResetKey ?? 'tokens-list' + + const renderVirtualRow = useCallback( + (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => ( + + ), [selectTokenContext], ) return ( - + {displayLpTokenLists || !isYieldEnabled ? null : } ) } + +interface TokensVirtualRowRendererProps { + row: TokensVirtualRow + selectTokenContext: SelectTokenContext +} + +function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowRendererProps): ReactNode { + switch (row.type) { + case 'favorite-section': + return ( + + ) + case 'title': + return ( + + {row.label} + {row.actionLabel && row.onAction ? ( + + {row.actionLabel} + + ) : null} + + ) + default: + return + } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts index d8cb8eadc3..fda562f5a3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts @@ -10,6 +10,8 @@ import { Nullish } from 'types' import { Field } from 'legacy/state/types' +import { TradeType } from 'modules/trade/types' + interface SelectTokenWidgetState { open: boolean field?: Field @@ -21,6 +23,8 @@ interface SelectTokenWidgetState { onSelectToken?: (currency: Currency) => void onInputPressEnter?: Command selectedTargetChainId?: number + tradeType?: TradeType + forceOpen?: boolean } export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = { @@ -32,6 +36,8 @@ export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = { listToImport: undefined, selectedPoolAddress: undefined, selectedTargetChainId: undefined, + tradeType: undefined, + forceOpen: false, } export const { atom: selectTokenWidgetAtom, updateAtom: updateSelectTokenWidgetAtom } = atomWithPartialUpdate( diff --git a/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts new file mode 100644 index 0000000000..05c88af196 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts @@ -0,0 +1,103 @@ +import { ALL_SUPPORTED_CHAINS_MAP, ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +export function createChainInfoForTests(baseChainId: SupportedChainId, overrides?: Partial): ChainInfo { + const base = ALL_SUPPORTED_CHAINS_MAP[baseChainId] + + if (!base) { + throw new Error(`Missing base chain definition for ${baseChainId}`) + } + + return buildChainInfo(base, overrides) +} + +function buildChainInfo(base: ChainInfo, overrides: Partial | undefined): ChainInfo { + const chainId = resolveChainId(base, overrides) + + return { + ...base, + ...overrides, + id: chainId, + contracts: resolveContracts(base, overrides), + bridges: resolveBridges(base, overrides), + rpcUrls: resolveRpcUrls(base, overrides), + logo: resolveLogo(base, overrides), + docs: resolveDocs(base, overrides), + website: resolveWebsite(base, overrides), + blockExplorer: resolveBlockExplorer(base, overrides), + nativeCurrency: resolveNativeCurrency(base, overrides, chainId), + } +} + +function resolveChainId(base: ChainInfo, overrides: Partial | undefined): ChainInfo['id'] { + return overrides?.id ?? base.id +} + +function resolveContracts(base: ChainInfo, overrides: Partial | undefined): ChainInfo['contracts'] { + const merged = overrides?.contracts + + return merged ? { ...base.contracts, ...merged } : { ...base.contracts } +} + +function resolveBridges(base: ChainInfo, overrides: Partial | undefined): ChainInfo['bridges'] { + const bridges = overrides?.bridges ?? base.bridges + + return bridges?.map(cloneBridge) +} + +function resolveRpcUrls(base: ChainInfo, overrides: Partial | undefined): ChainInfo['rpcUrls'] { + return cloneRpcUrls(overrides?.rpcUrls ?? base.rpcUrls) +} + +function resolveLogo(base: ChainInfo, overrides: Partial | undefined): ChainInfo['logo'] { + return cloneThemedImage(overrides?.logo ?? base.logo) +} + +function resolveDocs(base: ChainInfo, overrides: Partial | undefined): ChainInfo['docs'] { + return cloneWebUrl(overrides?.docs ?? base.docs) +} + +function resolveWebsite(base: ChainInfo, overrides: Partial | undefined): ChainInfo['website'] { + return cloneWebUrl(overrides?.website ?? base.website) +} + +function resolveBlockExplorer(base: ChainInfo, overrides: Partial | undefined): ChainInfo['blockExplorer'] { + return cloneWebUrl(overrides?.blockExplorer ?? base.blockExplorer) +} + +function resolveNativeCurrency( + base: ChainInfo, + overrides: Partial | undefined, + chainId: ChainInfo['id'], +): ChainInfo['nativeCurrency'] { + return { + ...base.nativeCurrency, + ...(overrides?.nativeCurrency ?? {}), + chainId, + } +} + +function cloneBridge(bridge: NonNullable[number]): NonNullable[number] { + return { ...bridge } +} + +function cloneRpcUrls(rpcUrls: ChainInfo['rpcUrls']): ChainInfo['rpcUrls'] { + return Object.entries(rpcUrls).reduce( + (acc, [key, value]) => { + acc[key] = { + http: [...value.http], + ...(value.webSocket ? { webSocket: [...value.webSocket] } : {}), + } + + return acc + }, + {} as ChainInfo['rpcUrls'], + ) +} + +function cloneThemedImage(image: ChainInfo['logo']): ChainInfo['logo'] { + return { ...image } +} + +function cloneWebUrl(webUrl: T): T { + return { ...webUrl } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/types.ts index 5c775d8e0a..da71e3ab96 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts @@ -8,11 +8,14 @@ import { Nullish } from 'types' import { PermitCompatibleTokens } from 'modules/permit' +export type TokenSelectionHandler = (token: TokenWithLogo) => Promise | void + export interface SelectTokenContext { balancesState: BalancesState selectedToken?: Nullish - onSelectToken(token: TokenWithLogo): void + onSelectToken: TokenSelectionHandler + onTokenListItemClick?(token: TokenWithLogo): void unsupportedTokens: { [tokenAddress: string]: { dateAdded: number } } permitCompatibleTokens: PermitCompatibleTokens diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.test.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.test.ts new file mode 100644 index 0000000000..adfaece52d --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.test.ts @@ -0,0 +1,67 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +import { sortChainsByDisplayOrder } from './sortChainsByDisplayOrder' + +import { createChainInfoForTests } from '../test-utils/createChainInfoForTests' + +describe('sortChainsByDisplayOrder', () => { + it('orders chains according to the canonical network selector order', () => { + const chains = [ + createChainInfoForTests(SupportedChainId.POLYGON), + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BNB), + ] + + const result = sortChainsByDisplayOrder(chains) + + expect(result.map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BNB, + SupportedChainId.POLYGON, + ]) + }) + + it('keeps unknown chains at the end while preserving their relative order', () => { + const customChainA = createChainInfoForTests(SupportedChainId.MAINNET, { + id: 9991, + label: 'Custom A', + eip155Label: 'custom-a', + }) + const customChainB = createChainInfoForTests(SupportedChainId.MAINNET, { + id: 9992, + label: 'Custom B', + eip155Label: 'custom-b', + }) + const chains = [ + customChainA, + createChainInfoForTests(SupportedChainId.MAINNET), + customChainB, + createChainInfoForTests(SupportedChainId.BNB), + ] + + const result = sortChainsByDisplayOrder(chains) + + expect(result.map((chain) => chain.id)).toEqual([ + SupportedChainId.MAINNET, + SupportedChainId.BNB, + customChainA.id, + customChainB.id, + ]) + }) + + it('promotes the pinned chain to the first slot', () => { + const chains = [ + createChainInfoForTests(SupportedChainId.MAINNET), + createChainInfoForTests(SupportedChainId.BNB), + createChainInfoForTests(SupportedChainId.POLYGON), + ] + + const result = sortChainsByDisplayOrder(chains, { pinChainId: SupportedChainId.POLYGON }) + + expect(result.map((chain) => chain.id)).toEqual([ + SupportedChainId.POLYGON, + SupportedChainId.MAINNET, + SupportedChainId.BNB, + ]) + }) +}) diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts new file mode 100644 index 0000000000..59d30cd945 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts @@ -0,0 +1,52 @@ +import { SORTED_CHAIN_IDS } from '@cowprotocol/common-const' +import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk' + +const CHAIN_ORDER = SORTED_CHAIN_IDS.reduce>((acc, chainId, index) => { + acc[chainId] = index + return acc +}, {} as Record) + +interface SortOptions { + pinChainId?: ChainInfo['id'] +} + +/** + * Sorts a list of chains so it matches the canonical network selector order. + * Optionally promotes the provided `pinChainId` to the first position. + */ +export function sortChainsByDisplayOrder(chains: ChainInfo[], options?: SortOptions): ChainInfo[] { + if (chains.length <= 1) { + return chains.slice() + } + + const weightedChains = chains.map((chain, index) => ({ + chain, + weight: CHAIN_ORDER[chain.id as SupportedChainId] ?? Number.MAX_SAFE_INTEGER, + index, + })) + + weightedChains.sort((a, b) => { + if (a.weight === b.weight) { + return a.index - b.index + } + + return a.weight - b.weight + }) + + const orderedChains = weightedChains.map((entry) => entry.chain) + + if (!options?.pinChainId) { + return orderedChains + } + + const pinIndex = orderedChains.findIndex((chain) => chain.id === options.pinChainId) + + if (pinIndex <= 0) { + return orderedChains + } + + const [pinnedChain] = orderedChains.splice(pinIndex, 1) + orderedChains.unshift(pinnedChain) + + return orderedChains +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts new file mode 100644 index 0000000000..8f827b0288 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts @@ -0,0 +1,7 @@ +import { TokenWithLogo } from '@cowprotocol/common-const' + +type TokenIdentifier = Pick + +export function getTokenUniqueKey(token: TokenIdentifier): string { + return `${token.chainId}:${token.address.toLowerCase()}` +} diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx index d1a1a34f8a..5f6753ad01 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx @@ -13,13 +13,8 @@ import { useSetUserApproveAmountModalState, } from 'modules/erc20Approve' import { useTradeApproveState } from 'modules/erc20Approve/state/useTradeApproveState' -import { - ImportTokenModal, - SelectTokenWidget, - useSelectTokenWidgetState, - useTokenListAddingError, - useUpdateSelectTokenWidgetState, -} from 'modules/tokensList' +import { ImportTokenModal, useSelectTokenWidgetState, useTokenListAddingError } from 'modules/tokensList' +import { useCloseTokenSelectWidget } from 'modules/tokensList/hooks/useCloseTokenSelectWidget' import { useZeroApproveModalState, ZeroApprovalModal } from 'modules/zeroApproval' import { TransactionErrorContent } from 'common/pure/TransactionErrorContent' @@ -34,22 +29,17 @@ import { WrapNativeModal } from '../WrapNativeModal' interface TradeWidgetModalsProps { confirmModal: ReactNode | undefined genericModal: ReactNode | undefined - selectTokenWidget: ReactNode | undefined } // todo refactor it -// eslint-disable-next-line complexity,max-lines-per-function -export function TradeWidgetModals({ - confirmModal, - genericModal, - selectTokenWidget = , -}: TradeWidgetModalsProps): ReactNode { +// eslint-disable-next-line max-lines-per-function +export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetModalsProps): ReactNode { const { chainId, account } = useWalletInfo() const { state: rawState } = useTradeState() const importTokenCallback = useAddUserToken() const { isOpen: isTradeReviewOpen, error: confirmError, pendingTrade } = useTradeConfirmState() - const { open: isTokenSelectOpen, field } = useSelectTokenWidgetState() + const { field } = useSelectTokenWidgetState() const [{ isOpen: isWrapNativeOpen }, setWrapNativeScreenState] = useWrapNativeScreenState() const { approveInProgress, @@ -67,16 +57,16 @@ export function TradeWidgetModals({ } = useAutoImportTokensState(rawState?.inputCurrencyId, rawState?.outputCurrencyId) const { onDismiss: closeTradeConfirm } = useTradeConfirmActions() - const updateSelectTokenWidgetState = useUpdateSelectTokenWidgetState() + const closeTokenSelectWidget = useCloseTokenSelectWidget() const resetApproveModalState = useResetApproveProgressModalState() const updateApproveAmountState = useSetUserApproveAmountModalState() const resetAllScreens = useCallback( - (closeTokenSelectWidget = true, shouldCloseAutoImportModal = true) => { + (shouldCloseTokenSelectWidget = true, shouldCloseAutoImportModal = true) => { closeTradeConfirm() closeZeroApprovalModal() if (shouldCloseAutoImportModal) closeAutoImportModal() - if (closeTokenSelectWidget) updateSelectTokenWidgetState({ open: false }) + if (shouldCloseTokenSelectWidget) closeTokenSelectWidget() setWrapNativeScreenState({ isOpen: false }) resetApproveModalState() setTokenListAddingError(null) @@ -86,7 +76,7 @@ export function TradeWidgetModals({ closeTradeConfirm, closeZeroApprovalModal, closeAutoImportModal, - updateSelectTokenWidgetState, + closeTokenSelectWidget, setWrapNativeScreenState, resetApproveModalState, updateApproveAmountState, @@ -96,8 +86,10 @@ export function TradeWidgetModals({ const isOutputTokenSelector = field === Field.OUTPUT const isOutputTokenSelectorRef = useRef(isOutputTokenSelector) - isOutputTokenSelectorRef.current = isOutputTokenSelector + useEffect(() => { + isOutputTokenSelectorRef.current = isOutputTokenSelector + }, [isOutputTokenSelector]) const error = tokenListAddingError || approveError || confirmError /** @@ -127,10 +119,6 @@ export function TradeWidgetModals({ return } - if (isTokenSelectOpen) { - return selectTokenWidget - } - if (isAutoImportModalOpen) { return } diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx index a2fa5606d5..804ecf787b 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx @@ -1,6 +1,6 @@ import { JSX, useEffect } from 'react' -import { useSelectTokenWidgetState } from 'modules/tokensList' +import { SelectTokenWidget, useChainsToSelect, useSelectTokenWidgetState } from 'modules/tokensList' import { useSetShouldUseAutoSlippage } from 'modules/tradeSlippage' import * as styledEl from './styled' @@ -17,8 +17,13 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element { disableSuggestedSlippageApi = false, enableSmartSlippage, } = params - const modals = TradeWidgetModals({ confirmModal, genericModal, selectTokenWidget: slots.selectTokenWidget }) + const modals = TradeWidgetModals({ confirmModal, genericModal }) const { open: isTokenSelectOpen } = useSelectTokenWidgetState() + const chainsToSelect = useChainsToSelect() + const isTokenSelectWide = + isTokenSelectOpen && !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0) + + const selectTokenWidgetNode = slots.selectTokenWidget ?? const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage() @@ -27,17 +32,21 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element { }, [enableSmartSlippage, setShouldUseAutoSlippage]) return ( - - - {slots.updaters} - - - {modals || } - + <> + + + {slots.updaters} + + + {modals || } + + + {selectTokenWidgetNode} + ) } diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx index 2e93ac29d8..7cb13d2376 100644 --- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx +++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx @@ -3,9 +3,14 @@ import { UI } from '@cowprotocol/ui' import styled from 'styled-components/macro' import { WIDGET_MAX_WIDTH } from 'theme' -export const Container = styled.div<{ isTokenSelectOpen?: boolean }>` +export const Container = styled.div<{ isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean }>` width: 100%; - max-width: ${({ isTokenSelectOpen }) => (isTokenSelectOpen ? WIDGET_MAX_WIDTH.tokenSelect : WIDGET_MAX_WIDTH.swap)}; + max-width: ${({ isTokenSelectOpen, isTokenSelectWide }) => + isTokenSelectOpen + ? isTokenSelectWide + ? WIDGET_MAX_WIDTH.tokenSelectSidebar + : WIDGET_MAX_WIDTH.tokenSelect + : WIDGET_MAX_WIDTH.swap}; margin: 0 auto; position: relative; ` diff --git a/apps/cowswap-frontend/src/theme/consts.tsx b/apps/cowswap-frontend/src/theme/consts.tsx index 5230490c10..8a008740d7 100644 --- a/apps/cowswap-frontend/src/theme/consts.tsx +++ b/apps/cowswap-frontend/src/theme/consts.tsx @@ -25,6 +25,7 @@ export const WIDGET_MAX_WIDTH = { limit: '1350px', content: '680px', tokenSelect: '590px', + tokenSelectSidebar: '700px', } export const TextWrapper = styled(Text)<{ color: keyof Colors; override?: boolean }>` diff --git a/libs/tokens/src/pure/TokenLogo/index.tsx b/libs/tokens/src/pure/TokenLogo/index.tsx index 7f49b5e345..6d2c67f048 100644 --- a/libs/tokens/src/pure/TokenLogo/index.tsx +++ b/libs/tokens/src/pure/TokenLogo/index.tsx @@ -1,5 +1,5 @@ import { atom, useAtom } from 'jotai' -import { useCallback, useMemo } from 'react' +import { ReactNode, useCallback, useMemo } from 'react' import { BaseChainInfo, @@ -41,11 +41,19 @@ export interface TokenLogoProps { hideNetworkBadge?: boolean } -// TODO: Break down this large function into smaller functions -// TODO: Add proper return type annotation -// TODO: Reduce function complexity by extracting logic -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, complexity, max-lines-per-function -export function TokenLogo({ +export function TokenLogo(props: TokenLogoProps): ReactNode { + const { token } = props + + if (token instanceof LpToken) { + return + } + + return +} + +type StandardTokenLogoProps = TokenLogoProps & { token?: TokenWithLogo | Currency | null } + +function StandardTokenLogo({ logoURI, token, className, @@ -53,31 +61,10 @@ export function TokenLogo({ sizeMobile, noWrap, hideNetworkBadge, -}: TokenLogoProps) { - const tokensByAddress = useTokensByAddressMap() - +}: StandardTokenLogoProps): ReactNode { const [invalidUrls, setInvalidUrls] = useAtom(invalidUrlsAtom) - const isLpToken = token instanceof LpToken - - const urls = useMemo(() => { - if (token instanceof LpToken) return - - // TODO: get rid of Currency usage and remove type casting - if (token) { - if (token instanceof NativeCurrency) { - return [cowprotocolTokenLogoUrl(NATIVE_CURRENCY_ADDRESS.toLowerCase(), token.chainId as SupportedChainId)] - } - - return getTokenLogoUrls(token as TokenWithLogo) - } - - return logoURI ? uriToHttp(logoURI) : [] - }, [logoURI, token]) - - const validUrls = useMemo(() => urls && urls.filter((url) => !invalidUrls[url]), [urls, invalidUrls]) - - const currentUrl = validUrls?.[0] + const { currentUrl, initial } = useTokenLogoUrl({ token, logoURI, invalidUrls }) const logoUrl = useNetworkLogo(token?.chainId) const showNetworkBadge = logoUrl && !hideNetworkBadge @@ -88,40 +75,7 @@ export function TokenLogo({ setInvalidUrls((state) => ({ ...state, [currentUrl]: true })) }, [currentUrl, setInvalidUrls]) - const initial = token?.symbol?.[0] || token?.name?.[0] - - if (isLpToken) { - return ( - - -
- -
-
- -
-
-
- ) - } - - const actualTokenContent = currentUrl ? ( - - {`${token?.symbol - - ) : initial ? ( - - - - ) : ( - - - - ) + const actualTokenContent = renderTokenLogoContent({ currentUrl, onError, token, initial }) if (noWrap) { return actualTokenContent @@ -137,7 +91,12 @@ export function TokenLogo({ const cutThicknessForCalc = getBorderWidth(chainLogoSizeForCalc) return ( - + <> {showNetworkBadge ? ( ) } + +type LpTokenLogoProps = Omit & { token: LpToken } + +function LpTokenLogo({ token, className, size = 36, sizeMobile }: LpTokenLogoProps): ReactNode { + const tokensByAddress = useTokensByAddressMap() + + return ( + + +
+ +
+
+ +
+
+
+ ) +} + +interface TokenLogoUrlOptions { + token?: TokenWithLogo | Currency | null + logoURI?: string + invalidUrls: Record +} + +function useTokenLogoUrl({ token, logoURI, invalidUrls }: TokenLogoUrlOptions): { + currentUrl?: string + initial?: string +} { + const urls = useMemo(() => { + if (token instanceof LpToken) { + return [] + } + + if (token instanceof NativeCurrency) { + return [cowprotocolTokenLogoUrl(NATIVE_CURRENCY_ADDRESS.toLowerCase(), token.chainId as SupportedChainId)] + } + + if (token) { + return getTokenLogoUrls(token as TokenWithLogo) + } + + return logoURI ? uriToHttp(logoURI) : [] + }, [logoURI, token]) + + const validUrls = useMemo(() => urls && urls.filter((url) => !invalidUrls[url]), [urls, invalidUrls]) + const currentUrl = validUrls?.[0] + const initial = token?.symbol?.[0] || token?.name?.[0] + + return { currentUrl, initial } +} + +interface TokenLogoContentOptions { + currentUrl?: string + onError: () => void + token?: TokenWithLogo | Currency | null + initial?: string +} + +function renderTokenLogoContent({ currentUrl, onError, token, initial }: TokenLogoContentOptions): ReactNode { + if (currentUrl) { + return ( + + {`${token?.symbol + + ) + } + + if (initial) { + return ( + + + + ) + } + + return ( + + + + ) +} diff --git a/libs/ui/src/enum.ts b/libs/ui/src/enum.ts index 7a13500436..6e7c2039ce 100644 --- a/libs/ui/src/enum.ts +++ b/libs/ui/src/enum.ts @@ -101,6 +101,41 @@ export enum UI { COLOR_GREEN = '--cow-color-green', COLOR_RED = '--cow-color-red', + // Chain-specific accent colors + COLOR_CHAIN_ETHEREUM_BG = '--cow-color-chain-ethereum-bg', + COLOR_CHAIN_ETHEREUM_BORDER = '--cow-color-chain-ethereum-border', + COLOR_CHAIN_ETHEREUM_ACCENT = '--cow-color-chain-ethereum-accent', + COLOR_CHAIN_BNB_BG = '--cow-color-chain-bnb-bg', + COLOR_CHAIN_BNB_BORDER = '--cow-color-chain-bnb-border', + COLOR_CHAIN_BNB_ACCENT = '--cow-color-chain-bnb-accent', + COLOR_CHAIN_BASE_BG = '--cow-color-chain-base-bg', + COLOR_CHAIN_BASE_BORDER = '--cow-color-chain-base-border', + COLOR_CHAIN_BASE_ACCENT = '--cow-color-chain-base-accent', + COLOR_CHAIN_ARBITRUM_BG = '--cow-color-chain-arbitrum-bg', + COLOR_CHAIN_ARBITRUM_BORDER = '--cow-color-chain-arbitrum-border', + COLOR_CHAIN_ARBITRUM_ACCENT = '--cow-color-chain-arbitrum-accent', + COLOR_CHAIN_POLYGON_BG = '--cow-color-chain-polygon-bg', + COLOR_CHAIN_POLYGON_BORDER = '--cow-color-chain-polygon-border', + COLOR_CHAIN_POLYGON_ACCENT = '--cow-color-chain-polygon-accent', + COLOR_CHAIN_AVALANCHE_BG = '--cow-color-chain-avalanche-bg', + COLOR_CHAIN_AVALANCHE_BORDER = '--cow-color-chain-avalanche-border', + COLOR_CHAIN_AVALANCHE_ACCENT = '--cow-color-chain-avalanche-accent', + COLOR_CHAIN_GNOSIS_BG = '--cow-color-chain-gnosis-bg', + COLOR_CHAIN_GNOSIS_BORDER = '--cow-color-chain-gnosis-border', + COLOR_CHAIN_GNOSIS_ACCENT = '--cow-color-chain-gnosis-accent', + COLOR_CHAIN_LENS_BG = '--cow-color-chain-lens-bg', + COLOR_CHAIN_LENS_BORDER = '--cow-color-chain-lens-border', + COLOR_CHAIN_LENS_ACCENT = '--cow-color-chain-lens-accent', + COLOR_CHAIN_SEPOLIA_BG = '--cow-color-chain-sepolia-bg', + COLOR_CHAIN_SEPOLIA_BORDER = '--cow-color-chain-sepolia-border', + COLOR_CHAIN_SEPOLIA_ACCENT = '--cow-color-chain-sepolia-accent', + COLOR_CHAIN_LINEA_BG = '--cow-color-chain-linea-bg', + COLOR_CHAIN_LINEA_BORDER = '--cow-color-chain-linea-border', + COLOR_CHAIN_LINEA_ACCENT = '--cow-color-chain-linea-accent', + COLOR_CHAIN_PLASMA_BG = '--cow-color-chain-plasma-bg', + COLOR_CHAIN_PLASMA_BORDER = '--cow-color-chain-plasma-border', + COLOR_CHAIN_PLASMA_ACCENT = '--cow-color-chain-plasma-accent', + // Neutral colors - Base grayscale palette from black (0) to white (100) COLOR_WHITE = '--cow-color-neutral-100', COLOR_NEUTRAL_100 = '--cow-color-neutral-100', diff --git a/libs/ui/src/pure/Input/index.tsx b/libs/ui/src/pure/Input/index.tsx index 4503352d71..c1a5dfb8b3 100644 --- a/libs/ui/src/pure/Input/index.tsx +++ b/libs/ui/src/pure/Input/index.tsx @@ -1,4 +1,4 @@ -import { InputHTMLAttributes } from 'react' +import { InputHTMLAttributes, ReactNode } from 'react' import { Search } from 'react-feather' import styled from 'styled-components/macro' @@ -31,15 +31,18 @@ const SearchInputEl = styled.input` border-radius: 12px; border: none; - ::placeholder { + &::placeholder { color: inherit; opacity: 0.7; + transition: color 0.1s ease-in-out; + } + + &:focus::placeholder { + color: transparent; } ` -// TODO: Add proper return type annotation -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type -export function SearchInput(props: InputHTMLAttributes) { +export function SearchInput(props: InputHTMLAttributes): ReactNode { return ( diff --git a/libs/ui/src/pure/Popover/index.tsx b/libs/ui/src/pure/Popover/index.tsx index ca0078e5e1..9e45b4c42b 100644 --- a/libs/ui/src/pure/Popover/index.tsx +++ b/libs/ui/src/pure/Popover/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useMediaQuery, useInterval, useElementViewportTracking } from '@cowprotocol/common-hooks' @@ -24,7 +24,9 @@ const MOBILE_FULL_WIDTH_STYLES = { boxSizing: 'border-box' as const, } -function createMobileModifiers(arrowElement: HTMLDivElement | null): Array>>> { +function createMobileModifiers( + arrowElement: HTMLDivElement | null, +): Array>>> { return [ { name: 'offset', @@ -44,7 +46,9 @@ function createMobileModifiers(arrowElement: HTMLDivElement | null): Array>>> { +function createDesktopModifiers( + arrowElement: HTMLDivElement | null, +): Array>>> { return [ { name: 'offset', options: { offset: [8, 8] } }, { name: 'arrow', options: { element: arrowElement } }, @@ -52,7 +56,6 @@ function createDesktopModifiers(arrowElement: HTMLDivElement | null): Array(() => show || forceMount) + + useEffect(() => { + if ((show || forceMount) && !hasMountedPortal) { + setHasMountedPortal(true) + } + }, [show, forceMount, hasMountedPortal]) + + return forceMount || show || hasMountedPortal } export default function Popover(props: PopoverProps): React.JSX.Element { @@ -84,75 +104,133 @@ export default function Popover(props: PopoverProps): React.JSX.Element { showMobileBackdrop = false, mobileBorderRadius, zIndex = 999999, + forceMount = false, } = props const [referenceElement, setReferenceElement] = useState(null) const [popperElement, setPopperElement] = useState(null) const [arrowElement, setArrowElement] = useState(null) - const isMobile = useMediaQuery(Media.upToSmall(false)) const shouldUseFullWidth = isMobile && mobileMode === PopoverMobileMode.FullWidth - - // Use hook for viewport tracking and utility for backdrop height calculation const { rect } = useElementViewportTracking(referenceElement, shouldUseFullWidth && showMobileBackdrop) - const backdropHeight = useMemo(() => { if (!shouldUseFullWidth || !showMobileBackdrop) return '100vh' return calculateAvailableSpaceAbove(rect, 8) }, [rect, shouldUseFullWidth, showMobileBackdrop]) - const options = useMemo( (): Options => ({ placement: shouldUseFullWidth ? 'top' : placement, strategy: 'fixed', - modifiers: shouldUseFullWidth - ? createMobileModifiers(arrowElement) - : createDesktopModifiers(arrowElement), + modifiers: shouldUseFullWidth ? createMobileModifiers(arrowElement) : createDesktopModifiers(arrowElement), }), [arrowElement, placement, shouldUseFullWidth], ) - const { styles, update, attributes } = usePopper(referenceElement, popperElement, options) - const updateCallback = useCallback(() => { update?.() }, [update]) const intervalDelay = useMemo(() => (show ? 100 : null), [show]) useInterval(updateCallback, intervalDelay) - + const shouldRenderPortal = useLazyPortalMount(show, forceMount) + const popperStyle = { + ...styles.popper, + zIndex, + ...(shouldUseFullWidth && MOBILE_FULL_WIDTH_STYLES), + ...(shouldUseFullWidth && mobileBorderRadius && { borderRadius: mobileBorderRadius }), + } + const arrowPlacement = (attributes.popper?.['data-popper-placement'] as string | undefined)?.split('-')[0] ?? '' return ( <> {children} - - {isMobile && showMobileBackdrop && } - + + ) +} + +interface PopoverPortalProps { + shouldRender: boolean + show: boolean + isMobile: boolean + showMobileBackdrop: boolean + backdropHeight: string + className?: string + setPopperElement(value: HTMLDivElement | null): void + popperStyle: React.CSSProperties + popperAttributes: ReturnType['attributes']['popper'] + bgColor?: string + color?: string + borderColor?: string + content: React.ReactNode + setArrowElement(value: HTMLDivElement | null): void + arrowStyle: React.CSSProperties + arrowAttributes: ReturnType['attributes']['arrow'] + arrowPlacement: string +} + +function PopoverPortal({ + shouldRender, + show, + isMobile, + showMobileBackdrop, + backdropHeight, + className, + setPopperElement, + popperStyle, + popperAttributes, + bgColor, + color, + borderColor, + content, + setArrowElement, + arrowStyle, + arrowAttributes, + arrowPlacement, +}: PopoverPortalProps): React.ReactNode { + if (!shouldRender) { + return null + } + + return ( + + {isMobile && showMobileBackdrop && } + + {content} + - {content} - - - - + {...arrowAttributes} + /> + + ) } diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx index b5b4872b06..f39c641a93 100644 --- a/libs/ui/src/theme/ThemeColorVars.tsx +++ b/libs/ui/src/theme/ThemeColorVars.tsx @@ -5,6 +5,153 @@ import { css } from 'styled-components/macro' import { UI } from '../enum' +interface ChainAccentConfig { + bgVar: UI + borderVar: UI + accentVar?: UI + lightBg: string + darkBg: string + lightBorder: string + darkBorder: string + lightColor: string + darkColor: string +} + +interface ChainAccentInput { + bgVar: UI + borderVar: UI + accentVar?: UI + color: string + lightColor?: string + darkColor?: string + lightBgAlpha?: number + darkBgAlpha?: number + lightBorderAlpha?: number + darkBorderAlpha?: number +} + +const CHAIN_LIGHT_BG_ALPHA = 0.22 +const CHAIN_DARK_BG_ALPHA = 0.32 +const CHAIN_LIGHT_BORDER_ALPHA = 0.45 +const CHAIN_DARK_BORDER_ALPHA = 0.65 + +const chainAlpha = (color: string, alpha: number): string => transparentize(color, 1 - alpha) + +function createChainAccent({ + bgVar, + borderVar, + accentVar, + color, + lightColor = color, + darkColor = color, + lightBgAlpha = CHAIN_LIGHT_BG_ALPHA, + darkBgAlpha = CHAIN_DARK_BG_ALPHA, + lightBorderAlpha = CHAIN_LIGHT_BORDER_ALPHA, + darkBorderAlpha = CHAIN_DARK_BORDER_ALPHA, +}: ChainAccentInput): ChainAccentConfig { + return { + bgVar, + borderVar, + accentVar, + lightBg: chainAlpha(lightColor, lightBgAlpha), + darkBg: chainAlpha(darkColor, darkBgAlpha), + lightBorder: chainAlpha(lightColor, lightBorderAlpha), + darkBorder: chainAlpha(darkColor, darkBorderAlpha), + lightColor, + darkColor, + } +} + +const CHAIN_ACCENT_CONFIG: ChainAccentConfig[] = [ + createChainAccent({ + bgVar: UI.COLOR_CHAIN_ETHEREUM_BG, + borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER, + accentVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT, + color: '#627EEA', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_BNB_BG, + borderVar: UI.COLOR_CHAIN_BNB_BORDER, + accentVar: UI.COLOR_CHAIN_BNB_ACCENT, + color: '#F0B90B', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_BASE_BG, + borderVar: UI.COLOR_CHAIN_BASE_BORDER, + accentVar: UI.COLOR_CHAIN_BASE_ACCENT, + color: '#0052FF', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_ARBITRUM_BG, + borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER, + accentVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT, + color: '#1B4ADD', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_POLYGON_BG, + borderVar: UI.COLOR_CHAIN_POLYGON_BORDER, + accentVar: UI.COLOR_CHAIN_POLYGON_ACCENT, + color: '#8247E5', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_AVALANCHE_BG, + borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER, + accentVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT, + color: '#FF3944', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_GNOSIS_BG, + borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER, + accentVar: UI.COLOR_CHAIN_GNOSIS_ACCENT, + color: '#07795B', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_LENS_BG, + borderVar: UI.COLOR_CHAIN_LENS_BORDER, + accentVar: UI.COLOR_CHAIN_LENS_ACCENT, + color: '#5A5A5A', + darkColor: '#D7D7D7', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_SEPOLIA_BG, + borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER, + accentVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT, + color: '#C12FF2', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_LINEA_BG, + borderVar: UI.COLOR_CHAIN_LINEA_BORDER, + accentVar: UI.COLOR_CHAIN_LINEA_ACCENT, + color: '#61DFFF', + }), + createChainAccent({ + bgVar: UI.COLOR_CHAIN_PLASMA_BG, + borderVar: UI.COLOR_CHAIN_PLASMA_BORDER, + accentVar: UI.COLOR_CHAIN_PLASMA_ACCENT, + color: '#569F8C', + }), +] + +const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG.map(({ + bgVar, + borderVar, + accentVar, + lightBg, + darkBg, + lightBorder, + darkBorder, + lightColor, + darkColor, +}) => css` + ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)}; + ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)}; + ${accentVar + ? css` + ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)}; + ` + : ''} +`) + export const ThemeColorVars = css` :root { // V3 @@ -83,6 +230,8 @@ export const ThemeColorVars = css` ${UI.COLOR_ALERT_TEXT_DARKER}: ${({ theme }) => getContrastText(theme.alert, theme.darkMode ? darken(theme.alert, 0.55) : darken(theme.alert, 0.35))}; + ${CHAIN_ACCENT_VAR_DECLARATIONS} + ${UI.COLOR_WARNING}: ${({ theme }) => theme.warning}; ${UI.COLOR_WARNING_BG}: ${({ theme }) => transparentize(theme.warning, 0.85)}; ${UI.COLOR_WARNING_TEXT}: ${({ theme }) => diff --git a/package.json b/package.json index 076747729f..5d0f4beebd 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "@sentry/tracing": "^7.80.0", "@sentry/webpack-plugin": "^2.10.0", "@swc/helpers": "~0.5.2", - "@tanstack/react-virtual": "^3.0.2", + "@tanstack/react-virtual": "^3.13.12", "@trezor/connect-plugin-ethereum": "^9.0.1", "@trezor/connect-web": "^9.0.11", "@types/hdkey": "^2.0.1", diff --git a/yarn.lock b/yarn.lock index 36b48ea98f..654d2b0e8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8048,7 +8048,7 @@ "@tanstack/query-core" "4.36.1" use-sync-external-store "^1.2.0" -"@tanstack/react-virtual@^3.0.2": +"@tanstack/react-virtual@^3.13.12": version "3.13.12" resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz#d372dc2783739cc04ec1a728ca8203937687a819" integrity sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==