From 15b5fe7eee22bda1761c3899067a160d6e80910a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 7 Nov 2025 15:45:45 +0000
Subject: [PATCH 01/37] feat: add ChainPanel component and integrate
cross-chain selection
---
.../containers/SelectTokenWidget/index.tsx | 173 ++++++++-------
.../src/modules/tokensList/index.ts | 1 +
.../tokensList/pure/ChainPanel/index.tsx | 64 ++++++
.../tokensList/pure/ChainPanel/styled.ts | 66 ++++++
.../tokensList/pure/ChainsSelector/index.tsx | 121 ++++------
.../tokensList/pure/ChainsSelector/styled.tsx | 167 +++++++-------
.../pure/FavoriteTokensList/index.tsx | 17 +-
.../pure/FavoriteTokensList/styled.ts | 31 ++-
.../pure/SelectTokenModal/index.cosmos.tsx | 70 +++++-
.../pure/SelectTokenModal/index.tsx | 206 +++++++++++++-----
.../pure/SelectTokenModal/styled.ts | 123 ++++++++---
.../tokensList/pure/TokensContent/index.tsx | 31 +--
.../trade/containers/TradeWidget/index.tsx | 7 +-
.../trade/containers/TradeWidget/styled.tsx | 9 +-
apps/cowswap-frontend/src/theme/consts.tsx | 1 +
15 files changed, 674 insertions(+), 413 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
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..49609c7c3f 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -34,6 +34,7 @@ import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { useTokensToSelect } from '../../hooks/useTokensToSelect'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
+import { ChainPanel } from '../../pure/ChainPanel'
import { ImportListModal } from '../../pure/ImportListModal'
import { ImportTokenModal } from '../../pure/ImportTokenModal'
import { SelectTokenModal } from '../../pure/SelectTokenModal'
@@ -42,11 +43,21 @@ import { ManageListsAndTokens } from '../ManageListsAndTokens'
const Wrapper = styled.div`
width: 100%;
+`
- > div {
- height: calc(100vh - 200px);
- min-height: 600px;
- }
+const InnerWrapper = styled.div<{ $hasSidebar: boolean }>`
+ height: calc(100vh - 200px);
+ min-height: 600px;
+ width: 100%;
+ margin: 0 auto;
+ display: flex;
+ align-items: stretch;
+`
+
+const ModalContainer = styled.div`
+ flex: 1;
+ min-width: 0;
+ display: flex;
`
const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
@@ -113,6 +124,9 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
const isInjectedWidgetMode = isInjectedWidget()
const closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const modalTitle = field === Field.INPUT ? 'Swap from' : field === Field.OUTPUT ? 'Swap to' : 'Select token'
+ // TODO: Confirm copy requirements for BUY orders and update titles accordingly.
+ const chainsPanelTitle = 'Cross chain swap'
const openPoolPage = useCallback(
(selectedPoolAddress: string) => {
@@ -152,83 +166,94 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
updateSelectTokenWidget({ listToImport: undefined })
}
+ const isBridgingEnabled = !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0)
+
if (!onSelectToken || !open) return null
return (
- {(() => {
- if (tokenToImport && !standalone) {
- return (
-
- )
- }
-
- if (listToImport && !standalone) {
- return (
-
- )
- }
-
- if (isManageWidgetOpen && !standalone) {
- return (
- setIsManageWidgetOpen(false)}
- />
- )
- }
+
+ {(() => {
+ if (tokenToImport && !standalone) {
+ return (
+
+ )
+ }
+
+ if (listToImport && !standalone) {
+ return (
+
+ )
+ }
+
+ if (isManageWidgetOpen && !standalone) {
+ return (
+ setIsManageWidgetOpen(false)}
+ />
+ )
+ }
+
+ if (selectedPoolAddress) {
+ return (
+
+ )
+ }
- if (selectedPoolAddress) {
return (
-
+ <>
+
+ setIsManageWidgetOpen(true)}
+ hideFavoriteTokensTooltip={isInjectedWidgetMode}
+ openPoolPage={openPoolPage}
+ tokenListCategoryState={tokenListCategoryState}
+ disableErc20={disableErc20}
+ account={account}
+ areTokensLoading={areTokensLoading}
+ tokenListTags={tokenListTags}
+ areTokensFromBridge={areTokensFromBridge}
+ isRouteAvailable={isRouteAvailable}
+ modalTitle={modalTitle}
+ hasChainPanel={isBridgingEnabled}
+ />
+
+ {isBridgingEnabled && chainsToSelect && (
+
+ )}
+ >
)
- }
-
- 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}
- />
- )
- })()}
+ })()}
+
)
}
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/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
new file mode 100644
index 0000000000..43cfd7640f
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -0,0 +1,64 @@
+import { ReactNode, useMemo, useState } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+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
+}
+
+export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProps): ReactNode {
+ const [chainQuery, setChainQuery] = useState('')
+ const normalizedChainQuery = chainQuery.trim().toLowerCase()
+ const chains = chainsState?.chains ?? EMPTY_CHAINS
+ const isLoading = chainsState?.isLoading ?? false
+
+ const filteredChains = useMemo(() => {
+ 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
+ })
+ }, [chains, normalizedChainQuery])
+
+ if (!isLoading && chains.length === 0) {
+ return null
+ }
+
+ const showEmptyState = !isLoading && filteredChains.length === 0 && !!normalizedChainQuery
+
+ return (
+
+
+ {title}
+
+ setChainQuery(event.target.value)}
+ placeholder="Search network"
+ />
+
+
+ {showEmptyState && No networks match "{chainQuery}".}
+
+
+ )
+}
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..82a20db3b4
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
@@ -0,0 +1,66 @@
+import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui'
+
+import styled from 'styled-components/macro'
+
+export const Panel = styled.div`
+ width: 210px;
+ flex-shrink: 0;
+ background: var(${UI.COLOR_PAPER_DARKER});
+ border-left: 1px solid var(${UI.COLOR_BORDER});
+ padding: 16px 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 0;
+ border-top-right-radius: 20px;
+ border-bottom-right-radius: 20px;
+
+ ${Media.upToMedium()} {
+ width: 100%;
+ border-left: none;
+ border-top: 1px solid var(${UI.COLOR_BORDER});
+ border-radius: 0 0 20px 20px;
+ }
+
+ ${Media.upToSmall()} {
+ padding: 16px;
+ }
+`
+
+export const PanelHeader = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+`
+
+export const PanelTitle = styled.h4`
+ font-size: 14px;
+ font-weight: 500;
+ margin: 0;
+ width: 100%;
+ text-align: center;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+`
+
+export const PanelSearchInput = styled(UISearchInput)`
+ width: 100%;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ border: 1px solid var(${UI.COLOR_PAPER_DARKEST});
+ border-radius: 12px;
+ padding: 8px 12px;
+ background: var(${UI.COLOR_PAPER_DARKER});
+ font-size: 14px;
+`
+
+export const PanelList = styled.div`
+ flex: 1;
+ overflow-y: auto;
+ padding-right: 4px;
+`
+
+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 a19d79113f..24308fa343 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -1,107 +1,64 @@
import { ReactNode } from 'react'
-import { useMediaQuery, useTheme } from '@cowprotocol/common-hooks'
+import { useTheme } from '@cowprotocol/common-hooks'
import { ChainInfo } from '@cowprotocol/cow-sdk'
-import { HoverTooltip, Media } from '@cowprotocol/ui'
-import { Menu, MenuButton, MenuItem } from '@reach/menu-button'
-import { Check, ChevronDown, ChevronUp } from 'react-feather'
+import { Check } from 'react-feather'
import * as styledEl from './styled'
// Number of skeleton shimmers to show during loading state
const LOADING_ITEMS_COUNT = 10
-const LoadingShimmerElements = (
-
- {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => (
-
- ))}
-
-)
-
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({
- chains,
- onSelectChain,
- defaultChainId,
- isLoading,
- visibleNetworkIcons = LOADING_ITEMS_COUNT,
-}: ChainsSelectorProps): ReactNode {
- const isMobile = useMediaQuery(Media.upToSmall(false))
-
+export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoading }: ChainsSelectorProps): ReactNode {
const theme = useTheme()
if (isLoading) {
- return LoadingShimmerElements
+ return (
+
+ {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => (
+
+
+
+
+ ))}
+
+ )
}
- 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 (
-
- {visibleChains.map((chain) => (
-
- onSelectChain(chain)} iconOnly>
-
-
-
- ))}
- {shouldDisplayMore && (
-
- )}
-
+
+ {chains.map((chain) => {
+ const isActive = defaultChainId === chain.id
+
+ return (
+ onSelectChain(chain)}
+ active$={isActive}
+ 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..5f6d3d5214 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,99 @@
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`
+import { blankButtonMixin } from '../commonElements'
+
+export const List = styled.div`
display: flex;
- flex-flow: row;
- gap: 8px;
+ flex-direction: column;
+ gap: 4px;
width: 100%;
-
- ${Media.upToSmall()} {
- overflow-x: auto;
- overflow-y: hidden;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* IE and Edge */
-
- &::-webkit-scrollbar {
- display: none;
- }
- }
`
-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 }>`
+ ${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;
+ border-radius: 18px;
+ border: 1px solid ${({ active$ }) => (active$ ? `var(${UI.COLOR_PRIMARY_OPACITY_80})` : 'transparent')};
+ background: ${({ active$ }) => (active$ ? `var(${UI.COLOR_PRIMARY_OPACITY_10})` : 'transparent')};
+ box-shadow: ${({ active$ }) => (active$ ? `0 0 0 1px var(${UI.COLOR_PRIMARY_OPACITY_10}) 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: var(${UI.COLOR_PRIMARY_OPACITY_70});
}
+`
- > img {
- width: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)};
- height: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)};
- border-radius: 100%;
- }
+export const ChainInfo = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`
- > span {
- padding: 0 4px;
- }
+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`
+ 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: var(${UI.COLOR_PRIMARY});
+`
+
+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 b34b58aa99..afc5895533 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -2,9 +2,7 @@ import { ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { TokenLogo } from '@cowprotocol/tokens'
-import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui'
-
-import { Link } from 'react-router'
+import { TokenSymbol } from '@cowprotocol/ui'
import * as styledEl from './styled'
export interface FavoriteTokensListProps {
@@ -16,19 +14,10 @@ export interface FavoriteTokensListProps {
}
export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode {
- const { tokens, hideTooltip, selectedToken, onSelectToken } = props
-
+ const { tokens, selectedToken, onSelectToken } = props
+
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()
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..f7a91aac01 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
@@ -19,27 +19,24 @@ export const List = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
- padding-top: 10px;
- ${Media.upToSmall()} {
- width: 0;
- min-width: 100%;
- flex-wrap: nowrap;
- overflow-x: scroll;
- overflow-y: hidden;
+ width: 0;
+ min-width: 100%;
+ flex-wrap: nowrap;
+ overflow-x: scroll;
+ overflow-y: hidden;
- padding: 10px 0;
- -webkit-overflow-scrolling: touch;
+ padding: 10px 0;
+ -webkit-overflow-scrolling: touch;
- @media (hover: hover) {
- ${({ theme }) => theme.colorScrollbar};
- }
+ @media (hover: hover) {
+ ${({ theme }) => theme.colorScrollbar};
+ }
- @media (hover: none) {
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
+ @media (hover: none) {
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
}
}
`
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..6fa4404d99 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,7 +33,24 @@ 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 defaultModalProps: SelectTokenModalProps = {
tokenListTags: {},
account: undefined,
permitCompatibleTokens: {},
@@ -35,10 +59,6 @@ const defaultProps: SelectTokenModalProps = {
favoriteTokens: favoriteTokensMock,
areTokensLoading: false,
areTokensFromBridge: false,
- chainsToSelect: undefined,
- onSelectChain(chain: ChainInfo) {
- console.log('onSelectChain', chain)
- },
tokenListCategoryState: [null, () => void 0],
balancesState: {
values: balances,
@@ -48,6 +68,7 @@ const defaultProps: SelectTokenModalProps = {
},
selectedToken,
isRouteAvailable: true,
+ modalTitle: 'Swap from',
onSelectToken() {
console.log('onSelectToken')
},
@@ -62,30 +83,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 1d6d72da1f..d9497b822f 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -1,24 +1,21 @@
-import React, { ReactNode, useMemo, useState } from 'react'
+import { ReactNode, useMemo, useState } 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 { BackButton, SearchInput } from '@cowprotocol/ui'
import { Currency } from '@uniswap/sdk-core'
-import { X } from 'react-feather'
import { Nullish } from 'types'
import { PermitCompatibleTokens } from 'modules/permit'
+import { SettingsIcon } from 'modules/trade/pure/Settings'
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 { SelectTokenContext } from '../../types'
import { TokensContent } from '../TokensContent'
export interface SelectTokenModalProps {
@@ -32,7 +29,6 @@ export interface SelectTokenModalProps {
displayLpTokenLists?: boolean
disableErc20?: boolean
account: string | undefined
- chainsToSelect: ChainsToSelectState | undefined
tokenListCategoryState: [T, (category: T) => void]
defaultInputValue?: string
areTokensLoading: boolean
@@ -40,13 +36,14 @@ export interface SelectTokenModalProps {
standalone?: boolean
areTokensFromBridge: boolean
isRouteAvailable: boolean | undefined
+ modalTitle?: string
+ hasChainPanel?: boolean
onSelectToken(token: TokenWithLogo): void
openPoolPage(poolAddress: string): void
onInputPressEnter?(): void
onOpenManageWidget(): void
onDismiss(): void
- onSelectChain(chain: ChainInfo): void
}
function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext {
@@ -74,6 +71,73 @@ function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext
)
}
+function useTokenSearchInput(defaultInputValue = ''): [string, (value: string) => void, string] {
+ const [inputValue, setInputValue] = useState(defaultInputValue)
+
+ return [inputValue, setInputValue, inputValue.trim()]
+}
+
+function useTokensContent(props: SelectTokenModalProps, searchInput: string, context: SelectTokenContext): ReactNode {
+ const {
+ displayLpTokenLists,
+ favoriteTokens,
+ selectedToken,
+ hideFavoriteTokensTooltip,
+ areTokensLoading,
+ allTokens,
+ areTokensFromBridge,
+ onSelectToken,
+ } = props
+
+ return (
+
+ )
+}
+
+function TitleBarActions({
+ showManageButton,
+ onDismiss,
+ onOpenManageWidget,
+ title,
+}: {
+ showManageButton: boolean
+ onDismiss(): void
+ onOpenManageWidget(): void
+ title: string
+}): ReactNode {
+ return (
+
+
+
+ {title}
+
+ {showManageButton && (
+
+
+
+
+
+ )}
+
+ )
+}
+
export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
const {
defaultInputValue = '',
@@ -85,29 +149,27 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
openPoolPage,
tokenListCategoryState,
disableErc20,
- chainsToSelect,
- onSelectChain,
- areTokensFromBridge,
isRouteAvailable,
+ modalTitle,
+ hasChainPanel,
+ standalone,
+ onOpenManageWidget,
} = props
- const [inputValue, setInputValue] = useState(defaultInputValue)
+ const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
const selectTokenContext = useSelectTokenContext(props)
-
- const trimmedInputValue = inputValue.trim()
-
- const allListsContent = (
-
- )
+ const allListsContent = useTokensContent(props, trimmedInputValue, selectTokenContext)
+ const resolvedModalTitle = modalTitle ?? 'Select token'
return (
-
-
+
+
+
setInputValue(e.target.value)}
placeholder="Search name or paste address..."
/>
-
-
-
-
- {displayLpTokenLists ? (
-
- {allListsContent}
-
- ) : (
- <>
- {!!chainsToSelect?.chains?.length && (
- <>
-
-
-
- >
- )}
- {allListsContent}
- >
- )}
+
+
+
+
+ {allListsContent}
+
+
+
)
}
+
+interface TokenColumnContentProps {
+ displayLpTokenLists?: boolean
+ account: string | undefined
+ inputValue: string
+ onSelectToken(token: TokenWithLogo): void
+ 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}
+}
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..c7180be51c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -1,75 +1,128 @@
-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 }>`
display: flex;
flex-direction: column;
background: var(${UI.COLOR_PAPER});
border-radius: 20px;
width: 100%;
+ overflow: hidden;
+ border-top-right-radius: ${({ $hasChainPanel }) => ($hasChainPanel ? '0' : '20px')};
+ border-bottom-right-radius: ${({ $hasChainPanel }) => ($hasChainPanel ? '0' : '20px')};
+
+ ${Media.upToMedium()} {
+ border-radius: 20px;
+ }
`
-export const Row = styled.div`
- margin: 0 20px 20px;
+export const TitleBar = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px 24px 12px;
+ gap: 12px;
+
+ ${Media.upToSmall()} {
+ padding: 16px 16px 8px;
+ }
`
-export const ChainsSelectorWrapper = styled.div`
- border-bottom: 1px solid var(${UI.COLOR_BORDER});
- padding: 2px 16px 10px 20px;
- margin-bottom: 20px;
+export const TitleGroup = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
`
-export const Separator = styled.div`
- width: 100%;
- border-bottom: 1px solid var(${UI.COLOR_BORDER});
+export const ModalTitle = styled.h3`
+ font-size: 20px;
+ font-weight: 600;
+ margin: 0;
`
-export const Header = styled.div`
+export const TitleActions = styled.div`
display: flex;
- flex-direction: row;
- padding: 10px 16px;
- margin-bottom: 8px;
align-items: center;
- border-bottom: 1px solid var(${UI.COLOR_BORDER});
-
- > h3 {
- font-size: 16px;
- font-weight: 500;
- margin: 0;
- }
+ gap: 8px;
`
-export const ActionButton = styled.button`
+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 {
- opacity: 1;
+ background: var(${UI.COLOR_PAPER_DARKER});
+ }
+`
+
+export const SearchRow = styled.div`
+ padding: 0 24px 16px;
+ border-bottom: 1px solid var(${UI.COLOR_BORDER});
+ display: flex;
+
+ > * {
+ width: 100%;
+ }
+
+ ${Media.upToSmall()} {
+ padding: 0 16px 16px;
+ }
+`
+
+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: 16px 24px 24px;
+
+ ${Media.upToSmall()} {
+ padding: 16px;
+ }
+`
+
+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 TokensLoader = styled.div`
width: 100%;
height: 100%;
overflow: auto;
- padding: 20px 0;
+ padding: 40px 0;
text-align: center;
`
@@ -77,6 +130,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/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
index 7a626e20d6..2b5fbe6917 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -6,8 +6,6 @@ import { Nullish } from '@cowprotocol/types'
import { Loader } from '@cowprotocol/ui'
import { Currency } from '@uniswap/sdk-core'
-import { Edit } from 'react-feather'
-
import { TokenSearchResults } from '../../containers/TokenSearchResults'
import { SelectTokenContext } from '../../types'
import { FavoriteTokensList } from '../FavoriteTokensList'
@@ -23,17 +21,14 @@ export interface TokensContentProps {
areTokensLoading: boolean
allTokens: TokenWithLogo[]
searchInput: string
- standalone?: boolean
areTokensFromBridge: boolean
onSelectToken(token: TokenWithLogo): void
- onOpenManageWidget(): void
}
export function TokensContent({
selectTokenContext,
onSelectToken,
- onOpenManageWidget,
selectedToken,
favoriteTokens,
hideFavoriteTokensTooltip,
@@ -41,22 +36,18 @@ export function TokensContent({
allTokens,
displayLpTokenLists,
searchInput,
- standalone,
areTokensFromBridge,
}: TokensContentProps): ReactNode {
return (
<>
{!areTokensLoading && !!favoriteTokens.length && (
<>
-
-
-
-
+
>
)}
{areTokensLoading ? (
@@ -81,16 +72,6 @@ export function TokensContent({
)}
>
)}
- {!standalone && (
- <>
-
-
-
- Manage Token Lists
-
-
- >
- )}
>
)
}
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..b34eb96a73 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 { useChainsToSelect, useSelectTokenWidgetState } from 'modules/tokensList'
import { useSetShouldUseAutoSlippage } from 'modules/tradeSlippage'
import * as styledEl from './styled'
@@ -19,6 +19,9 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element {
} = params
const modals = TradeWidgetModals({ confirmModal, genericModal, selectTokenWidget: slots.selectTokenWidget })
const { open: isTokenSelectOpen } = useSelectTokenWidgetState()
+ const chainsToSelect = useChainsToSelect()
+ const isTokenSelectWide =
+ isTokenSelectOpen && !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0)
const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage()
@@ -27,7 +30,7 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element {
}, [enableSmartSlippage, setShouldUseAutoSlippage])
return (
-
+
`
+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..244719068c 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: '660px',
}
export const TextWrapper = styled(Text)<{ color: keyof Colors; override?: boolean }>`
From 805ad39c3f40d2581892b26a998abdfe6382f434 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 7 Nov 2025 16:37:18 +0000
Subject: [PATCH 02/37] refactor: enhance ChainPanel styling and structure
---
.../modules/tokensList/pure/ChainPanel/index.tsx | 12 +++++++-----
.../modules/tokensList/pure/ChainPanel/styled.ts | 16 ++++++++++++----
.../tokensList/pure/ChainsSelector/styled.tsx | 4 +++-
.../tokensList/pure/SelectTokenModal/styled.ts | 13 ++-----------
4 files changed, 24 insertions(+), 21 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
index 43cfd7640f..901e8af144 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -45,11 +45,13 @@ export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProp
{title}
- setChainQuery(event.target.value)}
- placeholder="Search network"
- />
+
+ setChainQuery(event.target.value)}
+ placeholder="Search network"
+ />
+
`
+ --min-height: 46px;
${blankButtonMixin};
width: 100%;
@@ -20,7 +21,8 @@ export const ChainButton = styled.button<{ active$?: boolean }>`
justify-content: space-between;
gap: 16px;
padding: 8px 12px;
- border-radius: 18px;
+ min-height: var(--min-height);
+ border-radius: var(--min-height);
border: 1px solid ${({ active$ }) => (active$ ? `var(${UI.COLOR_PRIMARY_OPACITY_80})` : 'transparent')};
background: ${({ active$ }) => (active$ ? `var(${UI.COLOR_PRIMARY_OPACITY_10})` : 'transparent')};
box-shadow: ${({ active$ }) => (active$ ? `0 0 0 1px var(${UI.COLOR_PRIMARY_OPACITY_10}) inset` : 'none')};
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 c7180be51c..becda025ca 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -23,7 +23,7 @@ export const TitleBar = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
- padding: 20px 24px 12px;
+ padding: 12px 14px;
gap: 12px;
${Media.upToSmall()} {
@@ -67,17 +67,8 @@ export const TitleActionButton = styled.button`
`
export const SearchRow = styled.div`
- padding: 0 24px 16px;
- border-bottom: 1px solid var(${UI.COLOR_BORDER});
+ padding: 0 14px 14px;
display: flex;
-
- > * {
- width: 100%;
- }
-
- ${Media.upToSmall()} {
- padding: 0 16px 16px;
- }
`
export const Body = styled.div`
From 1ab99db214954cc029a6c87cb33eebde51f90c6c Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 7 Nov 2025 17:26:08 +0000
Subject: [PATCH 03/37] refactor: streamline token selection components and
improve UI structure
---
.../containers/SelectTokenWidget/index.tsx | 5 +-
.../pure/FavoriteTokensList/index.tsx | 56 +++++++------
.../pure/FavoriteTokensList/styled.ts | 80 +++++--------------
.../pure/SelectTokenModal/index.tsx | 8 +-
.../pure/SelectTokenModal/styled.ts | 12 ++-
.../tokensList/pure/TokensContent/index.tsx | 49 ++++++------
.../pure/TokensVirtualList/index.tsx | 54 ++++++++++---
7 files changed, 136 insertions(+), 128 deletions(-)
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 49609c7c3f..d8d8231c23 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -121,8 +121,6 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
const tokenListTags = useTokenListsTags()
const onTokenListAddingError = useOnTokenListAddingError()
- const isInjectedWidgetMode = isInjectedWidget()
-
const closeTokenSelectWidget = useCloseTokenSelectWidget()
const modalTitle = field === Field.INPUT ? 'Swap from' : field === Field.OUTPUT ? 'Swap to' : 'Select token'
// TODO: Confirm copy requirements for BUY orders and update titles accordingly.
@@ -167,6 +165,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
}
const isBridgingEnabled = !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0)
+ const isInjectedWidgetMode = isInjectedWidget()
if (!onSelectToken || !open) return null
@@ -234,7 +233,6 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
onInputPressEnter={onInputPressEnter}
onDismiss={onDismiss}
onOpenManageWidget={() => setIsManageWidgetOpen(true)}
- hideFavoriteTokensTooltip={isInjectedWidgetMode}
openPoolPage={openPoolPage}
tokenListCategoryState={tokenListCategoryState}
disableErc20={disableErc20}
@@ -245,6 +243,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
isRouteAvailable={isRouteAvailable}
modalTitle={modalTitle}
hasChainPanel={isBridgingEnabled}
+ hideFavoriteTokensTooltip={isInjectedWidgetMode}
/>
{isBridgingEnabled && chainsToSelect && (
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 afc5895533..c38e804fd4 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -1,43 +1,47 @@
import { ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { TokenLogo } from '@cowprotocol/tokens'
-import { TokenSymbol } from '@cowprotocol/ui'
+import { HelpTooltip } from '@cowprotocol/ui'
+
+import { Link } from 'react-router'
import * as styledEl from './styled'
+
+import { SelectTokenContext } from '../../types'
+import { TokenListItemContainer } from '../TokenListItemContainer'
+
export interface FavoriteTokensListProps {
tokens: TokenWithLogo[]
+ selectTokenContext: SelectTokenContext
hideTooltip?: boolean
- selectedToken?: string
-
- onSelectToken(token: TokenWithLogo): void
}
export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode {
- const { tokens, selectedToken, onSelectToken } = props
+ const { tokens, selectTokenContext, hideTooltip } = props
+
+ if (!tokens.length) {
+ return null
+ }
return (
-
+
+
+ Favourite 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)}
- >
-
-
-
- )
- })}
+ {tokens.map((token) => (
+
+ ))}
-
+
)
}
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 f7a91aac01..e4da1f8a9a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
@@ -1,71 +1,31 @@
-import { Media, UI } from '@cowprotocol/ui'
+import { UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
-export const Header = styled.div`
- display: flex;
- gap: 5px;
- flex-direction: row;
- align-items: center;
-
- > h4 {
- font-size: 14px;
- font-weight: 500;
- margin: 0;
- }
+export const Section = styled.div`
+ padding: 8px 0 12px;
+ border-bottom: 1px solid var(${UI.COLOR_BORDER});
+ margin-bottom: 8px;
`
-export const List = styled.div`
+export const TitleRow = styled.div`
display: flex;
- flex-wrap: wrap;
- gap: 10px;
-
- width: 0;
- min-width: 100%;
- flex-wrap: nowrap;
- overflow-x: scroll;
- overflow-y: hidden;
-
- padding: 10px 0;
- -webkit-overflow-scrolling: touch;
-
- @media (hover: hover) {
- ${({ theme }) => theme.colorScrollbar};
- }
-
- @media (hover: none) {
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
- }
-`
-
-export const TokensItem = styled.button`
- display: inline-flex;
- flex-direction: row;
align-items: center;
gap: 6px;
- justify-content: center;
- background: none;
- outline: none;
- padding: 6px 10px;
- border-radius: 10px;
- color: inherit;
- 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)};
- transition: border var(${UI.ANIMATION_DURATION}) ease-in-out;
- white-space: nowrap;
+ padding: 0 16px;
+ margin-bottom: 4px;
+`
- ${Media.upToSmall()} {
- flex: 0 0 auto;
- }
+export const Title = styled.span`
+ display: block;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+`
- :hover {
- border: 1px solid ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : `var(${UI.COLOR_PRIMARY})`)};
- }
+export const List = styled.div`
+ display: flex;
+ flex-direction: column;
`
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 d9497b822f..41d074fd02 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -81,12 +81,10 @@ function useTokensContent(props: SelectTokenModalProps, searchInput: string, con
const {
displayLpTokenLists,
favoriteTokens,
- selectedToken,
- hideFavoriteTokensTooltip,
areTokensLoading,
allTokens,
areTokensFromBridge,
- onSelectToken,
+ hideFavoriteTokensTooltip,
} = props
return (
@@ -94,13 +92,11 @@ function useTokensContent(props: SelectTokenModalProps, searchInput: string, con
displayLpTokenLists={displayLpTokenLists}
selectTokenContext={context}
favoriteTokens={favoriteTokens}
- selectedToken={selectedToken}
- hideFavoriteTokensTooltip={hideFavoriteTokensTooltip}
areTokensLoading={areTokensLoading}
allTokens={allTokens}
searchInput={searchInput}
areTokensFromBridge={areTokensFromBridge}
- onSelectToken={onSelectToken}
+ hideFavoriteTokensTooltip={hideFavoriteTokensTooltip}
/>
)
}
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 becda025ca..6cc4d5dd46 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -87,7 +87,7 @@ export const TokenColumn = styled.div`
min-height: 0;
display: flex;
flex-direction: column;
- padding: 16px 24px 24px;
+ padding: 0 0 14px;
${Media.upToSmall()} {
padding: 16px;
@@ -109,6 +109,16 @@ export const Separator = styled.div`
margin: 0 0 16px;
`
+export const ListTitle = styled.div`
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ padding: 8px 16px 4px;
+`
+
+
export const TokensLoader = styled.div`
width: 100%;
height: 100%;
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 2b5fbe6917..e8fd638997 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -1,14 +1,10 @@
-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 { TokenSearchResults } from '../../containers/TokenSearchResults'
import { SelectTokenContext } from '../../types'
-import { FavoriteTokensList } from '../FavoriteTokensList'
import * as styledEl from '../SelectTokenModal/styled'
import { TokensVirtualList } from '../TokensVirtualList'
@@ -16,40 +12,45 @@ export interface TokensContentProps {
displayLpTokenLists?: boolean
selectTokenContext: SelectTokenContext
favoriteTokens: TokenWithLogo[]
- selectedToken?: Nullish
- hideFavoriteTokensTooltip?: boolean
areTokensLoading: boolean
allTokens: TokenWithLogo[]
searchInput: string
areTokensFromBridge: boolean
-
- onSelectToken(token: TokenWithLogo): void
+ hideFavoriteTokensTooltip?: boolean
}
export function TokensContent({
selectTokenContext,
- onSelectToken,
- selectedToken,
favoriteTokens,
- hideFavoriteTokensTooltip,
areTokensLoading,
allTokens,
displayLpTokenLists,
searchInput,
areTokensFromBridge,
+ hideFavoriteTokensTooltip,
}: TokensContentProps): ReactNode {
+ const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0
+
+ const favoriteAddresses = useMemo(() => {
+ if (!shouldShowFavoritesInline) {
+ return undefined
+ }
+
+ return new Set(favoriteTokens.map((token) => token.address.toLowerCase()))
+ }, [favoriteTokens, shouldShowFavoritesInline])
+
+ const tokensWithoutFavorites = useMemo(() => {
+ if (!favoriteAddresses) {
+ return allTokens
+ }
+
+ return allTokens.filter((token) => !favoriteAddresses.has(token.address.toLowerCase()))
+ }, [allTokens, favoriteAddresses])
+
+ const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined
+
return (
<>
- {!areTokensLoading && !!favoriteTokens.length && (
- <>
-
- >
- )}
{areTokensLoading ? (
@@ -66,8 +67,10 @@ export function TokensContent({
) : (
)}
>
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..8c137c6ddb 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -10,36 +10,72 @@ 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[]
+ hideFavoriteTokensTooltip?: boolean
}
+type TokensVirtualRow =
+ | { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean }
+ | { type: 'title'; label: string }
+ | { type: 'token'; token: TokenWithLogo }
+
export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
- const { allTokens, selectTokenContext, displayLpTokenLists } = props
+ const { allTokens, selectTokenContext, displayLpTokenLists, favoriteTokens, hideFavoriteTokensTooltip } = props
const { values: balances } = selectTokenContext.balancesState
const { isYieldEnabled } = useFeatureFlags()
- const sortedTokens = useMemo(
- () => (balances ? allTokens.sort(tokensListSorter(balances)) : allTokens),
- [allTokens, balances],
- )
+ const sortedTokens = useMemo(() => {
+ const listToSort = [...allTokens]
+ return balances ? listToSort.sort(tokensListSorter(balances)) : listToSort
+ }, [allTokens, balances])
+
+ const rows = useMemo(() => {
+ const tokenRows = sortedTokens.map((token) => ({ type: 'token', token }))
+
+ if (favoriteTokens?.length) {
+ return [
+ { type: 'favorite-section', tokens: favoriteTokens, hideTooltip: hideFavoriteTokensTooltip },
+ { type: 'title', label: 'All tokens' },
+ ...tokenRows,
+ ]
+ }
+
+ return tokenRows
+ }, [favoriteTokens, hideFavoriteTokensTooltip, sortedTokens])
const getItemView = useCallback(
- (sortedTokens: TokenWithLogo[], virtualRow: VirtualItem) => {
- const token = sortedTokens[virtualRow.index]
+ (rows: TokensVirtualRow[], virtualRow: VirtualItem) => {
+ const row = rows[virtualRow.index]
- return
+ switch (row.type) {
+ case 'favorite-section':
+ return (
+
+ )
+ case 'title':
+ return {row.label}
+ default:
+ return
+ }
},
[selectTokenContext],
)
return (
-
+
{displayLpTokenLists || !isYieldEnabled ? null : }
)
From 67278e32938694308147810accd8e41b9cbfd0d8 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 7 Nov 2025 17:27:59 +0000
Subject: [PATCH 04/37] feat: replace active chain icon with SVG and update
styling
---
.../modules/tokensList/pure/ChainsSelector/index.tsx | 7 ++++---
.../modules/tokensList/pure/ChainsSelector/styled.tsx | 10 ++++++++++
2 files changed, 14 insertions(+), 3 deletions(-)
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 24308fa343..91bb08051f 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -1,9 +1,10 @@
import { ReactNode } from 'react'
+import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg'
import { useTheme } from '@cowprotocol/common-hooks'
import { ChainInfo } from '@cowprotocol/cow-sdk'
-import { Check } from 'react-feather'
+import SVG from 'react-inlinesvg'
import * as styledEl from './styled'
@@ -52,8 +53,8 @@ export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoadin
{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 f2a20fedcb..195170f7f8 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
@@ -74,6 +74,16 @@ export const ActiveIcon = styled.span`
align-items: center;
justify-content: center;
color: var(${UI.COLOR_PRIMARY});
+
+ > svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+ }
+
+ > svg > path {
+ fill: currentColor;
+ }
`
export const LoadingRow = styled.div`
From 6cd9ee0fa98a4a129f66d6204692d87da9344074 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Sat, 8 Nov 2025 08:32:14 +0000
Subject: [PATCH 05/37] feat: implement SelectTokenWidget with controller and
view separation for improved token selection
---
.../src/common/pure/VirtualList/index.tsx | 91 ++++--
.../SelectTokenWidget/controller.ts | 130 ++++++++
.../SelectTokenWidget/controllerProps.ts | 221 +++++++++++++
.../SelectTokenWidget/controllerState.ts | 225 ++++++++++++++
.../containers/SelectTokenWidget/index.tsx | 291 +++++-------------
.../tokensList/pure/ChainPanel/styled.ts | 2 +-
.../tokensList/pure/ChainsSelector/index.tsx | 103 ++++---
.../pure/FavoriteTokensList/index.tsx | 41 ++-
.../pure/SelectTokenModal/index.tsx | 58 +++-
.../tokensList/pure/TokensContent/index.tsx | 3 +
.../pure/TokensVirtualList/index.tsx | 68 ++--
11 files changed, 914 insertions(+), 319 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
index a5f10673af..595fef8179 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,39 @@ 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 VirtualListProps {
id?: string
@@ -26,10 +50,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 +60,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 +77,7 @@ export function VirtualList({
}, scrollDelay)
}, [])
+ // eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
getScrollElement: () => parentRef.current,
count: items.length,
@@ -60,6 +85,25 @@ 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()
return (
@@ -67,17 +111,16 @@ export function VirtualList({
{children}
- {virtualItems.map((item) => {
- if (loading) {
- return {threeDivs()}
- }
-
- return (
-
- {getItemView(items, item)}
-
- )
- })}
+ {virtualItems.map((item) => (
+
+ ))}
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..1ea0d2405e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
@@ -0,0 +1,130 @@
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { isInjectedWidget } from '@cowprotocol/common-utils'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import { Field } from 'legacy/state/types'
+
+import { useLpTokensWithBalances } from 'modules/yield/shared'
+
+import {
+ SelectTokenWidgetViewProps,
+ buildSelectTokenModalPropsInput,
+ buildSelectTokenWidgetViewProps,
+ useSelectTokenModalPropsMemo,
+} from './controllerProps'
+import {
+ hasAvailableChains,
+ useDismissHandler,
+ useImportFlowCallbacks,
+ useManageWidgetVisibility,
+ usePoolPageHandlers,
+ useTokenAdminActions,
+ useTokenDataSources,
+ useTokenSelectionHandler,
+ 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'
+
+
+const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
+
+export interface SelectTokenWidgetProps {
+ displayLpTokenLists?: boolean
+ standalone?: boolean
+}
+
+export interface SelectTokenWidgetController {
+ shouldRender: boolean
+ isBridgingEnabled: boolean
+ viewProps: SelectTokenWidgetViewProps
+}
+
+export function useSelectTokenWidgetController({
+ displayLpTokenLists,
+ standalone,
+}: SelectTokenWidgetProps): SelectTokenWidgetController {
+ const widgetState = useSelectTokenWidgetState(),
+ { count: lpTokensWithBalancesCount } = useLpTokensWithBalances(),
+ resolvedField = widgetState.field ?? Field.INPUT
+ const chainsToSelect = useChainsToSelect(),
+ onSelectChain = useOnSelectChain()
+ const { isManageWidgetOpen, openManageWidget, closeManageWidget } = useManageWidgetVisibility()
+ const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
+ const { account } = useWalletInfo(),
+ closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const tokenData = useTokenDataSources(),
+ onTokenListAddingError = useOnTokenListAddingError(),
+ { addCustomTokenLists, importTokenCallback } = useTokenAdminActions()
+ const { modalTitle, chainsPanelTitle, disableErc20, tokenListCategoryState } = useWidgetMetadata(
+ resolvedField,
+ displayLpTokenLists,
+ widgetState.oppositeToken,
+ lpTokensWithBalancesCount,
+ )
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget),
+ { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
+ const importFlows = useImportFlowCallbacks(
+ importTokenCallback,
+ widgetState.onSelectToken,
+ onDismiss,
+ addCustomTokenLists,
+ onTokenListAddingError,
+ updateSelectTokenWidget,
+ ),
+ handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken),
+ isInjectedWidgetMode = isInjectedWidget(),
+ isBridgingEnabled = hasAvailableChains(chainsToSelect)
+ const selectTokenModalPropsInput = buildSelectTokenModalPropsInput({
+ standalone,
+ displayLpTokenLists,
+ tokenData,
+ widgetState,
+ favoriteTokens: standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens,
+ handleSelectToken,
+ onDismiss,
+ onOpenManageWidget: openManageWidget,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ isBridgingEnabled,
+ isInjectedWidgetMode,
+ modalTitle,
+ }),
+ selectTokenModalProps = useSelectTokenModalPropsMemo(selectTokenModalPropsInput)
+ const viewProps = buildSelectTokenWidgetViewProps({
+ standalone,
+ tokenToImport: widgetState.tokenToImport,
+ listToImport: widgetState.listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress: widgetState.selectedPoolAddress,
+ isBridgingEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ onBackFromImport: importFlows.resetTokenImport,
+ onImportTokens: importFlows.importTokenAndClose,
+ onImportList: importFlows.importListAndBack,
+ allTokenLists: tokenData.allTokenLists,
+ userAddedTokens: tokenData.userAddedTokens,
+ onCloseManageWidget: closeManageWidget,
+ onClosePoolPage: closePoolPage,
+ selectTokenModalProps,
+ onSelectToken: handleSelectToken,
+ })
+
+ return {
+ shouldRender: Boolean(widgetState.onSelectToken && widgetState.open),
+ isBridgingEnabled,
+ viewProps,
+ }
+}
+
+export type { SelectTokenWidgetViewProps } from './controllerProps'
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..bd0216c85e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
@@ -0,0 +1,221 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { ListState } from '@cowprotocol/tokens'
+
+import { ChainsToSelectState } 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
+ isBridgingEnabled: 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(token: TokenWithLogo): void
+}
+
+interface BuildViewPropsArgs {
+ standalone?: boolean
+ tokenToImport?: TokenWithLogo
+ listToImport?: ListState
+ isManageWidgetOpen: boolean
+ selectedPoolAddress?: string
+ isBridgingEnabled: 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(token: TokenWithLogo): void
+}
+
+interface BuildModalPropsArgs {
+ standalone?: boolean
+ displayLpTokenLists?: boolean
+ tokenData: TokenDataSources
+ widgetState: WidgetState
+ favoriteTokens: TokenWithLogo[]
+ handleSelectToken(token: TokenWithLogo): void
+ onDismiss(): void
+ onOpenManageWidget(): void
+ openPoolPage(poolAddress: string): void
+ tokenListCategoryState: TokenListCategoryState
+ disableErc20: boolean
+ account: string | undefined
+ isBridgingEnabled: boolean
+ isInjectedWidgetMode: boolean
+ modalTitle: string
+}
+
+export function buildSelectTokenWidgetViewProps({
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ isBridgingEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ onBackFromImport,
+ onImportTokens,
+ onImportList,
+ allTokenLists,
+ userAddedTokens,
+ onCloseManageWidget,
+ onClosePoolPage,
+ selectTokenModalProps,
+ onSelectToken,
+}: BuildViewPropsArgs): SelectTokenWidgetViewProps {
+ return {
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ isBridgingEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ onBackFromImport,
+ onImportTokens,
+ onImportList,
+ allTokenLists,
+ userAddedTokens,
+ onCloseManageWidget,
+ onClosePoolPage,
+ selectTokenModalProps,
+ onSelectToken,
+ }
+}
+
+export function buildSelectTokenModalPropsInput({
+ standalone,
+ displayLpTokenLists,
+ tokenData,
+ widgetState,
+ favoriteTokens,
+ handleSelectToken,
+ onDismiss,
+ onOpenManageWidget,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ isBridgingEnabled,
+ isInjectedWidgetMode,
+ modalTitle,
+}: BuildModalPropsArgs): SelectTokenModalProps {
+ return {
+ standalone,
+ displayLpTokenLists,
+ unsupportedTokens: tokenData.unsupportedTokens,
+ selectedToken: widgetState.selectedToken,
+ allTokens: tokenData.allTokens,
+ favoriteTokens,
+ balancesState: tokenData.balancesState,
+ permitCompatibleTokens: tokenData.permitCompatibleTokens,
+ onSelectToken: handleSelectToken,
+ onInputPressEnter: widgetState.onInputPressEnter,
+ onDismiss,
+ onOpenManageWidget,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ areTokensLoading: tokenData.areTokensLoading,
+ tokenListTags: tokenData.tokenListTags,
+ areTokensFromBridge: tokenData.areTokensFromBridge,
+ isRouteAvailable: tokenData.isRouteAvailable,
+ modalTitle,
+ hasChainPanel: isBridgingEnabled,
+ hideFavoriteTokensTooltip: isInjectedWidgetMode,
+ selectedTargetChainId: widgetState.selectedTargetChainId,
+ }
+}
+
+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,
+ balancesState: props.balancesState,
+ permitCompatibleTokens: props.permitCompatibleTokens,
+ onSelectToken: props.onSelectToken,
+ 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,
+ }),
+ [
+ props.standalone,
+ props.displayLpTokenLists,
+ props.unsupportedTokens,
+ props.selectedToken,
+ props.allTokens,
+ props.favoriteTokens,
+ props.balancesState,
+ props.permitCompatibleTokens,
+ props.onSelectToken,
+ 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,
+ ],
+ )
+}
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..f6461ab024
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -0,0 +1,225 @@
+import { Dispatch, SetStateAction, useCallback, useState } from 'react'
+
+import { useCowAnalytics } from '@cowprotocol/analytics'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import {
+ ListState,
+ TokenListCategory,
+ useAddList,
+ useAddUserToken,
+ useAllListsList,
+ useTokenListsTags,
+ useUnsupportedTokens,
+ useUserAddedTokens,
+} from '@cowprotocol/tokens'
+
+import { Field } from 'legacy/state/types'
+
+
+import { useTokensBalancesCombined } from 'modules/combinedBalances'
+import { usePermitCompatibleTokens } from 'modules/permit'
+
+import { CowSwapAnalyticsCategory } from 'common/analytics/types'
+
+import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
+
+import { useTokensToSelect } from '../../hooks/useTokensToSelect'
+import { ChainsToSelectState } 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
+}
+
+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,
+ 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 = field === Field.INPUT ? 'Swap from' : field === Field.OUTPUT ? 'Swap to' : 'Select token'
+ const chainsPanelTitle =
+ field === Field.INPUT ? 'From network' : field === Field.OUTPUT ? 'To network' : 'Select network'
+
+ return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle }
+}
+
+export function useDismissHandler(
+ closeManageWidget: () => void,
+ closeTokenSelectWidget: () => void,
+): () => void {
+ return useCallback(() => {
+ closeManageWidget()
+ closeTokenSelectWidget()
+ }, [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: ((token: TokenWithLogo) => void) | undefined,
+ onDismiss: () => void,
+ addCustomTokenLists: (list: ListState) => void,
+ onTokenListAddingError: (error: Error) => void,
+ updateSelectTokenWidget: UpdateSelectTokenWidgetFn,
+): ImportFlowCallbacks {
+ const importTokenAndClose = useCallback(
+ (tokens: TokenWithLogo[]) => {
+ importTokenCallback(tokens)
+ onSelectToken?.(tokens[0])
+ onDismiss()
+ },
+ [importTokenCallback, onSelectToken, onDismiss],
+ )
+
+ 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 useTokenSelectionHandler(
+ onSelectToken: ((token: TokenWithLogo) => void) | undefined,
+): (token: TokenWithLogo) => void {
+ return useCallback(
+ (token: TokenWithLogo) => {
+ onSelectToken?.(token)
+ },
+ [onSelectToken],
+ )
+}
+
+export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean {
+ if (!chainsToSelect) {
+ return false
+ }
+
+ return chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0
+}
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 d8d8231c23..20abe061ec 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -1,39 +1,13 @@
-import { ReactNode, useCallback, useState } from 'react'
-
-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 { ReactNode } from 'react'
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 {
+ useSelectTokenWidgetController,
+ type SelectTokenWidgetProps,
+ type SelectTokenWidgetViewProps,
+} from './controller'
-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'
import { ChainPanel } from '../../pure/ChainPanel'
import { ImportListModal } from '../../pure/ImportListModal'
import { ImportTokenModal } from '../../pure/ImportTokenModal'
@@ -60,199 +34,92 @@ const ModalContainer = styled.div`
display: flex;
`
-const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
+export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode {
+ const controller = useSelectTokenWidgetController(props)
+
+ if (!controller.shouldRender) {
+ return null
+ }
-interface SelectTokenWidgetProps {
- displayLpTokenLists?: boolean
- standalone?: boolean
+ return (
+
+
+
+
+
+ )
}
-// TODO: Break down this large function into smaller functions
-// eslint-disable-next-line max-lines-per-function
-export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTokenWidgetProps): ReactNode {
+function SelectTokenWidgetView(props: SelectTokenWidgetViewProps): ReactNode {
const {
- open,
- onSelectToken,
+ standalone,
tokenToImport,
listToImport,
- selectedToken,
- onInputPressEnter,
+ isManageWidgetOpen,
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),
- )
-
- const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
- const { account } = useWalletInfo()
-
- const cowAnalytics = useCowAnalytics()
- const addCustomTokenLists = useAddList((source) => {
- cowAnalytics.sendEvent({
- category: CowSwapAnalyticsCategory.LIST,
- action: 'Add List Success',
- label: source,
- })
- })
- const importTokenCallback = useAddUserToken()
-
- 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 closeTokenSelectWidget = useCloseTokenSelectWidget()
- const modalTitle = field === Field.INPUT ? 'Swap from' : field === Field.OUTPUT ? 'Swap to' : 'Select token'
- // TODO: Confirm copy requirements for BUY orders and update titles accordingly.
- const chainsPanelTitle = 'Cross chain swap'
-
- const openPoolPage = useCallback(
- (selectedPoolAddress: string) => {
- updateSelectTokenWidget({ selectedPoolAddress })
- },
- [updateSelectTokenWidget],
- )
-
- 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()
+ isBridgingEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ onBackFromImport,
+ onImportTokens,
+ onImportList,
+ allTokenLists,
+ userAddedTokens,
+ onCloseManageWidget,
+ onClosePoolPage,
+ selectTokenModalProps,
+ onSelectToken,
+ } = props
+
+ if (tokenToImport && !standalone) {
+ return (
+
+ )
}
- const importListAndBack = (list: ListState): void => {
- try {
- addCustomTokenLists(list)
- } catch (error) {
- onDismiss()
- onTokenListAddingError(error)
- }
- updateSelectTokenWidget({ listToImport: undefined })
+ if (listToImport && !standalone) {
+ return (
+
+ )
}
- const isBridgingEnabled = !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0)
- const isInjectedWidgetMode = isInjectedWidget()
+ if (isManageWidgetOpen && !standalone) {
+ return (
+
+ )
+ }
- if (!onSelectToken || !open) return null
+ if (selectedPoolAddress) {
+ return (
+
+ )
+ }
return (
-
-
- {(() => {
- if (tokenToImport && !standalone) {
- return (
-
- )
- }
-
- if (listToImport && !standalone) {
- return (
-
- )
- }
-
- if (isManageWidgetOpen && !standalone) {
- return (
- setIsManageWidgetOpen(false)}
- />
- )
- }
-
- if (selectedPoolAddress) {
- return (
-
- )
- }
-
- return (
- <>
-
- setIsManageWidgetOpen(true)}
- openPoolPage={openPoolPage}
- tokenListCategoryState={tokenListCategoryState}
- disableErc20={disableErc20}
- account={account}
- areTokensLoading={areTokensLoading}
- tokenListTags={tokenListTags}
- areTokensFromBridge={areTokensFromBridge}
- isRouteAvailable={isRouteAvailable}
- modalTitle={modalTitle}
- hasChainPanel={isBridgingEnabled}
- hideFavoriteTokensTooltip={isInjectedWidgetMode}
- />
-
- {isBridgingEnabled && chainsToSelect && (
-
- )}
- >
- )
- })()}
-
-
+ <>
+
+
+
+ {isBridgingEnabled && chainsToSelect && (
+
+ )}
+ >
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
index e2826265fe..8ac5f8ff9d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
@@ -3,7 +3,7 @@ import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
export const Panel = styled.div`
- width: 210px;
+ width: 200px;
flex-shrink: 0;
background: var(${UI.COLOR_PAPER_DARKER});
border-left: 1px solid var(${UI.COLOR_BORDER});
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 91bb08051f..75ee5d4874 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -8,7 +8,6 @@ import SVG from 'react-inlinesvg'
import * as styledEl from './styled'
-// Number of skeleton shimmers to show during loading state
const LOADING_ITEMS_COUNT = 10
export interface ChainsSelectorProps {
@@ -19,47 +18,81 @@ export interface ChainsSelectorProps {
}
export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoading }: ChainsSelectorProps): ReactNode {
- const theme = useTheme()
+ const { darkMode } = useTheme()
if (isLoading) {
- return (
-
- {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => (
-
-
-
-
- ))}
-
- )
+ return
}
+ return (
+
+ )
+}
+
+function ChainsLoadingList(): ReactNode {
return (
- {chains.map((chain) => {
- const isActive = defaultChainId === chain.id
+ {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => (
+
+
+
+
+ ))}
+
+ )
+}
+
+interface ChainsListProps {
+ chains: ChainInfo[]
+ defaultChainId?: ChainInfo['id']
+ onSelectChain(chain: ChainInfo): void
+ isDarkMode: boolean
+}
- return (
- onSelectChain(chain)}
- active$={isActive}
- aria-pressed={isActive}
- >
-
-
-
-
- {chain.label}
-
- {isActive && (
-
-
-
- )}
-
- )
- })}
+function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
+ return (
+
+ {chains.map((chain) => (
+
+ ))}
)
}
+
+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
+
+ return (
+ onSelectChain(chain)} active$={isActive} aria-pressed={isActive}>
+
+
+
+
+ {chain.label}
+
+ {isActive && (
+
+
+
+ )}
+
+ )
+}
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 c38e804fd4..9430279d22 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -27,21 +27,38 @@ export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode {
Favourite tokens
- {!hideTooltip && (
-
- Your favorite saved tokens. Edit this list in the Tokens page.
- >
- }
- />
- )}
+ {!hideTooltip && }
- {tokens.map((token) => (
-
- ))}
+
)
}
+
+function FavoriteTokensTooltip(): ReactNode {
+ return (
+
+ Your favorite saved tokens. Edit this list in the Tokens page.
+ >
+ }
+ />
+ )
+}
+
+interface FavoriteTokensItemsProps {
+ tokens: TokenWithLogo[]
+ selectTokenContext: SelectTokenContext
+}
+
+function FavoriteTokensItems({ tokens, selectTokenContext }: FavoriteTokensItemsProps): ReactNode {
+ return (
+ <>
+ {tokens.map((token) => (
+
+ ))}
+ >
+ )
+}
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 41d074fd02..3bcc33fb3c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -38,6 +38,7 @@ export interface SelectTokenModalProps {
isRouteAvailable: boolean | undefined
modalTitle?: string
hasChainPanel?: boolean
+ selectedTargetChainId?: number
onSelectToken(token: TokenWithLogo): void
openPoolPage(poolAddress: string): void
@@ -77,26 +78,43 @@ function useTokenSearchInput(defaultInputValue = ''): [string, (value: string) =
return [inputValue, setInputValue, inputValue.trim()]
}
-function useTokensContent(props: SelectTokenModalProps, searchInput: string, context: SelectTokenContext): ReactNode {
- const {
- displayLpTokenLists,
- favoriteTokens,
- areTokensLoading,
- allTokens,
- areTokensFromBridge,
- hideFavoriteTokensTooltip,
- } = props
+interface TokensContentSectionProps
+ extends Pick<
+ SelectTokenModalProps,
+ | 'displayLpTokenLists'
+ | 'favoriteTokens'
+ | 'areTokensLoading'
+ | 'allTokens'
+ | 'areTokensFromBridge'
+ | 'hideFavoriteTokensTooltip'
+ | 'selectedTargetChainId'
+ > {
+ searchInput: string
+ selectTokenContext: SelectTokenContext
+}
+function TokensContentSection({
+ displayLpTokenLists,
+ favoriteTokens,
+ areTokensLoading,
+ allTokens,
+ searchInput,
+ areTokensFromBridge,
+ hideFavoriteTokensTooltip,
+ selectedTargetChainId,
+ selectTokenContext,
+}: TokensContentSectionProps): ReactNode {
return (
)
}
@@ -150,11 +168,29 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
hasChainPanel,
standalone,
onOpenManageWidget,
+ favoriteTokens,
+ areTokensLoading,
+ allTokens,
+ areTokensFromBridge,
+ hideFavoriteTokensTooltip,
+ selectedTargetChainId,
} = props
const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
const selectTokenContext = useSelectTokenContext(props)
- const allListsContent = useTokensContent(props, trimmedInputValue, selectTokenContext)
+ const allListsContent = (
+
+ )
const resolvedModalTitle = modalTitle ?? 'Select token'
return (
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 e8fd638997..332cf451a2 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -17,6 +17,7 @@ export interface TokensContentProps {
searchInput: string
areTokensFromBridge: boolean
hideFavoriteTokensTooltip?: boolean
+ selectedTargetChainId?: number
}
export function TokensContent({
@@ -28,6 +29,7 @@ export function TokensContent({
searchInput,
areTokensFromBridge,
hideFavoriteTokensTooltip,
+ selectedTargetChainId,
}: TokensContentProps): ReactNode {
const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0
@@ -71,6 +73,7 @@ export function TokensContent({
displayLpTokenLists={displayLpTokenLists}
favoriteTokens={favoriteTokensInline}
hideFavoriteTokensTooltip={hideFavoriteTokensTooltip}
+ scrollResetKey={selectedTargetChainId}
/>
)}
>
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 8c137c6ddb..dc639be1bf 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useCallback, useMemo } from 'react'
+import { ReactNode, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { useFeatureFlags } from '@cowprotocol/common-hooks'
@@ -20,6 +20,7 @@ export interface TokensVirtualListProps {
selectTokenContext: SelectTokenContext
favoriteTokens?: TokenWithLogo[]
hideFavoriteTokensTooltip?: boolean
+ scrollResetKey?: number
}
type TokensVirtualRow =
@@ -28,7 +29,14 @@ type TokensVirtualRow =
| { type: 'token'; token: TokenWithLogo }
export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
- const { allTokens, selectTokenContext, displayLpTokenLists, favoriteTokens, hideFavoriteTokensTooltip } = props
+ const {
+ allTokens,
+ selectTokenContext,
+ displayLpTokenLists,
+ favoriteTokens,
+ hideFavoriteTokensTooltip,
+ scrollResetKey,
+ } = props
const { values: balances } = selectTokenContext.balancesState
const { isYieldEnabled } = useFeatureFlags()
@@ -52,31 +60,43 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
return tokenRows
}, [favoriteTokens, hideFavoriteTokensTooltip, sortedTokens])
- const getItemView = useCallback(
- (rows: TokensVirtualRow[], virtualRow: VirtualItem) => {
- const row = rows[virtualRow.index]
-
- switch (row.type) {
- case 'favorite-section':
- return (
-
- )
- case 'title':
- return {row.label}
- default:
- return
- }
- },
- [selectTokenContext],
- )
+ const getItemView = useMemo(() => createTokensVirtualRowRenderer(selectTokenContext), [selectTokenContext])
+
+ const virtualListKey = scrollResetKey ?? 'tokens-list'
return (
-
+
{displayLpTokenLists || !isYieldEnabled ? null : }
)
}
+
+function createTokensVirtualRowRenderer(selectTokenContext: SelectTokenContext) {
+ return (rows: TokensVirtualRow[], virtualRow: VirtualItem): ReactNode => {
+ const row = rows[virtualRow.index]
+ return renderTokensVirtualRow(row, selectTokenContext)
+ }
+}
+
+function renderTokensVirtualRow(row: TokensVirtualRow, selectTokenContext: SelectTokenContext): ReactNode {
+ switch (row.type) {
+ case 'favorite-section':
+ return (
+
+ )
+ case 'title':
+ return {row.label}
+ default:
+ return
+ }
+}
From d04dd1238fb8582fc46ecd2e5df4168cf21bf66a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Sat, 8 Nov 2025 15:30:56 +0000
Subject: [PATCH 06/37] refactor: enhance token selection components by
separating rendering logic and improving structure
---
.../src/common/pure/VirtualList/index.tsx | 51 +++-
.../tokensList/pure/ChainsSelector/index.tsx | 57 ++--
.../pure/FavoriteTokensList/index.tsx | 18 +-
.../tokensList/pure/LpTokenLists/index.tsx | 126 +--------
.../pure/LpTokenLists/rowRenderer.tsx | 253 ++++++++++++++++++
.../pure/SelectTokenModal/helpers.tsx | 119 ++++++++
.../pure/SelectTokenModal/index.tsx | 175 ++----------
.../tokensList/pure/SelectTokenModal/types.ts | 37 +++
.../pure/TokensVirtualList/index.tsx | 39 ++-
9 files changed, 559 insertions(+), 316 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/rowRenderer.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
index 595fef8179..2134dc4129 100644
--- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
@@ -41,6 +41,39 @@ function VirtualListRow({ item, loading, items, getItemView, measureElement }
)
}
+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
items: T[]
@@ -105,22 +138,20 @@ export function VirtualList({
}, [scrollResetKey, virtualizer])
const virtualItems = virtualizer.getVirtualItems()
+ const virtualRows = renderVirtualListRows({
+ virtualItems,
+ loading,
+ items,
+ getItemView,
+ measureElement: virtualizer.measureElement,
+ })
return (
{children}
- {virtualItems.map((item) => (
-
- ))}
+ {virtualRows}
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 75ee5d4874..c6b4940c26 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -9,6 +9,7 @@ import SVG from 'react-inlinesvg'
import * as styledEl from './styled'
const LOADING_ITEMS_COUNT = 10
+const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index)
export interface ChainsSelectorProps {
chains: ChainInfo[]
@@ -35,18 +36,30 @@ export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoadin
}
function ChainsLoadingList(): ReactNode {
+ const skeletonRows = renderChainSkeletonRows()
+
return (
- {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => (
-
-
-
-
- ))}
+ {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']
@@ -55,21 +68,35 @@ interface ChainsListProps {
}
function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
+ const chainButtons = renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode })
+
return (
- {chains.map((chain) => (
-
- ))}
+ {chainButtons}
)
}
+interface ChainButtonsRenderProps extends ChainsListProps {}
+
+function renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainButtonsRenderProps): ReactNode[] {
+ const elements: ReactNode[] = []
+
+ for (const chain of chains) {
+ elements.push(
+ ,
+ )
+ }
+
+ return elements
+}
+
interface ChainButtonProps {
chain: ChainInfo
isActive: boolean
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 9430279d22..655949ddbc 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -54,11 +54,15 @@ interface FavoriteTokensItemsProps {
}
function FavoriteTokensItems({ tokens, selectTokenContext }: FavoriteTokensItemsProps): ReactNode {
- return (
- <>
- {tokens.map((token) => (
-
- ))}
- >
- )
+ return createFavoriteTokenItems(tokens, selectTokenContext)
+}
+
+function createFavoriteTokenItems(tokens: TokenWithLogo[], selectTokenContext: SelectTokenContext): ReactNode[] {
+ const elements: ReactNode[] = []
+
+ for (const token of tokens) {
+ elements.push()
+ }
+
+ return elements
}
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 b5a1619b5c..2928f24356 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
@@ -1,50 +1,23 @@
-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 { 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 { VirtualItem } from '@tanstack/react-virtual'
-import { Info } from 'react-feather'
+import { Media } from '@cowprotocol/ui'
import { PoolInfoStates } from 'modules/yield/shared'
import { VirtualList } from 'common/pure/VirtualList'
+import { useLpTokenRowRenderer } from './rowRenderer'
import {
CreatePoolLink,
EmptyList,
ListHeader,
- ListItem,
- LpTokenBalance,
- LpTokenInfo,
- LpTokenTooltip,
- LpTokenWrapper,
- LpTokenYieldPercentage,
- MobileCard,
- MobileCardLabel,
- MobileCardRow,
- MobileCardValue,
NoPoolWrapper,
Wrapper,
} from './styled'
-const LoadingElement = (
-
-
-
-)
-
-const MobileCardRowItem: React.FC<{ label: string; value: ReactNode }> = ({ label, value }) => (
-
- {label}:
- {value}
-
-)
-
interface LpTokenListsProps {
account: string | undefined
lpTokens: LpToken[]
@@ -55,9 +28,6 @@ interface LpTokenListsProps {
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,
@@ -66,89 +36,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 max-lines-per-function, 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..48cb93eee7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/rowRenderer.tsx
@@ -0,0 +1,253 @@
+import { MouseEventHandler, ReactNode, useMemo } from 'react'
+
+import { BalancesState } from '@cowprotocol/balances-and-allowances'
+import { LpToken, TokenWithLogo } 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'
+
+interface LpTokenRowRendererParams {
+ balances: BalancesState['values']
+ poolsInfo: PoolInfoStates | undefined
+ openPoolPage(poolAddress: string): void
+ onSelectToken(token: TokenWithLogo): void
+ 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(token: TokenWithLogo): void
+}
+
+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/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
new file mode 100644
index 0000000000..85722913b8
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
@@ -0,0 +1,119 @@
+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,
+ account,
+ tokenListTags,
+ } = props
+
+ return useMemo(
+ () => ({
+ balancesState,
+ selectedToken,
+ onSelectToken,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ tokenListTags,
+ isWalletConnected: !!account,
+ }),
+ [balancesState, selectedToken, onSelectToken, 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'
+ | 'areTokensLoading'
+ | 'allTokens'
+ | 'areTokensFromBridge'
+ | 'hideFavoriteTokensTooltip'
+ | 'selectedTargetChainId'
+ > {
+ searchInput: string
+ selectTokenContext: SelectTokenContext
+}
+
+export function TokensContentSection({
+ displayLpTokenLists,
+ favoriteTokens,
+ areTokensLoading,
+ allTokens,
+ searchInput,
+ areTokensFromBridge,
+ hideFavoriteTokensTooltip,
+ selectedTargetChainId,
+ selectTokenContext,
+}: 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.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
index 3bcc33fb3c..bea6e25e2a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -1,156 +1,16 @@
-import { ReactNode, useMemo, useState } from 'react'
+import { ReactNode } from 'react'
-import { BalancesState } from '@cowprotocol/balances-and-allowances'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens'
-import { BackButton, SearchInput } from '@cowprotocol/ui'
-import { Currency } from '@uniswap/sdk-core'
-
-import { Nullish } from 'types'
-
-import { PermitCompatibleTokens } from 'modules/permit'
-import { SettingsIcon } from 'modules/trade/pure/Settings'
+import { SearchInput } from '@cowprotocol/ui'
+import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers'
import { SelectTokenModalContent } from './SelectTokenModalContent'
import * as styledEl from './styled'
import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
-import { SelectTokenContext } from '../../types'
-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
- tokenListCategoryState: [T, (category: T) => void]
- defaultInputValue?: string
- areTokensLoading: boolean
- tokenListTags: TokenListTags
- standalone?: boolean
- areTokensFromBridge: boolean
- isRouteAvailable: boolean | undefined
- modalTitle?: string
- hasChainPanel?: boolean
- selectedTargetChainId?: number
-
- onSelectToken(token: TokenWithLogo): void
- openPoolPage(poolAddress: string): void
- onInputPressEnter?(): void
- onOpenManageWidget(): void
- onDismiss(): 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],
- )
-}
-
-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'
- | 'areTokensLoading'
- | 'allTokens'
- | 'areTokensFromBridge'
- | 'hideFavoriteTokensTooltip'
- | 'selectedTargetChainId'
- > {
- searchInput: string
- selectTokenContext: SelectTokenContext
-}
-
-function TokensContentSection({
- displayLpTokenLists,
- favoriteTokens,
- areTokensLoading,
- allTokens,
- searchInput,
- areTokensFromBridge,
- hideFavoriteTokensTooltip,
- selectedTargetChainId,
- selectTokenContext,
-}: TokensContentSectionProps): ReactNode {
- return (
-
- )
-}
-
-function TitleBarActions({
- showManageButton,
- onDismiss,
- onOpenManageWidget,
- title,
-}: {
- showManageButton: boolean
- onDismiss(): void
- onOpenManageWidget(): void
- title: string
-}): ReactNode {
- return (
-
-
-
- {title}
-
- {showManageButton && (
-
-
-
-
-
- )}
-
- )
-}
+import type { SelectTokenModalProps } from './types'
+export type { SelectTokenModalProps }
export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
const {
@@ -178,19 +38,6 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
const selectTokenContext = useSelectTokenContext(props)
- const allListsContent = (
-
- )
const resolvedModalTitle = modalTitle ?? 'Select token'
return (
@@ -222,7 +69,17 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
tokenListCategoryState={tokenListCategoryState}
isRouteAvailable={isRouteAvailable}
>
- {allListsContent}
+
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..4a2b40e11f
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -0,0 +1,37 @@
+import { BalancesState } from '@cowprotocol/balances-and-allowances'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens'
+import { Currency } from '@uniswap/sdk-core'
+
+import { Nullish } from 'types'
+
+import { PermitCompatibleTokens } from 'modules/permit'
+
+export interface SelectTokenModalProps {
+ allTokens: TokenWithLogo[]
+ favoriteTokens: 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
+
+ onSelectToken(token: TokenWithLogo): void
+ openPoolPage(poolAddress: string): void
+ onInputPressEnter?(): void
+ onOpenManageWidget(): void
+ onDismiss(): void
+}
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 dc639be1bf..631c513a80 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -3,8 +3,6 @@ import { ReactNode, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { useFeatureFlags } from '@cowprotocol/common-hooks'
-import { VirtualItem } from '@tanstack/react-virtual'
-
import { CoWAmmBanner } from 'common/containers/CoWAmmBanner'
import { VirtualList } from 'common/pure/VirtualList'
@@ -60,16 +58,25 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
return tokenRows
}, [favoriteTokens, hideFavoriteTokensTooltip, sortedTokens])
- const getItemView = useMemo(() => createTokensVirtualRowRenderer(selectTokenContext), [selectTokenContext])
-
const virtualListKey = scrollResetKey ?? 'tokens-list'
+ const renderedRows = useMemo(
+ () =>
+ rows.map((row, index) => (
+
+ )),
+ [rows, selectTokenContext],
+ )
return (
renderedRows[virtualRow.index]}
scrollResetKey={scrollResetKey}
>
{displayLpTokenLists || !isYieldEnabled ? null : }
@@ -77,14 +84,12 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
)
}
-function createTokensVirtualRowRenderer(selectTokenContext: SelectTokenContext) {
- return (rows: TokensVirtualRow[], virtualRow: VirtualItem): ReactNode => {
- const row = rows[virtualRow.index]
- return renderTokensVirtualRow(row, selectTokenContext)
- }
+interface TokensVirtualRowRendererProps {
+ row: TokensVirtualRow
+ selectTokenContext: SelectTokenContext
}
-function renderTokensVirtualRow(row: TokensVirtualRow, selectTokenContext: SelectTokenContext): ReactNode {
+function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowRendererProps): ReactNode {
switch (row.type) {
case 'favorite-section':
return (
@@ -100,3 +105,15 @@ function renderTokensVirtualRow(row: TokensVirtualRow, selectTokenContext: Selec
return
}
}
+
+function getRowKey(row: TokensVirtualRow, index: number): string {
+ if (row.type === 'favorite-section') {
+ return 'favorite-section'
+ }
+
+ if (row.type === 'title') {
+ return `title-${row.label}`
+ }
+
+ return `token-${row.token.address ?? index}`
+}
From d7a7799745c892d4c7540184d42c4db2c13da2ba Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Sat, 8 Nov 2025 15:36:52 +0000
Subject: [PATCH 07/37] chore: update @tanstack/react-virtual dependency to
version 3.13.12 in package.json and yarn.lock
---
package.json | 2 +-
yarn.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/package.json b/package.json
index e6dcc8d41f..cc18cea45b 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 16f3d8fd45..3d46e429cb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8240,7 +8240,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==
From 23214a36021b00aba9799b9c526040a52d6cf230 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Sat, 8 Nov 2025 16:59:07 +0000
Subject: [PATCH 08/37] refactor: simplify token selection logic and enhance
ChainPanel state handling
---
.../SelectTokenWidget/controllerState.ts | 6 +-----
.../containers/SelectTokenWidget/index.tsx | 19 ++++++++++++++++++-
.../tokensList/hooks/useChainsToSelect.ts | 16 ++++++++++------
.../tokensList/pure/ChainPanel/index.tsx | 11 +++++------
4 files changed, 34 insertions(+), 18 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
index f6461ab024..227aeb708e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -217,9 +217,5 @@ export function useTokenSelectionHandler(
}
export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean {
- if (!chainsToSelect) {
- return false
- }
-
- return chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0
+ return Boolean(chainsToSelect)
}
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 20abe061ec..34b1cec334 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -1,6 +1,8 @@
import { ReactNode } from 'react'
-import styled from 'styled-components/macro'
+import { Media } from '@cowprotocol/ui'
+
+import styled, { css } from 'styled-components/macro'
import {
useSelectTokenWidgetController,
@@ -26,6 +28,21 @@ const InnerWrapper = styled.div<{ $hasSidebar: boolean }>`
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;
+ }
+ `}
`
const ModalContainer = styled.div`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
index b781b7c112..0ce7277d31 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -43,13 +43,17 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
return useMemo(() => {
if (!field || !isBridgingEnabled) return undefined
- const currentChainInfo = mapChainInfo(chainId, CHAIN_INFO[chainId])
+ const chainInfo = CHAIN_INFO[chainId]
+ if (!chainInfo) return undefined
+
+ const currentChainInfo = mapChainInfo(chainId, chainInfo)
const isSourceChainSupportedByBridge = Boolean(
bridgeSupportedNetworks?.find((bridgeChain) => bridgeChain.id === chainId),
)
// For the sell token selector we only display supported chains
if (field === Field.INPUT) {
+ // Sell side can only pick among wallet-supported chains
return {
defaultChainId: selectedTargetChainId,
chains: supportedChains,
@@ -57,6 +61,8 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
}
}
+ const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) ?? []
+
/**
* When the source chain is not supported by bridge provider
* We act as non-bridge mode
@@ -64,17 +70,15 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
if (!isSourceChainSupportedByBridge) {
return {
defaultChainId: selectedTargetChainId,
- chains: [],
+ chains: [currentChainInfo],
isLoading: false,
}
}
- const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled)
-
return {
defaultChainId: selectedTargetChainId,
- // Add the source network to the list if it's not supported by bridge provider
- chains: [...(isSourceChainSupportedByBridge ? [] : [currentChainInfo]), ...(destinationChains || [])],
+ // Bridge supports this chain, so show the destinations reported by the provider
+ chains: destinationChains,
isLoading,
}
}, [
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
index 901e8af144..64513a24b4 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -34,11 +34,9 @@ export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProp
})
}, [chains, normalizedChainQuery])
- if (!isLoading && chains.length === 0) {
- return null
- }
-
- const showEmptyState = !isLoading && filteredChains.length === 0 && !!normalizedChainQuery
+ const showSearchEmptyState = !isLoading && filteredChains.length === 0 && !!normalizedChainQuery
+ // When bridge networks are unavailable we still render the panel but show the fallback copy
+ const showUnavailableState = !isLoading && chains.length === 0 && !normalizedChainQuery
return (
@@ -59,7 +57,8 @@ export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProp
defaultChainId={chainsState?.defaultChainId}
onSelectChain={onSelectChain}
/>
- {showEmptyState && No networks match "{chainQuery}".}
+ {showUnavailableState && No networks available for this trade.}
+ {showSearchEmptyState && No networks match "{chainQuery}".}
)
From 05cd3441909888af71b914c7c004e7ae29b28694 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Sun, 9 Nov 2025 08:45:32 +0000
Subject: [PATCH 09/37] refactor: optimize token rendering logic in
TokensVirtualList using useCallback
---
.../pure/TokensVirtualList/index.tsx | 34 ++++++-------------
1 file changed, 10 insertions(+), 24 deletions(-)
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 631c513a80..1ede7fa062 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -1,8 +1,10 @@
-import { ReactNode, useMemo } from 'react'
+import { ReactNode, useCallback, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { useFeatureFlags } from '@cowprotocol/common-hooks'
+import { VirtualItem } from '@tanstack/react-virtual'
+
import { CoWAmmBanner } from 'common/containers/CoWAmmBanner'
import { VirtualList } from 'common/pure/VirtualList'
@@ -59,16 +61,12 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
}, [favoriteTokens, hideFavoriteTokensTooltip, sortedTokens])
const virtualListKey = scrollResetKey ?? 'tokens-list'
- const renderedRows = useMemo(
- () =>
- rows.map((row, index) => (
-
- )),
- [rows, selectTokenContext],
+
+ const renderVirtualRow = useCallback(
+ (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => (
+
+ ),
+ [selectTokenContext],
)
return (
@@ -76,7 +74,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
key={virtualListKey}
id="tokens-list"
items={rows}
- getItemView={(_, virtualRow) => renderedRows[virtualRow.index]}
+ getItemView={renderVirtualRow}
scrollResetKey={scrollResetKey}
>
{displayLpTokenLists || !isYieldEnabled ? null : }
@@ -105,15 +103,3 @@ function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowR
return
}
}
-
-function getRowKey(row: TokensVirtualRow, index: number): string {
- if (row.type === 'favorite-section') {
- return 'favorite-section'
- }
-
- if (row.type === 'title') {
- return `title-${row.label}`
- }
-
- return `token-${row.token.address ?? index}`
-}
From 16be22a7832c2dd47bbff7b42783254c40468c54 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Sun, 9 Nov 2025 13:15:57 +0000
Subject: [PATCH 10/37] refactor: enhance token search results rendering with
virtualized list and improved section handling
---
.../containers/TokenSearchResults/styled.ts | 8 +-
.../tokensList/pure/ImportTokenItem/index.tsx | 13 +-
.../tokensList/pure/ImportTokenItem/styled.ts | 12 +-
.../pure/TokenSearchContent/index.tsx | 225 ++++++++++++++----
4 files changed, 196 insertions(+), 62 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/styled.ts
index abea186dbe..2d0d5e94ed 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/styled.ts
@@ -9,11 +9,11 @@ export const TokenNotFound = styled.div`
text-align: center;
`
-export const ImportTokenWrapper = styled.div`
- margin: 20px 0;
-`
-
export const LoaderWrapper = styled.div`
text-align: center;
margin: 20px 0 10px 0;
`
+
+export const SectionTitleRow = styled.div`
+ margin: 20px 0 8px;
+`
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 c5033aa57b..43bde9a1cd 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 { CheckCircle } from 'react-feather'
@@ -12,14 +14,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/TokenSearchContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
index 3e93f5ce62..9374326d3a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
@@ -1,10 +1,14 @@
-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 { VirtualItem } from '@tanstack/react-virtual'
+
+import { VirtualList } from 'common/pure/VirtualList'
+
import * as styledEl from '../../containers/TokenSearchResults/styled'
import { SelectTokenContext } from '../../types'
import { ImportTokenItem } from '../ImportTokenItem'
@@ -54,6 +58,27 @@ export function TokenSearchContent({
return [matched, remaining]
}, [activeListsResult, searchInput])
+ 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 (
@@ -63,49 +88,157 @@ export function TokenSearchContent({
if (isTokenNotFound) return No tokens found
- return (
- <>
- {/*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 ? (
-
-
- Expanded results from inactive Token Lists
-
-
- {inactiveListsResult.slice(0, SEARCH_RESULTS_LIMIT).map((token) => {
- return
- })}
-
-
- ) : null}
-
- {/*Tokens from external sources*/}
- {externalApiResult?.length ? (
-
-
- Additional Results from External Sources
-
-
- {externalApiResult.map((token) => {
- return
- })}
-
-
- ) : null}
- >
- )
+ return
+}
+
+type TokenImportSection = 'blockchain' | 'inactive' | 'external'
+
+type TokenSearchRow =
+ | { 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[] = []
+
+ 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: 'Expanded results from inactive Token Lists',
+ tooltip: '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: 'Additional Results from External Sources',
+ tooltip: '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 'token':
+ return
+ case 'section-title': {
+ const tooltip = row.tooltip ?? ''
+ return (
+
+ {row.text}
+
+ )
+ }
+ case 'import-token':
+ return (
+
+ )
+ default:
+ return null
+ }
}
From d587f33d1b3d1fc204349c9cd7b925c26722ccc2 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Sun, 9 Nov 2025 13:17:40 +0000
Subject: [PATCH 11/37] feat: add guide banner to TokenSearchContent for custom
token addition
---
.../containers/TokenSearchResults/index.tsx | 19 ------------
.../pure/TokenSearchContent/index.tsx | 30 ++++++++++++++++++-
2 files changed, 29 insertions(+), 20 deletions(-)
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 ee75248cc9..4bb84a16f8 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
@@ -3,13 +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 { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
@@ -75,18 +68,6 @@ export function TokenSearchResults({
return (
-
-
- Can't find your token on the list?{' '}
- Read our guide on how to add custom tokens.
-
-
-
case 'token':
return
case 'section-title': {
@@ -242,3 +254,19 @@ function TokenSearchRowRenderer({ row, selectTokenContext, importToken }: TokenS
return null
}
}
+
+function GuideBanner(): ReactNode {
+ return (
+
+
+ Can't find your token on the list?{' '}
+ Read our guide on how to add custom tokens.
+
+
+ )
+}
From c091aad396094fe1c97d78c49317006a05eb55c6 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 08:44:40 +0000
Subject: [PATCH 12/37] feat: implement recent tokens feature in
SelectTokenWidget
---
.../SelectTokenWidget/controller.ts | 121 +++++++---
.../SelectTokenWidget/controllerProps.ts | 10 +
.../SelectTokenWidget/controllerState.ts | 30 ++-
.../tokensList/hooks/useRecentTokens.ts | 224 ++++++++++++++++++
.../pure/SelectTokenModal/helpers.tsx | 16 +-
.../pure/SelectTokenModal/index.cosmos.tsx | 7 +
.../pure/SelectTokenModal/index.tsx | 2 +
.../tokensList/pure/SelectTokenModal/types.ts | 2 +
.../pure/TokenListItemContainer/index.tsx | 12 +-
.../tokensList/pure/TokensContent/index.tsx | 34 ++-
.../pure/TokensVirtualList/index.tsx | 26 +-
.../src/modules/tokensList/types.ts | 1 +
.../src/modules/tokensList/utils/tokenKey.ts | 7 +
13 files changed, 436 insertions(+), 56 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
index 1ea0d2405e..4bec909eb7 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
@@ -22,6 +22,7 @@ import {
useTokenDataSources,
useTokenSelectionHandler,
useWidgetMetadata,
+ useRecentTokenSection,
} from './controllerState'
import { useChainsToSelect } from '../../hooks/useChainsToSelect'
@@ -31,7 +32,6 @@ import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
-
const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
export interface SelectTokenWidgetProps {
@@ -54,50 +54,119 @@ export function useSelectTokenWidgetController({
resolvedField = widgetState.field ?? Field.INPUT
const chainsToSelect = useChainsToSelect(),
onSelectChain = useOnSelectChain()
- const { isManageWidgetOpen, openManageWidget, closeManageWidget } = useManageWidgetVisibility()
+ const manageWidget = useManageWidgetVisibility()
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
const { account } = useWalletInfo(),
closeTokenSelectWidget = useCloseTokenSelectWidget()
- const tokenData = useTokenDataSources(),
- onTokenListAddingError = useOnTokenListAddingError(),
- { addCustomTokenLists, importTokenCallback } = useTokenAdminActions()
- const { modalTitle, chainsPanelTitle, disableErc20, tokenListCategoryState } = useWidgetMetadata(
+ const tokenData = useTokenDataSources()
+ const onTokenListAddingError = useOnTokenListAddingError()
+ const tokenAdminActions = useTokenAdminActions()
+ const widgetMetadata = useWidgetMetadata(
resolvedField,
displayLpTokenLists,
widgetState.oppositeToken,
lpTokensWithBalancesCount,
)
- const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget),
- { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
+
+ const { isBridgingEnabled, viewProps } = useSelectTokenWidgetViewState({
+ displayLpTokenLists,
+ standalone,
+ widgetState,
+ chainsToSelect,
+ onSelectChain,
+ manageWidget,
+ updateSelectTokenWidget,
+ account,
+ closeTokenSelectWidget,
+ tokenData,
+ onTokenListAddingError,
+ tokenAdminActions,
+ widgetMetadata,
+ })
+
+ return {
+ shouldRender: Boolean(widgetState.onSelectToken && widgetState.open),
+ isBridgingEnabled,
+ viewProps,
+ }
+}
+
+interface ViewStateArgs {
+ 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
+}
+
+interface ViewStateResult {
+ isBridgingEnabled: boolean
+ viewProps: SelectTokenWidgetViewProps
+}
+
+function useSelectTokenWidgetViewState(args: ViewStateArgs): ViewStateResult {
+ const {
+ displayLpTokenLists,
+ standalone,
+ widgetState,
+ chainsToSelect,
+ onSelectChain,
+ manageWidget,
+ updateSelectTokenWidget,
+ account,
+ closeTokenSelectWidget,
+ tokenData,
+ onTokenListAddingError,
+ tokenAdminActions,
+ widgetMetadata,
+ } = args
+
+ const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
+ const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
const importFlows = useImportFlowCallbacks(
- importTokenCallback,
- widgetState.onSelectToken,
- onDismiss,
- addCustomTokenLists,
- onTokenListAddingError,
- updateSelectTokenWidget,
- ),
- handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken),
- isInjectedWidgetMode = isInjectedWidget(),
- isBridgingEnabled = hasAvailableChains(chainsToSelect)
+ tokenAdminActions.importTokenCallback,
+ widgetState.onSelectToken,
+ onDismiss,
+ tokenAdminActions.addCustomTokenLists,
+ onTokenListAddingError,
+ updateSelectTokenWidget,
+ )
+ const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken)
+ const isBridgingEnabled = hasAvailableChains(chainsToSelect)
+ const { recentTokens, handleTokenListItemClick } = useRecentTokenSection(
+ tokenData.allTokens,
+ tokenData.favoriteTokens,
+ )
const selectTokenModalPropsInput = buildSelectTokenModalPropsInput({
standalone,
displayLpTokenLists,
tokenData,
widgetState,
favoriteTokens: standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens,
+ recentTokens,
handleSelectToken,
+ onTokenListItemClick: handleTokenListItemClick,
onDismiss,
onOpenManageWidget: openManageWidget,
openPoolPage,
- tokenListCategoryState,
- disableErc20,
+ tokenListCategoryState: widgetMetadata.tokenListCategoryState,
+ disableErc20: widgetMetadata.disableErc20,
account,
isBridgingEnabled,
- isInjectedWidgetMode,
- modalTitle,
+ isInjectedWidgetMode: isInjectedWidget(),
+ modalTitle: widgetMetadata.modalTitle,
}),
selectTokenModalProps = useSelectTokenModalPropsMemo(selectTokenModalPropsInput)
+
const viewProps = buildSelectTokenWidgetViewProps({
standalone,
tokenToImport: widgetState.tokenToImport,
@@ -105,7 +174,7 @@ export function useSelectTokenWidgetController({
isManageWidgetOpen,
selectedPoolAddress: widgetState.selectedPoolAddress,
isBridgingEnabled,
- chainsPanelTitle,
+ chainsPanelTitle: widgetMetadata.chainsPanelTitle,
chainsToSelect,
onSelectChain,
onDismiss,
@@ -120,11 +189,7 @@ export function useSelectTokenWidgetController({
onSelectToken: handleSelectToken,
})
- return {
- shouldRender: Boolean(widgetState.onSelectToken && widgetState.open),
- isBridgingEnabled,
- viewProps,
- }
+ return { isBridgingEnabled, viewProps }
}
export type { SelectTokenWidgetViewProps } from './controllerProps'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
index bd0216c85e..0751c5d0a1 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
@@ -62,7 +62,9 @@ interface BuildModalPropsArgs {
tokenData: TokenDataSources
widgetState: WidgetState
favoriteTokens: TokenWithLogo[]
+ recentTokens: TokenWithLogo[]
handleSelectToken(token: TokenWithLogo): void
+ onTokenListItemClick(token: TokenWithLogo): void
onDismiss(): void
onOpenManageWidget(): void
openPoolPage(poolAddress: string): void
@@ -124,7 +126,9 @@ export function buildSelectTokenModalPropsInput({
tokenData,
widgetState,
favoriteTokens,
+ recentTokens,
handleSelectToken,
+ onTokenListItemClick,
onDismiss,
onOpenManageWidget,
openPoolPage,
@@ -142,9 +146,11 @@ export function buildSelectTokenModalPropsInput({
selectedToken: widgetState.selectedToken,
allTokens: tokenData.allTokens,
favoriteTokens,
+ recentTokens,
balancesState: tokenData.balancesState,
permitCompatibleTokens: tokenData.permitCompatibleTokens,
onSelectToken: handleSelectToken,
+ onTokenListItemClick,
onInputPressEnter: widgetState.onInputPressEnter,
onDismiss,
onOpenManageWidget,
@@ -172,9 +178,11 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
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,
@@ -198,9 +206,11 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
props.selectedToken,
props.allTokens,
props.favoriteTokens,
+ props.recentTokens,
props.balancesState,
props.permitCompatibleTokens,
props.onSelectToken,
+ props.onTokenListItemClick,
props.onInputPressEnter,
props.onDismiss,
props.onOpenManageWidget,
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
index 227aeb708e..d3f536d335 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -15,7 +15,6 @@ import {
import { Field } from 'legacy/state/types'
-
import { useTokensBalancesCombined } from 'modules/combinedBalances'
import { usePermitCompatibleTokens } from 'modules/permit'
@@ -23,6 +22,7 @@ import { CowSwapAnalyticsCategory } from 'common/analytics/types'
import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
+import { useRecentTokens } from '../../hooks/useRecentTokens'
import { useTokensToSelect } from '../../hooks/useTokensToSelect'
import { ChainsToSelectState } from '../../types'
@@ -30,10 +30,7 @@ import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelec
type UpdateSelectTokenWidgetFn = ReturnType
-export type TokenListCategoryState = [
- TokenListCategory[] | null,
- Dispatch>,
-]
+export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>]
interface ManageWidgetVisibility {
isManageWidgetOpen: boolean
@@ -78,6 +75,11 @@ interface ImportFlowCallbacks {
resetTokenImport(): void
}
+interface RecentTokenSection {
+ recentTokens: TokenWithLogo[]
+ handleTokenListItemClick(token: TokenWithLogo): void
+}
+
export function useManageWidgetVisibility(): ManageWidgetVisibility {
const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false)
@@ -143,10 +145,7 @@ export function useWidgetMetadata(
return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle }
}
-export function useDismissHandler(
- closeManageWidget: () => void,
- closeTokenSelectWidget: () => void,
-): () => void {
+export function useDismissHandler(closeManageWidget: () => void, closeTokenSelectWidget: () => void): () => void {
return useCallback(() => {
closeManageWidget()
closeTokenSelectWidget()
@@ -205,6 +204,19 @@ export function useImportFlowCallbacks(
return { importTokenAndClose, importListAndBack, resetTokenImport }
}
+export function useRecentTokenSection(allTokens: TokenWithLogo[], favoriteTokens: TokenWithLogo[]): RecentTokenSection {
+ const { recentTokens, addRecentToken } = useRecentTokens({ allTokens, favoriteTokens })
+
+ const handleTokenListItemClick = useCallback(
+ (token: TokenWithLogo) => {
+ addRecentToken(token)
+ },
+ [addRecentToken],
+ )
+
+ return { recentTokens, handleTokenListItemClick }
+}
+
export function useTokenSelectionHandler(
onSelectToken: ((token: TokenWithLogo) => void) | undefined,
): (token: TokenWithLogo) => void {
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..d9994544a5
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
@@ -0,0 +1,224 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { getTokenUniqueKey } from '../utils/tokenKey'
+
+const RECENT_TOKENS_LIMIT = 4
+const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1'
+
+interface StoredRecentToken {
+ chainId: number
+ address: string
+ decimals: number
+ symbol?: string
+ name?: string
+ logoURI?: string
+ tags?: string[]
+}
+
+interface UseRecentTokensParams {
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ maxItems?: number
+}
+
+export interface RecentTokensState {
+ recentTokens: TokenWithLogo[]
+ addRecentToken(token: TokenWithLogo): void
+}
+
+export function useRecentTokens({
+ allTokens,
+ favoriteTokens,
+ maxItems = RECENT_TOKENS_LIMIT,
+}: UseRecentTokensParams): RecentTokensState {
+ const [storedTokens, setStoredTokens] = useState(() => readStoredTokens(maxItems))
+
+ useEffect(() => {
+ persistStoredTokens(storedTokens)
+ }, [storedTokens])
+
+ const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens])
+ const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens])
+
+ useEffect(() => {
+ setStoredTokens((prev) => {
+ const filtered = prev.filter((token) => !favoriteKeys.has(getStoredTokenKey(token)))
+
+ return filtered.length === prev.length ? prev : filtered
+ })
+ }, [favoriteKeys])
+
+ const recentTokens = useMemo(() => {
+ const seenKeys = new Set()
+ const result: TokenWithLogo[] = []
+
+ for (const entry of storedTokens) {
+ 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
+ }, [favoriteKeys, maxItems, storedTokens, tokensByKey])
+
+ const addRecentToken = useCallback(
+ (token: TokenWithLogo) => {
+ const key = getTokenUniqueKey(token)
+
+ if (favoriteKeys.has(key)) {
+ return
+ }
+
+ setStoredTokens((prev) => {
+ const normalized = toStoredToken(token)
+ const withoutToken = prev.filter((entry) => getStoredTokenKey(entry) !== key)
+ const next = [normalized, ...withoutToken].slice(0, maxItems)
+
+ persistStoredTokens(next)
+
+ return next
+ })
+ },
+ [favoriteKeys, maxItems],
+ )
+
+ return { recentTokens, addRecentToken }
+}
+
+function buildTokensByKey(tokens: TokenWithLogo[]): Map {
+ const map = new Map()
+
+ for (const token of tokens) {
+ map.set(getTokenUniqueKey(token), token)
+ }
+
+ return map
+}
+
+function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set {
+ const set = new Set()
+
+ for (const token of tokens) {
+ set.add(getTokenUniqueKey(token))
+ }
+
+ return set
+}
+
+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
+ }
+}
+
+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 getStoredTokenKey(token: StoredRecentToken): string {
+ return getTokenUniqueKey(token)
+}
+
+function readStoredTokens(limit: number): StoredRecentToken[] {
+ 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 []
+ }
+
+ const sanitized = parsed
+ .map((item) => sanitizeStoredToken(item))
+ .filter((item): item is StoredRecentToken => Boolean(item))
+
+ return sanitized.slice(0, limit)
+ } catch {
+ return []
+ }
+}
+
+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 persistStoredTokens(tokens: StoredRecentToken[]): 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
+ }
+}
+
+function canUseLocalStorage(): boolean {
+ return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
index 85722913b8..5709cfe04a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
@@ -18,6 +18,7 @@ export function useSelectTokenContext(props: SelectTokenModalProps): SelectToken
unsupportedTokens,
permitCompatibleTokens,
onSelectToken,
+ onTokenListItemClick,
account,
tokenListTags,
} = props
@@ -27,12 +28,22 @@ export function useSelectTokenContext(props: SelectTokenModalProps): SelectToken
balancesState,
selectedToken,
onSelectToken,
+ onTokenListItemClick,
unsupportedTokens,
permitCompatibleTokens,
tokenListTags,
isWalletConnected: !!account,
}),
- [balancesState, selectedToken, onSelectToken, unsupportedTokens, permitCompatibleTokens, tokenListTags, account],
+ [
+ balancesState,
+ selectedToken,
+ onSelectToken,
+ onTokenListItemClick,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ tokenListTags,
+ account,
+ ],
)
}
@@ -47,6 +58,7 @@ interface TokensContentSectionProps
SelectTokenModalProps,
| 'displayLpTokenLists'
| 'favoriteTokens'
+ | 'recentTokens'
| 'areTokensLoading'
| 'allTokens'
| 'areTokensFromBridge'
@@ -60,6 +72,7 @@ interface TokensContentSectionProps
export function TokensContentSection({
displayLpTokenLists,
favoriteTokens,
+ recentTokens,
areTokensLoading,
allTokens,
searchInput,
@@ -73,6 +86,7 @@ export function TokensContentSection({
displayLpTokenLists={displayLpTokenLists}
selectTokenContext={selectTokenContext}
favoriteTokens={favoriteTokens}
+ recentTokens={recentTokens}
areTokensLoading={areTokensLoading}
allTokens={allTokens}
searchInput={searchInput}
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 6fa4404d99..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
@@ -50,6 +50,9 @@ const chainsMock: ChainInfo[] = [
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,
@@ -57,6 +60,7 @@ const defaultModalProps: SelectTokenModalProps = {
unsupportedTokens,
allTokens: allTokensMock,
favoriteTokens: favoriteTokensMock,
+ recentTokens: recentTokensMock,
areTokensLoading: false,
areTokensFromBridge: false,
tokenListCategoryState: [null, () => void 0],
@@ -72,6 +76,9 @@ const defaultModalProps: SelectTokenModalProps = {
onSelectToken() {
console.log('onSelectToken')
},
+ onTokenListItemClick(token) {
+ console.log('onTokenListItemClick', token.symbol)
+ },
onOpenManageWidget() {
console.log('onOpenManageWidget')
},
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 bea6e25e2a..853f8d2828 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -29,6 +29,7 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
standalone,
onOpenManageWidget,
favoriteTokens,
+ recentTokens,
areTokensLoading,
allTokens,
areTokensFromBridge,
@@ -72,6 +73,7 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
{
allTokens: TokenWithLogo[]
favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
balancesState: BalancesState
unsupportedTokens: UnsupportedTokensState
selectedToken?: Nullish
@@ -30,6 +31,7 @@ export interface SelectTokenModalProps {
selectedTargetChainId?: number
onSelectToken(token: TokenWithLogo): void
+ onTokenListItemClick?(token: TokenWithLogo): void
openPoolPage(poolAddress: string): void
onInputPressEnter?(): void
onOpenManageWidget(): void
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/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
index 332cf451a2..e8ef77225e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -5,6 +5,7 @@ import { Loader } from '@cowprotocol/ui'
import { TokenSearchResults } from '../../containers/TokenSearchResults'
import { SelectTokenContext } from '../../types'
+import { getTokenUniqueKey } from '../../utils/tokenKey'
import * as styledEl from '../SelectTokenModal/styled'
import { TokensVirtualList } from '../TokensVirtualList'
@@ -12,6 +13,7 @@ export interface TokensContentProps {
displayLpTokenLists?: boolean
selectTokenContext: SelectTokenContext
favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
areTokensLoading: boolean
allTokens: TokenWithLogo[]
searchInput: string
@@ -23,6 +25,7 @@ export interface TokensContentProps {
export function TokensContent({
selectTokenContext,
favoriteTokens,
+ recentTokens,
areTokensLoading,
allTokens,
displayLpTokenLists,
@@ -32,24 +35,36 @@ export function TokensContent({
selectedTargetChainId,
}: TokensContentProps): ReactNode {
const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0
+ const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0
- const favoriteAddresses = useMemo(() => {
- if (!shouldShowFavoritesInline) {
+ const pinnedTokenKeys = useMemo(() => {
+ if (!shouldShowFavoritesInline && !shouldShowRecentsInline) {
return undefined
}
- return new Set(favoriteTokens.map((token) => token.address.toLowerCase()))
- }, [favoriteTokens, shouldShowFavoritesInline])
+ const pinned = new Set()
- const tokensWithoutFavorites = useMemo(() => {
- if (!favoriteAddresses) {
+ 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) => !favoriteAddresses.has(token.address.toLowerCase()))
- }, [allTokens, favoriteAddresses])
+ return allTokens.filter((token) => !pinnedTokenKeys.has(getTokenUniqueKey(token)))
+ }, [allTokens, pinnedTokenKeys])
const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined
+ const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined
return (
<>
@@ -69,9 +84,10 @@ export function TokensContent({
) : (
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 1ede7fa062..1b0bb3d225 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -19,6 +19,7 @@ export interface TokensVirtualListProps {
displayLpTokenLists?: boolean
selectTokenContext: SelectTokenContext
favoriteTokens?: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
hideFavoriteTokensTooltip?: boolean
scrollResetKey?: number
}
@@ -34,6 +35,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
selectTokenContext,
displayLpTokenLists,
favoriteTokens,
+ recentTokens,
hideFavoriteTokensTooltip,
scrollResetKey,
} = props
@@ -48,17 +50,27 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
const rows = useMemo(() => {
const tokenRows = sortedTokens.map((token) => ({ type: 'token', token }))
+ const composedRows: TokensVirtualRow[] = []
if (favoriteTokens?.length) {
- return [
- { type: 'favorite-section', tokens: favoriteTokens, hideTooltip: hideFavoriteTokensTooltip },
- { type: 'title', label: 'All tokens' },
- ...tokenRows,
- ]
+ composedRows.push({
+ type: 'favorite-section',
+ tokens: favoriteTokens,
+ hideTooltip: hideFavoriteTokensTooltip,
+ })
}
- return tokenRows
- }, [favoriteTokens, hideFavoriteTokensTooltip, sortedTokens])
+ if (recentTokens?.length) {
+ composedRows.push({ type: 'title', label: 'Recent' })
+ recentTokens.forEach((token) => composedRows.push({ type: 'token', token }))
+ }
+
+ if (favoriteTokens?.length || recentTokens?.length) {
+ composedRows.push({ type: 'title', label: 'All tokens' })
+ }
+
+ return [...composedRows, ...tokenRows]
+ }, [favoriteTokens, hideFavoriteTokensTooltip, recentTokens, sortedTokens])
const virtualListKey = scrollResetKey ?? 'tokens-list'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/types.ts
index 5c775d8e0a..0d7a6b74ca 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts
@@ -13,6 +13,7 @@ export interface SelectTokenContext {
selectedToken?: Nullish
onSelectToken(token: TokenWithLogo): void
+ onTokenListItemClick?(token: TokenWithLogo): void
unsupportedTokens: { [tokenAddress: string]: { dateAdded: number } }
permitCompatibleTokens: PermitCompatibleTokens
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()}`
+}
From 135becd3f1f38515c9fd13126f234311a4201376 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 09:38:25 +0000
Subject: [PATCH 13/37] refactor: improve FavoriteTokensList component
structure and styling
---
.../pure/FavoriteTokensList/index.tsx | 56 +++++++++------
.../pure/FavoriteTokensList/styled.ts | 71 +++++++++++++++----
2 files changed, 94 insertions(+), 33 deletions(-)
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 655949ddbc..6fcdae4789 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -1,14 +1,15 @@
import { ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { HelpTooltip } from '@cowprotocol/ui'
+import { areAddressesEqual, getCurrencyAddress } from '@cowprotocol/common-utils'
+import { TokenLogo } from '@cowprotocol/tokens'
+import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui'
import { Link } from 'react-router'
import * as styledEl from './styled'
import { SelectTokenContext } from '../../types'
-import { TokenListItemContainer } from '../TokenListItemContainer'
export interface FavoriteTokensListProps {
tokens: TokenWithLogo[]
@@ -26,12 +27,10 @@ export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode {
return (
- Favourite tokens
+ Favorite tokens
{!hideTooltip && }
-
-
-
+ {renderFavoriteTokenItems(tokens, selectTokenContext)}
)
}
@@ -48,21 +47,38 @@ function FavoriteTokensTooltip(): ReactNode {
)
}
-interface FavoriteTokensItemsProps {
- tokens: TokenWithLogo[]
- selectTokenContext: SelectTokenContext
-}
+function renderFavoriteTokenItems(tokens: TokenWithLogo[], context: SelectTokenContext): ReactNode[] {
+ const { selectedToken } = context
+ const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined
-function FavoriteTokensItems({ tokens, selectTokenContext }: FavoriteTokensItemsProps): ReactNode {
- return createFavoriteTokenItems(tokens, selectTokenContext)
-}
+ return tokens.map((token) => {
+ const isSelected =
+ !!selectedToken &&
+ token.chainId === selectedToken.chainId &&
+ !!selectedAddress &&
+ areAddressesEqual(token.address, selectedAddress)
-function createFavoriteTokenItems(tokens: TokenWithLogo[], selectTokenContext: SelectTokenContext): ReactNode[] {
- const elements: ReactNode[] = []
-
- for (const token of tokens) {
- elements.push()
- }
+ const handleClick = (): void => {
+ if (isSelected) {
+ return
+ }
+ context.onTokenListItemClick?.(token)
+ context.onSelectToken(token)
+ }
- return elements
+ 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 e4da1f8a9a..308760b212 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
@@ -1,31 +1,76 @@
-import { UI } from '@cowprotocol/ui'
+import { Media, UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
export const Section = styled.div`
- padding: 8px 0 12px;
- border-bottom: 1px solid var(${UI.COLOR_BORDER});
- margin-bottom: 8px;
+ padding: 12px 16px 16px;
`
export const TitleRow = styled.div`
display: flex;
align-items: center;
gap: 6px;
- padding: 0 16px;
- margin-bottom: 4px;
`
-export const Title = styled.span`
- display: block;
- font-size: 12px;
- font-weight: 600;
- letter-spacing: 0.02em;
- text-transform: uppercase;
+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`
display: flex;
- flex-direction: column;
+ flex-wrap: wrap;
+ gap: 10px;
+ padding-top: 10px;
+
+ ${Media.upToSmall()} {
+ width: 0;
+ min-width: 100%;
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding: 10px 0;
+ -webkit-overflow-scrolling: touch;
+
+ @media (hover: hover) {
+ ${({ theme }) => theme.colorScrollbar};
+ }
+
+ @media (hover: none) {
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+ }
+`
+
+export const TokenButton = styled.button`
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ justify-content: center;
+ background: none;
+ outline: none;
+ padding: 6px 10px;
+ border-radius: 10px;
+ color: inherit;
+ border: 1px solid var(${UI.COLOR_PAPER_DARKER});
+ font-weight: 500;
+ font-size: 16px;
+ 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;
+
+ ${Media.upToSmall()} {
+ flex: 0 0 auto;
+ }
+
+ :hover {
+ border: 1px solid ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : `var(${UI.COLOR_PRIMARY})`)};
+ }
`
From 2ca306abaad1725fa87196d20821e777671cfc92 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 10:17:42 +0000
Subject: [PATCH 14/37] refactor: enhance styling and structure of token
selection components
---
.../tokensList/pure/ChainPanel/styled.ts | 1 +
.../pure/FavoriteTokensList/index.tsx | 4 +--
.../pure/FavoriteTokensList/styled.ts | 14 +++++++---
.../pure/SelectTokenModal/index.tsx | 16 ++++++-----
.../pure/SelectTokenModal/styled.ts | 27 +++++++++++++++----
5 files changed, 45 insertions(+), 17 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
index 8ac5f8ff9d..200ddfd1c6 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
@@ -64,6 +64,7 @@ export const PanelList = styled.div`
flex: 1;
overflow-y: auto;
padding-right: 4px;
+ ${({ theme }) => theme.colorScrollbar};
`
export const EmptyState = styled.div`
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 6fcdae4789..eadfcb886b 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -3,7 +3,7 @@ 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 { Link } from 'react-router'
@@ -37,7 +37,7 @@ export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode {
function FavoriteTokensTooltip(): ReactNode {
return (
-
Your favorite saved tokens. Edit this list in the Tokens page.
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 308760b212..89ccfba70e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
@@ -1,15 +1,14 @@
-import { Media, UI } from '@cowprotocol/ui'
+import { HelpTooltip, Media, UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
export const Section = styled.div`
- padding: 12px 16px 16px;
+ padding: 0 14px 14px;
`
export const TitleRow = styled.div`
display: flex;
align-items: center;
- gap: 6px;
`
export const Title = styled.h4`
@@ -74,3 +73,12 @@ export const TokenButton = 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/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
index 853f8d2828..6a64aa93e0 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -50,13 +50,15 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
title={resolvedModalTitle}
/>
- e.key === 'Enter' && onInputPressEnter?.()}
- onChange={(e) => setInputValue(e.target.value)}
- placeholder="Search name or paste address..."
- />
+
+ e.key === 'Enter' && onInputPressEnter?.()}
+ onChange={(e) => setInputValue(e.target.value)}
+ placeholder="Search name or paste address..."
+ />
+
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 6cc4d5dd46..bc1d5e2104 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -69,6 +69,26 @@ export const TitleActionButton = styled.button`
export const SearchRow = styled.div`
padding: 0 14px 14px;
display: flex;
+ align-items: center;
+`
+
+export const SearchInputWrapper = styled.div`
+ width: 100%;
+
+ > div {
+ width: 100%;
+ background: #f2f2f2;
+ border-radius: 46px;
+ padding: 0 14px;
+ height: 46px;
+ display: flex;
+ align-items: center;
+ }
+
+ input {
+ background: transparent;
+ height: 100%;
+ }
`
export const Body = styled.div`
@@ -110,15 +130,12 @@ export const Separator = styled.div`
`
export const ListTitle = styled.div`
- font-size: 12px;
- font-weight: 600;
- letter-spacing: 0.02em;
- text-transform: uppercase;
+ font-size: 14px;
+ font-weight: 500;
color: var(${UI.COLOR_TEXT_OPACITY_70});
padding: 8px 16px 4px;
`
-
export const TokensLoader = styled.div`
width: 100%;
height: 100%;
From 2c95cd73502f0169f24273e2ef75204beea80c3e Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 13:49:05 +0000
Subject: [PATCH 15/37] refactor: improve ChainPanel functionality with scroll
detection and styling adjustments
---
.../tokensList/pure/ChainPanel/index.tsx | 40 ++++++++++++++++++-
.../tokensList/pure/ChainPanel/styled.ts | 7 +++-
.../pure/SelectTokenModal/styled.ts | 12 +++---
libs/ui/src/pure/Input/index.tsx | 13 +++---
4 files changed, 58 insertions(+), 14 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
index 64513a24b4..613a044f71 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useMemo, useState } from 'react'
+import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { ChainInfo } from '@cowprotocol/cow-sdk'
@@ -17,6 +17,8 @@ export interface ChainPanelProps {
export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProps): ReactNode {
const [chainQuery, setChainQuery] = useState('')
+ const [hasVerticalScroll, setHasVerticalScroll] = useState(false)
+ const listRef = useRef(null)
const normalizedChainQuery = chainQuery.trim().toLowerCase()
const chains = chainsState?.chains ?? EMPTY_CHAINS
const isLoading = chainsState?.isLoading ?? false
@@ -38,6 +40,40 @@ export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProp
// When bridge networks are unavailable we still render the panel but show the fallback copy
const showUnavailableState = !isLoading && chains.length === 0 && !normalizedChainQuery
+ useEffect(() => {
+ const updateScrollState = (): void => {
+ const element = listRef.current
+
+ if (!element) {
+ return
+ }
+
+ const hasScroll = element.scrollHeight - element.clientHeight > 1
+ setHasVerticalScroll((current) => (current === hasScroll ? current : hasScroll))
+ }
+
+ updateScrollState()
+
+ // ResizeObserver tracks size changes (e.g. viewport height, font scaling) without forcing layout.
+ const resizeObserver =
+ typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateScrollState()) : undefined
+ resizeObserver?.observe(listRef.current as Element)
+
+ // MutationObserver lets us react when rows are added/removed so the gutter toggles immediately.
+ const mutationObserver =
+ typeof MutationObserver !== 'undefined' ? new MutationObserver(() => updateScrollState()) : undefined
+ mutationObserver?.observe(listRef.current as Element, { childList: true, subtree: true })
+
+ // Scroll containers can overflow when the viewport height changes (e.g. window resize, soft keyboard).
+ window.addEventListener('resize', updateScrollState)
+
+ return () => {
+ resizeObserver?.disconnect()
+ mutationObserver?.disconnect()
+ window.removeEventListener('resize', updateScrollState)
+ }
+ }, [filteredChains.length, isLoading, normalizedChainQuery])
+
return (
@@ -50,7 +86,7 @@ export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProp
placeholder="Search network"
/>
-
+
`
flex: 1;
overflow-y: auto;
- padding-right: 4px;
+ padding-right: ${({ $hasScrollbar }) => ($hasScrollbar ? '8px' : '0')};
+ margin-right: ${({ $hasScrollbar }) => ($hasScrollbar ? '-8px' : '0')};
+ box-sizing: content-box;
${({ theme }) => theme.colorScrollbar};
+ scrollbar-gutter: ${({ $hasScrollbar }) => ($hasScrollbar ? 'stable' : 'auto')};
`
export const EmptyState = styled.div`
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 bc1d5e2104..1808b7f1df 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -73,16 +73,18 @@ export const SearchRow = styled.div`
`
export const SearchInputWrapper = styled.div`
+ --input-height: 46px;
width: 100%;
> div {
width: 100%;
- background: #f2f2f2;
- border-radius: 46px;
- padding: 0 14px;
- height: 46px;
+ 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 {
@@ -107,7 +109,7 @@ export const TokenColumn = styled.div`
min-height: 0;
display: flex;
flex-direction: column;
- padding: 0 0 14px;
+ padding: 0;
${Media.upToSmall()} {
padding: 16px;
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 (
From 83abee26784cb88ea16cef4845ae5304739f1c39 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 14:29:11 +0000
Subject: [PATCH 16/37] refactor: implement chain-specific accent colors and
improve styling in ChainsSelector component
---
.../tokensList/pure/ChainsSelector/index.tsx | 90 ++++++++++---
.../tokensList/pure/ChainsSelector/styled.tsx | 31 ++++-
libs/ui/src/enum.ts | 24 ++++
libs/ui/src/theme/ThemeColorVars.tsx | 122 ++++++++++++++++++
4 files changed, 243 insertions(+), 24 deletions(-)
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 c6b4940c26..cc276e174c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -2,15 +2,65 @@ import { ReactNode } from 'react'
import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg'
import { useTheme } from '@cowprotocol/common-hooks'
-import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
+import { UI } from '@cowprotocol/ui'
import SVG from 'react-inlinesvg'
import * as styledEl from './styled'
+import type { ChainAccentVars } from './styled'
+
const LOADING_ITEMS_COUNT = 10
const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index)
+const CHAIN_ACCENT_VAR_MAP: Record = {
+ [SupportedChainId.MAINNET]: {
+ backgroundVar: UI.COLOR_CHAIN_ETHEREUM_BG,
+ borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER,
+ },
+ [SupportedChainId.BNB]: {
+ backgroundVar: UI.COLOR_CHAIN_BNB_BG,
+ borderVar: UI.COLOR_CHAIN_BNB_BORDER,
+ },
+ [SupportedChainId.BASE]: {
+ backgroundVar: UI.COLOR_CHAIN_BASE_BG,
+ borderVar: UI.COLOR_CHAIN_BASE_BORDER,
+ },
+ [SupportedChainId.ARBITRUM_ONE]: {
+ backgroundVar: UI.COLOR_CHAIN_ARBITRUM_BG,
+ borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER,
+ },
+ [SupportedChainId.POLYGON]: {
+ backgroundVar: UI.COLOR_CHAIN_POLYGON_BG,
+ borderVar: UI.COLOR_CHAIN_POLYGON_BORDER,
+ },
+ [SupportedChainId.AVALANCHE]: {
+ backgroundVar: UI.COLOR_CHAIN_AVALANCHE_BG,
+ borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER,
+ },
+ [SupportedChainId.GNOSIS_CHAIN]: {
+ backgroundVar: UI.COLOR_CHAIN_GNOSIS_BG,
+ borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER,
+ },
+ [SupportedChainId.LENS]: {
+ backgroundVar: UI.COLOR_CHAIN_LENS_BG,
+ borderVar: UI.COLOR_CHAIN_LENS_BORDER,
+ },
+ [SupportedChainId.SEPOLIA]: {
+ backgroundVar: UI.COLOR_CHAIN_SEPOLIA_BG,
+ borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER,
+ },
+ [SupportedChainId.LINEA]: {
+ backgroundVar: UI.COLOR_CHAIN_LINEA_BG,
+ borderVar: UI.COLOR_CHAIN_LINEA_BORDER,
+ },
+ [SupportedChainId.PLASMA]: {
+ backgroundVar: UI.COLOR_CHAIN_PLASMA_BG,
+ borderVar: UI.COLOR_CHAIN_PLASMA_BORDER,
+ },
+}
+
export interface ChainsSelectorProps {
chains: ChainInfo[]
onSelectChain: (chainId: ChainInfo) => void
@@ -26,23 +76,14 @@ export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoadin
}
return (
-
+
)
}
function ChainsLoadingList(): ReactNode {
const skeletonRows = renderChainSkeletonRows()
- return (
-
- {skeletonRows}
-
- )
+ return {skeletonRows}
}
function renderChainSkeletonRows(): ReactNode[] {
@@ -70,16 +111,17 @@ interface ChainsListProps {
function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
const chainButtons = renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode })
- return (
-
- {chainButtons}
-
- )
+ return {chainButtons}
}
interface ChainButtonsRenderProps extends ChainsListProps {}
-function renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainButtonsRenderProps): ReactNode[] {
+function renderChainButtons({
+ chains,
+ defaultChainId,
+ onSelectChain,
+ isDarkMode,
+}: ChainButtonsRenderProps): ReactNode[] {
const elements: ReactNode[] = []
for (const chain of chains) {
@@ -104,11 +146,21 @@ interface ChainButtonProps {
onSelectChain(chain: ChainInfo): void
}
+function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined {
+ return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId]
+}
+
function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButtonProps): ReactNode {
const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light
+ const accent = getChainAccent(chain.id)
return (
- onSelectChain(chain)} active$={isActive} aria-pressed={isActive}>
+ onSelectChain(chain)}
+ active$={isActive}
+ accent$={accent}
+ aria-pressed={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 195170f7f8..ae73cd4aef 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
@@ -4,6 +4,21 @@ import styled from 'styled-components/macro'
import { blankButtonMixin } from '../commonElements'
+export interface ChainAccentVars {
+ backgroundVar: UI
+ borderVar: UI
+}
+
+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
+
export const List = styled.div`
display: flex;
flex-direction: column;
@@ -11,7 +26,7 @@ export const List = styled.div`
width: 100%;
`
-export const ChainButton = styled.button<{ active$?: boolean }>`
+export const ChainButton = styled.button<{ active$?: boolean; accent$?: ChainAccentVars }>`
--min-height: 46px;
${blankButtonMixin};
@@ -23,9 +38,9 @@ export const ChainButton = styled.button<{ active$?: boolean }>`
padding: 8px 12px;
min-height: var(--min-height);
border-radius: var(--min-height);
- border: 1px solid ${({ active$ }) => (active$ ? `var(${UI.COLOR_PRIMARY_OPACITY_80})` : 'transparent')};
- background: ${({ active$ }) => (active$ ? `var(${UI.COLOR_PRIMARY_OPACITY_10})` : 'transparent')};
- box-shadow: ${({ active$ }) => (active$ ? `0 0 0 1px var(${UI.COLOR_PRIMARY_OPACITY_10}) inset` : 'none')};
+ 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:
border 0.2s ease,
@@ -33,7 +48,13 @@ export const ChainButton = styled.button<{ active$?: boolean }>`
box-shadow 0.2s ease;
&:hover {
- border-color: var(${UI.COLOR_PRIMARY_OPACITY_70});
+ border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)};
+ background: ${({ accent$ }) => getBackground(accent$)};
+ }
+
+ &:focus-visible {
+ outline: none;
+ border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)};
}
`
diff --git a/libs/ui/src/enum.ts b/libs/ui/src/enum.ts
index 7a13500436..506fed0004 100644
--- a/libs/ui/src/enum.ts
+++ b/libs/ui/src/enum.ts
@@ -101,6 +101,30 @@ 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_BNB_BG = '--cow-color-chain-bnb-bg',
+ COLOR_CHAIN_BNB_BORDER = '--cow-color-chain-bnb-border',
+ COLOR_CHAIN_BASE_BG = '--cow-color-chain-base-bg',
+ COLOR_CHAIN_BASE_BORDER = '--cow-color-chain-base-border',
+ COLOR_CHAIN_ARBITRUM_BG = '--cow-color-chain-arbitrum-bg',
+ COLOR_CHAIN_ARBITRUM_BORDER = '--cow-color-chain-arbitrum-border',
+ COLOR_CHAIN_POLYGON_BG = '--cow-color-chain-polygon-bg',
+ COLOR_CHAIN_POLYGON_BORDER = '--cow-color-chain-polygon-border',
+ COLOR_CHAIN_AVALANCHE_BG = '--cow-color-chain-avalanche-bg',
+ COLOR_CHAIN_AVALANCHE_BORDER = '--cow-color-chain-avalanche-border',
+ COLOR_CHAIN_GNOSIS_BG = '--cow-color-chain-gnosis-bg',
+ COLOR_CHAIN_GNOSIS_BORDER = '--cow-color-chain-gnosis-border',
+ COLOR_CHAIN_LENS_BG = '--cow-color-chain-lens-bg',
+ COLOR_CHAIN_LENS_BORDER = '--cow-color-chain-lens-border',
+ COLOR_CHAIN_SEPOLIA_BG = '--cow-color-chain-sepolia-bg',
+ COLOR_CHAIN_SEPOLIA_BORDER = '--cow-color-chain-sepolia-border',
+ COLOR_CHAIN_LINEA_BG = '--cow-color-chain-linea-bg',
+ COLOR_CHAIN_LINEA_BORDER = '--cow-color-chain-linea-border',
+ COLOR_CHAIN_PLASMA_BG = '--cow-color-chain-plasma-bg',
+ COLOR_CHAIN_PLASMA_BORDER = '--cow-color-chain-plasma-border',
+
// 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/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx
index b5b4872b06..399e3a30f1 100644
--- a/libs/ui/src/theme/ThemeColorVars.tsx
+++ b/libs/ui/src/theme/ThemeColorVars.tsx
@@ -5,6 +5,126 @@ import { css } from 'styled-components/macro'
import { UI } from '../enum'
+interface ChainAccentConfig {
+ bgVar: UI
+ borderVar: UI
+ lightBg: string
+ darkBg: string
+ lightBorder: string
+ darkBorder: string
+}
+
+interface ChainAccentInput {
+ bgVar: UI
+ borderVar: 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,
+ 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,
+ lightBg: chainAlpha(lightColor, lightBgAlpha),
+ darkBg: chainAlpha(darkColor, darkBgAlpha),
+ lightBorder: chainAlpha(lightColor, lightBorderAlpha),
+ darkBorder: chainAlpha(darkColor, darkBorderAlpha),
+ }
+}
+
+const CHAIN_ACCENT_CONFIG: ChainAccentConfig[] = [
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_ETHEREUM_BG,
+ borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER,
+ color: '#627EEA',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_BNB_BG,
+ borderVar: UI.COLOR_CHAIN_BNB_BORDER,
+ color: '#F0B90B',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_BASE_BG,
+ borderVar: UI.COLOR_CHAIN_BASE_BORDER,
+ color: '#0052FF',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_ARBITRUM_BG,
+ borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER,
+ color: '#1B4ADD',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_POLYGON_BG,
+ borderVar: UI.COLOR_CHAIN_POLYGON_BORDER,
+ color: '#8247E5',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_AVALANCHE_BG,
+ borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER,
+ color: '#FF3944',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_GNOSIS_BG,
+ borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER,
+ color: '#07795B',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_LENS_BG,
+ borderVar: UI.COLOR_CHAIN_LENS_BORDER,
+ color: '#5A5A5A',
+ darkColor: '#D7D7D7',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_SEPOLIA_BG,
+ borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER,
+ color: '#C12FF2',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_LINEA_BG,
+ borderVar: UI.COLOR_CHAIN_LINEA_BORDER,
+ color: '#61DFFF',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_PLASMA_BG,
+ borderVar: UI.COLOR_CHAIN_PLASMA_BORDER,
+ color: '#569F8C',
+ }),
+]
+
+const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG.map(({
+ bgVar,
+ borderVar,
+ lightBg,
+ darkBg,
+ lightBorder,
+ darkBorder,
+}) => css`
+ ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)};
+ ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)};
+`)
+
export const ThemeColorVars = css`
:root {
// V3
@@ -83,6 +203,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 }) =>
From 39d4e8f7274d2cee262095cf77b1e9c3cbb7a50c Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 15:06:25 +0000
Subject: [PATCH 17/37] refactor: streamline token selection and recent tokens
handling in SelectTokenWidget
---
.../SelectTokenWidget/controller.ts | 13 ++++---
.../SelectTokenWidget/controllerState.ts | 13 +++++--
.../containers/TokenSearchResults/index.tsx | 11 ++++--
.../tokensList/hooks/useRecentTokens.ts | 37 +++++++++++++++----
4 files changed, 54 insertions(+), 20 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
index 4bec909eb7..b768e2e69d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
@@ -132,20 +132,21 @@ function useSelectTokenWidgetViewState(args: ViewStateArgs): ViewStateResult {
const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget
const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
+ const { recentTokens, handleTokenListItemClick } = useRecentTokenSection(
+ tokenData.allTokens,
+ tokenData.favoriteTokens,
+ )
+ const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken)
const importFlows = useImportFlowCallbacks(
tokenAdminActions.importTokenCallback,
- widgetState.onSelectToken,
+ handleSelectToken,
onDismiss,
tokenAdminActions.addCustomTokenLists,
onTokenListAddingError,
updateSelectTokenWidget,
- )
- const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken)
- const isBridgingEnabled = hasAvailableChains(chainsToSelect)
- const { recentTokens, handleTokenListItemClick } = useRecentTokenSection(
- tokenData.allTokens,
tokenData.favoriteTokens,
)
+ const isBridgingEnabled = hasAvailableChains(chainsToSelect)
const selectTokenModalPropsInput = buildSelectTokenModalPropsInput({
standalone,
displayLpTokenLists,
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
index d3f536d335..5782737632 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -22,7 +22,7 @@ import { CowSwapAnalyticsCategory } from 'common/analytics/types'
import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
-import { useRecentTokens } from '../../hooks/useRecentTokens'
+import { persistRecentTokenSelection, useRecentTokens } from '../../hooks/useRecentTokens'
import { useTokensToSelect } from '../../hooks/useTokensToSelect'
import { ChainsToSelectState } from '../../types'
@@ -174,14 +174,21 @@ export function useImportFlowCallbacks(
addCustomTokenLists: (list: ListState) => void,
onTokenListAddingError: (error: Error) => void,
updateSelectTokenWidget: UpdateSelectTokenWidgetFn,
+ favoriteTokens: TokenWithLogo[],
): ImportFlowCallbacks {
const importTokenAndClose = useCallback(
(tokens: TokenWithLogo[]) => {
importTokenCallback(tokens)
- onSelectToken?.(tokens[0])
+ const [selectedToken] = tokens
+
+ if (selectedToken) {
+ persistRecentTokenSelection(selectedToken, favoriteTokens)
+ onSelectToken?.(selectedToken)
+ }
+
onDismiss()
},
- [importTokenCallback, onSelectToken, onDismiss],
+ [importTokenCallback, onSelectToken, onDismiss, favoriteTokens],
)
const importListAndBack = useCallback(
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 4bb84a16f8..b43723b9f2 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
@@ -23,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)
@@ -56,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({
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
index d9994544a5..5600a2fcd2 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
@@ -4,8 +4,8 @@ import { TokenWithLogo } from '@cowprotocol/common-const'
import { getTokenUniqueKey } from '../utils/tokenKey'
-const RECENT_TOKENS_LIMIT = 4
-const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1'
+export const RECENT_TOKENS_LIMIT = 4
+export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1'
interface StoredRecentToken {
chainId: number
@@ -78,16 +78,12 @@ export function useRecentTokens({
const addRecentToken = useCallback(
(token: TokenWithLogo) => {
- const key = getTokenUniqueKey(token)
-
- if (favoriteKeys.has(key)) {
+ if (favoriteKeys.has(getTokenUniqueKey(token))) {
return
}
setStoredTokens((prev) => {
- const normalized = toStoredToken(token)
- const withoutToken = prev.filter((entry) => getStoredTokenKey(entry) !== key)
- const next = [normalized, ...withoutToken].slice(0, maxItems)
+ const next = buildNextStoredTokens(prev, token, maxItems)
persistStoredTokens(next)
@@ -100,6 +96,23 @@ export function useRecentTokens({
return { recentTokens, addRecentToken }
}
+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 buildTokensByKey(tokens: TokenWithLogo[]): Map {
const map = new Map()
@@ -219,6 +232,14 @@ function persistStoredTokens(tokens: StoredRecentToken[]): void {
}
}
+function buildNextStoredTokens(prev: StoredRecentToken[], token: TokenWithLogo, maxItems: number): StoredRecentToken[] {
+ const normalized = toStoredToken(token)
+ const key = getStoredTokenKey(normalized)
+ const withoutToken = prev.filter((entry) => getStoredTokenKey(entry) !== key)
+
+ return [normalized, ...withoutToken].slice(0, maxItems)
+}
+
function canUseLocalStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
From 97514e76079b231b9a95b83878e463c677e2d91f Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 16:18:14 +0000
Subject: [PATCH 18/37] refactor: enhance recent tokens management and
integrate active chain handling in SelectTokenWidget
---
.../SelectTokenWidget/controller.ts | 23 +-
.../SelectTokenWidget/controllerState.ts | 8 +-
.../tokensList/hooks/recentTokensStorage.ts | 218 ++++++++++++++++++
.../tokensList/hooks/useRecentTokens.ts | 209 ++++-------------
4 files changed, 286 insertions(+), 172 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
index b768e2e69d..9c9111dcc3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
@@ -56,7 +56,7 @@ export function useSelectTokenWidgetController({
onSelectChain = useOnSelectChain()
const manageWidget = useManageWidgetVisibility()
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
- const { account } = useWalletInfo(),
+ const { account, chainId: walletChainId } = useWalletInfo(),
closeTokenSelectWidget = useCloseTokenSelectWidget()
const tokenData = useTokenDataSources()
const onTokenListAddingError = useOnTokenListAddingError()
@@ -82,6 +82,7 @@ export function useSelectTokenWidgetController({
onTokenListAddingError,
tokenAdminActions,
widgetMetadata,
+ walletChainId,
})
return {
@@ -105,6 +106,7 @@ interface ViewStateArgs {
onTokenListAddingError: ReturnType
tokenAdminActions: ReturnType
widgetMetadata: ReturnType
+ walletChainId?: number
}
interface ViewStateResult {
@@ -127,14 +129,17 @@ function useSelectTokenWidgetViewState(args: ViewStateArgs): ViewStateResult {
onTokenListAddingError,
tokenAdminActions,
widgetMetadata,
+ walletChainId,
} = args
+ const activeChainId = resolveActiveChainId(widgetState, walletChainId)
const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget
const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
const { recentTokens, handleTokenListItemClick } = useRecentTokenSection(
tokenData.allTokens,
tokenData.favoriteTokens,
+ activeChainId,
)
const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken)
const importFlows = useImportFlowCallbacks(
@@ -194,3 +199,19 @@ function useSelectTokenWidgetViewState(args: ViewStateArgs): ViewStateResult {
}
export type { SelectTokenWidgetViewProps } from './controllerProps'
+
+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/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
index 5782737632..dd306bd523 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -211,8 +211,12 @@ export function useImportFlowCallbacks(
return { importTokenAndClose, importListAndBack, resetTokenImport }
}
-export function useRecentTokenSection(allTokens: TokenWithLogo[], favoriteTokens: TokenWithLogo[]): RecentTokenSection {
- const { recentTokens, addRecentToken } = useRecentTokens({ allTokens, favoriteTokens })
+export function useRecentTokenSection(
+ allTokens: TokenWithLogo[],
+ favoriteTokens: TokenWithLogo[],
+ activeChainId?: number,
+): RecentTokenSection {
+ const { recentTokens, addRecentToken } = useRecentTokens({ allTokens, favoriteTokens, activeChainId })
const handleTokenListItemClick = useCallback(
(token: TokenWithLogo) => {
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
new file mode 100644
index 0000000000..fd8baef716
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
@@ -0,0 +1,218 @@
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { getTokenUniqueKey } from '../utils/tokenKey'
+
+export const RECENT_TOKENS_LIMIT = 4
+export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1'
+
+export interface StoredRecentToken {
+ chainId: number
+ address: string
+ decimals: number
+ symbol?: string
+ name?: string
+ logoURI?: string
+ tags?: string[]
+}
+
+export type StoredRecentTokensByChain = Record
+
+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/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
index 5600a2fcd2..521338549a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
@@ -2,24 +2,25 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { getTokenUniqueKey } from '../utils/tokenKey'
+import {
+ RECENT_TOKENS_LIMIT,
+ buildFavoriteTokenKeys,
+ buildNextStoredTokens,
+ buildTokensByKey,
+ getStoredTokenKey,
+ hydrateStoredToken,
+ persistRecentTokenSelection as persistRecentTokenSelectionInternal,
+ persistStoredTokens,
+ readStoredTokens,
+ type StoredRecentTokensByChain,
+} from './recentTokensStorage'
-export const RECENT_TOKENS_LIMIT = 4
-export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1'
-
-interface StoredRecentToken {
- chainId: number
- address: string
- decimals: number
- symbol?: string
- name?: string
- logoURI?: string
- tags?: string[]
-}
+import { getTokenUniqueKey } from '../utils/tokenKey'
interface UseRecentTokensParams {
allTokens: TokenWithLogo[]
favoriteTokens: TokenWithLogo[]
+ activeChainId?: number
maxItems?: number
}
@@ -31,30 +32,46 @@ export interface RecentTokensState {
export function useRecentTokens({
allTokens,
favoriteTokens,
+ activeChainId,
maxItems = RECENT_TOKENS_LIMIT,
}: UseRecentTokensParams): RecentTokensState {
- const [storedTokens, setStoredTokens] = useState(() => readStoredTokens(maxItems))
+ const [storedTokensByChain, setStoredTokensByChain] = useState(() =>
+ readStoredTokens(maxItems),
+ )
useEffect(() => {
- persistStoredTokens(storedTokens)
- }, [storedTokens])
+ persistStoredTokens(storedTokensByChain)
+ }, [storedTokensByChain])
const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens])
const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens])
useEffect(() => {
- setStoredTokens((prev) => {
- const filtered = prev.filter((token) => !favoriteKeys.has(getStoredTokenKey(token)))
+ 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
+ }
- return filtered.length === prev.length ? prev : 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 storedTokens) {
+ for (const entry of chainEntries) {
const key = getStoredTokenKey(entry)
if (seenKeys.has(key) || favoriteKeys.has(key)) {
@@ -74,7 +91,7 @@ export function useRecentTokens({
}
return result
- }, [favoriteKeys, maxItems, storedTokens, tokensByKey])
+ }, [activeChainId, favoriteKeys, maxItems, storedTokensByChain, tokensByKey])
const addRecentToken = useCallback(
(token: TokenWithLogo) => {
@@ -82,7 +99,7 @@ export function useRecentTokens({
return
}
- setStoredTokens((prev) => {
+ setStoredTokensByChain((prev) => {
const next = buildNextStoredTokens(prev, token, maxItems)
persistStoredTokens(next)
@@ -96,150 +113,4 @@ export function useRecentTokens({
return { recentTokens, addRecentToken }
}
-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 buildTokensByKey(tokens: TokenWithLogo[]): Map {
- const map = new Map()
-
- for (const token of tokens) {
- map.set(getTokenUniqueKey(token), token)
- }
-
- return map
-}
-
-function buildFavoriteTokenKeys(tokens: TokenWithLogo[]): Set {
- const set = new Set()
-
- for (const token of tokens) {
- set.add(getTokenUniqueKey(token))
- }
-
- return set
-}
-
-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
- }
-}
-
-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 getStoredTokenKey(token: StoredRecentToken): string {
- return getTokenUniqueKey(token)
-}
-
-function readStoredTokens(limit: number): StoredRecentToken[] {
- 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 []
- }
-
- const sanitized = parsed
- .map((item) => sanitizeStoredToken(item))
- .filter((item): item is StoredRecentToken => Boolean(item))
-
- return sanitized.slice(0, limit)
- } catch {
- return []
- }
-}
-
-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 persistStoredTokens(tokens: StoredRecentToken[]): 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
- }
-}
-
-function buildNextStoredTokens(prev: StoredRecentToken[], token: TokenWithLogo, maxItems: number): StoredRecentToken[] {
- const normalized = toStoredToken(token)
- const key = getStoredTokenKey(normalized)
- const withoutToken = prev.filter((entry) => getStoredTokenKey(entry) !== key)
-
- return [normalized, ...withoutToken].slice(0, maxItems)
-}
-
-function canUseLocalStorage(): boolean {
- return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
-}
+export { persistRecentTokenSelectionInternal as persistRecentTokenSelection }
From beeeb1fc2581957dba56e060167c95a3f64b3fa3 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 10 Nov 2025 20:29:32 +0000
Subject: [PATCH 19/37] refactor: enhance SelectTokenWidget with mobile chain
panel and improved styling
---
.../SelectTokenWidget/controller.ts | 348 +++++++++++++++---
.../SelectTokenWidget/controllerProps.ts | 31 +-
.../containers/SelectTokenWidget/index.tsx | 240 ++++++++++--
.../tokensList/pure/ChainPanel/index.tsx | 135 ++++---
.../tokensList/pure/ChainPanel/styled.ts | 81 +++-
.../tokensList/pure/ChainsSelector/index.tsx | 8 +-
.../pure/FavoriteTokensList/styled.ts | 4 +
.../SelectTokenModal/MobileChainSelector.tsx | 95 +++++
.../pure/SelectTokenModal/index.tsx | 189 +++++++---
.../mobileChainSelector.styled.ts | 70 ++++
.../pure/SelectTokenModal/styled.ts | 18 +-
.../tokensList/pure/SelectTokenModal/types.ts | 8 +
12 files changed, 1004 insertions(+), 223 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
index 9c9111dcc3..6a4782d4ad 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
@@ -1,4 +1,5 @@
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
import { isInjectedWidget } from '@cowprotocol/common-utils'
import { useWalletInfo } from '@cowprotocol/wallet'
@@ -32,6 +33,8 @@ import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
+import type { SelectTokenModalProps } from '../../pure/SelectTokenModal'
+
const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
export interface SelectTokenWidgetProps {
@@ -41,7 +44,7 @@ export interface SelectTokenWidgetProps {
export interface SelectTokenWidgetController {
shouldRender: boolean
- isBridgingEnabled: boolean
+ hasChainPanel: boolean
viewProps: SelectTokenWidgetViewProps
}
@@ -54,6 +57,7 @@ export function useSelectTokenWidgetController({
resolvedField = widgetState.field ?? Field.INPUT
const chainsToSelect = useChainsToSelect(),
onSelectChain = useOnSelectChain()
+ const isBridgeFeatureEnabled = useIsBridgingEnabled()
const manageWidget = useManageWidgetVisibility()
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
const { account, chainId: walletChainId } = useWalletInfo(),
@@ -68,7 +72,7 @@ export function useSelectTokenWidgetController({
lpTokensWithBalancesCount,
)
- const { isBridgingEnabled, viewProps } = useSelectTokenWidgetViewState({
+ const { isChainPanelEnabled, viewProps } = useSelectTokenWidgetViewState({
displayLpTokenLists,
standalone,
widgetState,
@@ -83,11 +87,12 @@ export function useSelectTokenWidgetController({
tokenAdminActions,
widgetMetadata,
walletChainId,
+ isBridgeFeatureEnabled,
})
return {
shouldRender: Boolean(widgetState.onSelectToken && widgetState.open),
- isBridgingEnabled,
+ hasChainPanel: isChainPanelEnabled,
viewProps,
}
}
@@ -107,13 +112,16 @@ interface ViewStateArgs {
tokenAdminActions: ReturnType
widgetMetadata: ReturnType
walletChainId?: number
+ isBridgeFeatureEnabled: boolean
}
interface ViewStateResult {
- isBridgingEnabled: boolean
+ isChainPanelEnabled: boolean
viewProps: SelectTokenWidgetViewProps
}
+type BuildViewPropsInput = Parameters[0]
+
function useSelectTokenWidgetViewState(args: ViewStateArgs): ViewStateResult {
const {
displayLpTokenLists,
@@ -130,9 +138,115 @@ function useSelectTokenWidgetViewState(args: ViewStateArgs): ViewStateResult {
tokenAdminActions,
widgetMetadata,
walletChainId,
+ isBridgeFeatureEnabled,
} = args
const activeChainId = resolveActiveChainId(widgetState, walletChainId)
+ const widgetDeps = useWidgetViewDependencies({
+ manageWidget,
+ closeTokenSelectWidget,
+ updateSelectTokenWidget,
+ tokenData,
+ tokenAdminActions,
+ onTokenListAddingError,
+ widgetState,
+ activeChainId,
+ })
+ const isChainPanelEnabled = isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect)
+ const selectTokenModalProps = useWidgetModalProps({
+ account,
+ chainsToSelect,
+ displayLpTokenLists,
+ handleSelectToken: widgetDeps.handleSelectToken,
+ handleTokenListItemClick: widgetDeps.handleTokenListItemClick,
+ hasChainPanel: isChainPanelEnabled,
+ onDismiss: widgetDeps.onDismiss,
+ onSelectChain,
+ openManageWidget: widgetDeps.openManageWidget,
+ openPoolPage: widgetDeps.openPoolPage,
+ 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 }
+}
+
+export type { SelectTokenWidgetViewProps } from './controllerProps'
+
+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
+}
+
+interface WidgetViewDependenciesResult {
+ isManageWidgetOpen: boolean
+ openManageWidget: ReturnType['openManageWidget']
+ closeManageWidget: ReturnType['closeManageWidget']
+ onDismiss(): void
+ openPoolPage: ReturnType['openPoolPage']
+ closePoolPage: ReturnType['closePoolPage']
+ recentTokens: ReturnType['recentTokens']
+ handleTokenListItemClick: ReturnType['handleTokenListItemClick']
+ handleSelectToken: ReturnType
+ importFlows: ReturnType
+}
+
+function useWidgetViewDependencies({
+ manageWidget,
+ closeTokenSelectWidget,
+ updateSelectTokenWidget,
+ tokenData,
+ tokenAdminActions,
+ onTokenListAddingError,
+ widgetState,
+ activeChainId,
+}: {
+ manageWidget: ReturnType
+ closeTokenSelectWidget: ReturnType
+ updateSelectTokenWidget: ReturnType
+ tokenData: ReturnType
+ tokenAdminActions: ReturnType
+ onTokenListAddingError: ReturnType
+ widgetState: ReturnType
+ activeChainId: number | undefined
+}): WidgetViewDependenciesResult {
const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget
const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
@@ -151,67 +265,207 @@ function useSelectTokenWidgetViewState(args: ViewStateArgs): ViewStateResult {
updateSelectTokenWidget,
tokenData.favoriteTokens,
)
- const isBridgingEnabled = hasAvailableChains(chainsToSelect)
- const selectTokenModalPropsInput = buildSelectTokenModalPropsInput({
- standalone,
+
+ return {
+ isManageWidgetOpen,
+ openManageWidget,
+ closeManageWidget,
+ onDismiss,
+ openPoolPage,
+ closePoolPage,
+ recentTokens,
+ handleTokenListItemClick,
+ handleSelectToken,
+ importFlows,
+ }
+}
+
+function useWidgetModalProps({
+ account,
+ chainsToSelect,
+ displayLpTokenLists,
+ handleSelectToken,
+ handleTokenListItemClick,
+ hasChainPanel,
+ onDismiss,
+ onSelectChain,
+ openManageWidget,
+ openPoolPage,
+ recentTokens,
+ standalone,
+ tokenData,
+ widgetMetadata,
+ widgetState,
+ isInjectedWidgetMode,
+}: {
+ account: string | undefined
+ chainsToSelect: ReturnType
+ displayLpTokenLists?: boolean
+ handleSelectToken: ReturnType
+ handleTokenListItemClick: ReturnType['handleTokenListItemClick']
+ hasChainPanel: boolean
+ onDismiss: () => void
+ onSelectChain: ReturnType
+ openManageWidget: ReturnType['openManageWidget']
+ openPoolPage: ReturnType['openPoolPage']
+ recentTokens: ReturnType['recentTokens']
+ standalone?: boolean
+ tokenData: ReturnType
+ widgetMetadata: ReturnType
+ widgetState: ReturnType
+ isInjectedWidgetMode: boolean
+}): SelectTokenModalProps {
+ const favoriteTokens = standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens
+
+ return useSelectTokenModalPropsMemo(
+ createSelectTokenModalProps({
+ account,
+ chainsPanelTitle: widgetMetadata.chainsPanelTitle,
+ chainsState: chainsToSelect,
+ disableErc20: widgetMetadata.disableErc20,
displayLpTokenLists,
- tokenData,
- widgetState,
- favoriteTokens: standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens,
- recentTokens,
+ favoriteTokens,
handleSelectToken,
- onTokenListItemClick: handleTokenListItemClick,
+ hasChainPanel,
+ isInjectedWidgetMode,
+ modalTitle: widgetMetadata.modalTitle,
onDismiss,
+ onSelectChain,
+ onTokenListItemClick: handleTokenListItemClick,
onOpenManageWidget: openManageWidget,
openPoolPage,
+ recentTokens,
+ standalone,
+ tokenData,
tokenListCategoryState: widgetMetadata.tokenListCategoryState,
- disableErc20: widgetMetadata.disableErc20,
- account,
- isBridgingEnabled,
- isInjectedWidgetMode: isInjectedWidget(),
- modalTitle: widgetMetadata.modalTitle,
+ widgetState,
}),
- selectTokenModalProps = useSelectTokenModalPropsMemo(selectTokenModalPropsInput)
+ )
+}
+
+function createSelectTokenModalProps({
+ account,
+ chainsPanelTitle,
+ chainsState,
+ disableErc20,
+ displayLpTokenLists,
+ favoriteTokens,
+ handleSelectToken,
+ hasChainPanel,
+ isInjectedWidgetMode,
+ modalTitle,
+ onDismiss,
+ onSelectChain,
+ onTokenListItemClick,
+ 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']
+ 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,
+ onDismiss,
+ onOpenManageWidget,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ hasChainPanel,
+ chainsState,
+ chainsPanelTitle,
+ onSelectChain,
+ isInjectedWidgetMode,
+ modalTitle,
+ })
+}
- const viewProps = buildSelectTokenWidgetViewProps({
+function getSelectTokenWidgetViewPropsArgs({
+ allTokenLists,
+ chainsPanelTitle,
+ chainsToSelect,
+ closeManageWidget,
+ closePoolPage,
+ importFlows,
+ isChainPanelEnabled,
+ onDismiss,
+ onSelectChain,
+ selectTokenModalProps,
+ selectedPoolAddress,
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ userAddedTokens,
+ handleSelectToken,
+}: {
+ allTokenLists: ReturnType['allTokenLists']
+ chainsPanelTitle: string
+ chainsToSelect: ReturnType
+ closeManageWidget: ReturnType['closeManageWidget']
+ closePoolPage: ReturnType['closePoolPage']
+ importFlows: ReturnType
+ 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
+}): BuildViewPropsInput {
+ return {
standalone,
- tokenToImport: widgetState.tokenToImport,
- listToImport: widgetState.listToImport,
+ tokenToImport,
+ listToImport,
isManageWidgetOpen,
- selectedPoolAddress: widgetState.selectedPoolAddress,
- isBridgingEnabled,
- chainsPanelTitle: widgetMetadata.chainsPanelTitle,
+ selectedPoolAddress,
+ isChainPanelEnabled,
+ chainsPanelTitle,
chainsToSelect,
onSelectChain,
onDismiss,
onBackFromImport: importFlows.resetTokenImport,
onImportTokens: importFlows.importTokenAndClose,
onImportList: importFlows.importListAndBack,
- allTokenLists: tokenData.allTokenLists,
- userAddedTokens: tokenData.userAddedTokens,
+ allTokenLists,
+ userAddedTokens,
onCloseManageWidget: closeManageWidget,
onClosePoolPage: closePoolPage,
selectTokenModalProps,
onSelectToken: handleSelectToken,
- })
-
- return { isBridgingEnabled, viewProps }
-}
-
-export type { SelectTokenWidgetViewProps } from './controllerProps'
-
-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/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
index 0751c5d0a1..ac0025c076 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
@@ -18,7 +18,7 @@ export interface SelectTokenWidgetViewProps {
listToImport?: ListState
isManageWidgetOpen: boolean
selectedPoolAddress?: string
- isBridgingEnabled: boolean
+ isChainPanelEnabled: boolean
chainsPanelTitle: string
chainsToSelect: ChainsToSelectState | undefined
onSelectChain(chain: ChainInfo): void
@@ -40,7 +40,7 @@ interface BuildViewPropsArgs {
listToImport?: ListState
isManageWidgetOpen: boolean
selectedPoolAddress?: string
- isBridgingEnabled: boolean
+ isChainPanelEnabled: boolean
chainsPanelTitle: string
chainsToSelect: ChainsToSelectState | undefined
onSelectChain(chain: ChainInfo): void
@@ -71,7 +71,10 @@ interface BuildModalPropsArgs {
tokenListCategoryState: TokenListCategoryState
disableErc20: boolean
account: string | undefined
- isBridgingEnabled: boolean
+ hasChainPanel: boolean
+ chainsState?: ChainsToSelectState
+ chainsPanelTitle: string
+ onSelectChain?(chain: ChainInfo): void
isInjectedWidgetMode: boolean
modalTitle: string
}
@@ -82,7 +85,7 @@ export function buildSelectTokenWidgetViewProps({
listToImport,
isManageWidgetOpen,
selectedPoolAddress,
- isBridgingEnabled,
+ isChainPanelEnabled,
chainsPanelTitle,
chainsToSelect,
onSelectChain,
@@ -103,7 +106,7 @@ export function buildSelectTokenWidgetViewProps({
listToImport,
isManageWidgetOpen,
selectedPoolAddress,
- isBridgingEnabled,
+ isChainPanelEnabled,
chainsPanelTitle,
chainsToSelect,
onSelectChain,
@@ -135,7 +138,10 @@ export function buildSelectTokenModalPropsInput({
tokenListCategoryState,
disableErc20,
account,
- isBridgingEnabled,
+ hasChainPanel,
+ chainsState,
+ chainsPanelTitle,
+ onSelectChain,
isInjectedWidgetMode,
modalTitle,
}: BuildModalPropsArgs): SelectTokenModalProps {
@@ -163,9 +169,12 @@ export function buildSelectTokenModalPropsInput({
areTokensFromBridge: tokenData.areTokensFromBridge,
isRouteAvailable: tokenData.isRouteAvailable,
modalTitle,
- hasChainPanel: isBridgingEnabled,
+ hasChainPanel,
+ mobileChainsLabel: chainsPanelTitle,
hideFavoriteTokensTooltip: isInjectedWidgetMode,
selectedTargetChainId: widgetState.selectedTargetChainId,
+ mobileChainsState: chainsState,
+ onSelectChain,
}
}
@@ -198,6 +207,10 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
hasChainPanel: props.hasChainPanel,
hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip,
selectedTargetChainId: props.selectedTargetChainId,
+ mobileChainsState: props.mobileChainsState,
+ mobileChainsLabel: props.mobileChainsLabel,
+ onSelectChain: props.onSelectChain,
+ onOpenMobileChainPanel: props.onOpenMobileChainPanel,
}),
[
props.standalone,
@@ -226,6 +239,10 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
props.hasChainPanel,
props.hideFavoriteTokensTooltip,
props.selectedTargetChainId,
+ props.mobileChainsState,
+ props.mobileChainsLabel,
+ props.onSelectChain,
+ props.onOpenMobileChainPanel,
],
)
}
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 34b1cec334..aa8e1f239c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -1,7 +1,10 @@
-import { ReactNode } from 'react'
+import { 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 styled, { css } from 'styled-components/macro'
import {
@@ -17,13 +20,14 @@ import { SelectTokenModal } from '../../pure/SelectTokenModal'
import { LpTokenPage } from '../LpTokenPage'
import { ManageListsAndTokens } from '../ManageListsAndTokens'
-const Wrapper = styled.div`
+const Wrapper = styled.div<{ $isMobileOverlay?: boolean }>`
width: 100%;
+ height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '100%' : 'auto')};
`
-const InnerWrapper = styled.div<{ $hasSidebar: boolean }>`
- height: calc(100vh - 200px);
- min-height: 600px;
+const InnerWrapper = styled.div<{ $hasSidebar: boolean; $isMobileOverlay?: boolean }>`
+ height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '100%' : 'calc(100vh - 200px)')};
+ min-height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '0' : '600px')};
width: 100%;
margin: 0 auto;
display: flex;
@@ -43,50 +47,171 @@ const InnerWrapper = styled.div<{ $hasSidebar: boolean }>`
min-height: 0;
}
`}
+
+ ${({ $isMobileOverlay }) =>
+ $isMobileOverlay &&
+ css`
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ `}
`
-const ModalContainer = styled.div`
+const ModalContainer = styled.div<{ $isMobileOverlay?: boolean }>`
flex: 1;
min-width: 0;
display: flex;
+ height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '100%' : 'auto')};
`
export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode {
const controller = useSelectTokenWidgetController(props)
+ const isCompactLayout = useMediaQuery(Media.upToMedium(false))
+ const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false)
+ const isChainPanelVisible = controller.hasChainPanel && !isCompactLayout
+ const shouldLockScroll = isCompactLayout || isMobileChainPanelOpen
+ const { shouldRender } = controller
+
+ useEffect(() => {
+ if (!shouldRender) {
+ return
+ }
+
+ if (isChainPanelVisible) {
+ setIsMobileChainPanelOpen(false)
+ }
+ }, [isChainPanelVisible, shouldRender])
- if (!controller.shouldRender) {
+ useEffect(() => {
+ if (!shouldRender) {
+ removeBodyClass('noScroll')
+ return undefined
+ }
+
+ if (shouldLockScroll) {
+ addBodyClass('noScroll')
+ return () => removeBodyClass('noScroll')
+ }
+
+ removeBodyClass('noScroll')
+ return undefined
+ }, [shouldLockScroll, shouldRender])
+
+ if (!shouldRender) {
return null
}
- return (
-
-
-
+ const widgetContent = (
+
+
+
)
+
+ if (isCompactLayout) {
+ const overlay = (
+
+ {widgetContent}
+
+ )
+
+ if (typeof document === 'undefined') {
+ return overlay
+ }
+
+ return createPortal(overlay, document.body)
+ }
+
+ return widgetContent
+}
+
+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
+ }
+
+ const mobileChainsState = !isChainPanelVisible ? chainsToSelect : undefined
+ const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined
+ const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect
+ const showMobileChainPanel =
+ !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen
+
+ return (
+ <>
+
+
+
+ {showDesktopChainPanel && (
+
+ )}
+ {showMobileChainPanel &&
+ renderMobileChainPanel({
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onClose: () => setIsMobileChainPanelOpen(false),
+ })}
+ >
+ )
}
-function SelectTokenWidgetView(props: SelectTokenWidgetViewProps): ReactNode {
+function getBlockingView(
+ props: SelectTokenWidgetViewProps & {
+ isChainPanelVisible: boolean
+ isCompactLayout: boolean
+ isMobileChainPanelOpen: boolean
+ setIsMobileChainPanelOpen(value: boolean): void
+ },
+): ReactNode | null {
const {
standalone,
tokenToImport,
listToImport,
isManageWidgetOpen,
selectedPoolAddress,
- isBridgingEnabled,
- chainsPanelTitle,
- chainsToSelect,
- onSelectChain,
+ allTokenLists,
+ userAddedTokens,
onDismiss,
onBackFromImport,
onImportTokens,
onImportList,
- allTokenLists,
- userAddedTokens,
onCloseManageWidget,
onClosePoolPage,
- selectTokenModalProps,
onSelectToken,
} = props
@@ -129,14 +254,73 @@ function SelectTokenWidgetView(props: SelectTokenWidgetViewProps): ReactNode {
)
}
- return (
- <>
-
-
-
- {isBridgingEnabled && chainsToSelect && (
-
- )}
- >
+ return null
+}
+
+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,
)
}
+
+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;
+`
+
+const MobileChainPanelCard = styled.div`
+ flex: 1;
+ max-width: 100%;
+ height: 100%;
+`
+
+const MobileWidgetOverlay = styled.div`
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`
+
+const MobileWidgetCard = styled.div`
+ width: 100%;
+ height: 100%;
+ display: flex;
+ padding: 0;
+ box-sizing: border-box;
+`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
index 613a044f71..d0da81a2e8 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -1,6 +1,7 @@
-import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'
+import { ReactNode, useMemo, useState } from 'react'
import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { BackButton } from '@cowprotocol/ui'
import * as styledEl from './styled'
@@ -13,72 +14,37 @@ export interface ChainPanelProps {
title: string
chainsState: ChainsToSelectState | undefined
onSelectChain(chain: ChainInfo): void
+ variant?: 'default' | 'fullscreen'
+ onClose?(): void
}
-export function ChainPanel({ title, chainsState, onSelectChain }: ChainPanelProps): ReactNode {
+export function ChainPanel({
+ title,
+ chainsState,
+ onSelectChain,
+ variant = 'default',
+ onClose,
+}: ChainPanelProps): ReactNode {
const [chainQuery, setChainQuery] = useState('')
- const [hasVerticalScroll, setHasVerticalScroll] = useState(false)
- const listRef = useRef(null)
- const normalizedChainQuery = chainQuery.trim().toLowerCase()
const chains = chainsState?.chains ?? EMPTY_CHAINS
const isLoading = chainsState?.isLoading ?? false
+ const normalizedChainQuery = chainQuery.trim().toLowerCase()
- const filteredChains = useMemo(() => {
- 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
- })
- }, [chains, normalizedChainQuery])
-
- const showSearchEmptyState = !isLoading && filteredChains.length === 0 && !!normalizedChainQuery
- // When bridge networks are unavailable we still render the panel but show the fallback copy
- const showUnavailableState = !isLoading && chains.length === 0 && !normalizedChainQuery
-
- useEffect(() => {
- const updateScrollState = (): void => {
- const element = listRef.current
-
- if (!element) {
- return
- }
-
- const hasScroll = element.scrollHeight - element.clientHeight > 1
- setHasVerticalScroll((current) => (current === hasScroll ? current : hasScroll))
- }
-
- updateScrollState()
-
- // ResizeObserver tracks size changes (e.g. viewport height, font scaling) without forcing layout.
- const resizeObserver =
- typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateScrollState()) : undefined
- resizeObserver?.observe(listRef.current as Element)
-
- // MutationObserver lets us react when rows are added/removed so the gutter toggles immediately.
- const mutationObserver =
- typeof MutationObserver !== 'undefined' ? new MutationObserver(() => updateScrollState()) : undefined
- mutationObserver?.observe(listRef.current as Element, { childList: true, subtree: true })
-
- // Scroll containers can overflow when the viewport height changes (e.g. window resize, soft keyboard).
- window.addEventListener('resize', updateScrollState)
+ const filteredChains = useMemo(
+ () => filterChainsByQuery(chains, normalizedChainQuery),
+ [chains, normalizedChainQuery],
+ )
- return () => {
- resizeObserver?.disconnect()
- mutationObserver?.disconnect()
- window.removeEventListener('resize', updateScrollState)
- }
- }, [filteredChains.length, isLoading, normalizedChainQuery])
+ const { showSearchEmptyState, showUnavailableState } = getEmptyStateFlags({
+ filteredChainsLength: filteredChains.length,
+ isLoading,
+ normalizedChainQuery,
+ totalChains: chains.length,
+ })
return (
-
-
- {title}
-
+
+
-
+
)
}
+
+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
index 6b4bfe5900..95bbc2b824 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.ts
@@ -2,44 +2,58 @@ import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
-export const Panel = styled.div`
- width: 200px;
+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: 1px solid var(${UI.COLOR_BORDER});
- padding: 16px 10px;
+ 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: 20px;
- border-bottom-right-radius: 20px;
+ 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: 0 0 20px 20px;
+ border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')};
}
${Media.upToSmall()} {
- padding: 16px;
+ padding: ${({ $variant }) => ($variant === 'fullscreen' ? '14px' : '16px')};
+ background: var(${UI.COLOR_PAPER});
}
`
-export const PanelHeader = styled.div`
+export const PanelHeader = styled.div<{ $isFullscreen?: boolean }>`
display: flex;
align-items: center;
- justify-content: space-between;
+ justify-content: ${({ $isFullscreen }) => ($isFullscreen ? 'space-between' : 'space-between')};
+ gap: 12px;
+ padding: ${({ $isFullscreen }) => ($isFullscreen ? '4px 0' : '0')};
`
-export const PanelTitle = styled.h4`
- font-size: 14px;
- font-weight: 500;
+export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>`
+ font-size: ${({ $isFullscreen }) => ($isFullscreen ? '20px' : '14px')};
+ font-weight: ${({ $isFullscreen }) => ($isFullscreen ? 600 : 500)};
margin: 0;
- width: 100%;
- text-align: center;
- color: var(${UI.COLOR_TEXT_OPACITY_70});
+ 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`
@@ -49,6 +63,29 @@ export const PanelSearchInputWrapper = styled.div`
background: transparent;
border-radius: var(--min-height);
padding: 0 10px;
+
+ ${Media.upToSmall()} {
+ --min-height: 46px;
+ border: none;
+ padding: 0;
+ background: transparent;
+
+ > 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;
+ }
+
+ input {
+ background: transparent;
+ height: 100%;
+ }
+ }
`
export const PanelSearchInput = styled(UISearchInput)`
@@ -58,16 +95,20 @@ export const PanelSearchInput = styled(UISearchInput)`
background: transparent;
font-size: 14px;
font-weight: 500;
+
+ &::placeholder {
+ color: var(${UI.COLOR_TEXT_OPACITY_50});
+ }
`
-export const PanelList = styled.div<{ $hasScrollbar: boolean }>`
+export const PanelList = styled.div`
flex: 1;
overflow-y: auto;
- padding-right: ${({ $hasScrollbar }) => ($hasScrollbar ? '8px' : '0')};
- margin-right: ${({ $hasScrollbar }) => ($hasScrollbar ? '-8px' : '0')};
+ padding-right: 8px;
+ margin-right: -8px;
box-sizing: content-box;
${({ theme }) => theme.colorScrollbar};
- scrollbar-gutter: ${({ $hasScrollbar }) => ($hasScrollbar ? 'stable' : 'auto')};
+ scrollbar-gutter: stable;
`
export const EmptyState = styled.div`
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 cc276e174c..aa5ae87a4b 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -139,6 +139,10 @@ function renderChainButtons({
return elements
}
+export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined {
+ return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId]
+}
+
interface ChainButtonProps {
chain: ChainInfo
isActive: boolean
@@ -146,10 +150,6 @@ interface ChainButtonProps {
onSelectChain(chain: ChainInfo): void
}
-function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined {
- return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId]
-}
-
function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButtonProps): ReactNode {
const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light
const accent = getChainAccent(chain.id)
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 89ccfba70e..71bc8c292c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
@@ -4,6 +4,10 @@ import styled from 'styled-components/macro'
export const Section = styled.div`
padding: 0 14px 14px;
+
+ ${Media.upToSmall()} {
+ padding: 8px 14px 4px;
+ }
`
export const TitleRow = styled.div`
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..66b94f21f3
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
@@ -0,0 +1,95 @@
+import { ReactNode, useMemo } from 'react'
+
+import { useTheme } from '@cowprotocol/common-hooks'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import * as styledEl from './mobileChainSelector.styled'
+
+import { ChainsToSelectState } from '../../types'
+import { getChainAccent } from '../ChainsSelector'
+
+const MAX_VISIBLE_CHAINS = 3
+
+interface MobileChainSelectorProps {
+ chainsState: ChainsToSelectState
+ label?: string
+ onSelectChain(chain: ChainInfo): void
+ onOpenPanel(): void
+}
+
+export function MobileChainSelector({
+ chainsState,
+ label,
+ onSelectChain,
+ onOpenPanel,
+}: MobileChainSelectorProps): ReactNode {
+ const orderedChains = useMemo(
+ () => reorderChains(chainsState.chains ?? [], chainsState.defaultChainId),
+ [chainsState.chains, chainsState.defaultChainId],
+ )
+
+ const totalChains = chainsState.chains?.length ?? 0
+ const visibleChains = orderedChains.slice(0, MAX_VISIBLE_CHAINS)
+ const remainingCount = Math.max(totalChains - visibleChains.length, 0)
+
+ return (
+
+ {label && {label}}
+
+ {visibleChains.map((chain) => (
+
+ ))}
+ {remainingCount > 0 && (
+
+ +{remainingCount} more
+
+ )}
+
+
+ )
+}
+
+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}
+ >
+
+
+ )
+}
+
+function reorderChains(chains: ChainInfo[], defaultChainId: ChainInfo['id'] | undefined): ChainInfo[] {
+ if (!defaultChainId) {
+ return [...chains]
+ }
+
+ const sorted = [...chains]
+ const index = sorted.findIndex((chain) => chain.id === defaultChainId)
+
+ if (index <= 0) {
+ return sorted
+ }
+
+ const [current] = sorted.splice(index, 1)
+ return [current, ...sorted]
+}
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 6a64aa93e0..a3c3491cad 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -1,9 +1,10 @@
-import { ReactNode } from 'react'
+import { ComponentProps, ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { SearchInput } from '@cowprotocol/ui'
import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers'
+import { MobileChainSelector } from './MobileChainSelector'
import { SelectTokenModalContent } from './SelectTokenModalContent'
import * as styledEl from './styled'
@@ -25,7 +26,7 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
disableErc20,
isRouteAvailable,
modalTitle,
- hasChainPanel,
+ hasChainPanel = false,
standalone,
onOpenManageWidget,
favoriteTokens,
@@ -35,59 +36,61 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
areTokensFromBridge,
hideFavoriteTokensTooltip,
selectedTargetChainId,
+ mobileChainsState,
+ mobileChainsLabel,
+ onSelectChain,
+ onOpenMobileChainPanel,
+ isFullScreenMobile,
} = props
const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
const selectTokenContext = useSelectTokenContext(props)
const resolvedModalTitle = modalTitle ?? 'Select token'
+ const mobileChainSelector = getMobileChainSelectorConfig({
+ hasChainPanel,
+ mobileChainsState,
+ mobileChainsLabel,
+ onSelectChain,
+ onOpenMobileChainPanel,
+ })
return (
-
-
-
-
- e.key === 'Enter' && onInputPressEnter?.()}
- onChange={(e) => setInputValue(e.target.value)}
- placeholder="Search name or paste address..."
- />
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
)
}
@@ -133,3 +136,93 @@ function TokenColumnContent(props: TokenColumnContentProps): ReactNode {
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 (
+
+
+
+
+ {
+ if (event.key === 'Enter') {
+ onSearchEnter?.()
+ }
+ }}
+ onChange={(event) => onSearchChange(event.target.value)}
+ placeholder="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..c2b0ec0768
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts
@@ -0,0 +1,70 @@
+import { UI } from '@cowprotocol/ui'
+
+import styled from 'styled-components/macro'
+
+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 16px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`
+
+export const MobileSelectorLabel = styled.span`
+ font-size: 13px;
+ font-weight: 600;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+`
+
+export const ChipsWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`
+
+export const ChainChipButton = styled.button<{ $active?: boolean; $accent?: ChainAccentVars }>`
+ --size: 44px;
+ width: var(--size);
+ height: var(--size);
+ border-radius: 50%;
+ 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;
+
+ > img {
+ width: 70%;
+ height: 70%;
+ object-fit: contain;
+ }
+`
+
+export const MoreChipButton = styled.button`
+ --size: 44px;
+ height: var(--size);
+ min-width: var(--size);
+ padding: 0 14px;
+ border-radius: var(--size);
+ border: 1px dashed var(${UI.COLOR_BORDER});
+ background: transparent;
+ color: var(${UI.COLOR_TEXT});
+ font-weight: 600;
+ font-size: 13px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+`
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 1808b7f1df..e6b0609313 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -4,18 +4,20 @@ import styled from 'styled-components/macro'
import { blankButtonMixin } from '../commonElements'
-export const Wrapper = styled.div<{ $hasChainPanel?: boolean }>`
+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 }) => ($hasChainPanel ? '0' : '20px')};
- border-bottom-right-radius: ${({ $hasChainPanel }) => ($hasChainPanel ? '0' : '20px')};
+ border-top-right-radius: ${({ $hasChainPanel, $isFullScreen }) =>
+ $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'};
+ border-bottom-right-radius: ${({ $hasChainPanel, $isFullScreen }) =>
+ $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'};
${Media.upToMedium()} {
- border-radius: 20px;
+ border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')};
}
`
@@ -27,7 +29,7 @@ export const TitleBar = styled.div`
gap: 12px;
${Media.upToSmall()} {
- padding: 16px 16px 8px;
+ padding: 14px 14px 8px;
}
`
@@ -110,10 +112,6 @@ export const TokenColumn = styled.div`
display: flex;
flex-direction: column;
padding: 0;
-
- ${Media.upToSmall()} {
- padding: 16px;
- }
`
export const Row = styled.div`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
index dfb89970b6..7081335466 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -1,5 +1,6 @@
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'
@@ -7,6 +8,8 @@ import { Nullish } from 'types'
import { PermitCompatibleTokens } from 'modules/permit'
+import { ChainsToSelectState } from '../../types'
+
export interface SelectTokenModalProps {
allTokens: TokenWithLogo[]
favoriteTokens: TokenWithLogo[]
@@ -29,6 +32,11 @@ export interface SelectTokenModalProps {
modalTitle?: string
hasChainPanel?: boolean
selectedTargetChainId?: number
+ mobileChainsState?: ChainsToSelectState
+ mobileChainsLabel?: string
+ onSelectChain?(chain: ChainInfo): void
+ onOpenMobileChainPanel?(): void
+ isFullScreenMobile?: boolean
onSelectToken(token: TokenWithLogo): void
onTokenListItemClick?(token: TokenWithLogo): void
From 0f4b0eaa93a5223c8a520efcec3aa3fe5a3e108e Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 11 Nov 2025 07:31:26 +0000
Subject: [PATCH 20/37] refactor: optimize token sorting logic in
TokensVirtualList for better performance
---
.../pure/TokensVirtualList/index.tsx | 24 +++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
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 1b0bb3d225..015dec969e 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'
@@ -44,8 +45,27 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
const { isYieldEnabled } = useFeatureFlags()
const sortedTokens = useMemo(() => {
- const listToSort = [...allTokens]
- return balances ? listToSort.sort(tokensListSorter(balances)) : listToSort
+ 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(() => {
From 229fa86175c7a548cdacd5d26c9fbd6da0016108 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 11 Nov 2025 12:25:59 +0000
Subject: [PATCH 21/37] refactor: modularize SelectTokenWidget dependencies and
enhance recent tokens functionality
---
.../SelectTokenWidget/controller.ts | 405 +-----------------
.../controllerDependencies.ts | 84 ++++
.../SelectTokenWidget/controllerProps.ts | 5 +
.../SelectTokenWidget/controllerState.ts | 9 +-
.../tokensList/hooks/useRecentTokens.ts | 22 +-
.../pure/SelectTokenModal/helpers.tsx | 3 +
.../pure/SelectTokenModal/index.tsx | 2 +
.../pure/SelectTokenModal/styled.ts | 18 +
.../tokensList/pure/SelectTokenModal/types.ts | 1 +
.../tokensList/pure/TokensContent/index.tsx | 3 +
.../pure/TokensVirtualList/index.tsx | 24 +-
11 files changed, 173 insertions(+), 403 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
index 6a4782d4ad..acd8662de5 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
@@ -1,30 +1,18 @@
-import { TokenWithLogo } from '@cowprotocol/common-const'
import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
-import { isInjectedWidget } from '@cowprotocol/common-utils'
import { useWalletInfo } from '@cowprotocol/wallet'
import { Field } from 'legacy/state/types'
import { useLpTokensWithBalances } from 'modules/yield/shared'
+import { SelectTokenWidgetViewProps } from './controllerProps'
import {
- SelectTokenWidgetViewProps,
- buildSelectTokenModalPropsInput,
- buildSelectTokenWidgetViewProps,
- useSelectTokenModalPropsMemo,
-} from './controllerProps'
-import {
- hasAvailableChains,
- useDismissHandler,
- useImportFlowCallbacks,
useManageWidgetVisibility,
- usePoolPageHandlers,
useTokenAdminActions,
useTokenDataSources,
- useTokenSelectionHandler,
useWidgetMetadata,
- useRecentTokenSection,
} from './controllerState'
+import { useSelectTokenWidgetViewState } from './controllerViewState'
import { useChainsToSelect } from '../../hooks/useChainsToSelect'
import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
@@ -33,10 +21,6 @@ import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
-import type { SelectTokenModalProps } from '../../pure/SelectTokenModal'
-
-const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
-
export interface SelectTokenWidgetProps {
displayLpTokenLists?: boolean
standalone?: boolean
@@ -52,16 +36,16 @@ export function useSelectTokenWidgetController({
displayLpTokenLists,
standalone,
}: SelectTokenWidgetProps): SelectTokenWidgetController {
- const widgetState = useSelectTokenWidgetState(),
- { count: lpTokensWithBalancesCount } = useLpTokensWithBalances(),
- resolvedField = widgetState.field ?? Field.INPUT
- const chainsToSelect = useChainsToSelect(),
- onSelectChain = useOnSelectChain()
+ 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(),
- closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const { account, chainId: walletChainId } = useWalletInfo()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
const tokenData = useTokenDataSources()
const onTokenListAddingError = useOnTokenListAddingError()
const tokenAdminActions = useTokenAdminActions()
@@ -97,375 +81,4 @@ export function useSelectTokenWidgetController({
}
}
-interface ViewStateArgs {
- 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
-}
-
-type BuildViewPropsInput = Parameters[0]
-
-function useSelectTokenWidgetViewState(args: ViewStateArgs): 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 isChainPanelEnabled = isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect)
- const selectTokenModalProps = useWidgetModalProps({
- account,
- chainsToSelect,
- displayLpTokenLists,
- handleSelectToken: widgetDeps.handleSelectToken,
- handleTokenListItemClick: widgetDeps.handleTokenListItemClick,
- hasChainPanel: isChainPanelEnabled,
- onDismiss: widgetDeps.onDismiss,
- onSelectChain,
- openManageWidget: widgetDeps.openManageWidget,
- openPoolPage: widgetDeps.openPoolPage,
- 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 }
-}
-
export type { SelectTokenWidgetViewProps } from './controllerProps'
-
-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
-}
-
-interface WidgetViewDependenciesResult {
- isManageWidgetOpen: boolean
- openManageWidget: ReturnType['openManageWidget']
- closeManageWidget: ReturnType['closeManageWidget']
- onDismiss(): void
- openPoolPage: ReturnType['openPoolPage']
- closePoolPage: ReturnType['closePoolPage']
- recentTokens: ReturnType['recentTokens']
- handleTokenListItemClick: ReturnType['handleTokenListItemClick']
- handleSelectToken: ReturnType
- importFlows: ReturnType
-}
-
-function useWidgetViewDependencies({
- manageWidget,
- closeTokenSelectWidget,
- updateSelectTokenWidget,
- tokenData,
- tokenAdminActions,
- onTokenListAddingError,
- widgetState,
- activeChainId,
-}: {
- manageWidget: ReturnType
- closeTokenSelectWidget: ReturnType
- updateSelectTokenWidget: ReturnType
- tokenData: ReturnType
- tokenAdminActions: ReturnType
- onTokenListAddingError: ReturnType
- widgetState: ReturnType
- activeChainId: number | undefined
-}): WidgetViewDependenciesResult {
- const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget
- const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
- const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
- const { recentTokens, handleTokenListItemClick } = useRecentTokenSection(
- tokenData.allTokens,
- tokenData.favoriteTokens,
- activeChainId,
- )
- const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken)
- const importFlows = useImportFlowCallbacks(
- tokenAdminActions.importTokenCallback,
- handleSelectToken,
- onDismiss,
- tokenAdminActions.addCustomTokenLists,
- onTokenListAddingError,
- updateSelectTokenWidget,
- tokenData.favoriteTokens,
- )
-
- return {
- isManageWidgetOpen,
- openManageWidget,
- closeManageWidget,
- onDismiss,
- openPoolPage,
- closePoolPage,
- recentTokens,
- handleTokenListItemClick,
- handleSelectToken,
- importFlows,
- }
-}
-
-function useWidgetModalProps({
- account,
- chainsToSelect,
- displayLpTokenLists,
- handleSelectToken,
- handleTokenListItemClick,
- hasChainPanel,
- onDismiss,
- onSelectChain,
- openManageWidget,
- openPoolPage,
- recentTokens,
- standalone,
- tokenData,
- widgetMetadata,
- widgetState,
- isInjectedWidgetMode,
-}: {
- account: string | undefined
- chainsToSelect: ReturnType
- displayLpTokenLists?: boolean
- handleSelectToken: ReturnType
- handleTokenListItemClick: ReturnType['handleTokenListItemClick']
- hasChainPanel: boolean
- onDismiss: () => void
- onSelectChain: ReturnType
- openManageWidget: ReturnType['openManageWidget']
- openPoolPage: ReturnType['openPoolPage']
- recentTokens: ReturnType['recentTokens']
- standalone?: boolean
- tokenData: ReturnType
- widgetMetadata: ReturnType
- widgetState: ReturnType
- isInjectedWidgetMode: boolean
-}): SelectTokenModalProps {
- const favoriteTokens = standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens
-
- return useSelectTokenModalPropsMemo(
- createSelectTokenModalProps({
- account,
- chainsPanelTitle: widgetMetadata.chainsPanelTitle,
- chainsState: chainsToSelect,
- disableErc20: widgetMetadata.disableErc20,
- displayLpTokenLists,
- favoriteTokens,
- handleSelectToken,
- hasChainPanel,
- isInjectedWidgetMode,
- modalTitle: widgetMetadata.modalTitle,
- onDismiss,
- onSelectChain,
- onTokenListItemClick: handleTokenListItemClick,
- onOpenManageWidget: openManageWidget,
- openPoolPage,
- recentTokens,
- standalone,
- tokenData,
- tokenListCategoryState: widgetMetadata.tokenListCategoryState,
- widgetState,
- }),
- )
-}
-
-function createSelectTokenModalProps({
- account,
- chainsPanelTitle,
- chainsState,
- disableErc20,
- displayLpTokenLists,
- favoriteTokens,
- handleSelectToken,
- hasChainPanel,
- isInjectedWidgetMode,
- modalTitle,
- onDismiss,
- onSelectChain,
- onTokenListItemClick,
- 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']
- 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,
- onDismiss,
- onOpenManageWidget,
- openPoolPage,
- tokenListCategoryState,
- disableErc20,
- account,
- hasChainPanel,
- chainsState,
- chainsPanelTitle,
- onSelectChain,
- isInjectedWidgetMode,
- modalTitle,
- })
-}
-
-function getSelectTokenWidgetViewPropsArgs({
- allTokenLists,
- chainsPanelTitle,
- chainsToSelect,
- closeManageWidget,
- closePoolPage,
- importFlows,
- isChainPanelEnabled,
- onDismiss,
- onSelectChain,
- selectTokenModalProps,
- selectedPoolAddress,
- standalone,
- tokenToImport,
- listToImport,
- isManageWidgetOpen,
- userAddedTokens,
- handleSelectToken,
-}: {
- allTokenLists: ReturnType['allTokenLists']
- chainsPanelTitle: string
- chainsToSelect: ReturnType
- closeManageWidget: ReturnType['closeManageWidget']
- closePoolPage: ReturnType['closePoolPage']
- importFlows: ReturnType
- 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
-}): BuildViewPropsInput {
- 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,
- }
-}
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..1b4386d16e
--- /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)
+ 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/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
index ac0025c076..0fd25181d5 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
@@ -65,6 +65,7 @@ interface BuildModalPropsArgs {
recentTokens: TokenWithLogo[]
handleSelectToken(token: TokenWithLogo): void
onTokenListItemClick(token: TokenWithLogo): void
+ onClearRecentTokens(): void
onDismiss(): void
onOpenManageWidget(): void
openPoolPage(poolAddress: string): void
@@ -132,6 +133,7 @@ export function buildSelectTokenModalPropsInput({
recentTokens,
handleSelectToken,
onTokenListItemClick,
+ onClearRecentTokens,
onDismiss,
onOpenManageWidget,
openPoolPage,
@@ -175,6 +177,7 @@ export function buildSelectTokenModalPropsInput({
selectedTargetChainId: widgetState.selectedTargetChainId,
mobileChainsState: chainsState,
onSelectChain,
+ onClearRecentTokens,
}
}
@@ -211,6 +214,7 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
mobileChainsLabel: props.mobileChainsLabel,
onSelectChain: props.onSelectChain,
onOpenMobileChainPanel: props.onOpenMobileChainPanel,
+ onClearRecentTokens: props.onClearRecentTokens,
}),
[
props.standalone,
@@ -243,6 +247,7 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
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
index dd306bd523..ff08244061 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -78,6 +78,7 @@ interface ImportFlowCallbacks {
interface RecentTokenSection {
recentTokens: TokenWithLogo[]
handleTokenListItemClick(token: TokenWithLogo): void
+ clearRecentTokens(): void
}
export function useManageWidgetVisibility(): ManageWidgetVisibility {
@@ -216,7 +217,11 @@ export function useRecentTokenSection(
favoriteTokens: TokenWithLogo[],
activeChainId?: number,
): RecentTokenSection {
- const { recentTokens, addRecentToken } = useRecentTokens({ allTokens, favoriteTokens, activeChainId })
+ const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({
+ allTokens,
+ favoriteTokens,
+ activeChainId,
+ })
const handleTokenListItemClick = useCallback(
(token: TokenWithLogo) => {
@@ -225,7 +230,7 @@ export function useRecentTokenSection(
[addRecentToken],
)
- return { recentTokens, handleTokenListItemClick }
+ return { recentTokens, handleTokenListItemClick, clearRecentTokens }
}
export function useTokenSelectionHandler(
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
index 521338549a..e1ea8a97cf 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
@@ -27,6 +27,7 @@ interface UseRecentTokensParams {
export interface RecentTokensState {
recentTokens: TokenWithLogo[]
addRecentToken(token: TokenWithLogo): void
+ clearRecentTokens(): void
}
export function useRecentTokens({
@@ -110,7 +111,26 @@ export function useRecentTokens({
[favoriteKeys, maxItems],
)
- return { recentTokens, addRecentToken }
+ 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/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
index 5709cfe04a..8793810e45 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
@@ -64,6 +64,7 @@ interface TokensContentSectionProps
| 'areTokensFromBridge'
| 'hideFavoriteTokensTooltip'
| 'selectedTargetChainId'
+ | 'onClearRecentTokens'
> {
searchInput: string
selectTokenContext: SelectTokenContext
@@ -80,6 +81,7 @@ export function TokensContentSection({
hideFavoriteTokensTooltip,
selectedTargetChainId,
selectTokenContext,
+ onClearRecentTokens,
}: TokensContentSectionProps): ReactNode {
return (
)
}
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 a3c3491cad..a665b65937 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -31,6 +31,7 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
onOpenManageWidget,
favoriteTokens,
recentTokens,
+ onClearRecentTokens,
areTokensLoading,
allTokens,
areTokensFromBridge,
@@ -81,6 +82,7 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
displayLpTokenLists={displayLpTokenLists}
favoriteTokens={favoriteTokens}
recentTokens={recentTokens}
+ onClearRecentTokens={onClearRecentTokens}
areTokensLoading={areTokensLoading}
allTokens={allTokens}
searchInput={trimmedInputValue}
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 e6b0609313..a726e48eeb 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -130,12 +130,30 @@ export const Separator = styled.div`
`
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 {
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ }
+`
+
export const TokensLoader = styled.div`
width: 100%;
height: 100%;
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
index 7081335466..c8bb8bb281 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -40,6 +40,7 @@ export interface SelectTokenModalProps {
onSelectToken(token: TokenWithLogo): void
onTokenListItemClick?(token: TokenWithLogo): void
+ onClearRecentTokens?(): void
openPoolPage(poolAddress: string): void
onInputPressEnter?(): void
onOpenManageWidget(): void
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 e8ef77225e..2bad71148a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -20,6 +20,7 @@ export interface TokensContentProps {
areTokensFromBridge: boolean
hideFavoriteTokensTooltip?: boolean
selectedTargetChainId?: number
+ onClearRecentTokens?: () => void
}
export function TokensContent({
@@ -33,6 +34,7 @@ export function TokensContent({
areTokensFromBridge,
hideFavoriteTokensTooltip,
selectedTargetChainId,
+ onClearRecentTokens,
}: TokensContentProps): ReactNode {
const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0
const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0
@@ -90,6 +92,7 @@ export function TokensContent({
recentTokens={recentTokensInline}
hideFavoriteTokensTooltip={hideFavoriteTokensTooltip}
scrollResetKey={selectedTargetChainId}
+ onClearRecentTokens={onClearRecentTokens}
/>
)}
>
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 015dec969e..4a863f5ec3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -23,11 +23,12 @@ export interface TokensVirtualListProps {
recentTokens?: TokenWithLogo[]
hideFavoriteTokensTooltip?: boolean
scrollResetKey?: number
+ onClearRecentTokens?: () => void
}
type TokensVirtualRow =
| { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean }
- | { type: 'title'; label: string }
+ | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void }
| { type: 'token'; token: TokenWithLogo }
export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
@@ -39,6 +40,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
recentTokens,
hideFavoriteTokensTooltip,
scrollResetKey,
+ onClearRecentTokens,
} = props
const { values: balances } = selectTokenContext.balancesState
@@ -81,7 +83,12 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
}
if (recentTokens?.length) {
- composedRows.push({ type: 'title', label: 'Recent' })
+ composedRows.push({
+ type: 'title',
+ label: 'Recent',
+ actionLabel: onClearRecentTokens ? 'Clear' : undefined,
+ onAction: onClearRecentTokens,
+ })
recentTokens.forEach((token) => composedRows.push({ type: 'token', token }))
}
@@ -90,7 +97,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
}
return [...composedRows, ...tokenRows]
- }, [favoriteTokens, hideFavoriteTokensTooltip, recentTokens, sortedTokens])
+ }, [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens])
const virtualListKey = scrollResetKey ?? 'tokens-list'
@@ -130,7 +137,16 @@ function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowR
/>
)
case 'title':
- return {row.label}
+ return (
+
+ {row.label}
+ {row.actionLabel && row.onAction ? (
+
+ {row.actionLabel}
+
+ ) : null}
+
+ )
default:
return
}
From f60c0496d700140061fe6658e49d05db528aec53 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 11 Nov 2025 12:26:15 +0000
Subject: [PATCH 22/37] refactor: add controllerModalProps and
controllerViewState for SelectTokenWidget functionality
---
.../SelectTokenWidget/controllerModalProps.ts | 214 ++++++++++++++++++
.../SelectTokenWidget/controllerProps.ts | 1 -
.../SelectTokenWidget/controllerViewState.ts | 129 +++++++++++
3 files changed, 343 insertions(+), 1 deletion(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts
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