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>
-
-
-
- ))}
- {shouldDisplayMore && (
-
+ onSelectChain(chain)}
+ active$={isActive}
+ accent$={accent}
+ aria-pressed={isActive}
+ >
+
+
+
+
+ {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}
+ >
+
+
+ )
+}
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 ? (
-
-
-
- ) : 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 (
+
+
+
+ )
+ }
+
+ 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==