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

feat: add DataRow primitive with `leading` / `trailing` / `info` slots and a `warning` variant. Unifies wallet-react-ui's internal DataRow and smart-routing-address-react-ui's LabeledValueRow; wallet-react-ui now consumes it from `@zerodev/react-ui`.
127 changes: 127 additions & 0 deletions packages/react-ui/src/components/DataRow/DataRow.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from '@storybook/react-vite'

import { Icon } from '../Icon'
import { Text } from '../Text'
import { DataRow, DataRowSkeleton } from './index'

const meta = {
title: 'DataRow',
component: DataRow,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
onInfoClick: { action: 'info-clicked' },
},
args: {
label: 'Max slippage',
value: '0.50%',
},
decorators: [
(Story) => (
<div style={{ width: 344 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof DataRow>

export default meta
type Story = StoryObj<typeof meta>

/** Plain row — label on the left, value on the right. */
export const Default: Story = {}

/** With the inline info icon between label and value. */
export const WithInfoIcon: Story = {
args: {
label: 'Max slippage',
value: '0.50%',
info: true,
},
}

/** With an interactive info button (keyboard-accessible; fires `onInfoClick`). */
export const WithInteractiveInfo: Story = {
args: {
label: 'Ready in',
value: '≈ 3 min',
info: true,
onInfoClick: () => {},
},
}

/** With a trailing chevron for expandable rows. */
export const WithTrailingIcon: Story = {
args: {
label: 'Estimated fee',
value: '0.74%',
info: true,
trailing: (
<Icon
name="chevronDown"
className="zd:w-3.5 zd:h-3.5 zd:text-greyScale"
/>
),
},
}

/** With a leading warning icon before the value (solarOrange). */
export const WithLeadingIcon: Story = {
args: {
label: 'Fee',
value: '0.0012 ETH',
leading: (
<Icon name="warning" className="zd:w-3 zd:h-3 zd:text-solarOrange" />
),
},
}

/** With leading + trailing icons together (e.g. gas fee with a status hint). */
export const WithLeadingAndTrailingIcons: Story = {
args: {
label: 'Fee',
value: '0.0012 ETH ($3.50)',
leading: (
<Icon name="warning" className="zd:w-3 zd:h-3 zd:text-solarOrange" />
),
trailing: (
<Icon
name="gasStation"
className="zd:w-3.5 zd:h-3.5 zd:text-solarOrange"
/>
),
},
}

/** Value slot accepts arbitrary ReactNode (rendered verbatim, no Text wrapping). */
export const WithCustomValue: Story = {
args: {
label: 'Status',
value: <Text className="zd:text-solarOrange">Pending</Text>,
},
}

/**
* Warning variant — the orange-tinted card treatment used for the "Minimum
* deposit" row in the SRA "Deposit funds" flow.
*/
export const Warning: Story = {
args: {
label: 'Minimum deposit',
value: '27.88 USDC',
info: true,
variant: 'warning',
},
}

/** Pulse-placeholder loading state. */
export const Skeleton: StoryObj<typeof DataRowSkeleton> = {
render: () => <DataRowSkeleton />,
}

/** Skeleton with the label rendered as-is on the left. */
export const SkeletonWithLabel: StoryObj<typeof DataRowSkeleton> = {
render: () => <DataRowSkeleton label="Fee" />,
}
150 changes: 150 additions & 0 deletions packages/react-ui/src/components/DataRow/DataRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

// The real Icon component uses `import.meta.glob` to eagerly load SVGs from
// disk, which vitest can't resolve. Stub it with a minimal mock that emits
// `data-testid="icon-<name>"` for query-by-testid.
vi.mock('../Icon', async (importOriginal) => {
const actual = await importOriginal<typeof import('../Icon')>()
const React = await import('react')

const MockIcon = ({
name,
...props
}: { name: string } & React.SVGProps<SVGSVGElement>) =>
React.createElement('svg', { 'data-testid': `icon-${name}`, ...props })

return {
...actual,
Icon: MockIcon,
icons: {},
}
})

import { DataRow, DataRowSkeleton } from './index'

afterEach(cleanup)

describe('DataRow', () => {
it('renders the label and a string value', () => {
render(<DataRow label="Max slippage" value="0.50%" />)
expect(screen.getByText('Max slippage')).toBeDefined()
expect(screen.getByText('0.50%')).toBeDefined()
})

it('renders a ReactNode value verbatim (no Text wrapping)', () => {
render(
<DataRow
label="Status"
value={<span data-testid="custom-value">custom</span>}
/>,
)
expect(screen.getByTestId('custom-value').textContent).toBe('custom')
})

it('leaves the label untouched (no auto title-casing)', () => {
render(<DataRow label="gasFee" value="0.001" />)
expect(screen.getByText('gasFee')).toBeDefined()
expect(screen.queryByText('Gas Fee')).toBeNull()
})

it('does not render the info icon by default', () => {
render(<DataRow label="Max slippage" value="0.50%" />)
expect(screen.queryByTestId('data-row-info')).toBeNull()
})

it('renders the info icon when info is true', () => {
render(<DataRow label="Max slippage" value="0.50%" info />)
expect(screen.getByTestId('data-row-info')).toBeDefined()
})

it('renders the info icon as a non-interactive element without onInfoClick', () => {
render(<DataRow label="Max slippage" value="0.50%" info />)
expect(screen.queryByRole('button')).toBeNull()
})

it('renders the info icon as a button when onInfoClick is supplied', () => {
const onInfoClick = vi.fn()
render(
<DataRow
label="Max slippage"
value="0.50%"
info
onInfoClick={onInfoClick}
/>,
)
const button = screen.getByRole('button', {
name: 'Max slippage — more info',
})
fireEvent.click(button)
expect(onInfoClick).toHaveBeenCalledTimes(1)
})

it('renders leading content inside the value group, before the value', () => {
render(
<DataRow
label="Fee"
value="0.05 ETH"
leading={<span data-testid="leading">L</span>}
/>,
)
const valueGroup = screen.getByTestId('data-row-value')
const leading = valueGroup.querySelector('[data-testid="leading"]')
expect(leading).not.toBeNull()
// Leading must appear before the value text within the group.
expect(valueGroup.textContent).toBe('L0.05 ETH')
})

it('renders trailing content inside the value group, after the value', () => {
render(
<DataRow
label="Estimated fee"
value="0.74%"
trailing={<span data-testid="trailing">chev</span>}
/>,
)
const valueGroup = screen.getByTestId('data-row-value')
expect(valueGroup.querySelector('[data-testid="trailing"]')).not.toBeNull()
expect(valueGroup.textContent).toBe('0.74%chev')
})

it('applies default styling (no orange text)', () => {
render(<DataRow label="X" value="Y" />)
expect(screen.getByText('X').className).not.toContain('solarOrange')
})

it('applies warning styling to label and value', () => {
render(
<DataRow label="Minimum deposit" value="27.88 USDC" variant="warning" />,
)
expect(screen.getByText('Minimum deposit').className).toContain(
'solarOrange',
)
expect(screen.getByText('27.88 USDC').className).toContain('solarOrange')
})

it('merges custom className onto the root element', () => {
const { container } = render(
<DataRow label="X" value="Y" className="mt-4" />,
)
const root = container.firstChild as HTMLElement
expect(root.className).toContain('mt-4')
expect(root.className).toContain('flex')
})
})

describe('DataRowSkeleton', () => {
it('renders two pulse placeholders when no label is given', () => {
const { container } = render(<DataRowSkeleton />)
const pulses = container.querySelectorAll('.zd\\:animate-pulse')
expect(pulses).toHaveLength(2)
})

it('renders the label verbatim on the left when provided', () => {
render(<DataRowSkeleton label="Fee" />)
expect(screen.getByText('Fee')).toBeDefined()
})
})
Loading
Loading