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> - {chain.label} - - - ))} - {shouldDisplayMore && ( - - {({ isOpen }) => ( - <> - - {selectedMenuChain ? ( - {selectedMenuChain.label} - ) : isOpen ? ( - Less - ) : ( - More - )} - {isOpen ? : } - - - {chains.map((chain) => ( - onSelectChain(chain)} - active$={defaultChainId === chain.id} - iconSize={21} - tabIndex={0} - borderless - > - {chain.label} - {chain.label} - {chain.id === defaultChainId && } - - ))} - - - )} - - )} - + + {chains.map((chain) => { + const isActive = defaultChainId === chain.id + + return ( + onSelectChain(chain)} + active$={isActive} + aria-pressed={isActive} + > + + + {chain.label} + + {chain.label} + + {isActive && ( + + + + )} + + ) + })} + ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx index 7b8260b2e8..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} - - {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} + + {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} + > {chain.label} 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} + > + {chain.label} + + ) +} + +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['openPoolPage'] + recentTokens: ReturnType['recentTokens'] + standalone: boolean | undefined + tokenData: ReturnType + tokenListCategoryState: ReturnType['tokenListCategoryState'] + widgetState: ReturnType +}): SelectTokenModalProps { + return buildSelectTokenModalPropsInput({ + standalone, + displayLpTokenLists, + tokenData, + widgetState, + favoriteTokens, + recentTokens, + handleSelectToken, + onTokenListItemClick, + onClearRecentTokens, + onDismiss, + onOpenManageWidget, + openPoolPage, + tokenListCategoryState, + disableErc20, + account, + hasChainPanel, + chainsState, + chainsPanelTitle, + onSelectChain, + isInjectedWidgetMode, + modalTitle, + }) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts index 0fd25181d5..da42c58e14 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -11,7 +11,6 @@ import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidget import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' type WidgetState = ReturnType - export interface SelectTokenWidgetViewProps { standalone?: boolean tokenToImport?: TokenWithLogo diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts new file mode 100644 index 0000000000..076cade65c --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts @@ -0,0 +1,129 @@ +import { isInjectedWidget } from '@cowprotocol/common-utils' + +import { useWidgetViewDependencies } from './controllerDependencies' +import { getSelectTokenWidgetViewPropsArgs, useWidgetModalProps } from './controllerModalProps' +import { SelectTokenWidgetViewProps, buildSelectTokenWidgetViewProps } from './controllerProps' +import { + hasAvailableChains, + useManageWidgetVisibility, + useTokenAdminActions, + useTokenDataSources, + useWidgetMetadata, +} from './controllerState' + +import { useChainsToSelect } from '../../hooks/useChainsToSelect' +import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' +import { useOnSelectChain } from '../../hooks/useOnSelectChain' +import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +export interface SelectTokenWidgetViewStateArgs { + displayLpTokenLists?: boolean + standalone?: boolean + widgetState: ReturnType + chainsToSelect: ReturnType + onSelectChain: ReturnType + manageWidget: ReturnType + updateSelectTokenWidget: ReturnType + account: string | undefined + closeTokenSelectWidget: ReturnType + tokenData: ReturnType + onTokenListAddingError: ReturnType + tokenAdminActions: ReturnType + widgetMetadata: ReturnType + walletChainId?: number + isBridgeFeatureEnabled: boolean +} + +interface ViewStateResult { + isChainPanelEnabled: boolean + viewProps: SelectTokenWidgetViewProps +} + +export function useSelectTokenWidgetViewState(args: SelectTokenWidgetViewStateArgs): ViewStateResult { + const { + displayLpTokenLists, + standalone, + widgetState, + chainsToSelect, + onSelectChain, + manageWidget, + updateSelectTokenWidget, + account, + closeTokenSelectWidget, + tokenData, + onTokenListAddingError, + tokenAdminActions, + widgetMetadata, + walletChainId, + isBridgeFeatureEnabled, + } = args + + const activeChainId = resolveActiveChainId(widgetState, walletChainId) + const widgetDeps = useWidgetViewDependencies({ + manageWidget, + closeTokenSelectWidget, + updateSelectTokenWidget, + tokenData, + tokenAdminActions, + onTokenListAddingError, + widgetState, + activeChainId, + }) + const isChainPanelEnabled = isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect) + const selectTokenModalProps = useWidgetModalProps({ + account, + chainsToSelect, + displayLpTokenLists, + widgetDeps, + hasChainPanel: isChainPanelEnabled, + onSelectChain, + recentTokens: widgetDeps.recentTokens, + standalone, + tokenData, + widgetMetadata, + widgetState, + isInjectedWidgetMode: isInjectedWidget(), + }) + + const viewProps = buildSelectTokenWidgetViewProps( + getSelectTokenWidgetViewPropsArgs({ + allTokenLists: tokenData.allTokenLists, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + chainsToSelect, + closeManageWidget: widgetDeps.closeManageWidget, + closePoolPage: widgetDeps.closePoolPage, + importFlows: widgetDeps.importFlows, + isChainPanelEnabled, + onDismiss: widgetDeps.onDismiss, + onSelectChain, + selectTokenModalProps, + selectedPoolAddress: widgetState.selectedPoolAddress, + standalone, + tokenToImport: widgetState.tokenToImport, + listToImport: widgetState.listToImport, + isManageWidgetOpen: widgetDeps.isManageWidgetOpen, + userAddedTokens: tokenData.userAddedTokens, + handleSelectToken: widgetDeps.handleSelectToken, + }), + ) + + return { isChainPanelEnabled, viewProps } +} + +function resolveActiveChainId( + widgetState: ReturnType, + walletChainId?: number, +): number | undefined { + return ( + widgetState.selectedTargetChainId ?? + walletChainId ?? + extractChainId(widgetState.oppositeToken) ?? + extractChainId(widgetState.selectedToken) + ) +} + +function extractChainId(token: { chainId?: number } | undefined | null): number | undefined { + return typeof token?.chainId === 'number' ? token.chainId : undefined +} From 96df86973396fc1cf1454362b53be2c04da3e56c Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Tue, 11 Nov 2025 13:05:14 +0000 Subject: [PATCH 23/37] refactor: add accent color support for chains in ChainsSelector component --- .../tokensList/pure/ChainsSelector/index.tsx | 13 ++++++++- .../tokensList/pure/ChainsSelector/styled.tsx | 8 ++++-- libs/ui/src/enum.ts | 11 ++++++++ libs/ui/src/theme/ThemeColorVars.tsx | 27 +++++++++++++++++++ 4 files changed, 56 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 aa5ae87a4b..7a12265d19 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx @@ -18,46 +18,57 @@ const CHAIN_ACCENT_VAR_MAP: Record = { [SupportedChainId.MAINNET]: { backgroundVar: UI.COLOR_CHAIN_ETHEREUM_BG, borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER, + accentColorVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT, }, [SupportedChainId.BNB]: { backgroundVar: UI.COLOR_CHAIN_BNB_BG, borderVar: UI.COLOR_CHAIN_BNB_BORDER, + accentColorVar: UI.COLOR_CHAIN_BNB_ACCENT, }, [SupportedChainId.BASE]: { backgroundVar: UI.COLOR_CHAIN_BASE_BG, borderVar: UI.COLOR_CHAIN_BASE_BORDER, + accentColorVar: UI.COLOR_CHAIN_BASE_ACCENT, }, [SupportedChainId.ARBITRUM_ONE]: { backgroundVar: UI.COLOR_CHAIN_ARBITRUM_BG, borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER, + accentColorVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT, }, [SupportedChainId.POLYGON]: { backgroundVar: UI.COLOR_CHAIN_POLYGON_BG, borderVar: UI.COLOR_CHAIN_POLYGON_BORDER, + accentColorVar: UI.COLOR_CHAIN_POLYGON_ACCENT, }, [SupportedChainId.AVALANCHE]: { backgroundVar: UI.COLOR_CHAIN_AVALANCHE_BG, borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER, + accentColorVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT, }, [SupportedChainId.GNOSIS_CHAIN]: { backgroundVar: UI.COLOR_CHAIN_GNOSIS_BG, borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER, + accentColorVar: UI.COLOR_CHAIN_GNOSIS_ACCENT, }, [SupportedChainId.LENS]: { backgroundVar: UI.COLOR_CHAIN_LENS_BG, borderVar: UI.COLOR_CHAIN_LENS_BORDER, + accentColorVar: UI.COLOR_CHAIN_LENS_ACCENT, }, [SupportedChainId.SEPOLIA]: { backgroundVar: UI.COLOR_CHAIN_SEPOLIA_BG, borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER, + accentColorVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT, }, [SupportedChainId.LINEA]: { backgroundVar: UI.COLOR_CHAIN_LINEA_BG, borderVar: UI.COLOR_CHAIN_LINEA_BORDER, + accentColorVar: UI.COLOR_CHAIN_LINEA_ACCENT, }, [SupportedChainId.PLASMA]: { backgroundVar: UI.COLOR_CHAIN_PLASMA_BG, borderVar: UI.COLOR_CHAIN_PLASMA_BORDER, + accentColorVar: UI.COLOR_CHAIN_PLASMA_ACCENT, }, } @@ -168,7 +179,7 @@ function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButton {chain.label} {isActive && ( -