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
13 changes: 13 additions & 0 deletions .changeset/silver-radix-popover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@zerodev/react-ui": patch
---

refactor: rewrite `SelectDropdown` on top of `@radix-ui/react-popover` (new peer dependency). Radix now owns focus management, keyboard navigation, click-outside, ESC dismissal, portaling, and ARIA wiring — replacing the hand-rolled equivalents.

API changes:
- Removed `anchorRef` (dropped the parent-ref timing dance that never worked reliably).
- Added `align?: 'start' | 'center' | 'end'` — forwarded to Radix `Popover.Content`.
- Panel width now defaults to trigger width via Radix's `--radix-popover-trigger-width` variable. Wider panels (e.g. spanning two pills in a row) are done via the new `panelWidth` prop, e.g. `panelWidth="calc(var(--radix-popover-trigger-width) * 2 + 4px)"`. `panelWidth` is applied inline so it beats any class-based width.
- Removed the `logoInitial` field from `SelectDropdownItem` — `PillItem` derives the initial from `symbol` internally.

Also: `Wrapper` and `PillItem` now accept a `ref` prop (needed for Radix `Popover.Trigger asChild`), and `PillItem` renders its chevron whenever `!disabled` rather than gating on `onClick`, so it looks correct when wrapped in an external interactive parent.
2 changes: 2 additions & 0 deletions packages/react-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@
"zerodev"
],
"peerDependencies": {
"@radix-ui/react-popover": "^1.1.0",
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@radix-ui/react-popover": "^1.1.0",
"@storybook/addon-a11y": "^10.3.3",
"@storybook/addon-docs": "^10.3.3",
"@storybook/react-vite": "^10.3.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { TokenChainPill } from './index'
import { PillItem } from './index'

const meta: Meta<typeof TokenChainPill> = {
title: 'SmartRoutingAddress/TokenChainPill',
component: TokenChainPill,
const meta: Meta<typeof PillItem> = {
title: 'PillItem',
component: PillItem,
parameters: { layout: 'centered' },
decorators: [
(Story) => (
Expand All @@ -21,16 +21,17 @@ const meta: Meta<typeof TokenChainPill> = {
export default meta
type Story = StoryObj<typeof meta>

/** Interactive source-token pill — the default variant shown in Figma. */
export const InteractiveToken: Story = {
/** Interactive variant — the default. Renders a chevron and is
* keyboard-accessible (Enter/Space trigger `onClick`). */
export const Interactive: Story = {
args: {
label: 'USDC',
logoBg: '#2775CA',
onClick: () => {},
},
}

/** Interactive source-chain pill. */
/** Second interactive example with a different label/logo. */
export const InteractiveChain: Story = {
args: {
label: 'Base',
Expand All @@ -40,10 +41,9 @@ export const InteractiveChain: Story = {
}

/**
* 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.
* Display variant — 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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,39 @@
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TokenChainPill } from './index'
import { PillItem } from './index'

afterEach(cleanup)

describe('TokenChainPill', () => {
describe('PillItem', () => {
it('renders the label', () => {
render(<TokenChainPill label="USDC" />)
render(<PillItem label="USDC" />)
expect(screen.getByText('USDC')).toBeDefined()
})

it('renders the logo image when logoUri is provided', () => {
render(
<TokenChainPill label="USDC" logoUri="https://example.com/usdc.png" />,
)
render(<PillItem label="USDC" logoUri="https://example.com/usdc.png" />)
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(<TokenChainPill label="base" />)
render(<PillItem label="base" />)
expect(screen.getByText('B')).toBeDefined()
})

it('is not interactive by default (no chevron, no button role)', () => {
render(<TokenChainPill label="USDC" />)
expect(screen.queryByTestId('token-chain-pill-chevron')).toBeNull()
it('renders the chevron but no button role without onClick', () => {
render(<PillItem label="USDC" />)
// Chevron shows whenever the pill is not disabled — this lets the pill be
// wrapped in an external interactive parent (e.g. a Popover.Trigger)
// without losing the affordance.
expect(screen.getByTestId('token-chain-pill-chevron')).toBeDefined()
expect(screen.queryByRole('button')).toBeNull()
})

it('becomes an interactive button when onClick is supplied', () => {
const onClick = vi.fn()
render(<TokenChainPill label="USDC" onClick={onClick} />)
render(<PillItem label="USDC" onClick={onClick} />)
const button = screen.getByRole('button')
expect(button).toBeDefined()
expect(screen.getByTestId('token-chain-pill-chevron')).toBeDefined()
Expand All @@ -44,7 +45,7 @@ describe('TokenChainPill', () => {

it('triggers onClick on Enter and Space keys', () => {
const onClick = vi.fn()
render(<TokenChainPill label="USDC" onClick={onClick} />)
render(<PillItem label="USDC" onClick={onClick} />)
const button = screen.getByRole('button')
fireEvent.keyDown(button, { key: 'Enter' })
fireEvent.keyDown(button, { key: ' ' })
Expand All @@ -53,14 +54,14 @@ describe('TokenChainPill', () => {

it('ignores unrelated keys', () => {
const onClick = vi.fn()
render(<TokenChainPill label="USDC" onClick={onClick} />)
render(<PillItem label="USDC" onClick={onClick} />)
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(<TokenChainPill label="Arbitrum One" onClick={onClick} disabled />)
render(<PillItem label="Arbitrum One" onClick={onClick} disabled />)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByTestId('token-chain-pill-chevron')).toBeNull()
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { cn, Icon, Text, Wrapper } from '@zerodev/react-ui'
import type { KeyboardEvent } from 'react'
import type { HTMLAttributes, KeyboardEvent, Ref } from 'react'
import { cn } from '../../utils/common'
import { Icon } from '../Icon'
import { Text } from '../Text'
import { Wrapper } from '../Wrapper'

export interface TokenChainPillProps {
export interface PillItemProps
extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
/** Text label rendered next to the logo (e.g., "USDC", "Base"). */
label: string
/** URL of the logo image; when omitted, a `logoBg` + first letter of `label` placeholder is drawn. */
Expand All @@ -12,17 +16,19 @@ export interface TokenChainPillProps {
onClick?: () => void
/** When true, renders as a dimmed, non-interactive pill (no chevron). */
disabled?: boolean
className?: string
ref?: Ref<HTMLDivElement>
}

export function TokenChainPill({
export function PillItem({
label,
logoUri,
logoBg = '#E6EFFB',
onClick,
disabled,
className,
}: TokenChainPillProps) {
ref,
...rest
}: PillItemProps) {
const logoInitial = label.charAt(0).toUpperCase()

const interactive = Boolean(onClick) && !disabled
Expand All @@ -37,24 +43,24 @@ export function TokenChainPill({

return (
<Wrapper
ref={ref}
variant="solid"
// Override Wrapper's variant-based bg color for the display variant —
// Figma spec is exactly rgba(255,255,255,0.05), which none of the
// Wrapper variants match. Inline style beats Wrapper's own style.
style={
interactive
? undefined
: { backgroundColor: 'rgba(255, 255, 255, 0.05)' }
disabled ? { backgroundColor: 'rgba(255, 255, 255, 0.05)' } : undefined
}
className={cn(
// Sizing/padding matches Figma: outer pl-1 pr-2 py-1, rounded-2xl.
// Height is content-driven — 44px logo + 4px vertical padding = 52px.
'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',
// Universal inner shadow from the Figma design token.
'zd:shadow-[inset_0_-4px_4px_0_rgba(255,255,255,0.1),inset_0_3px_4px_0_rgba(0,0,0,0.02)]',
interactive && 'zd:cursor-pointer',
!disabled && 'zd:cursor-pointer',
className,
)}
{...rest}
{...(interactive && {
role: 'button',
tabIndex: 0,
Expand Down Expand Up @@ -93,7 +99,7 @@ export function TokenChainPill({
</div>
<Text className="zd:whitespace-nowrap zd:text-body1">{label}</Text>
</div>
{interactive && (
{!disabled && (
<div className="zd:flex zd:shrink-0 zd:items-center zd:rounded-full zd:p-2">
<Icon
name="chevronDown"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'

import {
SelectDropdown,
type SelectDropdownItem,
type SelectDropdownProps,
} from './index'

const TOKENS: SelectDropdownItem[] = [
{
id: 'USDC',
symbol: 'USDC',
subtitle: '13 networks',
logoBg: '#2775CA',
badge: 'Recommended',
},
{
id: 'WETH',
symbol: 'WETH',
subtitle: '11 networks',
logoBg: '#627EEA',
},
{
id: 'USDT',
symbol: 'USDT',
subtitle: '9 networks',
logoBg: '#26A17B',
},
{
id: 'DAI',
symbol: 'DAI',
subtitle: '1 network',
logoBg: '#F4B731',
},
{
id: 'WBTC',
symbol: 'WBTC',
subtitle: '6 networks',
logoBg: '#F09242',
},
]

const meta: Meta<typeof SelectDropdown> = {
title: 'SelectDropdown',
component: SelectDropdown,
parameters: { layout: 'centered' },
decorators: [
(Story) => (
// Wider than a single pill so the panel has room to breathe in
// Storybook. Real callers embed the SelectDropdown inside their own
// layout and can size the trigger however they like.
<div style={{ width: 260 }}>
<Story />
</div>
),
],
}

export default meta
type Story = StoryObj<typeof meta>

function Controlled(props: Omit<SelectDropdownProps, 'value' | 'onChange'>) {
const [value, setValue] = useState('USDC')
return <SelectDropdown {...props} value={value} onChange={setValue} />
}

/** Default token dropdown — click the pill to open the list. */
export const Default: Story = {
render: () => <Controlled items={TOKENS} />,
}

/** Wider panel via `panelWidth`. Defaults to trigger width; overriding
* widens it — here to twice the trigger plus a 4px gap, which is the pattern
* used when two pills share a row. */
export const WidePanel: Story = {
render: () => (
<Controlled
items={TOKENS}
panelWidth="calc(var(--radix-popover-trigger-width) * 2 + 4px)"
/>
),
}

/** Disabled trigger — no chevron, no click handler. */
export const Disabled: Story = {
render: () => (
<SelectDropdown items={TOKENS} value="USDC" onChange={() => {}} disabled />
),
}
Loading
Loading