Skip to content

Commit b6863ac

Browse files
committed
feat: address display component
1 parent f4ac300 commit b6863ac

10 files changed

Lines changed: 503 additions & 0 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { StorybookConfig } from '@storybook/react-vite'
2+
3+
const config: StorybookConfig = {
4+
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
5+
addons: ['@storybook/addon-a11y', '@storybook/addon-docs'],
6+
framework: '@storybook/react-vite',
7+
}
8+
export default config
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { Preview } from '@storybook/react-vite'
2+
import '../src/styles.css'
3+
4+
const preview: Preview = {
5+
parameters: {
6+
controls: {
7+
matchers: {
8+
color: /(background|color)$/i,
9+
date: /Date$/i,
10+
},
11+
},
12+
},
13+
}
14+
15+
export default preview
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
plugins: {
3+
'@tailwindcss/postcss': {},
4+
},
5+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
import { AddressDisplay } from './index'
3+
4+
const meta: Meta<typeof AddressDisplay> = {
5+
title: 'SmartRoutingAddress/AddressDisplay',
6+
component: AddressDisplay,
7+
parameters: { layout: 'centered' },
8+
decorators: [
9+
(Story) => (
10+
<div style={{ width: 368 }}>
11+
<Story />
12+
</div>
13+
),
14+
],
15+
argTypes: {
16+
onQrClick: { action: 'qr-clicked' },
17+
},
18+
}
19+
20+
export default meta
21+
type Story = StoryObj<typeof meta>
22+
23+
/**
24+
* Loading variant — matches Figma 17634:104343 ("Smart Funding" card).
25+
* Rendered while the parent is watching for a deposit at the generated
26+
* address.
27+
*/
28+
export const LoadingWatching: Story = {
29+
args: {
30+
loadingText: 'Watching for your deposit on Base…',
31+
},
32+
}
33+
34+
/**
35+
* Same loading variant, used earlier in the flow while the SRA server is
36+
* still generating the deposit address itself.
37+
*/
38+
export const LoadingGenerating: Story = {
39+
args: {
40+
loadingText: 'Generating deposit address…',
41+
},
42+
}
43+
44+
/**
45+
* Ready variant — matches Figma 17762:78875. Shows the full deposit address
46+
* on the left and a 52×52 white QR button on the right.
47+
*/
48+
export const Ready: Story = {
49+
args: {
50+
address: '0x8f527b33CD7c791aEDe7EbA077140D81A0000001',
51+
onQrClick: () => {},
52+
},
53+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @vitest-environment happy-dom
3+
*/
4+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
import { AddressDisplay } from './index'
7+
8+
afterEach(cleanup)
9+
10+
const ADDRESS = '0x8f527b33CD7c791aEDe7EbA077140D81A0000001'
11+
12+
describe('AddressDisplay', () => {
13+
describe('loading variant', () => {
14+
it('renders the loading variant when address is omitted', () => {
15+
render(<AddressDisplay />)
16+
expect(screen.getByTestId('address-display-loading')).toBeDefined()
17+
expect(screen.queryByTestId('address-display-ready')).toBeNull()
18+
})
19+
20+
it('renders the default loading text', () => {
21+
render(<AddressDisplay />)
22+
expect(screen.getByText('Watching for your deposit…')).toBeDefined()
23+
})
24+
25+
it('respects the loadingText prop', () => {
26+
render(<AddressDisplay loadingText="Generating deposit address…" />)
27+
expect(screen.getByText('Generating deposit address…')).toBeDefined()
28+
})
29+
30+
it('does not render the QR button', () => {
31+
render(<AddressDisplay />)
32+
expect(screen.queryByTestId('address-display-qr-button')).toBeNull()
33+
})
34+
})
35+
36+
describe('ready variant', () => {
37+
it('renders the ready variant when address is supplied', () => {
38+
render(<AddressDisplay address={ADDRESS} />)
39+
expect(screen.getByTestId('address-display-ready')).toBeDefined()
40+
expect(screen.queryByTestId('address-display-loading')).toBeNull()
41+
})
42+
43+
it('renders the full address text', () => {
44+
render(<AddressDisplay address={ADDRESS} />)
45+
expect(screen.getByTestId('address-display-address').textContent).toBe(
46+
ADDRESS,
47+
)
48+
})
49+
50+
it('renders the QR button', () => {
51+
render(<AddressDisplay address={ADDRESS} />)
52+
expect(screen.getByTestId('address-display-qr-button')).toBeDefined()
53+
expect(screen.getByRole('button', { name: 'Show QR code' })).toBeDefined()
54+
})
55+
56+
it('fires onQrClick when the QR button is clicked', () => {
57+
const onQrClick = vi.fn()
58+
render(<AddressDisplay address={ADDRESS} onQrClick={onQrClick} />)
59+
fireEvent.click(screen.getByTestId('address-display-qr-button'))
60+
expect(onQrClick).toHaveBeenCalledTimes(1)
61+
})
62+
63+
it('does not throw when onQrClick is omitted and the button is clicked', () => {
64+
render(<AddressDisplay address={ADDRESS} />)
65+
expect(() =>
66+
fireEvent.click(screen.getByTestId('address-display-qr-button')),
67+
).not.toThrow()
68+
})
69+
})
70+
})
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { cn, Icon, Text, Wrapper } from '@zerodev/react-ui'
2+
3+
/**
4+
* The card that renders the deposit address in the SRA "Deposit funds" screen.
5+
*
6+
* Two visual states, each mapped 1:1 to a Figma node:
7+
* - Loading (Figma 17634:104343, "Smart Funding" card): 16px radius, 20%
8+
* white surface, centered loading icon + `loadingText`. Rendered when
9+
* `address` is omitted.
10+
* - Ready (Figma 17762:78875, address+QR row): 14px radius, 40% offWhite
11+
* surface, left-aligned address text + a 52×52 white QR button on the
12+
* right. Rendered when `address` is supplied.
13+
*
14+
* The two states have deliberately different visuals; they share this
15+
* component because the parent renders the same slot in both cases.
16+
*/
17+
export interface AddressDisplayProps {
18+
/** Deposit address to render; when omitted, the loading variant is shown. */
19+
address?: string
20+
/**
21+
* Message rendered in the loading variant. Include chain-specific context
22+
* from the caller, e.g., `Watching for your deposit on Base…`.
23+
*/
24+
loadingText?: string
25+
/** Handler for the QR icon button (ready variant). */
26+
onQrClick?: () => void
27+
className?: string
28+
}
29+
30+
export function AddressDisplay({
31+
address,
32+
loadingText = 'Watching for your deposit…',
33+
onQrClick,
34+
className,
35+
}: AddressDisplayProps) {
36+
if (address === undefined) {
37+
return (
38+
<Wrapper
39+
variant="ghost"
40+
className={cn(
41+
// Figma: rounded-2xl (16px), p-16, centered content, gap 8px, 20%
42+
// white surface from Wrapper's ghost variant. Height pinned to
43+
// 68px so it matches the ready variant.
44+
'zd:relative zd:flex zd:h-[68px] zd:w-full zd:items-center zd:justify-center zd:gap-2 zd:overflow-hidden zd:rounded-2xl zd:px-4',
45+
'zd:shadow-[inset_0_-4px_4px_0_rgba(255,255,255,0.1),inset_0_3px_4px_0_rgba(0,0,0,0.02)]',
46+
className,
47+
)}
48+
data-testid="address-display-loading"
49+
>
50+
<Icon name="lineLoading" className="zd:size-4 zd:text-greyScale/50" />
51+
<Text className="zd:text-body1 zd:text-greyScale/50">
52+
{loadingText}
53+
</Text>
54+
</Wrapper>
55+
)
56+
}
57+
58+
return (
59+
<Wrapper
60+
variant="ghost"
61+
// Figma's 40% offWhite surface — none of Wrapper's white-only variants
62+
// hit this tint, so override the backgroundColor. Inline style wins
63+
// over Wrapper's own style prop.
64+
style={{ backgroundColor: 'rgba(247, 245, 240, 0.4)' }}
65+
className={cn(
66+
// Figma: rounded-[14px], pl-16 pr-8 py-8, gap-12 items-center.
67+
// Height pinned to 68px so the loading and ready variants swap without
68+
// the layout jumping.
69+
'zd:relative zd:flex zd:h-[68px] zd:w-full zd:items-center zd:gap-3 zd:overflow-hidden zd:rounded-[14px] zd:pl-4 zd:pr-2 zd:py-2',
70+
'zd:shadow-[inset_0_-4px_4px_0_rgba(255,255,255,0.1),inset_0_3px_4px_0_rgba(0,0,0,0.02)]',
71+
className,
72+
)}
73+
data-testid="address-display-ready"
74+
>
75+
<Text
76+
className="zd:min-w-0 zd:flex-1 zd:break-all zd:text-body2"
77+
data-testid="address-display-address"
78+
>
79+
{address}
80+
</Text>
81+
<button
82+
type="button"
83+
onClick={onQrClick}
84+
aria-label="Show QR code"
85+
className="zd:flex zd:size-13 zd:shrink-0 zd:cursor-pointer zd:items-center zd:justify-center zd:rounded-2xl zd:bg-white"
86+
data-testid="address-display-qr-button"
87+
>
88+
<Icon name="qrCode" className="zd:size-5 zd:text-greyScale" />
89+
</button>
90+
</Wrapper>
91+
)
92+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
import { TokenChainPill } from './index'
3+
4+
const meta: Meta<typeof TokenChainPill> = {
5+
title: 'SmartRoutingAddress/TokenChainPill',
6+
component: TokenChainPill,
7+
parameters: { layout: 'centered' },
8+
decorators: [
9+
(Story) => (
10+
<div style={{ width: 162 }}>
11+
<Story />
12+
</div>
13+
),
14+
],
15+
argTypes: {
16+
logoBg: { control: 'color' },
17+
onClick: { action: 'clicked' },
18+
},
19+
}
20+
21+
export default meta
22+
type Story = StoryObj<typeof meta>
23+
24+
/** Interactive source-token pill — the default variant shown in Figma. */
25+
export const InteractiveToken: Story = {
26+
args: {
27+
label: 'USDC',
28+
logoBg: '#2775CA',
29+
logoInitial: 'U',
30+
onClick: () => {},
31+
},
32+
}
33+
34+
/** Interactive source-chain pill. */
35+
export const InteractiveChain: Story = {
36+
args: {
37+
label: 'Base',
38+
logoBg: '#0052FF',
39+
logoInitial: 'B',
40+
onClick: () => {},
41+
},
42+
}
43+
44+
/**
45+
* Display variant — the destination pill from the "Arrives as" card
46+
* (Figma 17777:81278). Renders on a 5% white surface with no chevron.
47+
* Achieved by omitting `onClick`; setting `disabled: true` alongside an
48+
* `onClick` handler produces the same visual.
49+
*/
50+
export const Display: Story = {
51+
args: {
52+
label: 'Arbitrum One',
53+
logoBg: '#28A0F0',
54+
logoInitial: 'A',
55+
},
56+
}
57+
58+
/** Same display variant, but with `disabled: true` forcing a passed
59+
* `onClick` handler to be ignored — useful for "temporarily unavailable"
60+
* states without unmounting/remounting props. */
61+
export const DisplayForcedDisabled: Story = {
62+
args: {
63+
label: 'Arbitrum One',
64+
logoBg: '#28A0F0',
65+
logoInitial: 'A',
66+
onClick: () => {},
67+
disabled: true,
68+
},
69+
}
70+
71+
/** With an external logo image supplied. */
72+
export const WithLogoImage: Story = {
73+
args: {
74+
label: 'USDC',
75+
logoUri:
76+
'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png',
77+
onClick: () => {},
78+
},
79+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* @vitest-environment happy-dom
3+
*/
4+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
5+
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
import { TokenChainPill } from './index'
7+
8+
afterEach(cleanup)
9+
10+
describe('TokenChainPill', () => {
11+
it('renders the label', () => {
12+
render(<TokenChainPill label="USDC" />)
13+
expect(screen.getByText('USDC')).toBeDefined()
14+
})
15+
16+
it('renders the logo image when logoUri is provided', () => {
17+
render(
18+
<TokenChainPill label="USDC" logoUri="https://example.com/usdc.png" />,
19+
)
20+
const img = screen.getByTestId('token-chain-pill-logo')
21+
expect(img.getAttribute('src')).toBe('https://example.com/usdc.png')
22+
})
23+
24+
it('renders the initial placeholder when logoUri is absent', () => {
25+
render(<TokenChainPill label="Base" logoInitial="B" />)
26+
expect(screen.getByText('B')).toBeDefined()
27+
})
28+
29+
it('is not interactive by default (no chevron, no button role)', () => {
30+
render(<TokenChainPill label="USDC" />)
31+
expect(screen.queryByTestId('token-chain-pill-chevron')).toBeNull()
32+
expect(screen.queryByRole('button')).toBeNull()
33+
})
34+
35+
it('becomes an interactive button when onClick is supplied', () => {
36+
const onClick = vi.fn()
37+
render(<TokenChainPill label="USDC" onClick={onClick} />)
38+
const button = screen.getByRole('button')
39+
expect(button).toBeDefined()
40+
expect(screen.getByTestId('token-chain-pill-chevron')).toBeDefined()
41+
fireEvent.click(button)
42+
expect(onClick).toHaveBeenCalledTimes(1)
43+
})
44+
45+
it('triggers onClick on Enter and Space keys', () => {
46+
const onClick = vi.fn()
47+
render(<TokenChainPill label="USDC" onClick={onClick} />)
48+
const button = screen.getByRole('button')
49+
fireEvent.keyDown(button, { key: 'Enter' })
50+
fireEvent.keyDown(button, { key: ' ' })
51+
expect(onClick).toHaveBeenCalledTimes(2)
52+
})
53+
54+
it('ignores unrelated keys', () => {
55+
const onClick = vi.fn()
56+
render(<TokenChainPill label="USDC" onClick={onClick} />)
57+
fireEvent.keyDown(screen.getByRole('button'), { key: 'a' })
58+
expect(onClick).not.toHaveBeenCalled()
59+
})
60+
61+
it('renders as non-interactive when disabled, even with onClick', () => {
62+
const onClick = vi.fn()
63+
render(<TokenChainPill label="Arbitrum One" onClick={onClick} disabled />)
64+
expect(screen.queryByRole('button')).toBeNull()
65+
expect(screen.queryByTestId('token-chain-pill-chevron')).toBeNull()
66+
})
67+
})

0 commit comments

Comments
 (0)