From a6909791dd0f03e91782c86e2e3a526ae6d46933 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 13 Nov 2025 18:09:45 +0000
Subject: [PATCH 001/100] feat(tokenselector): enhance VirtualList component
---
.../src/common/pure/VirtualList/index.tsx | 122 +++++++++---
apps/cowswap-frontend/src/locales/en-US.po | 27 +--
apps/cowswap-frontend/src/theme/consts.tsx | 1 +
libs/tokens/src/pure/TokenLogo/index.tsx | 174 +++++++++++-------
libs/ui/src/enum.ts | 35 ++++
libs/ui/src/pure/Input/index.tsx | 13 +-
libs/ui/src/pure/Popover/index.tsx | 166 ++++++++++++-----
libs/ui/src/theme/ThemeColorVars.tsx | 149 +++++++++++++++
8 files changed, 537 insertions(+), 150 deletions(-)
diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
index a5f10673af8..2134dc41297 100644
--- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
@@ -1,4 +1,4 @@
-import { ReactNode, useCallback, useRef } from 'react'
+import { ReactNode, useCallback, useLayoutEffect, useRef } from 'react'
import { useVirtualizer, VirtualItem } from '@tanstack/react-virtual'
import ms from 'ms.macro'
@@ -7,15 +7,72 @@ import { ListInner, ListScroller, ListWrapper, LoadingRows } from './styled'
const scrollDelay = ms`400ms`
-// TODO: Add proper return type annotation
-// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-const threeDivs = () => (
- <>
-
-
-
- >
-)
+const LoadingPlaceholder: () => ReactNode = () => {
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+interface VirtualListRowProps {
+ item: VirtualItem
+ loading?: boolean
+ items: T[]
+ getItemView(items: T[], item: VirtualItem): ReactNode
+ measureElement(element: Element | null): void
+}
+
+function VirtualListRow({ item, loading, items, getItemView, measureElement }: VirtualListRowProps): ReactNode {
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {getItemView(items, item)}
+
+ )
+}
+
+interface VirtualListRowsProps {
+ virtualItems: VirtualItem[]
+ loading?: boolean
+ items: T[]
+ getItemView(items: T[], item: VirtualItem): ReactNode
+ measureElement(element: Element | null): void
+}
+
+function renderVirtualListRows({
+ virtualItems,
+ loading,
+ items,
+ getItemView,
+ measureElement,
+}: VirtualListRowsProps): ReactNode[] {
+ const elements: ReactNode[] = []
+
+ for (const item of virtualItems) {
+ elements.push(
+ ,
+ )
+ }
+
+ return elements
+}
interface VirtualListProps {
id?: string
@@ -26,10 +83,9 @@ interface VirtualListProps {
loading?: boolean
estimateSize?: () => number
children?: ReactNode
+ scrollResetKey?: string | number | boolean
}
-// TODO: Add proper return type annotation
-// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function VirtualList({
id,
items,
@@ -37,7 +93,8 @@ export function VirtualList({
getItemView,
children,
estimateSize = () => 56,
-}: VirtualListProps) {
+ scrollResetKey,
+}: VirtualListProps): ReactNode {
const parentRef = useRef(null)
const wrapperRef = useRef(null)
const scrollTimeoutRef = useRef(undefined)
@@ -53,6 +110,7 @@ export function VirtualList({
}, scrollDelay)
}, [])
+ // eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
getScrollElement: () => parentRef.current,
count: items.length,
@@ -60,24 +118,40 @@ export function VirtualList({
overscan: 5,
})
+ useLayoutEffect(() => {
+ if (scrollResetKey === undefined) {
+ return
+ }
+
+ const scrollContainer = parentRef.current
+
+ if (scrollContainer) {
+ scrollContainer.scrollTop = 0
+ scrollContainer.scrollLeft = 0
+
+ if (typeof scrollContainer.scrollTo === 'function') {
+ scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' })
+ }
+ }
+
+ virtualizer.scrollToOffset(0, { align: 'start' })
+ }, [scrollResetKey, virtualizer])
+
const virtualItems = virtualizer.getVirtualItems()
+ const virtualRows = renderVirtualListRows({
+ virtualItems,
+ loading,
+ items,
+ getItemView,
+ measureElement: virtualizer.measureElement,
+ })
return (
{children}
- {virtualItems.map((item) => {
- if (loading) {
- return {threeDivs()}
- }
-
- return (
-
- {getItemView(items, item)}
-
- )
- })}
+ {virtualRows}
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index 85dd692ccd6..ea987f8f0b2 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -455,7 +455,6 @@ msgid "View details"
msgstr "View details"
#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
-#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "More"
msgstr "More"
@@ -827,8 +826,8 @@ msgid "Copied"
msgstr "Copied"
#: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
-msgid "Can't find your token on the list?"
-msgstr "Can't find your token on the list?"
+#~ msgid "Can't find your token on the list?"
+#~ msgstr "Can't find your token on the list?"
#: apps/cowswap-frontend/src/modules/trade/pure/ReceiveAmountTitle/index.tsx
msgid "icon"
@@ -847,8 +846,8 @@ msgid "Please connect your wallet to one of our supported networks."
msgstr "Please connect your wallet to one of our supported networks."
#: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
-msgid "<0>Read our guide0> on how to add custom tokens."
-msgstr "<0>Read our guide0> on how to add custom tokens."
+#~ msgid "<0>Read our guide0> on how to add custom tokens."
+#~ msgstr "<0>Read our guide0> on how to add custom tokens."
#: apps/cowswap-frontend/src/modules/hooksStore/containers/TenderlySimulate/index.tsx
msgid "Retry"
@@ -1220,8 +1219,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
-msgid "Manage Token Lists"
-msgstr "Manage Token Lists"
+#~ msgid "Manage Token Lists"
+#~ msgstr "Manage Token Lists"
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
msgid "No results found"
@@ -3156,7 +3155,6 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e
msgid "Aave Debt Swap Flashloan"
msgstr "Aave Debt Swap Flashloan"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
#: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx
msgid "Details"
msgstr "Details"
@@ -3812,6 +3810,10 @@ msgstr "User rejected approval transaction"
msgid "Swap on"
msgstr "Swap on"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
+msgid "Can't find your token on the list? <0>Read our guide0> on how to add custom tokens."
+msgstr "Can't find your token on the list? <0>Read our guide0> on how to add custom tokens."
+
#: apps/cowswap-frontend/src/modules/account/containers/Transaction/ActivityDetails.tsx
#: apps/cowswap-frontend/src/modules/orderProgressBar/pure/TransactionSubmittedContent/index.tsx
msgid "Transaction"
@@ -4322,7 +4324,6 @@ msgstr "Decrease Value"
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
#: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
-#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
msgid "Balance"
msgstr "Balance"
@@ -4385,8 +4386,8 @@ msgid "funds"
msgstr "funds"
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
-msgid "Pool details"
-msgstr "Pool details"
+#~ msgid "Pool details"
+#~ msgstr "Pool details"
#: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx
msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution"
@@ -5894,8 +5895,8 @@ msgid "You sold <0/>"
msgstr "You sold <0/>"
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
-msgid "Less"
-msgstr "Less"
+#~ msgid "Less"
+#~ msgstr "Less"
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
msgid "Claim your LlamaPay vesting contract funds"
diff --git a/apps/cowswap-frontend/src/theme/consts.tsx b/apps/cowswap-frontend/src/theme/consts.tsx
index 5230490c10b..8a008740d70 100644
--- a/apps/cowswap-frontend/src/theme/consts.tsx
+++ b/apps/cowswap-frontend/src/theme/consts.tsx
@@ -25,6 +25,7 @@ export const WIDGET_MAX_WIDTH = {
limit: '1350px',
content: '680px',
tokenSelect: '590px',
+ tokenSelectSidebar: '700px',
}
export const TextWrapper = styled(Text)<{ color: keyof Colors; override?: boolean }>`
diff --git a/libs/tokens/src/pure/TokenLogo/index.tsx b/libs/tokens/src/pure/TokenLogo/index.tsx
index 7f49b5e345e..6d2c67f048e 100644
--- a/libs/tokens/src/pure/TokenLogo/index.tsx
+++ b/libs/tokens/src/pure/TokenLogo/index.tsx
@@ -1,5 +1,5 @@
import { atom, useAtom } from 'jotai'
-import { useCallback, useMemo } from 'react'
+import { ReactNode, useCallback, useMemo } from 'react'
import {
BaseChainInfo,
@@ -41,11 +41,19 @@ export interface TokenLogoProps {
hideNetworkBadge?: boolean
}
-// TODO: Break down this large function into smaller functions
-// TODO: Add proper return type annotation
-// TODO: Reduce function complexity by extracting logic
-// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, complexity, max-lines-per-function
-export function TokenLogo({
+export function TokenLogo(props: TokenLogoProps): ReactNode {
+ const { token } = props
+
+ if (token instanceof LpToken) {
+ return
+ }
+
+ return
+}
+
+type StandardTokenLogoProps = TokenLogoProps & { token?: TokenWithLogo | Currency | null }
+
+function StandardTokenLogo({
logoURI,
token,
className,
@@ -53,31 +61,10 @@ export function TokenLogo({
sizeMobile,
noWrap,
hideNetworkBadge,
-}: TokenLogoProps) {
- const tokensByAddress = useTokensByAddressMap()
-
+}: StandardTokenLogoProps): ReactNode {
const [invalidUrls, setInvalidUrls] = useAtom(invalidUrlsAtom)
- const isLpToken = token instanceof LpToken
-
- const urls = useMemo(() => {
- if (token instanceof LpToken) return
-
- // TODO: get rid of Currency usage and remove type casting
- if (token) {
- if (token instanceof NativeCurrency) {
- return [cowprotocolTokenLogoUrl(NATIVE_CURRENCY_ADDRESS.toLowerCase(), token.chainId as SupportedChainId)]
- }
-
- return getTokenLogoUrls(token as TokenWithLogo)
- }
-
- return logoURI ? uriToHttp(logoURI) : []
- }, [logoURI, token])
-
- const validUrls = useMemo(() => urls && urls.filter((url) => !invalidUrls[url]), [urls, invalidUrls])
-
- const currentUrl = validUrls?.[0]
+ const { currentUrl, initial } = useTokenLogoUrl({ token, logoURI, invalidUrls })
const logoUrl = useNetworkLogo(token?.chainId)
const showNetworkBadge = logoUrl && !hideNetworkBadge
@@ -88,40 +75,7 @@ export function TokenLogo({
setInvalidUrls((state) => ({ ...state, [currentUrl]: true }))
}, [currentUrl, setInvalidUrls])
- const initial = token?.symbol?.[0] || token?.name?.[0]
-
- if (isLpToken) {
- return (
-
-
-
-
-
-
-
-
-
-
- )
- }
-
- const actualTokenContent = currentUrl ? (
-
-
-
- ) : initial ? (
-
-
-
- ) : (
-
-
-
- )
+ const actualTokenContent = renderTokenLogoContent({ currentUrl, onError, token, initial })
if (noWrap) {
return actualTokenContent
@@ -137,7 +91,12 @@ export function TokenLogo({
const cutThicknessForCalc = getBorderWidth(chainLogoSizeForCalc)
return (
-
+
<>
{showNetworkBadge ? (
)
}
+
+type LpTokenLogoProps = Omit & { token: LpToken }
+
+function LpTokenLogo({ token, className, size = 36, sizeMobile }: LpTokenLogoProps): ReactNode {
+ const tokensByAddress = useTokensByAddressMap()
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+interface TokenLogoUrlOptions {
+ token?: TokenWithLogo | Currency | null
+ logoURI?: string
+ invalidUrls: Record
+}
+
+function useTokenLogoUrl({ token, logoURI, invalidUrls }: TokenLogoUrlOptions): {
+ currentUrl?: string
+ initial?: string
+} {
+ const urls = useMemo(() => {
+ if (token instanceof LpToken) {
+ return []
+ }
+
+ if (token instanceof NativeCurrency) {
+ return [cowprotocolTokenLogoUrl(NATIVE_CURRENCY_ADDRESS.toLowerCase(), token.chainId as SupportedChainId)]
+ }
+
+ if (token) {
+ return getTokenLogoUrls(token as TokenWithLogo)
+ }
+
+ return logoURI ? uriToHttp(logoURI) : []
+ }, [logoURI, token])
+
+ const validUrls = useMemo(() => urls && urls.filter((url) => !invalidUrls[url]), [urls, invalidUrls])
+ const currentUrl = validUrls?.[0]
+ const initial = token?.symbol?.[0] || token?.name?.[0]
+
+ return { currentUrl, initial }
+}
+
+interface TokenLogoContentOptions {
+ currentUrl?: string
+ onError: () => void
+ token?: TokenWithLogo | Currency | null
+ initial?: string
+}
+
+function renderTokenLogoContent({ currentUrl, onError, token, initial }: TokenLogoContentOptions): ReactNode {
+ if (currentUrl) {
+ return (
+
+
+
+ )
+ }
+
+ if (initial) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/libs/ui/src/enum.ts b/libs/ui/src/enum.ts
index 7a135004366..6e7c2039cec 100644
--- a/libs/ui/src/enum.ts
+++ b/libs/ui/src/enum.ts
@@ -101,6 +101,41 @@ export enum UI {
COLOR_GREEN = '--cow-color-green',
COLOR_RED = '--cow-color-red',
+ // Chain-specific accent colors
+ COLOR_CHAIN_ETHEREUM_BG = '--cow-color-chain-ethereum-bg',
+ COLOR_CHAIN_ETHEREUM_BORDER = '--cow-color-chain-ethereum-border',
+ COLOR_CHAIN_ETHEREUM_ACCENT = '--cow-color-chain-ethereum-accent',
+ COLOR_CHAIN_BNB_BG = '--cow-color-chain-bnb-bg',
+ COLOR_CHAIN_BNB_BORDER = '--cow-color-chain-bnb-border',
+ COLOR_CHAIN_BNB_ACCENT = '--cow-color-chain-bnb-accent',
+ COLOR_CHAIN_BASE_BG = '--cow-color-chain-base-bg',
+ COLOR_CHAIN_BASE_BORDER = '--cow-color-chain-base-border',
+ COLOR_CHAIN_BASE_ACCENT = '--cow-color-chain-base-accent',
+ COLOR_CHAIN_ARBITRUM_BG = '--cow-color-chain-arbitrum-bg',
+ COLOR_CHAIN_ARBITRUM_BORDER = '--cow-color-chain-arbitrum-border',
+ COLOR_CHAIN_ARBITRUM_ACCENT = '--cow-color-chain-arbitrum-accent',
+ COLOR_CHAIN_POLYGON_BG = '--cow-color-chain-polygon-bg',
+ COLOR_CHAIN_POLYGON_BORDER = '--cow-color-chain-polygon-border',
+ COLOR_CHAIN_POLYGON_ACCENT = '--cow-color-chain-polygon-accent',
+ COLOR_CHAIN_AVALANCHE_BG = '--cow-color-chain-avalanche-bg',
+ COLOR_CHAIN_AVALANCHE_BORDER = '--cow-color-chain-avalanche-border',
+ COLOR_CHAIN_AVALANCHE_ACCENT = '--cow-color-chain-avalanche-accent',
+ COLOR_CHAIN_GNOSIS_BG = '--cow-color-chain-gnosis-bg',
+ COLOR_CHAIN_GNOSIS_BORDER = '--cow-color-chain-gnosis-border',
+ COLOR_CHAIN_GNOSIS_ACCENT = '--cow-color-chain-gnosis-accent',
+ COLOR_CHAIN_LENS_BG = '--cow-color-chain-lens-bg',
+ COLOR_CHAIN_LENS_BORDER = '--cow-color-chain-lens-border',
+ COLOR_CHAIN_LENS_ACCENT = '--cow-color-chain-lens-accent',
+ COLOR_CHAIN_SEPOLIA_BG = '--cow-color-chain-sepolia-bg',
+ COLOR_CHAIN_SEPOLIA_BORDER = '--cow-color-chain-sepolia-border',
+ COLOR_CHAIN_SEPOLIA_ACCENT = '--cow-color-chain-sepolia-accent',
+ COLOR_CHAIN_LINEA_BG = '--cow-color-chain-linea-bg',
+ COLOR_CHAIN_LINEA_BORDER = '--cow-color-chain-linea-border',
+ COLOR_CHAIN_LINEA_ACCENT = '--cow-color-chain-linea-accent',
+ COLOR_CHAIN_PLASMA_BG = '--cow-color-chain-plasma-bg',
+ COLOR_CHAIN_PLASMA_BORDER = '--cow-color-chain-plasma-border',
+ COLOR_CHAIN_PLASMA_ACCENT = '--cow-color-chain-plasma-accent',
+
// Neutral colors - Base grayscale palette from black (0) to white (100)
COLOR_WHITE = '--cow-color-neutral-100',
COLOR_NEUTRAL_100 = '--cow-color-neutral-100',
diff --git a/libs/ui/src/pure/Input/index.tsx b/libs/ui/src/pure/Input/index.tsx
index 4503352d715..c1a5dfb8b35 100644
--- a/libs/ui/src/pure/Input/index.tsx
+++ b/libs/ui/src/pure/Input/index.tsx
@@ -1,4 +1,4 @@
-import { InputHTMLAttributes } from 'react'
+import { InputHTMLAttributes, ReactNode } from 'react'
import { Search } from 'react-feather'
import styled from 'styled-components/macro'
@@ -31,15 +31,18 @@ const SearchInputEl = styled.input`
border-radius: 12px;
border: none;
- ::placeholder {
+ &::placeholder {
color: inherit;
opacity: 0.7;
+ transition: color 0.1s ease-in-out;
+ }
+
+ &:focus::placeholder {
+ color: transparent;
}
`
-// TODO: Add proper return type annotation
-// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-export function SearchInput(props: InputHTMLAttributes) {
+export function SearchInput(props: InputHTMLAttributes): ReactNode {
return (
diff --git a/libs/ui/src/pure/Popover/index.tsx b/libs/ui/src/pure/Popover/index.tsx
index ca0078e5e15..9e45b4c42b5 100644
--- a/libs/ui/src/pure/Popover/index.tsx
+++ b/libs/ui/src/pure/Popover/index.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useMemo, useState } from 'react'
+import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { useMediaQuery, useInterval, useElementViewportTracking } from '@cowprotocol/common-hooks'
@@ -24,7 +24,9 @@ const MOBILE_FULL_WIDTH_STYLES = {
boxSizing: 'border-box' as const,
}
-function createMobileModifiers(arrowElement: HTMLDivElement | null): Array>>> {
+function createMobileModifiers(
+ arrowElement: HTMLDivElement | null,
+): Array>>> {
return [
{
name: 'offset',
@@ -44,7 +46,9 @@ function createMobileModifiers(arrowElement: HTMLDivElement | null): Array>>> {
+function createDesktopModifiers(
+ arrowElement: HTMLDivElement | null,
+): Array>>> {
return [
{ name: 'offset', options: { offset: [8, 8] } },
{ name: 'arrow', options: { element: arrowElement } },
@@ -52,7 +56,6 @@ function createDesktopModifiers(arrowElement: HTMLDivElement | null): Array(() => show || forceMount)
+
+ useEffect(() => {
+ if ((show || forceMount) && !hasMountedPortal) {
+ setHasMountedPortal(true)
+ }
+ }, [show, forceMount, hasMountedPortal])
+
+ return forceMount || show || hasMountedPortal
}
export default function Popover(props: PopoverProps): React.JSX.Element {
@@ -84,75 +104,133 @@ export default function Popover(props: PopoverProps): React.JSX.Element {
showMobileBackdrop = false,
mobileBorderRadius,
zIndex = 999999,
+ forceMount = false,
} = props
const [referenceElement, setReferenceElement] = useState(null)
const [popperElement, setPopperElement] = useState(null)
const [arrowElement, setArrowElement] = useState(null)
-
const isMobile = useMediaQuery(Media.upToSmall(false))
const shouldUseFullWidth = isMobile && mobileMode === PopoverMobileMode.FullWidth
-
- // Use hook for viewport tracking and utility for backdrop height calculation
const { rect } = useElementViewportTracking(referenceElement, shouldUseFullWidth && showMobileBackdrop)
-
const backdropHeight = useMemo(() => {
if (!shouldUseFullWidth || !showMobileBackdrop) return '100vh'
return calculateAvailableSpaceAbove(rect, 8)
}, [rect, shouldUseFullWidth, showMobileBackdrop])
-
const options = useMemo(
(): Options => ({
placement: shouldUseFullWidth ? 'top' : placement,
strategy: 'fixed',
- modifiers: shouldUseFullWidth
- ? createMobileModifiers(arrowElement)
- : createDesktopModifiers(arrowElement),
+ modifiers: shouldUseFullWidth ? createMobileModifiers(arrowElement) : createDesktopModifiers(arrowElement),
}),
[arrowElement, placement, shouldUseFullWidth],
)
-
const { styles, update, attributes } = usePopper(referenceElement, popperElement, options)
-
const updateCallback = useCallback(() => {
update?.()
}, [update])
const intervalDelay = useMemo(() => (show ? 100 : null), [show])
useInterval(updateCallback, intervalDelay)
-
+ const shouldRenderPortal = useLazyPortalMount(show, forceMount)
+ const popperStyle = {
+ ...styles.popper,
+ zIndex,
+ ...(shouldUseFullWidth && MOBILE_FULL_WIDTH_STYLES),
+ ...(shouldUseFullWidth && mobileBorderRadius && { borderRadius: mobileBorderRadius }),
+ }
+ const arrowPlacement = (attributes.popper?.['data-popper-placement'] as string | undefined)?.split('-')[0] ?? ''
return (
<>
{children}
-
- {isMobile && showMobileBackdrop && }
-
+ >
+ )
+}
+
+interface PopoverPortalProps {
+ shouldRender: boolean
+ show: boolean
+ isMobile: boolean
+ showMobileBackdrop: boolean
+ backdropHeight: string
+ className?: string
+ setPopperElement(value: HTMLDivElement | null): void
+ popperStyle: React.CSSProperties
+ popperAttributes: ReturnType['attributes']['popper']
+ bgColor?: string
+ color?: string
+ borderColor?: string
+ content: React.ReactNode
+ setArrowElement(value: HTMLDivElement | null): void
+ arrowStyle: React.CSSProperties
+ arrowAttributes: ReturnType['attributes']['arrow']
+ arrowPlacement: string
+}
+
+function PopoverPortal({
+ shouldRender,
+ show,
+ isMobile,
+ showMobileBackdrop,
+ backdropHeight,
+ className,
+ setPopperElement,
+ popperStyle,
+ popperAttributes,
+ bgColor,
+ color,
+ borderColor,
+ content,
+ setArrowElement,
+ arrowStyle,
+ arrowAttributes,
+ arrowPlacement,
+}: PopoverPortalProps): React.ReactNode {
+ if (!shouldRender) {
+ return null
+ }
+
+ return (
+
+ {isMobile && showMobileBackdrop && }
+
+ {content}
+
- {content}
-
-
-
- >
+ {...arrowAttributes}
+ />
+
+
)
}
diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx
index b5b4872b065..f39c641a938 100644
--- a/libs/ui/src/theme/ThemeColorVars.tsx
+++ b/libs/ui/src/theme/ThemeColorVars.tsx
@@ -5,6 +5,153 @@ import { css } from 'styled-components/macro'
import { UI } from '../enum'
+interface ChainAccentConfig {
+ bgVar: UI
+ borderVar: UI
+ accentVar?: UI
+ lightBg: string
+ darkBg: string
+ lightBorder: string
+ darkBorder: string
+ lightColor: string
+ darkColor: string
+}
+
+interface ChainAccentInput {
+ bgVar: UI
+ borderVar: UI
+ accentVar?: UI
+ color: string
+ lightColor?: string
+ darkColor?: string
+ lightBgAlpha?: number
+ darkBgAlpha?: number
+ lightBorderAlpha?: number
+ darkBorderAlpha?: number
+}
+
+const CHAIN_LIGHT_BG_ALPHA = 0.22
+const CHAIN_DARK_BG_ALPHA = 0.32
+const CHAIN_LIGHT_BORDER_ALPHA = 0.45
+const CHAIN_DARK_BORDER_ALPHA = 0.65
+
+const chainAlpha = (color: string, alpha: number): string => transparentize(color, 1 - alpha)
+
+function createChainAccent({
+ bgVar,
+ borderVar,
+ accentVar,
+ color,
+ lightColor = color,
+ darkColor = color,
+ lightBgAlpha = CHAIN_LIGHT_BG_ALPHA,
+ darkBgAlpha = CHAIN_DARK_BG_ALPHA,
+ lightBorderAlpha = CHAIN_LIGHT_BORDER_ALPHA,
+ darkBorderAlpha = CHAIN_DARK_BORDER_ALPHA,
+}: ChainAccentInput): ChainAccentConfig {
+ return {
+ bgVar,
+ borderVar,
+ accentVar,
+ lightBg: chainAlpha(lightColor, lightBgAlpha),
+ darkBg: chainAlpha(darkColor, darkBgAlpha),
+ lightBorder: chainAlpha(lightColor, lightBorderAlpha),
+ darkBorder: chainAlpha(darkColor, darkBorderAlpha),
+ lightColor,
+ darkColor,
+ }
+}
+
+const CHAIN_ACCENT_CONFIG: ChainAccentConfig[] = [
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_ETHEREUM_BG,
+ borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER,
+ accentVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT,
+ color: '#627EEA',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_BNB_BG,
+ borderVar: UI.COLOR_CHAIN_BNB_BORDER,
+ accentVar: UI.COLOR_CHAIN_BNB_ACCENT,
+ color: '#F0B90B',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_BASE_BG,
+ borderVar: UI.COLOR_CHAIN_BASE_BORDER,
+ accentVar: UI.COLOR_CHAIN_BASE_ACCENT,
+ color: '#0052FF',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_ARBITRUM_BG,
+ borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER,
+ accentVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT,
+ color: '#1B4ADD',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_POLYGON_BG,
+ borderVar: UI.COLOR_CHAIN_POLYGON_BORDER,
+ accentVar: UI.COLOR_CHAIN_POLYGON_ACCENT,
+ color: '#8247E5',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_AVALANCHE_BG,
+ borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER,
+ accentVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT,
+ color: '#FF3944',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_GNOSIS_BG,
+ borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER,
+ accentVar: UI.COLOR_CHAIN_GNOSIS_ACCENT,
+ color: '#07795B',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_LENS_BG,
+ borderVar: UI.COLOR_CHAIN_LENS_BORDER,
+ accentVar: UI.COLOR_CHAIN_LENS_ACCENT,
+ color: '#5A5A5A',
+ darkColor: '#D7D7D7',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_SEPOLIA_BG,
+ borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER,
+ accentVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT,
+ color: '#C12FF2',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_LINEA_BG,
+ borderVar: UI.COLOR_CHAIN_LINEA_BORDER,
+ accentVar: UI.COLOR_CHAIN_LINEA_ACCENT,
+ color: '#61DFFF',
+ }),
+ createChainAccent({
+ bgVar: UI.COLOR_CHAIN_PLASMA_BG,
+ borderVar: UI.COLOR_CHAIN_PLASMA_BORDER,
+ accentVar: UI.COLOR_CHAIN_PLASMA_ACCENT,
+ color: '#569F8C',
+ }),
+]
+
+const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG.map(({
+ bgVar,
+ borderVar,
+ accentVar,
+ lightBg,
+ darkBg,
+ lightBorder,
+ darkBorder,
+ lightColor,
+ darkColor,
+}) => css`
+ ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)};
+ ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)};
+ ${accentVar
+ ? css`
+ ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)};
+ `
+ : ''}
+`)
+
export const ThemeColorVars = css`
:root {
// V3
@@ -83,6 +230,8 @@ export const ThemeColorVars = css`
${UI.COLOR_ALERT_TEXT_DARKER}: ${({ theme }) =>
getContrastText(theme.alert, theme.darkMode ? darken(theme.alert, 0.55) : darken(theme.alert, 0.35))};
+ ${CHAIN_ACCENT_VAR_DECLARATIONS}
+
${UI.COLOR_WARNING}: ${({ theme }) => theme.warning};
${UI.COLOR_WARNING_BG}: ${({ theme }) => transparentize(theme.warning, 0.85)};
${UI.COLOR_WARNING_TEXT}: ${({ theme }) =>
From 77f44af16fb98cf734619256f20434b45bee60cd Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 13 Nov 2025 19:22:45 +0000
Subject: [PATCH 002/100] feat(tokenselector): add useDeferredVisibility hook
for optimized rendering
---
.../tokensList/hooks/useDeferredVisibility.ts | 68 ++++++++++++++++
.../tokensList/pure/TokenInfo/index.tsx | 5 +-
.../tokensList/pure/TokenListItem/index.tsx | 78 ++++++++++++++-----
3 files changed, 131 insertions(+), 20 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts
new file mode 100644
index 00000000000..34e330b60fe
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts
@@ -0,0 +1,68 @@
+import { useCallback, useEffect, useState } from 'react'
+
+interface DeferredVisibilityOptions {
+ /**
+ * Expands the observed viewport so we hydrate content slightly before it
+ * scrolls into view.
+ */
+ rootMargin?: string
+ /**
+ * When this key changes we reset the visibility state. Helpful when the same
+ * virtualized row instance renders different data.
+ */
+ resetKey?: string | number
+}
+
+interface DeferredVisibilityResult {
+ ref: (element: T | null) => void
+ isVisible: boolean
+}
+
+const DEFAULT_ROOT_MARGIN = '120px'
+
+// Lightweight helper to delay hydration of expensive UI until the row is close to the viewport.
+export function useDeferredVisibility(
+ options: DeferredVisibilityOptions = {},
+): DeferredVisibilityResult {
+ const { rootMargin = DEFAULT_ROOT_MARGIN, resetKey } = options
+ const [element, setElement] = useState(null)
+ const [isVisible, setIsVisible] = useState(false)
+
+ useEffect(() => {
+ if (resetKey === undefined) {
+ return
+ }
+
+ setIsVisible(false)
+ }, [resetKey])
+
+ useEffect(() => {
+ if (isVisible || !element) {
+ return undefined
+ }
+
+ if (typeof IntersectionObserver === 'undefined') {
+ setIsVisible(true)
+ return undefined
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setIsVisible(true)
+ }
+ },
+ { rootMargin },
+ )
+
+ observer.observe(element)
+
+ return () => observer.disconnect()
+ }, [element, isVisible, rootMargin])
+
+ const ref = useCallback((node: T | null) => {
+ setElement(node)
+ }, [])
+
+ return { ref, isVisible }
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx
index 95d0f4ad77f..8129de48de6 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx
@@ -12,10 +12,11 @@ export interface TokenInfoProps {
token: TokenWithLogo
className?: string
tags?: ReactNode
+ showAddress?: boolean
}
export function TokenInfo(props: TokenInfoProps): ReactNode {
- const { token, className, tags } = props
+ const { token, className, tags, showAddress = true } = props
return (
@@ -23,7 +24,7 @@ export function TokenInfo(props: TokenInfoProps): ReactNode {
-
+ {showAddress ? : null}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
index 5594741567b..9e141417f3e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
@@ -12,6 +12,7 @@ import { Nullish } from 'types'
import * as styledEl from './styled'
+import { useDeferredVisibility } from '../../hooks/useDeferredVisibility'
import { TokenInfo } from '../TokenInfo'
import { TokenTags } from '../TokenTags'
@@ -58,6 +59,13 @@ export function TokenListItem(props: TokenListItemProps): ReactNode {
className,
} = props
+ const tokenKey = `${token.chainId}:${token.address.toLowerCase()}`
+ // Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport.
+ const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility({
+ resetKey: tokenKey,
+ rootMargin: '200px',
+ })
+
const handleClick: MouseEventHandler = (e) => {
if (isTokenSelected) {
e.preventDefault()
@@ -74,11 +82,15 @@ export function TokenListItem(props: TokenListItemProps): ReactNode {
)
const isSupportedChain = token.chainId in SupportedChainId
-
- const balanceAmount = balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined
+ const shouldShowBalances = isWalletConnected && isSupportedChain
+ // Formatting balances (BigNumber -> CurrencyAmount -> Fiat) is expensive; delay until the row is visible.
+ const shouldFormatBalances = shouldShowBalances && hasIntersected
+ const balanceAmount =
+ shouldFormatBalances && balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined
return (
+ hasIntersected ? (
+
+ ) : null
}
/>
- {isWalletConnected && (
-
- {isSupportedChain ? (
- <>
- {balanceAmount ? : LoadingElement}
- {usdAmount ? : null}
- >
- ) : null}
-
- )}
+
{children}
)
}
+
+interface TokenBalanceColumnProps {
+ shouldShow: boolean
+ shouldFormat: boolean
+ balanceAmount?: CurrencyAmount
+ usdAmount?: CurrencyAmount | null
+}
+
+function TokenBalanceColumn({
+ shouldShow,
+ shouldFormat,
+ balanceAmount,
+ usdAmount,
+}: TokenBalanceColumnProps): ReactNode {
+ if (!shouldShow) {
+ return null
+ }
+
+ return (
+
+ {shouldFormat ? (
+ <>
+ {balanceAmount ? : LoadingElement}
+ {usdAmount ? : null}
+ >
+ ) : (
+ LoadingElement
+ )}
+
+ )
+}
From 272d2c9fef1741119de8fd68cc2ead4be8173f76 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 13 Nov 2025 19:30:33 +0000
Subject: [PATCH 003/100] refactor(tokenselector): streamline token search
results and enhance rendering logic
---
.../containers/TokenSearchResults/index.tsx | 21 --
.../containers/TokenSearchResults/styled.ts | 8 +-
.../tokensList/pure/ImportTokenItem/index.tsx | 13 +-
.../tokensList/pure/ImportTokenItem/styled.ts | 11 +-
.../pure/TokenSearchContent/index.tsx | 277 ++++++++++++++----
5 files changed, 233 insertions(+), 97 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 4ca54eced20..4bb84a16f8d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
@@ -3,15 +3,6 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils'
import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens'
-import {
- BannerOrientation,
- ExternalLink,
- InlineBanner,
- LINK_GUIDE_ADD_CUSTOM_TOKEN,
- StatusColorVariant,
-} from '@cowprotocol/ui'
-
-import { Trans } from '@lingui/react/macro'
import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
@@ -77,18 +68,6 @@ export function TokenSearchResults({
return (
-
-
- Can't find your token on the list?{' '}
- Read our guide on how to add custom tokens.
-
-
-
+
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts
index 068ff64547d..d528acc9465 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ImportTokenItem/styled.ts
@@ -2,20 +2,15 @@ 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;
- }
-
- &:last-child {
- margin-bottom: 0;
+ padding: ${({ $isFirst, $isLast }) => `${$isFirst ? '20px' : '0'} 14px ${$isLast ? '0' : '20px'} 14px`};
}
`
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 5330d9c2778..a238ab18d09 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
@@ -1,12 +1,22 @@
-import { ReactNode, useMemo } from 'react'
+import { ReactNode, useCallback, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils'
import { TokenSearchResponse } from '@cowprotocol/tokens'
-import { Loader } from '@cowprotocol/ui'
+import {
+ BannerOrientation,
+ ExternalLink,
+ InlineBanner,
+ LINK_GUIDE_ADD_CUSTOM_TOKEN,
+ Loader,
+ StatusColorVariant,
+} from '@cowprotocol/ui'
import { t } from '@lingui/core/macro'
import { Trans } from '@lingui/react/macro'
+import { VirtualItem } from '@tanstack/react-virtual'
+
+import { VirtualList } from 'common/pure/VirtualList'
import * as styledEl from '../../containers/TokenSearchResults/styled'
import { SelectTokenContext } from '../../types'
@@ -23,7 +33,6 @@ interface TokenSearchContentProps {
importToken: (tokenToImport: TokenWithLogo) => void
}
-// TODO: Add proper return type annotation
export function TokenSearchContent({
searchInput,
searchResults,
@@ -47,8 +56,6 @@ export function TokenSearchContent({
for (const t of activeListsResult) {
if (doesTokenMatchSymbolOrAddress(t, searchInput)) {
- // There should ever be only 1 token with a given address
- // There can be multiple with the same symbol
matched.push(t)
} else {
remaining.push(t)
@@ -58,59 +65,211 @@ export function TokenSearchContent({
return [matched, remaining]
}, [activeListsResult, searchInput])
- return isLoading ? (
-
-
-
- ) : isTokenNotFound ? (
-
- No tokens found
-
- ) : (
- <>
- {/*Matched tokens first, followed by tokens from active lists*/}
- {matchedTokens.concat(activeList).map((token) => {
- return
- })}
-
- {/*Tokens from blockchain*/}
- {blockchainResult?.length ? (
-
- {blockchainResult.slice(0, SEARCH_RESULTS_LIMIT).map((token) => {
- return
- })}
-
- ) : null}
-
- {/*Tokens from inactive lists*/}
- {inactiveListsResult?.length ? (
-
-
- {t`Expanded results from inactive Token Lists`}
-
-
- {inactiveListsResult.slice(0, SEARCH_RESULTS_LIMIT).map((token) => {
- return
- })}
-
-
- ) : null}
-
- {/*Tokens from external sources*/}
- {externalApiResult?.length ? (
-
-
- {t`Additional Results from External Sources`}
-
-
- {externalApiResult.map((token) => {
- return
- })}
-
-
- ) : null}
- >
+ const rows = useSearchRows({
+ isLoading,
+ matchedTokens,
+ activeList,
+ blockchainResult,
+ inactiveListsResult,
+ externalApiResult,
+ })
+
+ const renderRow = useCallback(
+ (items: TokenSearchRow[], virtualItem: VirtualItem) => (
+
+ ),
+ [importToken, selectTokenContext],
+ )
+
+ if (isLoading)
+ return (
+
+
+
+ )
+
+ if (isTokenNotFound)
+ return (
+
+ No tokens found
+
+ )
+
+ return
+}
+
+type TokenImportSection = 'blockchain' | 'inactive' | 'external'
+
+type TokenSearchRow =
+ | { type: 'banner' }
+ | { type: 'token'; token: TokenWithLogo }
+ | { type: 'section-title'; text: string; tooltip?: string }
+ | {
+ type: 'import-token'
+ token: TokenWithLogo
+ shadowed?: boolean
+ section: TokenImportSection
+ isFirstInSection: boolean
+ isLastInSection: boolean
+ wrapperId?: string
+ }
+
+interface UseSearchRowsParams {
+ isLoading: boolean
+ matchedTokens: TokenWithLogo[]
+ activeList: TokenWithLogo[]
+ blockchainResult?: TokenWithLogo[]
+ inactiveListsResult?: TokenWithLogo[]
+ externalApiResult?: TokenWithLogo[]
+}
+
+function useSearchRows({
+ isLoading,
+ matchedTokens,
+ activeList,
+ blockchainResult,
+ inactiveListsResult,
+ externalApiResult,
+}: UseSearchRowsParams): TokenSearchRow[] {
+ return useMemo(() => {
+ const entries: TokenSearchRow[] = []
+
+ if (isLoading) {
+ return entries
+ }
+
+ entries.push({ type: 'banner' })
+
+ for (const token of matchedTokens) {
+ entries.push({ type: 'token', token })
+ }
+
+ for (const token of activeList) {
+ entries.push({ type: 'token', token })
+ }
+
+ appendImportSection(entries, {
+ tokens: blockchainResult,
+ section: 'blockchain',
+ limit: SEARCH_RESULTS_LIMIT,
+ sectionTitle: undefined,
+ tooltip: undefined,
+ shadowed: false,
+ wrapperId: 'currency-import',
+ })
+
+ appendImportSection(entries, {
+ tokens: inactiveListsResult,
+ section: 'inactive',
+ limit: SEARCH_RESULTS_LIMIT,
+ sectionTitle: t`Expanded results from inactive Token Lists`,
+ tooltip: t`Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists.`,
+ shadowed: true,
+ })
+
+ appendImportSection(entries, {
+ tokens: externalApiResult,
+ section: 'external',
+ limit: SEARCH_RESULTS_LIMIT,
+ sectionTitle: t`Additional Results from External Sources`,
+ tooltip: t`Tokens from external sources.`,
+ shadowed: true,
+ })
+
+ return entries
+ }, [isLoading, matchedTokens, activeList, blockchainResult, inactiveListsResult, externalApiResult])
+}
+
+interface AppendImportSectionParams {
+ tokens?: TokenWithLogo[]
+ section: TokenImportSection
+ limit: number
+ sectionTitle?: string
+ tooltip?: string
+ shadowed?: boolean
+ wrapperId?: string
+}
+
+function appendImportSection(rows: TokenSearchRow[], params: AppendImportSectionParams): void {
+ const { tokens, section, limit, sectionTitle, tooltip, shadowed, wrapperId } = params
+
+ if (!tokens?.length) {
+ return
+ }
+
+ if (sectionTitle) {
+ rows.push({ type: 'section-title', text: sectionTitle, tooltip })
+ }
+
+ const limitedTokens = tokens.slice(0, limit)
+
+ limitedTokens.forEach((token, index) => {
+ rows.push({
+ type: 'import-token',
+ token,
+ section,
+ shadowed,
+ isFirstInSection: index === 0,
+ isLastInSection: index === limitedTokens.length - 1,
+ wrapperId: index === 0 ? wrapperId : undefined,
+ })
+ })
+}
+
+interface TokenSearchRowRendererProps {
+ row: TokenSearchRow
+ selectTokenContext: SelectTokenContext
+ importToken(token: TokenWithLogo): void
+}
+
+function TokenSearchRowRenderer({ row, selectTokenContext, importToken }: TokenSearchRowRendererProps): ReactNode {
+ switch (row.type) {
+ case 'banner':
+ return
+ case 'token':
+ return
+ case 'section-title': {
+ const tooltip = row.tooltip ?? ''
+ return (
+
+ {row.text}
+
+ )
+ }
+ case 'import-token':
+ return (
+
+ )
+ default:
+ return null
+ }
+}
+
+function GuideBanner(): ReactNode {
+ return (
+
+
+
+ Can't find your token on the list? Read our guide{' '}
+ on how to add custom tokens.
+
+
+
)
}
From ec63bfadfbcc3fcf22073c042316d5b29f0d3435 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 14 Nov 2025 09:29:33 +0000
Subject: [PATCH 004/100] feat(tokenselector): implement recent tokens feature
and enhance token selection context
---
apps/cowswap-frontend/src/locales/en-US.po | 15 +-
.../containers/SelectTokenWidget/index.tsx | 27 ++-
.../containers/TokenSearchResults/index.tsx | 11 +-
.../tokensList/hooks/recentTokensStorage.ts | 218 ++++++++++++++++++
.../tokensList/hooks/useRecentTokens.ts | 136 +++++++++++
.../pure/FavoriteTokensList/index.tsx | 106 +++++----
.../pure/FavoriteTokensList/styled.ts | 47 ++--
.../pure/SelectTokenModal/index.cosmos.tsx | 8 +
.../pure/SelectTokenModal/index.tsx | 21 +-
.../pure/SelectTokenModal/styled.ts | 25 ++
.../tokensList/pure/TokenListItem/index.tsx | 3 +-
.../pure/TokenListItemContainer/index.tsx | 12 +-
.../tokensList/pure/TokensContent/index.tsx | 165 +++++++++----
.../pure/TokensVirtualList/index.tsx | 128 +++++++++-
.../src/modules/tokensList/types.ts | 5 +-
.../src/modules/tokensList/utils/tokenKey.ts | 7 +
16 files changed, 803 insertions(+), 131 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
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/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index ea987f8f0b2..8916c8fb9a0 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -455,6 +455,7 @@ msgid "View details"
msgstr "View details"
#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "More"
msgstr "More"
@@ -1219,8 +1220,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
-#~ msgid "Manage Token Lists"
-#~ msgstr "Manage Token Lists"
+msgid "Manage Token Lists"
+msgstr "Manage Token Lists"
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
msgid "No results found"
@@ -3155,6 +3156,7 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e
msgid "Aave Debt Swap Flashloan"
msgstr "Aave Debt Swap Flashloan"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
#: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx
msgid "Details"
msgstr "Details"
@@ -4324,6 +4326,7 @@ msgstr "Decrease Value"
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
#: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
msgid "Balance"
msgstr "Balance"
@@ -4386,8 +4389,8 @@ msgid "funds"
msgstr "funds"
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
-#~ msgid "Pool details"
-#~ msgstr "Pool details"
+msgid "Pool details"
+msgstr "Pool details"
#: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx
msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution"
@@ -5895,8 +5898,8 @@ msgid "You sold <0/>"
msgstr "You sold <0/>"
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
-#~ msgid "Less"
-#~ msgstr "Less"
+msgid "Less"
+msgstr "Less"
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
msgid "Claim your LlamaPay vesting contract funds"
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 a58d847acca..fa8966f62fa 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -31,6 +31,7 @@ import { useChainsToSelect } from '../../hooks/useChainsToSelect'
import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
import { useOnSelectChain } from '../../hooks/useOnSelectChain'
import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
+import { useRecentTokens } from '../../hooks/useRecentTokens'
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { useTokensToSelect } from '../../hooks/useTokensToSelect'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
@@ -69,6 +70,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
selectedPoolAddress,
field,
oppositeToken,
+ selectedTargetChainId,
} = useSelectTokenWidgetState()
const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances()
const chainsToSelect = useChainsToSelect()
@@ -82,7 +84,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
)
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
- const { account } = useWalletInfo()
+ const { account, chainId: walletChainId } = useWalletInfo()
const cowAnalytics = useCowAnalytics()
const addCustomTokenLists = useAddList((source) => {
@@ -101,6 +103,17 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
areTokensFromBridge,
isRouteAvailable,
} = useTokensToSelect()
+ const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({
+ allTokens,
+ favoriteTokens,
+ activeChainId: selectedTargetChainId ?? walletChainId,
+ })
+ const handleTokenListItemClick = useCallback(
+ (token: TokenWithLogo) => {
+ addRecentToken(token)
+ },
+ [addRecentToken],
+ )
const userAddedTokens = useUserAddedTokens()
const allTokenLists = useAllListsList()
@@ -138,7 +151,13 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
const importTokenAndClose = (tokens: TokenWithLogo[]): void => {
importTokenCallback(tokens)
- onSelectToken?.(tokens[0])
+ const [tokenToSelect] = tokens
+
+ if (tokenToSelect) {
+ handleTokenListItemClick(tokenToSelect)
+ onSelectToken?.(tokenToSelect)
+ }
+
onDismiss()
}
@@ -209,9 +228,11 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
selectedToken={selectedToken}
allTokens={allTokens}
favoriteTokens={standalone ? EMPTY_FAV_TOKENS : favoriteTokens}
+ recentTokens={standalone ? undefined : recentTokens}
balancesState={balancesState}
permitCompatibleTokens={permitCompatibleTokens}
onSelectToken={onSelectToken}
+ onTokenListItemClick={handleTokenListItemClick}
onInputPressEnter={onInputPressEnter}
onDismiss={onDismiss}
onOpenManageWidget={() => setIsManageWidgetOpen(true)}
@@ -226,6 +247,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
tokenListTags={tokenListTags}
areTokensFromBridge={areTokensFromBridge}
isRouteAvailable={isRouteAvailable}
+ onClearRecentTokens={clearRecentTokens}
+ selectedTargetChainId={selectedTargetChainId}
/>
)
})()}
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 4bb84a16f8d..b43723b9f20 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/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
new file mode 100644
index 00000000000..990b944bc29
--- /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 {
+ // Best effort persistence
+ }
+}
+
+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
new file mode 100644
index 00000000000..e1ea8a97cf0
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useRecentTokens.ts
@@ -0,0 +1,136 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import {
+ RECENT_TOKENS_LIMIT,
+ buildFavoriteTokenKeys,
+ buildNextStoredTokens,
+ buildTokensByKey,
+ getStoredTokenKey,
+ hydrateStoredToken,
+ persistRecentTokenSelection as persistRecentTokenSelectionInternal,
+ persistStoredTokens,
+ readStoredTokens,
+ type StoredRecentTokensByChain,
+} from './recentTokensStorage'
+
+import { getTokenUniqueKey } from '../utils/tokenKey'
+
+interface UseRecentTokensParams {
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ activeChainId?: number
+ maxItems?: number
+}
+
+export interface RecentTokensState {
+ recentTokens: TokenWithLogo[]
+ addRecentToken(token: TokenWithLogo): void
+ clearRecentTokens(): void
+}
+
+export function useRecentTokens({
+ allTokens,
+ favoriteTokens,
+ activeChainId,
+ maxItems = RECENT_TOKENS_LIMIT,
+}: UseRecentTokensParams): RecentTokensState {
+ const [storedTokensByChain, setStoredTokensByChain] = useState(() =>
+ readStoredTokens(maxItems),
+ )
+
+ useEffect(() => {
+ persistStoredTokens(storedTokensByChain)
+ }, [storedTokensByChain])
+
+ const tokensByKey = useMemo(() => buildTokensByKey(allTokens), [allTokens])
+ const favoriteKeys = useMemo(() => buildFavoriteTokenKeys(favoriteTokens), [favoriteTokens])
+
+ useEffect(() => {
+ setStoredTokensByChain((prev) => {
+ const nextEntries: StoredRecentTokensByChain = {}
+ let didChange = false
+
+ for (const [chainKey, tokens] of Object.entries(prev)) {
+ const chainId = Number(chainKey)
+ const filtered = tokens.filter((token) => !favoriteKeys.has(getStoredTokenKey(token)))
+
+ if (filtered.length) {
+ nextEntries[chainId] = filtered
+ }
+
+ didChange = didChange || filtered.length !== tokens.length
+ }
+
+ return didChange ? nextEntries : prev
+ })
+ }, [favoriteKeys])
+
+ const recentTokens = useMemo(() => {
+ const chainEntries = activeChainId ? storedTokensByChain[activeChainId] ?? [] : []
+ const seenKeys = new Set()
+ const result: TokenWithLogo[] = []
+
+ for (const entry of chainEntries) {
+ const key = getStoredTokenKey(entry)
+
+ if (seenKeys.has(key) || favoriteKeys.has(key)) {
+ continue
+ }
+
+ const hydrated = hydrateStoredToken(entry, tokensByKey.get(key))
+
+ if (hydrated) {
+ result.push(hydrated)
+ seenKeys.add(key)
+ }
+
+ if (result.length >= maxItems) {
+ break
+ }
+ }
+
+ return result
+ }, [activeChainId, favoriteKeys, maxItems, storedTokensByChain, tokensByKey])
+
+ const addRecentToken = useCallback(
+ (token: TokenWithLogo) => {
+ if (favoriteKeys.has(getTokenUniqueKey(token))) {
+ return
+ }
+
+ setStoredTokensByChain((prev) => {
+ const next = buildNextStoredTokens(prev, token, maxItems)
+
+ persistStoredTokens(next)
+
+ return next
+ })
+ },
+ [favoriteKeys, maxItems],
+ )
+
+ const clearRecentTokens = useCallback(() => {
+ if (!activeChainId) {
+ return
+ }
+
+ setStoredTokensByChain((prev) => {
+ const chainEntries = prev[activeChainId]
+
+ if (!chainEntries?.length) {
+ return prev
+ }
+
+ const next: StoredRecentTokensByChain = { ...prev, [activeChainId]: [] }
+ persistStoredTokens(next)
+
+ return next
+ })
+ }, [activeChainId])
+
+ return { recentTokens, addRecentToken, clearRecentTokens }
+}
+
+export { persistRecentTokenSelectionInternal as persistRecentTokenSelection }
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
index b7c3623b896..103ceaa01b7 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -1,61 +1,87 @@
import { ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { areAddressesEqual, getCurrencyAddress } from '@cowprotocol/common-utils'
import { TokenLogo } from '@cowprotocol/tokens'
-import { HelpTooltip, TokenSymbol } from '@cowprotocol/ui'
+import { TokenSymbol } from '@cowprotocol/ui'
import { Trans } from '@lingui/react/macro'
import { Link } from 'react-router'
import * as styledEl from './styled'
+import { SelectTokenContext } from '../../types'
+
export interface FavoriteTokensListProps {
tokens: TokenWithLogo[]
+ selectTokenContext: SelectTokenContext
hideTooltip?: boolean
- selectedToken?: string
-
- onSelectToken(token: TokenWithLogo): void
}
export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode {
- const { tokens, hideTooltip, selectedToken, onSelectToken } = props
+ const { tokens, selectTokenContext, hideTooltip } = props
+
+ if (!tokens.length) {
+ return null
+ }
return (
-
-
-
+
+
+
Favorite tokens
-
- {!hideTooltip && (
-
- Your favorite saved tokens. Edit this list in the Tokens page.
-
- }
- />
- )}
-
-
- {tokens.map((token) => {
- const isTokenSelected = token.address.toLowerCase() === selectedToken?.toLowerCase()
-
- return (
- onSelectToken(token)}
- >
-
-
-
- )
- })}
-
-
+
+ {!hideTooltip && }
+
+ {renderFavoriteTokenItems(tokens, selectTokenContext)}
+
+ )
+}
+
+function FavoriteTokensTooltip(): ReactNode {
+ return (
+
+ Your favorite saved tokens. Edit this list in the Tokens page.
+
+ }
+ />
)
}
+
+function renderFavoriteTokenItems(tokens: TokenWithLogo[], context: SelectTokenContext): ReactNode[] {
+ const { selectedToken } = context
+ const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined
+
+ return tokens.map((token) => {
+ const isSelected =
+ !!selectedToken &&
+ token.chainId === selectedToken.chainId &&
+ !!selectedAddress &&
+ areAddressesEqual(token.address, selectedAddress)
+
+ const handleClick = (): void => {
+ if (isSelected) {
+ return
+ }
+ context.onTokenListItemClick?.(token)
+ context.onSelectToken(token)
+ }
+
+ return (
+
+
+
+
+ )
+ })
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
index ee278a509a1..ad258cd2cc2 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/styled.ts
@@ -1,18 +1,25 @@
-import { Media, UI } from '@cowprotocol/ui'
+import { HelpTooltip, Media, UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
-export const Header = styled.div`
+export const Section = styled.div`
+ padding: 0 14px 14px;
+
+ ${Media.upToSmall()} {
+ padding: 8px 14px 4px;
+ }
+`
+
+export const TitleRow = styled.div`
display: flex;
- gap: 5px;
- flex-direction: row;
align-items: center;
+`
- > h4 {
- font-size: 14px;
- font-weight: 500;
- margin: 0;
- }
+export const Title = styled.h4`
+ font-size: 14px;
+ font-weight: 500;
+ margin: 0;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
`
export const List = styled.div`
@@ -25,9 +32,8 @@ export const List = styled.div`
width: 0;
min-width: 100%;
flex-wrap: nowrap;
- overflow-x: scroll;
+ overflow-x: auto;
overflow-y: hidden;
-
padding: 10px 0;
-webkit-overflow-scrolling: touch;
@@ -44,9 +50,8 @@ export const List = styled.div`
}
`
-export const TokensItem = styled.button`
+export const TokenButton = styled.button`
display: inline-flex;
- flex-direction: row;
align-items: center;
gap: 6px;
justify-content: center;
@@ -58,9 +63,9 @@ export const TokensItem = styled.button`
border: 1px solid var(${UI.COLOR_PAPER_DARKER});
font-weight: 500;
font-size: 16px;
- cursor: ${({ disabled }) => (disabled ? '' : 'pointer')};
- background: ${({ disabled }) => disabled && `var(${UI.COLOR_PAPER_DARKER})`};
- opacity: ${({ disabled }) => (disabled ? 0.6 : 1)};
+ cursor: ${({ disabled }) => (disabled ? 'default' : 'pointer')};
+ background: ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')};
+ opacity: ${({ disabled }) => (disabled ? 0.65 : 1)};
transition: border var(${UI.ANIMATION_DURATION}) ease-in-out;
white-space: nowrap;
@@ -72,3 +77,13 @@ export const TokensItem = styled.button`
border: 1px solid ${({ disabled }) => (disabled ? `var(${UI.COLOR_PAPER_DARKER})` : `var(${UI.COLOR_PRIMARY})`)};
}
`
+
+export const FavoriteTooltip = styled(HelpTooltip)`
+ color: var(${UI.COLOR_TEXT_OPACITY_50});
+ transition: color 0.2s ease-in-out;
+ margin-left: 6px;
+
+ &:hover {
+ color: var(${UI.COLOR_TEXT});
+ }
+`
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 4a432e5c98a..00fef623738 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
@@ -48,9 +48,17 @@ const defaultProps: SelectTokenModalProps = {
},
selectedToken,
isRouteAvailable: true,
+ recentTokens: favoriteTokensMock.slice(0, 2),
+ selectedTargetChainId: SupportedChainId.SEPOLIA,
onSelectToken() {
console.log('onSelectToken')
},
+ onTokenListItemClick(token) {
+ console.log('onTokenListItemClick', token.symbol)
+ },
+ onClearRecentTokens() {
+ console.log('onClearRecentTokens')
+ },
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 f1092ce7ec9..a4e1e5f5609 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -17,7 +17,7 @@ import { SelectTokenModalContent } from './SelectTokenModalContent'
import * as styledEl from './styled'
import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
-import { ChainsToSelectState, SelectTokenContext } from '../../types'
+import { ChainsToSelectState, SelectTokenContext, TokenSelectionHandler } from '../../types'
import { ChainsSelector } from '../ChainsSelector'
import { IconButton } from '../commonElements'
import { TokensContent } from '../TokensContent'
@@ -25,6 +25,7 @@ import { TokensContent } from '../TokensContent'
export interface SelectTokenModalProps {
allTokens: TokenWithLogo[]
favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
balancesState: BalancesState
unsupportedTokens: UnsupportedTokensState
selectedToken?: Nullish
@@ -41,13 +42,16 @@ export interface SelectTokenModalProps {
standalone?: boolean
areTokensFromBridge: boolean
isRouteAvailable: boolean | undefined
+ selectedTargetChainId?: number
- onSelectToken(token: TokenWithLogo): void
+ onSelectToken: TokenSelectionHandler
+ onTokenListItemClick?(token: TokenWithLogo): void
openPoolPage(poolAddress: string): void
onInputPressEnter?(): void
onOpenManageWidget(): void
onDismiss(): void
onSelectChain(chain: ChainInfo): void
+ onClearRecentTokens?(): void
}
function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext {
@@ -57,6 +61,7 @@ function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext
unsupportedTokens,
permitCompatibleTokens,
onSelectToken,
+ onTokenListItemClick,
account,
tokenListTags,
} = props
@@ -66,12 +71,22 @@ function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext
balancesState,
selectedToken,
onSelectToken,
+ onTokenListItemClick,
unsupportedTokens,
permitCompatibleTokens,
tokenListTags,
isWalletConnected: !!account,
}),
- [balancesState, selectedToken, onSelectToken, unsupportedTokens, permitCompatibleTokens, tokenListTags, account],
+ [
+ balancesState,
+ selectedToken,
+ onSelectToken,
+ onTokenListItemClick,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ tokenListTags,
+ account,
+ ],
)
}
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 3016d33f0c9..9bcc0473df5 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -80,3 +80,28 @@ export const RouteNotAvailable = styled.div`
padding: 20px 0;
text-align: center;
`
+
+export const ListTitle = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 12px;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(${UI.COLOR_TEXT_OPACITY_50});
+ padding: 12px 20px 4px;
+`
+
+export const ListTitleActionButton = styled.button`
+ ${blankButtonMixin};
+ font-size: 12px;
+ font-weight: 600;
+ color: var(${UI.COLOR_PRIMARY});
+ cursor: pointer;
+ transition: opacity var(${UI.ANIMATION_DURATION}) ease-in-out;
+
+ &:hover {
+ opacity: 0.75;
+ }
+`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
index 9e141417f3e..d9d4c867a42 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
@@ -13,6 +13,7 @@ import { Nullish } from 'types'
import * as styledEl from './styled'
import { useDeferredVisibility } from '../../hooks/useDeferredVisibility'
+import { TokenSelectionHandler } from '../../types'
import { TokenInfo } from '../TokenInfo'
import { TokenTags } from '../TokenTags'
@@ -28,7 +29,7 @@ export interface TokenListItemProps {
balance: BigNumber | undefined
usdAmount?: CurrencyAmount | null
- onSelectToken?(token: TokenWithLogo): void
+ onSelectToken?: TokenSelectionHandler
isWalletConnected: boolean
isUnsupported?: boolean
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 4dd4ca71e46..97208d6eb0f 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 fa6b1ccf049..cd8bb607702 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -1,17 +1,14 @@
-import React, { ReactNode } from 'react'
+import React, { ReactNode, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
-import { getCurrencyAddress } from '@cowprotocol/common-utils'
-import { Nullish } from '@cowprotocol/types'
import { Loader } from '@cowprotocol/ui'
-import { Currency } from '@uniswap/sdk-core'
import { Trans } from '@lingui/react/macro'
import { Edit } from 'react-feather'
import { TokenSearchResults } from '../../containers/TokenSearchResults'
import { SelectTokenContext } from '../../types'
-import { FavoriteTokensList } from '../FavoriteTokensList'
+import { getTokenUniqueKey } from '../../utils/tokenKey'
import * as styledEl from '../SelectTokenModal/styled'
import { TokensVirtualList } from '../TokensVirtualList'
@@ -19,24 +16,22 @@ export interface TokensContentProps {
displayLpTokenLists?: boolean
selectTokenContext: SelectTokenContext
favoriteTokens: TokenWithLogo[]
- selectedToken?: Nullish
+ recentTokens?: TokenWithLogo[]
hideFavoriteTokensTooltip?: boolean
areTokensLoading: boolean
allTokens: TokenWithLogo[]
searchInput: string
standalone?: boolean
areTokensFromBridge: boolean
-
- onSelectToken(token: TokenWithLogo): void
+ selectedTargetChainId?: number
onOpenManageWidget(): void
+ onClearRecentTokens?: () => void
}
export function TokensContent({
selectTokenContext,
- onSelectToken,
- onOpenManageWidget,
- selectedToken,
favoriteTokens,
+ recentTokens,
hideFavoriteTokensTooltip,
areTokensLoading,
allTokens,
@@ -44,44 +39,60 @@ export function TokensContent({
searchInput,
standalone,
areTokensFromBridge,
+ selectedTargetChainId,
+ onOpenManageWidget,
+ onClearRecentTokens,
}: TokensContentProps): ReactNode {
+ const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0
+ const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0
+
+ const pinnedTokenKeys = useMemo(() => {
+ if (!shouldShowFavoritesInline && !shouldShowRecentsInline) {
+ return undefined
+ }
+
+ const pinned = new Set()
+
+ if (shouldShowFavoritesInline) {
+ favoriteTokens.forEach((token) => pinned.add(getTokenUniqueKey(token)))
+ }
+
+ if (shouldShowRecentsInline && recentTokens) {
+ recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token)))
+ }
+
+ return pinned
+ }, [favoriteTokens, recentTokens, shouldShowFavoritesInline, shouldShowRecentsInline])
+
+ const tokensWithoutPinned = useMemo(() => {
+ if (!pinnedTokenKeys) {
+ return allTokens
+ }
+
+ return allTokens.filter((token) => !pinnedTokenKeys.has(getTokenUniqueKey(token)))
+ }, [allTokens, pinnedTokenKeys])
+
+ const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined
+ const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined
+
+ const tokensView = renderTokensView({
+ areTokensLoading,
+ searchInput,
+ selectTokenContext,
+ areTokensFromBridge,
+ allTokens,
+ tokensWithoutPinned,
+ displayLpTokenLists,
+ favoriteTokens: favoriteTokensInline,
+ recentTokens: recentTokensInline,
+ hideFavoriteTokensTooltip,
+ selectedTargetChainId,
+ onClearRecentTokens,
+ })
+
return (
<>
- {!areTokensLoading && !!favoriteTokens.length && (
- <>
-
-
-
-
- >
- )}
- {areTokensLoading ? (
-
-
-
- ) : (
- <>
- {searchInput ? (
-
- ) : (
-
- )}
- >
- )}
+ {tokensView}
{!standalone && (
<>
@@ -98,3 +109,65 @@ export function TokensContent({
>
)
}
+
+interface TokensViewProps {
+ areTokensLoading: boolean
+ searchInput: string
+ selectTokenContext: SelectTokenContext
+ areTokensFromBridge: boolean
+ allTokens: TokenWithLogo[]
+ tokensWithoutPinned: TokenWithLogo[]
+ displayLpTokenLists?: boolean
+ favoriteTokens?: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ hideFavoriteTokensTooltip?: boolean
+ selectedTargetChainId?: number
+ onClearRecentTokens?: () => void
+}
+
+function renderTokensView({
+ areTokensLoading,
+ searchInput,
+ selectTokenContext,
+ areTokensFromBridge,
+ allTokens,
+ tokensWithoutPinned,
+ displayLpTokenLists,
+ favoriteTokens,
+ recentTokens,
+ hideFavoriteTokensTooltip,
+ selectedTargetChainId,
+ onClearRecentTokens,
+}: TokensViewProps): ReactNode {
+ if (areTokensLoading) {
+ return (
+
+
+
+ )
+ }
+
+ if (searchInput) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
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 f657eb27c19..a4f65bfbaaf 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { useFeatureFlags } from '@cowprotocol/common-hooks'
+import { getIsNativeToken } from '@cowprotocol/common-utils'
import { VirtualItem } from '@tanstack/react-virtual'
@@ -10,37 +11,142 @@ import { VirtualList } from 'common/pure/VirtualList'
import { SelectTokenContext } from '../../types'
import { tokensListSorter } from '../../utils/tokensListSorter'
+import { FavoriteTokensList } from '../FavoriteTokensList'
+import * as modalStyled from '../SelectTokenModal/styled'
import { TokenListItemContainer } from '../TokenListItemContainer'
export interface TokensVirtualListProps {
allTokens: TokenWithLogo[]
displayLpTokenLists?: boolean
selectTokenContext: SelectTokenContext
+ favoriteTokens?: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ hideFavoriteTokensTooltip?: boolean
+ scrollResetKey?: number
+ onClearRecentTokens?: () => void
}
+type TokensVirtualRow =
+ | { type: 'favorite-section'; tokens: TokenWithLogo[]; hideTooltip?: boolean }
+ | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void }
+ | { type: 'token'; token: TokenWithLogo }
+
export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
- const { allTokens, selectTokenContext, displayLpTokenLists } = props
+ const {
+ allTokens,
+ selectTokenContext,
+ displayLpTokenLists,
+ favoriteTokens,
+ recentTokens,
+ hideFavoriteTokensTooltip,
+ scrollResetKey,
+ onClearRecentTokens,
+ } = props
const { values: balances } = selectTokenContext.balancesState
const { isYieldEnabled } = useFeatureFlags()
- const sortedTokens = useMemo(
- () => (balances ? allTokens.sort(tokensListSorter(balances)) : allTokens),
- [allTokens, balances],
- )
+ const sortedTokens = useMemo(() => {
+ if (!balances) {
+ return allTokens
+ }
+
+ const prioritized: TokenWithLogo[] = []
+ const remainder: TokenWithLogo[] = []
+
+ for (const token of allTokens) {
+ const hasBalance = Boolean(balances[token.address.toLowerCase()])
+ if (hasBalance || getIsNativeToken(token)) {
+ prioritized.push(token)
+ } else {
+ remainder.push(token)
+ }
+ }
+
+ const sortedPrioritized =
+ prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized
+
+ return [...sortedPrioritized, ...remainder]
+ }, [allTokens, balances])
+
+ const rows = useMemo(() => {
+ const tokenRows = sortedTokens.map((token) => ({ type: 'token', token }))
+ const composedRows: TokensVirtualRow[] = []
+
+ if (favoriteTokens?.length) {
+ composedRows.push({
+ type: 'favorite-section',
+ tokens: favoriteTokens,
+ hideTooltip: hideFavoriteTokensTooltip,
+ })
+ }
- const getItemView = useCallback(
- (sortedTokens: TokenWithLogo[], virtualRow: VirtualItem) => {
- const token = sortedTokens[virtualRow.index]
+ if (recentTokens?.length) {
+ composedRows.push({
+ type: 'title',
+ label: 'Recent',
+ actionLabel: onClearRecentTokens ? 'Clear' : undefined,
+ onAction: onClearRecentTokens,
+ })
+ recentTokens.forEach((token) => composedRows.push({ type: 'token', token }))
+ }
- return
- },
+ if (favoriteTokens?.length || recentTokens?.length) {
+ composedRows.push({ type: 'title', label: 'All tokens' })
+ }
+
+ return [...composedRows, ...tokenRows]
+ }, [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens])
+
+ const virtualListKey = scrollResetKey ?? 'tokens-list'
+
+ const renderVirtualRow = useCallback(
+ (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => (
+
+ ),
[selectTokenContext],
)
return (
-
+
{displayLpTokenLists || !isYieldEnabled ? null : }
)
}
+
+interface TokensVirtualRowRendererProps {
+ row: TokensVirtualRow
+ selectTokenContext: SelectTokenContext
+}
+
+function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowRendererProps): ReactNode {
+ switch (row.type) {
+ case 'favorite-section':
+ return (
+
+ )
+ case 'title':
+ return (
+
+ {row.label}
+ {row.actionLabel && row.onAction ? (
+
+ {row.actionLabel}
+
+ ) : null}
+
+ )
+ default:
+ return
+ }
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/types.ts
index 5c775d8e0a7..da71e3ab963 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts
@@ -8,11 +8,14 @@ import { Nullish } from 'types'
import { PermitCompatibleTokens } from 'modules/permit'
+export type TokenSelectionHandler = (token: TokenWithLogo) => Promise | void
+
export interface SelectTokenContext {
balancesState: BalancesState
selectedToken?: Nullish
- onSelectToken(token: TokenWithLogo): void
+ onSelectToken: TokenSelectionHandler
+ onTokenListItemClick?(token: TokenWithLogo): void
unsupportedTokens: { [tokenAddress: string]: { dateAdded: number } }
permitCompatibleTokens: PermitCompatibleTokens
diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts
new file mode 100644
index 00000000000..8f827b02885
--- /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 071ced1a4194fe552fb702cec749c3df9521ed78 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 14 Nov 2025 10:09:01 +0000
Subject: [PATCH 005/100] feat(tokenselector): add chain selection logic and
enhance token widget interactions
---
.../containers/SelectTokenWidget/index.tsx | 2 +-
.../hooks/useChainsToSelect.test.ts | 48 +++++++
.../tokensList/hooks/useChainsToSelect.ts | 117 +++++++++++++-----
.../hooks/useCloseTokenSelectWidget.ts | 19 ++-
.../tokensList/hooks/useOnSelectChain.ts | 24 +++-
.../hooks/useOpenTokenSelectWidget.ts | 34 +++--
.../src/modules/tokensList/index.ts | 1 +
.../pure/AddIntermediateTokenModal/index.tsx | 2 +-
.../tokensList/state/selectTokenWidgetAtom.ts | 6 +
.../test-utils/createChainInfoForTests.ts | 103 +++++++++++++++
.../utils/sortChainsByDisplayOrder.ts | 52 ++++++++
11 files changed, 357 insertions(+), 51 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.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 fa8966f62fa..21cc6df5bef 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -146,7 +146,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
const onDismiss = useCallback(() => {
setIsManageWidgetOpen(false)
- closeTokenSelectWidget()
+ closeTokenSelectWidget({ overrideForceLock: true })
}, [closeTokenSelectWidget])
const importTokenAndClose = (tokens: TokenWithLogo[]): void => {
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
new file mode 100644
index 00000000000..5bf6a56e458
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
@@ -0,0 +1,48 @@
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { createInputChainsState, createOutputChainsState } from './useChainsToSelect'
+
+import { createChainInfoForTests } from '../test-utils/createChainInfoForTests'
+
+describe('useChainsToSelect state builders', () => {
+ it('sorts sell-side chains using the canonical order', () => {
+ const supportedChains = [
+ createChainInfoForTests(SupportedChainId.AVALANCHE),
+ createChainInfoForTests(SupportedChainId.BASE),
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ ]
+
+ const state = createInputChainsState(SupportedChainId.BASE, supportedChains)
+
+ expect((state.chains ?? []).map((chain) => chain.id)).toEqual([
+ SupportedChainId.MAINNET,
+ SupportedChainId.BASE,
+ SupportedChainId.AVALANCHE,
+ ])
+ })
+
+ it('sorts bridge destination chains to match the canonical order', () => {
+ const bridgeChains = [
+ createChainInfoForTests(SupportedChainId.AVALANCHE),
+ createChainInfoForTests(SupportedChainId.BASE),
+ createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ ]
+
+ const state = createOutputChainsState({
+ selectedTargetChainId: SupportedChainId.POLYGON,
+ chainId: SupportedChainId.MAINNET,
+ currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
+ bridgeSupportedNetworks: bridgeChains,
+ areUnsupportedChainsEnabled: true,
+ isLoading: false,
+ })
+
+ expect((state.chains ?? []).map((chain) => chain.id)).toEqual([
+ SupportedChainId.MAINNET,
+ SupportedChainId.BASE,
+ SupportedChainId.ARBITRUM_ONE,
+ SupportedChainId.AVALANCHE,
+ ])
+ })
+})
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
index b781b7c1121..c42664014cd 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -9,10 +9,13 @@ import { useBridgeSupportedNetworks } from 'entities/bridgeProvider'
import { Field } from 'legacy/state/types'
+import { TradeType } from 'modules/trade/types'
+
import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { ChainsToSelectState } from '../types'
import { mapChainInfo } from '../utils/mapChainInfo'
+import { sortChainsByDisplayOrder } from '../utils/sortChainsByDisplayOrder'
/**
* Returns an array of chains to select in the token selector widget.
@@ -22,11 +25,12 @@ import { mapChainInfo } from '../utils/mapChainInfo'
*/
export function useChainsToSelect(): ChainsToSelectState | undefined {
const { chainId } = useWalletInfo()
- const { field, selectedTargetChainId = chainId } = useSelectTokenWidgetState()
+ const { field, selectedTargetChainId = chainId, tradeType } = useSelectTokenWidgetState()
const { data: bridgeSupportedNetworks, isLoading } = useBridgeSupportedNetworks()
const { areUnsupportedChainsEnabled } = useFeatureFlags()
const isBridgingEnabled = useIsBridgingEnabled()
const availableChains = useAvailableChains()
+ const isAdvancedTradeType = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS
const supportedChains = useMemo(() => {
return availableChains.reduce((acc, id) => {
@@ -41,42 +45,33 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
}, [availableChains])
return useMemo(() => {
- if (!field || !isBridgingEnabled) return undefined
+ if (!field || !chainId) return undefined
- const currentChainInfo = mapChainInfo(chainId, CHAIN_INFO[chainId])
- const isSourceChainSupportedByBridge = Boolean(
- bridgeSupportedNetworks?.find((bridgeChain) => bridgeChain.id === chainId),
- )
+ const chainInfo = CHAIN_INFO[chainId]
+ if (!chainInfo) return undefined
- // For the sell token selector we only display supported chains
- if (field === Field.INPUT) {
- return {
- defaultChainId: selectedTargetChainId,
- chains: supportedChains,
- isLoading: false,
- }
- }
+ const currentChainInfo = mapChainInfo(chainId, chainInfo)
+ // Limit/TWAP buys must stay on the wallet chain, so skip bridge wiring entirely.
+ const shouldForceSingleChain = isAdvancedTradeType && field === Field.OUTPUT
- /**
- * When the source chain is not supported by bridge provider
- * We act as non-bridge mode
- */
- if (!isSourceChainSupportedByBridge) {
- return {
- defaultChainId: selectedTargetChainId,
- chains: [],
- isLoading: false,
- }
+ if (!isBridgingEnabled && !shouldForceSingleChain) return undefined
+
+ if (shouldForceSingleChain) {
+ return createSingleChainState(chainId, currentChainInfo)
}
- const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled)
+ if (field === Field.INPUT) {
+ return createInputChainsState(selectedTargetChainId, supportedChains)
+ }
- return {
- defaultChainId: selectedTargetChainId,
- // Add the source network to the list if it's not supported by bridge provider
- chains: [...(isSourceChainSupportedByBridge ? [] : [currentChainInfo]), ...(destinationChains || [])],
+ return createOutputChainsState({
+ selectedTargetChainId,
+ chainId,
+ currentChainInfo,
+ bridgeSupportedNetworks,
+ areUnsupportedChainsEnabled,
isLoading,
- }
+ })
}, [
field,
selectedTargetChainId,
@@ -86,6 +81,7 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
isBridgingEnabled,
areUnsupportedChainsEnabled,
supportedChains,
+ isAdvancedTradeType,
])
}
@@ -101,3 +97,64 @@ function filterDestinationChains(
return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId)
}
}
+
+// Represents the “non-bridge” UX (bridging disabled or advanced-trade guardrail) where only the current chain is available.
+function createSingleChainState(
+ defaultChainId: SupportedChainId | number,
+ chain: ChainInfo,
+ isLoading = false,
+): ChainsToSelectState {
+ return {
+ defaultChainId,
+ chains: [chain],
+ isLoading,
+ }
+}
+
+// Sell-side selector intentionally limits chains to the wallet-supported list; bridge destinations never appear here.
+export function createInputChainsState(
+ selectedTargetChainId: SupportedChainId | number,
+ supportedChains: ChainInfo[],
+): ChainsToSelectState {
+ return {
+ defaultChainId: selectedTargetChainId,
+ chains: sortChainsByDisplayOrder(supportedChains),
+ isLoading: false,
+ }
+}
+
+interface CreateOutputChainsOptions {
+ selectedTargetChainId: SupportedChainId | number
+ chainId: SupportedChainId
+ currentChainInfo: ChainInfo
+ bridgeSupportedNetworks: ChainInfo[] | undefined
+ areUnsupportedChainsEnabled: boolean | undefined
+ isLoading: boolean
+}
+
+export function createOutputChainsState({
+ selectedTargetChainId,
+ chainId,
+ currentChainInfo,
+ bridgeSupportedNetworks,
+ areUnsupportedChainsEnabled,
+ isLoading,
+}: CreateOutputChainsOptions): ChainsToSelectState {
+ const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) ?? []
+ const orderedDestinationChains = sortChainsByDisplayOrder(destinationChains)
+ const isSourceChainSupportedByBridge = Boolean(
+ bridgeSupportedNetworks?.some((bridgeChain) => bridgeChain.id === chainId),
+ )
+
+ if (!isSourceChainSupportedByBridge) {
+ // Source chain is unsupported by the bridge provider; fall back to non-bridge behavior.
+ return createSingleChainState(selectedTargetChainId, currentChainInfo)
+ }
+
+ return {
+ defaultChainId: selectedTargetChainId,
+ // Bridge supports this chain, so expose the provider-supplied destinations.
+ chains: orderedDestinationChains,
+ isLoading,
+ }
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts
index 6434545dfac..9f52134ab7e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useCloseTokenSelectWidget.ts
@@ -1,15 +1,22 @@
import { useCallback } from 'react'
+import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'
import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom'
-// TODO: Add proper return type annotation
-// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-export function useCloseTokenSelectWidget() {
+type CloseTokenSelectWidget = (options?: { overrideForceLock?: boolean }) => void
+
+export function useCloseTokenSelectWidget(): CloseTokenSelectWidget {
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
+ const widgetState = useSelectTokenWidgetState()
+
+ return useCallback(
+ (options?: { overrideForceLock?: boolean }) => {
+ if (widgetState.forceOpen && !options?.overrideForceLock) return
- return useCallback(() => {
- updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE)
- }, [updateSelectTokenWidget])
+ updateSelectTokenWidget(DEFAULT_SELECT_TOKEN_WIDGET_STATE)
+ },
+ [updateSelectTokenWidget, widgetState.forceOpen],
+ )
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts
index eaf2b8997f2..f29896d1469 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOnSelectChain.ts
@@ -2,17 +2,31 @@ import { useCallback } from 'react'
import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { Field } from 'legacy/state/types'
+
+import { TradeType } from 'modules/trade/types'
+
+import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'
-// TODO: Add proper return type annotation
-// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
-export function useOnSelectChain() {
+type OnSelectChainHandler = (chain: ChainInfo) => void
+
+export function useOnSelectChain(): OnSelectChainHandler {
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
+ const widgetState = useSelectTokenWidgetState()
+ const shouldForceOpen =
+ widgetState.field === Field.INPUT &&
+ (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS)
+ // Limit/TWAP sells keep the widget pinned while the user flips chains; forceOpen keeps that behavior intact.
return useCallback(
(chain: ChainInfo) => {
- updateSelectTokenWidget({ selectedTargetChainId: chain.id })
+ updateSelectTokenWidget({
+ selectedTargetChainId: chain.id,
+ open: true,
+ forceOpen: shouldForceOpen,
+ })
},
- [updateSelectTokenWidget],
+ [updateSelectTokenWidget, shouldForceOpen],
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts
index 2f1a24c8abe..1b7de718630 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useOpenTokenSelectWidget.ts
@@ -8,6 +8,10 @@ import { Nullish } from 'types'
import { Field } from 'legacy/state/types'
+import { useTradeTypeInfo } from 'modules/trade/hooks/useTradeTypeInfo'
+import { useTradeTypeInfoFromUrl } from 'modules/trade/hooks/useTradeTypeInfoFromUrl'
+import { TradeType } from 'modules/trade/types'
+
import { useCloseTokenSelectWidget } from './useCloseTokenSelectWidget'
import { useUpdateSelectTokenWidgetState } from './useUpdateSelectTokenWidgetState'
@@ -20,28 +24,42 @@ export function useOpenTokenSelectWidget(): (
const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
const closeTokenSelectWidget = useCloseTokenSelectWidget()
const isBridgingEnabled = useIsBridgingEnabled()
+ const tradeTypeInfoFromState = useTradeTypeInfo()
+ const tradeTypeInfoFromUrl = useTradeTypeInfoFromUrl()
+ const tradeTypeInfo = tradeTypeInfoFromState ?? tradeTypeInfoFromUrl
+ const tradeType = tradeTypeInfo?.tradeType
+ // Advanced trades lock the target chain so price guarantees stay valid while the widget is open.
+ const shouldLockTargetChain = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS
return useCallback(
(selectedToken, field, oppositeToken, onSelectToken) => {
const isOutputField = field === Field.OUTPUT
- const selectedTargetChainId =
- isOutputField && selectedToken && isBridgingEnabled ? selectedToken.chainId : undefined
+ const nextSelectedTargetChainId =
+ isOutputField && selectedToken && isBridgingEnabled && !shouldLockTargetChain
+ ? selectedToken.chainId
+ : undefined
updateSelectTokenWidget({
selectedToken,
field,
oppositeToken,
open: true,
- selectedTargetChainId,
+ forceOpen: false,
+ selectedTargetChainId: nextSelectedTargetChainId,
+ tradeType,
onSelectToken: (currency) => {
- // Close the token selector regardless of network switching.
- // UX: When a user picks a token (even from another network),
- // the selector should close as per issue #6251 expected behavior.
- closeTokenSelectWidget()
+ // Keep selector UX consistent with #6251: always close after a selection, even if a chain switch follows.
+ closeTokenSelectWidget({ overrideForceLock: true })
onSelectToken(currency)
},
})
},
- [closeTokenSelectWidget, updateSelectTokenWidget, isBridgingEnabled],
+ [
+ closeTokenSelectWidget,
+ updateSelectTokenWidget,
+ isBridgingEnabled,
+ shouldLockTargetChain,
+ tradeType,
+ ],
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/index.ts b/apps/cowswap-frontend/src/modules/tokensList/index.ts
index c38c9b46b97..648d15da924 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/index.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/index.ts
@@ -11,3 +11,4 @@ export { useUpdateSelectTokenWidgetState } from './hooks/useUpdateSelectTokenWid
export { useOnTokenListAddingError } from './hooks/useOnTokenListAddingError'
export { useTokenListAddingError } from './hooks/useTokenListAddingError'
export { useSourceChainId } from './hooks/useSourceChainId'
+export { useChainsToSelect } from './hooks/useChainsToSelect'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx
index dea219daaed..afaf243779d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/AddIntermediateTokenModal/index.tsx
@@ -36,7 +36,7 @@ export function AddIntermediateTokenModal({ onDismiss, onBack, onImport }: AddIn
importTokenCallback([tokenToImport])
onImport(tokenToImport)
// when we import the token from here, we don't need to import it again in the SelectTokenWidget
- closeTokenSelectWidget()
+ closeTokenSelectWidget({ overrideForceLock: true })
}
}, [onImport, importTokenCallback, closeTokenSelectWidget, tokenToImport])
diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts
index d8cb8eadc39..fda562f5a37 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/state/selectTokenWidgetAtom.ts
@@ -10,6 +10,8 @@ import { Nullish } from 'types'
import { Field } from 'legacy/state/types'
+import { TradeType } from 'modules/trade/types'
+
interface SelectTokenWidgetState {
open: boolean
field?: Field
@@ -21,6 +23,8 @@ interface SelectTokenWidgetState {
onSelectToken?: (currency: Currency) => void
onInputPressEnter?: Command
selectedTargetChainId?: number
+ tradeType?: TradeType
+ forceOpen?: boolean
}
export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = {
@@ -32,6 +36,8 @@ export const DEFAULT_SELECT_TOKEN_WIDGET_STATE: SelectTokenWidgetState = {
listToImport: undefined,
selectedPoolAddress: undefined,
selectedTargetChainId: undefined,
+ tradeType: undefined,
+ forceOpen: false,
}
export const { atom: selectTokenWidgetAtom, updateAtom: updateSelectTokenWidgetAtom } = atomWithPartialUpdate(
diff --git a/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts
new file mode 100644
index 00000000000..05c88af1966
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/test-utils/createChainInfoForTests.ts
@@ -0,0 +1,103 @@
+import { ALL_SUPPORTED_CHAINS_MAP, ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
+
+export function createChainInfoForTests(baseChainId: SupportedChainId, overrides?: Partial): ChainInfo {
+ const base = ALL_SUPPORTED_CHAINS_MAP[baseChainId]
+
+ if (!base) {
+ throw new Error(`Missing base chain definition for ${baseChainId}`)
+ }
+
+ return buildChainInfo(base, overrides)
+}
+
+function buildChainInfo(base: ChainInfo, overrides: Partial | undefined): ChainInfo {
+ const chainId = resolveChainId(base, overrides)
+
+ return {
+ ...base,
+ ...overrides,
+ id: chainId,
+ contracts: resolveContracts(base, overrides),
+ bridges: resolveBridges(base, overrides),
+ rpcUrls: resolveRpcUrls(base, overrides),
+ logo: resolveLogo(base, overrides),
+ docs: resolveDocs(base, overrides),
+ website: resolveWebsite(base, overrides),
+ blockExplorer: resolveBlockExplorer(base, overrides),
+ nativeCurrency: resolveNativeCurrency(base, overrides, chainId),
+ }
+}
+
+function resolveChainId(base: ChainInfo, overrides: Partial | undefined): ChainInfo['id'] {
+ return overrides?.id ?? base.id
+}
+
+function resolveContracts(base: ChainInfo, overrides: Partial | undefined): ChainInfo['contracts'] {
+ const merged = overrides?.contracts
+
+ return merged ? { ...base.contracts, ...merged } : { ...base.contracts }
+}
+
+function resolveBridges(base: ChainInfo, overrides: Partial | undefined): ChainInfo['bridges'] {
+ const bridges = overrides?.bridges ?? base.bridges
+
+ return bridges?.map(cloneBridge)
+}
+
+function resolveRpcUrls(base: ChainInfo, overrides: Partial | undefined): ChainInfo['rpcUrls'] {
+ return cloneRpcUrls(overrides?.rpcUrls ?? base.rpcUrls)
+}
+
+function resolveLogo(base: ChainInfo, overrides: Partial | undefined): ChainInfo['logo'] {
+ return cloneThemedImage(overrides?.logo ?? base.logo)
+}
+
+function resolveDocs(base: ChainInfo, overrides: Partial | undefined): ChainInfo['docs'] {
+ return cloneWebUrl(overrides?.docs ?? base.docs)
+}
+
+function resolveWebsite(base: ChainInfo, overrides: Partial | undefined): ChainInfo['website'] {
+ return cloneWebUrl(overrides?.website ?? base.website)
+}
+
+function resolveBlockExplorer(base: ChainInfo, overrides: Partial | undefined): ChainInfo['blockExplorer'] {
+ return cloneWebUrl(overrides?.blockExplorer ?? base.blockExplorer)
+}
+
+function resolveNativeCurrency(
+ base: ChainInfo,
+ overrides: Partial | undefined,
+ chainId: ChainInfo['id'],
+): ChainInfo['nativeCurrency'] {
+ return {
+ ...base.nativeCurrency,
+ ...(overrides?.nativeCurrency ?? {}),
+ chainId,
+ }
+}
+
+function cloneBridge(bridge: NonNullable[number]): NonNullable[number] {
+ return { ...bridge }
+}
+
+function cloneRpcUrls(rpcUrls: ChainInfo['rpcUrls']): ChainInfo['rpcUrls'] {
+ return Object.entries(rpcUrls).reduce(
+ (acc, [key, value]) => {
+ acc[key] = {
+ http: [...value.http],
+ ...(value.webSocket ? { webSocket: [...value.webSocket] } : {}),
+ }
+
+ return acc
+ },
+ {} as ChainInfo['rpcUrls'],
+ )
+}
+
+function cloneThemedImage(image: ChainInfo['logo']): ChainInfo['logo'] {
+ return { ...image }
+}
+
+function cloneWebUrl(webUrl: T): T {
+ return { ...webUrl }
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts
new file mode 100644
index 00000000000..59d30cd945d
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/utils/sortChainsByDisplayOrder.ts
@@ -0,0 +1,52 @@
+import { SORTED_CHAIN_IDS } from '@cowprotocol/common-const'
+import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
+
+const CHAIN_ORDER = SORTED_CHAIN_IDS.reduce>((acc, chainId, index) => {
+ acc[chainId] = index
+ return acc
+}, {} as Record)
+
+interface SortOptions {
+ pinChainId?: ChainInfo['id']
+}
+
+/**
+ * Sorts a list of chains so it matches the canonical network selector order.
+ * Optionally promotes the provided `pinChainId` to the first position.
+ */
+export function sortChainsByDisplayOrder(chains: ChainInfo[], options?: SortOptions): ChainInfo[] {
+ if (chains.length <= 1) {
+ return chains.slice()
+ }
+
+ const weightedChains = chains.map((chain, index) => ({
+ chain,
+ weight: CHAIN_ORDER[chain.id as SupportedChainId] ?? Number.MAX_SAFE_INTEGER,
+ index,
+ }))
+
+ weightedChains.sort((a, b) => {
+ if (a.weight === b.weight) {
+ return a.index - b.index
+ }
+
+ return a.weight - b.weight
+ })
+
+ const orderedChains = weightedChains.map((entry) => entry.chain)
+
+ if (!options?.pinChainId) {
+ return orderedChains
+ }
+
+ const pinIndex = orderedChains.findIndex((chain) => chain.id === options.pinChainId)
+
+ if (pinIndex <= 0) {
+ return orderedChains
+ }
+
+ const [pinnedChain] = orderedChains.splice(pinIndex, 1)
+ orderedChains.unshift(pinnedChain)
+
+ return orderedChains
+}
From 41e42819da8a7983cacad7dc936b042c7ef100f4 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 14 Nov 2025 13:16:15 +0000
Subject: [PATCH 006/100] feat(tokenselector): enhance TradeWidget with token
selection and responsive layout adjustments
---
.../TradeWidget/TradeWidgetModals.tsx | 37 +++++++------------
.../trade/containers/TradeWidget/index.tsx | 37 ++++++++++++-------
.../trade/containers/TradeWidget/styled.tsx | 9 ++++-
3 files changed, 43 insertions(+), 40 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx
index d1a1a34f8af..eb8ddf1b92b 100644
--- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx
+++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx
@@ -13,13 +13,8 @@ import {
useSetUserApproveAmountModalState,
} from 'modules/erc20Approve'
import { useTradeApproveState } from 'modules/erc20Approve/state/useTradeApproveState'
-import {
- ImportTokenModal,
- SelectTokenWidget,
- useSelectTokenWidgetState,
- useTokenListAddingError,
- useUpdateSelectTokenWidgetState,
-} from 'modules/tokensList'
+import { ImportTokenModal, useSelectTokenWidgetState, useTokenListAddingError } from 'modules/tokensList'
+import { useCloseTokenSelectWidget } from 'modules/tokensList/hooks/useCloseTokenSelectWidget'
import { useZeroApproveModalState, ZeroApprovalModal } from 'modules/zeroApproval'
import { TransactionErrorContent } from 'common/pure/TransactionErrorContent'
@@ -34,22 +29,17 @@ import { WrapNativeModal } from '../WrapNativeModal'
interface TradeWidgetModalsProps {
confirmModal: ReactNode | undefined
genericModal: ReactNode | undefined
- selectTokenWidget: ReactNode | undefined
}
// todo refactor it
-// eslint-disable-next-line complexity,max-lines-per-function
-export function TradeWidgetModals({
- confirmModal,
- genericModal,
- selectTokenWidget = ,
-}: TradeWidgetModalsProps): ReactNode {
+// eslint-disable-next-line max-lines-per-function
+export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetModalsProps): ReactNode {
const { chainId, account } = useWalletInfo()
const { state: rawState } = useTradeState()
const importTokenCallback = useAddUserToken()
const { isOpen: isTradeReviewOpen, error: confirmError, pendingTrade } = useTradeConfirmState()
- const { open: isTokenSelectOpen, field } = useSelectTokenWidgetState()
+ const { field } = useSelectTokenWidgetState()
const [{ isOpen: isWrapNativeOpen }, setWrapNativeScreenState] = useWrapNativeScreenState()
const {
approveInProgress,
@@ -67,16 +57,16 @@ export function TradeWidgetModals({
} = useAutoImportTokensState(rawState?.inputCurrencyId, rawState?.outputCurrencyId)
const { onDismiss: closeTradeConfirm } = useTradeConfirmActions()
- const updateSelectTokenWidgetState = useUpdateSelectTokenWidgetState()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
const resetApproveModalState = useResetApproveProgressModalState()
const updateApproveAmountState = useSetUserApproveAmountModalState()
const resetAllScreens = useCallback(
- (closeTokenSelectWidget = true, shouldCloseAutoImportModal = true) => {
+ (shouldCloseTokenSelectWidget = true, shouldCloseAutoImportModal = true) => {
closeTradeConfirm()
closeZeroApprovalModal()
if (shouldCloseAutoImportModal) closeAutoImportModal()
- if (closeTokenSelectWidget) updateSelectTokenWidgetState({ open: false })
+ if (shouldCloseTokenSelectWidget) closeTokenSelectWidget()
setWrapNativeScreenState({ isOpen: false })
resetApproveModalState()
setTokenListAddingError(null)
@@ -86,7 +76,7 @@ export function TradeWidgetModals({
closeTradeConfirm,
closeZeroApprovalModal,
closeAutoImportModal,
- updateSelectTokenWidgetState,
+ closeTokenSelectWidget,
setWrapNativeScreenState,
resetApproveModalState,
updateApproveAmountState,
@@ -96,7 +86,10 @@ export function TradeWidgetModals({
const isOutputTokenSelector = field === Field.OUTPUT
const isOutputTokenSelectorRef = useRef(isOutputTokenSelector)
- isOutputTokenSelectorRef.current = isOutputTokenSelector
+
+ useEffect(() => {
+ isOutputTokenSelectorRef.current = isOutputTokenSelector
+ }, [isOutputTokenSelector])
const error = tokenListAddingError || approveError || confirmError
@@ -127,10 +120,6 @@ export function TradeWidgetModals({
return
}
- if (isTokenSelectOpen) {
- return selectTokenWidget
- }
-
if (isAutoImportModalOpen) {
return
}
diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx
index a2fa5606d57..804ecf787b3 100644
--- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx
@@ -1,6 +1,6 @@
import { JSX, useEffect } from 'react'
-import { useSelectTokenWidgetState } from 'modules/tokensList'
+import { SelectTokenWidget, useChainsToSelect, useSelectTokenWidgetState } from 'modules/tokensList'
import { useSetShouldUseAutoSlippage } from 'modules/tradeSlippage'
import * as styledEl from './styled'
@@ -17,8 +17,13 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element {
disableSuggestedSlippageApi = false,
enableSmartSlippage,
} = params
- const modals = TradeWidgetModals({ confirmModal, genericModal, selectTokenWidget: slots.selectTokenWidget })
+ const modals = TradeWidgetModals({ confirmModal, genericModal })
const { open: isTokenSelectOpen } = useSelectTokenWidgetState()
+ const chainsToSelect = useChainsToSelect()
+ const isTokenSelectWide =
+ isTokenSelectOpen && !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0)
+
+ const selectTokenWidgetNode = slots.selectTokenWidget ??
const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage()
@@ -27,17 +32,21 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element {
}, [enableSmartSlippage, setShouldUseAutoSlippage])
return (
-
-
- {slots.updaters}
-
-
- {modals || }
-
+ <>
+
+
+ {slots.updaters}
+
+
+ {modals || }
+
+
+ {selectTokenWidgetNode}
+ >
)
}
diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx
index 2e93ac29d82..7cb13d23761 100644
--- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx
@@ -3,9 +3,14 @@ import { UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
import { WIDGET_MAX_WIDTH } from 'theme'
-export const Container = styled.div<{ isTokenSelectOpen?: boolean }>`
+export const Container = styled.div<{ isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean }>`
width: 100%;
- max-width: ${({ isTokenSelectOpen }) => (isTokenSelectOpen ? WIDGET_MAX_WIDTH.tokenSelect : WIDGET_MAX_WIDTH.swap)};
+ max-width: ${({ isTokenSelectOpen, isTokenSelectWide }) =>
+ isTokenSelectOpen
+ ? isTokenSelectWide
+ ? WIDGET_MAX_WIDTH.tokenSelectSidebar
+ : WIDGET_MAX_WIDTH.tokenSelect
+ : WIDGET_MAX_WIDTH.swap};
margin: 0 auto;
position: relative;
`
From 7063b6a8de9c8786a9f8681dc9582feda2c98567 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 14 Nov 2025 16:38:50 +0000
Subject: [PATCH 007/100] feat(tokenselector): implement SelectTokenModal with
enhanced token selection
---
.../pure/SelectTokenModal/helpers.tsx | 142 ++++++++
.../pure/SelectTokenModal/index.cosmos.tsx | 25 +-
.../pure/SelectTokenModal/index.tsx | 312 ++++++++++--------
.../pure/SelectTokenModal/styled.ts | 206 +++++++++---
.../tokensList/pure/SelectTokenModal/types.ts | 46 +++
5 files changed, 548 insertions(+), 183 deletions(-)
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/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
new file mode 100644
index 00000000000..829b24179b2
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
@@ -0,0 +1,142 @@
+import { ReactNode, useMemo, useState } from 'react'
+
+import { BackButton } from '@cowprotocol/ui'
+
+import { SettingsIcon } from 'modules/trade/pure/Settings'
+
+import * as styledEl from './styled'
+
+import { SelectTokenContext } from '../../types'
+import { TokensContent } from '../TokensContent'
+
+import type { SelectTokenModalProps } from './types'
+
+export function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext {
+ const {
+ selectedToken,
+ balancesState,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ onSelectToken,
+ onTokenListItemClick,
+ account,
+ tokenListTags,
+ } = props
+
+ return useMemo(
+ () => ({
+ balancesState,
+ selectedToken,
+ onSelectToken,
+ onTokenListItemClick,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ tokenListTags,
+ isWalletConnected: !!account,
+ }),
+ [
+ balancesState,
+ selectedToken,
+ onSelectToken,
+ onTokenListItemClick,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ tokenListTags,
+ account,
+ ],
+ )
+}
+
+export function useTokenSearchInput(defaultInputValue = ''): [string, (value: string) => void, string] {
+ const [inputValue, setInputValue] = useState(defaultInputValue)
+
+ return [inputValue, setInputValue, inputValue.trim()]
+}
+
+interface TokensContentSectionProps
+ extends Pick<
+ SelectTokenModalProps,
+ | 'displayLpTokenLists'
+ | 'favoriteTokens'
+ | 'recentTokens'
+ | 'areTokensLoading'
+ | 'allTokens'
+ | 'areTokensFromBridge'
+ | 'hideFavoriteTokensTooltip'
+ | 'selectedTargetChainId'
+ | 'onClearRecentTokens'
+ | 'onOpenManageWidget'
+ | 'standalone'
+ > {
+ searchInput: string
+ selectTokenContext: SelectTokenContext
+}
+
+export function TokensContentSection({
+ displayLpTokenLists,
+ favoriteTokens,
+ recentTokens,
+ areTokensLoading,
+ allTokens,
+ searchInput,
+ areTokensFromBridge,
+ hideFavoriteTokensTooltip,
+ selectedTargetChainId,
+ selectTokenContext,
+ onClearRecentTokens,
+ onOpenManageWidget,
+ standalone,
+}: TokensContentSectionProps): ReactNode {
+ return (
+
+ )
+}
+
+interface TitleBarActionsProps {
+ showManageButton: boolean
+ onDismiss(): void
+ onOpenManageWidget(): void
+ title: string
+}
+
+export function TitleBarActions({
+ showManageButton,
+ onDismiss,
+ onOpenManageWidget,
+ title,
+}: TitleBarActionsProps): ReactNode {
+ return (
+
+
+
+ {title}
+
+ {showManageButton && (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx
index 00fef623738..b7335114950 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,7 @@ import { BigNumber } from '@ethersproject/bignumber'
import styled from 'styled-components/macro'
import { allTokensMock, favoriteTokensMock } from '../../mocks'
+import { mapChainInfo } from '../../utils/mapChainInfo'
import { SelectTokenModal, SelectTokenModalProps } from './index'
@@ -13,7 +15,7 @@ const Wrapper = styled.div`
max-height: 90vh;
margin: 20px auto;
display: flex;
- width: 450px;
+ width: 520px;
`
const unsupportedTokens = {}
@@ -26,6 +28,20 @@ const balances = allTokensMock.reduce((acc, token) => {
return acc
}, {})
+const chainsMock: ChainInfo[] = [
+ SupportedChainId.MAINNET,
+ SupportedChainId.BASE,
+ SupportedChainId.ARBITRUM_ONE,
+].reduce((acc, id) => {
+ const info = CHAIN_INFO[id]
+
+ if (info) {
+ acc.push(mapChainInfo(id, info))
+ }
+
+ return acc
+}, [])
+
const defaultProps: SelectTokenModalProps = {
tokenListTags: {},
account: undefined,
@@ -35,7 +51,11 @@ const defaultProps: SelectTokenModalProps = {
favoriteTokens: favoriteTokensMock,
areTokensLoading: false,
areTokensFromBridge: false,
- chainsToSelect: undefined,
+ chainsToSelect: {
+ chains: chainsMock,
+ isLoading: false,
+ defaultChainId: SupportedChainId.MAINNET,
+ },
onSelectChain(chain: ChainInfo) {
console.log('onSelectChain', chain)
},
@@ -48,6 +68,7 @@ const defaultProps: SelectTokenModalProps = {
},
selectedToken,
isRouteAvailable: true,
+ modalTitle: 'Swap from',
recentTokens: favoriteTokensMock.slice(0, 2),
selectedTargetChainId: SupportedChainId.SEPOLIA,
onSelectToken() {
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 a4e1e5f5609..18b7cd849b5 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -1,94 +1,21 @@
-import React, { ReactNode, useMemo, useState } from 'react'
+import { ReactNode } from 'react'
-import { BalancesState } from '@cowprotocol/balances-and-allowances'
-import { TokenWithLogo } from '@cowprotocol/common-const'
-import { ChainInfo } from '@cowprotocol/cow-sdk'
-import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens'
import { SearchInput } from '@cowprotocol/ui'
-import { Currency } from '@uniswap/sdk-core'
import { t } from '@lingui/core/macro'
-import { X } from 'react-feather'
-import { Nullish } from 'types'
-
-import { PermitCompatibleTokens } from 'modules/permit'
+import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers'
import { SelectTokenModalContent } from './SelectTokenModalContent'
import * as styledEl from './styled'
+
import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
-import { ChainsToSelectState, SelectTokenContext, TokenSelectionHandler } from '../../types'
import { ChainsSelector } from '../ChainsSelector'
-import { IconButton } from '../commonElements'
-import { TokensContent } from '../TokensContent'
-
-export interface SelectTokenModalProps {
- allTokens: TokenWithLogo[]
- favoriteTokens: TokenWithLogo[]
- recentTokens?: TokenWithLogo[]
- balancesState: BalancesState
- unsupportedTokens: UnsupportedTokensState
- selectedToken?: Nullish
- permitCompatibleTokens: PermitCompatibleTokens
- hideFavoriteTokensTooltip?: boolean
- displayLpTokenLists?: boolean
- disableErc20?: boolean
- account: string | undefined
- chainsToSelect: ChainsToSelectState | undefined
- tokenListCategoryState: [T, (category: T) => void]
- defaultInputValue?: string
- areTokensLoading: boolean
- tokenListTags: TokenListTags
- standalone?: boolean
- areTokensFromBridge: boolean
- isRouteAvailable: boolean | undefined
- selectedTargetChainId?: number
- onSelectToken: TokenSelectionHandler
- onTokenListItemClick?(token: TokenWithLogo): void
- openPoolPage(poolAddress: string): void
- onInputPressEnter?(): void
- onOpenManageWidget(): void
- onDismiss(): void
- onSelectChain(chain: ChainInfo): void
- onClearRecentTokens?(): void
-}
+import type { SelectTokenModalProps } from './types'
+import type { TokenSelectionHandler } from '../../types'
-function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext {
- const {
- selectedToken,
- balancesState,
- unsupportedTokens,
- permitCompatibleTokens,
- onSelectToken,
- onTokenListItemClick,
- account,
- tokenListTags,
- } = props
-
- return useMemo(
- () => ({
- balancesState,
- selectedToken,
- onSelectToken,
- onTokenListItemClick,
- unsupportedTokens,
- permitCompatibleTokens,
- tokenListTags,
- isWalletConnected: !!account,
- }),
- [
- balancesState,
- selectedToken,
- onSelectToken,
- onTokenListItemClick,
- unsupportedTokens,
- permitCompatibleTokens,
- tokenListTags,
- account,
- ],
- )
-}
+export type { SelectTokenModalProps }
export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
const {
@@ -105,64 +32,191 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
onSelectChain,
areTokensFromBridge,
isRouteAvailable,
+ standalone,
+ onOpenManageWidget,
+ favoriteTokens,
+ recentTokens,
+ onClearRecentTokens,
+ areTokensLoading,
+ allTokens,
+ hideFavoriteTokensTooltip,
+ selectedTargetChainId,
+ modalTitle,
+ hasChainPanel = false,
+ isFullScreenMobile,
} = props
- const [inputValue, setInputValue] = useState(defaultInputValue)
+ const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
const selectTokenContext = useSelectTokenContext(props)
+ const resolvedModalTitle = modalTitle ?? t`Select token`
+ const legacyChainsState =
+ !hasChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined
- const trimmedInputValue = inputValue.trim()
+ return (
+
+
+
+
+
+ )
+}
- const allListsContent = (
-
+interface TokenColumnContentProps {
+ displayLpTokenLists?: boolean
+ account: string | undefined
+ inputValue: string
+ onSelectToken: TokenSelectionHandler
+ openPoolPage(poolAddress: string): void
+ disableErc20?: boolean
+ tokenListCategoryState: SelectTokenModalProps['tokenListCategoryState']
+ isRouteAvailable: boolean | undefined
+ chainsToSelect?: SelectTokenModalProps['chainsToSelect']
+ onSelectChain: SelectTokenModalProps['onSelectChain']
+ children: ReactNode
+}
+
+function TokenColumnContent({
+ displayLpTokenLists,
+ account,
+ inputValue,
+ onSelectToken,
+ openPoolPage,
+ disableErc20,
+ tokenListCategoryState,
+ isRouteAvailable,
+ chainsToSelect,
+ onSelectChain,
+ children,
+}: TokenColumnContentProps): ReactNode {
+ if (displayLpTokenLists) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+ <>
+ {renderLegacyChainSelector(chainsToSelect, onSelectChain)}
+ {children}
+ >
)
+}
+
+function renderLegacyChainSelector(
+ chainsToSelect: SelectTokenModalProps['chainsToSelect'],
+ onSelectChain: SelectTokenModalProps['onSelectChain'],
+): ReactNode {
+ if (!chainsToSelect?.chains?.length) {
+ return null
+ }
return (
-
-
- e.key === 'Enter' && onInputPressEnter?.()}
- onChange={(e) => setInputValue(e.target.value)}
- placeholder={t`Search name or paste address...`}
- />
-
-
-
-
- {displayLpTokenLists ? (
-
- {allListsContent}
-
- ) : (
- <>
- {!!chainsToSelect?.chains?.length && (
- <>
-
-
-
- >
- )}
- {allListsContent}
- >
- )}
+
+
+
+ )
+}
+
+interface SelectTokenModalShellProps {
+ children: ReactNode
+ hasChainPanel: boolean
+ isFullScreenMobile?: boolean
+ title: string
+ showManageButton: boolean
+ onDismiss(): void
+ onOpenManageWidget: () => void
+ searchValue: string
+ onSearchChange(value: string): void
+ onSearchEnter?: () => void
+}
+
+function SelectTokenModalShell({
+ children,
+ hasChainPanel,
+ isFullScreenMobile,
+ title,
+ showManageButton,
+ onDismiss,
+ onOpenManageWidget,
+ searchValue,
+ onSearchChange,
+ onSearchEnter,
+}: SelectTokenModalShellProps): ReactNode {
+ return (
+
+
+
+
+ {
+ if (event.key === 'Enter') {
+ onSearchEnter?.()
+ }
+ }}
+ onChange={(event) => onSearchChange(event.target.value)}
+ placeholder={t`Search name or paste address...`}
+ />
+
+
+
+ {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 9bcc0473df5..b5860b1c0a0 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -1,67 +1,171 @@
-import { UI } from '@cowprotocol/ui'
+import { Media, UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
import { blankButtonMixin } from '../commonElements'
-export const Wrapper = styled.div`
+export const Wrapper = styled.div<{ $hasChainPanel?: boolean; $isFullScreen?: boolean }>`
display: flex;
flex-direction: column;
background: var(${UI.COLOR_PAPER});
- border-radius: 20px;
+ border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '20px')};
width: 100%;
+ overflow: hidden;
+ border-top-right-radius: ${({ $hasChainPanel, $isFullScreen }) =>
+ $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'};
+ border-bottom-right-radius: ${({ $hasChainPanel, $isFullScreen }) =>
+ $isFullScreen ? '0' : $hasChainPanel ? '0' : '20px'};
+
+ ${Media.upToMedium()} {
+ border-radius: ${({ $isFullScreen }) => ($isFullScreen ? '0' : '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: 12px 14px;
+ gap: 12px;
+
+ ${Media.upToSmall()} {
+ padding: 14px 14px 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;
+
+ ${Media.upToSmall()} {
+ font-size: 18px;
+ }
`
-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 14px 14px;
+ display: flex;
+ align-items: center;
+`
+
+export const SearchInputWrapper = styled.div`
+ --input-height: 46px;
+ width: 100%;
+
+ > div {
+ width: 100%;
+ background: var(${UI.COLOR_PAPER_DARKER});
+ border-radius: var(--input-height);
+ height: var(--input-height);
+ display: flex;
+ align-items: center;
+ padding: 0 14px;
+ font-size: 15px;
+ }
+
+ input {
+ background: transparent;
+ height: 100%;
+ }
+`
+
+export const Body = styled.div`
+ display: flex;
+ flex: 1;
+ min-height: 0;
+
+ ${Media.upToMedium()} {
+ flex-direction: column;
+ }
+`
+
+export const TokenColumn = styled.div`
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+`
+
+export const LegacyChainsWrapper = styled.div`
+ border-bottom: 1px solid var(${UI.COLOR_BORDER});
+ padding: 2px 10px 10px 14px;
+ margin: 0 14px 16px;
+
+ ${Media.upToSmall()} {
+ margin: 0 10px 16px;
+ padding: 2px 4px 10px 8px;
+ }
+`
+
+export const Row = styled.div`
+ padding: 0 24px;
+ margin-bottom: 16px;
+
+ ${Media.upToSmall()} {
+ padding: 0 16px;
+ }
+`
+
+export const Separator = styled.div`
+ width: 100%;
+ border-bottom: 1px solid var(${UI.COLOR_BORDER});
+ margin: 0 0 16px;
+`
+
+export const ListTitle = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 14px;
+ font-weight: 500;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ padding: 8px 16px 4px;
+`
+
+export const ListTitleActionButton = styled.button`
+ ${blankButtonMixin};
+ font-size: 13px;
+ font-weight: 600;
+ color: var(${UI.COLOR_TEXT});
+ padding: 2px 6px;
+ border-radius: 6px;
+ transition: color var(${UI.ANIMATION_DURATION}) ease-in-out;
+
+ &:hover {
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
}
`
@@ -69,7 +173,7 @@ export const TokensLoader = styled.div`
width: 100%;
height: 100%;
overflow: auto;
- padding: 20px 0;
+ padding: 40px 0;
text-align: center;
`
@@ -77,31 +181,29 @@ export const RouteNotAvailable = styled.div`
width: 100%;
height: 100%;
overflow: auto;
- padding: 20px 0;
+ padding: 40px 0;
text-align: center;
`
-export const ListTitle = styled.div`
+export const ActionButton = styled.button`
+ ${blankButtonMixin};
+
display: flex;
- justify-content: space-between;
+ width: 100%;
align-items: center;
- font-size: 12px;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: var(${UI.COLOR_TEXT_OPACITY_50});
- padding: 12px 20px 4px;
-`
-
-export const ListTitleActionButton = styled.button`
- ${blankButtonMixin};
- font-size: 12px;
- font-weight: 600;
- color: var(${UI.COLOR_PRIMARY});
+ flex-direction: row;
+ justify-content: center;
+ gap: 10px;
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;
&:hover {
- opacity: 0.75;
+ opacity: 1;
}
`
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 00000000000..649cfcfe1b8
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -0,0 +1,46 @@
+import { BalancesState } from '@cowprotocol/balances-and-allowances'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens'
+import { Currency } from '@uniswap/sdk-core'
+
+import { Nullish } from 'types'
+
+import { PermitCompatibleTokens } from 'modules/permit'
+
+import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
+
+export interface SelectTokenModalProps {
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ balancesState: BalancesState
+ unsupportedTokens: UnsupportedTokensState
+ selectedToken?: Nullish
+ permitCompatibleTokens: PermitCompatibleTokens
+ hideFavoriteTokensTooltip?: boolean
+ displayLpTokenLists?: boolean
+ disableErc20?: boolean
+ account: string | undefined
+ chainsToSelect?: ChainsToSelectState
+ tokenListCategoryState: [T, (category: T) => void]
+ defaultInputValue?: string
+ areTokensLoading: boolean
+ tokenListTags: TokenListTags
+ standalone?: boolean
+ areTokensFromBridge: boolean
+ isRouteAvailable: boolean | undefined
+ selectedTargetChainId?: number
+ modalTitle?: string
+ hasChainPanel?: boolean
+ isFullScreenMobile?: boolean
+
+ onSelectToken: TokenSelectionHandler
+ onTokenListItemClick?(token: TokenWithLogo): void
+ onClearRecentTokens?(): void
+ openPoolPage(poolAddress: string): void
+ onInputPressEnter?(): void
+ onOpenManageWidget(): void
+ onDismiss(): void
+ onSelectChain(chain: ChainInfo): void
+}
From a52a5dda66c7d89bab5f2a2cf49dce0c4baaa876 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 14 Nov 2025 17:31:48 +0000
Subject: [PATCH 008/100] feat(tokenselector): enhance SelectTokenModal with
chain selection
---
apps/cowswap-frontend/src/locales/en-US.po | 26 +-
.../containers/SelectTokenWidget/index.tsx | 9 +
.../tokensList/pure/ChainPanel/index.tsx | 119 +++++++++
.../tokensList/pure/ChainPanel/styled.tsx | 115 ++++++++
.../pure/ChainsSelector/index.cosmos.tsx | 23 +-
.../tokensList/pure/ChainsSelector/index.tsx | 248 ++++++++++++------
.../tokensList/pure/ChainsSelector/styled.tsx | 202 +++++++-------
.../pure/SelectTokenModal/index.cosmos.tsx | 9 +-
.../pure/SelectTokenModal/index.tsx | 70 ++++-
.../pure/SelectTokenModal/styled.ts | 2 +
.../tokensList/pure/SelectTokenModal/types.ts | 1 +
11 files changed, 621 insertions(+), 203 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.tsx
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index 8916c8fb9a0..ab5722a3b5b 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -270,6 +270,10 @@ msgstr "replaced"
msgid "Bridge via"
msgstr "Bridge via"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+msgid "Select token"
+msgstr "Select token"
+
#: apps/cowswap-frontend/src/modules/trade/pure/LimitOrdersPromoBanner/index.tsx
msgid "Trade your way - personalize the interface and customize your limit orders"
msgstr "Trade your way - personalize the interface and customize your limit orders"
@@ -455,7 +459,6 @@ msgid "View details"
msgstr "View details"
#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
-#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "More"
msgstr "More"
@@ -2208,6 +2211,11 @@ msgstr "Dismiss hiring message"
msgid "dialog content"
msgstr "dialog content"
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+msgid "Cross chain swap"
+msgstr "Cross chain swap"
+
#: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx
msgid "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are"
msgstr "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are"
@@ -3660,6 +3668,10 @@ msgstr "Create LlamaPay Vesting"
msgid "Transaction expiration"
msgstr "Transaction expiration"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "Search network"
+msgstr "Search network"
+
#: apps/cowswap-frontend/src/legacy/components/ErrorBoundary/ChunkLoadError.tsx
msgid "CowSwap no connection"
msgstr "CowSwap no connection"
@@ -4725,6 +4737,10 @@ msgstr "Execution price"
msgid "No tokens found"
msgstr "No tokens found"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks available for this trade."
+msgstr "No networks available for this trade."
+
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenTags/index.tsx
msgid "Unsupported"
msgstr "Unsupported"
@@ -4765,6 +4781,10 @@ msgstr "<0/><1/>Consider waiting for lower network costs.<2/><3/>You may still m
msgid "Swapping on"
msgstr "Swapping on"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks match \"{chainQuery}\"."
+msgstr "No networks match \"{chainQuery}\"."
+
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/OrderTypeField.tsx
msgid "(Fill or Kill)"
msgstr "(Fill or Kill)"
@@ -5898,8 +5918,8 @@ msgid "You sold <0/>"
msgstr "You sold <0/>"
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
-msgid "Less"
-msgstr "Less"
+#~ msgid "Less"
+#~ msgstr "Less"
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
msgid "Claim your LlamaPay vesting contract funds"
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 21cc6df5bef..4fdb05af604 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -2,6 +2,7 @@ import { ReactNode, useCallback, useState } from 'react'
import { useCowAnalytics } from '@cowprotocol/analytics'
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
import { isInjectedWidget } from '@cowprotocol/common-utils'
import {
ListState,
@@ -15,6 +16,7 @@ import {
} from '@cowprotocol/tokens'
import { useWalletInfo } from '@cowprotocol/wallet'
+import { t } from '@lingui/core/macro'
import styled from 'styled-components/macro'
import { Field } from 'legacy/state/types'
@@ -23,6 +25,7 @@ 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'
@@ -74,6 +77,7 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
} = useSelectTokenWidgetState()
const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances()
const chainsToSelect = useChainsToSelect()
+ const isBridgingEnabled = useIsBridgingEnabled()
const onSelectChain = useOnSelectChain()
const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false)
@@ -125,6 +129,9 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
const isInjectedWidgetMode = isInjectedWidget()
+ const showChainPanel = isBridgingEnabled && Boolean(chainsToSelect?.chains?.length)
+ const chainsPanelTitle = t`Cross chain swap`
+
const closeTokenSelectWidget = useCloseTokenSelectWidget()
const openPoolPage = useCallback(
@@ -242,6 +249,8 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
disableErc20={disableErc20}
account={account}
chainsToSelect={chainsToSelect}
+ hasChainPanel={showChainPanel}
+ chainsPanelTitle={chainsPanelTitle}
onSelectChain={onSelectChain}
areTokensLoading={areTokensLoading}
tokenListTags={tokenListTags}
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 00000000000..bc4ff079993
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -0,0 +1,119 @@
+import { ReactNode, useMemo, useState } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { BackButton } from '@cowprotocol/ui'
+
+import { t } from '@lingui/core/macro'
+
+import * as styledEl from './styled'
+
+import { ChainsToSelectState } from '../../types'
+import { ChainsSelector } from '../ChainsSelector'
+
+const EMPTY_CHAINS: ChainInfo[] = []
+
+export interface ChainPanelProps {
+ title: string
+ chainsState: ChainsToSelectState | undefined
+ onSelectChain(chain: ChainInfo): void
+ variant?: 'default' | 'fullscreen'
+ onClose?(): void
+}
+
+export function ChainPanel({
+ title,
+ chainsState,
+ onSelectChain,
+ variant = 'default',
+ onClose,
+}: ChainPanelProps): ReactNode {
+ const [chainQuery, setChainQuery] = useState('')
+ const chains = chainsState?.chains ?? EMPTY_CHAINS
+ const isLoading = chainsState?.isLoading ?? false
+ const normalizedChainQuery = chainQuery.trim().toLowerCase()
+
+ const filteredChains = useMemo(
+ () => filterChainsByQuery(chains, normalizedChainQuery),
+ [chains, normalizedChainQuery],
+ )
+
+ const { showSearchEmptyState, showUnavailableState } = getEmptyStateFlags({
+ filteredChainsLength: filteredChains.length,
+ isLoading,
+ normalizedChainQuery,
+ totalChains: chains.length,
+ })
+
+ return (
+
+
+
+ setChainQuery(event.target.value)}
+ placeholder={t`Search network`}
+ />
+
+
+
+ {showUnavailableState && {t`No networks available for this trade.`}}
+ {showSearchEmptyState && {t`No networks match "${chainQuery}".`}}
+
+
+ )
+}
+
+interface ChainPanelHeaderProps {
+ title: string
+ variant: 'default' | 'fullscreen'
+ onClose?: () => void
+}
+
+function ChainPanelHeader({ title, variant, onClose }: ChainPanelHeaderProps): ReactNode {
+ const isFullscreen = variant === 'fullscreen'
+
+ return (
+
+ {isFullscreen && onClose ? : null}
+ {title}
+ {isFullscreen && onClose ? : null}
+
+ )
+}
+
+function filterChainsByQuery(chains: ChainInfo[], normalizedChainQuery: string): ChainInfo[] {
+ if (!chains.length || !normalizedChainQuery) {
+ return chains
+ }
+
+ return chains.filter((chain) => {
+ const labelMatch = chain.label.toLowerCase().includes(normalizedChainQuery)
+ const idMatch = String(chain.id).includes(normalizedChainQuery)
+
+ return labelMatch || idMatch
+ })
+}
+
+function getEmptyStateFlags({
+ filteredChainsLength,
+ isLoading,
+ normalizedChainQuery,
+ totalChains,
+}: {
+ filteredChainsLength: number
+ isLoading: boolean
+ normalizedChainQuery: string
+ totalChains: number
+}): { showSearchEmptyState: boolean; showUnavailableState: boolean } {
+ const hasQuery = Boolean(normalizedChainQuery)
+
+ return {
+ showUnavailableState: !isLoading && totalChains === 0 && !hasQuery,
+ showSearchEmptyState: !isLoading && filteredChainsLength === 0 && hasQuery,
+ }
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx
new file mode 100644
index 00000000000..6cbbb54eb75
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx
@@ -0,0 +1,115 @@
+import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui'
+
+import styled from 'styled-components/macro'
+
+export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>`
+ width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '240px')};
+ min-width: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '220px')};
+ max-width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '280px')};
+ height: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : 'auto')};
+ flex-shrink: 0;
+ background: var(${UI.COLOR_PAPER_DARKER});
+ border-left: ${({ $variant }) => ($variant === 'fullscreen' ? 'none' : `1px solid var(${UI.COLOR_BORDER})`)};
+ padding: ${({ $variant }) => ($variant === 'fullscreen' ? '20px 16px' : '16px 10px')};
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-height: 0;
+ border-top-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')};
+ border-bottom-right-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '20px')};
+
+ ${Media.upToMedium()} {
+ width: 100%;
+ min-width: 0;
+ border-left: none;
+ border-top: 1px solid var(${UI.COLOR_BORDER});
+ border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')};
+ }
+
+ ${Media.upToSmall()} {
+ padding: ${({ $variant }) => ($variant === 'fullscreen' ? '14px' : '16px')};
+ background: var(${UI.COLOR_PAPER});
+ }
+`
+
+export const PanelHeader = styled.div<{ $isFullscreen?: boolean }>`
+ display: flex;
+ align-items: center;
+ justify-content: ${({ $isFullscreen }) => ($isFullscreen ? 'space-between' : 'space-between')};
+ gap: 12px;
+ padding: ${({ $isFullscreen }) => ($isFullscreen ? '4px 0' : '0')};
+`
+
+export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>`
+ font-size: ${({ $isFullscreen }) => ($isFullscreen ? '18px' : '14px')};
+ font-weight: ${({ $isFullscreen }) => ($isFullscreen ? 600 : 500)};
+ margin: 0;
+ flex: 1;
+ text-align: ${({ $isFullscreen }) => ($isFullscreen ? 'left' : 'center')};
+ color: ${({ $isFullscreen }) => ($isFullscreen ? `var(${UI.COLOR_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)};
+`
+
+export const PanelSearchInputWrapper = styled.div`
+ --min-height: 36px;
+ min-height: var(--min-height);
+ border: 1px solid var(${UI.COLOR_PAPER_DARKEST});
+ background: transparent;
+ border-radius: var(--min-height);
+ padding: 0 10px;
+ color: var(${UI.COLOR_TEXT});
+
+ ${Media.upToSmall()} {
+ --min-height: 46px;
+ border: none;
+ padding: 0;
+ background: transparent;
+ color: inherit;
+
+ > div {
+ width: 100%;
+ background: var(${UI.COLOR_PAPER_DARKER});
+ border-radius: var(--min-height);
+ height: var(--min-height);
+ display: flex;
+ align-items: center;
+ padding: 0 14px;
+ font-size: 15px;
+ color: inherit;
+ }
+
+ input {
+ background: transparent;
+ height: 100%;
+ }
+ }
+`
+
+export const PanelSearchInput = styled(UISearchInput)`
+ width: 100%;
+ color: inherit;
+ border: none;
+ background: transparent;
+ font-size: 14px;
+ font-weight: 400;
+
+ ${Media.upToSmall()} {
+ font-size: 16px;
+ }
+`
+
+export const PanelList = styled.div`
+ flex: 1;
+ overflow-y: auto;
+ padding-right: 8px;
+ margin-right: -8px;
+ box-sizing: content-box;
+ ${({ theme }) => theme.colorScrollbar};
+ scrollbar-gutter: stable;
+`
+
+export const EmptyState = styled.div`
+ text-align: center;
+ font-size: 14px;
+ color: var(${UI.COLOR_TEXT_OPACITY_70});
+ padding: 32px 8px;
+`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx
index 0d84c815ad6..18e22153db9 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.cosmos.tsx
@@ -1,5 +1,5 @@
import { CHAIN_INFO } from '@cowprotocol/common-const'
-import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk'
+import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
import styled from 'styled-components/macro'
@@ -7,17 +7,15 @@ import { mapChainInfo } from '../../utils/mapChainInfo'
import { ChainsSelector } from './index'
-const chains: ChainInfo[] = [
- ...Object.keys(CHAIN_INFO).map((chainId) => {
- const supportedChainId = +chainId as SupportedChainId
- const info = CHAIN_INFO[supportedChainId]
+const chains: ChainInfo[] = Object.keys(CHAIN_INFO).map((chainId) => {
+ const supportedChainId = Number(chainId) as SupportedChainId
+ const info = CHAIN_INFO[supportedChainId]
- return mapChainInfo(supportedChainId, info)
- }),
-]
+ return mapChainInfo(supportedChainId, info)
+})
const Wrapper = styled.div`
- width: 450px;
+ width: 320px;
`
const Fixtures = {
@@ -26,10 +24,15 @@ const Fixtures = {
console.log('Chain selected: ', chainId)}
+ onSelectChain={(chain) => console.log('Chain selected: ', chain.label)}
/>
),
+ loading: () => (
+
+ undefined} />
+
+ ),
}
export default Fixtures
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 c41203af129..7a12265d19f 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -1,112 +1,188 @@
import { ReactNode } from 'react'
-import { useMediaQuery, useTheme } from '@cowprotocol/common-hooks'
-import { ChainInfo } from '@cowprotocol/cow-sdk'
-import { HoverTooltip, Media } from '@cowprotocol/ui'
+import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg'
+import { useTheme } from '@cowprotocol/common-hooks'
+import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
+import { UI } from '@cowprotocol/ui'
-import { Trans } from '@lingui/react/macro'
-import { Menu, MenuButton, MenuItem } from '@reach/menu-button'
-import { Check, ChevronDown, ChevronUp } from 'react-feather'
+import SVG from 'react-inlinesvg'
import * as styledEl from './styled'
-// Number of skeleton shimmers to show during loading state
+import type { ChainAccentVars } from './styled'
+
const LOADING_ITEMS_COUNT = 10
+const LOADING_SKELETON_INDICES = Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => index)
-const LoadingShimmerElements = (
-
- {Array.from({ length: LOADING_ITEMS_COUNT }, (_, index) => (
-
- ))}
-
-)
+const CHAIN_ACCENT_VAR_MAP: Record = {
+ [SupportedChainId.MAINNET]: {
+ backgroundVar: UI.COLOR_CHAIN_ETHEREUM_BG,
+ borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT,
+ },
+ [SupportedChainId.BNB]: {
+ backgroundVar: UI.COLOR_CHAIN_BNB_BG,
+ borderVar: UI.COLOR_CHAIN_BNB_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_BNB_ACCENT,
+ },
+ [SupportedChainId.BASE]: {
+ backgroundVar: UI.COLOR_CHAIN_BASE_BG,
+ borderVar: UI.COLOR_CHAIN_BASE_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_BASE_ACCENT,
+ },
+ [SupportedChainId.ARBITRUM_ONE]: {
+ backgroundVar: UI.COLOR_CHAIN_ARBITRUM_BG,
+ borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT,
+ },
+ [SupportedChainId.POLYGON]: {
+ backgroundVar: UI.COLOR_CHAIN_POLYGON_BG,
+ borderVar: UI.COLOR_CHAIN_POLYGON_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_POLYGON_ACCENT,
+ },
+ [SupportedChainId.AVALANCHE]: {
+ backgroundVar: UI.COLOR_CHAIN_AVALANCHE_BG,
+ borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT,
+ },
+ [SupportedChainId.GNOSIS_CHAIN]: {
+ backgroundVar: UI.COLOR_CHAIN_GNOSIS_BG,
+ borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_GNOSIS_ACCENT,
+ },
+ [SupportedChainId.LENS]: {
+ backgroundVar: UI.COLOR_CHAIN_LENS_BG,
+ borderVar: UI.COLOR_CHAIN_LENS_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_LENS_ACCENT,
+ },
+ [SupportedChainId.SEPOLIA]: {
+ backgroundVar: UI.COLOR_CHAIN_SEPOLIA_BG,
+ borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT,
+ },
+ [SupportedChainId.LINEA]: {
+ backgroundVar: UI.COLOR_CHAIN_LINEA_BG,
+ borderVar: UI.COLOR_CHAIN_LINEA_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_LINEA_ACCENT,
+ },
+ [SupportedChainId.PLASMA]: {
+ backgroundVar: UI.COLOR_CHAIN_PLASMA_BG,
+ borderVar: UI.COLOR_CHAIN_PLASMA_BORDER,
+ accentColorVar: UI.COLOR_CHAIN_PLASMA_ACCENT,
+ },
+}
export interface ChainsSelectorProps {
chains: ChainInfo[]
onSelectChain: (chainId: ChainInfo) => void
defaultChainId?: ChainInfo['id']
- visibleNetworkIcons?: number // Number of network icons to display before showing "More" dropdown
isLoading: boolean
}
-export function ChainsSelector({
+export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoading }: ChainsSelectorProps): ReactNode {
+ const { darkMode } = useTheme()
+
+ if (isLoading) {
+ return
+ }
+
+ return (
+
+ )
+}
+
+function ChainsLoadingList(): ReactNode {
+ const skeletonRows = renderChainSkeletonRows()
+
+ return {skeletonRows}
+}
+
+function renderChainSkeletonRows(): ReactNode[] {
+ const elements: ReactNode[] = []
+
+ for (const index of LOADING_SKELETON_INDICES) {
+ elements.push(
+
+
+
+ ,
+ )
+ }
+
+ return elements
+}
+
+interface ChainsListProps {
+ chains: ChainInfo[]
+ defaultChainId?: ChainInfo['id']
+ onSelectChain(chain: ChainInfo): void
+ isDarkMode: boolean
+}
+
+function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
+ const chainButtons = renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode })
+
+ return {chainButtons}
+}
+
+interface ChainButtonsRenderProps extends ChainsListProps {}
+
+function renderChainButtons({
chains,
- onSelectChain,
defaultChainId,
- isLoading,
- visibleNetworkIcons = LOADING_ITEMS_COUNT,
-}: ChainsSelectorProps): ReactNode {
- const isMobile = useMediaQuery(Media.upToSmall(false))
-
- const theme = useTheme()
+ onSelectChain,
+ isDarkMode,
+}: ChainButtonsRenderProps): ReactNode[] {
+ const elements: ReactNode[] = []
- if (isLoading) {
- return LoadingShimmerElements
+ for (const chain of chains) {
+ elements.push(
+ ,
+ )
}
- const shouldDisplayMore = !isMobile && chains.length > visibleNetworkIcons
- const visibleChains = isMobile ? chains : chains.slice(0, visibleNetworkIcons)
- // Find the selected chain that isn't visible in the main row (so we can display it in the dropdown)
- const selectedMenuChain = !isMobile && chains.find((i) => i.id === defaultChainId && !visibleChains.includes(i))
+ return elements
+}
+
+export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined {
+ return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId]
+}
+
+interface ChainButtonProps {
+ chain: ChainInfo
+ isActive: boolean
+ isDarkMode: boolean
+ onSelectChain(chain: ChainInfo): void
+}
+
+function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButtonProps): ReactNode {
+ const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light
+ const accent = getChainAccent(chain.id)
return (
-
- {visibleChains.map((chain) => (
-
- onSelectChain(chain)} iconOnly>
-
-
-
- ))}
- {shouldDisplayMore && (
-
+ onSelectChain(chain)}
+ active$={isActive}
+ accent$={accent}
+ aria-pressed={isActive}
+ >
+
+
+
+
+ {chain.label}
+
+ {isActive && (
+
+
+
)}
-
+
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
index 7b8260b2e89..9391d6976f3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
@@ -1,118 +1,136 @@
import { UI } from '@cowprotocol/ui'
-import { Media } from '@cowprotocol/ui'
-import { MenuList } from '@reach/menu-button'
import styled from 'styled-components/macro'
-export const Wrapper = styled.div`
- display: flex;
- flex-flow: row;
- gap: 8px;
- width: 100%;
+import { blankButtonMixin } from '../commonElements'
- ${Media.upToSmall()} {
- overflow-x: auto;
- overflow-y: hidden;
- -webkit-overflow-scrolling: touch;
- scrollbar-width: none; /* Firefox */
- -ms-overflow-style: none; /* IE and Edge */
+export interface ChainAccentVars {
+ backgroundVar: UI
+ borderVar: UI
+ accentColorVar?: UI
+}
- &::-webkit-scrollbar {
- display: none;
- }
- }
+const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})`
+const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_80})`
+const fallbackHoverBorder = `var(${UI.COLOR_PRIMARY_OPACITY_70})`
+
+const getBackground = (accent$?: ChainAccentVars, fallback = fallbackBackground): string =>
+ accent$ ? `var(${accent$.backgroundVar})` : fallback
+
+const getBorder = (accent$?: ChainAccentVars, fallback = fallbackBorder): string =>
+ accent$ ? `var(${accent$.borderVar})` : fallback
+
+const getAccentColor = (accent$?: ChainAccentVars): string | undefined =>
+ accent$?.accentColorVar ? `var(${accent$.accentColorVar})` : undefined
+
+export const List = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 100%;
`
-export const ChainItem = styled.button<{
- active$?: boolean
- iconOnly?: boolean
- iconSize?: number
- borderless?: boolean
- isLoading?: boolean
-}>`
- --itemSize: 38px;
- width: ${({ iconOnly }) => (iconOnly ? 'var(--itemSize)' : 'auto')};
- height: var(--itemSize);
+export const ChainButton = styled.button<{ active$?: boolean; accent$?: ChainAccentVars }>`
+ --min-height: 46px;
+ ${blankButtonMixin};
+
+ width: 100%;
display: flex;
align-items: center;
- justify-content: ${({ iconOnly }) => (iconOnly ? 'center' : 'flex-start')};
- gap: 4px;
- font-weight: 500;
- font-size: 13px;
- border-radius: 14px;
- padding: 6px;
- border: ${({ active$, borderless }) =>
- borderless ? 'none' : `1px solid var(${active$ ? UI.COLOR_PRIMARY_OPACITY_70 : UI.COLOR_TEXT_OPACITY_10})`};
- cursor: ${({ isLoading }) => (isLoading ? 'default' : 'pointer')};
- line-height: 1;
- outline: none;
- margin: 0;
- vertical-align: top;
- background: ${({ active$ }) => (active$ ? `var(${UI.COLOR_PAPER_DARKER})` : 'transparent')};
- color: var(${UI.COLOR_TEXT_OPACITY_70});
- box-shadow: ${({ active$ }) =>
- active$
- ? `0px -1px 0px 0px var(${UI.COLOR_TEXT_OPACITY_10}) inset,
- 0px 0px 0px 1px var(${UI.COLOR_PRIMARY_OPACITY_10}) inset,
- 0px 1px 3px 0px var(${UI.COLOR_TEXT_OPACITY_10})`
- : '0'};
+ justify-content: space-between;
+ gap: 16px;
+ padding: 8px 12px;
+ min-height: var(--min-height);
+ border-radius: var(--min-height);
+ border: 1px solid ${({ active$, accent$ }) => (active$ ? getBorder(accent$) : 'transparent')};
+ background: ${({ active$, accent$ }) => (active$ ? getBackground(accent$) : 'transparent')};
+ box-shadow: ${({ active$, accent$ }) => (active$ ? `0 0 0 1px ${getBackground(accent$)} inset` : 'none')};
+ cursor: pointer;
transition:
- color 0.2s ease-in-out,
- background 0.2s ease-in-out,
- box-shadow 0.2s ease-in-out;
- overflow: ${({ isLoading }) => (isLoading ? 'hidden' : 'visible')};
- position: relative;
+ border 0.2s ease,
+ background 0.2s ease,
+ box-shadow 0.2s ease;
&:hover {
- border-color: ${({ isLoading }) =>
- isLoading ? `var(${UI.COLOR_TEXT_OPACITY_10})` : `var(${UI.COLOR_TEXT_OPACITY_25})`};
- background: ${({ isLoading }) => (isLoading ? 'transparent' : `var(${UI.COLOR_PAPER_DARKER})`)};
- color: ${({ isLoading }) => (isLoading ? `var(${UI.COLOR_TEXT_OPACITY_70})` : `var(${UI.COLOR_TEXT})`)};
+ border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)};
+ background: ${({ accent$ }) => getBackground(accent$)};
}
- > img {
- width: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)};
- height: ${({ iconOnly, iconSize = 24 }) => (iconOnly ? '100%' : `${iconSize}px`)};
- border-radius: 100%;
+ &:focus-visible {
+ outline: none;
+ border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)};
}
+`
- > span {
- padding: 0 4px;
- }
+export const ChainInfo = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`
+
+export const ChainLogo = styled.div`
+ --size: 28px;
+ width: var(--size);
+ height: var(--size);
+ border-radius: var(--size);
+ overflow: hidden;
+ background: var(${UI.COLOR_PAPER});
+ display: flex;
+ align-items: center;
+ justify-content: center;
- &:before {
- content: '';
- width: var(--itemSize);
- height: var(--itemSize);
- display: ${({ isLoading }) => (isLoading ? 'block' : 'none')};
- transform: translateX(-100%);
- position: absolute;
- left: 0;
- top: 0;
- ${({ theme, isLoading }) => isLoading && theme.shimmer};
+ > img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
}
`
-export const MenuWrapper = styled.div`
- position: relative;
+export const ChainText = styled.span`
+ font-weight: 500;
+ font-size: 15px;
+ color: var(${UI.COLOR_TEXT});
`
-export const MenuListStyled = styled(MenuList)`
+export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: string }>`
+ width: 20px;
+ height: 20px;
display: flex;
- justify-content: flex-start;
- align-items: stretch;
- flex-direction: column;
- gap: 4px;
- position: absolute;
- right: 0;
- top: 40px;
- z-index: 12;
- border-radius: 12px;
- padding: 10px;
- background: var(${UI.COLOR_PAPER});
- box-shadow: var(${UI.BOX_SHADOW});
+ align-items: center;
+ justify-content: center;
+ color: ${({ color$, accent$ }) => getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)};
+
+ > svg {
+ width: 16px;
+ height: 16px;
+ display: block;
+ }
+
+ > svg > path {
+ fill: currentColor;
+ }
+`
+
+export const LoadingRow = styled.div`
+ width: 100%;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 14px;
+ border-radius: 18px;
border: 1px solid var(${UI.COLOR_TEXT_OPACITY_10});
- outline: none;
- overflow: hidden;
- min-width: 200px;
+`
+
+export const LoadingCircle = styled.div`
+ width: 36px;
+ height: 36px;
+ border-radius: 50%;
+ ${({ theme }) => theme.shimmer};
+`
+
+export const LoadingBar = styled.div`
+ flex: 1;
+ height: 14px;
+ border-radius: 8px;
+ ${({ theme }) => theme.shimmer};
`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx
index b7335114950..67199062de7 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
@@ -15,7 +15,7 @@ const Wrapper = styled.div`
max-height: 90vh;
margin: 20px auto;
display: flex;
- width: 520px;
+ width: 900px;
`
const unsupportedTokens = {}
@@ -56,6 +56,8 @@ const defaultProps: SelectTokenModalProps = {
isLoading: false,
defaultChainId: SupportedChainId.MAINNET,
},
+ hasChainPanel: true,
+ chainsPanelTitle: 'Cross chain swap',
onSelectChain(chain: ChainInfo) {
console.log('onSelectChain', chain)
},
@@ -97,6 +99,11 @@ const Fixtures = {
),
+ noChainPanel: () => (
+
+
+
+ ),
importByAddress: () => (
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 18b7cd849b5..dbe1e45f8a0 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -8,8 +8,8 @@ import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenS
import { SelectTokenModalContent } from './SelectTokenModalContent'
import * as styledEl from './styled'
-
import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
+import { ChainPanel } from '../ChainPanel'
import { ChainsSelector } from '../ChainsSelector'
import type { SelectTokenModalProps } from './types'
@@ -19,7 +19,6 @@ export type { SelectTokenModalProps }
export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
const {
- defaultInputValue = '',
onSelectToken,
onDismiss,
onInputPressEnter,
@@ -28,7 +27,6 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
openPoolPage,
tokenListCategoryState,
disableErc20,
- chainsToSelect,
onSelectChain,
areTokensFromBridge,
isRouteAvailable,
@@ -41,20 +39,23 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
allTokens,
hideFavoriteTokensTooltip,
selectedTargetChainId,
- modalTitle,
- hasChainPanel = false,
isFullScreenMobile,
} = props
- const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
- const selectTokenContext = useSelectTokenContext(props)
- const resolvedModalTitle = modalTitle ?? t`Select token`
- const legacyChainsState =
- !hasChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined
+ const {
+ inputValue,
+ setInputValue,
+ trimmedInputValue,
+ selectTokenContext,
+ showChainPanel,
+ legacyChainsState,
+ chainPanel,
+ resolvedModalTitle,
+ } = useSelectTokenModalLayout(props)
return (
void
+ trimmedInputValue: string
+ selectTokenContext: ReturnType
+ showChainPanel: boolean
+ legacyChainsState: SelectTokenModalProps['chainsToSelect']
+ chainPanel: ReactNode
+ resolvedModalTitle: string
+} {
+ const {
+ defaultInputValue = '',
+ chainsToSelect,
+ onSelectChain,
+ modalTitle,
+ hasChainPanel = false,
+ chainsPanelTitle,
+ } = props
+
+ const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
+ const selectTokenContext = useSelectTokenContext(props)
+ const resolvedModalTitle = modalTitle ?? t`Select token`
+ const showChainPanel = hasChainPanel && Boolean(chainsToSelect?.chains?.length)
+ const legacyChainsState =
+ !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined
+ const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap`
+ const chainPanel =
+ showChainPanel && chainsToSelect ? (
+
+ ) : null
+
+ return {
+ inputValue,
+ setInputValue,
+ trimmedInputValue,
+ selectTokenContext,
+ showChainPanel,
+ legacyChainsState,
+ chainPanel,
+ resolvedModalTitle,
+ }
+}
+
interface SelectTokenModalShellProps {
children: ReactNode
hasChainPanel: boolean
@@ -177,6 +222,7 @@ interface SelectTokenModalShellProps {
searchValue: string
onSearchChange(value: string): void
onSearchEnter?: () => void
+ sideContent?: ReactNode
}
function SelectTokenModalShell({
@@ -190,6 +236,7 @@ function SelectTokenModalShell({
searchValue,
onSearchChange,
onSearchEnter,
+ sideContent,
}: SelectTokenModalShellProps): ReactNode {
return (
@@ -216,6 +263,7 @@ function SelectTokenModalShell({
{children}
+ {sideContent}
)
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 b5860b1c0a0..98b614e413a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -103,6 +103,8 @@ export const Body = styled.div`
display: flex;
flex: 1;
min-height: 0;
+ gap: 16px;
+ align-items: stretch;
${Media.upToMedium()} {
flex-direction: column;
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 649cfcfe1b8..afc04da70c7 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -33,6 +33,7 @@ export interface SelectTokenModalProps {
selectedTargetChainId?: number
modalTitle?: string
hasChainPanel?: boolean
+ chainsPanelTitle?: string
isFullScreenMobile?: boolean
onSelectToken: TokenSelectionHandler
From b849a44f3ed66dd05c8a955d410f67a5fd9e127e Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 17 Nov 2025 12:21:24 +0000
Subject: [PATCH 009/100] refactor: extract SelectTokenWidget controller
---
.../SelectTokenWidget/controller.ts | 85 +++++
.../controllerDependencies.ts | 84 +++++
.../SelectTokenWidget/controllerModalProps.ts | 214 +++++++++++
.../SelectTokenWidget/controllerProps.ts | 248 +++++++++++++
.../SelectTokenWidget/controllerState.ts | 300 ++++++++++++++++
.../SelectTokenWidget/controllerViewState.ts | 136 +++++++
.../containers/SelectTokenWidget/index.tsx | 331 +++++-------------
.../containers/SelectTokenWidget/styled.ts | 22 ++
8 files changed, 1181 insertions(+), 239 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/controllerDependencies.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.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
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/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
new file mode 100644
index 00000000000..e064930038a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts
@@ -0,0 +1,85 @@
+import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import { Field } from 'legacy/state/types'
+
+import { useLpTokensWithBalances } from 'modules/yield/shared'
+
+import { SelectTokenWidgetViewProps } from './controllerProps'
+import {
+ useManageWidgetVisibility,
+ useTokenAdminActions,
+ useTokenDataSources,
+ useWidgetMetadata,
+} from './controllerState'
+import { useSelectTokenWidgetViewState } from './controllerViewState'
+
+import { useChainsToSelect } from '../../hooks/useChainsToSelect'
+import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
+import { useOnSelectChain } from '../../hooks/useOnSelectChain'
+import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
+import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
+
+export interface SelectTokenWidgetProps {
+ displayLpTokenLists?: boolean
+ standalone?: boolean
+}
+
+export interface SelectTokenWidgetController {
+ shouldRender: boolean
+ hasChainPanel: boolean
+ viewProps: SelectTokenWidgetViewProps
+}
+
+export function useSelectTokenWidgetController({
+ displayLpTokenLists,
+ standalone,
+}: SelectTokenWidgetProps): SelectTokenWidgetController {
+ const widgetState = useSelectTokenWidgetState()
+ const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances()
+ const resolvedField = widgetState.field ?? Field.INPUT
+ const chainsToSelect = useChainsToSelect()
+ const onSelectChain = useOnSelectChain()
+ const isBridgeFeatureEnabled = useIsBridgingEnabled()
+ const manageWidget = useManageWidgetVisibility()
+ const updateSelectTokenWidget = useUpdateSelectTokenWidgetState()
+ const { account, chainId: walletChainId } = useWalletInfo()
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const tokenData = useTokenDataSources()
+ const onTokenListAddingError = useOnTokenListAddingError()
+ const tokenAdminActions = useTokenAdminActions()
+ const widgetMetadata = useWidgetMetadata(
+ resolvedField,
+ widgetState.tradeType,
+ displayLpTokenLists,
+ widgetState.oppositeToken,
+ lpTokensWithBalancesCount,
+ )
+
+ const { isChainPanelEnabled, viewProps } = useSelectTokenWidgetViewState({
+ displayLpTokenLists,
+ standalone,
+ widgetState,
+ chainsToSelect,
+ onSelectChain,
+ manageWidget,
+ updateSelectTokenWidget,
+ account,
+ closeTokenSelectWidget,
+ tokenData,
+ onTokenListAddingError,
+ tokenAdminActions,
+ widgetMetadata,
+ walletChainId,
+ isBridgeFeatureEnabled,
+ })
+
+ return {
+ shouldRender: Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)),
+ hasChainPanel: isChainPanelEnabled,
+ viewProps,
+ }
+}
+
+export type { SelectTokenWidgetViewProps } from './controllerProps'
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts
new file mode 100644
index 00000000000..76925a3e776
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerDependencies.ts
@@ -0,0 +1,84 @@
+import {
+ useDismissHandler,
+ useImportFlowCallbacks,
+ useManageWidgetVisibility,
+ usePoolPageHandlers,
+ useRecentTokenSection,
+ useTokenAdminActions,
+ useTokenDataSources,
+ useTokenSelectionHandler,
+} from './controllerState'
+
+import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
+import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
+import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
+
+export interface WidgetViewDependenciesResult {
+ isManageWidgetOpen: boolean
+ openManageWidget: ReturnType['openManageWidget']
+ closeManageWidget: ReturnType['closeManageWidget']
+ onDismiss(): void
+ openPoolPage: ReturnType['openPoolPage']
+ closePoolPage: ReturnType['closePoolPage']
+ recentTokens: ReturnType['recentTokens']
+ handleTokenListItemClick: ReturnType['handleTokenListItemClick']
+ clearRecentTokens: ReturnType['clearRecentTokens']
+ handleSelectToken: ReturnType
+ importFlows: ReturnType
+}
+
+interface WidgetViewDependenciesArgs {
+ manageWidget: ReturnType
+ closeTokenSelectWidget: ReturnType
+ updateSelectTokenWidget: ReturnType
+ tokenData: ReturnType
+ tokenAdminActions: ReturnType
+ onTokenListAddingError: ReturnType
+ widgetState: ReturnType
+ activeChainId: number | undefined
+}
+
+export function useWidgetViewDependencies({
+ manageWidget,
+ closeTokenSelectWidget,
+ updateSelectTokenWidget,
+ tokenData,
+ tokenAdminActions,
+ onTokenListAddingError,
+ widgetState,
+ activeChainId,
+}: WidgetViewDependenciesArgs): WidgetViewDependenciesResult {
+ const { isManageWidgetOpen, openManageWidget, closeManageWidget } = manageWidget
+ const onDismiss = useDismissHandler(closeManageWidget, closeTokenSelectWidget)
+ const { openPoolPage, closePoolPage } = usePoolPageHandlers(updateSelectTokenWidget)
+ const { recentTokens, handleTokenListItemClick, clearRecentTokens } = useRecentTokenSection(
+ tokenData.allTokens,
+ tokenData.favoriteTokens,
+ activeChainId,
+ )
+ const handleSelectToken = useTokenSelectionHandler(widgetState.onSelectToken, widgetState)
+ const importFlows = useImportFlowCallbacks(
+ tokenAdminActions.importTokenCallback,
+ handleSelectToken,
+ onDismiss,
+ tokenAdminActions.addCustomTokenLists,
+ onTokenListAddingError,
+ updateSelectTokenWidget,
+ tokenData.favoriteTokens,
+ )
+
+ return {
+ isManageWidgetOpen,
+ openManageWidget,
+ closeManageWidget,
+ onDismiss,
+ openPoolPage,
+ closePoolPage,
+ recentTokens,
+ handleTokenListItemClick,
+ clearRecentTokens,
+ handleSelectToken,
+ importFlows,
+ }
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts
new file mode 100644
index 00000000000..2ed07f83c63
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts
@@ -0,0 +1,214 @@
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import { buildSelectTokenModalPropsInput, buildSelectTokenWidgetViewProps, useSelectTokenModalPropsMemo } from './controllerProps'
+import {
+ useManageWidgetVisibility,
+ usePoolPageHandlers,
+ useRecentTokenSection,
+ useTokenDataSources,
+ useTokenSelectionHandler,
+ useWidgetMetadata,
+} from './controllerState'
+
+import { useChainsToSelect } from '../../hooks/useChainsToSelect'
+import { useOnSelectChain } from '../../hooks/useOnSelectChain'
+import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+
+import type { WidgetViewDependenciesResult } from './controllerDependencies'
+import type { SelectTokenModalProps } from '../../pure/SelectTokenModal'
+
+const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
+
+interface WidgetModalPropsArgs {
+ account: string | undefined
+ chainsToSelect: ReturnType
+ displayLpTokenLists?: boolean
+ widgetDeps: WidgetViewDependenciesResult
+ hasChainPanel: boolean
+ onSelectChain: ReturnType
+ recentTokens: ReturnType['recentTokens']
+ standalone?: boolean
+ tokenData: ReturnType
+ widgetMetadata: ReturnType
+ widgetState: ReturnType
+ isInjectedWidgetMode: boolean
+}
+
+export function useWidgetModalProps({
+ account,
+ chainsToSelect,
+ displayLpTokenLists,
+ widgetDeps,
+ hasChainPanel,
+ onSelectChain,
+ recentTokens,
+ standalone,
+ tokenData,
+ widgetMetadata,
+ widgetState,
+ isInjectedWidgetMode,
+}: WidgetModalPropsArgs): SelectTokenModalProps {
+ const favoriteTokens = standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens
+
+ return useSelectTokenModalPropsMemo(
+ createSelectTokenModalProps({
+ account,
+ chainsPanelTitle: widgetMetadata.chainsPanelTitle,
+ chainsState: chainsToSelect,
+ disableErc20: widgetMetadata.disableErc20,
+ displayLpTokenLists,
+ favoriteTokens,
+ handleSelectToken: widgetDeps.handleSelectToken,
+ hasChainPanel,
+ isInjectedWidgetMode,
+ modalTitle: widgetMetadata.modalTitle,
+ onDismiss: widgetDeps.onDismiss,
+ onSelectChain,
+ onTokenListItemClick: widgetDeps.handleTokenListItemClick,
+ onClearRecentTokens: widgetDeps.clearRecentTokens,
+ onOpenManageWidget: widgetDeps.openManageWidget,
+ openPoolPage: widgetDeps.openPoolPage,
+ recentTokens,
+ standalone,
+ tokenData,
+ tokenListCategoryState: widgetMetadata.tokenListCategoryState,
+ widgetState,
+ }),
+ )
+}
+
+interface BuildViewPropsArgs {
+ allTokenLists: ReturnType['allTokenLists']
+ chainsPanelTitle: string
+ chainsToSelect: ReturnType
+ closeManageWidget: ReturnType['closeManageWidget']
+ closePoolPage: ReturnType['closePoolPage']
+ importFlows: WidgetViewDependenciesResult['importFlows']
+ isChainPanelEnabled: boolean
+ onDismiss: () => void
+ onSelectChain: ReturnType
+ selectTokenModalProps: ReturnType
+ selectedPoolAddress: ReturnType['selectedPoolAddress']
+ standalone: boolean | undefined
+ tokenToImport: ReturnType['tokenToImport']
+ listToImport: ReturnType['listToImport']
+ isManageWidgetOpen: ReturnType['isManageWidgetOpen']
+ userAddedTokens: ReturnType['userAddedTokens']
+ handleSelectToken: ReturnType
+}
+
+type BuildViewPropsInput = Parameters[0]
+
+export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): BuildViewPropsInput {
+ const {
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ isChainPanelEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ importFlows,
+ allTokenLists,
+ userAddedTokens,
+ closeManageWidget,
+ closePoolPage,
+ selectTokenModalProps,
+ handleSelectToken,
+ } = args
+
+ return {
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ isChainPanelEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ onBackFromImport: importFlows.resetTokenImport,
+ onImportTokens: importFlows.importTokenAndClose,
+ onImportList: importFlows.importListAndBack,
+ allTokenLists,
+ userAddedTokens,
+ onCloseManageWidget: closeManageWidget,
+ onClosePoolPage: closePoolPage,
+ selectTokenModalProps,
+ onSelectToken: handleSelectToken,
+ }
+}
+
+function createSelectTokenModalProps({
+ account,
+ chainsPanelTitle,
+ chainsState,
+ disableErc20,
+ displayLpTokenLists,
+ favoriteTokens,
+ handleSelectToken,
+ hasChainPanel,
+ isInjectedWidgetMode,
+ modalTitle,
+ onDismiss,
+ onSelectChain,
+ onTokenListItemClick,
+ onClearRecentTokens,
+ onOpenManageWidget,
+ openPoolPage,
+ recentTokens,
+ standalone,
+ tokenData,
+ tokenListCategoryState,
+ widgetState,
+}: {
+ account: string | undefined
+ chainsPanelTitle: string
+ chainsState: ReturnType
+ disableErc20: boolean
+ displayLpTokenLists: boolean | undefined
+ favoriteTokens: TokenWithLogo[]
+ handleSelectToken: ReturnType
+ hasChainPanel: boolean
+ isInjectedWidgetMode: boolean
+ modalTitle: string
+ onDismiss: () => void
+ onSelectChain: ReturnType
+ onTokenListItemClick: ReturnType['handleTokenListItemClick']
+ onClearRecentTokens: ReturnType['clearRecentTokens']
+ onOpenManageWidget: ReturnType['openManageWidget']
+ openPoolPage: ReturnType['openPoolPage']
+ recentTokens: ReturnType['recentTokens']
+ standalone: boolean | undefined
+ tokenData: ReturnType
+ tokenListCategoryState: ReturnType['tokenListCategoryState']
+ widgetState: ReturnType
+}): SelectTokenModalProps {
+ return buildSelectTokenModalPropsInput({
+ standalone,
+ displayLpTokenLists,
+ tokenData,
+ widgetState,
+ favoriteTokens,
+ recentTokens,
+ handleSelectToken,
+ onTokenListItemClick,
+ onClearRecentTokens,
+ onDismiss,
+ onOpenManageWidget,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ hasChainPanel,
+ chainsState,
+ chainsPanelTitle,
+ onSelectChain,
+ isInjectedWidgetMode,
+ modalTitle,
+ })
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
new file mode 100644
index 00000000000..2b3d77c7fab
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
@@ -0,0 +1,248 @@
+import { useMemo } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { ListState } from '@cowprotocol/tokens'
+
+import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
+
+import type { TokenDataSources, TokenListCategoryState } from './controllerState'
+import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+import type { SelectTokenModalProps } from '../../pure/SelectTokenModal'
+
+type WidgetState = ReturnType
+export interface SelectTokenWidgetViewProps {
+ standalone?: boolean
+ tokenToImport?: TokenWithLogo
+ listToImport?: ListState
+ isManageWidgetOpen: boolean
+ selectedPoolAddress?: string
+ isChainPanelEnabled: boolean
+ chainsPanelTitle: string
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain(chain: ChainInfo): void
+ onDismiss(): void
+ onBackFromImport(): void
+ onImportTokens(tokens: TokenWithLogo[]): void
+ onImportList(list: ListState): void
+ allTokenLists: ListState[]
+ userAddedTokens: TokenWithLogo[]
+ onCloseManageWidget(): void
+ onClosePoolPage(): void
+ selectTokenModalProps: SelectTokenModalProps
+ onSelectToken: TokenSelectionHandler
+}
+
+interface BuildViewPropsArgs {
+ standalone?: boolean
+ tokenToImport?: TokenWithLogo
+ listToImport?: ListState
+ isManageWidgetOpen: boolean
+ selectedPoolAddress?: string
+ isChainPanelEnabled: boolean
+ chainsPanelTitle: string
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain(chain: ChainInfo): void
+ onDismiss(): void
+ onBackFromImport(): void
+ onImportTokens(tokens: TokenWithLogo[]): void
+ onImportList(list: ListState): void
+ allTokenLists: ListState[]
+ userAddedTokens: TokenWithLogo[]
+ onCloseManageWidget(): void
+ onClosePoolPage(): void
+ selectTokenModalProps: SelectTokenModalProps
+ onSelectToken: TokenSelectionHandler
+}
+
+interface BuildModalPropsArgs {
+ standalone?: boolean
+ displayLpTokenLists?: boolean
+ tokenData: TokenDataSources
+ widgetState: WidgetState
+ favoriteTokens: TokenWithLogo[]
+ recentTokens: TokenWithLogo[]
+ handleSelectToken: TokenSelectionHandler
+ onTokenListItemClick(token: TokenWithLogo): void
+ onClearRecentTokens(): void
+ onDismiss(): void
+ onOpenManageWidget(): void
+ openPoolPage(poolAddress: string): void
+ tokenListCategoryState: TokenListCategoryState
+ disableErc20: boolean
+ account: string | undefined
+ hasChainPanel: boolean
+ chainsState?: ChainsToSelectState
+ onSelectChain?(chain: ChainInfo): void
+ isInjectedWidgetMode: boolean
+ chainsPanelTitle: string
+ modalTitle: string
+}
+
+export function buildSelectTokenWidgetViewProps({
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ isChainPanelEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ onBackFromImport,
+ onImportTokens,
+ onImportList,
+ allTokenLists,
+ userAddedTokens,
+ onCloseManageWidget,
+ onClosePoolPage,
+ selectTokenModalProps,
+ onSelectToken,
+}: BuildViewPropsArgs): SelectTokenWidgetViewProps {
+ return {
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ isChainPanelEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onDismiss,
+ onBackFromImport,
+ onImportTokens,
+ onImportList,
+ allTokenLists,
+ userAddedTokens,
+ onCloseManageWidget,
+ onClosePoolPage,
+ selectTokenModalProps,
+ onSelectToken,
+ }
+}
+
+export function buildSelectTokenModalPropsInput({
+ standalone,
+ displayLpTokenLists,
+ tokenData,
+ widgetState,
+ favoriteTokens,
+ recentTokens,
+ handleSelectToken,
+ onTokenListItemClick,
+ onClearRecentTokens,
+ onDismiss,
+ onOpenManageWidget,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ hasChainPanel,
+ chainsState,
+ onSelectChain,
+ isInjectedWidgetMode,
+ chainsPanelTitle,
+ modalTitle,
+}: BuildModalPropsArgs): SelectTokenModalProps {
+ return {
+ standalone,
+ displayLpTokenLists,
+ unsupportedTokens: tokenData.unsupportedTokens,
+ selectedToken: widgetState.selectedToken,
+ allTokens: tokenData.allTokens,
+ favoriteTokens,
+ recentTokens,
+ balancesState: tokenData.balancesState,
+ permitCompatibleTokens: tokenData.permitCompatibleTokens,
+ onSelectToken: handleSelectToken,
+ onTokenListItemClick,
+ onInputPressEnter: widgetState.onInputPressEnter,
+ onDismiss,
+ onOpenManageWidget,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ areTokensLoading: tokenData.areTokensLoading,
+ tokenListTags: tokenData.tokenListTags,
+ areTokensFromBridge: tokenData.areTokensFromBridge,
+ isRouteAvailable: tokenData.isRouteAvailable,
+ modalTitle,
+ hasChainPanel,
+ chainsToSelect: chainsState,
+ chainsPanelTitle,
+ hideFavoriteTokensTooltip: isInjectedWidgetMode,
+ selectedTargetChainId: widgetState.selectedTargetChainId,
+ onSelectChain,
+ onClearRecentTokens,
+ }
+}
+
+export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): SelectTokenModalProps {
+ return useMemo(
+ () => ({
+ standalone: props.standalone,
+ displayLpTokenLists: props.displayLpTokenLists,
+ unsupportedTokens: props.unsupportedTokens,
+ selectedToken: props.selectedToken,
+ allTokens: props.allTokens,
+ favoriteTokens: props.favoriteTokens,
+ recentTokens: props.recentTokens,
+ balancesState: props.balancesState,
+ permitCompatibleTokens: props.permitCompatibleTokens,
+ onSelectToken: props.onSelectToken,
+ onTokenListItemClick: props.onTokenListItemClick,
+ onInputPressEnter: props.onInputPressEnter,
+ onDismiss: props.onDismiss,
+ onOpenManageWidget: props.onOpenManageWidget,
+ openPoolPage: props.openPoolPage,
+ tokenListCategoryState: props.tokenListCategoryState,
+ disableErc20: props.disableErc20,
+ account: props.account,
+ areTokensLoading: props.areTokensLoading,
+ tokenListTags: props.tokenListTags,
+ areTokensFromBridge: props.areTokensFromBridge,
+ isRouteAvailable: props.isRouteAvailable,
+ modalTitle: props.modalTitle,
+ hasChainPanel: props.hasChainPanel,
+ hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip,
+ chainsPanelTitle: props.chainsPanelTitle,
+ selectedTargetChainId: props.selectedTargetChainId,
+ onSelectChain: props.onSelectChain,
+ onClearRecentTokens: props.onClearRecentTokens,
+ }),
+ [
+ props.standalone,
+ props.displayLpTokenLists,
+ props.unsupportedTokens,
+ props.selectedToken,
+ props.allTokens,
+ props.favoriteTokens,
+ props.recentTokens,
+ props.balancesState,
+ props.permitCompatibleTokens,
+ props.onSelectToken,
+ props.onTokenListItemClick,
+ props.onInputPressEnter,
+ props.onDismiss,
+ props.onOpenManageWidget,
+ props.openPoolPage,
+ props.tokenListCategoryState,
+ props.disableErc20,
+ props.account,
+ props.areTokensLoading,
+ props.tokenListTags,
+ props.areTokensFromBridge,
+ props.isRouteAvailable,
+ props.modalTitle,
+ props.hasChainPanel,
+ props.chainsPanelTitle,
+ props.hideFavoriteTokensTooltip,
+ props.selectedTargetChainId,
+ props.onSelectChain,
+ props.onClearRecentTokens,
+ ],
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
new file mode 100644
index 00000000000..0a4c44ac65a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -0,0 +1,300 @@
+import { Dispatch, SetStateAction, useCallback, useState } from 'react'
+
+import { useCowAnalytics } from '@cowprotocol/analytics'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+import {
+ ListState,
+ TokenListCategory,
+ useAddList,
+ useAddUserToken,
+ useAllListsList,
+ useTokenListsTags,
+ useUnsupportedTokens,
+ useUserAddedTokens,
+} from '@cowprotocol/tokens'
+import { useWalletInfo } from '@cowprotocol/wallet'
+
+import { Field } from 'legacy/state/types'
+
+import { useTokensBalancesCombined } from 'modules/combinedBalances'
+import { usePermitCompatibleTokens } from 'modules/permit'
+import { TradeType } from 'modules/trade/types'
+
+import { CowSwapAnalyticsCategory } from 'common/analytics/types'
+import { useOnSelectNetwork } from 'common/hooks/useOnSelectNetwork'
+
+import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
+
+import { persistRecentTokenSelection, useRecentTokens } from '../../hooks/useRecentTokens'
+import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+import { useTokensToSelect } from '../../hooks/useTokensToSelect'
+import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
+
+import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
+
+type UpdateSelectTokenWidgetFn = ReturnType
+
+export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>]
+
+interface ManageWidgetVisibility {
+ isManageWidgetOpen: boolean
+ openManageWidget(): void
+ closeManageWidget(): void
+}
+
+interface TokenAdminActions {
+ addCustomTokenLists(list: ListState): void
+ importTokenCallback(tokens: TokenWithLogo[]): void
+}
+
+export interface TokenDataSources {
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ areTokensLoading: boolean
+ areTokensFromBridge: boolean
+ isRouteAvailable: boolean | undefined
+ userAddedTokens: TokenWithLogo[]
+ allTokenLists: ListState[]
+ balancesState: ReturnType
+ unsupportedTokens: ReturnType
+ permitCompatibleTokens: ReturnType
+ tokenListTags: ReturnType
+}
+
+interface WidgetMetadata {
+ disableErc20: boolean
+ tokenListCategoryState: TokenListCategoryState
+ modalTitle: string
+ chainsPanelTitle: string
+}
+
+interface PoolPageHandlers {
+ openPoolPage(poolAddress: string): void
+ closePoolPage(): void
+}
+
+interface ImportFlowCallbacks {
+ importTokenAndClose(tokens: TokenWithLogo[]): void
+ importListAndBack(list: ListState): void
+ resetTokenImport(): void
+}
+
+interface RecentTokenSection {
+ recentTokens: TokenWithLogo[]
+ handleTokenListItemClick(token: TokenWithLogo): void
+ clearRecentTokens(): void
+}
+
+export function useManageWidgetVisibility(): ManageWidgetVisibility {
+ const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false)
+
+ const openManageWidget = useCallback(() => setIsManageWidgetOpen(true), [])
+ const closeManageWidget = useCallback(() => setIsManageWidgetOpen(false), [])
+
+ return { isManageWidgetOpen, openManageWidget, closeManageWidget }
+}
+
+export function useTokenAdminActions(): TokenAdminActions {
+ const cowAnalytics = useCowAnalytics()
+
+ const addCustomTokenLists = useAddList((source) => {
+ cowAnalytics.sendEvent({
+ category: CowSwapAnalyticsCategory.LIST,
+ action: 'Add List Success',
+ label: source,
+ })
+ })
+ const importTokenCallback = useAddUserToken()
+
+ return { addCustomTokenLists, importTokenCallback }
+}
+
+export function useTokenDataSources(): TokenDataSources {
+ const tokensState = useTokensToSelect()
+ const userAddedTokens = useUserAddedTokens()
+ const allTokenLists = useAllListsList()
+ const balancesState = useTokensBalancesCombined()
+ const unsupportedTokens = useUnsupportedTokens()
+ const permitCompatibleTokens = usePermitCompatibleTokens()
+ const tokenListTags = useTokenListsTags()
+
+ return {
+ allTokens: tokensState.tokens,
+ favoriteTokens: tokensState.favoriteTokens,
+ areTokensLoading: tokensState.isLoading,
+ areTokensFromBridge: tokensState.areTokensFromBridge,
+ isRouteAvailable: tokensState.isRouteAvailable,
+ userAddedTokens,
+ allTokenLists,
+ balancesState,
+ unsupportedTokens,
+ permitCompatibleTokens,
+ tokenListTags,
+ }
+}
+
+export function useWidgetMetadata(
+ field: Field,
+ tradeType: TradeType | undefined,
+ displayLpTokenLists: boolean | undefined,
+ oppositeToken: Parameters[1],
+ lpTokensWithBalancesCount: number,
+): WidgetMetadata {
+ const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists
+ const tokenListCategoryState: TokenListCategoryState = useState(
+ getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount),
+ )
+ const modalTitle = resolveModalTitle(field, tradeType)
+ const chainsPanelTitle =
+ field === Field.INPUT ? 'From network' : field === Field.OUTPUT ? 'To network' : 'Select network'
+
+ return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle }
+}
+
+function resolveModalTitle(field: Field, tradeType: TradeType | undefined): string {
+ const isSwapTrade = !tradeType || tradeType === TradeType.SWAP
+
+ if (field === Field.INPUT) {
+ return isSwapTrade ? 'Swap from' : 'Sell token'
+ }
+
+ if (field === Field.OUTPUT) {
+ return isSwapTrade ? 'Swap to' : 'Buy token'
+ }
+
+ return 'Select token'
+}
+
+export function useDismissHandler(
+ closeManageWidget: () => void,
+ closeTokenSelectWidget: (options?: { overrideForceLock?: boolean }) => void,
+): () => void {
+ return useCallback(() => {
+ closeManageWidget()
+ closeTokenSelectWidget({ overrideForceLock: true })
+ }, [closeManageWidget, closeTokenSelectWidget])
+}
+
+export function usePoolPageHandlers(updateSelectTokenWidget: UpdateSelectTokenWidgetFn): PoolPageHandlers {
+ const openPoolPage = useCallback(
+ (selectedPoolAddress: string) => {
+ updateSelectTokenWidget({ selectedPoolAddress })
+ },
+ [updateSelectTokenWidget],
+ )
+
+ const closePoolPage = useCallback(() => {
+ updateSelectTokenWidget({ selectedPoolAddress: undefined })
+ }, [updateSelectTokenWidget])
+
+ return { openPoolPage, closePoolPage }
+}
+
+export function useImportFlowCallbacks(
+ importTokenCallback: ReturnType,
+ onSelectToken: TokenSelectionHandler | undefined,
+ onDismiss: () => void,
+ addCustomTokenLists: (list: ListState) => void,
+ onTokenListAddingError: (error: Error) => void,
+ updateSelectTokenWidget: UpdateSelectTokenWidgetFn,
+ favoriteTokens: TokenWithLogo[],
+): ImportFlowCallbacks {
+ const importTokenAndClose = useCallback(
+ (tokens: TokenWithLogo[]) => {
+ importTokenCallback(tokens)
+ const [selectedToken] = tokens
+
+ if (selectedToken) {
+ persistRecentTokenSelection(selectedToken, favoriteTokens)
+ onSelectToken?.(selectedToken)
+ }
+
+ onDismiss()
+ },
+ [importTokenCallback, onSelectToken, onDismiss, favoriteTokens],
+ )
+
+ const importListAndBack = useCallback(
+ (list: ListState) => {
+ try {
+ addCustomTokenLists(list)
+ } catch (error) {
+ onDismiss()
+ onTokenListAddingError(error as Error)
+ }
+ updateSelectTokenWidget({ listToImport: undefined })
+ },
+ [addCustomTokenLists, onDismiss, onTokenListAddingError, updateSelectTokenWidget],
+ )
+
+ const resetTokenImport = useCallback(() => {
+ updateSelectTokenWidget({ tokenToImport: undefined })
+ }, [updateSelectTokenWidget])
+
+ return { importTokenAndClose, importListAndBack, resetTokenImport }
+}
+
+export function useRecentTokenSection(
+ allTokens: TokenWithLogo[],
+ favoriteTokens: TokenWithLogo[],
+ activeChainId?: number,
+): RecentTokenSection {
+ const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({
+ allTokens,
+ favoriteTokens,
+ activeChainId,
+ })
+
+ const handleTokenListItemClick = useCallback(
+ (token: TokenWithLogo) => {
+ addRecentToken(token)
+ },
+ [addRecentToken],
+ )
+
+ return { recentTokens, handleTokenListItemClick, clearRecentTokens }
+}
+
+export function useTokenSelectionHandler(
+ onSelectToken: TokenSelectionHandler | undefined,
+ widgetState: ReturnType,
+): TokenSelectionHandler {
+ const { chainId: walletChainId } = useWalletInfo()
+ const onSelectNetwork = useOnSelectNetwork()
+
+ return useCallback(
+ async (token: TokenWithLogo) => {
+ const targetChainId = widgetState.selectedTargetChainId
+ // SELL-side limit/TWAP orders must run on the picked network,
+ // so nudge the wallet onto that chain before finalizing selection.
+ const shouldSwitchWalletNetwork =
+ widgetState.field === Field.INPUT &&
+ (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) &&
+ typeof targetChainId === 'number' &&
+ targetChainId !== walletChainId
+
+ if (shouldSwitchWalletNetwork && targetChainId in SupportedChainId) {
+ try {
+ await onSelectNetwork(targetChainId as SupportedChainId, true)
+ } catch (error) {
+ console.error('Failed to switch network after token selection', error)
+ }
+ }
+
+ onSelectToken?.(token)
+ },
+ [
+ onSelectToken,
+ widgetState.field,
+ widgetState.tradeType,
+ widgetState.selectedTargetChainId,
+ walletChainId,
+ onSelectNetwork,
+ ],
+ )
+}
+
+export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean {
+ return Boolean(chainsToSelect)
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts
new file mode 100644
index 00000000000..7c1e1a1d3a7
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts
@@ -0,0 +1,136 @@
+import { isInjectedWidget } from '@cowprotocol/common-utils'
+
+import { TradeType } from 'modules/trade/types'
+
+import { useWidgetViewDependencies } from './controllerDependencies'
+import { getSelectTokenWidgetViewPropsArgs, useWidgetModalProps } from './controllerModalProps'
+import { SelectTokenWidgetViewProps, buildSelectTokenWidgetViewProps } from './controllerProps'
+import {
+ hasAvailableChains,
+ useManageWidgetVisibility,
+ useTokenAdminActions,
+ useTokenDataSources,
+ useWidgetMetadata,
+} from './controllerState'
+
+import { useChainsToSelect } from '../../hooks/useChainsToSelect'
+import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
+import { useOnSelectChain } from '../../hooks/useOnSelectChain'
+import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
+import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
+import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
+
+export interface SelectTokenWidgetViewStateArgs {
+ displayLpTokenLists?: boolean
+ standalone?: boolean
+ widgetState: ReturnType
+ chainsToSelect: ReturnType
+ onSelectChain: ReturnType
+ manageWidget: ReturnType
+ updateSelectTokenWidget: ReturnType
+ account: string | undefined
+ closeTokenSelectWidget: ReturnType
+ tokenData: ReturnType
+ onTokenListAddingError: ReturnType
+ tokenAdminActions: ReturnType
+ widgetMetadata: ReturnType
+ walletChainId?: number
+ isBridgeFeatureEnabled: boolean
+}
+
+interface ViewStateResult {
+ isChainPanelEnabled: boolean
+ viewProps: SelectTokenWidgetViewProps
+}
+
+// TODO: Re-enable once Yield should support cross-network selection in the modal.
+const ENABLE_YIELD_CHAIN_PANEL = false
+
+export function useSelectTokenWidgetViewState(args: SelectTokenWidgetViewStateArgs): ViewStateResult {
+ const {
+ displayLpTokenLists,
+ standalone,
+ widgetState,
+ chainsToSelect,
+ onSelectChain,
+ manageWidget,
+ updateSelectTokenWidget,
+ account,
+ closeTokenSelectWidget,
+ tokenData,
+ onTokenListAddingError,
+ tokenAdminActions,
+ widgetMetadata,
+ walletChainId,
+ isBridgeFeatureEnabled,
+ } = args
+
+ const activeChainId = resolveActiveChainId(widgetState, walletChainId)
+ const widgetDeps = useWidgetViewDependencies({
+ manageWidget,
+ closeTokenSelectWidget,
+ updateSelectTokenWidget,
+ tokenData,
+ tokenAdminActions,
+ onTokenListAddingError,
+ widgetState,
+ activeChainId,
+ })
+ const shouldDisableChainPanelForYield = widgetState.tradeType === TradeType.YIELD && !ENABLE_YIELD_CHAIN_PANEL
+ const isChainPanelEnabled =
+ isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect) && !shouldDisableChainPanelForYield
+ const selectTokenModalProps = useWidgetModalProps({
+ account,
+ chainsToSelect,
+ displayLpTokenLists,
+ widgetDeps,
+ hasChainPanel: isChainPanelEnabled,
+ onSelectChain,
+ recentTokens: widgetDeps.recentTokens,
+ standalone,
+ tokenData,
+ widgetMetadata,
+ widgetState,
+ isInjectedWidgetMode: isInjectedWidget(),
+ })
+
+ const viewProps = buildSelectTokenWidgetViewProps(
+ getSelectTokenWidgetViewPropsArgs({
+ allTokenLists: tokenData.allTokenLists,
+ chainsPanelTitle: widgetMetadata.chainsPanelTitle,
+ chainsToSelect,
+ closeManageWidget: widgetDeps.closeManageWidget,
+ closePoolPage: widgetDeps.closePoolPage,
+ importFlows: widgetDeps.importFlows,
+ isChainPanelEnabled,
+ onDismiss: widgetDeps.onDismiss,
+ onSelectChain,
+ selectTokenModalProps,
+ selectedPoolAddress: widgetState.selectedPoolAddress,
+ standalone,
+ tokenToImport: widgetState.tokenToImport,
+ listToImport: widgetState.listToImport,
+ isManageWidgetOpen: widgetDeps.isManageWidgetOpen,
+ userAddedTokens: tokenData.userAddedTokens,
+ handleSelectToken: widgetDeps.handleSelectToken,
+ }),
+ )
+
+ return { isChainPanelEnabled, viewProps }
+}
+
+function resolveActiveChainId(
+ widgetState: ReturnType,
+ walletChainId?: number,
+): number | undefined {
+ return (
+ widgetState.selectedTargetChainId ??
+ walletChainId ??
+ extractChainId(widgetState.oppositeToken) ??
+ extractChainId(widgetState.selectedToken)
+ )
+}
+
+function extractChainId(token: { chainId?: number } | undefined | null): number | undefined {
+ return typeof token?.chainId === 'number' ? token.chainId : undefined
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
index 4fdb05af604..7c080c01dad 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -1,266 +1,119 @@
-import { ReactNode, useCallback, useState } from 'react'
+import { ReactNode } from 'react'
-import { useCowAnalytics } from '@cowprotocol/analytics'
-import { TokenWithLogo } from '@cowprotocol/common-const'
-import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
-import { isInjectedWidget } from '@cowprotocol/common-utils'
import {
- ListState,
- TokenListCategory,
- useAddList,
- useAddUserToken,
- useAllListsList,
- useTokenListsTags,
- useUnsupportedTokens,
- useUserAddedTokens,
-} from '@cowprotocol/tokens'
-import { useWalletInfo } from '@cowprotocol/wallet'
+ useSelectTokenWidgetController,
+ type SelectTokenWidgetProps,
+ type SelectTokenWidgetViewProps,
+} from './controller'
+import { InnerWrapper, ModalContainer, Wrapper } from './styled'
-import { t } from '@lingui/core/macro'
-import styled from 'styled-components/macro'
-
-import { Field } from 'legacy/state/types'
-
-import { useTokensBalancesCombined } from 'modules/combinedBalances'
-import { usePermitCompatibleTokens } from 'modules/permit'
-import { useLpTokensWithBalances } from 'modules/yield/shared'
-
-
-import { CowSwapAnalyticsCategory } from 'common/analytics/types'
-
-import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
-
-import { useChainsToSelect } from '../../hooks/useChainsToSelect'
-import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
-import { useOnSelectChain } from '../../hooks/useOnSelectChain'
-import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
-import { useRecentTokens } from '../../hooks/useRecentTokens'
-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'
import { LpTokenPage } from '../LpTokenPage'
import { ManageListsAndTokens } from '../ManageListsAndTokens'
-const Wrapper = styled.div`
- width: 100%;
+export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode {
+ const { shouldRender, hasChainPanel, viewProps } = useSelectTokenWidgetController(props)
- > div {
- height: calc(100vh - 200px);
- min-height: 600px;
+ if (!shouldRender) {
+ return null
}
-`
-
-const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
-
-interface SelectTokenWidgetProps {
- displayLpTokenLists?: boolean
- standalone?: boolean
-}
-
-// TODO: Break down this large function into smaller functions
-// eslint-disable-next-line max-lines-per-function
-export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTokenWidgetProps): ReactNode {
- const {
- open,
- onSelectToken,
- tokenToImport,
- listToImport,
- selectedToken,
- onInputPressEnter,
- selectedPoolAddress,
- field,
- oppositeToken,
- selectedTargetChainId,
- } = useSelectTokenWidgetState()
- const { count: lpTokensWithBalancesCount } = useLpTokensWithBalances()
- const chainsToSelect = useChainsToSelect()
- const isBridgingEnabled = useIsBridgingEnabled()
- 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, chainId: walletChainId } = 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 { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({
- allTokens,
- favoriteTokens,
- activeChainId: selectedTargetChainId ?? walletChainId,
- })
- const handleTokenListItemClick = useCallback(
- (token: TokenWithLogo) => {
- addRecentToken(token)
- },
- [addRecentToken],
+ return (
+
+
+
+
+
)
+}
- const userAddedTokens = useUserAddedTokens()
- const allTokenLists = useAllListsList()
- const balancesState = useTokensBalancesCombined()
- const unsupportedTokens = useUnsupportedTokens()
- const permitCompatibleTokens = usePermitCompatibleTokens()
- const tokenListTags = useTokenListsTags()
- const onTokenListAddingError = useOnTokenListAddingError()
+function SelectTokenWidgetView(
+ props: SelectTokenWidgetViewProps & {
+ isChainPanelVisible: boolean
+ },
+): ReactNode {
+ const { isChainPanelVisible, isChainPanelEnabled, chainsPanelTitle, chainsToSelect, onSelectChain, selectTokenModalProps } =
+ props
- const isInjectedWidgetMode = isInjectedWidget()
+ const blockingView = getBlockingView(props)
- const showChainPanel = isBridgingEnabled && Boolean(chainsToSelect?.chains?.length)
- const chainsPanelTitle = t`Cross chain swap`
+ if (blockingView) {
+ return blockingView
+ }
- const closeTokenSelectWidget = useCloseTokenSelectWidget()
+ const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect
- const openPoolPage = useCallback(
- (selectedPoolAddress: string) => {
- updateSelectTokenWidget({ selectedPoolAddress })
- },
- [updateSelectTokenWidget],
+ return (
+ <>
+
+
+
+ {showDesktopChainPanel && (
+
+ )}
+ >
)
+}
- const closePoolPage = useCallback(() => {
- updateSelectTokenWidget({ selectedPoolAddress: undefined })
- }, [updateSelectTokenWidget])
-
- const resetTokenImport = useCallback(() => {
- updateSelectTokenWidget({
- tokenToImport: undefined,
- })
- }, [updateSelectTokenWidget])
-
- const onDismiss = useCallback(() => {
- setIsManageWidgetOpen(false)
- closeTokenSelectWidget({ overrideForceLock: true })
- }, [closeTokenSelectWidget])
-
- const importTokenAndClose = (tokens: TokenWithLogo[]): void => {
- importTokenCallback(tokens)
- const [tokenToSelect] = tokens
-
- if (tokenToSelect) {
- handleTokenListItemClick(tokenToSelect)
- onSelectToken?.(tokenToSelect)
- }
-
- onDismiss()
+function getBlockingView(props: SelectTokenWidgetViewProps): ReactNode | null {
+ const {
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ allTokenLists,
+ userAddedTokens,
+ onDismiss,
+ onBackFromImport,
+ onImportTokens,
+ onImportList,
+ onCloseManageWidget,
+ onClosePoolPage,
+ 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 (
+
+ )
}
- if (!onSelectToken || !open) return null
-
- return (
-
- {(() => {
- if (tokenToImport && !standalone) {
- return (
-
- )
- }
-
- if (listToImport && !standalone) {
- return (
-
- )
- }
-
- if (isManageWidgetOpen && !standalone) {
- return (
- setIsManageWidgetOpen(false)}
- />
- )
- }
+ if (isManageWidgetOpen && !standalone) {
+ return (
+
+ )
+ }
- if (selectedPoolAddress) {
- return (
-
- )
- }
+ if (selectedPoolAddress) {
+ return (
+
+ )
+ }
- return (
- setIsManageWidgetOpen(true)}
- hideFavoriteTokensTooltip={isInjectedWidgetMode}
- openPoolPage={openPoolPage}
- tokenListCategoryState={tokenListCategoryState}
- disableErc20={disableErc20}
- account={account}
- chainsToSelect={chainsToSelect}
- hasChainPanel={showChainPanel}
- chainsPanelTitle={chainsPanelTitle}
- onSelectChain={onSelectChain}
- areTokensLoading={areTokensLoading}
- tokenListTags={tokenListTags}
- areTokensFromBridge={areTokensFromBridge}
- isRouteAvailable={isRouteAvailable}
- onClearRecentTokens={clearRecentTokens}
- selectedTargetChainId={selectedTargetChainId}
- />
- )
- })()}
-
- )
+ return null
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts
new file mode 100644
index 00000000000..5af04231e12
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts
@@ -0,0 +1,22 @@
+import styled from 'styled-components/macro'
+
+export const Wrapper = styled.div`
+ width: 100%;
+ height: 100%;
+`
+
+export const InnerWrapper = styled.div<{ $hasSidebar: boolean }>`
+ min-height: min(600px, 100%);
+ width: 100%;
+ margin: 0 auto;
+ display: flex;
+ align-items: stretch;
+ flex-direction: ${({ $hasSidebar }) => ($hasSidebar ? 'row' : 'column')};
+`
+
+export const ModalContainer = styled.div`
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ height: 100%;
+`
From 259c318cfd0719092bb1a3ae2d859b09a2bd000f Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 17 Nov 2025 12:24:29 +0000
Subject: [PATCH 010/100] docs: describe recent token storage schema
---
.../src/modules/tokensList/hooks/recentTokensStorage.ts | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
index 990b944bc29..25c13dbb210 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/recentTokensStorage.ts
@@ -3,6 +3,8 @@ import { TokenWithLogo } from '@cowprotocol/common-const'
import { getTokenUniqueKey } from '../utils/tokenKey'
export const RECENT_TOKENS_LIMIT = 4
+// Storage schema: { [chainId: number]: StoredRecentToken[] } serialized under this key.
+// `migrateLegacyStoredTokens` upgrades the legacy array payload into the map format.
export const RECENT_TOKENS_STORAGE_KEY = 'select-token-widget:recent-tokens:v1'
export interface StoredRecentToken {
From d64ef3c87ff2a7d913a3eedcc9c2ec93e7478ea8 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 17 Nov 2025 14:18:19 +0000
Subject: [PATCH 011/100] fix: make SelectTokenModal onSelectChain optional
---
.../src/modules/tokensList/pure/SelectTokenModal/types.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 afc04da70c7..41dfe6ef284 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -43,5 +43,5 @@ export interface SelectTokenModalProps {
onInputPressEnter?(): void
onOpenManageWidget(): void
onDismiss(): void
- onSelectChain(chain: ChainInfo): void
+ onSelectChain?(chain: ChainInfo): void
}
From a60856cde5bbcd072de98c2c200f496d5c88473f Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 17 Nov 2025 14:32:29 +0000
Subject: [PATCH 012/100] fix: guard optional chain selector handlers
---
.../pure/SelectTokenModal/index.tsx | 52 +++++++++++++------
1 file changed, 35 insertions(+), 17 deletions(-)
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 dbe1e45f8a0..b898ea7285d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -152,7 +152,7 @@ function renderLegacyChainSelector(
chainsToSelect: SelectTokenModalProps['chainsToSelect'],
onSelectChain: SelectTokenModalProps['onSelectChain'],
): ReactNode {
- if (!chainsToSelect?.chains?.length) {
+ if (!chainsToSelect?.chains?.length || !onSelectChain) {
return null
}
@@ -168,6 +168,33 @@ function renderLegacyChainSelector(
)
}
+interface ChainPanelLayout {
+ showChainPanel: boolean
+ legacyChainsState: SelectTokenModalProps['chainsToSelect']
+ chainPanel: ReactNode
+ resolvedTitle: string
+}
+
+function getChainPanelLayout({
+ hasChainPanel,
+ chainsToSelect,
+ onSelectChain,
+ chainsPanelTitle,
+}: Pick): ChainPanelLayout {
+ const resolvedTitle = chainsPanelTitle ?? t`Cross chain swap`
+ if (!chainsToSelect?.chains?.length || !onSelectChain) {
+ return { showChainPanel: false, legacyChainsState: undefined, chainPanel: null, resolvedTitle }
+ }
+
+ const showChainPanel = Boolean(hasChainPanel)
+ const legacyChainsState = showChainPanel ? undefined : chainsToSelect
+ const chainPanel = showChainPanel ? (
+
+ ) : null
+
+ return { showChainPanel, legacyChainsState, chainPanel, resolvedTitle }
+}
+
function useSelectTokenModalLayout(props: SelectTokenModalProps): {
inputValue: string
setInputValue: (value: string) => void
@@ -178,26 +205,17 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): {
chainPanel: ReactNode
resolvedModalTitle: string
} {
- const {
- defaultInputValue = '',
- chainsToSelect,
- onSelectChain,
- modalTitle,
- hasChainPanel = false,
- chainsPanelTitle,
- } = props
+ const { defaultInputValue = '', chainsToSelect, onSelectChain, modalTitle, hasChainPanel = false, chainsPanelTitle } = props
const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
const selectTokenContext = useSelectTokenContext(props)
const resolvedModalTitle = modalTitle ?? t`Select token`
- const showChainPanel = hasChainPanel && Boolean(chainsToSelect?.chains?.length)
- const legacyChainsState =
- !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined
- const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap`
- const chainPanel =
- showChainPanel && chainsToSelect ? (
-
- ) : null
+ const { showChainPanel, legacyChainsState, chainPanel } = getChainPanelLayout({
+ hasChainPanel,
+ chainsToSelect,
+ onSelectChain,
+ chainsPanelTitle,
+ })
return {
inputValue,
From dd238e83feec6d79991e456889519484f783eeaf Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Mon, 17 Nov 2025 11:02:11 +0000
Subject: [PATCH 013/100] feat(tokenselector): implement SelectTokenWidget with
enhanced chain selection
---
.../MobileChainPanelPortal.tsx | 45 ++++
.../SelectTokenWidget/controllerProps.ts | 32 +--
.../containers/SelectTokenWidget/index.tsx | 114 +++++++++-
.../containers/SelectTokenWidget/styled.ts | 78 ++++++-
.../tokensList/pure/ChainPanel/styled.tsx | 15 +-
.../tokensList/pure/ChainsSelector/styled.tsx | 8 +-
.../SelectTokenModal/MobileChainSelector.tsx | 108 ++++++++++
.../pure/SelectTokenModal/helpers.tsx | 8 +-
.../pure/SelectTokenModal/index.cosmos.tsx | 67 +++---
.../pure/SelectTokenModal/index.tsx | 198 +++++++-----------
.../mobileChainSelector.styled.ts | 147 +++++++++++++
.../pure/SelectTokenModal/styled.ts | 36 ----
.../tokensList/pure/SelectTokenModal/types.ts | 8 +-
.../tokensList/pure/TokensContent/index.tsx | 123 +++--------
14 files changed, 658 insertions(+), 329 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx
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/MobileChainPanelPortal.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx
new file mode 100644
index 00000000000..c85b9f888ed
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/MobileChainPanelPortal.tsx
@@ -0,0 +1,45 @@
+import { MouseEvent, ReactNode } from 'react'
+
+import { createPortal } from 'react-dom'
+
+import { MobileChainPanelCard, MobileChainPanelOverlay } from './styled'
+
+import { ChainPanel } from '../../pure/ChainPanel'
+
+import type { SelectTokenWidgetViewProps } from './controllerProps'
+
+interface MobileChainPanelPortalProps {
+ chainsPanelTitle: string
+ chainsToSelect: SelectTokenWidgetViewProps['chainsToSelect']
+ onSelectChain: SelectTokenWidgetViewProps['onSelectChain']
+ onClose(): void
+}
+
+export function MobileChainPanelPortal({
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ onClose,
+}: MobileChainPanelPortalProps): ReactNode {
+ if (typeof document === 'undefined') {
+ return null
+ }
+
+ return createPortal(
+
+ ) => event.stopPropagation()}>
+ {
+ onSelectChain(chain)
+ onClose()
+ }}
+ variant="fullscreen"
+ onClose={onClose}
+ />
+
+ ,
+ document.body,
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
index 2b3d77c7fab..4223a467d43 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
@@ -73,9 +73,9 @@ interface BuildModalPropsArgs {
account: string | undefined
hasChainPanel: boolean
chainsState?: ChainsToSelectState
+ chainsPanelTitle: string
onSelectChain?(chain: ChainInfo): void
isInjectedWidgetMode: boolean
- chainsPanelTitle: string
modalTitle: string
}
@@ -136,15 +136,15 @@ export function buildSelectTokenModalPropsInput({
onDismiss,
onOpenManageWidget,
openPoolPage,
- tokenListCategoryState,
- disableErc20,
- account,
- hasChainPanel,
- chainsState,
- onSelectChain,
- isInjectedWidgetMode,
- chainsPanelTitle,
- modalTitle,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ hasChainPanel,
+ chainsState,
+ chainsPanelTitle,
+ onSelectChain,
+ isInjectedWidgetMode,
+ modalTitle,
}: BuildModalPropsArgs): SelectTokenModalProps {
return {
standalone,
@@ -171,10 +171,10 @@ export function buildSelectTokenModalPropsInput({
isRouteAvailable: tokenData.isRouteAvailable,
modalTitle,
hasChainPanel,
- chainsToSelect: chainsState,
- chainsPanelTitle,
+ mobileChainsLabel: chainsPanelTitle,
hideFavoriteTokensTooltip: isInjectedWidgetMode,
selectedTargetChainId: widgetState.selectedTargetChainId,
+ mobileChainsState: chainsState,
onSelectChain,
onClearRecentTokens,
}
@@ -208,9 +208,11 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
modalTitle: props.modalTitle,
hasChainPanel: props.hasChainPanel,
hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip,
- chainsPanelTitle: props.chainsPanelTitle,
selectedTargetChainId: props.selectedTargetChainId,
+ mobileChainsState: props.mobileChainsState,
+ mobileChainsLabel: props.mobileChainsLabel,
onSelectChain: props.onSelectChain,
+ onOpenMobileChainPanel: props.onOpenMobileChainPanel,
onClearRecentTokens: props.onClearRecentTokens,
}),
[
@@ -238,10 +240,12 @@ export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): Sele
props.isRouteAvailable,
props.modalTitle,
props.hasChainPanel,
- props.chainsPanelTitle,
props.hideFavoriteTokensTooltip,
props.selectedTargetChainId,
+ props.mobileChainsState,
+ props.mobileChainsLabel,
props.onSelectChain,
+ props.onOpenMobileChainPanel,
props.onClearRecentTokens,
],
)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
index 7c080c01dad..ff512fb1947 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -1,11 +1,18 @@
-import { ReactNode } from 'react'
+import { MouseEvent, ReactNode, useEffect, useState } from 'react'
+
+import { useMediaQuery } from '@cowprotocol/common-hooks'
+import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils'
+import { Media } from '@cowprotocol/ui'
+
+import { createPortal } from 'react-dom'
import {
useSelectTokenWidgetController,
type SelectTokenWidgetProps,
type SelectTokenWidgetViewProps,
} from './controller'
-import { InnerWrapper, ModalContainer, Wrapper } from './styled'
+import { MobileChainPanelPortal } from './MobileChainPanelPortal'
+import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from './styled'
import { ChainPanel } from '../../pure/ChainPanel'
import { ImportListModal } from '../../pure/ImportListModal'
@@ -16,27 +23,90 @@ import { ManageListsAndTokens } from '../ManageListsAndTokens'
export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode {
const { shouldRender, hasChainPanel, viewProps } = useSelectTokenWidgetController(props)
+ const isCompactLayout = useMediaQuery(Media.upToMedium(false))
+ const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false)
+ const isChainPanelVisible = hasChainPanel && !isCompactLayout
+
+ useEffect(() => {
+ if (!shouldRender) {
+ return
+ }
+
+ if (isChainPanelVisible) {
+ setIsMobileChainPanelOpen(false)
+ }
+ }, [isChainPanelVisible, shouldRender])
+
+ useEffect(() => {
+ if (!shouldRender) {
+ removeBodyClass('noScroll')
+ return undefined
+ }
+
+ addBodyClass('noScroll')
+ return () => removeBodyClass('noScroll')
+ }, [shouldRender])
if (!shouldRender) {
return null
}
- return (
+ const widgetContent = (
-
-
+
+
)
+
+ const handleOverlayClick = (event: MouseEvent): void => {
+ if (event.target !== event.currentTarget) {
+ return
+ }
+
+ viewProps.onDismiss()
+ }
+
+ const overlay = (
+
+
+ {widgetContent}
+
+
+ )
+
+ if (typeof document === 'undefined') {
+ return overlay
+ }
+
+ return createPortal(overlay, document.body)
}
function SelectTokenWidgetView(
props: SelectTokenWidgetViewProps & {
isChainPanelVisible: boolean
+ isCompactLayout: boolean
+ isMobileChainPanelOpen: boolean
+ setIsMobileChainPanelOpen(value: boolean): void
},
): ReactNode {
- const { isChainPanelVisible, isChainPanelEnabled, chainsPanelTitle, chainsToSelect, onSelectChain, selectTokenModalProps } =
- props
+ const {
+ isChainPanelVisible,
+ isCompactLayout,
+ isMobileChainPanelOpen,
+ setIsMobileChainPanelOpen,
+ isChainPanelEnabled,
+ chainsPanelTitle,
+ chainsToSelect,
+ onSelectChain,
+ selectTokenModalProps,
+ } = props
const blockingView = getBlockingView(props)
@@ -44,21 +114,47 @@ function SelectTokenWidgetView(
return blockingView
}
+ const closeMobileChainPanel = (): void => setIsMobileChainPanelOpen(false)
+ const mobileChainsState = isChainPanelEnabled && !isChainPanelVisible ? chainsToSelect : undefined
+ const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined
const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect
+ const showMobileChainPanel = !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen
return (
<>
-
+
{showDesktopChainPanel && (
)}
+ {showMobileChainPanel && (
+
+ )}
>
)
}
-function getBlockingView(props: SelectTokenWidgetViewProps): ReactNode | null {
+function getBlockingView(
+ props: SelectTokenWidgetViewProps & {
+ isChainPanelVisible: boolean
+ isCompactLayout: boolean
+ isMobileChainPanelOpen: boolean
+ setIsMobileChainPanelOpen(value: boolean): void
+ },
+): ReactNode | null {
const {
standalone,
tokenToImport,
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts
index 5af04231e12..04eddcd7d8d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/styled.ts
@@ -1,17 +1,43 @@
-import styled from 'styled-components/macro'
+import { Media } from '@cowprotocol/ui'
+
+import styled, { css } from 'styled-components/macro'
+import { WIDGET_MAX_WIDTH } from 'theme'
export const Wrapper = styled.div`
width: 100%;
height: 100%;
`
-export const InnerWrapper = styled.div<{ $hasSidebar: boolean }>`
- min-height: min(600px, 100%);
+export const InnerWrapper = styled.div<{ $hasSidebar: boolean; $isMobileOverlay?: boolean }>`
+ height: 100%;
+ min-height: ${({ $isMobileOverlay }) => ($isMobileOverlay ? '0' : 'min(600px, 100%)')};
width: 100%;
margin: 0 auto;
display: flex;
align-items: stretch;
- flex-direction: ${({ $hasSidebar }) => ($hasSidebar ? 'row' : 'column')};
+
+ ${({ $hasSidebar }) =>
+ $hasSidebar &&
+ css`
+ /* Stack modal + sidebar vertically on narrow screens so neither pane collapses */
+ ${Media.upToMedium()} {
+ flex-direction: column;
+ height: auto;
+ min-height: 0;
+ }
+
+ ${Media.upToSmall()} {
+ min-height: 0;
+ }
+ `}
+
+ ${({ $isMobileOverlay }) =>
+ $isMobileOverlay &&
+ css`
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ `}
`
export const ModalContainer = styled.div`
@@ -20,3 +46,47 @@ export const ModalContainer = styled.div`
display: flex;
height: 100%;
`
+
+export const MobileChainPanelOverlay = styled.div`
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+`
+
+export const MobileChainPanelCard = styled.div`
+ flex: 1;
+ max-width: 100%;
+ height: 100%;
+`
+
+export const WidgetOverlay = styled.div`
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ background: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ box-sizing: border-box;
+
+ ${Media.upToMedium()} {
+ padding: 0;
+ }
+`
+
+export const WidgetCard = styled.div<{ $isCompactLayout: boolean; $hasChainPanel: boolean }>`
+ width: 100%;
+ max-width: ${({ $isCompactLayout, $hasChainPanel }) =>
+ $isCompactLayout ? '100%' : $hasChainPanel ? WIDGET_MAX_WIDTH.tokenSelectSidebar : WIDGET_MAX_WIDTH.tokenSelect};
+ height: ${({ $isCompactLayout }) => ($isCompactLayout ? '100%' : '90vh')};
+ max-height: 100%;
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ box-sizing: border-box;
+`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx
index 6cbbb54eb75..c52a99f92c5 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/styled.tsx
@@ -2,10 +2,10 @@ import { Media, SearchInput as UISearchInput, UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
+import { IconButton } from '../commonElements'
+
export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>`
- width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '240px')};
- min-width: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '220px')};
- max-width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '280px')};
+ width: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : '200px')};
height: ${({ $variant }) => ($variant === 'fullscreen' ? '100%' : 'auto')};
flex-shrink: 0;
background: var(${UI.COLOR_PAPER_DARKER});
@@ -20,7 +20,6 @@ export const Panel = styled.div<{ $variant: 'default' | 'fullscreen' }>`
${Media.upToMedium()} {
width: 100%;
- min-width: 0;
border-left: none;
border-top: 1px solid var(${UI.COLOR_BORDER});
border-radius: ${({ $variant }) => ($variant === 'fullscreen' ? '0' : '0 0 20px 20px')};
@@ -49,6 +48,14 @@ export const PanelTitle = styled.h4<{ $isFullscreen?: boolean }>`
color: ${({ $isFullscreen }) => ($isFullscreen ? `var(${UI.COLOR_TEXT})` : `var(${UI.COLOR_TEXT_OPACITY_70})`)};
`
+export const PanelCloseButton = styled(IconButton)`
+ flex-shrink: 0;
+ border-radius: 50%;
+ width: 32px;
+ height: 32px;
+ background: var(${UI.COLOR_PAPER});
+`
+
export const PanelSearchInputWrapper = styled.div`
--min-height: 36px;
min-height: var(--min-height);
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 9391d6976f3..bab99e3ead3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
@@ -72,9 +72,8 @@ export const ChainLogo = styled.div`
--size: 28px;
width: var(--size);
height: var(--size);
- border-radius: var(--size);
overflow: hidden;
- background: var(${UI.COLOR_PAPER});
+ background: transparent;
display: flex;
align-items: center;
justify-content: center;
@@ -82,7 +81,7 @@ export const ChainLogo = styled.div`
> img {
width: 100%;
height: 100%;
- object-fit: cover;
+ object-fit: contain;
}
`
@@ -98,7 +97,8 @@ export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: stri
display: flex;
align-items: center;
justify-content: center;
- color: ${({ color$, accent$ }) => getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)};
+ color: ${({ color$, accent$ }) =>
+ getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)};
> svg {
width: 16px;
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 00000000000..bae7db6e408
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
@@ -0,0 +1,108 @@
+import { ReactNode, useEffect, useMemo, useRef } from 'react'
+
+import { useTheme } from '@cowprotocol/common-hooks'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+
+import { ChevronDown } from 'react-feather'
+
+import * as styledEl from './mobileChainSelector.styled'
+
+import { ChainsToSelectState } from '../../types'
+import { sortChainsByDisplayOrder } from '../../utils/sortChainsByDisplayOrder'
+import { getChainAccent } from '../ChainsSelector'
+
+interface MobileChainSelectorProps {
+ chainsState: ChainsToSelectState
+ label?: string
+ onSelectChain(chain: ChainInfo): void
+ onOpenPanel(): void
+}
+
+export function MobileChainSelector({
+ chainsState,
+ label,
+ onSelectChain,
+ onOpenPanel,
+}: MobileChainSelectorProps): ReactNode {
+ const scrollRef = useRef(null)
+ const orderedChains = useMemo(
+ () =>
+ sortChainsByDisplayOrder(chainsState.chains ?? [], {
+ pinChainId: chainsState.defaultChainId,
+ }),
+ [chainsState.chains, chainsState.defaultChainId],
+ )
+
+ const totalChains = chainsState.chains?.length ?? 0
+ const canRenderChains = orderedChains.length > 0
+ const activeChainLabel = orderedChains.find((chain) => chain.id === chainsState.defaultChainId)?.label
+
+ useEffect(() => {
+ if (!scrollRef.current) {
+ return
+ }
+
+ scrollRef.current.scrollTo({ left: 0, behavior: 'auto' })
+ }, [chainsState.defaultChainId])
+
+ return (
+
+ {label ? (
+
+ {label}
+ {activeChainLabel ? (
+
+ {activeChainLabel}
+
+ ) : null}
+
+ ) : null}
+
+ {canRenderChains ? (
+
+ {orderedChains.map((chain) => (
+
+ ))}
+
+ ) : null}
+ {totalChains > 0 ? (
+
+
+ View all ({totalChains})
+
+
+
+ ) : null}
+
+
+ )
+}
+
+interface ChainChipProps {
+ chain: ChainInfo
+ isActive: boolean
+ onSelectChain(chain: ChainInfo): void
+}
+
+function ChainChip({ chain, isActive, onSelectChain }: ChainChipProps): ReactNode {
+ const { darkMode } = useTheme()
+ const accent = getChainAccent(chain.id)
+ const logoSrc = darkMode ? chain.logo.dark : chain.logo.light
+
+ return (
+ onSelectChain(chain)}
+ $active={isActive}
+ $accent={accent}
+ aria-pressed={isActive}
+ >
+
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
index 829b24179b2..8793810e452 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
@@ -65,8 +65,6 @@ interface TokensContentSectionProps
| 'hideFavoriteTokensTooltip'
| 'selectedTargetChainId'
| 'onClearRecentTokens'
- | 'onOpenManageWidget'
- | 'standalone'
> {
searchInput: string
selectTokenContext: SelectTokenContext
@@ -84,8 +82,6 @@ export function TokensContentSection({
selectedTargetChainId,
selectTokenContext,
onClearRecentTokens,
- onOpenManageWidget,
- standalone,
}: TokensContentSectionProps): ReactNode {
return (
)
}
@@ -128,7 +122,7 @@ export function TitleBarActions({
{showManageButton && (
((acc, id) => {
const info = CHAIN_INFO[id]
@@ -42,25 +50,19 @@ const chainsMock: ChainInfo[] = [
return acc
}, [])
-const defaultProps: SelectTokenModalProps = {
+const favoriteTokenAddresses = new Set(favoriteTokensMock.map((token) => token.address.toLowerCase()))
+const recentTokensMock = allTokensMock.filter((token) => !favoriteTokenAddresses.has(token.address.toLowerCase())).slice(0, 3)
+
+const defaultModalProps: SelectTokenModalProps = {
tokenListTags: {},
account: undefined,
permitCompatibleTokens: {},
unsupportedTokens,
allTokens: allTokensMock,
favoriteTokens: favoriteTokensMock,
+ recentTokens: recentTokensMock,
areTokensLoading: false,
areTokensFromBridge: false,
- chainsToSelect: {
- chains: chainsMock,
- isLoading: false,
- defaultChainId: SupportedChainId.MAINNET,
- },
- hasChainPanel: true,
- chainsPanelTitle: 'Cross chain swap',
- onSelectChain(chain: ChainInfo) {
- console.log('onSelectChain', chain)
- },
tokenListCategoryState: [null, () => void 0],
balancesState: {
values: balances,
@@ -71,17 +73,12 @@ const defaultProps: SelectTokenModalProps = {
selectedToken,
isRouteAvailable: true,
modalTitle: 'Swap from',
- recentTokens: favoriteTokensMock.slice(0, 2),
- selectedTargetChainId: SupportedChainId.SEPOLIA,
onSelectToken() {
console.log('onSelectToken')
},
onTokenListItemClick(token) {
console.log('onTokenListItemClick', token.symbol)
},
- onClearRecentTokens() {
- console.log('onClearRecentTokens')
- },
onOpenManageWidget() {
console.log('onOpenManageWidget')
},
@@ -93,35 +90,57 @@ const defaultProps: SelectTokenModalProps = {
},
}
+const defaultChainPanelProps = {
+ title: 'Cross chain swap',
+ chainsState: {
+ defaultChainId: SupportedChainId.MAINNET,
+ chains: chainsMock,
+ isLoading: false,
+ },
+ onSelectChain(chain: ChainInfo) {
+ console.log('onSelectChain', chain)
+ },
+}
+
const Fixtures = {
default: () => (
-
+
+
+
+ ),
+ loadingSidebar: () => (
+
+
+
),
- noChainPanel: () => (
+ 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 b898ea7285d..ceb58448b39 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,23 @@
-import { ReactNode } from 'react'
+import { ComponentProps, ReactNode } from 'react'
import { SearchInput } from '@cowprotocol/ui'
import { t } from '@lingui/core/macro'
import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers'
+import { MobileChainSelector } from './MobileChainSelector'
import { SelectTokenModalContent } from './SelectTokenModalContent'
import * as styledEl from './styled'
import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
-import { ChainPanel } from '../ChainPanel'
-import { ChainsSelector } from '../ChainsSelector'
import type { SelectTokenModalProps } from './types'
import type { TokenSelectionHandler } from '../../types'
-
export type { SelectTokenModalProps }
export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
const {
+ defaultInputValue = '',
onSelectToken,
onDismiss,
onInputPressEnter,
@@ -27,9 +26,9 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
openPoolPage,
tokenListCategoryState,
disableErc20,
- onSelectChain,
- areTokensFromBridge,
isRouteAvailable,
+ modalTitle,
+ hasChainPanel = false,
standalone,
onOpenManageWidget,
favoriteTokens,
@@ -37,25 +36,30 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
onClearRecentTokens,
areTokensLoading,
allTokens,
+ areTokensFromBridge,
hideFavoriteTokensTooltip,
selectedTargetChainId,
+ mobileChainsState,
+ mobileChainsLabel,
+ onSelectChain,
+ onOpenMobileChainPanel,
isFullScreenMobile,
} = props
- const {
- inputValue,
- setInputValue,
- trimmedInputValue,
- selectTokenContext,
- showChainPanel,
- legacyChainsState,
- chainPanel,
- resolvedModalTitle,
- } = useSelectTokenModalLayout(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 (
- {renderLegacyChainSelector(chainsToSelect, onSelectChain)}
- {children}
- >
- )
-}
-
-function renderLegacyChainSelector(
- chainsToSelect: SelectTokenModalProps['chainsToSelect'],
- onSelectChain: SelectTokenModalProps['onSelectChain'],
-): ReactNode {
- if (!chainsToSelect?.chains?.length || !onSelectChain) {
- return null
- }
-
- return (
-
-
-
- )
-}
-
-interface ChainPanelLayout {
- showChainPanel: boolean
- legacyChainsState: SelectTokenModalProps['chainsToSelect']
- chainPanel: ReactNode
- resolvedTitle: string
-}
-
-function getChainPanelLayout({
- hasChainPanel,
- chainsToSelect,
- onSelectChain,
- chainsPanelTitle,
-}: Pick): ChainPanelLayout {
- const resolvedTitle = chainsPanelTitle ?? t`Cross chain swap`
- if (!chainsToSelect?.chains?.length || !onSelectChain) {
- return { showChainPanel: false, legacyChainsState: undefined, chainPanel: null, resolvedTitle }
- }
-
- const showChainPanel = Boolean(hasChainPanel)
- const legacyChainsState = showChainPanel ? undefined : chainsToSelect
- const chainPanel = showChainPanel ? (
-
- ) : null
-
- return { showChainPanel, legacyChainsState, chainPanel, resolvedTitle }
-}
-
-function useSelectTokenModalLayout(props: SelectTokenModalProps): {
- inputValue: string
- setInputValue: (value: string) => void
- trimmedInputValue: string
- selectTokenContext: ReturnType
- showChainPanel: boolean
- legacyChainsState: SelectTokenModalProps['chainsToSelect']
- chainPanel: ReactNode
- resolvedModalTitle: string
-} {
- const { defaultInputValue = '', chainsToSelect, onSelectChain, modalTitle, hasChainPanel = false, chainsPanelTitle } = props
-
- const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue)
- const selectTokenContext = useSelectTokenContext(props)
- const resolvedModalTitle = modalTitle ?? t`Select token`
- const { showChainPanel, legacyChainsState, chainPanel } = getChainPanelLayout({
- hasChainPanel,
- chainsToSelect,
- onSelectChain,
- chainsPanelTitle,
- })
-
- return {
- inputValue,
- setInputValue,
- trimmedInputValue,
- selectTokenContext,
- showChainPanel,
- legacyChainsState,
- chainPanel,
- resolvedModalTitle,
- }
+ return {children}
}
interface SelectTokenModalShellProps {
@@ -240,7 +152,7 @@ interface SelectTokenModalShellProps {
searchValue: string
onSearchChange(value: string): void
onSearchEnter?: () => void
- sideContent?: ReactNode
+ mobileChainSelector?: ComponentProps
}
function SelectTokenModalShell({
@@ -254,7 +166,7 @@ function SelectTokenModalShell({
searchValue,
onSearchChange,
onSearchEnter,
- sideContent,
+ mobileChainSelector,
}: SelectTokenModalShellProps): ReactNode {
return (
@@ -279,10 +191,42 @@ function SelectTokenModalShell({
/>
+ {mobileChainSelector ? : null}
{children}
- {sideContent}
)
}
+
+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 00000000000..2f3fd129525
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts
@@ -0,0 +1,147 @@
+import { UI } from '@cowprotocol/ui'
+
+import styled from 'styled-components/macro'
+
+import { ListTitle } from './styled'
+
+import type { ChainAccentVars } from '../ChainsSelector/styled'
+
+const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})`
+const fallbackBorder = `var(${UI.COLOR_PRIMARY_OPACITY_50})`
+
+const getBackground = (accent?: ChainAccentVars): string =>
+ accent ? `var(${accent.backgroundVar})` : fallbackBackground
+const getBorder = (accent?: ChainAccentVars): string => (accent ? `var(${accent.borderVar})` : fallbackBorder)
+
+export const MobileSelectorRow = styled.div`
+ padding: 0 14px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`
+
+export const MobileSelectorLabel = styled(ListTitle)`
+ padding: 4px 0;
+ justify-content: flex-start;
+ gap: 6px;
+ flex-wrap: wrap;
+`
+
+export const ActiveChainLabel = styled.span`
+ color: var(${UI.COLOR_TEXT});
+ font-weight: 600;
+ font-size: 14px;
+`
+
+export const ScrollContainer = styled.div`
+ --cta-width: min(45vw, 130px);
+ --fade-width: clamp(14px, 6vw, 32px);
+ --cta-gap: 2px;
+ --cta-offset: calc(var(--cta-width) + var(--cta-gap));
+ position: relative;
+ min-height: 44px;
+ overflow: hidden;
+ padding-right: var(--cta-offset);
+`
+
+export const ScrollArea = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ padding-right: var(--fade-width);
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ scroll-snap-type: x proximity;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ mask-image: linear-gradient(
+ 90deg,
+ #000 0%,
+ #000 calc(100% - var(--cta-offset) - var(--fade-width)),
+ rgba(0, 0, 0, 0) 100%
+ );
+ -webkit-mask-image: linear-gradient(
+ 90deg,
+ #000 0%,
+ #000 calc(100% - var(--cta-offset) - var(--fade-width)),
+ rgba(0, 0, 0, 0) 100%
+ );
+`
+
+export const FixedAllNetworks = styled.div`
+ pointer-events: none;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ width: var(--cta-width);
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+
+ > button {
+ pointer-events: auto;
+ width: 100%;
+ position: relative;
+ z-index: 1;
+ }
+`
+
+export const ChainChipButton = styled.button<{ $active?: boolean; $accent?: ChainAccentVars }>`
+ --size: 44px;
+ width: var(--size);
+ height: var(--size);
+ border-radius: 10px;
+ border: 2px solid ${({ $active, $accent }) => ($active ? getBorder($accent) : 'transparent')};
+ background: ${({ $active, $accent }) => ($active ? getBackground($accent) : 'transparent')};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition:
+ border 0.2s ease,
+ background 0.2s ease;
+ flex-shrink: 0;
+ scroll-snap-align: start;
+
+ > img {
+ --size: 100%;
+ width: var(--size);
+ height: var(--size);
+ object-fit: contain;
+ }
+`
+
+export const MoreChipButton = styled.button`
+ --size: 44px;
+ height: var(--size);
+ padding: 0 12px;
+ border-radius: var(--size);
+ border: 1px solid var(${UI.COLOR_PAPER_DARKEST});
+ background: var(${UI.COLOR_PAPER});
+ color: var(${UI.COLOR_TEXT});
+ font-weight: 600;
+ font-size: 13px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ cursor: pointer;
+ white-space: nowrap;
+ flex-shrink: 0;
+
+ svg {
+ --size: 18px;
+ stroke: var(${UI.COLOR_TEXT_OPACITY_50});
+ width: var(--size);
+ height: var(--size);
+ min-width: var(--size);
+ min-height: var(--size);
+ }
+`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
index 98b614e413a..4dae09d3ba6 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/styled.ts
@@ -103,8 +103,6 @@ export const Body = styled.div`
display: flex;
flex: 1;
min-height: 0;
- gap: 16px;
- align-items: stretch;
${Media.upToMedium()} {
flex-direction: column;
@@ -120,17 +118,6 @@ export const TokenColumn = styled.div`
padding: 0;
`
-export const LegacyChainsWrapper = styled.div`
- border-bottom: 1px solid var(${UI.COLOR_BORDER});
- padding: 2px 10px 10px 14px;
- margin: 0 14px 16px;
-
- ${Media.upToSmall()} {
- margin: 0 10px 16px;
- padding: 2px 4px 10px 8px;
- }
-`
-
export const Row = styled.div`
padding: 0 24px;
margin-bottom: 16px;
@@ -186,26 +173,3 @@ export const RouteNotAvailable = styled.div`
padding: 40px 0;
text-align: center;
`
-
-export const ActionButton = styled.button`
- ${blankButtonMixin};
-
- display: flex;
- width: 100%;
- align-items: center;
- flex-direction: row;
- justify-content: center;
- gap: 10px;
- 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;
-
- &:hover {
- opacity: 1;
- }
-`
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 41dfe6ef284..e29be36aca4 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -22,7 +22,6 @@ export interface SelectTokenModalProps {
displayLpTokenLists?: boolean
disableErc20?: boolean
account: string | undefined
- chainsToSelect?: ChainsToSelectState
tokenListCategoryState: [T, (category: T) => void]
defaultInputValue?: string
areTokensLoading: boolean
@@ -30,10 +29,13 @@ export interface SelectTokenModalProps {
standalone?: boolean
areTokensFromBridge: boolean
isRouteAvailable: boolean | undefined
- selectedTargetChainId?: number
modalTitle?: string
hasChainPanel?: boolean
- chainsPanelTitle?: string
+ selectedTargetChainId?: number
+ mobileChainsState?: ChainsToSelectState
+ mobileChainsLabel?: string
+ onSelectChain?(chain: ChainInfo): void
+ onOpenMobileChainPanel?(): void
isFullScreenMobile?: boolean
onSelectToken: TokenSelectionHandler
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 cd8bb607702..2bad71148a4 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -3,9 +3,6 @@ import React, { ReactNode, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { Loader } from '@cowprotocol/ui'
-import { Trans } from '@lingui/react/macro'
-import { Edit } from 'react-feather'
-
import { TokenSearchResults } from '../../containers/TokenSearchResults'
import { SelectTokenContext } from '../../types'
import { getTokenUniqueKey } from '../../utils/tokenKey'
@@ -17,14 +14,12 @@ export interface TokensContentProps {
selectTokenContext: SelectTokenContext
favoriteTokens: TokenWithLogo[]
recentTokens?: TokenWithLogo[]
- hideFavoriteTokensTooltip?: boolean
areTokensLoading: boolean
allTokens: TokenWithLogo[]
searchInput: string
- standalone?: boolean
areTokensFromBridge: boolean
+ hideFavoriteTokensTooltip?: boolean
selectedTargetChainId?: number
- onOpenManageWidget(): void
onClearRecentTokens?: () => void
}
@@ -32,15 +27,13 @@ export function TokensContent({
selectTokenContext,
favoriteTokens,
recentTokens,
- hideFavoriteTokensTooltip,
areTokensLoading,
allTokens,
displayLpTokenLists,
searchInput,
- standalone,
areTokensFromBridge,
+ hideFavoriteTokensTooltip,
selectedTargetChainId,
- onOpenManageWidget,
onClearRecentTokens,
}: TokensContentProps): ReactNode {
const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0
@@ -75,99 +68,35 @@ export function TokensContent({
const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined
const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined
- const tokensView = renderTokensView({
- areTokensLoading,
- searchInput,
- selectTokenContext,
- areTokensFromBridge,
- allTokens,
- tokensWithoutPinned,
- displayLpTokenLists,
- favoriteTokens: favoriteTokensInline,
- recentTokens: recentTokensInline,
- hideFavoriteTokensTooltip,
- selectedTargetChainId,
- onClearRecentTokens,
- })
-
return (
<>
- {tokensView}
- {!standalone && (
+ {areTokensLoading ? (
+
+
+
+ ) : (
<>
-
-
-
- {' '}
-
- Manage Token Lists
-
-
-
+ {searchInput ? (
+
+ ) : (
+
+ )}
>
)}
>
)
}
-
-interface TokensViewProps {
- areTokensLoading: boolean
- searchInput: string
- selectTokenContext: SelectTokenContext
- areTokensFromBridge: boolean
- allTokens: TokenWithLogo[]
- tokensWithoutPinned: TokenWithLogo[]
- displayLpTokenLists?: boolean
- favoriteTokens?: TokenWithLogo[]
- recentTokens?: TokenWithLogo[]
- hideFavoriteTokensTooltip?: boolean
- selectedTargetChainId?: number
- onClearRecentTokens?: () => void
-}
-
-function renderTokensView({
- areTokensLoading,
- searchInput,
- selectTokenContext,
- areTokensFromBridge,
- allTokens,
- tokensWithoutPinned,
- displayLpTokenLists,
- favoriteTokens,
- recentTokens,
- hideFavoriteTokensTooltip,
- selectedTargetChainId,
- onClearRecentTokens,
-}: TokensViewProps): ReactNode {
- if (areTokensLoading) {
- return (
-
-
-
- )
- }
-
- if (searchInput) {
- return (
-
- )
- }
-
- return (
-
- )
-}
From 7739ad4f97e9dffa63da7741f11ee7d70abd2e9d Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 18 Nov 2025 08:30:55 +0000
Subject: [PATCH 014/100] fix: update fallback logic for unsupported source
chains in chain selection
---
.../tokensList/hooks/useChainsToSelect.test.ts | 17 +++++++++++++++++
.../tokensList/hooks/useChainsToSelect.ts | 2 +-
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
index 5bf6a56e458..d6547eab754 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
@@ -45,4 +45,21 @@ describe('useChainsToSelect state builders', () => {
SupportedChainId.AVALANCHE,
])
})
+
+ it('falls back to wallet chain when bridge does not support the source chain', () => {
+ const state = createOutputChainsState({
+ selectedTargetChainId: SupportedChainId.BASE,
+ chainId: SupportedChainId.SEPOLIA,
+ currentChainInfo: createChainInfoForTests(SupportedChainId.SEPOLIA),
+ bridgeSupportedNetworks: [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
+ ],
+ areUnsupportedChainsEnabled: true,
+ isLoading: false,
+ })
+
+ expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA)
+ expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA])
+ })
})
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
index c42664014cd..f59b315a8d4 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -148,7 +148,7 @@ export function createOutputChainsState({
if (!isSourceChainSupportedByBridge) {
// Source chain is unsupported by the bridge provider; fall back to non-bridge behavior.
- return createSingleChainState(selectedTargetChainId, currentChainInfo)
+ return createSingleChainState(chainId, currentChainInfo)
}
return {
From 43247d6c922add8eaae7ec199d5f741585d48fd5 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 18 Nov 2025 10:45:47 +0000
Subject: [PATCH 015/100] refactor: remove onSelectChain from
SelectTokenModalProps interface
---
.../src/modules/tokensList/pure/SelectTokenModal/types.ts | 1 -
1 file changed, 1 deletion(-)
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 e29be36aca4..5f4c388b31c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -45,5 +45,4 @@ export interface SelectTokenModalProps {
onInputPressEnter?(): void
onOpenManageWidget(): void
onDismiss(): void
- onSelectChain?(chain: ChainInfo): void
}
From 03f600243b46e283a2772ea47c1068ff1cdfacf0 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 18 Nov 2025 12:49:02 +0000
Subject: [PATCH 016/100] refactor: improve tooltip handling in
TokenSourceTitle component
---
.../tokensList/pure/TokenSearchContent/index.tsx | 2 +-
.../tokensList/pure/TokenSourceTitle/index.tsx | 11 +++++++----
2 files changed, 8 insertions(+), 5 deletions(-)
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 a238ab18d09..b70d5756636 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
@@ -233,7 +233,7 @@ function TokenSearchRowRenderer({ row, selectTokenContext, importToken }: TokenS
case 'token':
return
case 'section-title': {
- const tooltip = row.tooltip ?? ''
+ const tooltip = row.tooltip?.trim() || undefined
return (
{row.text}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx
index bf12a0f6bc7..51e8fbcd1ae 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSourceTitle/index.tsx
@@ -25,13 +25,14 @@ const Title = styled.h4`
export interface TokenSourceTitleProps {
children: string
- tooltip: string
+ tooltip?: string
}
// TODO: Add proper return type annotation
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function TokenSourceTitle(props: TokenSourceTitleProps) {
const { children, tooltip } = props
+ const tooltipText = tooltip?.trim()
return (
@@ -39,9 +40,11 @@ export function TokenSourceTitle(props: TokenSourceTitleProps) {
{children}
-
-
-
+ {tooltipText ? (
+
+
+
+ ) : null}
)
}
From 7e85dc119b903827a116b89758fa34502f3fd5b0 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 19 Nov 2025 16:18:20 +0000
Subject: [PATCH 017/100] refactor: token pinning logic to ensure recent tokens
are displayed correctly
---
.../modules/tokensList/pure/TokensContent/index.tsx | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
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 2bad71148a4..02199a83aa6 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -40,22 +40,21 @@ export function TokensContent({
const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0
const pinnedTokenKeys = useMemo(() => {
- if (!shouldShowFavoritesInline && !shouldShowRecentsInline) {
+ // Only hide "Recent" tokens from the main list.
+ // Favorite tokens should still appear in "All tokens" so they participate
+ // in balance-based sorting and show their balances.
+ if (!shouldShowRecentsInline) {
return undefined
}
const pinned = new Set()
- if (shouldShowFavoritesInline) {
- favoriteTokens.forEach((token) => pinned.add(getTokenUniqueKey(token)))
- }
-
if (shouldShowRecentsInline && recentTokens) {
recentTokens.forEach((token) => pinned.add(getTokenUniqueKey(token)))
}
return pinned
- }, [favoriteTokens, recentTokens, shouldShowFavoritesInline, shouldShowRecentsInline])
+ }, [recentTokens, shouldShowRecentsInline])
const tokensWithoutPinned = useMemo(() => {
if (!pinnedTokenKeys) {
From 5e58eaf53aec9d141b83f148ca3a7313c6a2c2c1 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 19 Nov 2025 16:25:10 +0000
Subject: [PATCH 018/100] refactor: remove x close button in manage token list
modal
---
.../containers/ManageListsAndTokens/index.tsx | 11 +++--------
.../tokensList/containers/SelectTokenWidget/index.tsx | 1 -
2 files changed, 3 insertions(+), 9 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx
index 46aff689b97..833b0ba034a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/ManageListsAndTokens/index.tsx
@@ -17,7 +17,7 @@ export interface ManageListsAndTokensProps {
lists: ListState[]
customTokens: TokenWithLogo[]
onBack(): void
- onDismiss(): void
+ onDismiss?(): void
}
const tokensInputPlaceholder = '0x0000'
@@ -50,20 +50,15 @@ export function ManageListsAndTokens(props: ManageListsAndTokensProps): ReactNod
const tokenSearchResponse = useSearchToken(isTokenAddressValid ? tokenInput : null)
const listSearchResponse = useSearchList(isListUrlValid ? listInput : null)
- // TODO: Add proper return type annotation
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const setListsTab = () => {
+ const setListsTab = (): void => {
setCurrentTab('lists')
setInputValue('')
}
- // TODO: Add proper return type annotation
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const setTokensTab = () => {
+ const setTokensTab = (): void => {
setCurrentTab('tokens')
setInputValue('')
}
-
return (
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 ff512fb1947..40d3a5c89e9 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -194,7 +194,6 @@ function getBlockingView(
)
From 93ec9eded9f6fa2558166ef5a2301e1b78d12f6e Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 20 Nov 2025 14:16:03 +0000
Subject: [PATCH 019/100] refactor: standardize token key generation across
hooks and components
---
.../modules/accountProxy/hooks/useRefundAmounts.ts | 3 ++-
.../hooks/useTokenBalanceAndUsdValue.ts | 3 ++-
.../modules/accountProxy/hooks/useTokensToRefund.ts | 3 ++-
.../modules/tokensList/pure/TokenListItem/index.tsx | 3 ++-
.../src/modules/tokensList/utils/tokenKey.ts | 3 ++-
apps/explorer/src/hooks/useTokenList.ts | 5 +++--
libs/common-utils/src/areAddressesEqual.ts | 7 +++++--
libs/common-utils/src/tokens.ts | 13 +++++++++++++
8 files changed, 31 insertions(+), 9 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts
index 98ebb04b94c..fc8f4e819f0 100644
--- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts
+++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useRefundAmounts.ts
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
+import { getTokenId } from '@cowprotocol/common-utils'
import { CurrencyAmount } from '@uniswap/sdk-core'
import { getUsdPriceStateKey, useUsdPrices } from 'modules/usdAmount'
@@ -25,7 +26,7 @@ export function useRefundAmounts(): TokenUsdAmounts | null {
return tokensToRefund.reduce((acc, { token, balance }) => {
const usdPrice = usdPrices[getUsdPriceStateKey(token)]
- const tokenKey = token.address.toLowerCase()
+ const tokenKey = getTokenId(token)
acc[tokenKey] = {
token,
diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts
index d6a425c41a2..efb26014813 100644
--- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts
+++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokenBalanceAndUsdValue.ts
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { useTokensBalances } from '@cowprotocol/balances-and-allowances'
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenAddressKey } from '@cowprotocol/common-utils'
import { useTokensByAddressMap } from '@cowprotocol/tokens'
import { CurrencyAmount, Token } from '@uniswap/sdk-core'
@@ -16,7 +17,7 @@ export function useTokenBalanceAndUsdValue(tokenAddress: string | undefined): To
const tokensByAddress = useTokensByAddressMap()
const { values: balances } = useTokensBalances()
- const tokenKey = tokenAddress?.toLowerCase() || undefined
+ const tokenKey = tokenAddress ? getTokenAddressKey(tokenAddress) : undefined
const token = !!tokenKey && tokensByAddress[tokenKey]
const balanceRaw = !!tokenKey && balances[tokenKey]
diff --git a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts
index acd702340f4..598848a5a31 100644
--- a/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts
+++ b/apps/cowswap-frontend/src/modules/accountProxy/hooks/useTokensToRefund.ts
@@ -2,6 +2,7 @@ import { useMemo } from 'react'
import { useTokensBalances } from '@cowprotocol/balances-and-allowances'
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenAddressKey } from '@cowprotocol/common-utils'
import { useTokensByAddressMap } from '@cowprotocol/tokens'
import { BigNumber } from '@ethersproject/bignumber'
@@ -17,7 +18,7 @@ export function useTokensToRefund(): TokenToRefund[] | undefined {
return useMemo(() => {
return Object.keys(balances.values)
.reduce((acc, tokenAddress) => {
- const token = tokensByAddress[tokenAddress.toLowerCase()]
+ const token = tokensByAddress[getTokenAddressKey(tokenAddress)]
const balance = balances.values[tokenAddress]
if (token && balance?.gt(0)) {
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
index d9d4c867a42..3c6d27dda1c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx
@@ -14,6 +14,7 @@ import * as styledEl from './styled'
import { useDeferredVisibility } from '../../hooks/useDeferredVisibility'
import { TokenSelectionHandler } from '../../types'
+import { getTokenUniqueKey } from '../../utils/tokenKey'
import { TokenInfo } from '../TokenInfo'
import { TokenTags } from '../TokenTags'
@@ -60,7 +61,7 @@ export function TokenListItem(props: TokenListItemProps): ReactNode {
className,
} = props
- const tokenKey = `${token.chainId}:${token.address.toLowerCase()}`
+ const tokenKey = getTokenUniqueKey(token)
// Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport.
const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility({
resetKey: tokenKey,
diff --git a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts
index 8f827b02885..5abaeeaeea3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/utils/tokenKey.ts
@@ -1,7 +1,8 @@
import { TokenWithLogo } from '@cowprotocol/common-const'
+import { getTokenId } from '@cowprotocol/common-utils'
type TokenIdentifier = Pick
export function getTokenUniqueKey(token: TokenIdentifier): string {
- return `${token.chainId}:${token.address.toLowerCase()}`
+ return getTokenId(token)
}
diff --git a/apps/explorer/src/hooks/useTokenList.ts b/apps/explorer/src/hooks/useTokenList.ts
index 8eb0a45f1f3..2ab85ac0685 100644
--- a/apps/explorer/src/hooks/useTokenList.ts
+++ b/apps/explorer/src/hooks/useTokenList.ts
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const'
+import { getTokenAddressKey } from '@cowprotocol/common-utils'
import { ALL_SUPPORTED_CHAIN_IDS, mapSupportedNetworks, SupportedChainId } from '@cowprotocol/cow-sdk'
import type { TokenInfo, TokenList } from '@uniswap/token-lists'
@@ -65,7 +66,7 @@ export function useTokenList(chainId: SupportedChainId | undefined): { data: Tok
const nativeToken = NATIVE_TOKEN_PER_NETWORK[chainId]
- data[NATIVE_TOKEN_ADDRESS.toLowerCase()] = {
+ data[getTokenAddressKey(NATIVE_TOKEN_ADDRESS)] = {
...nativeToken,
name: nativeToken.name || '',
symbol: nativeToken.symbol || '',
@@ -93,7 +94,7 @@ function fetcher(tokenListUrl: string): Promise {
tokens.reduce((acc, token) => {
// Pick only supported chains
if (SUPPORTED_CHAIN_IDS_SET.has(token.chainId)) {
- acc[token.chainId][token.address.toLowerCase()] = token
+ acc[token.chainId][getTokenAddressKey(token.address)] = token
}
return acc
}, INITIAL_TOKEN_LIST_PER_NETWORK),
diff --git a/libs/common-utils/src/areAddressesEqual.ts b/libs/common-utils/src/areAddressesEqual.ts
index 41765bee726..5cd80b3e9b5 100644
--- a/libs/common-utils/src/areAddressesEqual.ts
+++ b/libs/common-utils/src/areAddressesEqual.ts
@@ -1,7 +1,10 @@
import { Nullish } from '@cowprotocol/types'
+import { getTokenAddressKey } from './tokens'
+
export function areAddressesEqual(a: Nullish, b: Nullish): boolean {
- if ((a && !b) || (!a && b)) return false
+ if (!a && !b) return true
+ if (!a || !b) return false
- return a?.toLowerCase() === b?.toLowerCase()
+ return getTokenAddressKey(a) === getTokenAddressKey(b)
}
diff --git a/libs/common-utils/src/tokens.ts b/libs/common-utils/src/tokens.ts
index a3a5287030d..53a69fb205f 100644
--- a/libs/common-utils/src/tokens.ts
+++ b/libs/common-utils/src/tokens.ts
@@ -14,3 +14,16 @@ export function isNativeAddress(tokenAddress: string, chainId: ChainId): boolean
return native && doesTokenMatchSymbolOrAddress(native, tokenAddressLower)
}
+
+export function getTokenAddressKey(address: string): string {
+ return address.toLowerCase()
+}
+
+export interface TokenIdentifier {
+ address: string
+ chainId: number
+}
+
+export function getTokenId(token: TokenIdentifier): string {
+ return `${token.chainId}:${getTokenAddressKey(token.address)}`
+}
From edaaed4fea9653b49ff690a46b4dd9e6922783bb Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 20 Nov 2025 15:53:56 +0000
Subject: [PATCH 020/100] refactor: integrate useCloseTokenSelectWidget for
improved widget closure handling
---
.../containers/SelectTokenWidget/index.tsx | 17 ++++++++++++++++-
1 file changed, 16 insertions(+), 1 deletion(-)
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 40d3a5c89e9..39f2d04c837 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -1,4 +1,4 @@
-import { MouseEvent, ReactNode, useEffect, useState } from 'react'
+import { MouseEvent, ReactNode, useEffect, useRef, useState } from 'react'
import { useMediaQuery } from '@cowprotocol/common-hooks'
import { addBodyClass, removeBodyClass } from '@cowprotocol/common-utils'
@@ -14,6 +14,7 @@ import {
import { MobileChainPanelPortal } from './MobileChainPanelPortal'
import { InnerWrapper, ModalContainer, WidgetCard, WidgetOverlay, Wrapper } from './styled'
+import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
import { ChainPanel } from '../../pure/ChainPanel'
import { ImportListModal } from '../../pure/ImportListModal'
import { ImportTokenModal } from '../../pure/ImportTokenModal'
@@ -26,6 +27,20 @@ export function SelectTokenWidget(props: SelectTokenWidgetProps): ReactNode {
const isCompactLayout = useMediaQuery(Media.upToMedium(false))
const [isMobileChainPanelOpen, setIsMobileChainPanelOpen] = useState(false)
const isChainPanelVisible = hasChainPanel && !isCompactLayout
+ const closeTokenSelectWidget = useCloseTokenSelectWidget()
+
+ const closeTokenSelectWidgetRef =
+ useRef>(closeTokenSelectWidget)
+
+ useEffect(() => {
+ closeTokenSelectWidgetRef.current = closeTokenSelectWidget
+ }, [closeTokenSelectWidget])
+
+ useEffect(() => {
+ return () => {
+ closeTokenSelectWidgetRef.current?.({ overrideForceLock: true })
+ }
+ }, [])
useEffect(() => {
if (!shouldRender) {
From f21738b5829ffcbfef6f74b2e14e2c508ecb0b28 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 25 Nov 2025 13:35:03 +0000
Subject: [PATCH 021/100] refactor: optimize token import logic and fix balance
retrieval in TokenListItemContainer
---
.../containers/SelectTokenWidget/index.tsx | 21 +++++++++++--------
.../pure/TokenListItemContainer/index.tsx | 2 +-
2 files changed, 13 insertions(+), 10 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 fa8966f62fa..cfbba946687 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -149,17 +149,20 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
closeTokenSelectWidget()
}, [closeTokenSelectWidget])
- const importTokenAndClose = (tokens: TokenWithLogo[]): void => {
- importTokenCallback(tokens)
- const [tokenToSelect] = tokens
+ const importTokenAndClose = useCallback(
+ (tokens: TokenWithLogo[]): void => {
+ importTokenCallback(tokens)
+ const [tokenToSelect] = tokens
- if (tokenToSelect) {
- handleTokenListItemClick(tokenToSelect)
- onSelectToken?.(tokenToSelect)
- }
+ if (tokenToSelect) {
+ handleTokenListItemClick(tokenToSelect)
+ onSelectToken?.(tokenToSelect)
+ }
- onDismiss()
- }
+ onDismiss()
+ },
+ [handleTokenListItemClick, importTokenCallback, onDismiss, onSelectToken],
+ )
const importListAndBack = (list: ListState): void => {
try {
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 97208d6eb0f..b7d8c72506f 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItemContainer/index.tsx
@@ -37,7 +37,7 @@ export function TokenListItemContainer({ token, context }: TokenListItemContaine
isPermitCompatible={permitCompatibleTokens[addressLowerCase]}
selectedToken={selectedToken}
token={token}
- balance={balances ? balances[token.address.toLowerCase()] : undefined}
+ balance={balances ? balances[addressLowerCase] : undefined}
onSelectToken={handleSelectToken}
isWalletConnected={isWalletConnected}
tokenListTags={tokenListTags}
From 38939a49a1b5edd13d30eb077cd449c0ea718fa7 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 26 Nov 2025 09:53:18 +0000
Subject: [PATCH 022/100] refactor: enhance TradeWidgetModals to utilize
usePrevious
---
.../TradeWidget/TradeWidgetModals.tsx | 24 +++++++++++++------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx
index eb8ddf1b92b..1a348f30311 100644
--- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx
+++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/TradeWidgetModals.tsx
@@ -1,5 +1,6 @@
import { ReactNode, useCallback, useEffect, useRef } from 'react'
+import { usePrevious } from '@cowprotocol/common-hooks'
import { useAddUserToken } from '@cowprotocol/tokens'
import { useWalletInfo } from '@cowprotocol/wallet'
@@ -85,11 +86,9 @@ export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetMod
)
const isOutputTokenSelector = field === Field.OUTPUT
- const isOutputTokenSelectorRef = useRef(isOutputTokenSelector)
-
- useEffect(() => {
- isOutputTokenSelectorRef.current = isOutputTokenSelector
- }, [isOutputTokenSelector])
+ const previousIsOutputTokenSelector = usePrevious(isOutputTokenSelector)
+ const previousChainId = usePrevious(chainId)
+ const isInitialRenderRef = useRef(true)
const error = tokenListAddingError || approveError || confirmError
@@ -105,8 +104,19 @@ export function TradeWidgetModals({ confirmModal, genericModal }: TradeWidgetMod
* Because network might be changed from the widget inside
*/
useEffect(() => {
- resetAllScreens(isOutputTokenSelectorRef.current)
- }, [chainId, resetAllScreens])
+ const chainChanged = previousChainId !== chainId
+
+ if (!chainChanged && !isInitialRenderRef.current) {
+ return
+ }
+
+ isInitialRenderRef.current = false
+
+ const shouldCloseTokenSelectWidget =
+ chainChanged ? isOutputTokenSelector : previousIsOutputTokenSelector ?? isOutputTokenSelector
+
+ resetAllScreens(shouldCloseTokenSelectWidget)
+ }, [chainId, isOutputTokenSelector, previousChainId, previousIsOutputTokenSelector, resetAllScreens])
if (genericModal) {
return genericModal
From cd49f8e3b6a8806a136518172aaa1c08dca01bcd Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 26 Nov 2025 09:55:24 +0000
Subject: [PATCH 023/100] refactor: simplify max-width logic in TradeWidget
container
---
.../trade/containers/TradeWidget/styled.tsx | 21 ++++++++++++-------
1 file changed, 14 insertions(+), 7 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx
index 7cb13d23761..0c02d8d7ae8 100644
--- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/styled.tsx
@@ -3,14 +3,21 @@ import { UI } from '@cowprotocol/ui'
import styled from 'styled-components/macro'
import { WIDGET_MAX_WIDTH } from 'theme'
-export const Container = styled.div<{ isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean }>`
+type ContainerSizeProps = { isTokenSelectOpen?: boolean; isTokenSelectWide?: boolean }
+
+const getContainerMaxWidth = ({ isTokenSelectOpen, isTokenSelectWide }: ContainerSizeProps): string => {
+ if (!isTokenSelectOpen) {
+ return WIDGET_MAX_WIDTH.swap
+ }
+
+ const openedWidth = isTokenSelectWide ? WIDGET_MAX_WIDTH.tokenSelectSidebar : WIDGET_MAX_WIDTH.tokenSelect
+
+ return openedWidth
+}
+
+export const Container = styled.div`
width: 100%;
- max-width: ${({ isTokenSelectOpen, isTokenSelectWide }) =>
- isTokenSelectOpen
- ? isTokenSelectWide
- ? WIDGET_MAX_WIDTH.tokenSelectSidebar
- : WIDGET_MAX_WIDTH.tokenSelect
- : WIDGET_MAX_WIDTH.swap};
+ max-width: ${getContainerMaxWidth};
margin: 0 auto;
position: relative;
`
From b3f48f1525aeb39959a7f8f634f290e8dc9b4c1e Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 27 Nov 2025 10:13:13 +0000
Subject: [PATCH 024/100] refactor: extract TokenColumnContent component and
clean up SelectTokenModal
---
.../SelectTokenModal/TokenColumnContent.tsx | 83 +++++++++++++++++++
.../pure/SelectTokenModal/index.tsx | 77 +----------------
2 files changed, 84 insertions(+), 76 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
new file mode 100644
index 00000000000..0d2c51b9de6
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
@@ -0,0 +1,83 @@
+import { ReactNode } from 'react'
+
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { TokenListCategory } from '@cowprotocol/tokens'
+
+import { SelectTokenModalContent } from './SelectTokenModalContent'
+import * as styledEl from './styled'
+
+import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
+import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
+import { ChainsSelector } from '../ChainsSelector'
+
+type TokenListCategoryState = [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void]
+
+export interface TokenColumnContentProps {
+ displayLpTokenLists?: boolean
+ account: string | undefined
+ inputValue: string
+ onSelectToken: TokenSelectionHandler
+ openPoolPage(poolAddress: string): void
+ disableErc20?: boolean
+ tokenListCategoryState: TokenListCategoryState
+ isRouteAvailable: boolean | undefined
+ chainsToSelect?: ChainsToSelectState
+ onSelectChain: (chain: ChainInfo) => void
+ children: ReactNode
+}
+
+export function TokenColumnContent({
+ displayLpTokenLists,
+ account,
+ inputValue,
+ onSelectToken,
+ openPoolPage,
+ disableErc20,
+ tokenListCategoryState,
+ isRouteAvailable,
+ chainsToSelect,
+ onSelectChain,
+ children,
+}: TokenColumnContentProps): ReactNode {
+ if (displayLpTokenLists) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+ <>
+ {renderLegacyChainSelector(chainsToSelect, onSelectChain)}
+ {children}
+ >
+ )
+}
+
+function renderLegacyChainSelector(
+ chainsToSelect: ChainsToSelectState | undefined,
+ onSelectChain: (chain: ChainInfo) => void,
+): ReactNode {
+ if (!chainsToSelect?.chains?.length) {
+ return null
+ }
+
+ 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 18b7cd849b5..2c40b037392 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -5,15 +5,10 @@ import { SearchInput } from '@cowprotocol/ui'
import { t } from '@lingui/core/macro'
import { TokensContentSection, TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers'
-import { SelectTokenModalContent } from './SelectTokenModalContent'
import * as styledEl from './styled'
-
-
-import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
-import { ChainsSelector } from '../ChainsSelector'
+import { TokenColumnContent } from './TokenColumnContent'
import type { SelectTokenModalProps } from './types'
-import type { TokenSelectionHandler } from '../../types'
export type { SelectTokenModalProps }
@@ -96,76 +91,6 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
)
}
-interface TokenColumnContentProps {
- displayLpTokenLists?: boolean
- account: string | undefined
- inputValue: string
- onSelectToken: TokenSelectionHandler
- openPoolPage(poolAddress: string): void
- disableErc20?: boolean
- tokenListCategoryState: SelectTokenModalProps['tokenListCategoryState']
- isRouteAvailable: boolean | undefined
- chainsToSelect?: SelectTokenModalProps['chainsToSelect']
- onSelectChain: SelectTokenModalProps['onSelectChain']
- children: ReactNode
-}
-
-function TokenColumnContent({
- displayLpTokenLists,
- account,
- inputValue,
- onSelectToken,
- openPoolPage,
- disableErc20,
- tokenListCategoryState,
- isRouteAvailable,
- chainsToSelect,
- onSelectChain,
- children,
-}: TokenColumnContentProps): ReactNode {
- if (displayLpTokenLists) {
- return (
-
- {children}
-
- )
- }
-
- return (
- <>
- {renderLegacyChainSelector(chainsToSelect, onSelectChain)}
- {children}
- >
- )
-}
-
-function renderLegacyChainSelector(
- chainsToSelect: SelectTokenModalProps['chainsToSelect'],
- onSelectChain: SelectTokenModalProps['onSelectChain'],
-): ReactNode {
- if (!chainsToSelect?.chains?.length) {
- return null
- }
-
- return (
-
-
-
- )
-}
-
interface SelectTokenModalShellProps {
children: ReactNode
hasChainPanel: boolean
From 8288b94dc5c35ff195e0e5fd2cb9c43561a5765a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 27 Nov 2025 10:21:39 +0000
Subject: [PATCH 025/100] refactor: rename SelectTokenModalProps to
TokenListContentProps
---
.../tokensList/pure/SelectTokenModal/types.ts | 17 +++++++++++++----
1 file changed, 13 insertions(+), 4 deletions(-)
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 649cfcfe1b8..70f4c4b7399 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -10,7 +10,7 @@ import { PermitCompatibleTokens } from 'modules/permit'
import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
-export interface SelectTokenModalProps {
+export interface TokenListContentProps {
allTokens: TokenWithLogo[]
favoriteTokens: TokenWithLogo[]
recentTokens?: TokenWithLogo[]
@@ -22,7 +22,6 @@ export interface SelectTokenModalProps {
displayLpTokenLists?: boolean
disableErc20?: boolean
account: string | undefined
- chainsToSelect?: ChainsToSelectState
tokenListCategoryState: [T, (category: T) => void]
defaultInputValue?: string
areTokensLoading: boolean
@@ -30,11 +29,18 @@ export interface SelectTokenModalProps {
standalone?: boolean
areTokensFromBridge: boolean
isRouteAvailable: boolean | undefined
- selectedTargetChainId?: number
modalTitle?: string
hasChainPanel?: boolean
isFullScreenMobile?: boolean
+ selectedTargetChainId?: number
+}
+
+export interface ChainSelectionProps {
+ chainsToSelect?: ChainsToSelectState
+ onSelectChain(chain: ChainInfo): void
+}
+export interface ModalCallbackProps {
onSelectToken: TokenSelectionHandler
onTokenListItemClick?(token: TokenWithLogo): void
onClearRecentTokens?(): void
@@ -42,5 +48,8 @@ export interface SelectTokenModalProps {
onInputPressEnter?(): void
onOpenManageWidget(): void
onDismiss(): void
- onSelectChain(chain: ChainInfo): void
}
+
+export type SelectTokenModalProps = TokenListContentProps &
+ ChainSelectionProps &
+ ModalCallbackProps
From 8c81595163195bc0f46a3b3e822f4bf374d57582 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 27 Nov 2025 10:26:06 +0000
Subject: [PATCH 026/100] refactor: add TODO for prop count reduction in
TokenListContentProps
---
.../src/modules/tokensList/pure/SelectTokenModal/types.ts | 1 +
1 file changed, 1 insertion(+)
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 70f4c4b7399..725d69d9ee3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts
@@ -10,6 +10,7 @@ import { PermitCompatibleTokens } from 'modules/permit'
import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
+// TODO: Refactor to reduce prop count
export interface TokenListContentProps {
allTokens: TokenWithLogo[]
favoriteTokens: TokenWithLogo[]
From 0bbdc81788d7d126d610645cfb20899aa75a9c2a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 27 Nov 2025 16:03:04 +0000
Subject: [PATCH 027/100] refactor: update useSourceChainId to include field
check for chain selection
---
.../tokensList/hooks/useSourceChainId.test.ts | 95 +++++++++++++++++++
.../tokensList/hooks/useSourceChainId.ts | 15 ++-
2 files changed, 107 insertions(+), 3 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts
new file mode 100644
index 00000000000..04a2a1521fc
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.test.ts
@@ -0,0 +1,95 @@
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet'
+
+import { renderHook } from '@testing-library/react'
+
+import { Field } from 'legacy/state/types'
+
+import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
+import { useSourceChainId } from './useSourceChainId'
+
+import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom'
+
+jest.mock('@cowprotocol/wallet', () => ({
+ ...jest.requireActual('@cowprotocol/wallet'),
+ useWalletInfo: jest.fn(),
+}))
+
+jest.mock('./useSelectTokenWidgetState', () => ({
+ useSelectTokenWidgetState: jest.fn(),
+}))
+
+const mockUseWalletInfo = useWalletInfo as jest.MockedFunction
+const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction
+
+type WidgetState = ReturnType
+const createWidgetState = (override: Partial): WidgetState => {
+ return {
+ ...DEFAULT_SELECT_TOKEN_WIDGET_STATE,
+ ...override,
+ } as WidgetState
+}
+
+describe('useSourceChainId', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo)
+ mockUseSelectTokenWidgetState.mockReturnValue(createWidgetState({ open: false }))
+ })
+
+ it('returns wallet chain when selector is closed', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ open: false,
+ field: Field.OUTPUT,
+ selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN,
+ }),
+ )
+
+ const { result } = renderHook(() => useSourceChainId())
+
+ expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' })
+ })
+
+ it('keeps wallet chain for output selection even while open', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ open: true,
+ field: Field.OUTPUT,
+ selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN,
+ }),
+ )
+
+ const { result } = renderHook(() => useSourceChainId())
+
+ expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' })
+ })
+
+ it('uses selector chain for input selection when open on a supported chain', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ open: true,
+ field: Field.INPUT,
+ selectedTargetChainId: SupportedChainId.GNOSIS_CHAIN,
+ }),
+ )
+
+ const { result } = renderHook(() => useSourceChainId())
+
+ expect(result.current).toEqual({ chainId: SupportedChainId.GNOSIS_CHAIN, source: 'selector' })
+ })
+
+ it('ignores unsupported chains and falls back to wallet', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ open: true,
+ field: Field.INPUT,
+ selectedTargetChainId: 999,
+ }),
+ )
+
+ const { result } = renderHook(() => useSourceChainId())
+
+ expect(result.current).toEqual({ chainId: SupportedChainId.MAINNET, source: 'wallet' })
+ })
+})
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts
index 6a3e4ad475d..61f1f41556e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSourceChainId.ts
@@ -3,15 +3,24 @@ import { useMemo } from 'react'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { useWalletInfo } from '@cowprotocol/wallet'
+import { Field } from 'legacy/state/types'
+
import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
export function useSourceChainId(): { chainId: number; source: 'wallet' | 'selector' } {
const { chainId } = useWalletInfo()
- const { selectedTargetChainId = chainId, open } = useSelectTokenWidgetState()
+ const { selectedTargetChainId = chainId, open, field } = useSelectTokenWidgetState()
return useMemo(() => {
// Source chainId should always be a value from SupportedChainId
- if (!open || !(selectedTargetChainId in SupportedChainId) || selectedTargetChainId === chainId) {
+ const isSelectingSellChain = field === Field.INPUT
+
+ if (
+ !open ||
+ !isSelectingSellChain ||
+ !(selectedTargetChainId in SupportedChainId) ||
+ selectedTargetChainId === chainId
+ ) {
return {
chainId,
source: 'wallet',
@@ -22,5 +31,5 @@ export function useSourceChainId(): { chainId: number; source: 'wallet' | 'selec
chainId: selectedTargetChainId,
source: 'selector',
}
- }, [open, chainId, selectedTargetChainId])
+ }, [open, field, chainId, selectedTargetChainId])
}
From 4f46d1f704049b93dc8daa56f08538c5afee00b3 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 28 Nov 2025 16:05:07 +0000
Subject: [PATCH 028/100] fix: improve error logging for network switch failure
in token selection
---
.../containers/SelectTokenWidget/controllerState.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
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 0a4c44ac65a..2207aee2946 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -3,6 +3,7 @@ import { Dispatch, SetStateAction, useCallback, useState } from 'react'
import { useCowAnalytics } from '@cowprotocol/analytics'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
+import { log } from '@cowprotocol/sdk-common'
import {
ListState,
TokenListCategory,
@@ -278,7 +279,8 @@ export function useTokenSelectionHandler(
try {
await onSelectNetwork(targetChainId as SupportedChainId, true)
} catch (error) {
- console.error('Failed to switch network after token selection', error)
+ const message = error instanceof Error ? error.message : String(error)
+ log(`Failed to switch network after token selection: ${message}`)
}
}
From 9f68f8a36c5e824a1f4709b136eb7c9c9ed11fe1 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 28 Nov 2025 16:12:08 +0000
Subject: [PATCH 029/100] refactor: streamline onSelectChain handling in
SelectTokenWidget
---
.../SelectTokenWidget/controllerProps.ts | 16 +++++++++-------
1 file changed, 9 insertions(+), 7 deletions(-)
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 2b3d77c7fab..f6c0e621790 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts
@@ -139,13 +139,15 @@ export function buildSelectTokenModalPropsInput({
tokenListCategoryState,
disableErc20,
account,
- hasChainPanel,
- chainsState,
- onSelectChain,
- isInjectedWidgetMode,
- chainsPanelTitle,
- modalTitle,
+ hasChainPanel,
+ chainsState,
+ onSelectChain,
+ isInjectedWidgetMode,
+ chainsPanelTitle,
+ modalTitle,
}: BuildModalPropsArgs): SelectTokenModalProps {
+ const selectChainHandler: (chain: ChainInfo) => void = onSelectChain ?? (() => undefined)
+
return {
standalone,
displayLpTokenLists,
@@ -175,7 +177,7 @@ export function buildSelectTokenModalPropsInput({
chainsPanelTitle,
hideFavoriteTokensTooltip: isInjectedWidgetMode,
selectedTargetChainId: widgetState.selectedTargetChainId,
- onSelectChain,
+ onSelectChain: selectChainHandler,
onClearRecentTokens,
}
}
From ec0d7c2fa84304998a0c324a6fca84b4af3cbc52 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 28 Nov 2025 17:11:39 +0000
Subject: [PATCH 030/100] refactor: make onSelectChain and
onOpenMobileChainPanel optional in SelectTokenModal
---
.../src/modules/tokensList/pure/SelectTokenModal/index.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
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 f9ee127034c..8e2bb51b7f1 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -231,8 +231,8 @@ function getMobileChainSelectorConfig({
showChainPanel: boolean
mobileChainsState: SelectTokenModalProps['mobileChainsState']
mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel']
- onSelectChain: SelectTokenModalProps['onSelectChain']
- onOpenMobileChainPanel: SelectTokenModalProps['onOpenMobileChainPanel']
+ onSelectChain?: SelectTokenModalProps['onSelectChain']
+ onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel']
}): ComponentProps | undefined {
const canRender =
!showChainPanel &&
From 1baae327bd84a4a8ab25a00831141066ad968692 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 28 Nov 2025 17:17:46 +0000
Subject: [PATCH 031/100] refactor: simplify JSX structure in SelectTokenModal
and TokenColumnContent
---
.../SelectTokenModal/TokenColumnContent.tsx | 15 ++++++---------
.../tokensList/pure/SelectTokenModal/index.tsx | 18 ++++++++----------
2 files changed, 14 insertions(+), 19 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
index 0d2c51b9de6..7b9e32cdcc8 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
@@ -4,7 +4,6 @@ import { ChainInfo } from '@cowprotocol/cow-sdk'
import { TokenListCategory } from '@cowprotocol/tokens'
import { SelectTokenModalContent } from './SelectTokenModalContent'
-import * as styledEl from './styled'
import { LpTokenListsWidget } from '../../containers/LpTokenListsWidget'
import { ChainsToSelectState, TokenSelectionHandler } from '../../types'
@@ -71,13 +70,11 @@ function renderLegacyChainSelector(
}
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 8e2bb51b7f1..c92149d7b1d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -99,16 +99,14 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode {
>
From b72b0e05205e3a5955a189c5f0c5283cc059fbbb Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 28 Nov 2025 17:20:59 +0000
Subject: [PATCH 032/100] feat: add onSelectChain prop to defaultModalProps in
SelectTokenModal
---
.../modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx | 1 +
1 file changed, 1 insertion(+)
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 08b158b7532..7a715d72407 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
@@ -73,6 +73,7 @@ const defaultModalProps: SelectTokenModalProps = {
selectedToken,
isRouteAvailable: true,
modalTitle: 'Swap from',
+ onSelectChain: () => undefined,
onSelectToken() {
console.log('onSelectToken')
},
From 2cf36f009559c12304041e135feafe5b076e35ce Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 28 Nov 2025 17:40:46 +0000
Subject: [PATCH 033/100] refactor: update chainsToSelect handling in
SelectTokenWidget and SelectTokenModal
---
.../tokensList/containers/SelectTokenWidget/index.tsx | 2 ++
.../src/modules/tokensList/pure/SelectTokenModal/index.tsx | 5 +++--
2 files changed, 5 insertions(+), 2 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 48330411b13..7a805cae316 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -119,6 +119,7 @@ function SelectTokenWidgetView(
const handleOpenMobileChainPanel = mobileChainsState ? () => setIsMobileChainPanelOpen(true) : undefined
const showDesktopChainPanel = isChainPanelVisible && isChainPanelEnabled && chainsToSelect
const showMobileChainPanel = !isChainPanelVisible && isChainPanelEnabled && chainsToSelect && isMobileChainPanelOpen
+ const modalChainsToSelect = isChainPanelVisible ? undefined : chainsToSelect
return (
<>
@@ -126,6 +127,7 @@ function SelectTokenWidgetView(
0 ? chainsToSelect : undefined
const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap`
From b47f361b2e527f17c41f9375e6f29961b632fea9 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 13:34:47 +0000
Subject: [PATCH 034/100] refactor: streamline chain accent color management
---
libs/ui/src/enum.ts | 35 ----
libs/ui/src/theme/ThemeColorVars.tsx | 251 ++++++++++++++++-----------
2 files changed, 146 insertions(+), 140 deletions(-)
diff --git a/libs/ui/src/enum.ts b/libs/ui/src/enum.ts
index 6e7c2039cec..7a135004366 100644
--- a/libs/ui/src/enum.ts
+++ b/libs/ui/src/enum.ts
@@ -101,41 +101,6 @@ export enum UI {
COLOR_GREEN = '--cow-color-green',
COLOR_RED = '--cow-color-red',
- // Chain-specific accent colors
- COLOR_CHAIN_ETHEREUM_BG = '--cow-color-chain-ethereum-bg',
- COLOR_CHAIN_ETHEREUM_BORDER = '--cow-color-chain-ethereum-border',
- COLOR_CHAIN_ETHEREUM_ACCENT = '--cow-color-chain-ethereum-accent',
- COLOR_CHAIN_BNB_BG = '--cow-color-chain-bnb-bg',
- COLOR_CHAIN_BNB_BORDER = '--cow-color-chain-bnb-border',
- COLOR_CHAIN_BNB_ACCENT = '--cow-color-chain-bnb-accent',
- COLOR_CHAIN_BASE_BG = '--cow-color-chain-base-bg',
- COLOR_CHAIN_BASE_BORDER = '--cow-color-chain-base-border',
- COLOR_CHAIN_BASE_ACCENT = '--cow-color-chain-base-accent',
- COLOR_CHAIN_ARBITRUM_BG = '--cow-color-chain-arbitrum-bg',
- COLOR_CHAIN_ARBITRUM_BORDER = '--cow-color-chain-arbitrum-border',
- COLOR_CHAIN_ARBITRUM_ACCENT = '--cow-color-chain-arbitrum-accent',
- COLOR_CHAIN_POLYGON_BG = '--cow-color-chain-polygon-bg',
- COLOR_CHAIN_POLYGON_BORDER = '--cow-color-chain-polygon-border',
- COLOR_CHAIN_POLYGON_ACCENT = '--cow-color-chain-polygon-accent',
- COLOR_CHAIN_AVALANCHE_BG = '--cow-color-chain-avalanche-bg',
- COLOR_CHAIN_AVALANCHE_BORDER = '--cow-color-chain-avalanche-border',
- COLOR_CHAIN_AVALANCHE_ACCENT = '--cow-color-chain-avalanche-accent',
- COLOR_CHAIN_GNOSIS_BG = '--cow-color-chain-gnosis-bg',
- COLOR_CHAIN_GNOSIS_BORDER = '--cow-color-chain-gnosis-border',
- COLOR_CHAIN_GNOSIS_ACCENT = '--cow-color-chain-gnosis-accent',
- COLOR_CHAIN_LENS_BG = '--cow-color-chain-lens-bg',
- COLOR_CHAIN_LENS_BORDER = '--cow-color-chain-lens-border',
- COLOR_CHAIN_LENS_ACCENT = '--cow-color-chain-lens-accent',
- COLOR_CHAIN_SEPOLIA_BG = '--cow-color-chain-sepolia-bg',
- COLOR_CHAIN_SEPOLIA_BORDER = '--cow-color-chain-sepolia-border',
- COLOR_CHAIN_SEPOLIA_ACCENT = '--cow-color-chain-sepolia-accent',
- COLOR_CHAIN_LINEA_BG = '--cow-color-chain-linea-bg',
- COLOR_CHAIN_LINEA_BORDER = '--cow-color-chain-linea-border',
- COLOR_CHAIN_LINEA_ACCENT = '--cow-color-chain-linea-accent',
- COLOR_CHAIN_PLASMA_BG = '--cow-color-chain-plasma-bg',
- COLOR_CHAIN_PLASMA_BORDER = '--cow-color-chain-plasma-border',
- COLOR_CHAIN_PLASMA_ACCENT = '--cow-color-chain-plasma-accent',
-
// Neutral colors - Base grayscale palette from black (0) to white (100)
COLOR_WHITE = '--cow-color-neutral-100',
COLOR_NEUTRAL_100 = '--cow-color-neutral-100',
diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx
index f39c641a938..7fdd986b244 100644
--- a/libs/ui/src/theme/ThemeColorVars.tsx
+++ b/libs/ui/src/theme/ThemeColorVars.tsx
@@ -1,3 +1,5 @@
+import { CHAIN_INFO } from '@cowprotocol/common-const'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { getContrastText } from '@cowprotocol/ui-utils'
import { darken, lighten, transparentize } from 'color2k'
@@ -5,10 +7,37 @@ import { css } from 'styled-components/macro'
import { UI } from '../enum'
-interface ChainAccentConfig {
- bgVar: UI
- borderVar: UI
- accentVar?: UI
+/**
+ * Gets the chain name for CSS variables from CHAIN_INFO.
+ * Only 2 chains have different names in CHAIN_INFO vs CSS variables, so we handle those explicitly.
+ * For all other chains, CHAIN_INFO.name matches the CSS variable name format.
+ */
+const getChainName = (chainId: SupportedChainId): string => {
+ const chainInfoName = CHAIN_INFO[chainId].name
+
+ // Only 2 exceptions: arbitrum_one → arbitrum, gnosis_chain → gnosis
+ // All other chains use CHAIN_INFO.name directly
+ if (chainInfoName === 'arbitrum_one') return 'arbitrum'
+ if (chainInfoName === 'gnosis_chain') return 'gnosis'
+
+ return chainInfoName
+}
+
+/**
+ * Generates CSS variable names for chain-specific colors.
+ * This allows adding new chains without modifying the UI enum.
+ */
+const getChainColorVars = (chainName: string): { bgVar: string; borderVar: string; accentVar: string } => ({
+ bgVar: `--cow-color-chain-${chainName}-bg`,
+ borderVar: `--cow-color-chain-${chainName}-border`,
+ accentVar: `--cow-color-chain-${chainName}-accent`,
+})
+
+export interface ChainAccentConfig {
+ chainId: SupportedChainId
+ bgVar: string
+ borderVar: string
+ accentVar?: string
lightBg: string
darkBg: string
lightBorder: string
@@ -18,10 +47,8 @@ interface ChainAccentConfig {
}
interface ChainAccentInput {
- bgVar: UI
- borderVar: UI
- accentVar?: UI
- color: string
+ chainId: SupportedChainId
+ color?: string // Optional: defaults to CHAIN_INFO[chainId].color
lightColor?: string
darkColor?: string
lightBgAlpha?: number
@@ -38,119 +65,133 @@ const CHAIN_DARK_BORDER_ALPHA = 0.65
const chainAlpha = (color: string, alpha: number): string => transparentize(color, 1 - alpha)
function createChainAccent({
- bgVar,
- borderVar,
- accentVar,
+ chainId,
color,
- lightColor = color,
- darkColor = color,
+ lightColor,
+ darkColor,
lightBgAlpha = CHAIN_LIGHT_BG_ALPHA,
darkBgAlpha = CHAIN_DARK_BG_ALPHA,
lightBorderAlpha = CHAIN_LIGHT_BORDER_ALPHA,
darkBorderAlpha = CHAIN_DARK_BORDER_ALPHA,
}: ChainAccentInput): ChainAccentConfig {
+ // Use CHAIN_INFO.color as the single source of truth, allow override if needed
+ const baseColor = color ?? CHAIN_INFO[chainId].color
+ const finalLightColor = lightColor ?? baseColor
+ const finalDarkColor = darkColor ?? baseColor
+
+ const chainName = getChainName(chainId)
+ const { bgVar, borderVar, accentVar } = getChainColorVars(chainName)
+
return {
+ chainId,
bgVar,
borderVar,
accentVar,
- lightBg: chainAlpha(lightColor, lightBgAlpha),
- darkBg: chainAlpha(darkColor, darkBgAlpha),
- lightBorder: chainAlpha(lightColor, lightBorderAlpha),
- darkBorder: chainAlpha(darkColor, darkBorderAlpha),
- lightColor,
- darkColor,
+ lightBg: chainAlpha(finalLightColor, lightBgAlpha),
+ darkBg: chainAlpha(finalDarkColor, darkBgAlpha),
+ lightBorder: chainAlpha(finalLightColor, lightBorderAlpha),
+ darkBorder: chainAlpha(finalDarkColor, darkBorderAlpha),
+ lightColor: finalLightColor,
+ darkColor: finalDarkColor,
}
}
-const CHAIN_ACCENT_CONFIG: ChainAccentConfig[] = [
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_ETHEREUM_BG,
- borderVar: UI.COLOR_CHAIN_ETHEREUM_BORDER,
- accentVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT,
+/**
+ * Chain accent color configuration.
+ *
+ * NEW NETWORKS WORK OUT OF THE BOX:
+ * - When a new network is added to CHAIN_INFO (from @cowprotocol/cow-sdk), it automatically gets accent colors
+ * - Colors are derived from CHAIN_INFO[chainId].color (single source of truth)
+ * - Chain names and CSS variables are generated automatically from CHAIN_INFO
+ *
+ * CUSTOMIZATION:
+ * - Exclude chains from accent colors by adding them to CHAIN_ACCENT_EXCLUSIONS
+ * - Override colors in CHAIN_ACCENT_OVERRIDES if CHAIN_INFO color differs from design (e.g., MAINNET, LENS)
+ */
+
+// Chains to exclude from accent color configuration (e.g., testnets that don't need accent colors)
+const CHAIN_ACCENT_EXCLUSIONS: Set = new Set([
+ // Add chain IDs here if they shouldn't have accent colors
+ // Example: SupportedChainId.SOME_TESTNET,
+])
+
+// Color overrides for chains where CHAIN_INFO color differs from original design
+const CHAIN_ACCENT_OVERRIDES: Partial>> = {
+ [SupportedChainId.MAINNET]: {
+ // Override: Original color #627EEA differs from SDK's #62688F
color: '#627EEA',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_BNB_BG,
- borderVar: UI.COLOR_CHAIN_BNB_BORDER,
- accentVar: UI.COLOR_CHAIN_BNB_ACCENT,
- color: '#F0B90B',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_BASE_BG,
- borderVar: UI.COLOR_CHAIN_BASE_BORDER,
- accentVar: UI.COLOR_CHAIN_BASE_ACCENT,
- color: '#0052FF',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_ARBITRUM_BG,
- borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER,
- accentVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT,
- color: '#1B4ADD',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_POLYGON_BG,
- borderVar: UI.COLOR_CHAIN_POLYGON_BORDER,
- accentVar: UI.COLOR_CHAIN_POLYGON_ACCENT,
- color: '#8247E5',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_AVALANCHE_BG,
- borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER,
- accentVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT,
- color: '#FF3944',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_GNOSIS_BG,
- borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER,
- accentVar: UI.COLOR_CHAIN_GNOSIS_ACCENT,
- color: '#07795B',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_LENS_BG,
- borderVar: UI.COLOR_CHAIN_LENS_BORDER,
- accentVar: UI.COLOR_CHAIN_LENS_ACCENT,
+ },
+ [SupportedChainId.LENS]: {
+ // Override: Original color #5A5A5A differs from SDK's #FFFFFF
color: '#5A5A5A',
darkColor: '#D7D7D7',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_SEPOLIA_BG,
- borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER,
- accentVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT,
- color: '#C12FF2',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_LINEA_BG,
- borderVar: UI.COLOR_CHAIN_LINEA_BORDER,
- accentVar: UI.COLOR_CHAIN_LINEA_ACCENT,
- color: '#61DFFF',
- }),
- createChainAccent({
- bgVar: UI.COLOR_CHAIN_PLASMA_BG,
- borderVar: UI.COLOR_CHAIN_PLASMA_BORDER,
- accentVar: UI.COLOR_CHAIN_PLASMA_ACCENT,
- color: '#569F8C',
- }),
-]
-
-const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG.map(({
- bgVar,
- borderVar,
- accentVar,
- lightBg,
- darkBg,
- lightBorder,
- darkBorder,
- lightColor,
- darkColor,
-}) => css`
- ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)};
- ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)};
- ${accentVar
- ? css`
- ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)};
- `
- : ''}
-`)
+ },
+}
+
+// Automatically generate accent colors for all chains in CHAIN_INFO, excluding those in CHAIN_ACCENT_EXCLUSIONS
+const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.keys(CHAIN_INFO)
+ .map((key) => Number(key) as SupportedChainId)
+ .filter((chainId) => {
+ // Type guard: ensure chainId exists in CHAIN_INFO and is not excluded
+ return CHAIN_INFO[chainId] && !CHAIN_ACCENT_EXCLUSIONS.has(chainId)
+ })
+ .map((chainId) =>
+ createChainAccent({
+ chainId,
+ ...CHAIN_ACCENT_OVERRIDES[chainId],
+ }),
+ )
+
+/**
+ * Map of chain accent colors keyed by SupportedChainId for programmatic access.
+ * This allows components to access theme-aware chain colors without using CSS variables.
+ *
+ * @example
+ * ```tsx
+ * import { CHAIN_ACCENT_CONFIG } from '@cowprotocol/ui'
+ *
+ * const colors = CHAIN_ACCENT_CONFIG[SupportedChainId.MAINNET]
+ * // colors.bgVar, colors.borderVar, colors.lightBg, colors.darkBg, etc.
+ * ```
+ */
+export const CHAIN_ACCENT_CONFIG: Record =
+ CHAIN_ACCENT_CONFIG_ARRAY.reduce(
+ (acc, config) => {
+ acc[config.chainId] = config
+ return acc
+ },
+ {} as Record,
+ )
+
+/**
+ * Helper function to get chain accent colors for a given chainId.
+ * Returns undefined if the chain doesn't have accent colors configured.
+ *
+ * @example
+ * ```tsx
+ * import { getChainAccentColors } from '@cowprotocol/ui'
+ *
+ * const colors = getChainAccentColors(SupportedChainId.MAINNET)
+ * if (colors) {
+ * // Use colors.lightBg, colors.darkBg, etc.
+ * }
+ * ```
+ */
+export function getChainAccentColors(chainId: SupportedChainId): ChainAccentConfig | undefined {
+ return CHAIN_ACCENT_CONFIG[chainId]
+}
+
+const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG_ARRAY.map(
+ ({ bgVar, borderVar, accentVar, lightBg, darkBg, lightBorder, darkBorder, lightColor, darkColor }) => css`
+ ${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)};
+ ${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)};
+ ${accentVar
+ ? css`
+ ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)};
+ `
+ : ''}
+ `,
+)
export const ThemeColorVars = css`
:root {
From 956724a11451e5ee36c16000aef132b689db0334 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 13:42:50 +0000
Subject: [PATCH 035/100] test: add unit tests for chain accent color
management
---
libs/ui/src/theme/ThemeColorVars.test.ts | 188 +++++++++++++++++++++++
1 file changed, 188 insertions(+)
create mode 100644 libs/ui/src/theme/ThemeColorVars.test.ts
diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts
new file mode 100644
index 00000000000..63496201ce3
--- /dev/null
+++ b/libs/ui/src/theme/ThemeColorVars.test.ts
@@ -0,0 +1,188 @@
+import { CHAIN_INFO } from '@cowprotocol/common-const'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { CHAIN_ACCENT_CONFIG, getChainAccentColors } from './ThemeColorVars'
+
+describe('Chain Accent Colors', () => {
+ describe('getChainAccentColors', () => {
+ it('should return accent colors for MAINNET', () => {
+ const colors = getChainAccentColors(SupportedChainId.MAINNET)
+
+ expect(colors).toBeDefined()
+ expect(colors?.chainId).toBe(SupportedChainId.MAINNET)
+ expect(colors?.bgVar).toBe('--cow-color-chain-ethereum-bg')
+ expect(colors?.borderVar).toBe('--cow-color-chain-ethereum-border')
+ expect(colors?.accentVar).toBe('--cow-color-chain-ethereum-accent')
+ expect(colors?.lightColor).toBe('#627EEA') // Override applied
+ expect(colors?.darkColor).toBe('#627EEA')
+ })
+
+ it('should return accent colors for LENS with darkColor override', () => {
+ const colors = getChainAccentColors(SupportedChainId.LENS)
+
+ expect(colors).toBeDefined()
+ expect(colors?.chainId).toBe(SupportedChainId.LENS)
+ expect(colors?.lightColor).toBe('#5A5A5A') // Override applied
+ expect(colors?.darkColor).toBe('#D7D7D7') // Dark color override applied
+ })
+
+ it('should return accent colors for BNB using CHAIN_INFO color', () => {
+ const colors = getChainAccentColors(SupportedChainId.BNB)
+
+ expect(colors).toBeDefined()
+ expect(colors?.chainId).toBe(SupportedChainId.BNB)
+ expect(colors?.lightColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color)
+ expect(colors?.darkColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color)
+ })
+
+ it('should return undefined for non-existent chain', () => {
+ // Use a very large number that's unlikely to be a real chain ID
+ const colors = getChainAccentColors(999999 as SupportedChainId)
+
+ expect(colors).toBeUndefined()
+ })
+ })
+
+ describe('CHAIN_ACCENT_CONFIG', () => {
+ it('should include all chains from CHAIN_INFO', () => {
+ const chainIdsInChainInfo = Object.keys(CHAIN_INFO).map((key) => Number(key) as SupportedChainId)
+
+ // All chains from CHAIN_INFO should be in the config (unless excluded)
+ chainIdsInChainInfo.forEach((chainId) => {
+ const config = CHAIN_ACCENT_CONFIG[chainId]
+ // Config should exist unless it's explicitly excluded
+ // Since CHAIN_ACCENT_EXCLUSIONS is empty, all should exist
+ expect(config).toBeDefined()
+ })
+ })
+
+ it('should have correct CSS variable format for all chains', () => {
+ Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => {
+ if (!config) return
+
+ expect(config.bgVar).toMatch(/^--cow-color-chain-[a-z_]+-bg$/)
+ expect(config.borderVar).toMatch(/^--cow-color-chain-[a-z_]+-border$/)
+ expect(config.accentVar).toMatch(/^--cow-color-chain-[a-z_]+-accent$/)
+ })
+ })
+
+ it('should normalize arbitrum_one to arbitrum in CSS variables', () => {
+ const colors = getChainAccentColors(SupportedChainId.ARBITRUM_ONE)
+
+ expect(colors).toBeDefined()
+ expect(colors?.bgVar).toBe('--cow-color-chain-arbitrum-bg')
+ expect(colors?.borderVar).toBe('--cow-color-chain-arbitrum-border')
+ expect(colors?.accentVar).toBe('--cow-color-chain-arbitrum-accent')
+ // Verify it's NOT using the CHAIN_INFO name directly
+ expect(colors?.bgVar).not.toContain('arbitrum_one')
+ })
+
+ it('should normalize gnosis_chain to gnosis in CSS variables', () => {
+ const colors = getChainAccentColors(SupportedChainId.GNOSIS_CHAIN)
+
+ expect(colors).toBeDefined()
+ expect(colors?.bgVar).toBe('--cow-color-chain-gnosis-bg')
+ expect(colors?.borderVar).toBe('--cow-color-chain-gnosis-border')
+ expect(colors?.accentVar).toBe('--cow-color-chain-gnosis-accent')
+ // Verify it's NOT using the CHAIN_INFO name directly
+ expect(colors?.bgVar).not.toContain('gnosis_chain')
+ })
+
+ it('should use CHAIN_INFO.name directly for other chains', () => {
+ const colors = getChainAccentColors(SupportedChainId.POLYGON)
+
+ expect(colors).toBeDefined()
+ expect(colors?.bgVar).toBe('--cow-color-chain-polygon-bg')
+ // Polygon name in CHAIN_INFO is 'polygon', so it should match exactly
+ expect(CHAIN_INFO[SupportedChainId.POLYGON].name).toBe('polygon')
+ })
+ })
+
+ describe('Color overrides', () => {
+ it('should apply MAINNET color override', () => {
+ const colors = getChainAccentColors(SupportedChainId.MAINNET)
+ const sdkColor = CHAIN_INFO[SupportedChainId.MAINNET].color
+
+ expect(colors?.lightColor).toBe('#627EEA')
+ expect(colors?.lightColor).not.toBe(sdkColor) // Should differ from SDK
+ expect(sdkColor).toBe('#62688F') // Verify SDK color is different
+ })
+
+ it('should apply LENS color and darkColor overrides', () => {
+ const colors = getChainAccentColors(SupportedChainId.LENS)
+ const sdkColor = CHAIN_INFO[SupportedChainId.LENS].color
+
+ expect(colors?.lightColor).toBe('#5A5A5A')
+ expect(colors?.darkColor).toBe('#D7D7D7')
+ expect(colors?.lightColor).not.toBe(sdkColor) // Should differ from SDK
+ expect(sdkColor).toBe('#FFFFFF') // Verify SDK color is different
+ })
+
+ it('should use CHAIN_INFO color for chains without overrides', () => {
+ const colors = getChainAccentColors(SupportedChainId.BASE)
+ const sdkColor = CHAIN_INFO[SupportedChainId.BASE].color
+
+ expect(colors?.lightColor).toBe(sdkColor)
+ expect(colors?.darkColor).toBe(sdkColor)
+ })
+ })
+
+ describe('ChainAccentConfig structure', () => {
+ it('should have all required properties', () => {
+ const colors = getChainAccentColors(SupportedChainId.MAINNET)
+
+ expect(colors).toHaveProperty('chainId')
+ expect(colors).toHaveProperty('bgVar')
+ expect(colors).toHaveProperty('borderVar')
+ expect(colors).toHaveProperty('accentVar')
+ expect(colors).toHaveProperty('lightBg')
+ expect(colors).toHaveProperty('darkBg')
+ expect(colors).toHaveProperty('lightBorder')
+ expect(colors).toHaveProperty('darkBorder')
+ expect(colors).toHaveProperty('lightColor')
+ expect(colors).toHaveProperty('darkColor')
+ })
+
+ it('should have calculated alpha colors', () => {
+ const colors = getChainAccentColors(SupportedChainId.MAINNET)
+
+ // lightBg should be transparentized version of lightColor
+ expect(colors?.lightBg).toBeDefined()
+ expect(colors?.lightBg).not.toBe(colors?.lightColor)
+ expect(colors?.lightBg).toContain('rgba') // transparentize returns rgba
+
+ // darkBg should be transparentized version of darkColor
+ expect(colors?.darkBg).toBeDefined()
+ expect(colors?.darkBg).not.toBe(colors?.darkColor)
+ expect(colors?.darkBg).toContain('rgba')
+
+ // Borders should also be transparentized
+ expect(colors?.lightBorder).toContain('rgba')
+ expect(colors?.darkBorder).toContain('rgba')
+ })
+ })
+
+ describe('Automatic chain inclusion', () => {
+ it('should automatically include new chains from CHAIN_INFO', () => {
+ // This test verifies that the system automatically picks up all chains
+ // If a new chain is added to CHAIN_INFO, it should appear in CHAIN_ACCENT_CONFIG
+ const allChainIds = Object.keys(CHAIN_INFO).map((key) => Number(key) as SupportedChainId)
+
+ allChainIds.forEach((chainId) => {
+ const config = CHAIN_ACCENT_CONFIG[chainId]
+ // Currently no exclusions, so all should be present
+ expect(config).toBeDefined()
+ expect(config?.chainId).toBe(chainId)
+ })
+ })
+
+ it('should have consistent chainId in config', () => {
+ Object.entries(CHAIN_ACCENT_CONFIG).forEach(([key, config]) => {
+ if (!config) return
+
+ const chainId = Number(key) as SupportedChainId
+ expect(config.chainId).toBe(chainId)
+ })
+ })
+ })
+})
From eb337a1388fff8f9577ba40e2519943d7e3e3627 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 13:47:48 +0000
Subject: [PATCH 036/100] refactor: optimize VirtualList rendering by replacing
loop with map function
---
.../src/common/pure/VirtualList/index.tsx | 49 +++++++++----------
1 file changed, 23 insertions(+), 26 deletions(-)
diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
index 2134dc41297..2e6e2d614ef 100644
--- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
@@ -49,29 +49,27 @@ interface VirtualListRowsProps {
measureElement(element: Element | null): void
}
-function renderVirtualListRows({
+function VirtualListRows({
virtualItems,
loading,
items,
getItemView,
measureElement,
-}: VirtualListRowsProps): ReactNode[] {
- const elements: ReactNode[] = []
-
- for (const item of virtualItems) {
- elements.push(
- ,
- )
- }
-
- return elements
+}: VirtualListRowsProps): ReactNode {
+ return (
+ <>
+ {virtualItems.map((item) => (
+
+ ))}
+ >
+ )
}
interface VirtualListProps {
@@ -138,20 +136,19 @@ export function VirtualList({
}, [scrollResetKey, virtualizer])
const virtualItems = virtualizer.getVirtualItems()
- const virtualRows = renderVirtualListRows({
- virtualItems,
- loading,
- items,
- getItemView,
- measureElement: virtualizer.measureElement,
- })
return (
{children}
- {virtualRows}
+
From ead0b9eff679b786d522753c234a57af05cc4f38 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 14:42:12 +0000
Subject: [PATCH 037/100] refactor: update scrollResetKey type in VirtualList
component to number
---
apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
index 2e6e2d614ef..41640698b7f 100644
--- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
@@ -81,7 +81,7 @@ interface VirtualListProps {
loading?: boolean
estimateSize?: () => number
children?: ReactNode
- scrollResetKey?: string | number | boolean
+ scrollResetKey?: number
}
export function VirtualList({
From bba11f98f4da36ef8f90082740d9809bc92f82cc Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 14:51:53 +0000
Subject: [PATCH 038/100] refactor: implement fallback color handling for chain
accent colors
---
libs/ui/src/theme/ThemeColorVars.test.ts | 40 +++++++++++++---------
libs/ui/src/theme/ThemeColorVars.tsx | 43 +++++++++++-------------
2 files changed, 43 insertions(+), 40 deletions(-)
diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts
index 63496201ce3..ed66df89697 100644
--- a/libs/ui/src/theme/ThemeColorVars.test.ts
+++ b/libs/ui/src/theme/ThemeColorVars.test.ts
@@ -3,6 +3,8 @@ import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { CHAIN_ACCENT_CONFIG, getChainAccentColors } from './ThemeColorVars'
+import { Color } from '../colors'
+
describe('Chain Accent Colors', () => {
describe('getChainAccentColors', () => {
it('should return accent colors for MAINNET', () => {
@@ -34,32 +36,21 @@ describe('Chain Accent Colors', () => {
expect(colors?.lightColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color)
expect(colors?.darkColor).toBe(CHAIN_INFO[SupportedChainId.BNB].color)
})
-
- it('should return undefined for non-existent chain', () => {
- // Use a very large number that's unlikely to be a real chain ID
- const colors = getChainAccentColors(999999 as SupportedChainId)
-
- expect(colors).toBeUndefined()
- })
})
describe('CHAIN_ACCENT_CONFIG', () => {
it('should include all chains from CHAIN_INFO', () => {
const chainIdsInChainInfo = Object.keys(CHAIN_INFO).map((key) => Number(key) as SupportedChainId)
- // All chains from CHAIN_INFO should be in the config (unless excluded)
+ // All chains from CHAIN_INFO should be in the config
chainIdsInChainInfo.forEach((chainId) => {
const config = CHAIN_ACCENT_CONFIG[chainId]
- // Config should exist unless it's explicitly excluded
- // Since CHAIN_ACCENT_EXCLUSIONS is empty, all should exist
expect(config).toBeDefined()
})
})
it('should have correct CSS variable format for all chains', () => {
Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => {
- if (!config) return
-
expect(config.bgVar).toMatch(/^--cow-color-chain-[a-z_]+-bg$/)
expect(config.borderVar).toMatch(/^--cow-color-chain-[a-z_]+-border$/)
expect(config.accentVar).toMatch(/^--cow-color-chain-[a-z_]+-accent$/)
@@ -170,19 +161,36 @@ describe('Chain Accent Colors', () => {
allChainIds.forEach((chainId) => {
const config = CHAIN_ACCENT_CONFIG[chainId]
- // Currently no exclusions, so all should be present
expect(config).toBeDefined()
- expect(config?.chainId).toBe(chainId)
+ expect(config.chainId).toBe(chainId)
})
})
it('should have consistent chainId in config', () => {
Object.entries(CHAIN_ACCENT_CONFIG).forEach(([key, config]) => {
- if (!config) return
-
const chainId = Number(key) as SupportedChainId
expect(config.chainId).toBe(chainId)
})
})
})
+
+ describe('Fallback color handling', () => {
+ it('should use Color.neutral50 as fallback when chain color is missing', () => {
+ // Verify the fallback color constant exists and has the correct value
+ // This is defensive - in practice all chains in CHAIN_INFO should have colors
+ const fallbackColor = Color.neutral50
+ expect(fallbackColor).toBe('#827474')
+
+ // Verify all chains have valid colors (ensuring fallback logic doesn't break anything)
+ Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => {
+ expect(config.lightColor).toBeDefined()
+ expect(config.darkColor).toBeDefined()
+ expect(typeof config.lightColor).toBe('string')
+ expect(typeof config.darkColor).toBe('string')
+ // Colors should be valid hex or rgba format
+ expect(config.lightColor).toMatch(/^#[\da-fA-F]{6}$|^rgba?\(/)
+ expect(config.darkColor).toMatch(/^#[\da-fA-F]{6}$|^rgba?\(/)
+ })
+ })
+ })
})
diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx
index 7fdd986b244..4c3f04d379f 100644
--- a/libs/ui/src/theme/ThemeColorVars.tsx
+++ b/libs/ui/src/theme/ThemeColorVars.tsx
@@ -5,6 +5,7 @@ import { getContrastText } from '@cowprotocol/ui-utils'
import { darken, lighten, transparentize } from 'color2k'
import { css } from 'styled-components/macro'
+import { Color } from '../colors'
import { UI } from '../enum'
/**
@@ -62,6 +63,9 @@ const CHAIN_DARK_BG_ALPHA = 0.32
const CHAIN_LIGHT_BORDER_ALPHA = 0.45
const CHAIN_DARK_BORDER_ALPHA = 0.65
+// Fallback color if chain color is missing (uses neutral50 as approximation of UI.COLOR_TEXT)
+const FALLBACK_CHAIN_COLOR = Color.neutral50
+
const chainAlpha = (color: string, alpha: number): string => transparentize(color, 1 - alpha)
function createChainAccent({
@@ -75,7 +79,8 @@ function createChainAccent({
darkBorderAlpha = CHAIN_DARK_BORDER_ALPHA,
}: ChainAccentInput): ChainAccentConfig {
// Use CHAIN_INFO.color as the single source of truth, allow override if needed
- const baseColor = color ?? CHAIN_INFO[chainId].color
+ // Fallback to neutral gray if color is missing
+ const baseColor = color ?? CHAIN_INFO[chainId]?.color ?? FALLBACK_CHAIN_COLOR
const finalLightColor = lightColor ?? baseColor
const finalDarkColor = darkColor ?? baseColor
@@ -105,16 +110,9 @@ function createChainAccent({
* - Chain names and CSS variables are generated automatically from CHAIN_INFO
*
* CUSTOMIZATION:
- * - Exclude chains from accent colors by adding them to CHAIN_ACCENT_EXCLUSIONS
* - Override colors in CHAIN_ACCENT_OVERRIDES if CHAIN_INFO color differs from design (e.g., MAINNET, LENS)
*/
-// Chains to exclude from accent color configuration (e.g., testnets that don't need accent colors)
-const CHAIN_ACCENT_EXCLUSIONS: Set = new Set([
- // Add chain IDs here if they shouldn't have accent colors
- // Example: SupportedChainId.SOME_TESTNET,
-])
-
// Color overrides for chains where CHAIN_INFO color differs from original design
const CHAIN_ACCENT_OVERRIDES: Partial>> = {
[SupportedChainId.MAINNET]: {
@@ -128,12 +126,12 @@ const CHAIN_ACCENT_OVERRIDES: Partial Number(key) as SupportedChainId)
.filter((chainId) => {
- // Type guard: ensure chainId exists in CHAIN_INFO and is not excluded
- return CHAIN_INFO[chainId] && !CHAIN_ACCENT_EXCLUSIONS.has(chainId)
+ // Type guard: ensure chainId exists in CHAIN_INFO
+ return CHAIN_INFO[chainId]
})
.map((chainId) =>
createChainAccent({
@@ -154,30 +152,27 @@ const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.keys(CHAIN_INFO)
* // colors.bgVar, colors.borderVar, colors.lightBg, colors.darkBg, etc.
* ```
*/
-export const CHAIN_ACCENT_CONFIG: Record =
- CHAIN_ACCENT_CONFIG_ARRAY.reduce(
- (acc, config) => {
- acc[config.chainId] = config
- return acc
- },
- {} as Record,
- )
+export const CHAIN_ACCENT_CONFIG: Record = CHAIN_ACCENT_CONFIG_ARRAY.reduce(
+ (acc, config) => {
+ acc[config.chainId] = config
+ return acc
+ },
+ {} as Record,
+)
/**
* Helper function to get chain accent colors for a given chainId.
- * Returns undefined if the chain doesn't have accent colors configured.
+ * All chains have accent colors configured.
*
* @example
* ```tsx
* import { getChainAccentColors } from '@cowprotocol/ui'
*
* const colors = getChainAccentColors(SupportedChainId.MAINNET)
- * if (colors) {
- * // Use colors.lightBg, colors.darkBg, etc.
- * }
+ * // Use colors.lightBg, colors.darkBg, etc.
* ```
*/
-export function getChainAccentColors(chainId: SupportedChainId): ChainAccentConfig | undefined {
+export function getChainAccentColors(chainId: SupportedChainId): ChainAccentConfig {
return CHAIN_ACCENT_CONFIG[chainId]
}
From 9ed94c9921a38f069f926bfc06beb975f8534593 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:03:04 +0000
Subject: [PATCH 039/100] refactor: enhance chain accent color configuration
with a helper function
---
libs/ui/src/theme/ThemeColorVars.tsx | 49 +++++++++++++++++-----------
1 file changed, 30 insertions(+), 19 deletions(-)
diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx
index 4c3f04d379f..6142a429706 100644
--- a/libs/ui/src/theme/ThemeColorVars.tsx
+++ b/libs/ui/src/theme/ThemeColorVars.tsx
@@ -1,4 +1,4 @@
-import { CHAIN_INFO } from '@cowprotocol/common-const'
+import { BaseChainInfo, CHAIN_INFO } from '@cowprotocol/common-const'
import { SupportedChainId } from '@cowprotocol/cow-sdk'
import { getContrastText } from '@cowprotocol/ui-utils'
@@ -126,24 +126,38 @@ const CHAIN_ACCENT_OVERRIDES: Partial Number(key) as SupportedChainId)
- .filter((chainId) => {
- // Type guard: ensure chainId exists in CHAIN_INFO
- return CHAIN_INFO[chainId]
- })
- .map((chainId) =>
- createChainAccent({
+/**
+ * Helper function to create a Record with all SupportedChainId keys.
+ * Since CHAIN_INFO is Record, TypeScript ensures
+ * all keys are present when iterating over CHAIN_INFO entries.
+ *
+ * Note: TypeScript cannot statically verify completeness of dynamically constructed Records,
+ * but since we iterate over all CHAIN_INFO entries (which is a complete Record), all keys
+ * are guaranteed to be present at runtime.
+ */
+function createChainAccentConfig(): Record {
+ const config = {} as Record
+
+ // Iterate over all CHAIN_INFO entries - since CHAIN_INFO is Record,
+ // all SupportedChainId keys are guaranteed to be present
+ for (const [key, _chainInfo] of Object.entries(CHAIN_INFO) as [string, BaseChainInfo][]) {
+ const chainId = Number(key) as SupportedChainId
+ config[chainId] = createChainAccent({
chainId,
...CHAIN_ACCENT_OVERRIDES[chainId],
- }),
- )
+ })
+ }
+
+ return config satisfies Record
+}
/**
* Map of chain accent colors keyed by SupportedChainId for programmatic access.
* This allows components to access theme-aware chain colors without using CSS variables.
*
+ * TypeScript verifies completeness: since CHAIN_INFO is Record,
+ * iterating over all entries ensures all SupportedChainId keys are present.
+ *
* @example
* ```tsx
* import { CHAIN_ACCENT_CONFIG } from '@cowprotocol/ui'
@@ -152,13 +166,10 @@ const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.keys(CHAIN_INFO)
* // colors.bgVar, colors.borderVar, colors.lightBg, colors.darkBg, etc.
* ```
*/
-export const CHAIN_ACCENT_CONFIG: Record = CHAIN_ACCENT_CONFIG_ARRAY.reduce(
- (acc, config) => {
- acc[config.chainId] = config
- return acc
- },
- {} as Record,
-)
+export const CHAIN_ACCENT_CONFIG: Record = createChainAccentConfig()
+
+// Array version for CSS variable generation
+const CHAIN_ACCENT_CONFIG_ARRAY: ChainAccentConfig[] = Object.values(CHAIN_ACCENT_CONFIG)
/**
* Helper function to get chain accent colors for a given chainId.
From 1815ecfd7ac0a46a55ed4b7814b4e67f88016f38 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:10:37 +0000
Subject: [PATCH 040/100] fix: undo po file
---
apps/cowswap-frontend/src/locales/en-US.po | 34 +++++++++-------------
1 file changed, 13 insertions(+), 21 deletions(-)
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index 88e9cbb5847..25486dac0e4 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -464,6 +464,7 @@ msgid "View details"
msgstr "View details"
#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "More"
msgstr "More"
@@ -835,8 +836,8 @@ msgid "Copied"
msgstr "Copied"
#: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
-#~ msgid "Can't find your token on the list?"
-#~ msgstr "Can't find your token on the list?"
+msgid "Can't find your token on the list?"
+msgstr "Can't find your token on the list?"
#: apps/cowswap-frontend/src/modules/trade/pure/ReceiveAmountTitle/index.tsx
msgid "icon"
@@ -855,8 +856,8 @@ msgid "Please connect your wallet to one of our supported networks."
msgstr "Please connect your wallet to one of our supported networks."
#: apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx
-#~ msgid "<0>Read our guide0> on how to add custom tokens."
-#~ msgstr "<0>Read our guide0> on how to add custom tokens."
+msgid "<0>Read our guide0> on how to add custom tokens."
+msgstr "<0>Read our guide0> on how to add custom tokens."
#: apps/cowswap-frontend/src/modules/hooksStore/containers/TenderlySimulate/index.tsx
msgid "Retry"
@@ -1232,8 +1233,8 @@ msgid "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
msgstr "Partner fee can not be more than {PARTNER_FEE_MAX_BPS} BPS!"
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
-#~ msgid "Manage Token Lists"
-#~ msgstr "Manage Token Lists"
+msgid "Manage Token Lists"
+msgstr "Manage Token Lists"
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
msgid "No results found"
@@ -3213,6 +3214,7 @@ msgstr "CoW Swap's robust solver competition protects your slippage from being e
msgid "Aave Debt Swap Flashloan"
msgstr "Aave Debt Swap Flashloan"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
#: apps/cowswap-frontend/src/modules/yield/pure/TargetPoolPreviewInfo.tsx
msgid "Details"
msgstr "Details"
@@ -3888,10 +3890,6 @@ msgstr "User rejected approval transaction"
msgid "Swap on"
msgstr "Swap on"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
-msgid "Can't find your token on the list? <0>Read our guide0> on how to add custom tokens."
-msgstr "Can't find your token on the list? <0>Read our guide0> on how to add custom tokens."
-
#: apps/cowswap-frontend/src/modules/trade/pure/ProtocolFeeRow/index.tsx
#~ msgid "The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed.<0/><1/>Solver rewards are taken from this fee amount."
#~ msgstr "The fee is {protocolFeeBps} BPS ({protocolFeeAsPercent}%), applied only if the trade is executed.<0/><1/>Solver rewards are taken from this fee amount."
@@ -4406,6 +4404,7 @@ msgstr "Decrease Value"
#: apps/cowswap-frontend/src/legacy/components/Tokens/TokensTable.tsx
#: apps/cowswap-frontend/src/modules/ethFlow/pure/WrappingPreview/WrapCard.tsx
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
msgid "Balance"
msgstr "Balance"
@@ -4468,8 +4467,8 @@ msgid "funds"
msgstr "funds"
#: apps/cowswap-frontend/src/modules/tokensList/pure/LpTokenLists/index.tsx
-#~ msgid "Pool details"
-#~ msgstr "Pool details"
+msgid "Pool details"
+msgstr "Pool details"
#: apps/cowswap-frontend/src/modules/tradeSlippage/containers/HighSuggestedSlippageWarning/index.tsx
msgid "Slippage adjusted to {slippageBpsPercentage}% to ensure quick execution"
@@ -5989,8 +5988,8 @@ msgid "You sold <0/>"
msgstr "You sold <0/>"
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
-#~ msgid "Less"
-#~ msgstr "Less"
+msgid "Less"
+msgstr "Less"
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
msgid "Claim your LlamaPay vesting contract funds"
@@ -6286,10 +6285,3 @@ msgstr "Learn more"
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/HighFeeWarning/highFeeWarningHelpers.ts
msgid "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount"
msgstr "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount"
-
-# Notifications / jobs aria labels
-msgid "Trade alert settings"
-msgstr "Trade alert settings"
-
-msgid "View jobs (opens in a new tab)"
-msgstr "View jobs (opens in a new tab)"
From 956c4408574e995a3981e79b32e631e55dacd54a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:12:25 +0000
Subject: [PATCH 041/100] fix: revert changes po file
---
apps/cowswap-frontend/src/locales/en-US.po | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index 25486dac0e4..8c753759980 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -6285,3 +6285,10 @@ msgstr "Learn more"
#: apps/cowswap-frontend/src/modules/tradeWidgetAddons/containers/HighFeeWarning/highFeeWarningHelpers.ts
msgid "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount"
msgstr "Swap and bridge costs are at least {formattedFeePercentage}% of the swap amount"
+
+# Notifications / jobs aria labels
+msgid "Trade alert settings"
+msgstr "Trade alert settings"
+
+msgid "View jobs (opens in a new tab)"
+msgstr "View jobs (opens in a new tab)"
From 4e2b3360fba1f2bc1805b21008456565f8d3275a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:17:36 +0000
Subject: [PATCH 042/100] refactor: enforce accentVar as a required property in
ChainAccentConfig and update related tests
---
libs/ui/src/theme/ThemeColorVars.test.ts | 3 +++
libs/ui/src/theme/ThemeColorVars.tsx | 8 ++------
2 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts
index ed66df89697..e9548e69655 100644
--- a/libs/ui/src/theme/ThemeColorVars.test.ts
+++ b/libs/ui/src/theme/ThemeColorVars.test.ts
@@ -53,6 +53,9 @@ describe('Chain Accent Colors', () => {
Object.values(CHAIN_ACCENT_CONFIG).forEach((config) => {
expect(config.bgVar).toMatch(/^--cow-color-chain-[a-z_]+-bg$/)
expect(config.borderVar).toMatch(/^--cow-color-chain-[a-z_]+-border$/)
+ expect(config.accentVar).toBeDefined()
+ expect(config.accentVar).not.toBeUndefined()
+ expect(typeof config.accentVar).toBe('string')
expect(config.accentVar).toMatch(/^--cow-color-chain-[a-z_]+-accent$/)
})
})
diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx
index 6142a429706..393c7ed13fb 100644
--- a/libs/ui/src/theme/ThemeColorVars.tsx
+++ b/libs/ui/src/theme/ThemeColorVars.tsx
@@ -38,7 +38,7 @@ export interface ChainAccentConfig {
chainId: SupportedChainId
bgVar: string
borderVar: string
- accentVar?: string
+ accentVar: string
lightBg: string
darkBg: string
lightBorder: string
@@ -191,11 +191,7 @@ const CHAIN_ACCENT_VAR_DECLARATIONS = CHAIN_ACCENT_CONFIG_ARRAY.map(
({ bgVar, borderVar, accentVar, lightBg, darkBg, lightBorder, darkBorder, lightColor, darkColor }) => css`
${bgVar}: ${({ theme }) => (theme.darkMode ? darkBg : lightBg)};
${borderVar}: ${({ theme }) => (theme.darkMode ? darkBorder : lightBorder)};
- ${accentVar
- ? css`
- ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)};
- `
- : ''}
+ ${accentVar}: ${({ theme }) => (theme.darkMode ? darkColor : lightColor)};
`,
)
From 2825b637e1e5ff9a8561eb06f117d2e98bea73b0 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:20:59 +0000
Subject: [PATCH 043/100] test: add validation for chainId consistency in
ChainAccentColors
---
libs/ui/src/theme/ThemeColorVars.test.ts | 18 ++++++++++++++++++
libs/ui/src/theme/ThemeColorVars.tsx | 5 ++++-
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/libs/ui/src/theme/ThemeColorVars.test.ts b/libs/ui/src/theme/ThemeColorVars.test.ts
index e9548e69655..67c876a8b3e 100644
--- a/libs/ui/src/theme/ThemeColorVars.test.ts
+++ b/libs/ui/src/theme/ThemeColorVars.test.ts
@@ -119,6 +119,24 @@ describe('Chain Accent Colors', () => {
expect(colors?.lightColor).toBe(sdkColor)
expect(colors?.darkColor).toBe(sdkColor)
})
+
+ it('should always use Record key as chainId source of truth', () => {
+ // This test verifies that chainId always comes from the Record key,
+ // not from override objects. The type system (ChainAccentOverride) prevents
+ // chainId from being set in overrides, but this test documents the runtime behavior.
+ const mainnetColors = getChainAccentColors(SupportedChainId.MAINNET)
+ const lensColors = getChainAccentColors(SupportedChainId.LENS)
+
+ // Even though MAINNET and LENS have overrides, chainId should match the Record key
+ expect(mainnetColors.chainId).toBe(SupportedChainId.MAINNET)
+ expect(lensColors.chainId).toBe(SupportedChainId.LENS)
+
+ // Verify this holds for all chains with overrides
+ Object.entries(CHAIN_ACCENT_CONFIG).forEach(([key, config]) => {
+ const chainId = Number(key) as SupportedChainId
+ expect(config.chainId).toBe(chainId)
+ })
+ })
})
describe('ChainAccentConfig structure', () => {
diff --git a/libs/ui/src/theme/ThemeColorVars.tsx b/libs/ui/src/theme/ThemeColorVars.tsx
index 393c7ed13fb..40ea7d179e5 100644
--- a/libs/ui/src/theme/ThemeColorVars.tsx
+++ b/libs/ui/src/theme/ThemeColorVars.tsx
@@ -58,6 +58,9 @@ interface ChainAccentInput {
darkBorderAlpha?: number
}
+// Override type excludes chainId since the Record key is the single source of truth
+type ChainAccentOverride = Partial>
+
const CHAIN_LIGHT_BG_ALPHA = 0.22
const CHAIN_DARK_BG_ALPHA = 0.32
const CHAIN_LIGHT_BORDER_ALPHA = 0.45
@@ -114,7 +117,7 @@ function createChainAccent({
*/
// Color overrides for chains where CHAIN_INFO color differs from original design
-const CHAIN_ACCENT_OVERRIDES: Partial>> = {
+const CHAIN_ACCENT_OVERRIDES: Partial> = {
[SupportedChainId.MAINNET]: {
// Override: Original color #627EEA differs from SDK's #62688F
color: '#627EEA',
From aa7fa8f9f901079ce02ecd835223ec538d003063 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:33:37 +0000
Subject: [PATCH 044/100] refactor: decompose TokenSeachContent component
---
.../src/common/pure/VirtualList/index.tsx | 2 +-
.../pure/TokenSearchContent/GuideBanner.tsx | 29 +++
.../TokenSearchRowRenderer.tsx | 43 ++++
.../pure/TokenSearchContent/helpers.ts | 27 +++
.../pure/TokenSearchContent/index.tsx | 212 +-----------------
.../pure/TokenSearchContent/types.ts | 52 +++++
.../pure/TokenSearchContent/useSearchRows.ts | 65 ++++++
7 files changed, 228 insertions(+), 202 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts
diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
index 41640698b7f..96bee5c71a5 100644
--- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
@@ -81,7 +81,7 @@ interface VirtualListProps {
loading?: boolean
estimateSize?: () => number
children?: ReactNode
- scrollResetKey?: number
+ scrollResetKey?: number | string
}
export function VirtualList({
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx
new file mode 100644
index 00000000000..5765907cd04
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/GuideBanner.tsx
@@ -0,0 +1,29 @@
+import { ReactNode } from 'react'
+
+import {
+ BannerOrientation,
+ ExternalLink,
+ InlineBanner,
+ LINK_GUIDE_ADD_CUSTOM_TOKEN,
+ StatusColorVariant,
+} from '@cowprotocol/ui'
+
+import { Trans } from '@lingui/react/macro'
+
+export function GuideBanner(): ReactNode {
+ return (
+
+
+
+ Can't find your token on the list?{' '}
+ Read our guide on how to add custom tokens.
+
+
+
+ )
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx
new file mode 100644
index 00000000000..a0faa2bd110
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/TokenSearchRowRenderer.tsx
@@ -0,0 +1,43 @@
+import { ReactNode } from 'react'
+
+import { GuideBanner } from './GuideBanner'
+import { TokenSearchRowRendererProps } from './types'
+
+import * as styledEl from '../../containers/TokenSearchResults/styled'
+import { ImportTokenItem } from '../ImportTokenItem'
+import { TokenListItemContainer } from '../TokenListItemContainer'
+import { TokenSourceTitle } from '../TokenSourceTitle'
+
+export function TokenSearchRowRenderer({
+ row,
+ selectTokenContext,
+ importToken,
+}: TokenSearchRowRendererProps): ReactNode {
+ switch (row.type) {
+ case 'banner':
+ return
+ case 'token':
+ return
+ case 'section-title': {
+ const tooltip = row.tooltip?.trim() || undefined
+ return (
+
+ {row.text}
+
+ )
+ }
+ case 'import-token':
+ return (
+
+ )
+ default:
+ return null
+ }
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts
new file mode 100644
index 00000000000..d82d466f17e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/helpers.ts
@@ -0,0 +1,27 @@
+import { AppendImportSectionParams, TokenSearchRow } from './types'
+
+export function appendImportSection(rows: TokenSearchRow[], params: AppendImportSectionParams): void {
+ const { tokens, section, limit, sectionTitle, tooltip, shadowed, wrapperId } = params
+
+ if (!tokens?.length) {
+ return
+ }
+
+ if (sectionTitle) {
+ 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,
+ })
+ })
+}
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 b70d5756636..9f5a0437d6e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/index.tsx
@@ -2,36 +2,18 @@ import { ReactNode, useCallback, useMemo } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils'
-import { TokenSearchResponse } from '@cowprotocol/tokens'
-import {
- BannerOrientation,
- ExternalLink,
- InlineBanner,
- LINK_GUIDE_ADD_CUSTOM_TOKEN,
- Loader,
- StatusColorVariant,
-} from '@cowprotocol/ui'
+import { Loader } from '@cowprotocol/ui'
-import { t } from '@lingui/core/macro'
import { Trans } from '@lingui/react/macro'
import { VirtualItem } from '@tanstack/react-virtual'
import { VirtualList } from 'common/pure/VirtualList'
-import * as styledEl from '../../containers/TokenSearchResults/styled'
-import { SelectTokenContext } from '../../types'
-import { ImportTokenItem } from '../ImportTokenItem'
-import { TokenListItemContainer } from '../TokenListItemContainer'
-import { TokenSourceTitle } from '../TokenSourceTitle'
-
-const SEARCH_RESULTS_LIMIT = 100
+import { TokenSearchRowRenderer } from './TokenSearchRowRenderer'
+import { TokenSearchContentProps, TokenSearchRow } from './types'
+import { useSearchRows } from './useSearchRows'
-interface TokenSearchContentProps {
- searchInput: string
- searchResults: TokenSearchResponse
- selectTokenContext: SelectTokenContext
- importToken: (tokenToImport: TokenWithLogo) => void
-}
+import * as styledEl from '../../containers/TokenSearchResults/styled'
export function TokenSearchContent({
searchInput,
@@ -43,10 +25,10 @@ export function TokenSearchContent({
const searchCount = [
activeListsResult.length,
- inactiveListsResult.length,
- blockchainResult.length,
- externalApiResult.length,
- ].reduce((acc, cur) => acc + (cur ?? 0), 0)
+ inactiveListsResult?.length ?? 0,
+ blockchainResult?.length ?? 0,
+ externalApiResult?.length ?? 0,
+ ].reduce((acc, cur) => acc + cur, 0)
const isTokenNotFound = isLoading ? false : searchCount === 0
@@ -74,7 +56,7 @@ export function TokenSearchContent({
externalApiResult,
})
- const renderRow = useCallback(
+ const getItemView = useCallback(
(items: TokenSearchRow[], virtualItem: VirtualItem) => (
)
- return
-}
-
-type TokenImportSection = 'blockchain' | 'inactive' | 'external'
-
-type TokenSearchRow =
- | { type: 'banner' }
- | { type: 'token'; token: TokenWithLogo }
- | { type: 'section-title'; text: string; tooltip?: string }
- | {
- type: 'import-token'
- token: TokenWithLogo
- shadowed?: boolean
- section: TokenImportSection
- isFirstInSection: boolean
- isLastInSection: boolean
- wrapperId?: string
- }
-
-interface UseSearchRowsParams {
- isLoading: boolean
- matchedTokens: TokenWithLogo[]
- activeList: TokenWithLogo[]
- blockchainResult?: TokenWithLogo[]
- inactiveListsResult?: TokenWithLogo[]
- externalApiResult?: TokenWithLogo[]
-}
-
-function useSearchRows({
- isLoading,
- matchedTokens,
- activeList,
- blockchainResult,
- inactiveListsResult,
- externalApiResult,
-}: UseSearchRowsParams): TokenSearchRow[] {
- return useMemo(() => {
- const entries: TokenSearchRow[] = []
-
- if (isLoading) {
- return entries
- }
-
- entries.push({ type: 'banner' })
-
- for (const token of matchedTokens) {
- entries.push({ type: 'token', token })
- }
-
- for (const token of activeList) {
- entries.push({ type: 'token', token })
- }
-
- appendImportSection(entries, {
- tokens: blockchainResult,
- section: 'blockchain',
- limit: SEARCH_RESULTS_LIMIT,
- sectionTitle: undefined,
- tooltip: undefined,
- shadowed: false,
- wrapperId: 'currency-import',
- })
-
- appendImportSection(entries, {
- tokens: inactiveListsResult,
- section: 'inactive',
- limit: SEARCH_RESULTS_LIMIT,
- sectionTitle: t`Expanded results from inactive Token Lists`,
- tooltip: t`Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists.`,
- shadowed: true,
- })
-
- appendImportSection(entries, {
- tokens: externalApiResult,
- section: 'external',
- limit: SEARCH_RESULTS_LIMIT,
- sectionTitle: t`Additional Results from External Sources`,
- tooltip: t`Tokens from external sources.`,
- shadowed: true,
- })
-
- return entries
- }, [isLoading, matchedTokens, activeList, blockchainResult, inactiveListsResult, externalApiResult])
-}
-
-interface AppendImportSectionParams {
- tokens?: TokenWithLogo[]
- section: TokenImportSection
- limit: number
- sectionTitle?: string
- tooltip?: string
- shadowed?: boolean
- wrapperId?: string
-}
-
-function appendImportSection(rows: TokenSearchRow[], params: AppendImportSectionParams): void {
- const { tokens, section, limit, sectionTitle, tooltip, shadowed, wrapperId } = params
-
- if (!tokens?.length) {
- return
- }
-
- if (sectionTitle) {
- rows.push({ type: 'section-title', text: sectionTitle, tooltip })
- }
-
- const limitedTokens = tokens.slice(0, limit)
-
- limitedTokens.forEach((token, index) => {
- rows.push({
- type: 'import-token',
- token,
- section,
- shadowed,
- isFirstInSection: index === 0,
- isLastInSection: index === limitedTokens.length - 1,
- wrapperId: index === 0 ? wrapperId : undefined,
- })
- })
-}
-
-interface TokenSearchRowRendererProps {
- row: TokenSearchRow
- selectTokenContext: SelectTokenContext
- importToken(token: TokenWithLogo): void
-}
-
-function TokenSearchRowRenderer({ row, selectTokenContext, importToken }: TokenSearchRowRendererProps): ReactNode {
- switch (row.type) {
- case 'banner':
- return
- case 'token':
- return
- case 'section-title': {
- const tooltip = row.tooltip?.trim() || undefined
- return (
-
- {row.text}
-
- )
- }
- case 'import-token':
- return (
-
- )
- default:
- return null
- }
-}
-
-function GuideBanner(): ReactNode {
- return (
-
-
-
- Can't find your token on the list? Read our guide{' '}
- on how to add custom tokens.
-
-
-
- )
+ return
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts
new file mode 100644
index 00000000000..b6d1809135a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/types.ts
@@ -0,0 +1,52 @@
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { TokenSearchResponse } from '@cowprotocol/tokens'
+
+import { SelectTokenContext } from '../../types'
+
+export interface TokenSearchContentProps {
+ searchInput: string
+ searchResults: TokenSearchResponse
+ selectTokenContext: SelectTokenContext
+ importToken: (tokenToImport: TokenWithLogo) => void
+}
+
+export type TokenImportSection = 'blockchain' | 'inactive' | 'external'
+
+export type TokenSearchRow =
+ | { type: 'banner' }
+ | { type: 'token'; token: TokenWithLogo }
+ | { type: 'section-title'; text: string; tooltip?: string }
+ | {
+ type: 'import-token'
+ token: TokenWithLogo
+ shadowed?: boolean
+ section: TokenImportSection
+ isFirstInSection: boolean
+ isLastInSection: boolean
+ wrapperId?: string
+ }
+
+export interface UseSearchRowsParams {
+ isLoading: boolean
+ matchedTokens: TokenWithLogo[]
+ activeList: TokenWithLogo[]
+ blockchainResult?: TokenWithLogo[]
+ inactiveListsResult?: TokenWithLogo[]
+ externalApiResult?: TokenWithLogo[]
+}
+
+export interface AppendImportSectionParams {
+ tokens?: TokenWithLogo[]
+ section: TokenImportSection
+ limit: number
+ sectionTitle?: string
+ tooltip?: string
+ shadowed?: boolean
+ wrapperId?: string
+}
+
+export interface TokenSearchRowRendererProps {
+ row: TokenSearchRow
+ selectTokenContext: SelectTokenContext
+ importToken(token: TokenWithLogo): void
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts
new file mode 100644
index 00000000000..3ed9a6ed9f6
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.ts
@@ -0,0 +1,65 @@
+import { useMemo } from 'react'
+
+import { t } from '@lingui/core/macro'
+
+import { appendImportSection } from './helpers'
+import { TokenSearchRow, UseSearchRowsParams } from './types'
+
+const SEARCH_RESULTS_LIMIT = 100
+
+export function useSearchRows({
+ isLoading,
+ matchedTokens,
+ activeList,
+ blockchainResult,
+ inactiveListsResult,
+ externalApiResult,
+}: UseSearchRowsParams): TokenSearchRow[] {
+ return useMemo(() => {
+ const entries: TokenSearchRow[] = []
+
+ if (isLoading) {
+ return entries
+ }
+
+ entries.push({ type: 'banner' })
+
+ for (const token of matchedTokens) {
+ entries.push({ type: 'token', token })
+ }
+
+ for (const token of activeList) {
+ entries.push({ type: 'token', token })
+ }
+
+ appendImportSection(entries, {
+ tokens: blockchainResult,
+ section: 'blockchain',
+ limit: SEARCH_RESULTS_LIMIT,
+ sectionTitle: undefined,
+ tooltip: undefined,
+ shadowed: false,
+ wrapperId: 'currency-import',
+ })
+
+ appendImportSection(entries, {
+ tokens: inactiveListsResult,
+ section: 'inactive',
+ limit: SEARCH_RESULTS_LIMIT,
+ sectionTitle: t`Expanded results from inactive Token Lists`,
+ tooltip: t`Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists.`,
+ shadowed: true,
+ })
+
+ appendImportSection(entries, {
+ tokens: externalApiResult,
+ section: 'external',
+ limit: SEARCH_RESULTS_LIMIT,
+ sectionTitle: t`Additional Results from External Sources`,
+ tooltip: t`Tokens from external sources.`,
+ shadowed: true,
+ })
+
+ return entries
+ }, [isLoading, matchedTokens, activeList, blockchainResult, inactiveListsResult, externalApiResult])
+}
From e0daf213537b4f81b4742d178289a99874a22276 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:42:46 +0000
Subject: [PATCH 045/100] test: add comprehensive tests for useSearchRows hook
in TokenSearchContent
---
.../TokenSearchContent/useSearchRows.test.ts | 778 ++++++++++++++++++
1 file changed, 778 insertions(+)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts
new file mode 100644
index 00000000000..a394cbc2c5b
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts
@@ -0,0 +1,778 @@
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import { renderHook } from '@testing-library/react'
+
+import { useSearchRows } from './useSearchRows'
+
+// Helper to create test tokens with valid Ethereum addresses
+function createToken(
+ symbol: string,
+ address: string,
+ name: string,
+ chainId: SupportedChainId = SupportedChainId.SEPOLIA,
+): TokenWithLogo {
+ // Ensure address is valid (42 chars: 0x + 40 hex chars)
+ // If address is too short, pad it with zeros
+ let validAddress = address
+ if (address.length < 42) {
+ const hexPart = address.startsWith('0x') ? address.slice(2) : address
+ validAddress = `0x${hexPart.padStart(40, '0')}`
+ }
+ return new TokenWithLogo(undefined, chainId, validAddress, 18, symbol, name)
+}
+
+// Helper to create multiple tokens
+function createTokens(count: number, prefix = 'TOKEN'): TokenWithLogo[] {
+ return Array.from({ length: count }, (_, i) => {
+ // Generate valid 40-char hex address by padding the index
+ const hexIndex = i.toString(16).padStart(40, '0')
+ return createToken(`${prefix}${i}`, `0x${hexIndex}`, `${prefix} ${i}`)
+ })
+}
+
+// eslint-disable-next-line max-lines-per-function
+describe('useSearchRows', () => {
+ describe('Loading State', () => {
+ it('should return empty array when isLoading is true', () => {
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: true,
+ matchedTokens: [createToken('TOKEN', '0x1', 'Test Token')],
+ activeList: [createToken('TOKEN2', '0x2', 'Test Token 2')],
+ blockchainResult: [createToken('TOKEN3', '0x3', 'Test Token 3')],
+ inactiveListsResult: [createToken('TOKEN4', '0x4', 'Test Token 4')],
+ externalApiResult: [createToken('TOKEN5', '0x5', 'Test Token 5')],
+ }),
+ )
+
+ expect(result.current).toEqual([])
+ })
+
+ it('should ignore all token arrays when loading', () => {
+ const matchedTokens = [createToken('MATCHED', '0x1', 'Matched Token')]
+ const activeList = [createToken('ACTIVE', '0x2', 'Active Token')]
+
+ const { result: loadingResult } = renderHook(() =>
+ useSearchRows({
+ isLoading: true,
+ matchedTokens,
+ activeList,
+ blockchainResult: [createToken('BLOCKCHAIN', '0x3', 'Blockchain Token')],
+ inactiveListsResult: [createToken('INACTIVE', '0x4', 'Inactive Token')],
+ externalApiResult: [createToken('EXTERNAL', '0x5', 'External Token')],
+ }),
+ )
+
+ expect(loadingResult.current).toEqual([])
+
+ const { result: notLoadingResult } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens,
+ activeList,
+ }),
+ )
+
+ expect(notLoadingResult.current.length).toBeGreaterThan(0)
+ })
+ })
+
+ describe('Banner Row', () => {
+ it('should always include banner row as first entry when not loading', () => {
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ }),
+ )
+
+ expect(result.current.length).toBeGreaterThan(0)
+ expect(result.current[0]).toEqual({ type: 'banner' })
+ })
+
+ it('should have correct banner row type', () => {
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ }),
+ )
+
+ const bannerRow = result.current[0]
+ expect(bannerRow).toEqual({ type: 'banner' })
+ })
+ })
+
+ describe('Matched Tokens', () => {
+ it('should add matched tokens with correct structure', () => {
+ const token1 = createToken('TOKEN1', '0x1', 'Token 1')
+ const token2 = createToken('TOKEN2', '0x2', 'Token 2')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [token1, token2],
+ activeList: [],
+ }),
+ )
+
+ expect(result.current[0]).toEqual({ type: 'banner' })
+ expect(result.current[1]).toEqual({ type: 'token', token: token1 })
+ expect(result.current[2]).toEqual({ type: 'token', token: token2 })
+ })
+
+ it('should preserve order of matched tokens', () => {
+ const token1 = createToken('TOKEN1', '0x1', 'Token 1')
+ const token2 = createToken('TOKEN2', '0x2', 'Token 2')
+ const token3 = createToken('TOKEN3', '0x3', 'Token 3')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [token1, token2, token3],
+ activeList: [],
+ }),
+ )
+
+ const tokenRows = result.current.filter((row) => row.type === 'token') as Array<{
+ type: 'token'
+ token: TokenWithLogo
+ }>
+ expect(tokenRows[0].token).toBe(token1)
+ expect(tokenRows[1].token).toBe(token2)
+ expect(tokenRows[2].token).toBe(token3)
+ })
+
+ it('should handle empty matched tokens array', () => {
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ }),
+ )
+
+ const tokenRows = result.current.filter((row) => row.type === 'token')
+ expect(tokenRows.length).toBe(0)
+ expect(result.current[0]).toEqual({ type: 'banner' })
+ })
+ })
+
+ describe('Active List Tokens', () => {
+ it('should add active list tokens after matched tokens', () => {
+ const matchedToken = createToken('MATCHED', '0x1', 'Matched Token')
+ const activeToken = createToken('ACTIVE', '0x2', 'Active Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [matchedToken],
+ activeList: [activeToken],
+ }),
+ )
+
+ expect(result.current[0]).toEqual({ type: 'banner' })
+ expect(result.current[1]).toEqual({ type: 'token', token: matchedToken })
+ expect(result.current[2]).toEqual({ type: 'token', token: activeToken })
+ })
+
+ it('should have correct structure for active list tokens', () => {
+ const activeToken = createToken('ACTIVE', '0x1', 'Active Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [activeToken],
+ }),
+ )
+
+ const tokenRow = result.current.find((row) => row.type === 'token')
+ expect(tokenRow).toEqual({ type: 'token', token: activeToken })
+ })
+
+ it('should handle empty active list', () => {
+ const matchedToken = createToken('MATCHED', '0x1', 'Matched Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [matchedToken],
+ activeList: [],
+ }),
+ )
+
+ const tokenRows = result.current.filter((row) => row.type === 'token')
+ expect(tokenRows.length).toBe(1)
+ expect((tokenRows[0] as { type: 'token'; token: TokenWithLogo }).token).toBe(matchedToken)
+ })
+ })
+
+ describe('Blockchain Import Section', () => {
+ it('should add blockchain tokens when provided', () => {
+ const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: [blockchainToken],
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{
+ type: 'import-token'
+ token: TokenWithLogo
+ section: string
+ shadowed?: boolean
+ wrapperId?: string
+ }>
+
+ expect(importRows.length).toBe(1)
+ expect(importRows[0].token).toBe(blockchainToken)
+ expect(importRows[0].section).toBe('blockchain')
+ })
+
+ it('should not add section title for blockchain section', () => {
+ const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: [blockchainToken],
+ }),
+ )
+
+ const sectionTitles = result.current.filter((row) => row.type === 'section-title')
+ expect(sectionTitles.length).toBe(0)
+ })
+
+ it('should have shadowed false for blockchain tokens', () => {
+ const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: [blockchainToken],
+ }),
+ )
+
+ const importRow = result.current.find((row) => row.type === 'import-token') as {
+ type: 'import-token'
+ shadowed?: boolean
+ }
+
+ expect(importRow.shadowed).toBe(false)
+ })
+
+ it('should have wrapperId currency-import on first blockchain token', () => {
+ const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: [blockchainToken],
+ }),
+ )
+
+ const importRow = result.current.find((row) => row.type === 'import-token') as {
+ type: 'import-token'
+ wrapperId?: string
+ }
+
+ expect(importRow.wrapperId).toBe('currency-import')
+ })
+
+ it('should respect SEARCH_RESULTS_LIMIT for blockchain tokens', () => {
+ const blockchainTokens = createTokens(150)
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: blockchainTokens,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token')
+ expect(importRows.length).toBe(100)
+ })
+ })
+
+ describe('Inactive Lists Import Section', () => {
+ it('should add section title for inactive lists', () => {
+ const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ inactiveListsResult: [inactiveToken],
+ }),
+ )
+
+ const sectionTitle = result.current.find((row) => row.type === 'section-title') as {
+ type: 'section-title'
+ text: string
+ tooltip?: string
+ }
+
+ expect(sectionTitle).toBeDefined()
+ expect(sectionTitle.text).toBe('Expanded results from inactive Token Lists')
+ })
+
+ it('should include tooltip text for inactive lists', () => {
+ const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ inactiveListsResult: [inactiveToken],
+ }),
+ )
+
+ const sectionTitle = result.current.find((row) => row.type === 'section-title') as {
+ type: 'section-title'
+ tooltip?: string
+ }
+
+ expect(sectionTitle.tooltip).toBe(
+ 'Tokens from inactive lists. Import specific tokens below or click Manage to activate more lists.',
+ )
+ })
+
+ it('should have shadowed true for inactive tokens', () => {
+ const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ inactiveListsResult: [inactiveToken],
+ }),
+ )
+
+ const importRow = result.current.find((row) => row.type === 'import-token') as {
+ type: 'import-token'
+ shadowed?: boolean
+ }
+
+ expect(importRow.shadowed).toBe(true)
+ })
+
+ it('should have correct section type inactive', () => {
+ const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ inactiveListsResult: [inactiveToken],
+ }),
+ )
+
+ const importRow = result.current.find((row) => row.type === 'import-token') as {
+ type: 'import-token'
+ section: string
+ }
+
+ expect(importRow.section).toBe('inactive')
+ })
+
+ it('should respect limit for inactive tokens', () => {
+ const inactiveTokens = createTokens(150)
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ inactiveListsResult: inactiveTokens,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token')
+ expect(importRows.length).toBe(100)
+ })
+ })
+
+ describe('External API Import Section', () => {
+ it('should add section title for external API results', () => {
+ const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ externalApiResult: [externalToken],
+ }),
+ )
+
+ const sectionTitle = result.current.find((row) => row.type === 'section-title') as {
+ type: 'section-title'
+ text: string
+ }
+
+ expect(sectionTitle).toBeDefined()
+ expect(sectionTitle.text).toBe('Additional Results from External Sources')
+ })
+
+ it('should include tooltip text for external API results', () => {
+ const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ externalApiResult: [externalToken],
+ }),
+ )
+
+ const sectionTitle = result.current.find((row) => row.type === 'section-title') as {
+ type: 'section-title'
+ tooltip?: string
+ }
+
+ expect(sectionTitle.tooltip).toBe('Tokens from external sources.')
+ })
+
+ it('should have shadowed true for external tokens', () => {
+ const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ externalApiResult: [externalToken],
+ }),
+ )
+
+ const importRow = result.current.find((row) => row.type === 'import-token') as {
+ type: 'import-token'
+ shadowed?: boolean
+ }
+
+ expect(importRow.shadowed).toBe(true)
+ })
+
+ it('should have correct section type external', () => {
+ const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ externalApiResult: [externalToken],
+ }),
+ )
+
+ const importRow = result.current.find((row) => row.type === 'import-token') as {
+ type: 'import-token'
+ section: string
+ }
+
+ expect(importRow.section).toBe('external')
+ })
+
+ it('should respect limit for external tokens', () => {
+ const externalTokens = createTokens(150)
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ externalApiResult: externalTokens,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token')
+ expect(importRows.length).toBe(100)
+ })
+ })
+
+ describe('Import Section Structure', () => {
+ it('should have isFirstInSection true on first token', () => {
+ const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')]
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: tokens,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{
+ type: 'import-token'
+ isFirstInSection: boolean
+ }>
+
+ expect(importRows[0].isFirstInSection).toBe(true)
+ expect(importRows[1].isFirstInSection).toBe(false)
+ })
+
+ it('should have isLastInSection true on last token', () => {
+ const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')]
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: tokens,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{
+ type: 'import-token'
+ isLastInSection: boolean
+ }>
+
+ expect(importRows[0].isLastInSection).toBe(false)
+ expect(importRows[1].isLastInSection).toBe(true)
+ })
+
+ it('should have both flags false on middle tokens', () => {
+ const tokens = [
+ createToken('TOKEN1', '0x1', 'Token 1'),
+ createToken('TOKEN2', '0x2', 'Token 2'),
+ createToken('TOKEN3', '0x3', 'Token 3'),
+ ]
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: tokens,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{
+ type: 'import-token'
+ isFirstInSection: boolean
+ isLastInSection: boolean
+ }>
+
+ expect(importRows[1].isFirstInSection).toBe(false)
+ expect(importRows[1].isLastInSection).toBe(false)
+ })
+
+ it('should have wrapperId only on first token', () => {
+ const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')]
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: tokens,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{
+ type: 'import-token'
+ wrapperId?: string
+ }>
+
+ expect(importRows[0].wrapperId).toBe('currency-import')
+ expect(importRows[1].wrapperId).toBeUndefined()
+ })
+
+ it('should have correct section type on each import token', () => {
+ const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+ const inactiveToken = createToken('INACTIVE', '0x2', 'Inactive Token')
+ const externalToken = createToken('EXTERNAL', '0x3', 'External Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: [blockchainToken],
+ inactiveListsResult: [inactiveToken],
+ externalApiResult: [externalToken],
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token') as Array<{
+ type: 'import-token'
+ section: string
+ }>
+
+ expect(importRows.find((row) => row.section === 'blockchain')).toBeDefined()
+ expect(importRows.find((row) => row.section === 'inactive')).toBeDefined()
+ expect(importRows.find((row) => row.section === 'external')).toBeDefined()
+ })
+ })
+
+ describe('Edge Cases', () => {
+ it('should handle undefined token arrays by skipping section', () => {
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: undefined,
+ inactiveListsResult: undefined,
+ externalApiResult: undefined,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token')
+ expect(importRows.length).toBe(0)
+ expect(result.current).toEqual([{ type: 'banner' }])
+ })
+
+ it('should handle empty token arrays by skipping section', () => {
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: [],
+ inactiveListsResult: [],
+ externalApiResult: [],
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token')
+ expect(importRows.length).toBe(0)
+ expect(result.current).toEqual([{ type: 'banner' }])
+ })
+
+ it('should handle arrays exceeding limit by truncating to 100', () => {
+ const largeArray = createTokens(200)
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: largeArray,
+ }),
+ )
+
+ const importRows = result.current.filter((row) => row.type === 'import-token')
+ expect(importRows.length).toBe(100)
+ })
+
+ it('should handle single token in section with both first and last flags true', () => {
+ const singleToken = createToken('SINGLE', '0x1', 'Single Token')
+
+ const { result } = renderHook(() =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens: [],
+ activeList: [],
+ blockchainResult: [singleToken],
+ }),
+ )
+
+ const importRow = result.current.find((row) => row.type === 'import-token') as {
+ type: 'import-token'
+ isFirstInSection: boolean
+ isLastInSection: boolean
+ }
+
+ expect(importRow.isFirstInSection).toBe(true)
+ expect(importRow.isLastInSection).toBe(true)
+ })
+ })
+
+ describe('Memoization', () => {
+ it('should return same reference when dependencies do not change', () => {
+ const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')]
+ const activeList = [createToken('TOKEN2', '0x2', 'Token 2')]
+
+ const { result, rerender } = renderHook(
+ ({ matchedTokens, activeList }) =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens,
+ activeList,
+ }),
+ {
+ initialProps: { matchedTokens, activeList },
+ },
+ )
+
+ const firstResult = result.current
+
+ rerender({ matchedTokens, activeList })
+
+ expect(result.current).toBe(firstResult)
+ })
+
+ it('should return new reference when dependencies change', () => {
+ const matchedTokens1 = [createToken('TOKEN1', '0x1', 'Token 1')]
+ const matchedTokens2 = [createToken('TOKEN2', '0x2', 'Token 2')]
+ const activeList = [createToken('TOKEN3', '0x3', 'Token 3')]
+
+ const { result, rerender } = renderHook(
+ ({ matchedTokens, activeList }) =>
+ useSearchRows({
+ isLoading: false,
+ matchedTokens,
+ activeList,
+ }),
+ {
+ initialProps: { matchedTokens: matchedTokens1, activeList },
+ },
+ )
+
+ const firstResult = result.current
+
+ rerender({ matchedTokens: matchedTokens2, activeList })
+
+ expect(result.current).not.toBe(firstResult)
+ expect(result.current.length).toBe(firstResult.length)
+ })
+
+ it('should return new reference when isLoading changes', () => {
+ const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')]
+ const activeList = [createToken('TOKEN2', '0x2', 'Token 2')]
+
+ const { result, rerender } = renderHook(
+ ({ isLoading, matchedTokens, activeList }) =>
+ useSearchRows({
+ isLoading,
+ matchedTokens,
+ activeList,
+ }),
+ {
+ initialProps: { isLoading: false, matchedTokens, activeList },
+ },
+ )
+
+ const firstResult = result.current
+
+ rerender({ isLoading: true, matchedTokens, activeList })
+
+ expect(result.current).not.toBe(firstResult)
+ expect(result.current.length).toBe(0)
+ })
+ })
+})
From 90ccb7986c657fe06cf59e02b915c75894041152 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 15:47:04 +0000
Subject: [PATCH 046/100] refactor: simplify token creation in useSearchRows
tests
---
.../TokenSearchContent/useSearchRows.test.ts | 118 ++++++++----------
1 file changed, 55 insertions(+), 63 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts
index a394cbc2c5b..d62ccb5eeea 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenSearchContent/useSearchRows.test.ts
@@ -5,30 +5,22 @@ import { renderHook } from '@testing-library/react'
import { useSearchRows } from './useSearchRows'
-// Helper to create test tokens with valid Ethereum addresses
+// Helper to create valid Ethereum addresses
+const toAddress = (suffix: string | number): string => `0x${suffix.toString().padStart(40, '0')}`
+
+// Helper to create test tokens
function createToken(
symbol: string,
- address: string,
+ addressSuffix: string | number,
name: string,
chainId: SupportedChainId = SupportedChainId.SEPOLIA,
): TokenWithLogo {
- // Ensure address is valid (42 chars: 0x + 40 hex chars)
- // If address is too short, pad it with zeros
- let validAddress = address
- if (address.length < 42) {
- const hexPart = address.startsWith('0x') ? address.slice(2) : address
- validAddress = `0x${hexPart.padStart(40, '0')}`
- }
- return new TokenWithLogo(undefined, chainId, validAddress, 18, symbol, name)
+ return new TokenWithLogo(undefined, chainId, toAddress(addressSuffix), 18, symbol, name)
}
// Helper to create multiple tokens
function createTokens(count: number, prefix = 'TOKEN'): TokenWithLogo[] {
- return Array.from({ length: count }, (_, i) => {
- // Generate valid 40-char hex address by padding the index
- const hexIndex = i.toString(16).padStart(40, '0')
- return createToken(`${prefix}${i}`, `0x${hexIndex}`, `${prefix} ${i}`)
- })
+ return Array.from({ length: count }, (_, i) => createToken(`${prefix}${i}`, i, `${prefix} ${i}`))
}
// eslint-disable-next-line max-lines-per-function
@@ -38,11 +30,11 @@ describe('useSearchRows', () => {
const { result } = renderHook(() =>
useSearchRows({
isLoading: true,
- matchedTokens: [createToken('TOKEN', '0x1', 'Test Token')],
- activeList: [createToken('TOKEN2', '0x2', 'Test Token 2')],
- blockchainResult: [createToken('TOKEN3', '0x3', 'Test Token 3')],
- inactiveListsResult: [createToken('TOKEN4', '0x4', 'Test Token 4')],
- externalApiResult: [createToken('TOKEN5', '0x5', 'Test Token 5')],
+ matchedTokens: [createToken('TOKEN', 1, 'Test Token')],
+ activeList: [createToken('TOKEN2', 2, 'Test Token 2')],
+ blockchainResult: [createToken('TOKEN3', 3, 'Test Token 3')],
+ inactiveListsResult: [createToken('TOKEN4', 4, 'Test Token 4')],
+ externalApiResult: [createToken('TOKEN5', 5, 'Test Token 5')],
}),
)
@@ -50,17 +42,17 @@ describe('useSearchRows', () => {
})
it('should ignore all token arrays when loading', () => {
- const matchedTokens = [createToken('MATCHED', '0x1', 'Matched Token')]
- const activeList = [createToken('ACTIVE', '0x2', 'Active Token')]
+ const matchedTokens = [createToken('MATCHED', 1, 'Matched Token')]
+ const activeList = [createToken('ACTIVE', 2, 'Active Token')]
const { result: loadingResult } = renderHook(() =>
useSearchRows({
isLoading: true,
matchedTokens,
activeList,
- blockchainResult: [createToken('BLOCKCHAIN', '0x3', 'Blockchain Token')],
- inactiveListsResult: [createToken('INACTIVE', '0x4', 'Inactive Token')],
- externalApiResult: [createToken('EXTERNAL', '0x5', 'External Token')],
+ blockchainResult: [createToken('BLOCKCHAIN', 3, 'Blockchain Token')],
+ inactiveListsResult: [createToken('INACTIVE', 4, 'Inactive Token')],
+ externalApiResult: [createToken('EXTERNAL', 5, 'External Token')],
}),
)
@@ -108,8 +100,8 @@ describe('useSearchRows', () => {
describe('Matched Tokens', () => {
it('should add matched tokens with correct structure', () => {
- const token1 = createToken('TOKEN1', '0x1', 'Token 1')
- const token2 = createToken('TOKEN2', '0x2', 'Token 2')
+ const token1 = createToken('TOKEN1', 1, 'Token 1')
+ const token2 = createToken('TOKEN2', 2, 'Token 2')
const { result } = renderHook(() =>
useSearchRows({
@@ -125,9 +117,9 @@ describe('useSearchRows', () => {
})
it('should preserve order of matched tokens', () => {
- const token1 = createToken('TOKEN1', '0x1', 'Token 1')
- const token2 = createToken('TOKEN2', '0x2', 'Token 2')
- const token3 = createToken('TOKEN3', '0x3', 'Token 3')
+ const token1 = createToken('TOKEN1', 1, 'Token 1')
+ const token2 = createToken('TOKEN2', 2, 'Token 2')
+ const token3 = createToken('TOKEN3', 3, 'Token 3')
const { result } = renderHook(() =>
useSearchRows({
@@ -163,8 +155,8 @@ describe('useSearchRows', () => {
describe('Active List Tokens', () => {
it('should add active list tokens after matched tokens', () => {
- const matchedToken = createToken('MATCHED', '0x1', 'Matched Token')
- const activeToken = createToken('ACTIVE', '0x2', 'Active Token')
+ const matchedToken = createToken('MATCHED', 1, 'Matched Token')
+ const activeToken = createToken('ACTIVE', 2, 'Active Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -180,7 +172,7 @@ describe('useSearchRows', () => {
})
it('should have correct structure for active list tokens', () => {
- const activeToken = createToken('ACTIVE', '0x1', 'Active Token')
+ const activeToken = createToken('ACTIVE', 1, 'Active Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -195,7 +187,7 @@ describe('useSearchRows', () => {
})
it('should handle empty active list', () => {
- const matchedToken = createToken('MATCHED', '0x1', 'Matched Token')
+ const matchedToken = createToken('MATCHED', 1, 'Matched Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -213,7 +205,7 @@ describe('useSearchRows', () => {
describe('Blockchain Import Section', () => {
it('should add blockchain tokens when provided', () => {
- const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+ const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -238,7 +230,7 @@ describe('useSearchRows', () => {
})
it('should not add section title for blockchain section', () => {
- const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+ const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -254,7 +246,7 @@ describe('useSearchRows', () => {
})
it('should have shadowed false for blockchain tokens', () => {
- const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+ const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -274,7 +266,7 @@ describe('useSearchRows', () => {
})
it('should have wrapperId currency-import on first blockchain token', () => {
- const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
+ const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -312,7 +304,7 @@ describe('useSearchRows', () => {
describe('Inactive Lists Import Section', () => {
it('should add section title for inactive lists', () => {
- const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+ const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -334,7 +326,7 @@ describe('useSearchRows', () => {
})
it('should include tooltip text for inactive lists', () => {
- const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+ const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -356,7 +348,7 @@ describe('useSearchRows', () => {
})
it('should have shadowed true for inactive tokens', () => {
- const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+ const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -376,7 +368,7 @@ describe('useSearchRows', () => {
})
it('should have correct section type inactive', () => {
- const inactiveToken = createToken('INACTIVE', '0x1', 'Inactive Token')
+ const inactiveToken = createToken('INACTIVE', 1, 'Inactive Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -414,7 +406,7 @@ describe('useSearchRows', () => {
describe('External API Import Section', () => {
it('should add section title for external API results', () => {
- const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+ const externalToken = createToken('EXTERNAL', 1, 'External Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -435,7 +427,7 @@ describe('useSearchRows', () => {
})
it('should include tooltip text for external API results', () => {
- const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+ const externalToken = createToken('EXTERNAL', 1, 'External Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -455,7 +447,7 @@ describe('useSearchRows', () => {
})
it('should have shadowed true for external tokens', () => {
- const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+ const externalToken = createToken('EXTERNAL', 1, 'External Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -475,7 +467,7 @@ describe('useSearchRows', () => {
})
it('should have correct section type external', () => {
- const externalToken = createToken('EXTERNAL', '0x1', 'External Token')
+ const externalToken = createToken('EXTERNAL', 1, 'External Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -513,7 +505,7 @@ describe('useSearchRows', () => {
describe('Import Section Structure', () => {
it('should have isFirstInSection true on first token', () => {
- const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')]
+ const tokens = [createToken('TOKEN1', 1, 'Token 1'), createToken('TOKEN2', 2, 'Token 2')]
const { result } = renderHook(() =>
useSearchRows({
@@ -534,7 +526,7 @@ describe('useSearchRows', () => {
})
it('should have isLastInSection true on last token', () => {
- const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')]
+ const tokens = [createToken('TOKEN1', 1, 'Token 1'), createToken('TOKEN2', 2, 'Token 2')]
const { result } = renderHook(() =>
useSearchRows({
@@ -556,9 +548,9 @@ describe('useSearchRows', () => {
it('should have both flags false on middle tokens', () => {
const tokens = [
- createToken('TOKEN1', '0x1', 'Token 1'),
- createToken('TOKEN2', '0x2', 'Token 2'),
- createToken('TOKEN3', '0x3', 'Token 3'),
+ createToken('TOKEN1', 1, 'Token 1'),
+ createToken('TOKEN2', 2, 'Token 2'),
+ createToken('TOKEN3', 3, 'Token 3'),
]
const { result } = renderHook(() =>
@@ -581,7 +573,7 @@ describe('useSearchRows', () => {
})
it('should have wrapperId only on first token', () => {
- const tokens = [createToken('TOKEN1', '0x1', 'Token 1'), createToken('TOKEN2', '0x2', 'Token 2')]
+ const tokens = [createToken('TOKEN1', 1, 'Token 1'), createToken('TOKEN2', 2, 'Token 2')]
const { result } = renderHook(() =>
useSearchRows({
@@ -602,9 +594,9 @@ describe('useSearchRows', () => {
})
it('should have correct section type on each import token', () => {
- const blockchainToken = createToken('BLOCKCHAIN', '0x1', 'Blockchain Token')
- const inactiveToken = createToken('INACTIVE', '0x2', 'Inactive Token')
- const externalToken = createToken('EXTERNAL', '0x3', 'External Token')
+ const blockchainToken = createToken('BLOCKCHAIN', 1, 'Blockchain Token')
+ const inactiveToken = createToken('INACTIVE', 2, 'Inactive Token')
+ const externalToken = createToken('EXTERNAL', 3, 'External Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -680,7 +672,7 @@ describe('useSearchRows', () => {
})
it('should handle single token in section with both first and last flags true', () => {
- const singleToken = createToken('SINGLE', '0x1', 'Single Token')
+ const singleToken = createToken('SINGLE', 1, 'Single Token')
const { result } = renderHook(() =>
useSearchRows({
@@ -704,8 +696,8 @@ describe('useSearchRows', () => {
describe('Memoization', () => {
it('should return same reference when dependencies do not change', () => {
- const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')]
- const activeList = [createToken('TOKEN2', '0x2', 'Token 2')]
+ const matchedTokens = [createToken('TOKEN1', 1, 'Token 1')]
+ const activeList = [createToken('TOKEN2', 2, 'Token 2')]
const { result, rerender } = renderHook(
({ matchedTokens, activeList }) =>
@@ -727,9 +719,9 @@ describe('useSearchRows', () => {
})
it('should return new reference when dependencies change', () => {
- const matchedTokens1 = [createToken('TOKEN1', '0x1', 'Token 1')]
- const matchedTokens2 = [createToken('TOKEN2', '0x2', 'Token 2')]
- const activeList = [createToken('TOKEN3', '0x3', 'Token 3')]
+ const matchedTokens1 = [createToken('TOKEN1', 1, 'Token 1')]
+ const matchedTokens2 = [createToken('TOKEN2', 2, 'Token 2')]
+ const activeList = [createToken('TOKEN3', 3, 'Token 3')]
const { result, rerender } = renderHook(
({ matchedTokens, activeList }) =>
@@ -752,8 +744,8 @@ describe('useSearchRows', () => {
})
it('should return new reference when isLoading changes', () => {
- const matchedTokens = [createToken('TOKEN1', '0x1', 'Token 1')]
- const activeList = [createToken('TOKEN2', '0x2', 'Token 2')]
+ const matchedTokens = [createToken('TOKEN1', 1, 'Token 1')]
+ const activeList = [createToken('TOKEN2', 2, 'Token 2')]
const { result, rerender } = renderHook(
({ isLoading, matchedTokens, activeList }) =>
From bcd476265652d75f0bb11d7c376ecd4f59930a4f Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 16:51:41 +0000
Subject: [PATCH 047/100] refactor: replace legacy chain selector function with
a dedicated component
---
.../pure/SelectTokenModal/TokenColumnContent.tsx | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
index 0d2c51b9de6..5949943c12a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
@@ -56,16 +56,18 @@ export function TokenColumnContent({
return (
<>
- {renderLegacyChainSelector(chainsToSelect, onSelectChain)}
+
{children}
>
)
}
-function renderLegacyChainSelector(
- chainsToSelect: ChainsToSelectState | undefined,
- onSelectChain: (chain: ChainInfo) => void,
-): ReactNode {
+interface LegacyChainSelectorProps {
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain: (chain: ChainInfo) => void
+}
+
+function LegacyChainSelector({ chainsToSelect, onSelectChain }: LegacyChainSelectorProps): ReactNode {
if (!chainsToSelect?.chains?.length) {
return null
}
From 9e5e616c7939b5302320b7cba44681efcacb62cd Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 17:19:09 +0000
Subject: [PATCH 048/100] refactor: streamline ChainsSelector component and
enhance loading state management
---
.../tokensList/pure/ChainsSelector/index.tsx | 153 ++++++------------
.../tokensList/pure/ChainsSelector/styled.tsx | 11 +-
.../pure/SelectTokenModal/index.tsx | 13 +-
.../tokensList/pure/TokensContent/index.tsx | 32 ++--
4 files changed, 81 insertions(+), 128 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 7a12265d19f..89322ec525c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -3,7 +3,7 @@ import { ReactNode } from 'react'
import OrderCheckIcon from '@cowprotocol/assets/cow-swap/order-check.svg'
import { useTheme } from '@cowprotocol/common-hooks'
import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
-import { UI } from '@cowprotocol/ui'
+import { getChainAccentColors } from '@cowprotocol/ui'
import SVG from 'react-inlinesvg'
@@ -14,64 +14,6 @@ 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,
- accentColorVar: UI.COLOR_CHAIN_ETHEREUM_ACCENT,
- },
- [SupportedChainId.BNB]: {
- backgroundVar: UI.COLOR_CHAIN_BNB_BG,
- borderVar: UI.COLOR_CHAIN_BNB_BORDER,
- accentColorVar: UI.COLOR_CHAIN_BNB_ACCENT,
- },
- [SupportedChainId.BASE]: {
- backgroundVar: UI.COLOR_CHAIN_BASE_BG,
- borderVar: UI.COLOR_CHAIN_BASE_BORDER,
- accentColorVar: UI.COLOR_CHAIN_BASE_ACCENT,
- },
- [SupportedChainId.ARBITRUM_ONE]: {
- backgroundVar: UI.COLOR_CHAIN_ARBITRUM_BG,
- borderVar: UI.COLOR_CHAIN_ARBITRUM_BORDER,
- accentColorVar: UI.COLOR_CHAIN_ARBITRUM_ACCENT,
- },
- [SupportedChainId.POLYGON]: {
- backgroundVar: UI.COLOR_CHAIN_POLYGON_BG,
- borderVar: UI.COLOR_CHAIN_POLYGON_BORDER,
- accentColorVar: UI.COLOR_CHAIN_POLYGON_ACCENT,
- },
- [SupportedChainId.AVALANCHE]: {
- backgroundVar: UI.COLOR_CHAIN_AVALANCHE_BG,
- borderVar: UI.COLOR_CHAIN_AVALANCHE_BORDER,
- accentColorVar: UI.COLOR_CHAIN_AVALANCHE_ACCENT,
- },
- [SupportedChainId.GNOSIS_CHAIN]: {
- backgroundVar: UI.COLOR_CHAIN_GNOSIS_BG,
- borderVar: UI.COLOR_CHAIN_GNOSIS_BORDER,
- accentColorVar: UI.COLOR_CHAIN_GNOSIS_ACCENT,
- },
- [SupportedChainId.LENS]: {
- backgroundVar: UI.COLOR_CHAIN_LENS_BG,
- borderVar: UI.COLOR_CHAIN_LENS_BORDER,
- accentColorVar: UI.COLOR_CHAIN_LENS_ACCENT,
- },
- [SupportedChainId.SEPOLIA]: {
- backgroundVar: UI.COLOR_CHAIN_SEPOLIA_BG,
- borderVar: UI.COLOR_CHAIN_SEPOLIA_BORDER,
- accentColorVar: UI.COLOR_CHAIN_SEPOLIA_ACCENT,
- },
- [SupportedChainId.LINEA]: {
- backgroundVar: UI.COLOR_CHAIN_LINEA_BG,
- borderVar: UI.COLOR_CHAIN_LINEA_BORDER,
- accentColorVar: UI.COLOR_CHAIN_LINEA_ACCENT,
- },
- [SupportedChainId.PLASMA]: {
- backgroundVar: UI.COLOR_CHAIN_PLASMA_BG,
- borderVar: UI.COLOR_CHAIN_PLASMA_BORDER,
- accentColorVar: UI.COLOR_CHAIN_PLASMA_ACCENT,
- },
-}
-
export interface ChainsSelectorProps {
chains: ChainInfo[]
onSelectChain: (chainId: ChainInfo) => void
@@ -92,24 +34,24 @@ export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoadin
}
function ChainsLoadingList(): ReactNode {
- const skeletonRows = renderChainSkeletonRows()
-
- return {skeletonRows}
+ return (
+
+
+
+ )
}
-function renderChainSkeletonRows(): ReactNode[] {
- const elements: ReactNode[] = []
-
- for (const index of LOADING_SKELETON_INDICES) {
- elements.push(
-
-
-
- ,
- )
- }
-
- return elements
+function ChainsSkeletonList(): ReactNode {
+ return (
+ <>
+ {LOADING_SKELETON_INDICES.map((index) => (
+
+
+
+
+ ))}
+ >
+ )
}
interface ChainsListProps {
@@ -120,38 +62,47 @@ interface ChainsListProps {
}
function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
- const chainButtons = renderChainButtons({ chains, defaultChainId, onSelectChain, isDarkMode })
-
- return {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
+function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
+ return (
+ <>
+ {chains.map((chain) => (
+
+ ))}
+ >
+ )
}
export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined {
- return CHAIN_ACCENT_VAR_MAP[chainId as SupportedChainId]
+
+ const accentConfig = getChainAccentColors(chainId as SupportedChainId)
+ if (!accentConfig) {
+ return undefined
+ }
+
+
+ return {
+ backgroundVar: accentConfig.bgVar,
+ borderVar: accentConfig.borderVar,
+ accentColorVar: accentConfig.accentVar,
+ }
}
interface ChainButtonProps {
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 9391d6976f3..a446b5c62c9 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
@@ -5,9 +5,9 @@ import styled from 'styled-components/macro'
import { blankButtonMixin } from '../commonElements'
export interface ChainAccentVars {
- backgroundVar: UI
- borderVar: UI
- accentColorVar?: UI
+ backgroundVar: string
+ borderVar: string
+ accentColorVar: string
}
const fallbackBackground = `var(${UI.COLOR_PRIMARY_OPACITY_10})`
@@ -21,7 +21,7 @@ const getBorder = (accent$?: ChainAccentVars, fallback = fallbackBorder): string
accent$ ? `var(${accent$.borderVar})` : fallback
const getAccentColor = (accent$?: ChainAccentVars): string | undefined =>
- accent$?.accentColorVar ? `var(${accent$.accentColorVar})` : undefined
+ accent$ ? `var(${accent$.accentColorVar})` : undefined
export const List = styled.div`
display: flex;
@@ -98,7 +98,8 @@ export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: stri
display: flex;
align-items: center;
justify-content: center;
- color: ${({ color$, accent$ }) => getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)};
+ color: ${({ color$, accent$ }) =>
+ getAccentColor(accent$) ?? color$ ?? getBorder(accent$, `var(${UI.COLOR_PRIMARY})`)};
> svg {
width: 16px;
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 38442da13d1..ff4de3fa279 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
@@ -1,4 +1,4 @@
-import { ReactNode } from 'react'
+import { ReactNode, useMemo } from 'react'
import { SearchInput } from '@cowprotocol/ui'
@@ -188,10 +188,13 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): {
const legacyChainsState =
!showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined
const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap`
- const chainPanel =
- showChainPanel && chainsToSelect ? (
-
- ) : null
+ const chainPanel = useMemo(
+ () =>
+ showChainPanel && chainsToSelect ? (
+
+ ) : null,
+ [chainsToSelect, onSelectChain, resolvedChainPanelTitle, showChainPanel],
+ )
return {
inputValue,
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 cd8bb607702..633e78de5c3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -75,24 +75,22 @@ export function TokensContent({
const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined
const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined
- const tokensView = renderTokensView({
- areTokensLoading,
- searchInput,
- selectTokenContext,
- areTokensFromBridge,
- allTokens,
- tokensWithoutPinned,
- displayLpTokenLists,
- favoriteTokens: favoriteTokensInline,
- recentTokens: recentTokensInline,
- hideFavoriteTokensTooltip,
- selectedTargetChainId,
- onClearRecentTokens,
- })
-
return (
<>
- {tokensView}
+
{!standalone && (
<>
@@ -125,7 +123,7 @@ interface TokensViewProps {
onClearRecentTokens?: () => void
}
-function renderTokensView({
+function TokensView({
areTokensLoading,
searchInput,
selectTokenContext,
From b533601fd4659c513869757e1a4de03d30b61778 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 17:21:58 +0000
Subject: [PATCH 049/100] refactor: clean up getChainAccent function by
removing unnecessary whitespace
---
.../src/modules/tokensList/pure/ChainsSelector/index.tsx | 2 --
1 file changed, 2 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 89322ec525c..6fb87f833ed 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -91,13 +91,11 @@ function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode }
}
export function getChainAccent(chainId: ChainInfo['id']): ChainAccentVars | undefined {
-
const accentConfig = getChainAccentColors(chainId as SupportedChainId)
if (!accentConfig) {
return undefined
}
-
return {
backgroundVar: accentConfig.bgVar,
borderVar: accentConfig.borderVar,
From 2aff08664c14256ea850845eb372314909dd49b6 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 17:27:01 +0000
Subject: [PATCH 050/100] test: add unit tests for getChainAccent function to
validate chain accent color mappings
---
.../ChainsSelector/getChainAccent.test.ts | 156 ++++++++++++++++++
1 file changed, 156 insertions(+)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts
new file mode 100644
index 00000000000..69401f5cf6e
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/getChainAccent.test.ts
@@ -0,0 +1,156 @@
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+import { getChainAccentColors } from '@cowprotocol/ui'
+
+import { getChainAccent } from './index'
+
+jest.mock('@cowprotocol/ui', () => ({
+ ...jest.requireActual('@cowprotocol/ui'),
+ getChainAccentColors: jest.fn(),
+}))
+
+const mockGetChainAccentColors = getChainAccentColors as jest.MockedFunction
+
+describe('getChainAccent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('should return ChainAccentVars for valid chain ID', () => {
+ const mockAccentConfig = {
+ chainId: SupportedChainId.MAINNET,
+ bgVar: '--cow-color-chain-ethereum-bg',
+ borderVar: '--cow-color-chain-ethereum-border',
+ accentVar: '--cow-color-chain-ethereum-accent',
+ lightBg: 'rgba(98, 126, 234, 0.22)',
+ darkBg: 'rgba(98, 126, 234, 0.32)',
+ lightBorder: 'rgba(98, 126, 234, 0.45)',
+ darkBorder: 'rgba(98, 126, 234, 0.65)',
+ lightColor: '#627EEA',
+ darkColor: '#627EEA',
+ }
+
+ mockGetChainAccentColors.mockReturnValue(mockAccentConfig)
+
+ const result = getChainAccent(SupportedChainId.MAINNET)
+
+ expect(result).toEqual({
+ backgroundVar: '--cow-color-chain-ethereum-bg',
+ borderVar: '--cow-color-chain-ethereum-border',
+ accentColorVar: '--cow-color-chain-ethereum-accent',
+ })
+ expect(mockGetChainAccentColors).toHaveBeenCalledWith(SupportedChainId.MAINNET)
+ })
+
+ it('should return ChainAccentVars for different chain IDs', () => {
+ const mockAccentConfig = {
+ chainId: SupportedChainId.POLYGON,
+ bgVar: '--cow-color-chain-polygon-bg',
+ borderVar: '--cow-color-chain-polygon-border',
+ accentVar: '--cow-color-chain-polygon-accent',
+ lightBg: 'rgba(130, 71, 229, 0.22)',
+ darkBg: 'rgba(130, 71, 229, 0.32)',
+ lightBorder: 'rgba(130, 71, 229, 0.45)',
+ darkBorder: 'rgba(130, 71, 229, 0.65)',
+ lightColor: '#8247E5',
+ darkColor: '#8247E5',
+ }
+
+ mockGetChainAccentColors.mockReturnValue(mockAccentConfig)
+
+ const result = getChainAccent(SupportedChainId.POLYGON)
+
+ expect(result).toEqual({
+ backgroundVar: '--cow-color-chain-polygon-bg',
+ borderVar: '--cow-color-chain-polygon-border',
+ accentColorVar: '--cow-color-chain-polygon-accent',
+ })
+ expect(mockGetChainAccentColors).toHaveBeenCalledWith(SupportedChainId.POLYGON)
+ })
+
+ it('should return undefined when getChainAccentColors returns undefined', () => {
+ mockGetChainAccentColors.mockReturnValue(undefined as unknown as ReturnType)
+
+ const result = getChainAccent(999 as SupportedChainId)
+
+ expect(result).toBeUndefined()
+ expect(mockGetChainAccentColors).toHaveBeenCalledWith(999)
+ })
+
+ it('should return undefined when getChainAccentColors returns null', () => {
+ mockGetChainAccentColors.mockReturnValue(null as unknown as ReturnType)
+
+ const result = getChainAccent(SupportedChainId.MAINNET)
+
+ expect(result).toBeUndefined()
+ })
+
+ it('should correctly map all ChainAccentConfig properties to ChainAccentVars', () => {
+ const mockAccentConfig = {
+ chainId: SupportedChainId.ARBITRUM_ONE,
+ bgVar: '--cow-color-chain-arbitrum-bg',
+ borderVar: '--cow-color-chain-arbitrum-border',
+ accentVar: '--cow-color-chain-arbitrum-accent',
+ lightBg: 'rgba(27, 74, 221, 0.22)',
+ darkBg: 'rgba(27, 74, 221, 0.32)',
+ lightBorder: 'rgba(27, 74, 221, 0.45)',
+ darkBorder: 'rgba(27, 74, 221, 0.65)',
+ lightColor: '#1B4ADD',
+ darkColor: '#1B4ADD',
+ }
+
+ mockGetChainAccentColors.mockReturnValue(mockAccentConfig)
+
+ const result = getChainAccent(SupportedChainId.ARBITRUM_ONE)
+
+ expect(result).toBeDefined()
+ expect(result).toHaveProperty('backgroundVar', mockAccentConfig.bgVar)
+ expect(result).toHaveProperty('borderVar', mockAccentConfig.borderVar)
+ expect(result).toHaveProperty('accentColorVar', mockAccentConfig.accentVar)
+ expect(result).not.toHaveProperty('chainId')
+ expect(result).not.toHaveProperty('lightBg')
+ expect(result).not.toHaveProperty('darkBg')
+ })
+
+ it('should handle all supported chain IDs', () => {
+ const supportedChains = [
+ SupportedChainId.MAINNET,
+ SupportedChainId.BNB,
+ SupportedChainId.BASE,
+ SupportedChainId.ARBITRUM_ONE,
+ SupportedChainId.POLYGON,
+ SupportedChainId.AVALANCHE,
+ SupportedChainId.GNOSIS_CHAIN,
+ SupportedChainId.LENS,
+ SupportedChainId.SEPOLIA,
+ SupportedChainId.LINEA,
+ SupportedChainId.PLASMA,
+ ]
+
+ supportedChains.forEach((chainId) => {
+ const mockAccentConfig = {
+ chainId,
+ bgVar: `--cow-color-chain-test-bg`,
+ borderVar: `--cow-color-chain-test-border`,
+ accentVar: `--cow-color-chain-test-accent`,
+ lightBg: 'rgba(0, 0, 0, 0.22)',
+ darkBg: 'rgba(0, 0, 0, 0.32)',
+ lightBorder: 'rgba(0, 0, 0, 0.45)',
+ darkBorder: 'rgba(0, 0, 0, 0.65)',
+ lightColor: '#000000',
+ darkColor: '#000000',
+ }
+
+ mockGetChainAccentColors.mockReturnValue(mockAccentConfig)
+
+ const result = getChainAccent(chainId)
+
+ expect(result).toBeDefined()
+ expect(result).toHaveProperty('backgroundVar')
+ expect(result).toHaveProperty('borderVar')
+ expect(result).toHaveProperty('accentColorVar')
+ expect(typeof result?.backgroundVar).toBe('string')
+ expect(typeof result?.borderVar).toBe('string')
+ expect(typeof result?.accentColorVar).toBe('string')
+ })
+ })
+})
From e51d246223364c982dcdd3d8f67fbf67c8d6217a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Wed, 3 Dec 2025 17:29:18 +0000
Subject: [PATCH 051/100] refactor: revert po changes
---
apps/cowswap-frontend/src/locales/en-US.po | 26 +++-------------------
1 file changed, 3 insertions(+), 23 deletions(-)
diff --git a/apps/cowswap-frontend/src/locales/en-US.po b/apps/cowswap-frontend/src/locales/en-US.po
index a8e45ca0736..8c753759980 100644
--- a/apps/cowswap-frontend/src/locales/en-US.po
+++ b/apps/cowswap-frontend/src/locales/en-US.po
@@ -274,10 +274,6 @@ msgstr "replaced"
msgid "Bridge via"
msgstr "Bridge via"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
-msgid "Select token"
-msgstr "Select token"
-
#: apps/cowswap-frontend/src/modules/trade/pure/LimitOrdersPromoBanner/index.tsx
msgid "Trade your way - personalize the interface and customize your limit orders"
msgstr "Trade your way - personalize the interface and customize your limit orders"
@@ -468,6 +464,7 @@ msgid "View details"
msgstr "View details"
#: apps/cowswap-frontend/src/modules/application/containers/App/menuConsts.tsx
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
msgid "More"
msgstr "More"
@@ -2249,11 +2246,6 @@ msgstr "Dismiss hiring message"
msgid "dialog content"
msgstr "dialog content"
-#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
-#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
-msgid "Cross chain swap"
-msgstr "Cross chain swap"
-
#: apps/cowswap-frontend/src/modules/accountProxy/pure/FAQContent/index.tsx
msgid "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are"
msgstr "Since {accountProxyLabelString} is not an upgradeable smart-contract, it can be versioned and there are"
@@ -3742,10 +3734,6 @@ msgstr "Create LlamaPay Vesting"
msgid "Transaction expiration"
msgstr "Transaction expiration"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
-msgid "Search network"
-msgstr "Search network"
-
#: apps/cowswap-frontend/src/legacy/components/ErrorBoundary/ChunkLoadError.tsx
msgid "CowSwap no connection"
msgstr "CowSwap no connection"
@@ -4819,10 +4807,6 @@ msgstr "Execution price"
msgid "No tokens found"
msgstr "No tokens found"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
-msgid "No networks available for this trade."
-msgstr "No networks available for this trade."
-
#: apps/cowswap-frontend/src/modules/tokensList/pure/TokenTags/index.tsx
msgid "Unsupported"
msgstr "Unsupported"
@@ -4863,10 +4847,6 @@ msgstr "<0/><1/>Consider waiting for lower network costs.<2/><3/>You may still m
msgid "Swapping on"
msgstr "Swapping on"
-#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
-msgid "No networks match \"{chainQuery}\"."
-msgstr "No networks match \"{chainQuery}\"."
-
#: apps/cowswap-frontend/src/modules/ordersTable/pure/ReceiptModal/OrderTypeField.tsx
msgid "(Fill or Kill)"
msgstr "(Fill or Kill)"
@@ -6008,8 +5988,8 @@ msgid "You sold <0/>"
msgstr "You sold <0/>"
#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
-#~ msgid "Less"
-#~ msgstr "Less"
+msgid "Less"
+msgstr "Less"
#: libs/hook-dapp-lib/src/hookDappsRegistry.ts
msgid "Claim your LlamaPay vesting contract funds"
From 9817979d3a2459088ee7869c2dece61ca1f31e71 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 09:43:58 +0000
Subject: [PATCH 052/100] refactor: update TokenLogo component
---
libs/tokens/src/pure/TokenLogo/index.tsx | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/libs/tokens/src/pure/TokenLogo/index.tsx b/libs/tokens/src/pure/TokenLogo/index.tsx
index f988f0cd9ff..2e6f7745aed 100644
--- a/libs/tokens/src/pure/TokenLogo/index.tsx
+++ b/libs/tokens/src/pure/TokenLogo/index.tsx
@@ -75,7 +75,9 @@ function StandardTokenLogo({
setInvalidUrls((state) => ({ ...state, [currentUrl]: true }))
}, [currentUrl, setInvalidUrls])
- const actualTokenContent = renderTokenLogoContent({ currentUrl, onError, token, initial })
+ const actualTokenContent = (
+
+ )
if (noWrap) {
return actualTokenContent
@@ -172,14 +174,14 @@ function useTokenLogoUrl({ token, logoURI, invalidUrls }: TokenLogoUrlOptions):
return { currentUrl, initial }
}
-interface TokenLogoContentOptions {
+interface TokenLogoContentProps {
currentUrl?: string
onError: () => void
token?: TokenWithLogo | Currency | null
initial?: string
}
-function renderTokenLogoContent({ currentUrl, onError, token, initial }: TokenLogoContentOptions): ReactNode {
+function TokenLogoContent({ currentUrl, onError, token, initial }: TokenLogoContentProps): ReactNode {
const address = token && 'address' in token ? token.address : ''
if (currentUrl) {
From fcae04edce445cbd326aa2308fd7d621ef823ce5 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 09:44:07 +0000
Subject: [PATCH 053/100] fix: disable lint rule for useVirtualizer hook
compliance
---
apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
index 41640698b7f..3099f840376 100644
--- a/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/common/pure/VirtualList/index.tsx
@@ -108,6 +108,8 @@ export function VirtualList({
}, scrollDelay)
}, [])
+ // @tanstack/react-virtual's useVirtualizer hook doesn't fully comply with React hooks rules
+ // (e.g., it may call hooks conditionally or in a different order), so we need to disable the lint rule
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
getScrollElement: () => parentRef.current,
From f86ef33e207a247385808ab393c60cd3b4729740 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 10:53:50 +0000
Subject: [PATCH 054/100] refactor(tokensList): modularize FavoriteTokensList
component
---
.../pure/FavoriteTokensList/index.tsx | 83 ++++++++++++-------
1 file changed, 53 insertions(+), 30 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 103ceaa01b7..01660bf674d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/FavoriteTokensList/index.tsx
@@ -33,7 +33,9 @@ export function FavoriteTokensList(props: FavoriteTokensListProps): ReactNode {
{!hideTooltip && }
- {renderFavoriteTokenItems(tokens, selectTokenContext)}
+
+
+
)
}
@@ -50,38 +52,59 @@ function FavoriteTokensTooltip(): ReactNode {
)
}
-function renderFavoriteTokenItems(tokens: TokenWithLogo[], context: SelectTokenContext): ReactNode[] {
- const { selectedToken } = context
+interface FavoriteTokenItemProps {
+ token: TokenWithLogo
+ selectTokenContext: SelectTokenContext
+}
+
+function FavoriteTokenItem({ token, selectTokenContext }: FavoriteTokenItemProps): ReactNode {
+ const { selectedToken, onTokenListItemClick, onSelectToken } = selectTokenContext
const selectedAddress = selectedToken ? getCurrencyAddress(selectedToken) : undefined
- return tokens.map((token) => {
- const isSelected =
- !!selectedToken &&
- token.chainId === selectedToken.chainId &&
- !!selectedAddress &&
- areAddressesEqual(token.address, selectedAddress)
+ const isSelected =
+ !!selectedToken &&
+ token.chainId === selectedToken.chainId &&
+ !!selectedAddress &&
+ areAddressesEqual(token.address, selectedAddress)
- const handleClick = (): void => {
- if (isSelected) {
- return
- }
- context.onTokenListItemClick?.(token)
- context.onSelectToken(token)
+ const handleClick = (): void => {
+ if (isSelected) {
+ return
}
+ onTokenListItemClick?.(token)
+ onSelectToken(token)
+ }
- return (
-
-
-
-
- )
- })
+ return (
+
+
+
+
+ )
+}
+
+interface FavoriteTokenItemsProps {
+ tokens: TokenWithLogo[]
+ selectTokenContext: SelectTokenContext
+}
+
+function FavoriteTokenItems({ tokens, selectTokenContext }: FavoriteTokenItemsProps): ReactNode {
+ return (
+ <>
+ {tokens.map((token) => (
+
+ ))}
+ >
+ )
}
From c03dfdeb81fa51658855ba474c0143491ea3c043 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 10:57:24 +0000
Subject: [PATCH 055/100] refactor(tokensList): replace renderTokensView
function with TokensView component
---
.../tokensList/pure/TokensContent/index.tsx | 32 +++++++++----------
1 file changed, 15 insertions(+), 17 deletions(-)
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 cd8bb607702..633e78de5c3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx
@@ -75,24 +75,22 @@ export function TokensContent({
const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined
const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined
- const tokensView = renderTokensView({
- areTokensLoading,
- searchInput,
- selectTokenContext,
- areTokensFromBridge,
- allTokens,
- tokensWithoutPinned,
- displayLpTokenLists,
- favoriteTokens: favoriteTokensInline,
- recentTokens: recentTokensInline,
- hideFavoriteTokensTooltip,
- selectedTargetChainId,
- onClearRecentTokens,
- })
-
return (
<>
- {tokensView}
+
{!standalone && (
<>
@@ -125,7 +123,7 @@ interface TokensViewProps {
onClearRecentTokens?: () => void
}
-function renderTokensView({
+function TokensView({
areTokensLoading,
searchInput,
selectTokenContext,
From 374755b4ba4a568f130e39d2ae38ba0d1b84b014 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 11:06:11 +0000
Subject: [PATCH 056/100] refactor: rename renderValue function to formatValue
for clarity
---
.../tokensList/containers/LpTokenPage/index.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx
index 49ce02305c1..e97ab3b699c 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/LpTokenPage/index.tsx
@@ -20,7 +20,7 @@ import {
Wrapper,
} from './styled'
-function renderValue(value: T | undefined, template: (v: T) => string, defaultValue?: string): string | undefined {
+function formatValue(value: T | undefined, template: (v: T) => string, defaultValue?: string): string | undefined {
return value ? template(value) : defaultValue
}
@@ -84,7 +84,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L
Fee tier
- {renderValue(info?.feeTier, (t) => `${t}%`, '-')}
+ {formatValue(info?.feeTier, (t) => `${t}%`, '-')}
@@ -92,7 +92,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L
Volume (24h)
- {renderValue(info?.volume24h, (t) => `$${t}`, '-')}
+ {formatValue(info?.volume24h, (t) => `$${t}`, '-')}
@@ -100,7 +100,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L
APR
- {renderValue(info?.apy, (t) => `${t}%`, '-')}
+ {formatValue(info?.apy, (t) => `${t}%`, '-')}
@@ -108,7 +108,7 @@ export function LpTokenPage({ poolAddress, onBack, onDismiss, onSelectToken }: L
TVL
- {renderValue(info?.tvl, (t) => `$${t}`, '-')}
+ {formatValue(info?.tvl, (t) => `$${t}`, '-')}
From ab0d88fa757b792fdc1ffb6692371f051a34a6f0 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 11:25:42 +0000
Subject: [PATCH 057/100] refactor: implement SelectTokenWidget helper
functions
---
.../containers/SelectTokenWidget/helpers.tsx | 213 ++++++++++++++++++
.../containers/SelectTokenWidget/index.tsx | 124 ++++------
.../containers/SelectTokenWidget/types.ts | 115 ++++++++++
3 files changed, 368 insertions(+), 84 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
new file mode 100644
index 00000000000..286c81e2cb2
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
@@ -0,0 +1,213 @@
+import { ReactNode } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import {
+ GetSelectTokenWidgetContentProps,
+ RenderImportListModalProps,
+ RenderImportTokenModalProps,
+ RenderLpTokenPageProps,
+ RenderManageListsAndTokensProps,
+ RenderSelectTokenModalProps,
+} from './types'
+
+import { ImportListModal } from '../../pure/ImportListModal'
+import { ImportTokenModal } from '../../pure/ImportTokenModal'
+import { SelectTokenModal } from '../../pure/SelectTokenModal'
+import { LpTokenPage } from '../LpTokenPage'
+import { ManageListsAndTokens } from '../ManageListsAndTokens'
+
+const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
+
+function renderImportTokenModal({
+ tokenToImport,
+ onDismiss,
+ resetTokenImport,
+ importTokenAndClose,
+}: RenderImportTokenModalProps): ReactNode {
+ return (
+
+ )
+}
+
+function renderImportListModal({
+ listToImport,
+ onDismiss,
+ resetTokenImport,
+ importListAndBack,
+}: RenderImportListModalProps): ReactNode {
+ return (
+
+ )
+}
+
+function renderManageListsAndTokens({
+ allTokenLists,
+ userAddedTokens,
+ onDismiss,
+ setIsManageWidgetOpen,
+}: RenderManageListsAndTokensProps): ReactNode {
+ return (
+ setIsManageWidgetOpen(false)}
+ />
+ )
+}
+
+function renderLpTokenPage({
+ selectedPoolAddress,
+ onDismiss,
+ closePoolPage,
+ onSelectToken,
+}: RenderLpTokenPageProps): ReactNode {
+ return (
+
+ )
+}
+
+function renderSelectTokenModal(props: RenderSelectTokenModalProps): ReactNode {
+ const {
+ standalone,
+ displayLpTokenLists,
+ unsupportedTokens,
+ selectedToken,
+ allTokens,
+ favoriteTokens,
+ recentTokens,
+ balancesState,
+ permitCompatibleTokens,
+ onSelectToken,
+ handleTokenListItemClick,
+ onInputPressEnter,
+ onDismiss,
+ setIsManageWidgetOpen,
+ isInjectedWidgetMode,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ chainsToSelect,
+ onSelectChain,
+ areTokensLoading,
+ tokenListTags,
+ areTokensFromBridge,
+ isRouteAvailable,
+ clearRecentTokens,
+ selectedTargetChainId,
+ } = props
+
+ 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}
+ onClearRecentTokens={clearRecentTokens}
+ selectedTargetChainId={selectedTargetChainId}
+ />
+ )
+}
+
+export function getSelectTokenWidgetContent(props: GetSelectTokenWidgetContentProps): ReactNode {
+ const { standalone, tokenToImport, listToImport, isManageWidgetOpen, selectedPoolAddress } = props
+
+ if (tokenToImport && !standalone) {
+ return renderImportTokenModal({
+ tokenToImport,
+ onDismiss: props.onDismiss,
+ resetTokenImport: props.resetTokenImport,
+ importTokenAndClose: props.importTokenAndClose,
+ })
+ }
+
+ if (listToImport && !standalone) {
+ return renderImportListModal({
+ listToImport,
+ onDismiss: props.onDismiss,
+ resetTokenImport: props.resetTokenImport,
+ importListAndBack: props.importListAndBack,
+ })
+ }
+
+ if (isManageWidgetOpen && !standalone) {
+ return renderManageListsAndTokens({
+ allTokenLists: props.allTokenLists,
+ userAddedTokens: props.userAddedTokens,
+ onDismiss: props.onDismiss,
+ setIsManageWidgetOpen: props.setIsManageWidgetOpen,
+ })
+ }
+
+ if (selectedPoolAddress) {
+ return renderLpTokenPage({
+ selectedPoolAddress,
+ onDismiss: props.onDismiss,
+ closePoolPage: props.closePoolPage,
+ onSelectToken: props.onSelectToken,
+ })
+ }
+
+ return renderSelectTokenModal({
+ standalone: props.standalone,
+ displayLpTokenLists: props.displayLpTokenLists,
+ unsupportedTokens: props.unsupportedTokens,
+ selectedToken: props.selectedToken,
+ allTokens: props.allTokens,
+ favoriteTokens: props.favoriteTokens,
+ recentTokens: props.recentTokens,
+ balancesState: props.balancesState,
+ permitCompatibleTokens: props.permitCompatibleTokens,
+ onSelectToken: props.onSelectToken,
+ handleTokenListItemClick: props.handleTokenListItemClick,
+ onInputPressEnter: props.onInputPressEnter,
+ onDismiss: props.onDismiss,
+ setIsManageWidgetOpen: props.setIsManageWidgetOpen,
+ isInjectedWidgetMode: props.isInjectedWidgetMode,
+ openPoolPage: props.openPoolPage,
+ tokenListCategoryState: props.tokenListCategoryState,
+ disableErc20: props.disableErc20,
+ account: props.account,
+ chainsToSelect: props.chainsToSelect,
+ onSelectChain: props.onSelectChain,
+ areTokensLoading: props.areTokensLoading,
+ tokenListTags: props.tokenListTags,
+ areTokensFromBridge: props.areTokensFromBridge,
+ isRouteAvailable: props.isRouteAvailable,
+ clearRecentTokens: props.clearRecentTokens,
+ selectedTargetChainId: props.selectedTargetChainId,
+ })
+}
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 6e374bfb0e1..5dcb55fc3eb 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -26,6 +26,7 @@ import { useLpTokensWithBalances } from 'modules/yield/shared'
import { CowSwapAnalyticsCategory } from 'common/analytics/types'
import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
+import { getSelectTokenWidgetContent } from './helpers'
import { useChainsToSelect } from '../../hooks/useChainsToSelect'
import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
@@ -35,11 +36,6 @@ import { useRecentTokens } from '../../hooks/useRecentTokens'
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { useTokensToSelect } from '../../hooks/useTokensToSelect'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
-import { ImportListModal } from '../../pure/ImportListModal'
-import { ImportTokenModal } from '../../pure/ImportTokenModal'
-import { SelectTokenModal } from '../../pure/SelectTokenModal'
-import { LpTokenPage } from '../LpTokenPage'
-import { ManageListsAndTokens } from '../ManageListsAndTokens'
const Wrapper = styled.div`
width: 100%;
@@ -50,8 +46,6 @@ const Wrapper = styled.div`
}
`
-const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
-
interface SelectTokenWidgetProps {
displayLpTokenLists?: boolean
standalone?: boolean
@@ -182,83 +176,45 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
return (
- {(() => {
- if (tokenToImport && !standalone) {
- return (
-
- )
- }
-
- if (listToImport && !standalone) {
- return (
-
- )
- }
-
- if (isManageWidgetOpen && !standalone) {
- return (
- setIsManageWidgetOpen(false)}
- />
- )
- }
-
- if (selectedPoolAddress) {
- return (
-
- )
- }
-
- return (
- setIsManageWidgetOpen(true)}
- hideFavoriteTokensTooltip={isInjectedWidgetMode}
- openPoolPage={openPoolPage}
- tokenListCategoryState={tokenListCategoryState}
- disableErc20={disableErc20}
- account={account}
- chainsToSelect={chainsToSelect}
- onSelectChain={onSelectChain}
- areTokensLoading={areTokensLoading}
- tokenListTags={tokenListTags}
- areTokensFromBridge={areTokensFromBridge}
- isRouteAvailable={isRouteAvailable}
- onClearRecentTokens={clearRecentTokens}
- selectedTargetChainId={selectedTargetChainId}
- />
- )
- })()}
+ {getSelectTokenWidgetContent({
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ displayLpTokenLists,
+ unsupportedTokens,
+ selectedToken,
+ allTokens,
+ favoriteTokens,
+ recentTokens,
+ balancesState,
+ permitCompatibleTokens,
+ onSelectToken,
+ handleTokenListItemClick,
+ onInputPressEnter,
+ onDismiss,
+ setIsManageWidgetOpen,
+ resetTokenImport,
+ importTokenAndClose,
+ closePoolPage,
+ importListAndBack,
+ isInjectedWidgetMode,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ chainsToSelect,
+ onSelectChain,
+ areTokensLoading,
+ tokenListTags,
+ areTokensFromBridge,
+ isRouteAvailable,
+ clearRecentTokens,
+ selectedTargetChainId,
+ allTokenLists,
+ userAddedTokens,
+ })}
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
new file mode 100644
index 00000000000..35578288c2a
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
@@ -0,0 +1,115 @@
+import { BalancesState } from '@cowprotocol/balances-and-allowances'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import {
+ ListState,
+ TokenListCategory,
+ TokenListTags,
+ UnsupportedTokensState,
+ useAllListsList,
+} from '@cowprotocol/tokens'
+import { Currency } from '@uniswap/sdk-core'
+
+import { Nullish } from 'types'
+
+import { PermitCompatibleTokens } from 'modules/permit'
+
+import { ChainsToSelectState } from '../../types'
+
+export interface GetSelectTokenWidgetContentProps {
+ standalone?: boolean
+ tokenToImport?: TokenWithLogo
+ listToImport?: ListState
+ isManageWidgetOpen: boolean
+ selectedPoolAddress?: string
+ displayLpTokenLists?: boolean
+ unsupportedTokens: UnsupportedTokensState
+ selectedToken?: Nullish
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ balancesState: BalancesState
+ permitCompatibleTokens: PermitCompatibleTokens
+ onSelectToken: (token: TokenWithLogo) => void
+ handleTokenListItemClick: (token: TokenWithLogo) => void
+ onInputPressEnter?: () => void
+ onDismiss: () => void
+ setIsManageWidgetOpen: (open: boolean) => void
+ resetTokenImport: () => void
+ importTokenAndClose: (tokens: TokenWithLogo[]) => void
+ closePoolPage: () => void
+ importListAndBack: (list: ListState) => void
+ isInjectedWidgetMode: boolean
+ openPoolPage: (poolAddress: string) => void
+ tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void]
+ disableErc20: boolean
+ account: string | undefined
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain: (chain: ChainInfo) => void
+ areTokensLoading: boolean
+ tokenListTags: TokenListTags
+ areTokensFromBridge: boolean
+ isRouteAvailable: boolean | undefined
+ clearRecentTokens: () => void
+ selectedTargetChainId?: number
+ allTokenLists: ReturnType
+ userAddedTokens: TokenWithLogo[]
+}
+
+export interface RenderImportTokenModalProps {
+ tokenToImport: TokenWithLogo
+ onDismiss: () => void
+ resetTokenImport: () => void
+ importTokenAndClose: (tokens: TokenWithLogo[]) => void
+}
+
+export interface RenderImportListModalProps {
+ listToImport: ListState
+ onDismiss: () => void
+ resetTokenImport: () => void
+ importListAndBack: (list: ListState) => void
+}
+
+export interface RenderManageListsAndTokensProps {
+ allTokenLists: ReturnType
+ userAddedTokens: TokenWithLogo[]
+ onDismiss: () => void
+ setIsManageWidgetOpen: (open: boolean) => void
+}
+
+export interface RenderLpTokenPageProps {
+ selectedPoolAddress: string
+ onDismiss: () => void
+ closePoolPage: () => void
+ onSelectToken: (token: TokenWithLogo) => void
+}
+
+export interface RenderSelectTokenModalProps {
+ standalone?: boolean
+ displayLpTokenLists?: boolean
+ unsupportedTokens: UnsupportedTokensState
+ selectedToken?: Nullish
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ balancesState: BalancesState
+ permitCompatibleTokens: PermitCompatibleTokens
+ onSelectToken: (token: TokenWithLogo) => void
+ handleTokenListItemClick: (token: TokenWithLogo) => void
+ onInputPressEnter?: () => void
+ onDismiss: () => void
+ setIsManageWidgetOpen: (open: boolean) => void
+ isInjectedWidgetMode: boolean
+ openPoolPage: (poolAddress: string) => void
+ tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void]
+ disableErc20: boolean
+ account: string | undefined
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain: (chain: ChainInfo) => void
+ areTokensLoading: boolean
+ tokenListTags: TokenListTags
+ areTokensFromBridge: boolean
+ isRouteAvailable: boolean | undefined
+ clearRecentTokens: () => void
+ selectedTargetChainId?: number
+}
From b34da00edd2064fc63ff448a0de86e5666cc348d Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 12:00:04 +0000
Subject: [PATCH 058/100] feat(token-selector): implement SelectTokenWidget
helper functions and types
---
.../containers/SelectTokenWidget/helpers.tsx | 219 ++++++++++++++++++
.../containers/SelectTokenWidget/index.tsx | 129 ++++-------
.../containers/SelectTokenWidget/types.ts | 119 ++++++++++
3 files changed, 380 insertions(+), 87 deletions(-)
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
new file mode 100644
index 00000000000..acf335ac6b5
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
@@ -0,0 +1,219 @@
+import { ReactNode } from 'react'
+
+import { TokenWithLogo } from '@cowprotocol/common-const'
+
+import {
+ GetSelectTokenWidgetContentProps,
+ RenderImportListModalProps,
+ RenderImportTokenModalProps,
+ RenderLpTokenPageProps,
+ RenderManageListsAndTokensProps,
+ RenderSelectTokenModalProps,
+} from './types'
+
+import { ImportListModal } from '../../pure/ImportListModal'
+import { ImportTokenModal } from '../../pure/ImportTokenModal'
+import { SelectTokenModal } from '../../pure/SelectTokenModal'
+import { LpTokenPage } from '../LpTokenPage'
+import { ManageListsAndTokens } from '../ManageListsAndTokens'
+
+const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
+
+function renderImportTokenModal({
+ tokenToImport,
+ onDismiss,
+ resetTokenImport,
+ importTokenAndClose,
+}: RenderImportTokenModalProps): ReactNode {
+ return (
+
+ )
+}
+
+function renderImportListModal({
+ listToImport,
+ onDismiss,
+ resetTokenImport,
+ importListAndBack,
+}: RenderImportListModalProps): ReactNode {
+ return (
+
+ )
+}
+
+function renderManageListsAndTokens({
+ allTokenLists,
+ userAddedTokens,
+ onDismiss,
+ setIsManageWidgetOpen,
+}: RenderManageListsAndTokensProps): ReactNode {
+ return (
+ setIsManageWidgetOpen(false)}
+ />
+ )
+}
+
+function renderLpTokenPage({
+ selectedPoolAddress,
+ onDismiss,
+ closePoolPage,
+ onSelectToken,
+}: RenderLpTokenPageProps): ReactNode {
+ return (
+
+ )
+}
+
+function renderSelectTokenModal(props: RenderSelectTokenModalProps): ReactNode {
+ const {
+ standalone,
+ displayLpTokenLists,
+ unsupportedTokens,
+ selectedToken,
+ allTokens,
+ favoriteTokens,
+ recentTokens,
+ balancesState,
+ permitCompatibleTokens,
+ onSelectToken,
+ handleTokenListItemClick,
+ onInputPressEnter,
+ onDismiss,
+ setIsManageWidgetOpen,
+ isInjectedWidgetMode,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ chainsToSelect,
+ onSelectChain,
+ areTokensLoading,
+ tokenListTags,
+ areTokensFromBridge,
+ isRouteAvailable,
+ clearRecentTokens,
+ selectedTargetChainId,
+ hasChainPanel,
+ chainsPanelTitle,
+ } = props
+
+ return (
+ setIsManageWidgetOpen(true)}
+ hideFavoriteTokensTooltip={isInjectedWidgetMode}
+ openPoolPage={openPoolPage}
+ tokenListCategoryState={tokenListCategoryState}
+ disableErc20={disableErc20}
+ account={account}
+ chainsToSelect={chainsToSelect}
+ hasChainPanel={hasChainPanel}
+ chainsPanelTitle={chainsPanelTitle}
+ onSelectChain={onSelectChain}
+ areTokensLoading={areTokensLoading}
+ tokenListTags={tokenListTags}
+ areTokensFromBridge={areTokensFromBridge}
+ isRouteAvailable={isRouteAvailable}
+ onClearRecentTokens={clearRecentTokens}
+ selectedTargetChainId={selectedTargetChainId}
+ />
+ )
+}
+
+export function getSelectTokenWidgetContent(props: GetSelectTokenWidgetContentProps): ReactNode {
+ const { standalone, tokenToImport, listToImport, isManageWidgetOpen, selectedPoolAddress } = props
+
+ if (tokenToImport && !standalone) {
+ return renderImportTokenModal({
+ tokenToImport,
+ onDismiss: props.onDismiss,
+ resetTokenImport: props.resetTokenImport,
+ importTokenAndClose: props.importTokenAndClose,
+ })
+ }
+
+ if (listToImport && !standalone) {
+ return renderImportListModal({
+ listToImport,
+ onDismiss: props.onDismiss,
+ resetTokenImport: props.resetTokenImport,
+ importListAndBack: props.importListAndBack,
+ })
+ }
+
+ if (isManageWidgetOpen && !standalone) {
+ return renderManageListsAndTokens({
+ allTokenLists: props.allTokenLists,
+ userAddedTokens: props.userAddedTokens,
+ onDismiss: props.onDismiss,
+ setIsManageWidgetOpen: props.setIsManageWidgetOpen,
+ })
+ }
+
+ if (selectedPoolAddress) {
+ return renderLpTokenPage({
+ selectedPoolAddress,
+ onDismiss: props.onDismiss,
+ closePoolPage: props.closePoolPage,
+ onSelectToken: props.onSelectToken,
+ })
+ }
+
+ return renderSelectTokenModal({
+ standalone: props.standalone,
+ displayLpTokenLists: props.displayLpTokenLists,
+ unsupportedTokens: props.unsupportedTokens,
+ selectedToken: props.selectedToken,
+ allTokens: props.allTokens,
+ favoriteTokens: props.favoriteTokens,
+ recentTokens: props.recentTokens,
+ balancesState: props.balancesState,
+ permitCompatibleTokens: props.permitCompatibleTokens,
+ onSelectToken: props.onSelectToken,
+ handleTokenListItemClick: props.handleTokenListItemClick,
+ onInputPressEnter: props.onInputPressEnter,
+ onDismiss: props.onDismiss,
+ setIsManageWidgetOpen: props.setIsManageWidgetOpen,
+ isInjectedWidgetMode: props.isInjectedWidgetMode,
+ openPoolPage: props.openPoolPage,
+ tokenListCategoryState: props.tokenListCategoryState,
+ disableErc20: props.disableErc20,
+ account: props.account,
+ chainsToSelect: props.chainsToSelect,
+ onSelectChain: props.onSelectChain,
+ areTokensLoading: props.areTokensLoading,
+ tokenListTags: props.tokenListTags,
+ areTokensFromBridge: props.areTokensFromBridge,
+ isRouteAvailable: props.isRouteAvailable,
+ clearRecentTokens: props.clearRecentTokens,
+ selectedTargetChainId: props.selectedTargetChainId,
+ hasChainPanel: props.hasChainPanel,
+ chainsPanelTitle: props.chainsPanelTitle,
+ })
+}
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 903ec8998f7..c6e1f155139 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -25,10 +25,10 @@ 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 { getSelectTokenWidgetContent } from './helpers'
import { useChainsToSelect } from '../../hooks/useChainsToSelect'
import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
@@ -38,11 +38,6 @@ import { useRecentTokens } from '../../hooks/useRecentTokens'
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
import { useTokensToSelect } from '../../hooks/useTokensToSelect'
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
-import { ImportListModal } from '../../pure/ImportListModal'
-import { ImportTokenModal } from '../../pure/ImportTokenModal'
-import { SelectTokenModal } from '../../pure/SelectTokenModal'
-import { LpTokenPage } from '../LpTokenPage'
-import { ManageListsAndTokens } from '../ManageListsAndTokens'
const Wrapper = styled.div`
width: 100%;
@@ -53,8 +48,6 @@ const Wrapper = styled.div`
}
`
-const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
-
interface SelectTokenWidgetProps {
displayLpTokenLists?: boolean
standalone?: boolean
@@ -189,85 +182,47 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
return (
- {(() => {
- if (tokenToImport && !standalone) {
- return (
-
- )
- }
-
- if (listToImport && !standalone) {
- return (
-
- )
- }
-
- if (isManageWidgetOpen && !standalone) {
- return (
- setIsManageWidgetOpen(false)}
- />
- )
- }
-
- if (selectedPoolAddress) {
- return (
-
- )
- }
-
- return (
- setIsManageWidgetOpen(true)}
- hideFavoriteTokensTooltip={isInjectedWidgetMode}
- openPoolPage={openPoolPage}
- tokenListCategoryState={tokenListCategoryState}
- disableErc20={disableErc20}
- account={account}
- chainsToSelect={chainsToSelect}
- hasChainPanel={showChainPanel}
- chainsPanelTitle={chainsPanelTitle}
- onSelectChain={onSelectChain}
- areTokensLoading={areTokensLoading}
- tokenListTags={tokenListTags}
- areTokensFromBridge={areTokensFromBridge}
- isRouteAvailable={isRouteAvailable}
- onClearRecentTokens={clearRecentTokens}
- selectedTargetChainId={selectedTargetChainId}
- />
- )
- })()}
+ {getSelectTokenWidgetContent({
+ standalone,
+ tokenToImport,
+ listToImport,
+ isManageWidgetOpen,
+ selectedPoolAddress,
+ displayLpTokenLists,
+ unsupportedTokens,
+ selectedToken,
+ allTokens,
+ favoriteTokens,
+ recentTokens,
+ balancesState,
+ permitCompatibleTokens,
+ onSelectToken,
+ handleTokenListItemClick,
+ onInputPressEnter,
+ onDismiss,
+ setIsManageWidgetOpen,
+ resetTokenImport,
+ importTokenAndClose,
+ closePoolPage,
+ importListAndBack,
+ isInjectedWidgetMode,
+ openPoolPage,
+ tokenListCategoryState,
+ disableErc20,
+ account,
+ chainsToSelect,
+ onSelectChain,
+ areTokensLoading,
+ tokenListTags,
+ areTokensFromBridge,
+ isRouteAvailable,
+ clearRecentTokens,
+ selectedTargetChainId,
+ allTokenLists,
+ userAddedTokens,
+ hasChainPanel: showChainPanel,
+ chainsPanelTitle,
+ })}
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
new file mode 100644
index 00000000000..0ce617f09d5
--- /dev/null
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
@@ -0,0 +1,119 @@
+import { BalancesState } from '@cowprotocol/balances-and-allowances'
+import { TokenWithLogo } from '@cowprotocol/common-const'
+import { ChainInfo } from '@cowprotocol/cow-sdk'
+import {
+ ListState,
+ TokenListCategory,
+ TokenListTags,
+ UnsupportedTokensState,
+ useAllListsList,
+} from '@cowprotocol/tokens'
+import { Currency } from '@uniswap/sdk-core'
+
+import { Nullish } from 'types'
+
+import { PermitCompatibleTokens } from 'modules/permit'
+
+import { ChainsToSelectState } from '../../types'
+
+export interface GetSelectTokenWidgetContentProps {
+ standalone?: boolean
+ tokenToImport?: TokenWithLogo
+ listToImport?: ListState
+ isManageWidgetOpen: boolean
+ selectedPoolAddress?: string
+ displayLpTokenLists?: boolean
+ unsupportedTokens: UnsupportedTokensState
+ selectedToken?: Nullish
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ balancesState: BalancesState
+ permitCompatibleTokens: PermitCompatibleTokens
+ onSelectToken: (token: TokenWithLogo) => void
+ handleTokenListItemClick: (token: TokenWithLogo) => void
+ onInputPressEnter?: () => void
+ onDismiss: () => void
+ setIsManageWidgetOpen: (open: boolean) => void
+ resetTokenImport: () => void
+ importTokenAndClose: (tokens: TokenWithLogo[]) => void
+ closePoolPage: () => void
+ importListAndBack: (list: ListState) => void
+ isInjectedWidgetMode: boolean
+ openPoolPage: (poolAddress: string) => void
+ tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void]
+ disableErc20: boolean
+ account: string | undefined
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain: (chain: ChainInfo) => void
+ areTokensLoading: boolean
+ tokenListTags: TokenListTags
+ areTokensFromBridge: boolean
+ isRouteAvailable: boolean | undefined
+ clearRecentTokens: () => void
+ selectedTargetChainId?: number
+ allTokenLists: ReturnType
+ userAddedTokens: TokenWithLogo[]
+ hasChainPanel?: boolean
+ chainsPanelTitle?: string
+}
+
+export interface RenderImportTokenModalProps {
+ tokenToImport: TokenWithLogo
+ onDismiss: () => void
+ resetTokenImport: () => void
+ importTokenAndClose: (tokens: TokenWithLogo[]) => void
+}
+
+export interface RenderImportListModalProps {
+ listToImport: ListState
+ onDismiss: () => void
+ resetTokenImport: () => void
+ importListAndBack: (list: ListState) => void
+}
+
+export interface RenderManageListsAndTokensProps {
+ allTokenLists: ReturnType
+ userAddedTokens: TokenWithLogo[]
+ onDismiss: () => void
+ setIsManageWidgetOpen: (open: boolean) => void
+}
+
+export interface RenderLpTokenPageProps {
+ selectedPoolAddress: string
+ onDismiss: () => void
+ closePoolPage: () => void
+ onSelectToken: (token: TokenWithLogo) => void
+}
+
+export interface RenderSelectTokenModalProps {
+ standalone?: boolean
+ displayLpTokenLists?: boolean
+ unsupportedTokens: UnsupportedTokensState
+ selectedToken?: Nullish
+ allTokens: TokenWithLogo[]
+ favoriteTokens: TokenWithLogo[]
+ recentTokens?: TokenWithLogo[]
+ balancesState: BalancesState
+ permitCompatibleTokens: PermitCompatibleTokens
+ onSelectToken: (token: TokenWithLogo) => void
+ handleTokenListItemClick: (token: TokenWithLogo) => void
+ onInputPressEnter?: () => void
+ onDismiss: () => void
+ setIsManageWidgetOpen: (open: boolean) => void
+ isInjectedWidgetMode: boolean
+ openPoolPage: (poolAddress: string) => void
+ tokenListCategoryState: [TokenListCategory[] | null, (category: TokenListCategory[] | null) => void]
+ disableErc20: boolean
+ account: string | undefined
+ chainsToSelect: ChainsToSelectState | undefined
+ onSelectChain: (chain: ChainInfo) => void
+ areTokensLoading: boolean
+ tokenListTags: TokenListTags
+ areTokensFromBridge: boolean
+ isRouteAvailable: boolean | undefined
+ clearRecentTokens: () => void
+ selectedTargetChainId?: number
+ hasChainPanel?: boolean
+ chainsPanelTitle?: string
+}
From d77a522c53e29e9d66079bfca8fe40416e789d2b Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 15:42:33 +0000
Subject: [PATCH 059/100] fix: revert token list url
---
apps/cow-fi/services/tokens/index.ts | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/cow-fi/services/tokens/index.ts b/apps/cow-fi/services/tokens/index.ts
index e451439c610..c224613ca93 100644
--- a/apps/cow-fi/services/tokens/index.ts
+++ b/apps/cow-fi/services/tokens/index.ts
@@ -8,11 +8,12 @@ import path from 'path'
import { DATA_CACHE_TIME_SECONDS } from '@/const/meta'
import { Network } from '@/const/networkMap'
import { PlatformData, Platforms, TokenDetails, TokenInfo } from 'types'
+import { COW_CDN } from '@cowprotocol/common-const'
const NETWORKS: Network[] = ['ethereum', 'base', 'arbitrum-one', 'avalanche', 'polygon-pos', 'xdai']
const COW_TOKEN_ID = 'cow-protocol'
-const TOKEN_LISTS_URL = 'https://files.cow.fi/tokens/cowFi-tokens.json'
+const TOKEN_LISTS_URL = `${COW_CDN}/tokens/cowFi-tokens.json`
const DESCRIPTIONS_DIR_PATH = path.join(process.cwd(), 'data', 'descriptions')
/**
From 05327ab9e4e7962f50b4d8cca8f3a1edf6ff5d6e Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 16:33:52 +0000
Subject: [PATCH 060/100] feat: enhance localization support in ChainPanel and
SelectTokenModal components
---
.../src/modules/tokensList/pure/ChainPanel/index.tsx | 7 ++++++-
.../pure/SelectTokenModal/MobileChainSelector.tsx | 11 ++++++++---
.../tokensList/pure/SelectTokenModal/helpers.tsx | 6 ++++--
3 files changed, 18 insertions(+), 6 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 bc4ff079993..cfb2395429a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -4,6 +4,7 @@ import { ChainInfo } from '@cowprotocol/cow-sdk'
import { BackButton } from '@cowprotocol/ui'
import { t } from '@lingui/core/macro'
+import { Trans } from '@lingui/react/macro'
import * as styledEl from './styled'
@@ -62,7 +63,11 @@ export function ChainPanel({
onSelectChain={onSelectChain}
/>
{showUnavailableState && {t`No networks available for this trade.`}}
- {showSearchEmptyState && {t`No networks match "${chainQuery}".`}}
+ {showSearchEmptyState && (
+
+ No networks match {chainQuery}.
+
+ )}
)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
index bae7db6e408..31d8a2f9138 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
@@ -3,6 +3,8 @@ import { ReactNode, useEffect, useMemo, useRef } from 'react'
import { useTheme } from '@cowprotocol/common-hooks'
import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { msg } from '@lingui/core/macro'
+import { Trans, useLingui } from '@lingui/react/macro'
import { ChevronDown } from 'react-feather'
import * as styledEl from './mobileChainSelector.styled'
@@ -24,6 +26,7 @@ export function MobileChainSelector({
onSelectChain,
onOpenPanel,
}: MobileChainSelectorProps): ReactNode {
+ const { i18n } = useLingui()
const scrollRef = useRef(null)
const orderedChains = useMemo(
() =>
@@ -51,7 +54,7 @@ export function MobileChainSelector({
{label}
{activeChainLabel ? (
-
+
{activeChainLabel}
) : null}
@@ -72,8 +75,10 @@ export function MobileChainSelector({
) : null}
{totalChains > 0 ? (
-
- View all ({totalChains})
+
+
+ View all ({totalChains})
+
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 8793810e452..9040feb7968 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
@@ -2,6 +2,8 @@ import { ReactNode, useMemo, useState } from 'react'
import { BackButton } from '@cowprotocol/ui'
+import { t } from '@lingui/core/macro'
+
import { SettingsIcon } from 'modules/trade/pure/Settings'
import * as styledEl from './styled'
@@ -124,8 +126,8 @@ export function TitleBarActions({
From 655226ababaf3c1dffeabd10365416c821d8a9f4 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 16:34:08 +0000
Subject: [PATCH 061/100] fix: update ManageListsAndTokens component to include
onDismiss prop
---
.../tokensList/containers/SelectTokenWidget/index.tsx | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
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 0557380f8fc..662d118483a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -192,7 +192,14 @@ function getBlockingView(
}
if (isManageWidgetOpen && !standalone) {
- return
+ return (
+
+ )
}
if (selectedPoolAddress) {
From 4e22948ae18d35f36f9a3778ae7d18e1d920575c Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 17:43:27 +0000
Subject: [PATCH 062/100] fix: correct Russian plural form for 'View all
{totalChains} networks'
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix Russian translation to use genitive plural 'сетей' instead of 'сети'
- Add i18n support for modal titles in controllerState.ts
- Add Spanish translations for new token selector strings
---
apps/cowswap-frontend/src/locales/es-ES.po | 64 +++++++++++++++++++
apps/cowswap-frontend/src/locales/ru-RU.po | 64 +++++++++++++++++++
.../SelectTokenWidget/controllerState.ts | 6 +-
3 files changed, 131 insertions(+), 3 deletions(-)
diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po
index 9a8c0a3f783..af9e5001561 100644
--- a/apps/cowswap-frontend/src/locales/es-ES.po
+++ b/apps/cowswap-frontend/src/locales/es-ES.po
@@ -6294,3 +6294,67 @@ msgstr "Configuración de alertas de trading"
msgid "View jobs (opens in a new tab)"
msgstr "Ver trabajos (se abre en una pestaña nueva)"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "Search network"
+msgstr "Buscar red"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Select token"
+msgstr "Seleccionar token"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "From network"
+msgstr "Red de origen"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "To network"
+msgstr "Red de destino"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Select network"
+msgstr "Seleccionar red"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+msgid "Cross chain swap"
+msgstr "Intercambio entre cadenas"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Swap from"
+msgstr "Intercambiar desde"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Swap to"
+msgstr "Intercambiar a"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Sell token"
+msgstr "Vender token"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Buy token"
+msgstr "Comprar token"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
+msgid "Manage token lists"
+msgstr "Gestionar listas de tokens"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks available for this trade."
+msgstr "No hay redes disponibles para este intercambio."
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks match {chainQuery}."
+msgstr "No hay redes que coincidan con {chainQuery}."
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "View all ({totalChains})"
+msgstr "Ver todas ({totalChains})"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "View all {totalChains} networks"
+msgstr "Ver todas las {totalChains} redes"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "Selected network {activeChainLabel}"
+msgstr "Red seleccionada {activeChainLabel}"
diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po
index e10761044ca..1b6c84a7137 100644
--- a/apps/cowswap-frontend/src/locales/ru-RU.po
+++ b/apps/cowswap-frontend/src/locales/ru-RU.po
@@ -6294,3 +6294,67 @@ msgstr "Настройки уведомлений о сделках"
msgid "View jobs (opens in a new tab)"
msgstr "Просмотр вакансий (откроется в новой вкладке)"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "Search network"
+msgstr "Поиск сети"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Select token"
+msgstr "Выбрать токен"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "From network"
+msgstr "Исходная сеть"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "To network"
+msgstr "Целевая сеть"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Select network"
+msgstr "Выбрать сеть"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
+msgid "Cross chain swap"
+msgstr "Обмен между сетями"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Swap from"
+msgstr "Обменять из"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Swap to"
+msgstr "Обменять на"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Sell token"
+msgstr "Продать токен"
+
+#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+msgid "Buy token"
+msgstr "Купить токен"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx
+msgid "Manage token lists"
+msgstr "Управление списками токенов"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks available for this trade."
+msgstr "Нет доступных сетей для этой сделки."
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+msgid "No networks match {chainQuery}."
+msgstr "Нет сетей, соответствующих {chainQuery}."
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "View all ({totalChains})"
+msgstr "Показать все ({totalChains})"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "View all {totalChains} networks"
+msgstr "Показать все {totalChains} сетей"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+msgid "Selected network {activeChainLabel}"
+msgstr "Выбранная сеть {activeChainLabel}"
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 4a600002a5f..fd62f75f7b5 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
@@ -159,14 +159,14 @@ function resolveModalTitle(field: Field, tradeType: TradeType | undefined): stri
const isSwapTrade = !tradeType || tradeType === TradeType.SWAP
if (field === Field.INPUT) {
- return isSwapTrade ? 'Swap from' : 'Sell token'
+ return isSwapTrade ? t`Swap from` : t`Sell token`
}
if (field === Field.OUTPUT) {
- return isSwapTrade ? 'Swap to' : 'Buy token'
+ return isSwapTrade ? t`Swap to` : t`Buy token`
}
- return 'Select token'
+ return t`Select token`
}
export function useDismissHandler(
From 3ac256bc2988dc3ef24f834b884042e32f869ddb Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 17:54:08 +0000
Subject: [PATCH 063/100] fix: update Spanish and Russian translations for
cross-chain swap terminology
---
apps/cowswap-frontend/src/locales/es-ES.po | 8 ++++----
apps/cowswap-frontend/src/locales/ru-RU.po | 4 ++--
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po
index af9e5001561..e409add3cf6 100644
--- a/apps/cowswap-frontend/src/locales/es-ES.po
+++ b/apps/cowswap-frontend/src/locales/es-ES.po
@@ -5198,7 +5198,7 @@ msgstr "parte"
#: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx
msgid "Cross-chain swaps are here"
-msgstr "Los swaps de cadena media están aquí"
+msgstr "Los swaps entre cadenas están aquí"
#: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx
#~ msgid "Approval amount:"
@@ -6317,15 +6317,15 @@ msgstr "Seleccionar red"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
msgid "Cross chain swap"
-msgstr "Intercambio entre cadenas"
+msgstr "Swap entre cadenas"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Swap from"
-msgstr "Intercambiar desde"
+msgstr "Swap desde"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Swap to"
-msgstr "Intercambiar a"
+msgstr "Swap a"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Sell token"
diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po
index 1b6c84a7137..ed4e6c0e990 100644
--- a/apps/cowswap-frontend/src/locales/ru-RU.po
+++ b/apps/cowswap-frontend/src/locales/ru-RU.po
@@ -5198,7 +5198,7 @@ msgstr "часть"
#: apps/cowswap-frontend/src/modules/swap/pure/CrossChainUnlockScreen/index.tsx
msgid "Cross-chain swaps are here"
-msgstr "Перекрестные цепочки здесь"
+msgstr "Межсетевые обмены здесь"
#: apps/cowswap-frontend/src/modules/erc20Approve/containers/ApprovalAmountInput/ApprovalAmountInput.tsx
#~ msgid "Approval amount:"
@@ -6321,7 +6321,7 @@ msgstr "Обмен между сетями"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Swap from"
-msgstr "Обменять из"
+msgstr "Обменять с"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Swap to"
From d4f74609af1288bba5b7d8c51f51b95ed34cf873 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 18:06:27 +0000
Subject: [PATCH 064/100] fix: refine Russian translations for cross-chain swap
terminology
---
apps/cowswap-frontend/src/locales/ru-RU.po | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po
index ed4e6c0e990..228953869aa 100644
--- a/apps/cowswap-frontend/src/locales/ru-RU.po
+++ b/apps/cowswap-frontend/src/locales/ru-RU.po
@@ -6317,15 +6317,15 @@ msgstr "Выбрать сеть"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx
msgid "Cross chain swap"
-msgstr "Обмен между сетями"
+msgstr "Свап между сетями"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Swap from"
-msgstr "Обменять с"
+msgstr "Свап из сети"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Swap to"
-msgstr "Обменять на"
+msgstr "Свап в сеть"
#: apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts
msgid "Sell token"
@@ -6353,7 +6353,7 @@ msgstr "Показать все ({totalChains})"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
msgid "View all {totalChains} networks"
-msgstr "Показать все {totalChains} сетей"
+msgstr "Показать все сети ({totalChains})"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
msgid "Selected network {activeChainLabel}"
From 50c46c31b93b28286fca68d76b322046e21eafe1 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 18:08:34 +0000
Subject: [PATCH 065/100] fix: add Spanish and Russian translations for
'Recent' in TokensVirtualList component
---
apps/cowswap-frontend/src/locales/es-ES.po | 4 ++++
apps/cowswap-frontend/src/locales/ru-RU.po | 4 ++++
.../tokensList/pure/TokensVirtualList/index.tsx | 14 +++++---------
3 files changed, 13 insertions(+), 9 deletions(-)
diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po
index e409add3cf6..178864423fb 100644
--- a/apps/cowswap-frontend/src/locales/es-ES.po
+++ b/apps/cowswap-frontend/src/locales/es-ES.po
@@ -4199,6 +4199,10 @@ msgstr "Habilitar aprobación parcial"
msgid "Version"
msgstr "Versión"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+msgid "Recent"
+msgstr "Recientes"
+
#: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx
msgid "All tokens"
msgstr "Todos los tokens"
diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po
index 228953869aa..e97ddbb262d 100644
--- a/apps/cowswap-frontend/src/locales/ru-RU.po
+++ b/apps/cowswap-frontend/src/locales/ru-RU.po
@@ -4199,6 +4199,10 @@ msgstr "Включить частичные утверждения"
msgid "Version"
msgstr "Версии"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+msgid "Recent"
+msgstr "Недавние"
+
#: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx
msgid "All tokens"
msgstr "Все токены"
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 a4f65bfbaaf..8555d2a8407 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -4,6 +4,7 @@ import { TokenWithLogo } from '@cowprotocol/common-const'
import { useFeatureFlags } from '@cowprotocol/common-hooks'
import { getIsNativeToken } from '@cowprotocol/common-utils'
+import { t } from '@lingui/core/macro'
import { VirtualItem } from '@tanstack/react-virtual'
import { CoWAmmBanner } from 'common/containers/CoWAmmBanner'
@@ -63,8 +64,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
}
}
- const sortedPrioritized =
- prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized
+ const sortedPrioritized = prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized
return [...sortedPrioritized, ...remainder]
}, [allTokens, balances])
@@ -84,7 +84,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
if (recentTokens?.length) {
composedRows.push({
type: 'title',
- label: 'Recent',
+ label: t`Recent`,
actionLabel: onClearRecentTokens ? 'Clear' : undefined,
onAction: onClearRecentTokens,
})
@@ -92,7 +92,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
}
if (favoriteTokens?.length || recentTokens?.length) {
- composedRows.push({ type: 'title', label: 'All tokens' })
+ composedRows.push({ type: 'title', label: t`All tokens` })
}
return [...composedRows, ...tokenRows]
@@ -129,11 +129,7 @@ function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowR
switch (row.type) {
case 'favorite-section':
return (
-
+
)
case 'title':
return (
From 9a01bf31a97dabef54c6cb99c4675fa623e5b64a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Thu, 4 Dec 2025 18:10:09 +0000
Subject: [PATCH 066/100] fix: add Spanish and Russian translations for 'Clear'
in TokensVirtualList component
---
apps/cowswap-frontend/src/locales/es-ES.po | 4 ++++
apps/cowswap-frontend/src/locales/ru-RU.po | 4 ++++
.../src/modules/tokensList/pure/TokensVirtualList/index.tsx | 2 +-
3 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po
index 178864423fb..34a64f8fe71 100644
--- a/apps/cowswap-frontend/src/locales/es-ES.po
+++ b/apps/cowswap-frontend/src/locales/es-ES.po
@@ -4203,6 +4203,10 @@ msgstr "Versión"
msgid "Recent"
msgstr "Recientes"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+msgid "Clear"
+msgstr "Borrar"
+
#: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx
msgid "All tokens"
msgstr "Todos los tokens"
diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po
index e97ddbb262d..f1e4fba8a4e 100644
--- a/apps/cowswap-frontend/src/locales/ru-RU.po
+++ b/apps/cowswap-frontend/src/locales/ru-RU.po
@@ -4203,6 +4203,10 @@ msgstr "Версии"
msgid "Recent"
msgstr "Недавние"
+#: apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+msgid "Clear"
+msgstr "Очистить"
+
#: apps/cowswap-frontend/src/pages/Account/Tokens/TokensOverview.tsx
msgid "All tokens"
msgstr "Все токены"
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 8555d2a8407..67ce39cf294 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -85,7 +85,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
composedRows.push({
type: 'title',
label: t`Recent`,
- actionLabel: onClearRecentTokens ? 'Clear' : undefined,
+ actionLabel: onClearRecentTokens ? t`Clear` : undefined,
onAction: onClearRecentTokens,
})
recentTokens.forEach((token) => composedRows.push({ type: 'token', token }))
From 6af13056a9f318b5a8aa70f4634f07c6d978db8c Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 08:16:17 +0000
Subject: [PATCH 067/100] refactor: rename and modularize SelectTokenWidget
helper functions
---
.../containers/SelectTokenWidget/helpers.tsx | 148 ++++++++++--------
.../containers/SelectTokenWidget/index.tsx | 80 +++++-----
.../containers/SelectTokenWidget/types.ts | 12 +-
.../pure/TokensVirtualList/index.tsx | 13 +-
4 files changed, 129 insertions(+), 124 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
index 286c81e2cb2..6e46890d05d 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/helpers.tsx
@@ -3,12 +3,12 @@ import { ReactNode } from 'react'
import { TokenWithLogo } from '@cowprotocol/common-const'
import {
- GetSelectTokenWidgetContentProps,
- RenderImportListModalProps,
- RenderImportTokenModalProps,
- RenderLpTokenPageProps,
- RenderManageListsAndTokensProps,
- RenderSelectTokenModalProps,
+ ImportListModalContentProps,
+ ImportTokenModalContentProps,
+ LpTokenPageContentProps,
+ ManageListsAndTokensContentProps,
+ SelectTokenModalContentProps,
+ SelectTokenWidgetContentProps,
} from './types'
import { ImportListModal } from '../../pure/ImportListModal'
@@ -19,12 +19,12 @@ import { ManageListsAndTokens } from '../ManageListsAndTokens'
const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
-function renderImportTokenModal({
+export function ImportTokenModalContent({
tokenToImport,
onDismiss,
resetTokenImport,
importTokenAndClose,
-}: RenderImportTokenModalProps): ReactNode {
+}: ImportTokenModalContentProps): ReactNode {
return (
)
}
-function renderManageListsAndTokens({
+export function ManageListsAndTokensContent({
allTokenLists,
userAddedTokens,
onDismiss,
setIsManageWidgetOpen,
-}: RenderManageListsAndTokensProps): ReactNode {
+}: ManageListsAndTokensContentProps): ReactNode {
return (
+ )
}
if (listToImport && !standalone) {
- return renderImportListModal({
- listToImport,
- onDismiss: props.onDismiss,
- resetTokenImport: props.resetTokenImport,
- importListAndBack: props.importListAndBack,
- })
+ return (
+
+ )
}
if (isManageWidgetOpen && !standalone) {
- return renderManageListsAndTokens({
- allTokenLists: props.allTokenLists,
- userAddedTokens: props.userAddedTokens,
- onDismiss: props.onDismiss,
- setIsManageWidgetOpen: props.setIsManageWidgetOpen,
- })
+ return (
+
+ )
}
if (selectedPoolAddress) {
- return renderLpTokenPage({
- selectedPoolAddress,
- onDismiss: props.onDismiss,
- closePoolPage: props.closePoolPage,
- onSelectToken: props.onSelectToken,
- })
+ return (
+
+ )
}
- return renderSelectTokenModal({
- standalone: props.standalone,
- displayLpTokenLists: props.displayLpTokenLists,
- unsupportedTokens: props.unsupportedTokens,
- selectedToken: props.selectedToken,
- allTokens: props.allTokens,
- favoriteTokens: props.favoriteTokens,
- recentTokens: props.recentTokens,
- balancesState: props.balancesState,
- permitCompatibleTokens: props.permitCompatibleTokens,
- onSelectToken: props.onSelectToken,
- handleTokenListItemClick: props.handleTokenListItemClick,
- onInputPressEnter: props.onInputPressEnter,
- onDismiss: props.onDismiss,
- setIsManageWidgetOpen: props.setIsManageWidgetOpen,
- isInjectedWidgetMode: props.isInjectedWidgetMode,
- openPoolPage: props.openPoolPage,
- tokenListCategoryState: props.tokenListCategoryState,
- disableErc20: props.disableErc20,
- account: props.account,
- chainsToSelect: props.chainsToSelect,
- onSelectChain: props.onSelectChain,
- areTokensLoading: props.areTokensLoading,
- tokenListTags: props.tokenListTags,
- areTokensFromBridge: props.areTokensFromBridge,
- isRouteAvailable: props.isRouteAvailable,
- clearRecentTokens: props.clearRecentTokens,
- selectedTargetChainId: props.selectedTargetChainId,
- })
+ return (
+
+ )
}
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 5dcb55fc3eb..34473ae7693 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/index.tsx
@@ -26,7 +26,7 @@ import { useLpTokensWithBalances } from 'modules/yield/shared'
import { CowSwapAnalyticsCategory } from 'common/analytics/types'
import { getDefaultTokenListCategories } from './getDefaultTokenListCategories'
-import { getSelectTokenWidgetContent } from './helpers'
+import { SelectTokenWidgetContent } from './helpers'
import { useChainsToSelect } from '../../hooks/useChainsToSelect'
import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
@@ -176,45 +176,45 @@ export function SelectTokenWidget({ displayLpTokenLists, standalone }: SelectTok
return (
- {getSelectTokenWidgetContent({
- standalone,
- tokenToImport,
- listToImport,
- isManageWidgetOpen,
- selectedPoolAddress,
- displayLpTokenLists,
- unsupportedTokens,
- selectedToken,
- allTokens,
- favoriteTokens,
- recentTokens,
- balancesState,
- permitCompatibleTokens,
- onSelectToken,
- handleTokenListItemClick,
- onInputPressEnter,
- onDismiss,
- setIsManageWidgetOpen,
- resetTokenImport,
- importTokenAndClose,
- closePoolPage,
- importListAndBack,
- isInjectedWidgetMode,
- openPoolPage,
- tokenListCategoryState,
- disableErc20,
- account,
- chainsToSelect,
- onSelectChain,
- areTokensLoading,
- tokenListTags,
- areTokensFromBridge,
- isRouteAvailable,
- clearRecentTokens,
- selectedTargetChainId,
- allTokenLists,
- userAddedTokens,
- })}
+
)
}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
index 35578288c2a..041adebed24 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/types.ts
@@ -16,7 +16,7 @@ import { PermitCompatibleTokens } from 'modules/permit'
import { ChainsToSelectState } from '../../types'
-export interface GetSelectTokenWidgetContentProps {
+export interface SelectTokenWidgetContentProps {
standalone?: boolean
tokenToImport?: TokenWithLogo
listToImport?: ListState
@@ -56,35 +56,35 @@ export interface GetSelectTokenWidgetContentProps {
userAddedTokens: TokenWithLogo[]
}
-export interface RenderImportTokenModalProps {
+export interface ImportTokenModalContentProps {
tokenToImport: TokenWithLogo
onDismiss: () => void
resetTokenImport: () => void
importTokenAndClose: (tokens: TokenWithLogo[]) => void
}
-export interface RenderImportListModalProps {
+export interface ImportListModalContentProps {
listToImport: ListState
onDismiss: () => void
resetTokenImport: () => void
importListAndBack: (list: ListState) => void
}
-export interface RenderManageListsAndTokensProps {
+export interface ManageListsAndTokensContentProps {
allTokenLists: ReturnType
userAddedTokens: TokenWithLogo[]
onDismiss: () => void
setIsManageWidgetOpen: (open: boolean) => void
}
-export interface RenderLpTokenPageProps {
+export interface LpTokenPageContentProps {
selectedPoolAddress: string
onDismiss: () => void
closePoolPage: () => void
onSelectToken: (token: TokenWithLogo) => void
}
-export interface RenderSelectTokenModalProps {
+export interface SelectTokenModalContentProps {
standalone?: boolean
displayLpTokenLists?: boolean
unsupportedTokens: UnsupportedTokensState
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 a4f65bfbaaf..98b09635f8b 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx
@@ -63,8 +63,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
}
}
- const sortedPrioritized =
- prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized
+ const sortedPrioritized = prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized
return [...sortedPrioritized, ...remainder]
}, [allTokens, balances])
@@ -100,7 +99,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
const virtualListKey = scrollResetKey ?? 'tokens-list'
- const renderVirtualRow = useCallback(
+ const getItemView = useCallback(
(virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => (
),
@@ -112,7 +111,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode {
key={virtualListKey}
id="tokens-list"
items={rows}
- getItemView={renderVirtualRow}
+ getItemView={getItemView}
scrollResetKey={scrollResetKey}
>
{displayLpTokenLists || !isYieldEnabled ? null : }
@@ -129,11 +128,7 @@ function TokensVirtualRowRenderer({ row, selectTokenContext }: TokensVirtualRowR
switch (row.type) {
case 'favorite-section':
return (
-
+
)
case 'title':
return (
From 4f175e1f7f13310cfa87ceb183e0a58aa0b367d1 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 08:42:35 +0000
Subject: [PATCH 068/100] fix: conditionally render SelectTokenWidget based on
token selection state
---
.../src/modules/trade/containers/TradeWidget/index.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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 804ecf787b3..363c7990d99 100644
--- a/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx
+++ b/apps/cowswap-frontend/src/modules/trade/containers/TradeWidget/index.tsx
@@ -23,7 +23,7 @@ export function TradeWidget(props: TradeWidgetProps): JSX.Element {
const isTokenSelectWide =
isTokenSelectOpen && !!chainsToSelect && (chainsToSelect.isLoading || (chainsToSelect.chains?.length ?? 0) > 0)
- const selectTokenWidgetNode = slots.selectTokenWidget ??
+ const selectTokenWidgetNode = isTokenSelectOpen ? (slots.selectTokenWidget ?? ) : null
const setShouldUseAutoSlippage = useSetShouldUseAutoSlippage()
From a1a9dcf2a6afb07c220cd8d9ec424668a5a85f1c Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:10:25 +0000
Subject: [PATCH 069/100] fix: update chain selection logic for advanced trade
types to prevent unsupported operations
---
.../modules/tokensList/hooks/useChainsToSelect.ts | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
index f59b315a8d4..13efa115839 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -50,15 +50,15 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
const chainInfo = CHAIN_INFO[chainId]
if (!chainInfo) return undefined
- const currentChainInfo = mapChainInfo(chainId, chainInfo)
- // Limit/TWAP buys must stay on the wallet chain, so skip bridge wiring entirely.
- const shouldForceSingleChain = isAdvancedTradeType && field === Field.OUTPUT
+ // Limit/TWAP orders don't support chain selection - return undefined for both SELL and BUY
+ // These trade types rely on wallet/header network switcher instead
+ if (isAdvancedTradeType) {
+ return undefined
+ }
- if (!isBridgingEnabled && !shouldForceSingleChain) return undefined
+ const currentChainInfo = mapChainInfo(chainId, chainInfo)
- if (shouldForceSingleChain) {
- return createSingleChainState(chainId, currentChainInfo)
- }
+ if (!isBridgingEnabled) return undefined
if (field === Field.INPUT) {
return createInputChainsState(selectedTargetChainId, supportedChains)
From 24c41b74bde60e9dafa3e35a797f32f2eaa7c963 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:10:56 +0000
Subject: [PATCH 070/100] test: enhance useChainsToSelect tests for various
trade types and chain selection scenarios
---
.../hooks/useChainsToSelect.test.ts | 172 +++++++++++++++++-
1 file changed, 171 insertions(+), 1 deletion(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
index d6547eab754..ca2b35606c3 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
@@ -1,9 +1,61 @@
import { SupportedChainId } from '@cowprotocol/cow-sdk'
+import { useWalletInfo, WalletInfo } from '@cowprotocol/wallet'
-import { createInputChainsState, createOutputChainsState } from './useChainsToSelect'
+import { renderHook } from '@testing-library/react'
+import { Field } from 'legacy/state/types'
+
+import { TradeType } from 'modules/trade/types'
+
+import { useChainsToSelect, createInputChainsState, createOutputChainsState } from './useChainsToSelect'
+import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
+
+import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom'
import { createChainInfoForTests } from '../test-utils/createChainInfoForTests'
+jest.mock('@cowprotocol/wallet', () => ({
+ ...jest.requireActual('@cowprotocol/wallet'),
+ useWalletInfo: jest.fn(),
+}))
+
+jest.mock('@cowprotocol/common-hooks', () => ({
+ ...jest.requireActual('@cowprotocol/common-hooks'),
+ useIsBridgingEnabled: jest.fn(),
+ useAvailableChains: jest.fn(),
+ useFeatureFlags: jest.fn(),
+}))
+
+jest.mock('entities/bridgeProvider', () => ({
+ ...jest.requireActual('entities/bridgeProvider'),
+ useBridgeSupportedNetworks: jest.fn(),
+}))
+
+jest.mock('./useSelectTokenWidgetState', () => ({
+ ...jest.requireActual('./useSelectTokenWidgetState'),
+ useSelectTokenWidgetState: jest.fn(),
+}))
+
+const mockUseWalletInfo = useWalletInfo as jest.MockedFunction
+const mockUseSelectTokenWidgetState = useSelectTokenWidgetState as jest.MockedFunction
+
+const { useIsBridgingEnabled, useAvailableChains, useFeatureFlags } = require('@cowprotocol/common-hooks')
+const mockUseIsBridgingEnabled = useIsBridgingEnabled as jest.MockedFunction
+const mockUseAvailableChains = useAvailableChains as jest.MockedFunction
+const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction
+
+const { useBridgeSupportedNetworks } = require('entities/bridgeProvider')
+const mockUseBridgeSupportedNetworks = useBridgeSupportedNetworks as jest.MockedFunction<
+ typeof useBridgeSupportedNetworks
+>
+
+type WidgetState = ReturnType
+const createWidgetState = (override: Partial): WidgetState => {
+ return {
+ ...DEFAULT_SELECT_TOKEN_WIDGET_STATE,
+ ...override,
+ }
+}
+
describe('useChainsToSelect state builders', () => {
it('sorts sell-side chains using the canonical order', () => {
const supportedChains = [
@@ -63,3 +115,121 @@ describe('useChainsToSelect state builders', () => {
expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA])
})
})
+
+describe('useChainsToSelect hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseWalletInfo.mockReturnValue({ chainId: SupportedChainId.MAINNET } as WalletInfo)
+ mockUseIsBridgingEnabled.mockReturnValue(true)
+ mockUseAvailableChains.mockReturnValue([SupportedChainId.MAINNET, SupportedChainId.GNOSIS_CHAIN])
+ mockUseFeatureFlags.mockReturnValue({ areUnsupportedChainsEnabled: false })
+ mockUseBridgeSupportedNetworks.mockReturnValue({
+ data: [createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)],
+ isLoading: false,
+ })
+ })
+
+ it('returns undefined for LIMIT_ORDER + OUTPUT (buy token)', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ field: Field.OUTPUT,
+ tradeType: TradeType.LIMIT_ORDER,
+ selectedTargetChainId: SupportedChainId.MAINNET,
+ }),
+ )
+
+ const { result } = renderHook(() => useChainsToSelect())
+
+ expect(result.current).toBeUndefined()
+ })
+
+ it('returns undefined for ADVANCED_ORDERS + OUTPUT (buy token)', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ field: Field.OUTPUT,
+ tradeType: TradeType.ADVANCED_ORDERS,
+ selectedTargetChainId: SupportedChainId.MAINNET,
+ }),
+ )
+
+ const { result } = renderHook(() => useChainsToSelect())
+
+ expect(result.current).toBeUndefined()
+ })
+
+ it('returns undefined for LIMIT_ORDER + INPUT (sell token)', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ field: Field.INPUT,
+ tradeType: TradeType.LIMIT_ORDER,
+ selectedTargetChainId: SupportedChainId.MAINNET,
+ }),
+ )
+
+ const { result } = renderHook(() => useChainsToSelect())
+
+ expect(result.current).toBeUndefined()
+ })
+
+ it('returns undefined for ADVANCED_ORDERS + INPUT (sell token)', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ field: Field.INPUT,
+ tradeType: TradeType.ADVANCED_ORDERS,
+ selectedTargetChainId: SupportedChainId.MAINNET,
+ }),
+ )
+
+ const { result } = renderHook(() => useChainsToSelect())
+
+ expect(result.current).toBeUndefined()
+ })
+
+ it('returns chains for SWAP + OUTPUT (buy token)', () => {
+ // Include Mainnet in bridge data to exercise bridge destinations path
+ // Use mockReturnValueOnce for test isolation
+ mockUseBridgeSupportedNetworks.mockReturnValueOnce({
+ data: [createChainInfoForTests(SupportedChainId.MAINNET), createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)],
+ isLoading: false,
+ })
+
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ field: Field.OUTPUT,
+ tradeType: TradeType.SWAP,
+ selectedTargetChainId: SupportedChainId.MAINNET,
+ }),
+ )
+
+ const { result } = renderHook(() => useChainsToSelect())
+
+ expect(result.current).toBeDefined()
+ expect(result.current?.chains).toBeDefined()
+ expect(result.current?.chains?.length).toBeGreaterThan(0)
+ // Verify defaultChainId matches selectedTargetChainId (confirms bridge path, not fallback)
+ expect(result.current?.defaultChainId).toBe(SupportedChainId.MAINNET)
+ // Verify it returns bridge destinations (Gnosis), not single-chain fallback
+ expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ })
+
+ it('returns chains for SWAP + INPUT (sell token)', () => {
+ mockUseSelectTokenWidgetState.mockReturnValue(
+ createWidgetState({
+ field: Field.INPUT,
+ tradeType: TradeType.SWAP,
+ selectedTargetChainId: SupportedChainId.MAINNET,
+ }),
+ )
+
+ const { result } = renderHook(() => useChainsToSelect())
+
+ expect(result.current).toBeDefined()
+ expect(result.current?.chains).toBeDefined()
+ expect(result.current?.chains?.length).toBeGreaterThan(0)
+ // Verify defaultChainId matches selectedTargetChainId
+ expect(result.current?.defaultChainId).toBe(SupportedChainId.MAINNET)
+ // Verify it returns supported chains (Mainnet, Gnosis)
+ expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.MAINNET)).toBe(true)
+ expect(result.current?.chains?.some((chain) => chain.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ })
+})
From 0c1ce154e9e2241ec98595de24d16702d5e63607 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:27:56 +0000
Subject: [PATCH 071/100] feat: integrate feature flag for bridging enablement
in BridgingEnabledUpdater
---
.../src/common/updaters/BridgingEnabledUpdater.ts | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts
index edfbb1c2812..eff9e558fde 100644
--- a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts
+++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.ts
@@ -1,6 +1,6 @@
import { useEffect } from 'react'
-import { useSetIsBridgingEnabled } from '@cowprotocol/common-hooks'
+import { useFeatureFlags, useSetIsBridgingEnabled } from '@cowprotocol/common-hooks'
import { AccountType } from '@cowprotocol/types'
import { useAccountType, useWalletInfo } from '@cowprotocol/wallet'
@@ -13,11 +13,13 @@ export function BridgingEnabledUpdater(): null {
const tradeTypeInfo = useTradeTypeInfo()
const accountType = useAccountType()
const setIsBridgingEnabled = useSetIsBridgingEnabled()
+ const { isBridgingEnabled: isBridgingEnabledFlag } = useFeatureFlags()
const isSwapRoute = tradeTypeInfo?.route === Routes.SWAP
-
+ // Feature flag off should disable bridging regardless of route/wallet compatibility.
+ const isFeatureFlagEnabled = isBridgingEnabledFlag === undefined ? true : Boolean(isBridgingEnabledFlag)
const isWalletCompatible = Boolean(account ? accountType !== AccountType.SMART_CONTRACT : true)
- const shouldEnableBridging = isWalletCompatible && isSwapRoute
+ const shouldEnableBridging = isWalletCompatible && isSwapRoute && isFeatureFlagEnabled
useEffect(() => {
setIsBridgingEnabled(shouldEnableBridging)
From 9eea62d5ff449ef87a1f0c7e3e346e47b8c510a0 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 15:31:40 +0000
Subject: [PATCH 072/100] test: add unit tests for BridgingEnabledUpdater
component to validate feature flag behavior
---
.../updaters/BridgingEnabledUpdater.test.tsx | 63 +++++++++++++++++++
1 file changed, 63 insertions(+)
create mode 100644 apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx
diff --git a/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx
new file mode 100644
index 00000000000..549220a4881
--- /dev/null
+++ b/apps/cowswap-frontend/src/common/updaters/BridgingEnabledUpdater.test.tsx
@@ -0,0 +1,63 @@
+import { AccountType } from '@cowprotocol/types'
+
+import { render } from '@testing-library/react'
+
+import { BridgingEnabledUpdater } from './BridgingEnabledUpdater'
+
+import { Routes } from '../constants/routes'
+
+jest.mock('@cowprotocol/common-hooks', () => ({
+ ...jest.requireActual('@cowprotocol/common-hooks'),
+ useSetIsBridgingEnabled: jest.fn(),
+ useFeatureFlags: jest.fn(),
+}))
+
+jest.mock('@cowprotocol/wallet', () => ({
+ ...jest.requireActual('@cowprotocol/wallet'),
+ useWalletInfo: jest.fn(),
+ useAccountType: jest.fn(),
+}))
+
+jest.mock('modules/trade', () => ({
+ ...jest.requireActual('modules/trade'),
+ useTradeTypeInfo: jest.fn(),
+}))
+
+const { useSetIsBridgingEnabled, useFeatureFlags } = require('@cowprotocol/common-hooks')
+const mockUseSetIsBridgingEnabled = useSetIsBridgingEnabled as jest.MockedFunction
+const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction
+const { useWalletInfo, useAccountType } = require('@cowprotocol/wallet')
+
+const mockUseWalletInfo = useWalletInfo as jest.MockedFunction
+const mockUseAccountType = useAccountType as jest.MockedFunction
+const { useTradeTypeInfo } = require('modules/trade')
+const mockUseTradeTypeInfo = useTradeTypeInfo as jest.MockedFunction
+
+describe('BridgingEnabledUpdater', () => {
+ const setIsBridgingEnabled = jest.fn()
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ mockUseSetIsBridgingEnabled.mockReturnValue(setIsBridgingEnabled)
+ mockUseFeatureFlags.mockReturnValue({})
+ mockUseWalletInfo.mockReturnValue({ account: '0x123' })
+ mockUseAccountType.mockReturnValue(AccountType.EOA)
+ mockUseTradeTypeInfo.mockReturnValue({ route: Routes.SWAP })
+ })
+
+ it('disables bridging when the feature flag is false', () => {
+ mockUseFeatureFlags.mockReturnValue({ isBridgingEnabled: false })
+
+ render()
+
+ expect(setIsBridgingEnabled).toHaveBeenCalledWith(false)
+ })
+
+ it('enables bridging on swap route when the feature flag is true or undefined', () => {
+ mockUseFeatureFlags.mockReturnValue({ isBridgingEnabled: true })
+
+ render()
+
+ expect(setIsBridgingEnabled).toHaveBeenCalledWith(true)
+ })
+})
From 7ae47c0010e44657c1fd5a263ec1707e6c5d072a Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 18:11:25 +0000
Subject: [PATCH 073/100] feat: add support for disabled chain IDs in token
selection logic
---
.../hooks/useChainsToSelect.test.ts | 15 ++-
.../tokensList/hooks/useChainsToSelect.ts | 119 +++++++++++-------
.../tokensList/pure/ChainPanel/index.tsx | 1 +
.../tokensList/pure/ChainsSelector/index.tsx | 56 +++++++--
.../tokensList/pure/ChainsSelector/styled.tsx | 18 +--
.../SelectTokenModal/MobileChainSelector.tsx | 16 ++-
.../SelectTokenModal/TokenColumnContent.tsx | 1 +
.../mobileChainSelector.styled.ts | 8 +-
.../src/modules/tokensList/types.ts | 1 +
9 files changed, 166 insertions(+), 69 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
index ca2b35606c3..a9fb920a740 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
@@ -86,7 +86,6 @@ describe('useChainsToSelect state builders', () => {
chainId: SupportedChainId.MAINNET,
currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
bridgeSupportedNetworks: bridgeChains,
- areUnsupportedChainsEnabled: true,
isLoading: false,
})
@@ -98,7 +97,7 @@ describe('useChainsToSelect state builders', () => {
])
})
- it('falls back to wallet chain when bridge does not support the source chain', () => {
+ it('returns all bridge destinations even when source chain is not supported by bridge', () => {
const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.SEPOLIA,
@@ -107,12 +106,20 @@ describe('useChainsToSelect state builders', () => {
createChainInfoForTests(SupportedChainId.MAINNET),
createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
],
- areUnsupportedChainsEnabled: true,
isLoading: false,
})
+ // Default to source chain when the selected target isn't available
expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA)
- expect(state.chains?.map((chain) => chain.id)).toEqual([SupportedChainId.SEPOLIA])
+ // Should show all destinations plus source, but destinations disabled when source not supported
+ expect(state.chains?.map((chain) => chain.id)).toEqual([
+ SupportedChainId.MAINNET,
+ SupportedChainId.ARBITRUM_ONE,
+ SupportedChainId.SEPOLIA,
+ ])
+ expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBe(true)
+ expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
+ expect(state.disabledChainIds?.has(SupportedChainId.SEPOLIA)).toBe(false)
})
})
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
index 13efa115839..73982f63f43 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { CHAIN_INFO } from '@cowprotocol/common-const'
-import { useAvailableChains, useFeatureFlags, useIsBridgingEnabled } from '@cowprotocol/common-hooks'
+import { useAvailableChains, useIsBridgingEnabled } from '@cowprotocol/common-hooks'
import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
import { useWalletInfo } from '@cowprotocol/wallet'
@@ -27,7 +27,6 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
const { chainId } = useWalletInfo()
const { field, selectedTargetChainId = chainId, tradeType } = useSelectTokenWidgetState()
const { data: bridgeSupportedNetworks, isLoading } = useBridgeSupportedNetworks()
- const { areUnsupportedChainsEnabled } = useFeatureFlags()
const isBridgingEnabled = useIsBridgingEnabled()
const availableChains = useAvailableChains()
const isAdvancedTradeType = tradeType === TradeType.LIMIT_ORDER || tradeType === TradeType.ADVANCED_ORDERS
@@ -44,7 +43,8 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
}, [] as ChainInfo[])
}, [availableChains])
- return useMemo(() => {
+ // Compute output chains state for BUY token selection
+ const outputChainsState = useMemo(() => {
if (!field || !chainId) return undefined
const chainInfo = CHAIN_INFO[chainId]
@@ -56,20 +56,19 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
return undefined
}
- const currentChainInfo = mapChainInfo(chainId, chainInfo)
-
if (!isBridgingEnabled) return undefined
if (field === Field.INPUT) {
- return createInputChainsState(selectedTargetChainId, supportedChains)
+ return undefined
}
+ const currentChainInfo = mapChainInfo(chainId, chainInfo)
+
return createOutputChainsState({
selectedTargetChainId,
chainId,
currentChainInfo,
bridgeSupportedNetworks,
- areUnsupportedChainsEnabled,
isLoading,
})
}, [
@@ -79,36 +78,47 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
bridgeSupportedNetworks,
isLoading,
isBridgingEnabled,
- areUnsupportedChainsEnabled,
- supportedChains,
isAdvancedTradeType,
])
-}
-function filterDestinationChains(
- bridgeSupportedNetworks: ChainInfo[] | undefined,
- areUnsupportedChainsEnabled: boolean | undefined,
-): ChainInfo[] | undefined {
- if (areUnsupportedChainsEnabled) {
- // Nothing to filter, we return all bridge supported networks
- return bridgeSupportedNetworks
- } else {
- // If unsupported chains are not enabled, we only return the supported networks
- return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId)
- }
+ return useMemo(() => {
+ if (!field || !chainId) return undefined
+
+ const chainInfo = CHAIN_INFO[chainId]
+ if (!chainInfo) return undefined
+
+ // Limit/TWAP orders don't support chain selection - return undefined for both SELL and BUY
+ // These trade types rely on wallet/header network switcher instead
+ if (isAdvancedTradeType) {
+ return undefined
+ }
+
+ if (!isBridgingEnabled) return undefined
+
+ if (field === Field.INPUT) {
+ return createInputChainsState(selectedTargetChainId, supportedChains)
+ }
+
+ // For BUY token selection, include disabled chains info
+ if (outputChainsState) {
+ return outputChainsState
+ }
+
+ return undefined
+ }, [
+ field,
+ selectedTargetChainId,
+ chainId,
+ isBridgingEnabled,
+ supportedChains,
+ isAdvancedTradeType,
+ outputChainsState,
+ ])
}
-// Represents the “non-bridge” UX (bridging disabled or advanced-trade guardrail) where only the current chain is available.
-function createSingleChainState(
- defaultChainId: SupportedChainId | number,
- chain: ChainInfo,
- isLoading = false,
-): ChainsToSelectState {
- return {
- defaultChainId,
- chains: [chain],
- isLoading,
- }
+function filterDestinationChains(bridgeSupportedNetworks: ChainInfo[] | undefined): ChainInfo[] | undefined {
+ // Show only chains the app supports.
+ return bridgeSupportedNetworks?.filter((chain) => chain.id in SupportedChainId)
}
// Sell-side selector intentionally limits chains to the wallet-supported list; bridge destinations never appear here.
@@ -128,7 +138,6 @@ interface CreateOutputChainsOptions {
chainId: SupportedChainId
currentChainInfo: ChainInfo
bridgeSupportedNetworks: ChainInfo[] | undefined
- areUnsupportedChainsEnabled: boolean | undefined
isLoading: boolean
}
@@ -137,24 +146,44 @@ export function createOutputChainsState({
chainId,
currentChainInfo,
bridgeSupportedNetworks,
- areUnsupportedChainsEnabled,
isLoading,
}: CreateOutputChainsOptions): ChainsToSelectState {
- const destinationChains = filterDestinationChains(bridgeSupportedNetworks, areUnsupportedChainsEnabled) ?? []
- const orderedDestinationChains = sortChainsByDisplayOrder(destinationChains)
- const isSourceChainSupportedByBridge = Boolean(
- bridgeSupportedNetworks?.some((bridgeChain) => bridgeChain.id === chainId),
- )
-
- if (!isSourceChainSupportedByBridge) {
- // Source chain is unsupported by the bridge provider; fall back to non-bridge behavior.
- return createSingleChainState(chainId, currentChainInfo)
+ const destinationChains = filterDestinationChains(bridgeSupportedNetworks) ?? []
+ const isSourceChainSupportedByBridge = Boolean(destinationChains.some((chain) => chain.id === chainId))
+
+ if (process.env.NODE_ENV !== 'production') {
+ const destinationIds = destinationChains.map((c) => c.id)
+ const bridgeIds = bridgeSupportedNetworks?.map((c) => c.id)
+
+ console.debug('[useChainsToSelect] output chains', {
+ sourceChainId: chainId,
+ selectedTargetChainId,
+ bridgeSupportedNetworkIds: bridgeIds,
+ filteredDestinationIds: destinationIds,
+ isSourceChainSupportedByBridge,
+ })
}
+ // Always include the current chain for same-chain swaps (no bridging required)
+ const chainSet = new Set(destinationChains.map((chain) => chain.id))
+ const chainsWithCurrent = chainSet.has(chainId) ? destinationChains : [currentChainInfo, ...destinationChains]
+
+ const orderedDestinationChains = sortChainsByDisplayOrder(chainsWithCurrent)
+ const hasSelectedTarget = orderedDestinationChains.some((chain) => chain.id === selectedTargetChainId)
+ const fallbackChainId =
+ orderedDestinationChains.find((chain) => chain.id === chainId)?.id ?? orderedDestinationChains[0]?.id
+ const resolvedDefaultChainId = hasSelectedTarget ? selectedTargetChainId : fallbackChainId
+ const disabledChainIds = isSourceChainSupportedByBridge
+ ? undefined
+ : new Set(destinationChains.map((c) => c.id))
+
+ // Always return bridgeSupportedNetworks (filtered by feature flag) for BUY,
+ // even if the source lacks bridge support, so all destinations show (disabled when unsupported).
+ // Current chain is always included for same-chain swaps.
return {
- defaultChainId: selectedTargetChainId,
- // Bridge supports this chain, so expose the provider-supplied destinations.
+ defaultChainId: resolvedDefaultChainId,
chains: orderedDestinationChains,
isLoading,
+ disabledChainIds,
}
}
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 cfb2395429a..d893332aca9 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -61,6 +61,7 @@ export function ChainPanel({
chains={filteredChains}
defaultChainId={chainsState?.defaultChainId}
onSelectChain={onSelectChain}
+ disabledChainIds={chainsState?.disabledChainIds}
/>
{showUnavailableState && {t`No networks available for this trade.`}}
{showSearchEmptyState && (
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 6fb87f833ed..07ce95791ff 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -5,6 +5,8 @@ import { useTheme } from '@cowprotocol/common-hooks'
import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
import { getChainAccentColors } from '@cowprotocol/ui'
+import { msg } from '@lingui/core/macro'
+import { useLingui } from '@lingui/react/macro'
import SVG from 'react-inlinesvg'
import * as styledEl from './styled'
@@ -19,9 +21,16 @@ export interface ChainsSelectorProps {
onSelectChain: (chainId: ChainInfo) => void
defaultChainId?: ChainInfo['id']
isLoading: boolean
+ disabledChainIds?: Set
}
-export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoading }: ChainsSelectorProps): ReactNode {
+export function ChainsSelector({
+ chains,
+ onSelectChain,
+ defaultChainId,
+ isLoading,
+ disabledChainIds,
+}: ChainsSelectorProps): ReactNode {
const { darkMode } = useTheme()
if (isLoading) {
@@ -29,7 +38,13 @@ export function ChainsSelector({ chains, onSelectChain, defaultChainId, isLoadin
}
return (
-
+
)
}
@@ -59,9 +74,16 @@ interface ChainsListProps {
defaultChainId?: ChainInfo['id']
onSelectChain(chain: ChainInfo): void
isDarkMode: boolean
+ disabledChainIds?: Set
}
-function ChainsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
+function ChainsList({
+ chains,
+ defaultChainId,
+ onSelectChain,
+ isDarkMode,
+ disabledChainIds,
+}: ChainsListProps): ReactNode {
return (
)
}
-function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode }: ChainsListProps): ReactNode {
+function ChainsButtonsList({
+ chains,
+ defaultChainId,
+ onSelectChain,
+ isDarkMode,
+ disabledChainIds,
+}: ChainsListProps): ReactNode {
return (
<>
{chains.map((chain) => (
@@ -84,6 +113,7 @@ function ChainsButtonsList({ chains, defaultChainId, onSelectChain, isDarkMode }
isActive={defaultChainId === chain.id}
onSelectChain={onSelectChain}
isDarkMode={isDarkMode}
+ isDisabled={disabledChainIds?.has(chain.id) ?? false}
/>
))}
>
@@ -108,24 +138,36 @@ interface ChainButtonProps {
isActive: boolean
isDarkMode: boolean
onSelectChain(chain: ChainInfo): void
+ isDisabled: boolean
}
-function ChainButton({ chain, isActive, isDarkMode, onSelectChain }: ChainButtonProps): ReactNode {
+function ChainButton({ chain, isActive, isDarkMode, onSelectChain, isDisabled }: ChainButtonProps): ReactNode {
+ const { i18n } = useLingui()
const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light
const accent = getChainAccent(chain.id)
+ const disabledTooltip = i18n._(msg`This destination is not supported for this source chain`)
+
+ const handleClick = (): void => {
+ if (!isDisabled) {
+ onSelectChain(chain)
+ }
+ }
return (
onSelectChain(chain)}
+ onClick={handleClick}
active$={isActive}
accent$={accent}
aria-pressed={isActive}
+ aria-disabled={isDisabled}
+ disabled$={isDisabled}
+ title={isDisabled ? disabledTooltip : undefined}
>
- {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 1f545df0895..b15eaeb24a0 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/styled.tsx
@@ -30,7 +30,7 @@ export const List = styled.div`
width: 100%;
`
-export const ChainButton = styled.button<{ active$?: boolean; accent$?: ChainAccentVars }>`
+export const ChainButton = styled.button<{ active$?: boolean; accent$?: ChainAccentVars; disabled$?: boolean }>`
--min-height: 46px;
${blankButtonMixin};
@@ -45,20 +45,22 @@ export const ChainButton = styled.button<{ active$?: boolean; accent$?: ChainAcc
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;
+ cursor: ${({ disabled$ }) => (disabled$ ? 'not-allowed' : 'pointer')};
+ opacity: ${({ disabled$ }) => (disabled$ ? 0.5 : 1)};
transition:
border 0.2s ease,
background 0.2s ease,
- box-shadow 0.2s ease;
+ box-shadow 0.2s ease,
+ opacity 0.2s ease;
&:hover {
- border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)};
- background: ${({ accent$ }) => getBackground(accent$)};
+ border-color: ${({ accent$, disabled$ }) => (disabled$ ? 'transparent' : getBorder(accent$, fallbackHoverBorder))};
+ background: ${({ accent$, disabled$ }) => (disabled$ ? 'transparent' : getBackground(accent$))};
}
&:focus-visible {
outline: none;
- border-color: ${({ accent$ }) => getBorder(accent$, fallbackHoverBorder)};
+ border-color: ${({ accent$, disabled$ }) => (disabled$ ? 'transparent' : getBorder(accent$, fallbackHoverBorder))};
}
`
@@ -85,10 +87,10 @@ export const ChainLogo = styled.div`
}
`
-export const ChainText = styled.span`
+export const ChainText = styled.span<{ disabled$?: boolean }>`
font-weight: 500;
font-size: 15px;
- color: var(${UI.COLOR_TEXT});
+ color: ${({ disabled$ }) => (disabled$ ? `var(${UI.COLOR_TEXT_OPACITY_50})` : `var(${UI.COLOR_TEXT})`)};
`
export const ActiveIcon = styled.span<{ accent$?: ChainAccentVars; color$?: string }>`
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
index 31d8a2f9138..7ef23d7403f 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
@@ -69,6 +69,7 @@ export function MobileChainSelector({
chain={chain}
isActive={chainsState.defaultChainId === chain.id}
onSelectChain={onSelectChain}
+ isDisabled={chainsState.disabledChainIds?.has(chain.id) ?? false}
/>
))}
@@ -92,20 +93,31 @@ interface ChainChipProps {
chain: ChainInfo
isActive: boolean
onSelectChain(chain: ChainInfo): void
+ isDisabled: boolean
}
-function ChainChip({ chain, isActive, onSelectChain }: ChainChipProps): ReactNode {
+function ChainChip({ chain, isActive, onSelectChain, isDisabled }: ChainChipProps): ReactNode {
+ const { i18n } = useLingui()
const { darkMode } = useTheme()
const accent = getChainAccent(chain.id)
const logoSrc = darkMode ? chain.logo.dark : chain.logo.light
+ const handleClick = (): void => {
+ if (!isDisabled) {
+ onSelectChain(chain)
+ }
+ }
+
return (
onSelectChain(chain)}
+ onClick={handleClick}
$active={isActive}
$accent={accent}
+ $disabled={isDisabled}
aria-pressed={isActive}
+ aria-disabled={isDisabled}
+ title={isDisabled ? i18n._(msg`This destination is not supported for this source chain`) : undefined}
>
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
index f25c62541f5..82fe22ccbc7 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/TokenColumnContent.tsx
@@ -77,6 +77,7 @@ function LegacyChainSelector({ chainsToSelect, onSelectChain }: LegacyChainSelec
chains={chainsToSelect.chains}
defaultChainId={chainsToSelect.defaultChainId}
onSelectChain={onSelectChain}
+ disabledChainIds={chainsToSelect.disabledChainIds}
/>
)
}
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
index 2f3fd129525..6052a8c5ca4 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/mobileChainSelector.styled.ts
@@ -93,7 +93,7 @@ export const FixedAllNetworks = styled.div`
}
`
-export const ChainChipButton = styled.button<{ $active?: boolean; $accent?: ChainAccentVars }>`
+export const ChainChipButton = styled.button<{ $active?: boolean; $accent?: ChainAccentVars; $disabled?: boolean }>`
--size: 44px;
width: var(--size);
height: var(--size);
@@ -103,10 +103,12 @@ export const ChainChipButton = styled.button<{ $active?: boolean; $accent?: Chai
display: flex;
align-items: center;
justify-content: center;
- cursor: pointer;
+ cursor: ${({ $disabled }) => ($disabled ? 'not-allowed' : 'pointer')};
+ opacity: ${({ $disabled }) => ($disabled ? 0.5 : 1)};
transition:
border 0.2s ease,
- background 0.2s ease;
+ background 0.2s ease,
+ opacity 0.2s ease;
flex-shrink: 0;
scroll-snap-align: start;
diff --git a/apps/cowswap-frontend/src/modules/tokensList/types.ts b/apps/cowswap-frontend/src/modules/tokensList/types.ts
index da71e3ab963..d736b69473e 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/types.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/types.ts
@@ -27,4 +27,5 @@ export interface ChainsToSelectState {
chains: ChainInfo[] | undefined
defaultChainId?: number
isLoading?: boolean
+ disabledChainIds?: Set
}
From 356481e7cb7ef531ba4e7b472841435bf03f6a80 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 18:14:28 +0000
Subject: [PATCH 074/100] feat: add Spanish and Russian translations for
unsupported destination message in chain selection
---
apps/cowswap-frontend/src/locales/es-ES.po | 4 ++++
apps/cowswap-frontend/src/locales/ru-RU.po | 4 ++++
2 files changed, 8 insertions(+)
diff --git a/apps/cowswap-frontend/src/locales/es-ES.po b/apps/cowswap-frontend/src/locales/es-ES.po
index 34a64f8fe71..999166065c7 100644
--- a/apps/cowswap-frontend/src/locales/es-ES.po
+++ b/apps/cowswap-frontend/src/locales/es-ES.po
@@ -6366,3 +6366,7 @@ msgstr "Ver todas las {totalChains} redes"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
msgid "Selected network {activeChainLabel}"
msgstr "Red seleccionada {activeChainLabel}"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+msgid "This destination is not supported for this source chain"
+msgstr "Este destino no es compatible con esta red de origen"
diff --git a/apps/cowswap-frontend/src/locales/ru-RU.po b/apps/cowswap-frontend/src/locales/ru-RU.po
index f1e4fba8a4e..647773b2592 100644
--- a/apps/cowswap-frontend/src/locales/ru-RU.po
+++ b/apps/cowswap-frontend/src/locales/ru-RU.po
@@ -6366,3 +6366,7 @@ msgstr "Показать все сети ({totalChains})"
#: apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
msgid "Selected network {activeChainLabel}"
msgstr "Выбранная сеть {activeChainLabel}"
+
+#: apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+msgid "This destination is not supported for this source chain"
+msgstr "Этот пункт назначения недоступен для выбранной исходной сети"
From a1bab01d06893cf074f1219adc57c212f2ca3f92 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 18:16:48 +0000
Subject: [PATCH 075/100] refactor: remove debug logging from useChainsToSelect
hook to clean up production code
---
.../modules/tokensList/hooks/useChainsToSelect.ts | 13 -------------
1 file changed, 13 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
index 73982f63f43..d95c21bfdfe 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -151,19 +151,6 @@ export function createOutputChainsState({
const destinationChains = filterDestinationChains(bridgeSupportedNetworks) ?? []
const isSourceChainSupportedByBridge = Boolean(destinationChains.some((chain) => chain.id === chainId))
- if (process.env.NODE_ENV !== 'production') {
- const destinationIds = destinationChains.map((c) => c.id)
- const bridgeIds = bridgeSupportedNetworks?.map((c) => c.id)
-
- console.debug('[useChainsToSelect] output chains', {
- sourceChainId: chainId,
- selectedTargetChainId,
- bridgeSupportedNetworkIds: bridgeIds,
- filteredDestinationIds: destinationIds,
- isSourceChainSupportedByBridge,
- })
- }
-
// Always include the current chain for same-chain swaps (no bridging required)
const chainSet = new Set(destinationChains.map((chain) => chain.id))
const chainsWithCurrent = chainSet.has(chainId) ? destinationChains : [currentChainInfo, ...destinationChains]
From 5c5cd019560c3bc572de197c5c17ef040864b579 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Fri, 5 Dec 2025 19:46:37 +0000
Subject: [PATCH 076/100] feat: implement tooltip for disabled chains in
MobileChainSelector
---
.../SelectTokenModal/MobileChainSelector.tsx | 94 ++++++++++++++++++-
1 file changed, 90 insertions(+), 4 deletions(-)
diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
index 7ef23d7403f..a548dc695e8 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/MobileChainSelector.tsx
@@ -1,7 +1,8 @@
-import { ReactNode, useEffect, useMemo, useRef } from 'react'
+import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTheme } from '@cowprotocol/common-hooks'
import { ChainInfo } from '@cowprotocol/cow-sdk'
+import { Tooltip } from '@cowprotocol/ui'
import { msg } from '@lingui/core/macro'
import { Trans, useLingui } from '@lingui/react/macro'
@@ -13,6 +14,53 @@ import { ChainsToSelectState } from '../../types'
import { sortChainsByDisplayOrder } from '../../utils/sortChainsByDisplayOrder'
import { getChainAccent } from '../ChainsSelector'
+const DISABLED_CHAIN_TOOLTIP_DURATION_MS = 2500
+
+function useDisabledChainTooltip(durationMs: number): {
+ activeTooltipChainId: number | null
+ toggleTooltip(chainId: number): void
+ hideTooltip(): void
+} {
+ const [activeTooltipChainId, setActiveTooltipChainId] = useState(null)
+ const hideTimerRef = useRef(null)
+
+ const hideTooltip = useCallback((): void => {
+ if (hideTimerRef.current) {
+ window.clearTimeout(hideTimerRef.current)
+ hideTimerRef.current = null
+ }
+ setActiveTooltipChainId(null)
+ }, [])
+
+ const toggleTooltip = useCallback(
+ (chainId: number): void => {
+ setActiveTooltipChainId((prev) => {
+ if (hideTimerRef.current) {
+ window.clearTimeout(hideTimerRef.current)
+ hideTimerRef.current = null
+ }
+
+ const next = prev === chainId ? null : chainId
+ if (next !== null) {
+ hideTimerRef.current = window.setTimeout(() => {
+ setActiveTooltipChainId(null)
+ hideTimerRef.current = null
+ }, durationMs)
+ }
+
+ return next
+ })
+ },
+ [durationMs],
+ )
+
+ useEffect(() => {
+ return hideTooltip
+ }, [hideTooltip])
+
+ return { activeTooltipChainId, toggleTooltip, hideTooltip }
+}
+
interface MobileChainSelectorProps {
chainsState: ChainsToSelectState
label?: string
@@ -28,6 +76,9 @@ export function MobileChainSelector({
}: MobileChainSelectorProps): ReactNode {
const { i18n } = useLingui()
const scrollRef = useRef(null)
+ const { activeTooltipChainId, toggleTooltip, hideTooltip } = useDisabledChainTooltip(
+ DISABLED_CHAIN_TOOLTIP_DURATION_MS,
+ )
const orderedChains = useMemo(
() =>
sortChainsByDisplayOrder(chainsState.chains ?? [], {
@@ -70,6 +121,9 @@ export function MobileChainSelector({
isActive={chainsState.defaultChainId === chain.id}
onSelectChain={onSelectChain}
isDisabled={chainsState.disabledChainIds?.has(chain.id) ?? false}
+ isTooltipVisible={activeTooltipChainId === chain.id}
+ onDisabledClick={toggleTooltip}
+ onHideTooltip={hideTooltip}
/>
))}
@@ -94,22 +148,40 @@ interface ChainChipProps {
isActive: boolean
onSelectChain(chain: ChainInfo): void
isDisabled: boolean
+ isTooltipVisible: boolean
+ onDisabledClick(chainId: number): void
+ onHideTooltip(): void
}
-function ChainChip({ chain, isActive, onSelectChain, isDisabled }: ChainChipProps): ReactNode {
+function ChainChip({
+ chain,
+ isActive,
+ onSelectChain,
+ isDisabled,
+ isTooltipVisible,
+ onDisabledClick,
+ onHideTooltip,
+}: ChainChipProps): ReactNode {
const { i18n } = useLingui()
const { darkMode } = useTheme()
const accent = getChainAccent(chain.id)
const logoSrc = darkMode ? chain.logo.dark : chain.logo.light
+ const disabledTooltip = i18n._(msg`This destination is not supported for this source chain`)
+ const chipRef = useRef(null)
const handleClick = (): void => {
if (!isDisabled) {
+ onHideTooltip()
onSelectChain(chain)
+ return
}
+
+ onDisabledClick(chain.id)
}
- return (
+ const chipButton = (
)
+
+ return isDisabled ? (
+
+ {chipButton}
+
+ ) : (
+ chipButton
+ )
}
From cef77aec715d4010a5dc9a25942d71fff6a4ba57 Mon Sep 17 00:00:00 2001
From: fairlighteth <31534717+fairlighteth@users.noreply.github.com>
Date: Tue, 9 Dec 2025 16:44:30 +0000
Subject: [PATCH 077/100] feat: add useRoutesAvailability hook and integrate
into chain selection logic
---
.../src/entities/bridgeProvider/index.ts | 1 +
.../bridgeProvider/useRoutesAvailability.ts | 112 +++++++++++
.../hooks/useChainsToSelect.test.ts | 184 ++++++++++++++++--
.../tokensList/hooks/useChainsToSelect.ts | 168 ++++++++--------
.../tokensList/pure/ChainPanel/index.tsx | 1 +
.../tokensList/pure/ChainsSelector/index.tsx | 34 +++-
.../tokensList/pure/ChainsSelector/styled.tsx | 50 ++++-
.../SelectTokenModal/MobileChainSelector.tsx | 15 +-
.../mobileChainSelector.styled.ts | 38 +++-
.../src/modules/tokensList/types.ts | 1 +
10 files changed, 497 insertions(+), 107 deletions(-)
create mode 100644 apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts
diff --git a/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts b/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts
index d27704e9170..6708e945639 100644
--- a/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts
+++ b/apps/cowswap-frontend/src/entities/bridgeProvider/index.ts
@@ -1,3 +1,4 @@
export { useBridgeSupportedNetworks, useBridgeSupportedNetwork } from './useBridgeSupportedNetworks'
export { useBridgeSupportedTokens } from './useBridgeSupportedTokens'
+export { useRoutesAvailability } from './useRoutesAvailability'
export { BridgeProvidersUpdater } from './BridgeProvidersUpdater'
diff --git a/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts b/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts
new file mode 100644
index 00000000000..3edf4fe0aa0
--- /dev/null
+++ b/apps/cowswap-frontend/src/entities/bridgeProvider/useRoutesAvailability.ts
@@ -0,0 +1,112 @@
+import { useMemo } from 'react'
+
+import { SWR_NO_REFRESH_OPTIONS } from '@cowprotocol/common-const'
+import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
+import { SupportedChainId } from '@cowprotocol/cow-sdk'
+
+import useSWR from 'swr'
+import { bridgingSdk } from 'tradingSdk/bridgingSdk'
+
+import { useBridgeProvidersIds } from './useBridgeProvidersIds'
+
+export interface RoutesAvailabilityResult {
+ unavailableChainIds: Set
+ loadingChainIds: Set
+ isLoading: boolean
+}
+
+const EMPTY_RESULT: RoutesAvailabilityResult = {
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(),
+ isLoading: false,
+}
+
+interface RouteCheckResult {
+ chainId: number
+ isAvailable: boolean
+}
+
+/**
+ * Pre-checks route availability for multiple destination chains from a source chain.
+ * Returns which chains have unavailable routes and which are still loading.
+ *
+ * Note: Fires parallel requests for all destination chains without throttling.
+ * This is acceptable because:
+ * - SWR caches results, so repeated opens don't re-fetch
+ * - Chain count is limited (~10-15 max)
+ * - Requests are lightweight (token existence checks)
+ * If this becomes a bottleneck, consider batching or sequential fetching.
+ */
+export function useRoutesAvailability(
+ sourceChainId: SupportedChainId | undefined,
+ destinationChainIds: number[],
+): RoutesAvailabilityResult {
+ const isBridgingEnabled = useIsBridgingEnabled()
+ const providerIds = useBridgeProvidersIds()
+ const providersKey = providerIds.join('|')
+
+ // Filter out the source chain (same-chain swaps are always available)
+ const chainsToCheck = useMemo(
+ () => destinationChainIds.filter((id) => id !== sourceChainId),
+ [destinationChainIds, sourceChainId],
+ )
+
+ // Create a stable key for the SWR request
+ const swrKey = useMemo(() => {
+ if (!isBridgingEnabled || !sourceChainId || chainsToCheck.length === 0) {
+ return null
+ }
+ return [sourceChainId, chainsToCheck.sort().join(','), providersKey, 'useRoutesAvailability']
+ }, [isBridgingEnabled, sourceChainId, chainsToCheck, providersKey])
+
+ const { data, isLoading } = useSWR(
+ swrKey,
+ async (key) => {
+ const [sellChainId, chainIdsString] = key as [SupportedChainId, string, string, string]
+ const chainIds = chainIdsString.split(',').map(Number)
+
+ // Check routes in parallel for all destination chains
+ const results = await Promise.all(
+ chainIds.map(async (buyChainId: number): Promise => {
+ try {
+ const result = await bridgingSdk.getBuyTokens({ sellChainId, buyChainId })
+ const isAvailable = result.tokens.length > 0 && result.isRouteAvailable
+ return { chainId: buyChainId, isAvailable }
+ } catch (error) {
+ console.warn(`[useRoutesAvailability] Failed to check route ${sellChainId} -> ${buyChainId}`, error)
+ // Treat errors as unavailable routes
+ return { chainId: buyChainId, isAvailable: false }
+ }
+ }),
+ )
+
+ return results
+ },
+ SWR_NO_REFRESH_OPTIONS,
+ )
+
+ return useMemo(() => {
+ if (!swrKey) {
+ return EMPTY_RESULT
+ }
+
+ if (isLoading || !data) {
+ // While loading, mark all chains being checked as loading
+ return {
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(chainsToCheck),
+ isLoading: true,
+ }
+ }
+
+ const unavailableChainIds = new Set(
+ data.filter((result) => !result.isAvailable).map((result) => result.chainId),
+ )
+
+ return {
+ unavailableChainIds,
+ loadingChainIds: new Set(),
+ isLoading: false,
+ }
+ }, [swrKey, isLoading, data, chainsToCheck])
+}
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
index a9fb920a740..3b4ecdcdc9a 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.test.ts
@@ -13,6 +13,13 @@ import { useSelectTokenWidgetState } from './useSelectTokenWidgetState'
import { DEFAULT_SELECT_TOKEN_WIDGET_STATE } from '../state/selectTokenWidgetAtom'
import { createChainInfoForTests } from '../test-utils/createChainInfoForTests'
+// Default routes availability for tests (no unavailable routes, not loading)
+const DEFAULT_ROUTES_AVAILABILITY = {
+ unavailableChainIds: new Set(),
+ loadingChainIds: new Set(),
+ isLoading: false,
+}
+
jest.mock('@cowprotocol/wallet', () => ({
...jest.requireActual('@cowprotocol/wallet'),
useWalletInfo: jest.fn(),
@@ -28,6 +35,7 @@ jest.mock('@cowprotocol/common-hooks', () => ({
jest.mock('entities/bridgeProvider', () => ({
...jest.requireActual('entities/bridgeProvider'),
useBridgeSupportedNetworks: jest.fn(),
+ useRoutesAvailability: jest.fn(),
}))
jest.mock('./useSelectTokenWidgetState', () => ({
@@ -43,10 +51,11 @@ const mockUseIsBridgingEnabled = useIsBridgingEnabled as jest.MockedFunction
const mockUseFeatureFlags = useFeatureFlags as jest.MockedFunction
-const { useBridgeSupportedNetworks } = require('entities/bridgeProvider')
+const { useBridgeSupportedNetworks, useRoutesAvailability } = require('entities/bridgeProvider')
const mockUseBridgeSupportedNetworks = useBridgeSupportedNetworks as jest.MockedFunction<
typeof useBridgeSupportedNetworks
>
+const mockUseRoutesAvailability = useRoutesAvailability as jest.MockedFunction
type WidgetState = ReturnType
const createWidgetState = (override: Partial): WidgetState => {
@@ -73,22 +82,29 @@ describe('useChainsToSelect state builders', () => {
])
})
- it('sorts bridge destination chains to match the canonical order', () => {
- const bridgeChains = [
+ it('sorts BUY chains using the canonical order and returns all supportedChains', () => {
+ const supportedChains = [
createChainInfoForTests(SupportedChainId.AVALANCHE),
createChainInfoForTests(SupportedChainId.BASE),
createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
createChainInfoForTests(SupportedChainId.MAINNET),
]
+ const bridgeChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ ]
const state = createOutputChainsState({
- selectedTargetChainId: SupportedChainId.POLYGON,
+ selectedTargetChainId: SupportedChainId.BASE,
chainId: SupportedChainId.MAINNET,
currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
bridgeSupportedNetworks: bridgeChains,
+ supportedChains,
isLoading: false,
+ routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})
+ // Should return all supportedChains, sorted by canonical order
expect((state.chains ?? []).map((chain) => chain.id)).toEqual([
SupportedChainId.MAINNET,
SupportedChainId.BASE,
@@ -97,29 +113,172 @@ describe('useChainsToSelect state builders', () => {
])
})
- it('returns all bridge destinations even when source chain is not supported by bridge', () => {
+ it('disables chains not in bridge destinations when source is bridge-supported', () => {
+ const supportedChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
+ createChainInfoForTests(SupportedChainId.AVALANCHE),
+ ]
+ const bridgeChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ ]
+
+ const state = createOutputChainsState({
+ selectedTargetChainId: SupportedChainId.BASE,
+ chainId: SupportedChainId.MAINNET,
+ currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
+ bridgeSupportedNetworks: bridgeChains,
+ supportedChains,
+ isLoading: false,
+ routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
+ })
+
+ // Source (Mainnet) and Base are bridge-supported, others should be disabled
+ expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBeFalsy()
+ expect(state.disabledChainIds?.has(SupportedChainId.BASE)).toBeFalsy()
+ expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
+ expect(state.disabledChainIds?.has(SupportedChainId.AVALANCHE)).toBe(true)
+ })
+
+ it('disables all chains except source when source is not bridge-supported', () => {
+ const supportedChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
+ createChainInfoForTests(SupportedChainId.SEPOLIA),
+ ]
+ const bridgeChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
+ ]
+
const state = createOutputChainsState({
selectedTargetChainId: SupportedChainId.BASE,
- chainId: SupportedChainId.SEPOLIA,
+ chainId: SupportedChainId.SEPOLIA, // Sepolia not in bridge destinations
currentChainInfo: createChainInfoForTests(SupportedChainId.SEPOLIA),
- bridgeSupportedNetworks: [
- createChainInfoForTests(SupportedChainId.MAINNET),
- createChainInfoForTests(SupportedChainId.ARBITRUM_ONE),
- ],
+ bridgeSupportedNetworks: bridgeChains,
+ supportedChains,
isLoading: false,
+ routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
})
// Default to source chain when the selected target isn't available
expect(state.defaultChainId).toBe(SupportedChainId.SEPOLIA)
- // Should show all destinations plus source, but destinations disabled when source not supported
+ // Should return all supportedChains
expect(state.chains?.map((chain) => chain.id)).toEqual([
SupportedChainId.MAINNET,
SupportedChainId.ARBITRUM_ONE,
SupportedChainId.SEPOLIA,
])
+ // All chains except source should be disabled
expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBe(true)
expect(state.disabledChainIds?.has(SupportedChainId.ARBITRUM_ONE)).toBe(true)
- expect(state.disabledChainIds?.has(SupportedChainId.SEPOLIA)).toBe(false)
+ expect(state.disabledChainIds?.has(SupportedChainId.SEPOLIA)).toBeFalsy()
+ })
+
+ it('falls back to source when selected target is disabled', () => {
+ const supportedChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ createChainInfoForTests(SupportedChainId.AVALANCHE),
+ ]
+ const bridgeChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ ]
+
+ const state = createOutputChainsState({
+ selectedTargetChainId: SupportedChainId.AVALANCHE, // Not in bridge destinations
+ chainId: SupportedChainId.MAINNET,
+ currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
+ bridgeSupportedNetworks: bridgeChains,
+ supportedChains,
+ isLoading: false,
+ routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
+ })
+
+ // Avalanche is disabled, so should fallback to source (Mainnet)
+ expect(state.defaultChainId).toBe(SupportedChainId.MAINNET)
+ })
+
+ it('does not apply disabled state while loading bridge data', () => {
+ const supportedChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN),
+ ]
+
+ const state = createOutputChainsState({
+ selectedTargetChainId: SupportedChainId.BASE,
+ chainId: SupportedChainId.MAINNET,
+ currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
+ bridgeSupportedNetworks: undefined,
+ supportedChains,
+ isLoading: true, // Still loading
+ routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
+ })
+
+ // Should render all supportedChains
+ expect(state.chains?.length).toBe(3)
+ // No chains should be disabled while loading
+ expect(state.disabledChainIds).toBeUndefined()
+ // Selected target should be valid since nothing is disabled
+ expect(state.defaultChainId).toBe(SupportedChainId.BASE)
+ })
+
+ it('disables all except source when bridge data fails to load', () => {
+ const supportedChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN),
+ ]
+
+ const state = createOutputChainsState({
+ selectedTargetChainId: SupportedChainId.BASE,
+ chainId: SupportedChainId.MAINNET,
+ currentChainInfo: createChainInfoForTests(SupportedChainId.MAINNET),
+ bridgeSupportedNetworks: undefined,
+ supportedChains,
+ isLoading: false, // Finished loading, but no data
+ routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
+ })
+
+ // Should render all supportedChains
+ expect(state.chains?.length).toBe(3)
+ // All chains except source should be disabled when bridge data failed
+ expect(state.disabledChainIds?.has(SupportedChainId.MAINNET)).toBeFalsy()
+ expect(state.disabledChainIds?.has(SupportedChainId.BASE)).toBe(true)
+ expect(state.disabledChainIds?.has(SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ // Default should fallback to source since selected target is disabled
+ expect(state.defaultChainId).toBe(SupportedChainId.MAINNET)
+ })
+
+ it('injects current chain when not in supportedChains (feature-flagged chain)', () => {
+ // Simulate a scenario where wallet is on a feature-flagged chain not in supportedChains
+ const supportedChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ ]
+ const bridgeChains = [
+ createChainInfoForTests(SupportedChainId.MAINNET),
+ createChainInfoForTests(SupportedChainId.BASE),
+ ]
+
+ const state = createOutputChainsState({
+ selectedTargetChainId: SupportedChainId.BASE,
+ chainId: SupportedChainId.GNOSIS_CHAIN, // Not in supportedChains
+ currentChainInfo: createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN),
+ bridgeSupportedNetworks: bridgeChains,
+ supportedChains,
+ isLoading: false,
+ routesAvailability: DEFAULT_ROUTES_AVAILABILITY,
+ })
+
+ // Current chain should be injected into the list
+ expect(state.chains?.some((c) => c.id === SupportedChainId.GNOSIS_CHAIN)).toBe(true)
+ // Should have 3 chains: Mainnet, Base, and injected Gnosis
+ expect(state.chains?.length).toBe(3)
})
})
@@ -134,6 +293,7 @@ describe('useChainsToSelect hook', () => {
data: [createChainInfoForTests(SupportedChainId.GNOSIS_CHAIN)],
isLoading: false,
})
+ mockUseRoutesAvailability.mockReturnValue(DEFAULT_ROUTES_AVAILABILITY)
})
it('returns undefined for LIMIT_ORDER + OUTPUT (buy token)', () => {
diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
index d95c21bfdfe..04d22ca4745 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
+++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useChainsToSelect.ts
@@ -5,7 +5,7 @@ import { useAvailableChains, useIsBridgingEnabled } from '@cowprotocol/common-ho
import { ChainInfo, SupportedChainId } from '@cowprotocol/cow-sdk'
import { useWalletInfo } from '@cowprotocol/wallet'
-import { useBridgeSupportedNetworks } from 'entities/bridgeProvider'
+import { useBridgeSupportedNetworks, useRoutesAvailability } from 'entities/bridgeProvider'
import { Field } from 'legacy/state/types'
@@ -21,7 +21,7 @@ import { sortChainsByDisplayOrder } from '../utils/sortChainsByDisplayOrder'
* Returns an array of chains to select in the token selector widget.
* The array depends on sell/buy token selection.
* For the sell token we return all supported chains.
- * For the buy token we return current network + all bridge target networks.
+ * For the buy token we return all app-supported chains with disabled state for non-bridgeable targets.
*/
export function useChainsToSelect(): ChainsToSelectState | undefined {
const { chainId } = useWalletInfo()
@@ -34,85 +34,48 @@ export function useChainsToSelect(): ChainsToSelectState | undefined {
const supportedChains = useMemo(() => {
return availableChains.reduce((acc, id) => {
const info = CHAIN_INFO[id]
-
- if (info) {
- acc.push(mapChainInfo(id, info))
- }
-
+ if (info) acc.push(mapChainInfo(id, info))
return acc
}, [] as ChainInfo[])
}, [availableChains])
- // Compute output chains state for BUY token selection
- const outputChainsState = useMemo(() => {
- if (!field || !chainId) return undefined
+ const destinationChainIds = useMemo(() => supportedChains.map((c) => c.id), [supportedChains])
+ const isBuyField = field === Field.OUTPUT
+ const routesAvailability = useRoutesAvailability(
+ isBuyField && isBridgingEnabled ? chainId : undefined,
+ destinationChainIds,
+ )
+
+ return useMemo(() => {
+ if (!field || !chainId || !isBridgingEnabled || isAdvancedTradeType) return undefined
const chainInfo = CHAIN_INFO[chainId]
if (!chainInfo) return undefined
- // Limit/TWAP orders don't support chain selection - return undefined for both SELL and BUY
- // These trade types rely on wallet/header network switcher instead
- if (isAdvancedTradeType) {
- return undefined
- }
-
- if (!isBridgingEnabled) return undefined
-
if (field === Field.INPUT) {
- return undefined
+ return createInputChainsState(selectedTargetChainId, supportedChains)
}
- const currentChainInfo = mapChainInfo(chainId, chainInfo)
-
+ // BUY token selection - include disabled chains info
return createOutputChainsState({
selectedTargetChainId,
chainId,
- currentChainInfo,
+ currentChainInfo: mapChainInfo(chainId, chainInfo),
bridgeSupportedNetworks,
+ supportedChains,
isLoading,
+ routesAvailability,
})
}, [
field,
selectedTargetChainId,
chainId,
bridgeSupportedNetworks,
+ supportedChains,
isLoading,
isBridgingEnabled,
isAdvancedTradeType,
- ])
-
- return useMemo(() => {
- if (!field || !chainId) return undefined
-
- const chainInfo = CHAIN_INFO[chainId]
- if (!chainInfo) return undefined
-
- // Limit/TWAP orders don't support chain selection - return undefined for both SELL and BUY
- // These trade types rely on wallet/header network switcher instead
- if (isAdvancedTradeType) {
- return undefined
- }
-
- if (!isBridgingEnabled) return undefined
-
- if (field === Field.INPUT) {
- return createInputChainsState(selectedTargetChainId, supportedChains)
- }
-
- // For BUY token selection, include disabled chains info
- if (outputChainsState) {
- return outputChainsState
- }
-
- return undefined
- }, [
- field,
- selectedTargetChainId,
- chainId,
- isBridgingEnabled,
- supportedChains,
- isAdvancedTradeType,
- outputChainsState,
+ routesAvailability,
])
}
@@ -138,7 +101,51 @@ interface CreateOutputChainsOptions {
chainId: SupportedChainId
currentChainInfo: ChainInfo
bridgeSupportedNetworks: ChainInfo[] | undefined
+ supportedChains: ChainInfo[]
isLoading: boolean
+ routesAvailability: {
+ unavailableChainIds: Set
+ loadingChainIds: Set
+ isLoading: boolean
+ }
+}
+
+function computeDisabledChainIds(
+ orderedChains: ChainInfo[],
+ chainId: SupportedChainId,
+ destinationIds: Set,
+ sourceSupported: boolean,
+ isLoading: boolean,
+): Set {
+ // During loading, don't apply disabled states - wait for bridge data
+ if (isLoading) return new Set()
+
+ return new Set(
+ orderedChains
+ .filter((chain) => {
+ if (chain.id === chainId) return false // Source always enabled
+ if (!sourceSupported) return true // All disabled when source not supported
+ return !destinationIds.has(chain.id) // Disable if not a valid bridge destination
+ })
+ .map((c) => c.id),
+ )
+}
+
+function resolveDefaultChainId(
+ orderedChains: ChainInfo[],
+ selectedTargetChainId: number,
+ chainId: SupportedChainId,
+ disabledChainIds: Set,
+): number {
+ const isSelectedTargetValid =
+ orderedChains.some((c) => c.id === selectedTargetChainId) && !disabledChainIds.has(selectedTargetChainId)
+ if (isSelectedTargetValid) return selectedTargetChainId
+
+ const sourceInList = orderedChains.some((c) => c.id === chainId)
+ if (sourceInList) return chainId
+
+ const firstEnabledChain = orderedChains.find((c) => !disabledChainIds.has(c.id))
+ return firstEnabledChain?.id ?? orderedChains[0]?.id
}
export function createOutputChainsState({
@@ -146,31 +153,38 @@ export function createOutputChainsState({
chainId,
currentChainInfo,
bridgeSupportedNetworks,
+ supportedChains,
isLoading,
+ routesAvailability,
}: CreateOutputChainsOptions): ChainsToSelectState {
- const destinationChains = filterDestinationChains(bridgeSupportedNetworks) ?? []
- const isSourceChainSupportedByBridge = Boolean(destinationChains.some((chain) => chain.id === chainId))
-
- // Always include the current chain for same-chain swaps (no bridging required)
- const chainSet = new Set(destinationChains.map((chain) => chain.id))
- const chainsWithCurrent = chainSet.has(chainId) ? destinationChains : [currentChainInfo, ...destinationChains]
-
- const orderedDestinationChains = sortChainsByDisplayOrder(chainsWithCurrent)
- const hasSelectedTarget = orderedDestinationChains.some((chain) => chain.id === selectedTargetChainId)
- const fallbackChainId =
- orderedDestinationChains.find((chain) => chain.id === chainId)?.id ?? orderedDestinationChains[0]?.id
- const resolvedDefaultChainId = hasSelectedTarget ? selectedTargetChainId : fallbackChainId
- const disabledChainIds = isSourceChainSupportedByBridge
- ? undefined
- : new Set(destinationChains.map((c) => c.id))
-
- // Always return bridgeSupportedNetworks (filtered by feature flag) for BUY,
- // even if the source lacks bridge support, so all destinations show (disabled when unsupported).
- // Current chain is always included for same-chain swaps.
+ // Use all app-supported chains as the base list (consistent with SELL selector)
+ // Always include the current chain for same-chain swaps, even if feature-flagged off
+ const chainSet = new Set(supportedChains.map((c) => c.id))
+ const chainsWithCurrent = chainSet.has(chainId) ? supportedChains : [...supportedChains, currentChainInfo]
+ const orderedChains = sortChainsByDisplayOrder(chainsWithCurrent)
+
+ const destinationIds = new Set(filterDestinationChains(bridgeSupportedNetworks)?.map((c) => c.id) ?? [])
+ const sourceSupported = destinationIds.has(chainId)
+
+ // Compute base disabled chains from bridge network data
+ const baseDisabledChainIds = computeDisabledChainIds(
+ orderedChains,
+ chainId,
+ destinationIds,
+ sourceSupported,
+ isLoading,
+ )
+
+ // Merge with unavailable routes from pre-check (chains with no valid route)
+ const disabledChainIds = new Set([...baseDisabledChainIds, ...routesAvailability.unavailableChainIds])
+
+ const resolvedDefaultChainId = resolveDefaultChainId(orderedChains, selectedTargetChainId, chainId, disabledChainIds)
+
return {
defaultChainId: resolvedDefaultChainId,
- chains: orderedDestinationChains,
+ chains: orderedChains,
isLoading,
- disabledChainIds,
+ disabledChainIds: disabledChainIds.size > 0 ? disabledChainIds : undefined,
+ loadingChainIds: routesAvailability.loadingChainIds.size > 0 ? routesAvailability.loadingChainIds : undefined,
}
}
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 d893332aca9..faa001ab4f9 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainPanel/index.tsx
@@ -62,6 +62,7 @@ export function ChainPanel({
defaultChainId={chainsState?.defaultChainId}
onSelectChain={onSelectChain}
disabledChainIds={chainsState?.disabledChainIds}
+ loadingChainIds={chainsState?.loadingChainIds}
/>
{showUnavailableState && {t`No networks available for this trade.`}}
{showSearchEmptyState && (
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 07ce95791ff..d4e4ba74eb7 100644
--- a/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
+++ b/apps/cowswap-frontend/src/modules/tokensList/pure/ChainsSelector/index.tsx
@@ -22,6 +22,7 @@ export interface ChainsSelectorProps {
defaultChainId?: ChainInfo['id']
isLoading: boolean
disabledChainIds?: Set
+ loadingChainIds?: Set
}
export function ChainsSelector({
@@ -30,6 +31,7 @@ export function ChainsSelector({
defaultChainId,
isLoading,
disabledChainIds,
+ loadingChainIds,
}: ChainsSelectorProps): ReactNode {
const { darkMode } = useTheme()
@@ -44,6 +46,7 @@ export function ChainsSelector({
onSelectChain={onSelectChain}
isDarkMode={darkMode}
disabledChainIds={disabledChainIds}
+ loadingChainIds={loadingChainIds}
/>
)
}
@@ -75,6 +78,7 @@ interface ChainsListProps {
onSelectChain(chain: ChainInfo): void
isDarkMode: boolean
disabledChainIds?: Set
+ loadingChainIds?: Set
}
function ChainsList({
@@ -83,6 +87,7 @@ function ChainsList({
onSelectChain,
isDarkMode,
disabledChainIds,
+ loadingChainIds,
}: ChainsListProps): ReactNode {
return (
@@ -92,6 +97,7 @@ function ChainsList({
onSelectChain={onSelectChain}
isDarkMode={isDarkMode}
disabledChainIds={disabledChainIds}
+ loadingChainIds={loadingChainIds}
/>
)
@@ -103,6 +109,7 @@ function ChainsButtonsList({
onSelectChain,
isDarkMode,
disabledChainIds,
+ loadingChainIds,
}: ChainsListProps): ReactNode {
return (
<>
@@ -114,6 +121,7 @@ function ChainsButtonsList({
onSelectChain={onSelectChain}
isDarkMode={isDarkMode}
isDisabled={disabledChainIds?.has(chain.id) ?? false}
+ isLoading={loadingChainIds?.has(chain.id) ?? false}
/>
))}
>
@@ -139,37 +147,51 @@ interface ChainButtonProps {
isDarkMode: boolean
onSelectChain(chain: ChainInfo): void
isDisabled: boolean
+ isLoading: boolean
}
-function ChainButton({ chain, isActive, isDarkMode, onSelectChain, isDisabled }: ChainButtonProps): ReactNode {
+function ChainButton({
+ chain,
+ isActive,
+ isDarkMode,
+ onSelectChain,
+ isDisabled,
+ isLoading,
+}: ChainButtonProps): ReactNode {
const { i18n } = useLingui()
const logoSrc = isDarkMode ? chain.logo.dark : chain.logo.light
const accent = getChainAccent(chain.id)
const disabledTooltip = i18n._(msg`This destination is not supported for this source chain`)
+ const loadingTooltip = i18n._(msg`Checking route availability...`)
const handleClick = (): void => {
- if (!isDisabled) {
+ if (!isDisabled && !isLoading) {
onSelectChain(chain)
}
}
+ const tooltip = isLoading ? loadingTooltip : isDisabled ? disabledTooltip : undefined
+
return (