diff --git a/packages/smart-routing-address-react-ui/.storybook/main.ts b/packages/smart-routing-address-react-ui/.storybook/main.ts new file mode 100644 index 00000000..bb0ca3c1 --- /dev/null +++ b/packages/smart-routing-address-react-ui/.storybook/main.ts @@ -0,0 +1,8 @@ +import type { StorybookConfig } from '@storybook/react-vite' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-a11y', '@storybook/addon-docs'], + framework: '@storybook/react-vite', +} +export default config diff --git a/packages/smart-routing-address-react-ui/.storybook/preview.tsx b/packages/smart-routing-address-react-ui/.storybook/preview.tsx new file mode 100644 index 00000000..d6f199f8 --- /dev/null +++ b/packages/smart-routing-address-react-ui/.storybook/preview.tsx @@ -0,0 +1,15 @@ +import type { Preview } from '@storybook/react-vite' +import '../src/styles.css' + +const preview: Preview = { + parameters: { + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + }, +} + +export default preview diff --git a/packages/smart-routing-address-react-ui/package.json b/packages/smart-routing-address-react-ui/package.json index 83894c13..d8a67e67 100644 --- a/packages/smart-routing-address-react-ui/package.json +++ b/packages/smart-routing-address-react-ui/package.json @@ -2,13 +2,15 @@ "name": "@zerodev/smart-routing-address-react-ui", "version": "0.0.1", "private": true, - "description": "React hooks + provider for ZeroDev Smart Routing Address deposits", + "description": "React hooks, provider, and UI for ZeroDev Smart Routing Address deposits", "repository": { "type": "git", "url": "git+https://github.com/zerodevapp/zerodev-wallet-sdk.git", "directory": "packages/smart-routing-address-react-ui" }, - "sideEffects": false, + "sideEffects": [ + "**/*.css" + ], "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/_types/index.d.ts", @@ -17,7 +19,8 @@ "types": "./dist/_types/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" - } + }, + "./styles.css": "./dist/styles.css" }, "files": [ "dist", @@ -25,26 +28,36 @@ "src/**/*.tsx", "!dist/**/*.tsbuildinfo", "!src/**/*.test.ts", - "!src/**/*.test.tsx" + "!src/**/*.test.tsx", + "!src/**/*.stories.ts", + "!src/**/*.stories.tsx" ], "scripts": { - "build": "pnpm run clean && pnpm run build:js && pnpm run build:types", + "build": "pnpm run clean && pnpm run build:css && pnpm run build:js && pnpm run build:types", + "build:css": "npx @tailwindcss/cli -i ./src/styles.css -o ./dist/styles.css --minify", "build:js": "vite build", "build:types": "tsc --project ./tsconfig.build.json --outDir ./dist/_types --emitDeclarationOnly --declaration --declarationMap", "dev": "tsc --watch", + "dev:css": "npx @tailwindcss/cli -i ./src/styles.css -o ./dist/styles.css --watch", "typecheck": "tsc --noEmit --project ./tsconfig.build.json", "clean": "rm -rf dist", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "storybook": "storybook dev -p 6008", + "build-storybook": "storybook build" }, "keywords": [ "react", "hooks", + "components", "smart-routing", "deposit", "zerodev", "web3" ], + "dependencies": { + "@zerodev/react-ui": "workspace:*" + }, "peerDependencies": { "@zerodev/smart-routing-address": "^0.2.5", "react": "^18.0.0 || ^19.0.0", @@ -52,6 +65,11 @@ "viem": "^2.47.18" }, "devDependencies": { + "@storybook/addon-a11y": "^10.3.3", + "@storybook/addon-docs": "^10.3.3", + "@storybook/react-vite": "^10.3.3", + "@tailwindcss/cli": "^4.2.2", + "@tailwindcss/postcss": "^4.2.2", "@testing-library/react": "^16.3.0", "@types/react": "~19.2.14", "@types/react-dom": "^19", @@ -59,8 +77,11 @@ "@vitest/coverage-v8": "^4.0.18", "@zerodev/smart-routing-address": "^0.2.5", "happy-dom": "^20.6.0", + "postcss": "^8.5.8", "react": "19.2.0", "react-dom": "19.2.0", + "storybook": "^10.3.3", + "tailwindcss": "^4.2.2", "typescript": "5.9.3", "viem": "^2.47.18", "vite": "^6.4.1", diff --git a/packages/smart-routing-address-react-ui/postcss.config.js b/packages/smart-routing-address-react-ui/postcss.config.js new file mode 100644 index 00000000..a7f73a2d --- /dev/null +++ b/packages/smart-routing-address-react-ui/postcss.config.js @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +} 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 new file mode 100644 index 00000000..123dba9c --- /dev/null +++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.stories.tsx @@ -0,0 +1,79 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TokenChainPill } from './index' + +const meta: Meta = { + title: 'SmartRoutingAddress/TokenChainPill', + component: TokenChainPill, + parameters: { layout: 'centered' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + logoBg: { control: 'color' }, + onClick: { action: 'clicked' }, + }, +} + +export default meta +type Story = StoryObj + +/** Interactive source-token pill — the default variant shown in Figma. */ +export const InteractiveToken: Story = { + args: { + label: 'USDC', + logoBg: '#2775CA', + logoInitial: 'U', + onClick: () => {}, + }, +} + +/** Interactive source-chain pill. */ +export const InteractiveChain: Story = { + args: { + label: 'Base', + logoBg: '#0052FF', + logoInitial: 'B', + onClick: () => {}, + }, +} + +/** + * Display variant — the destination pill from the "Arrives as" card + * (Figma 17777:81278). Renders on a 5% white surface with no chevron. + * Achieved by omitting `onClick`; setting `disabled: true` alongside an + * `onClick` handler produces the same visual. + */ +export const Display: Story = { + args: { + label: 'Arbitrum One', + logoBg: '#28A0F0', + logoInitial: 'A', + }, +} + +/** Same display variant, but with `disabled: true` forcing a passed + * `onClick` handler to be ignored — useful for "temporarily unavailable" + * states without unmounting/remounting props. */ +export const DisplayForcedDisabled: Story = { + args: { + label: 'Arbitrum One', + logoBg: '#28A0F0', + logoInitial: 'A', + onClick: () => {}, + disabled: true, + }, +} + +/** With an external logo image supplied. */ +export const WithLogoImage: Story = { + args: { + label: 'USDC', + logoUri: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + onClick: () => {}, + }, +} 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 new file mode 100644 index 00000000..7aef8baa --- /dev/null +++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/TokenChainPill.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment happy-dom + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TokenChainPill } from './index' + +afterEach(cleanup) + +describe('TokenChainPill', () => { + it('renders the label', () => { + render() + expect(screen.getByText('USDC')).toBeDefined() + }) + + it('renders the logo image when logoUri is provided', () => { + render( + , + ) + const img = screen.getByTestId('token-chain-pill-logo') + expect(img.getAttribute('src')).toBe('https://example.com/usdc.png') + }) + + it('renders the initial placeholder when logoUri is absent', () => { + render() + expect(screen.getByText('B')).toBeDefined() + }) + + it('is not interactive by default (no chevron, no button role)', () => { + render() + expect(screen.queryByTestId('token-chain-pill-chevron')).toBeNull() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('becomes an interactive button when onClick is supplied', () => { + const onClick = vi.fn() + render() + const button = screen.getByRole('button') + expect(button).toBeDefined() + expect(screen.getByTestId('token-chain-pill-chevron')).toBeDefined() + fireEvent.click(button) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('triggers onClick on Enter and Space keys', () => { + const onClick = vi.fn() + render() + const button = screen.getByRole('button') + fireEvent.keyDown(button, { key: 'Enter' }) + fireEvent.keyDown(button, { key: ' ' }) + expect(onClick).toHaveBeenCalledTimes(2) + }) + + it('ignores unrelated keys', () => { + const onClick = vi.fn() + render() + fireEvent.keyDown(screen.getByRole('button'), { key: 'a' }) + expect(onClick).not.toHaveBeenCalled() + }) + + it('renders as non-interactive when disabled, even with onClick', () => { + const onClick = vi.fn() + render() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.queryByTestId('token-chain-pill-chevron')).toBeNull() + }) +}) 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 new file mode 100644 index 00000000..ebaf855b --- /dev/null +++ b/packages/smart-routing-address-react-ui/src/components/TokenChainPill/index.tsx @@ -0,0 +1,108 @@ +import { cn, Icon, Text, Wrapper } from '@zerodev/react-ui' +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. */ + logoUri?: string + /** Fallback background color when no `logoUri` is supplied. */ + logoBg?: string + /** Fallback initial letter shown inside the placeholder circle. */ + 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). */ + disabled?: boolean + className?: string +} + +export function TokenChainPill({ + label, + logoUri, + logoBg = '#E6EFFB', + logoInitial, + onClick, + disabled, + className, +}: TokenChainPillProps) { + const interactive = Boolean(onClick) && !disabled + + const handleKeyDown = (event: KeyboardEvent) => { + if (!interactive) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick?.() + } + } + + return ( + +
+ {/* 44×44 logo well with a 34×34 centered inner disc. Figma places the + image with translate-1/2 offsets so the smaller disc sits inside + the larger well — the 10px reveal reads as the pill's "logo pad". */} +
+
+ {logoUri ? ( + + ) : logoInitial ? ( + + {logoInitial} + + ) : null} +
+
+ {label} +
+ {interactive && ( +
+ +
+ )} +
+ ) +} diff --git a/packages/smart-routing-address-react-ui/src/index.ts b/packages/smart-routing-address-react-ui/src/index.ts index 86948ebc..27834365 100644 --- a/packages/smart-routing-address-react-ui/src/index.ts +++ b/packages/smart-routing-address-react-ui/src/index.ts @@ -1,8 +1,12 @@ /** * @zerodev/smart-routing-address-react-ui - * React hooks + provider for ZeroDev Smart Routing Address deposits. + * React hooks, provider, and UI for ZeroDev Smart Routing Address deposits. */ +// Components +export type { TokenChainPillProps } from './components/TokenChainPill' +export { TokenChainPill } from './components/TokenChainPill' + // Provider export type { SmartRoutingAddressProviderProps } from './context/SmartRoutingAddressProvider' export { SmartRoutingAddressProvider } from './context/SmartRoutingAddressProvider' diff --git a/packages/smart-routing-address-react-ui/src/styles.css b/packages/smart-routing-address-react-ui/src/styles.css new file mode 100644 index 00000000..93962891 --- /dev/null +++ b/packages/smart-routing-address-react-ui/src/styles.css @@ -0,0 +1,6 @@ +@import "tailwindcss" prefix(zd); +/* Tokens are single-sourced in @zerodev/react-ui. */ +@config "../../react-ui/tailwind.config.ts"; +/* Scan both packages' sources so the built CSS serves apps with one stylesheet. */ +@source "./"; +@source "../../react-ui/src"; diff --git a/packages/smart-routing-address-react-ui/vite.config.ts b/packages/smart-routing-address-react-ui/vite.config.ts index e4403713..621e3786 100644 --- a/packages/smart-routing-address-react-ui/vite.config.ts +++ b/packages/smart-routing-address-react-ui/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', + '@zerodev/react-ui', '@zerodev/smart-routing-address', 'viem', ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbde9258..b1e5cda3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -484,7 +484,26 @@ importers: version: 5.0.0(rollup@4.54.0)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.1)) packages/smart-routing-address-react-ui: + dependencies: + '@zerodev/react-ui': + specifier: workspace:* + version: link:../react-ui devDependencies: + '@storybook/addon-a11y': + specifier: ^10.3.3 + version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6)) + '@storybook/addon-docs': + specifier: ^10.3.3 + version: 10.3.3(@types/react@19.2.14)(esbuild@0.27.3)(rollup@4.54.0)(storybook@10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6))(vite@6.4.1(@types/node@20.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.1)) + '@storybook/react-vite': + specifier: ^10.3.3 + version: 10.3.3(esbuild@0.27.3)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.54.0)(storybook@10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6))(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.1)) + '@tailwindcss/cli': + specifier: ^4.2.2 + version: 4.2.2 + '@tailwindcss/postcss': + specifier: ^4.2.2 + version: 4.2.2 '@testing-library/react': specifier: ^16.3.0 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -506,12 +525,21 @@ importers: happy-dom: specifier: ^20.6.0 version: 20.6.0(bufferutil@4.0.9)(utf-8-validate@6.0.6) + postcss: + specifier: ^8.5.8 + version: 8.5.15 react: specifier: 19.2.0 version: 19.2.0 react-dom: specifier: 19.2.0 version: 19.2.0(react@19.2.0) + storybook: + specifier: ^10.3.3 + version: 10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6) + tailwindcss: + specifier: ^4.2.2 + version: 4.2.2 typescript: specifier: 5.9.3 version: 5.9.3 @@ -12091,6 +12119,12 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.3)': + dependencies: + '@types/mdx': 2.0.13 + '@types/react': 19.2.14 + react: 19.2.3 + '@mdx-js/react@3.1.1(@types/react@19.2.2)(react@19.2.3)': dependencies: '@types/mdx': 2.0.13 @@ -15206,6 +15240,23 @@ snapshots: axe-core: 4.11.1 storybook: 10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6) + '@storybook/addon-docs@10.3.3(@types/react@19.2.14)(esbuild@0.27.3)(rollup@4.54.0)(storybook@10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6))(vite@6.4.1(@types/node@20.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.1))': + dependencies: + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.3) + '@storybook/csf-plugin': 10.3.3(esbuild@0.27.3)(rollup@4.54.0)(storybook@10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6))(vite@6.4.1(@types/node@20.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.1)) + '@storybook/icons': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@storybook/react-dom-shim': 10.3.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6)) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + storybook: 10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@6.0.6) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - esbuild + - rollup + - vite + - webpack + '@storybook/addon-docs@10.3.3(@types/react@19.2.2)(esbuild@0.27.3)(rollup@4.54.0)(storybook@10.3.3(@testing-library/dom@10.4.1)(bufferutil@4.0.9)(prettier@2.8.8)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(utf-8-validate@5.0.10))(vite@6.4.1(@types/node@20.19.11)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.1))': dependencies: '@mdx-js/react': 3.1.1(@types/react@19.2.2)(react@19.2.3)