diff --git a/packages/react-ui/src/components/TokenListItem/TokenListItem.stories.tsx b/packages/react-ui/src/components/TokenListItem/TokenListItem.stories.tsx new file mode 100644 index 00000000..7e57147d --- /dev/null +++ b/packages/react-ui/src/components/TokenListItem/TokenListItem.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' + +import { TokenListItem } from '.' + +const meta = { + title: 'Shared/TokenListItem', + component: TokenListItem, + parameters: { layout: 'centered' }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Token: Story = { + args: { + symbol: 'ETH', + subtitle: '3 networks', + iconName: 'ethereum', + value: '$0.00', + change: '+13.31%', + }, +} + +export const InclChain: Story = { + args: { + symbol: 'ETH', + subtitle: 'Arbitrum', + subtitleIcon: 'arbitrum', + iconName: 'ethereum', + value: '$0.00', + change: '+13.31%', + }, +} + +export const Network: Story = { + args: { + symbol: 'ETH', + subtitle: '3 networks', + iconName: 'ethereum', + iconVariant: 'network', + value: '$0.00', + change: '+13.31%', + }, +} + +export const NegativeChange: Story = { + args: { + symbol: 'ETH', + subtitle: '3 networks', + iconName: 'ethereum', + value: '$0.00', + change: '-2.15%', + }, +} + +export const Loading: Story = { + args: { symbol: '', loading: true }, +} diff --git a/packages/react-ui/src/components/TokenListItem/TokenListItem.test.tsx b/packages/react-ui/src/components/TokenListItem/TokenListItem.test.tsx new file mode 100644 index 00000000..54de5cdc --- /dev/null +++ b/packages/react-ui/src/components/TokenListItem/TokenListItem.test.tsx @@ -0,0 +1,62 @@ +/** + * @vitest-environment happy-dom + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { TokenListItem } from '.' + +afterEach(() => cleanup()) + +describe('TokenListItem', () => { + it('renders symbol, subtitle, value, and change', () => { + render( + , + ) + expect(screen.getByText('ETH')).toBeDefined() + expect(screen.getByText('3 networks')).toBeDefined() + expect(screen.getByText('$0.00')).toBeDefined() + expect(screen.getByText('+13.31%')).toBeDefined() + }) + + it('renders as a button when onClick is provided', () => { + const onClick = vi.fn() + render() + fireEvent.click(screen.getByRole('button')) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('renders as a non-interactive div when no onClick', () => { + render() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('applies positive color by default for positive change', () => { + render() + const change = screen.getByText('+13.31%') + expect(change.className).toContain('text-positive') + }) + + it('applies negative color for change starting with -', () => { + render() + const change = screen.getByText('-2.15%') + expect(change.className).toContain('text-negative') + }) + + it('renders the skeleton when loading', () => { + render() + expect(screen.queryByText('ETH')).toBeNull() + }) + + it('does not fire onClick when disabled', () => { + const onClick = vi.fn() + render() + fireEvent.click(screen.getByRole('button')) + expect(onClick).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-ui/src/components/TokenListItem/index.tsx b/packages/react-ui/src/components/TokenListItem/index.tsx new file mode 100644 index 00000000..32078d36 --- /dev/null +++ b/packages/react-ui/src/components/TokenListItem/index.tsx @@ -0,0 +1,192 @@ +import type { ButtonHTMLAttributes, HTMLAttributes } from 'react' + +import { cn } from '../../utils/common' +import { Icon, type IconName } from '../Icon' +import { Text } from '../Text' + +export type TokenListItemIconVariant = 'token' | 'network' + +interface CommonProps { + /** Primary text — usually a token symbol like "ETH". */ + symbol: string + /** Subtitle text under the symbol (e.g. "3 networks" or "Arbitrum"). */ + subtitle?: string + /** Kit `Icon` name for the main slot (preferred for known chains/tokens). */ + iconName?: IconName + /** URL of the main icon image (token logo). Used when `iconName` is absent. */ + imageSource?: string + /** Kit `Icon` name rendered inline before the subtitle text (e.g. chain logo). */ + subtitleIcon?: IconName + /** Right-aligned value (e.g. "$0.00"). */ + value?: string + /** Right-aligned percentage change. Coloured green by default, red when + * the string starts with a leading "-". */ + change?: string + /** Shape of the main icon. `'token'` = 44x44 rounded square (default), + * `'network'` = 36x36 circle. */ + iconVariant?: TokenListItemIconVariant + /** Render a skeleton placeholder instead of the content. */ + loading?: boolean +} + +export type TokenListItemProps = CommonProps & + ( + | ({ onClick: ButtonHTMLAttributes['onClick'] } & Omit< + ButtonHTMLAttributes, + keyof CommonProps | 'children' + >) + | ({ onClick?: undefined } & Omit< + HTMLAttributes, + keyof CommonProps | 'children' + >) + ) + +function IconBlock({ + iconName, + imageSource, + variant, +}: { + iconName?: IconName + imageSource?: string + variant: TokenListItemIconVariant +}) { + const isNetwork = variant === 'network' + return ( +
+ {iconName ? ( + + ) : imageSource ? ( + + ) : null} +
+ ) +} + +function TokenListItemSkeleton({ + className, +}: { + className?: string | undefined +}) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ) +} + +export function TokenListItem({ + symbol, + subtitle, + iconName, + imageSource, + subtitleIcon, + value, + change, + iconVariant = 'token', + loading = false, + className, + onClick, + ...rest +}: TokenListItemProps) { + if (loading) return + + const changeColor = change?.startsWith('-') + ? 'zd:text-negative' + : 'zd:text-positive' + + const content = ( + <> +
+ +
+ {symbol} + {subtitle && ( +
+ {subtitleIcon && ( + + + + )} + + {subtitle} + +
+ )} +
+
+ {(value || change) && ( +
+ {value && {value}} + {change && ( + + {change} + + )} +
+ )} + + ) + + const baseClassName = cn( + 'zd:w-full zd:p-2 zd:flex zd:flex-row zd:items-center zd:justify-between zd:gap-2 zd:text-left', + className, + ) + + if (onClick) { + return ( + + ) + } + + return ( +
)} + > + {content} +
+ ) +} diff --git a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx index 123dba9c..417718ea 100644 --- a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx +++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx @@ -26,7 +26,6 @@ export const InteractiveToken: Story = { args: { label: 'USDC', logoBg: '#2775CA', - logoInitial: 'U', onClick: () => {}, }, } @@ -36,7 +35,6 @@ export const InteractiveChain: Story = { args: { label: 'Base', logoBg: '#0052FF', - logoInitial: 'B', onClick: () => {}, }, } @@ -51,7 +49,6 @@ export const Display: Story = { args: { label: 'Arbitrum One', logoBg: '#28A0F0', - logoInitial: 'A', }, } @@ -62,7 +59,6 @@ export const DisplayForcedDisabled: Story = { args: { label: 'Arbitrum One', logoBg: '#28A0F0', - logoInitial: 'A', onClick: () => {}, disabled: true, }, diff --git a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx index 7aef8baa..e3179e35 100644 --- a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx +++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx @@ -22,7 +22,7 @@ describe('TokenChainPill', () => { }) it('renders the initial placeholder when logoUri is absent', () => { - render() + render() expect(screen.getByText('B')).toBeDefined() }) diff --git a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx index ad249d75..930fda0d 100644 --- a/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx +++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx @@ -4,15 +4,10 @@ import type { KeyboardEvent } from 'react' export interface TokenChainPillProps { /** Text label rendered next to the logo (e.g., "USDC", "Base"). */ label: string - /** URL of the logo image; when omitted, a `logoBg` + `logoInitial` placeholder is drawn. */ + /** URL of the logo image; when omitted, a `logoBg` + first letter of `label` placeholder is drawn. */ logoUri?: string /** Fallback background color when no `logoUri` is supplied. */ logoBg?: string - /** Fallback initial letter shown inside the placeholder circle. Defaults - * to the first character of `label` (uppercased) — so most callers can - * omit this and let the pill derive it. Pass explicitly to override - * (e.g. `label="Arbitrum One"` but you want `"A"` regardless of casing). */ - logoInitial?: string /** Click handler; when supplied and not `disabled`, the pill becomes a keyboard-accessible button. */ onClick?: () => void /** When true, renders as a dimmed, non-interactive pill (no chevron). */ @@ -24,11 +19,12 @@ export function TokenChainPill({ label, logoUri, logoBg = '#E6EFFB', - logoInitial = label.charAt(0).toUpperCase(), onClick, disabled, className, }: TokenChainPillProps) { + const logoInitial = label.charAt(0).toUpperCase() + const interactive = Boolean(onClick) && !disabled const handleKeyDown = (event: KeyboardEvent) => {