Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) => (
<div style={{ width: 352 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof TokenListItem>

export default meta
type Story = StoryObj<typeof meta>

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 },
}
Original file line number Diff line number Diff line change
@@ -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(
<TokenListItem
symbol="ETH"
subtitle="3 networks"
value="$0.00"
change="+13.31%"
/>,
)
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(<TokenListItem symbol="ETH" onClick={onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})

it('renders as a non-interactive div when no onClick', () => {
render(<TokenListItem symbol="ETH" />)
expect(screen.queryByRole('button')).toBeNull()
})

it('applies positive color by default for positive change', () => {
render(<TokenListItem symbol="ETH" change="+13.31%" />)
const change = screen.getByText('+13.31%')
expect(change.className).toContain('text-positive')
})

it('applies negative color for change starting with -', () => {
render(<TokenListItem symbol="ETH" change="-2.15%" />)
const change = screen.getByText('-2.15%')
expect(change.className).toContain('text-negative')
})

it('renders the skeleton when loading', () => {
render(<TokenListItem symbol="ETH" loading />)
expect(screen.queryByText('ETH')).toBeNull()
})

it('does not fire onClick when disabled', () => {
const onClick = vi.fn()
render(<TokenListItem symbol="ETH" onClick={onClick} disabled />)
fireEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})
})
192 changes: 192 additions & 0 deletions packages/react-ui/src/components/TokenListItem/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>['onClick'] } & Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
keyof CommonProps | 'children'
>)
| ({ onClick?: undefined } & Omit<
HTMLAttributes<HTMLDivElement>,
keyof CommonProps | 'children'
>)
)

function IconBlock({
iconName,
imageSource,
variant,
}: {
iconName?: IconName
imageSource?: string
variant: TokenListItemIconVariant
}) {
const isNetwork = variant === 'network'
return (
<div
className={cn(
'zd:shrink-0 zd:bg-white zd:flex zd:items-center zd:justify-center zd:overflow-hidden',
isNetwork
? 'zd:w-9 zd:h-9 zd:rounded-full'
: 'zd:w-11 zd:h-11 zd:rounded-2xl',
)}
>
{iconName ? (
<Icon name={iconName} className="zd:w-6 zd:h-6" />
) : imageSource ? (
<img
src={imageSource}
alt=""
className="zd:w-6 zd:h-6 zd:object-contain"
/>
) : null}
</div>
)
}

function TokenListItemSkeleton({
className,
}: {
className?: string | undefined
}) {
return (
<div
className={cn(
'zd:w-full zd:p-2 zd:flex zd:flex-row zd:items-center zd:justify-between zd:gap-2',
className,
)}
>
<div className="zd:flex zd:flex-row zd:items-center zd:gap-2 zd:min-w-0">
<div className="zd:w-11 zd:h-11 zd:shrink-0 zd:rounded-2xl zd:bg-offWhite/40 zd:animate-pulse" />
<div className="zd:flex zd:flex-col zd:gap-2">
<div className="zd:h-3 zd:w-14 zd:rounded-md zd:bg-offWhite/40 zd:animate-pulse" />
<div className="zd:h-3 zd:w-10 zd:rounded-md zd:bg-offWhite/40 zd:animate-pulse" />
</div>
</div>
<div className="zd:flex zd:flex-col zd:items-end zd:gap-2 zd:shrink-0">
<div className="zd:h-3 zd:w-12 zd:rounded-md zd:bg-offWhite/40 zd:animate-pulse" />
<div className="zd:h-3 zd:w-10 zd:rounded-md zd:bg-offWhite/40 zd:animate-pulse" />
</div>
</div>
)
}

export function TokenListItem({
symbol,
subtitle,
iconName,
imageSource,
subtitleIcon,
value,
change,
iconVariant = 'token',
loading = false,
className,
onClick,
...rest
}: TokenListItemProps) {
if (loading) return <TokenListItemSkeleton className={className} />

const changeColor = change?.startsWith('-')
? 'zd:text-negative'
: 'zd:text-positive'

const content = (
<>
<div className="zd:flex zd:flex-row zd:items-center zd:gap-2 zd:min-w-0">
<IconBlock
{...(iconName && { iconName })}
{...(imageSource && { imageSource })}
variant={iconVariant}
/>
<div className="zd:flex zd:flex-col zd:items-start zd:gap-2 zd:min-w-0">
<Text className="zd:text-body1 zd:truncate">{symbol}</Text>
{subtitle && (
<div className="zd:flex zd:flex-row zd:items-center zd:gap-1 zd:min-w-0">
{subtitleIcon && (
<span className="zd:inline-flex zd:w-3 zd:h-3 zd:rounded-full zd:bg-offWhite/50 zd:overflow-hidden zd:shrink-0 zd:items-center zd:justify-center">
<Icon name={subtitleIcon} className="zd:w-full zd:h-full" />
</span>
)}
<Text className="zd:text-body3 zd:text-greyScale/50 zd:truncate">
{subtitle}
</Text>
</div>
)}
</div>
</div>
{(value || change) && (
<div className="zd:flex zd:flex-col zd:items-end zd:gap-2 zd:shrink-0">
{value && <Text className="zd:text-body1 zd:truncate">{value}</Text>}
{change && (
<Text className={cn('zd:text-body3 zd:truncate', changeColor)}>
{change}
</Text>
)}
</div>
)}
</>
)

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 (
<button
type="button"
onClick={onClick}
className={cn(
baseClassName,
'zd:transition-colors zd:rounded-xl',
(rest as ButtonHTMLAttributes<HTMLButtonElement>).disabled
? 'zd:opacity-50 zd:cursor-not-allowed'
: 'zd:cursor-pointer zd:hover:bg-offWhite/40',
)}
{...(rest as ButtonHTMLAttributes<HTMLButtonElement>)}
>
{content}
</button>
)
}

return (
<div
className={baseClassName}
{...(rest as HTMLAttributes<HTMLDivElement>)}
>
{content}
</div>
)
}