Skip to content

Commit fff48b9

Browse files
committed
rebase
1 parent a9810fa commit fff48b9

10 files changed

Lines changed: 371 additions & 7 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

packages/smart-routing-address-react-ui/package.json

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
"name": "@zerodev/smart-routing-address-react-ui",
33
"version": "0.0.1",
44
"private": true,
5-
"description": "React hooks + provider for ZeroDev Smart Routing Address deposits",
5+
"description": "React hooks, provider, and UI for ZeroDev Smart Routing Address deposits",
66
"repository": {
77
"type": "git",
88
"url": "git+https://github.com/zerodevapp/zerodev-wallet-sdk.git",
99
"directory": "packages/smart-routing-address-react-ui"
1010
},
11-
"sideEffects": false,
11+
"sideEffects": [
12+
"**/*.css"
13+
],
1214
"main": "./dist/index.cjs",
1315
"module": "./dist/index.mjs",
1416
"types": "./dist/_types/index.d.ts",
@@ -17,50 +19,69 @@
1719
"types": "./dist/_types/index.d.ts",
1820
"import": "./dist/index.mjs",
1921
"require": "./dist/index.cjs"
20-
}
22+
},
23+
"./styles.css": "./dist/styles.css"
2124
},
2225
"files": [
2326
"dist",
2427
"src/**/*.ts",
2528
"src/**/*.tsx",
2629
"!dist/**/*.tsbuildinfo",
2730
"!src/**/*.test.ts",
28-
"!src/**/*.test.tsx"
31+
"!src/**/*.test.tsx",
32+
"!src/**/*.stories.ts",
33+
"!src/**/*.stories.tsx"
2934
],
3035
"scripts": {
31-
"build": "pnpm run clean && pnpm run build:js && pnpm run build:types",
36+
"build": "pnpm run clean && pnpm run build:css && pnpm run build:js && pnpm run build:types",
37+
"build:css": "npx @tailwindcss/cli -i ./src/styles.css -o ./dist/styles.css --minify",
3238
"build:js": "vite build",
3339
"build:types": "tsc --project ./tsconfig.build.json --outDir ./dist/_types --emitDeclarationOnly --declaration --declarationMap",
3440
"dev": "tsc --watch",
41+
"dev:css": "npx @tailwindcss/cli -i ./src/styles.css -o ./dist/styles.css --watch",
3542
"typecheck": "tsc --noEmit --project ./tsconfig.build.json",
3643
"clean": "rm -rf dist",
3744
"test": "vitest run",
38-
"test:watch": "vitest"
45+
"test:watch": "vitest",
46+
"storybook": "storybook dev -p 6008",
47+
"build-storybook": "storybook build"
3948
},
4049
"keywords": [
4150
"react",
4251
"hooks",
52+
"components",
4353
"smart-routing",
4454
"deposit",
4555
"zerodev",
4656
"web3"
4757
],
58+
"dependencies": {
59+
"@zerodev/react-ui": "workspace:*"
60+
},
4861
"peerDependencies": {
4962
"@zerodev/smart-routing-address": "^0.2.5",
5063
"react": "^18.0.0 || ^19.0.0",
5164
"react-dom": "^18.0.0 || ^19.0.0",
5265
"viem": "^2.47.18"
5366
},
5467
"devDependencies": {
68+
"@storybook/addon-a11y": "^10.3.3",
69+
"@storybook/addon-docs": "^10.3.3",
70+
"@storybook/react-vite": "^10.3.3",
71+
"@tailwindcss/cli": "^4.2.2",
72+
"@tailwindcss/postcss": "^4.2.2",
5573
"@testing-library/react": "^16.3.0",
5674
"@types/react": "~19.2.14",
5775
"@types/react-dom": "^19",
5876
"@vitejs/plugin-react": "^4.7.0",
5977
"@vitest/coverage-v8": "^4.0.18",
6078
"@zerodev/smart-routing-address": "^0.2.5",
6179
"happy-dom": "^20.6.0",
80+
"postcss": "^8.5.8",
6281
"react": "19.2.0",
6382
"react-dom": "19.2.0",
83+
"storybook": "^10.3.3",
84+
"tailwindcss": "^4.2.2",
6485
"typescript": "5.9.3",
6586
"viem": "^2.47.18",
6687
"vite": "^6.4.1",
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: 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+
})
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { cn, Icon, Text, Wrapper } from '@zerodev/react-ui'
2+
import type { KeyboardEvent } from 'react'
3+
4+
export interface TokenChainPillProps {
5+
/** Text label rendered next to the logo (e.g., "USDC", "Base"). */
6+
label: string
7+
/** URL of the logo image; when omitted, a `logoBg` + `logoInitial` placeholder is drawn. */
8+
logoUri?: string
9+
/** Fallback background color when no `logoUri` is supplied. */
10+
logoBg?: string
11+
/** Fallback initial letter shown inside the placeholder circle. */
12+
logoInitial?: string
13+
/** Click handler; when supplied and not `disabled`, the pill becomes a keyboard-accessible button. */
14+
onClick?: () => void
15+
/** When true, renders as a dimmed, non-interactive pill (no chevron). */
16+
disabled?: boolean
17+
className?: string
18+
}
19+
20+
export function TokenChainPill({
21+
label,
22+
logoUri,
23+
logoBg = '#E6EFFB',
24+
logoInitial,
25+
onClick,
26+
disabled,
27+
className,
28+
}: TokenChainPillProps) {
29+
const interactive = Boolean(onClick) && !disabled
30+
31+
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
32+
if (!interactive) return
33+
if (event.key === 'Enter' || event.key === ' ') {
34+
event.preventDefault()
35+
onClick?.()
36+
}
37+
}
38+
39+
return (
40+
<Wrapper
41+
variant="solid"
42+
// Override Wrapper's variant-based bg color for the display variant —
43+
// Figma spec is exactly rgba(255,255,255,0.05), which none of the
44+
// Wrapper variants match. Inline style beats Wrapper's own style.
45+
style={
46+
interactive
47+
? undefined
48+
: { backgroundColor: 'rgba(255, 255, 255, 0.05)' }
49+
}
50+
className={cn(
51+
// Sizing/padding matches Figma: outer pl-1 pr-2 py-1, rounded-2xl.
52+
// Height is content-driven — 44px logo + 4px vertical padding = 52px.
53+
'zd:relative zd:flex zd:w-full zd:items-center zd:justify-between zd:overflow-hidden zd:rounded-2xl zd:pl-1 zd:pr-2 zd:py-1',
54+
// Universal inner shadow from the Figma design token.
55+
'zd:shadow-[inset_0_-4px_4px_0_rgba(255,255,255,0.1),inset_0_3px_4px_0_rgba(0,0,0,0.02)]',
56+
interactive && 'zd:cursor-pointer',
57+
className,
58+
)}
59+
{...(interactive && {
60+
role: 'button',
61+
tabIndex: 0,
62+
onClick,
63+
onKeyDown: handleKeyDown,
64+
})}
65+
>
66+
<div className="zd:flex zd:items-center zd:gap-1.5">
67+
{/* 44×44 logo well with a 34×34 centered inner disc. Figma places the
68+
image with translate-1/2 offsets so the smaller disc sits inside
69+
the larger well — the 10px reveal reads as the pill's "logo pad". */}
70+
<div
71+
className="zd:relative zd:size-11 zd:shrink-0 zd:rounded-xl"
72+
aria-hidden={!logoUri}
73+
>
74+
<div
75+
className={cn(
76+
'zd:absolute zd:top-1/2 zd:left-1/2 zd:-translate-x-1/2 zd:-translate-y-1/2',
77+
'zd:flex zd:size-8.5 zd:items-center zd:justify-center zd:overflow-hidden zd:rounded-full',
78+
)}
79+
style={logoUri ? undefined : { backgroundColor: logoBg }}
80+
>
81+
{logoUri ? (
82+
<img
83+
alt=""
84+
src={logoUri}
85+
className="zd:size-full zd:object-cover"
86+
data-testid="token-chain-pill-logo"
87+
/>
88+
) : logoInitial ? (
89+
<span className="zd:text-body2 zd:font-medium zd:text-white">
90+
{logoInitial}
91+
</span>
92+
) : null}
93+
</div>
94+
</div>
95+
<Text className="zd:whitespace-nowrap zd:text-body1">{label}</Text>
96+
</div>
97+
{interactive && (
98+
<div className="zd:flex zd:shrink-0 zd:items-center zd:rounded-full zd:p-2">
99+
<Icon
100+
name="chevronDown"
101+
className="zd:size-4 zd:text-greyScale"
102+
data-testid="token-chain-pill-chevron"
103+
/>
104+
</div>
105+
)}
106+
</Wrapper>
107+
)
108+
}

packages/smart-routing-address-react-ui/src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
/**
22
* @zerodev/smart-routing-address-react-ui
3-
* React hooks + provider for ZeroDev Smart Routing Address deposits.
3+
* React hooks, provider, and UI for ZeroDev Smart Routing Address deposits.
44
*/
55

6+
// Components
7+
export type { TokenChainPillProps } from './components/TokenChainPill'
8+
export { TokenChainPill } from './components/TokenChainPill'
9+
610
// Provider
711
export type { SmartRoutingAddressProviderProps } from './context/SmartRoutingAddressProvider'
812
export { SmartRoutingAddressProvider } from './context/SmartRoutingAddressProvider'

0 commit comments

Comments
 (0)