From a4287c03df38294309e22bcbbce3d3b5ccbc9e54 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 8 Jul 2026 10:20:27 +0400 Subject: [PATCH 1/5] feat: LabeledValueRow component --- .../LabeledValueRow.stories.tsx | 68 +++++++++++ .../LabeledValueRow/LabeledValueRow.test.tsx | 90 ++++++++++++++ .../src/components/LabeledValueRow/index.tsx | 115 ++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.stories.tsx create mode 100644 packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.test.tsx create mode 100644 packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx diff --git a/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.stories.tsx b/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.stories.tsx new file mode 100644 index 00000000..b689e96e --- /dev/null +++ b/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { Icon } from '@zerodev/react-ui' +import { LabeledValueRow } from './index' + +const meta: Meta = { + title: 'SmartRoutingAddress/LabeledValueRow', + component: LabeledValueRow, + parameters: { layout: 'centered' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + argTypes: { + onInfoClick: { action: 'info-clicked' }, + }, +} + +export default meta +type Story = StoryObj + +/** Default variant — Figma 17634:104295 ("Max slippage — 0.50%"). */ +export const Slippage: Story = { + args: { + label: 'Max slippage', + value: '0.50%', + info: true, + }, +} + +/** Ready-in row — Figma 17634:104320. Same shape as Slippage. */ +export const ReadyIn: Story = { + args: { + label: 'Ready in', + value: '≈ 3 min', + info: true, + }, +} + +/** + * Expandable variant — Figma 17634:104304 ("Estimated fee — 0.74% ⌄"). + * Same as default but with a chevron passed via `trailing`. + */ +export const Expandable: Story = { + args: { + label: 'Estimated fee', + value: '0.74%', + info: true, + trailing: ( + + ), + }, +} + +/** + * Warning variant — Figma 18210:75228 ("Minimum deposit — 27.88 USDC"). + * Orange-tinted card treatment with border, backdrop blur, and inner shadow. + */ +export const Warning: Story = { + args: { + label: 'Minimum deposit', + value: '27.88 USDC', + info: true, + variant: 'warning', + }, +} diff --git a/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.test.tsx b/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.test.tsx new file mode 100644 index 00000000..0a5059e8 --- /dev/null +++ b/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.test.tsx @@ -0,0 +1,90 @@ +/** + * @vitest-environment happy-dom + */ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { LabeledValueRow } from './index' + +afterEach(cleanup) + +describe('LabeledValueRow', () => { + it('renders the label and string value', () => { + render() + expect(screen.getByText('Max slippage')).toBeDefined() + expect(screen.getByText('0.50%')).toBeDefined() + }) + + it('renders a ReactNode value verbatim', () => { + render( + custom} + />, + ) + expect(screen.getByTestId('custom-value').textContent).toBe('custom') + }) + + it('does not render the info icon by default', () => { + render() + expect(screen.queryByTestId('labeled-value-row-info')).toBeNull() + }) + + it('renders the info icon when info is true', () => { + render() + expect(screen.getByTestId('labeled-value-row-info')).toBeDefined() + }) + + it('renders info icon as a non-interactive element without onInfoClick', () => { + render() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('renders info icon as a button when onInfoClick is supplied', () => { + const onInfoClick = vi.fn() + render( + , + ) + const button = screen.getByRole('button', { + name: 'Max slippage — more info', + }) + fireEvent.click(button) + expect(onInfoClick).toHaveBeenCalledTimes(1) + }) + + it('renders a trailing element inline with the value', () => { + render( + chev} + />, + ) + const valueGroup = screen.getByTestId('labeled-value-row-value') + expect(valueGroup.querySelector('[data-testid="trailing"]')).not.toBeNull() + }) + + it('applies default styling', () => { + render() + const label = screen.getByText('X') + expect(label.className).not.toContain('solarOrange') + }) + + it('applies warning styling to label and value', () => { + render( + , + ) + expect(screen.getByText('Minimum deposit').className).toContain( + 'solarOrange', + ) + expect(screen.getByText('27.88 USDC').className).toContain('solarOrange') + }) +}) diff --git a/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx b/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx new file mode 100644 index 00000000..67734239 --- /dev/null +++ b/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx @@ -0,0 +1,115 @@ +import { cn, Icon, Text } from '@zerodev/react-ui' +import type { ReactNode } from 'react' + +/** + * A label + value row used throughout the SRA "Deposit funds" screen. + * + * Three visual variants, each mapped 1:1 to a Figma node: + * - Default (Figma 17634:104295, "Max slippage — 0.50%"): plain inline row + * with `py-1` vertical padding. Body2 text, greyScale color. + * - Default + `trailing` (Figma 17634:104304, "Estimated fee — 0.74% ⌄"): + * same as default, plus an inline element next to the value (typically + * a chevron for expandable rows). + * - Warning (Figma 18210:75228, "Minimum deposit — 27.88 USDC"): card- + * shaped with orange text, 10% orange tint, 14px radius, border, backdrop + * blur, and inner shadow. + * + * The info icon is opt-in via `info`, and becomes a keyboard-accessible + * button when `onInfoClick` is supplied. + */ +export interface LabeledValueRowProps { + /** Label rendered on the left (e.g., "Max slippage"). */ + label: string + /** Value rendered on the right. Accepts a string or arbitrary ReactNode. */ + value: ReactNode + /** Renders a 14×14 info icon at 50% opacity between the label and value. */ + info?: boolean + /** Handler for the info icon; makes it a keyboard-accessible button. */ + onInfoClick?: () => void + /** + * Extra element rendered inline with the value (e.g., a chevron for + * expandable rows — Figma 17634:104304). + */ + trailing?: ReactNode + /** `'default'` for plain rows; `'warning'` for the orange-tinted card. */ + variant?: 'default' | 'warning' + className?: string +} + +export function LabeledValueRow({ + label, + value, + info, + onInfoClick, + trailing, + variant = 'default', + className, +}: LabeledValueRowProps) { + const isWarning = variant === 'warning' + const textColorClass = isWarning ? 'zd:text-solarOrange' : undefined + + return ( +
+ + {label} + + {info && + (onInfoClick ? ( + + ) : ( + + ))} + {/* Spacer pushes the value to the right end. min-w-0 so the label + and value can shrink independently if the container is narrow. */} +
+
+ {typeof value === 'string' ? ( + + {value} + + ) : ( + value + )} + {trailing} +
+
+ ) +} From b9fa3f47caac48e3854b8dd0d236804bdddfeb65 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 8 Jul 2026 10:23:12 +0400 Subject: [PATCH 2/5] lint --- .../src/components/LabeledValueRow/index.tsx | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx b/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx index 67734239..fd775d56 100644 --- a/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx +++ b/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx @@ -1,22 +1,6 @@ import { cn, Icon, Text } from '@zerodev/react-ui' import type { ReactNode } from 'react' -/** - * A label + value row used throughout the SRA "Deposit funds" screen. - * - * Three visual variants, each mapped 1:1 to a Figma node: - * - Default (Figma 17634:104295, "Max slippage — 0.50%"): plain inline row - * with `py-1` vertical padding. Body2 text, greyScale color. - * - Default + `trailing` (Figma 17634:104304, "Estimated fee — 0.74% ⌄"): - * same as default, plus an inline element next to the value (typically - * a chevron for expandable rows). - * - Warning (Figma 18210:75228, "Minimum deposit — 27.88 USDC"): card- - * shaped with orange text, 10% orange tint, 14px radius, border, backdrop - * blur, and inner shadow. - * - * The info icon is opt-in via `info`, and becomes a keyboard-accessible - * button when `onInfoClick` is supplied. - */ export interface LabeledValueRowProps { /** Label rendered on the left (e.g., "Max slippage"). */ label: string From 5fa8d25303b0768e90f772b0ecf72fc4fca7e389 Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Fri, 10 Jul 2026 09:34:28 +0400 Subject: [PATCH 3/5] refactor --- .../src/components/LabeledValueRow/LabeledValueRow.stories.tsx | 0 .../src/components/LabeledValueRow/LabeledValueRow.test.tsx | 0 .../src/components/LabeledValueRow/index.tsx | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename packages/{smart-routing-address-react => smart-routing-address-react-ui}/src/components/LabeledValueRow/LabeledValueRow.stories.tsx (100%) rename packages/{smart-routing-address-react => smart-routing-address-react-ui}/src/components/LabeledValueRow/LabeledValueRow.test.tsx (100%) rename packages/{smart-routing-address-react => smart-routing-address-react-ui}/src/components/LabeledValueRow/index.tsx (100%) diff --git a/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.stories.tsx b/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.stories.tsx similarity index 100% rename from packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.stories.tsx rename to packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.stories.tsx diff --git a/packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.test.tsx b/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.test.tsx similarity index 100% rename from packages/smart-routing-address-react/src/components/LabeledValueRow/LabeledValueRow.test.tsx rename to packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.test.tsx diff --git a/packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx b/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/index.tsx similarity index 100% rename from packages/smart-routing-address-react/src/components/LabeledValueRow/index.tsx rename to packages/smart-routing-address-react-ui/src/components/LabeledValueRow/index.tsx From e799f34223f36408c36fc1fc9143fcc725922b8a Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Tue, 14 Jul 2026 17:09:45 +0400 Subject: [PATCH 4/5] review --- .../components/DataRow/DataRow.stories.tsx | 127 +++++++++++++++ .../src/components/DataRow/DataRow.test.tsx | 150 ++++++++++++++++++ .../src/components/DataRow}/index.tsx | 79 ++++++--- packages/react-ui/src/index.ts | 6 + .../LabeledValueRow.stories.tsx | 68 -------- .../LabeledValueRow/LabeledValueRow.test.tsx | 90 ----------- .../components/DataRow/DataRow.stories.tsx | 75 --------- .../components/DataRow/DataRow.test.tsx | 98 ------------ .../src/signing/components/DataRow/index.tsx | 71 --------- .../Section/MessageDetails/index.tsx | 7 +- .../components/SigningPageSkeleton/index.tsx | 3 +- .../SmartFundingGasDetails/index.tsx | 24 ++- .../components/TxDetailsItem/index.tsx | 12 +- .../signing/components/TxGasFees/index.tsx | 23 ++- .../components/TypedDataMessage/index.tsx | 2 +- .../src/signing/pages/BatchCalls.tsx | 10 +- .../src/signing/pages/CollectionApproval.tsx | 10 +- .../src/signing/pages/Erc20Approval.tsx | 10 +- .../src/signing/pages/Erc20Transfer.tsx | 10 +- .../src/signing/pages/EthTransfer.tsx | 10 +- .../src/signing/pages/GenericRequest.tsx | 10 +- .../src/signing/pages/MintNft.tsx | 10 +- .../src/signing/pages/SignTypedData.tsx | 3 +- 23 files changed, 447 insertions(+), 461 deletions(-) create mode 100644 packages/react-ui/src/components/DataRow/DataRow.stories.tsx create mode 100644 packages/react-ui/src/components/DataRow/DataRow.test.tsx rename packages/{smart-routing-address-react-ui/src/components/LabeledValueRow => react-ui/src/components/DataRow}/index.tsx (51%) delete mode 100644 packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.stories.tsx delete mode 100644 packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.test.tsx delete mode 100644 packages/wallet-react-ui/src/signing/components/DataRow/DataRow.stories.tsx delete mode 100644 packages/wallet-react-ui/src/signing/components/DataRow/DataRow.test.tsx delete mode 100644 packages/wallet-react-ui/src/signing/components/DataRow/index.tsx diff --git a/packages/react-ui/src/components/DataRow/DataRow.stories.tsx b/packages/react-ui/src/components/DataRow/DataRow.stories.tsx new file mode 100644 index 00000000..989c00a1 --- /dev/null +++ b/packages/react-ui/src/components/DataRow/DataRow.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +/** 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: ( + + ), + }, +} + +/** With a leading warning icon before the value (solarOrange). */ +export const WithLeadingIcon: Story = { + args: { + label: 'Fee', + value: '0.0012 ETH', + leading: ( + + ), + }, +} + +/** 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: ( + + ), + trailing: ( + + ), + }, +} + +/** Value slot accepts arbitrary ReactNode (rendered verbatim, no Text wrapping). */ +export const WithCustomValue: Story = { + args: { + label: 'Status', + value: Pending, + }, +} + +/** + * 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 = { + render: () => , +} + +/** Skeleton with the label rendered as-is on the left. */ +export const SkeletonWithLabel: StoryObj = { + render: () => , +} diff --git a/packages/react-ui/src/components/DataRow/DataRow.test.tsx b/packages/react-ui/src/components/DataRow/DataRow.test.tsx new file mode 100644 index 00000000..e6078f1f --- /dev/null +++ b/packages/react-ui/src/components/DataRow/DataRow.test.tsx @@ -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-"` for query-by-testid. +vi.mock('../Icon', async (importOriginal) => { + const actual = await importOriginal() + const React = await import('react') + + const MockIcon = ({ + name, + ...props + }: { name: string } & React.SVGProps) => + 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() + expect(screen.getByText('Max slippage')).toBeDefined() + expect(screen.getByText('0.50%')).toBeDefined() + }) + + it('renders a ReactNode value verbatim (no Text wrapping)', () => { + render( + custom} + />, + ) + expect(screen.getByTestId('custom-value').textContent).toBe('custom') + }) + + it('leaves the label untouched (no auto title-casing)', () => { + render() + expect(screen.getByText('gasFee')).toBeDefined() + expect(screen.queryByText('Gas Fee')).toBeNull() + }) + + it('does not render the info icon by default', () => { + render() + expect(screen.queryByTestId('data-row-info')).toBeNull() + }) + + it('renders the info icon when info is true', () => { + render() + expect(screen.getByTestId('data-row-info')).toBeDefined() + }) + + it('renders the info icon as a non-interactive element without onInfoClick', () => { + render() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('renders the info icon as a button when onInfoClick is supplied', () => { + const onInfoClick = vi.fn() + render( + , + ) + 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( + L} + />, + ) + 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( + chev} + />, + ) + 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() + expect(screen.getByText('X').className).not.toContain('solarOrange') + }) + + it('applies warning styling to label and value', () => { + render( + , + ) + 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( + , + ) + 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() + const pulses = container.querySelectorAll('.zd\\:animate-pulse') + expect(pulses).toHaveLength(2) + }) + + it('renders the label verbatim on the left when provided', () => { + render() + expect(screen.getByText('Fee')).toBeDefined() + }) +}) diff --git a/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/index.tsx b/packages/react-ui/src/components/DataRow/index.tsx similarity index 51% rename from packages/smart-routing-address-react-ui/src/components/LabeledValueRow/index.tsx rename to packages/react-ui/src/components/DataRow/index.tsx index fd775d56..56e7bd00 100644 --- a/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/index.tsx +++ b/packages/react-ui/src/components/DataRow/index.tsx @@ -1,34 +1,43 @@ -import { cn, Icon, Text } from '@zerodev/react-ui' import type { ReactNode } from 'react' -export interface LabeledValueRowProps { +import { cn } from '../../utils/common' +import { Icon } from '../Icon' +import { Text } from '../Text' + +export interface DataRowProps { /** Label rendered on the left (e.g., "Max slippage"). */ label: string - /** Value rendered on the right. Accepts a string or arbitrary ReactNode. */ + /** Value rendered on the right. Accepts a string (wrapped in `Text`) or an + * arbitrary ReactNode (rendered verbatim). */ value: ReactNode - /** Renders a 14×14 info icon at 50% opacity between the label and value. */ + /** Renders a small info icon (14×14, greyScale/50) between the label and + * the value. */ info?: boolean - /** Handler for the info icon; makes it a keyboard-accessible button. */ + /** Handler for the info icon; when supplied, the icon becomes a keyboard- + * accessible button. Requires `info` to be true. */ onInfoClick?: () => void - /** - * Extra element rendered inline with the value (e.g., a chevron for - * expandable rows — Figma 17634:104304). - */ + /** Content rendered inside the value group, before the value. Typically a + * small decorative or status icon (e.g. warning). */ + leading?: ReactNode + /** Content rendered inside the value group, after the value. Typically a + * chevron or trailing icon. */ trailing?: ReactNode - /** `'default'` for plain rows; `'warning'` for the orange-tinted card. */ + /** `'default'` for a plain inline row; `'warning'` for the orange-tinted + * card treatment (Figma "Minimum deposit"). */ variant?: 'default' | 'warning' className?: string } -export function LabeledValueRow({ +export function DataRow({ label, value, info, onInfoClick, + leading, trailing, variant = 'default', className, -}: LabeledValueRowProps) { +}: DataRowProps) { const isWarning = variant === 'warning' const textColorClass = isWarning ? 'zd:text-solarOrange' : undefined @@ -62,29 +71,36 @@ export function LabeledValueRow({ onClick={onInfoClick} aria-label={`${label} — more info`} className="zd:flex zd:items-center zd:justify-center zd:cursor-pointer" - data-testid="labeled-value-row-info" + data-testid="data-row-info" > ) : ( ))} - {/* Spacer pushes the value to the right end. min-w-0 so the label - and value can shrink independently if the container is narrow. */} + {/* Spacer pushes the value to the right end. min-w-0 so the label and + value can shrink independently if the container is narrow. */}
+ {leading} {typeof value === 'string' ? ( {value} @@ -97,3 +113,28 @@ export function LabeledValueRow({
) } + +export interface DataRowSkeletonProps { + /** Optional label to render as-is on the left; when omitted a pulse + * placeholder is drawn in its place. */ + label?: string + className?: string +} + +export function DataRowSkeleton({ label, className }: DataRowSkeletonProps) { + return ( +
+ {label ? ( + {label} + ) : ( +
+ )} +
+
+ ) +} diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index d971751e..7a715964 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -6,6 +6,12 @@ export { Badge, type BadgeProps } from './components/Badge' export { Button, type ButtonProps } from './components/Button' export { Callout, type CalloutProps } from './components/Callout' +export { + DataRow, + type DataRowProps, + DataRowSkeleton, + type DataRowSkeletonProps, +} from './components/DataRow' export { Icon, type IconName, diff --git a/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.stories.tsx b/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.stories.tsx deleted file mode 100644 index b689e96e..00000000 --- a/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.stories.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import { Icon } from '@zerodev/react-ui' -import { LabeledValueRow } from './index' - -const meta: Meta = { - title: 'SmartRoutingAddress/LabeledValueRow', - component: LabeledValueRow, - parameters: { layout: 'centered' }, - decorators: [ - (Story) => ( -
- -
- ), - ], - argTypes: { - onInfoClick: { action: 'info-clicked' }, - }, -} - -export default meta -type Story = StoryObj - -/** Default variant — Figma 17634:104295 ("Max slippage — 0.50%"). */ -export const Slippage: Story = { - args: { - label: 'Max slippage', - value: '0.50%', - info: true, - }, -} - -/** Ready-in row — Figma 17634:104320. Same shape as Slippage. */ -export const ReadyIn: Story = { - args: { - label: 'Ready in', - value: '≈ 3 min', - info: true, - }, -} - -/** - * Expandable variant — Figma 17634:104304 ("Estimated fee — 0.74% ⌄"). - * Same as default but with a chevron passed via `trailing`. - */ -export const Expandable: Story = { - args: { - label: 'Estimated fee', - value: '0.74%', - info: true, - trailing: ( - - ), - }, -} - -/** - * Warning variant — Figma 18210:75228 ("Minimum deposit — 27.88 USDC"). - * Orange-tinted card treatment with border, backdrop blur, and inner shadow. - */ -export const Warning: Story = { - args: { - label: 'Minimum deposit', - value: '27.88 USDC', - info: true, - variant: 'warning', - }, -} diff --git a/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.test.tsx b/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.test.tsx deleted file mode 100644 index 0a5059e8..00000000 --- a/packages/smart-routing-address-react-ui/src/components/LabeledValueRow/LabeledValueRow.test.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { LabeledValueRow } from './index' - -afterEach(cleanup) - -describe('LabeledValueRow', () => { - it('renders the label and string value', () => { - render() - expect(screen.getByText('Max slippage')).toBeDefined() - expect(screen.getByText('0.50%')).toBeDefined() - }) - - it('renders a ReactNode value verbatim', () => { - render( - custom} - />, - ) - expect(screen.getByTestId('custom-value').textContent).toBe('custom') - }) - - it('does not render the info icon by default', () => { - render() - expect(screen.queryByTestId('labeled-value-row-info')).toBeNull() - }) - - it('renders the info icon when info is true', () => { - render() - expect(screen.getByTestId('labeled-value-row-info')).toBeDefined() - }) - - it('renders info icon as a non-interactive element without onInfoClick', () => { - render() - expect(screen.queryByRole('button')).toBeNull() - }) - - it('renders info icon as a button when onInfoClick is supplied', () => { - const onInfoClick = vi.fn() - render( - , - ) - const button = screen.getByRole('button', { - name: 'Max slippage — more info', - }) - fireEvent.click(button) - expect(onInfoClick).toHaveBeenCalledTimes(1) - }) - - it('renders a trailing element inline with the value', () => { - render( - chev} - />, - ) - const valueGroup = screen.getByTestId('labeled-value-row-value') - expect(valueGroup.querySelector('[data-testid="trailing"]')).not.toBeNull() - }) - - it('applies default styling', () => { - render() - const label = screen.getByText('X') - expect(label.className).not.toContain('solarOrange') - }) - - it('applies warning styling to label and value', () => { - render( - , - ) - expect(screen.getByText('Minimum deposit').className).toContain( - 'solarOrange', - ) - expect(screen.getByText('27.88 USDC').className).toContain('solarOrange') - }) -}) diff --git a/packages/wallet-react-ui/src/signing/components/DataRow/DataRow.stories.tsx b/packages/wallet-react-ui/src/signing/components/DataRow/DataRow.stories.tsx deleted file mode 100644 index 4b484834..00000000 --- a/packages/wallet-react-ui/src/signing/components/DataRow/DataRow.stories.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' - -import { icons, Text } from '@zerodev/react-ui' -import { DataRow, DataRowSkeleton } from '.' - -const iconNames = Object.keys(icons) - -const meta = { - title: 'Signing/DataRow', - component: DataRow, - parameters: { - layout: 'centered', - }, - tags: ['autodocs'], - argTypes: { - iconName: { - control: 'select', - options: [undefined, ...iconNames], - }, - leadingIconName: { - control: 'select', - options: [undefined, ...iconNames], - }, - }, - args: { - label: 'amount', - value: '0.05 ETH', - }, - decorators: [ - (Story) => ( -
- -
- ), - ], -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = {} - -export const CamelCaseLabel: Story = { - args: { - label: 'gasFee', - value: '0.00123 ETH', - }, -} - -export const WithLeadingIcon: Story = { - args: { - label: 'gas', - value: '0.0012 ETH', - leadingIconName: 'flame', - }, -} - -export const WithTrailingIcon: Story = { - args: { - label: 'network', - value: 'Sepolia', - iconName: 'info', - }, -} - -export const WithCustomValue: Story = { - args: { - label: 'status', - value: Pending, - }, -} - -export const Skeleton: StoryObj = { - render: () => , -} diff --git a/packages/wallet-react-ui/src/signing/components/DataRow/DataRow.test.tsx b/packages/wallet-react-ui/src/signing/components/DataRow/DataRow.test.tsx deleted file mode 100644 index 215cec17..00000000 --- a/packages/wallet-react-ui/src/signing/components/DataRow/DataRow.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@zerodev/react-ui', async (importOriginal) => { - const actual = await importOriginal() - const React = await import('react') - - const MockIcon = ({ - name, - ...props - }: { name: string } & React.SVGProps) => - React.createElement('svg', { 'data-testid': `icon-${name}`, ...props }) - - return { - ...actual, - Icon: MockIcon, - icons: {}, - } -}) - -import { DataRow } from './index' - -afterEach(() => { - cleanup() -}) - -describe('DataRow', () => { - describe('rendering', () => { - it('renders label and string value', () => { - render() - expect(screen.getByText('Amount')).toBeDefined() - expect(screen.getByText('0.05 ETH')).toBeDefined() - }) - - it('renders ReactNode value as-is (no Text wrapping)', () => { - render( - Alice} - />, - ) - expect(screen.getByTestId('custom-value')).toBeDefined() - }) - - it('returns null when label is missing', () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - - it('returns null when label is an empty string', () => { - const { container } = render() - expect(container.firstChild).toBeNull() - }) - }) - - describe('label formatting', () => { - it('converts camelCase labels to Title Case', () => { - render() - expect(screen.getByText('Gas Fee')).toBeDefined() - }) - - it('leaves already-capitalized labels untouched', () => { - render() - expect(screen.getByText('From')).toBeDefined() - }) - }) - - describe('icons', () => { - it('renders a leading icon before the value when leadingIconName is provided', () => { - render( - , - ) - expect(screen.getByTestId('icon-flame')).toBeDefined() - }) - - it('renders a trailing icon after the value when iconName is provided', () => { - render() - expect(screen.getByTestId('icon-info')).toBeDefined() - }) - - it('renders no icons by default', () => { - const { container } = render() - expect(container.querySelector('svg')).toBeNull() - }) - }) - - describe('className', () => { - it('merges custom className onto the root element', () => { - const { container } = render( - , - ) - const root = container.firstChild as HTMLElement - expect(root.className).toContain('mt-4') - expect(root.className).toContain('flex') - expect(root.className).toContain('flex-row') - }) - }) -}) diff --git a/packages/wallet-react-ui/src/signing/components/DataRow/index.tsx b/packages/wallet-react-ui/src/signing/components/DataRow/index.tsx deleted file mode 100644 index e0be34bd..00000000 --- a/packages/wallet-react-ui/src/signing/components/DataRow/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { cn, Icon, type IconName, Text } from '@zerodev/react-ui' -import type { ReactNode } from 'react' -import { camelCaseToTitle } from '../../../shared/utils/common' - -export interface DataRowProps { - label?: string - value: string | ReactNode - iconName?: IconName - leadingIconName?: IconName - className?: string -} - -export function DataRow({ - label, - value, - iconName, - leadingIconName, - className, -}: DataRowProps) { - if (!label) return null - - return ( -
- {camelCaseToTitle(label)} -
- {leadingIconName && ( - - )} - {typeof value === 'string' ? ( - {value} - ) : ( - value - )} - {iconName && ( - - )} -
-
- ) -} - -interface DataRowSkeletonProps { - className?: string - label?: string -} - -export function DataRowSkeleton({ className, label }: DataRowSkeletonProps) { - return ( -
- {label ? ( - {camelCaseToTitle(label)} - ) : ( -
- )} -
-
- ) -} diff --git a/packages/wallet-react-ui/src/signing/components/Section/MessageDetails/index.tsx b/packages/wallet-react-ui/src/signing/components/Section/MessageDetails/index.tsx index 7adb8fba..cf8eed7f 100644 --- a/packages/wallet-react-ui/src/signing/components/Section/MessageDetails/index.tsx +++ b/packages/wallet-react-ui/src/signing/components/Section/MessageDetails/index.tsx @@ -1,4 +1,5 @@ -import { DataRow } from '../../DataRow' +import { DataRow } from '@zerodev/react-ui' +import { camelCaseToTitle } from '../../../../shared/utils/common' import { Section } from '..' export interface MessageDetailsProps { @@ -9,7 +10,9 @@ export function MessageDetails({ details }: MessageDetailsProps) { return (
{Object.entries(details).map(([label, value]) => ( - + // Object keys are raw camelCase (e.g. "fromAddress"); the primitive + // DataRow renders labels verbatim, so title-case them here. + ))}
) diff --git a/packages/wallet-react-ui/src/signing/components/SigningPageSkeleton/index.tsx b/packages/wallet-react-ui/src/signing/components/SigningPageSkeleton/index.tsx index 41937913..dfdf0b09 100644 --- a/packages/wallet-react-ui/src/signing/components/SigningPageSkeleton/index.tsx +++ b/packages/wallet-react-ui/src/signing/components/SigningPageSkeleton/index.tsx @@ -1,5 +1,4 @@ -import { cn, Wrapper } from '@zerodev/react-ui' -import { DataRowSkeleton } from '../DataRow' +import { cn, DataRowSkeleton, Wrapper } from '@zerodev/react-ui' function SkeletonBar({ className }: { className?: string }) { return ( diff --git a/packages/wallet-react-ui/src/signing/components/SmartFundingGasDetails/index.tsx b/packages/wallet-react-ui/src/signing/components/SmartFundingGasDetails/index.tsx index 845e54b3..7bf6b87b 100644 --- a/packages/wallet-react-ui/src/signing/components/SmartFundingGasDetails/index.tsx +++ b/packages/wallet-react-ui/src/signing/components/SmartFundingGasDetails/index.tsx @@ -1,6 +1,5 @@ -import { Callout, Icon, Text, Wrapper } from '@zerodev/react-ui' +import { Callout, DataRow, Icon, Text, Wrapper } from '@zerodev/react-ui' import { useState } from 'react' -import { DataRow } from '../DataRow' import { type GasRoute, RouteItem } from '../RouteItem' interface ProviderFee { @@ -58,12 +57,22 @@ export function SmartFundingGasDetails({ + } /> + } />
@@ -102,7 +111,12 @@ export function SmartFundingGasDetails({ key={`fee${key.toString()}`} label={`${item.provider} Fee (${item.percentage}%)`} value={item.fee} - iconName="gasStation" + trailing={ + + } /> ))}
diff --git a/packages/wallet-react-ui/src/signing/components/TxDetailsItem/index.tsx b/packages/wallet-react-ui/src/signing/components/TxDetailsItem/index.tsx index 93604454..053f6a84 100644 --- a/packages/wallet-react-ui/src/signing/components/TxDetailsItem/index.tsx +++ b/packages/wallet-react-ui/src/signing/components/TxDetailsItem/index.tsx @@ -1,6 +1,6 @@ -import { Icon, Text, Wrapper } from '@zerodev/react-ui' +import { DataRow, Icon, Text, Wrapper } from '@zerodev/react-ui' import { useState } from 'react' -import { DataRow } from '../DataRow' +import { camelCaseToTitle } from '../../../shared/utils/common' export interface TxDetailsItemProps { title: string @@ -41,7 +41,13 @@ export function TxDetailsItem({ title, index, data }: TxDetailsItemProps) { {expanded && (
{Object.entries(data).map(([label, value]) => ( - + // Object keys are raw camelCase (e.g. "gasFee"); the primitive + // DataRow renders labels verbatim, so title-case them here. + ))}
)} diff --git a/packages/wallet-react-ui/src/signing/components/TxGasFees/index.tsx b/packages/wallet-react-ui/src/signing/components/TxGasFees/index.tsx index 6225b4bd..08fb21d9 100644 --- a/packages/wallet-react-ui/src/signing/components/TxGasFees/index.tsx +++ b/packages/wallet-react-ui/src/signing/components/TxGasFees/index.tsx @@ -1,5 +1,6 @@ import { cn, + DataRow, Icon, type IconName, ListItem, @@ -9,7 +10,6 @@ import { Wrapper, } from '@zerodev/react-ui' import { capitalizeFirst } from '../../../shared/utils/common' -import { DataRow } from '../DataRow' export type GasTier = 'low' | 'market' | 'fast' @@ -112,14 +112,29 @@ export function TxGasFees({ + } + trailing={ + + } /> {typeof slippage === 'number' && ( + } /> )}
diff --git a/packages/wallet-react-ui/src/signing/components/TypedDataMessage/index.tsx b/packages/wallet-react-ui/src/signing/components/TypedDataMessage/index.tsx index 03844d2a..55f581e5 100644 --- a/packages/wallet-react-ui/src/signing/components/TypedDataMessage/index.tsx +++ b/packages/wallet-react-ui/src/signing/components/TypedDataMessage/index.tsx @@ -1,5 +1,5 @@ +import { DataRow } from '@zerodev/react-ui' import { shortenHex } from '../../../shared/utils/common' -import { DataRow } from '../DataRow' import type { TypedDataField, TypedDataV4 } from './types' const INDENT_CLASS = ['', 'zd:pl-2', 'zd:pl-4', 'zd:pl-6', 'zd:pl-8'] as const diff --git a/packages/wallet-react-ui/src/signing/pages/BatchCalls.tsx b/packages/wallet-react-ui/src/signing/pages/BatchCalls.tsx index 7a7d91c8..616a9d59 100644 --- a/packages/wallet-react-ui/src/signing/pages/BatchCalls.tsx +++ b/packages/wallet-react-ui/src/signing/pages/BatchCalls.tsx @@ -1,4 +1,4 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, DataRowSkeleton, Icon, Text } from '@zerodev/react-ui' import { useMemo } from 'react' import { type Address, @@ -12,7 +12,6 @@ import { import { useReadContracts } from 'wagmi' import { shortenHex } from '../../shared/utils/common' import type { BatchCall } from '../../types.js' -import { DataRow, DataRowSkeleton } from '../components/DataRow' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' import { SigningPageSkeleton } from '../components/SigningPageSkeleton' @@ -331,7 +330,12 @@ export function BatchCalls({ calls, confirm, reject }: BatchCallsProps) { + } /> ) : ( diff --git a/packages/wallet-react-ui/src/signing/pages/CollectionApproval.tsx b/packages/wallet-react-ui/src/signing/pages/CollectionApproval.tsx index 5ee4b926..f39c93d3 100644 --- a/packages/wallet-react-ui/src/signing/pages/CollectionApproval.tsx +++ b/packages/wallet-react-ui/src/signing/pages/CollectionApproval.tsx @@ -1,9 +1,8 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, DataRowSkeleton, Icon, Text } from '@zerodev/react-ui' import type { Address, Hex } from 'viem' import { useReadContract } from 'wagmi' import { shortenHex } from '../../shared/utils/common' import { ArrowCardPair } from '../components/ArrowCardPair' -import { DataRow, DataRowSkeleton } from '../components/DataRow' import { InfoCard } from '../components/InfoCard' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' @@ -117,7 +116,12 @@ export function CollectionApproval({ + } /> ) : ( diff --git a/packages/wallet-react-ui/src/signing/pages/Erc20Approval.tsx b/packages/wallet-react-ui/src/signing/pages/Erc20Approval.tsx index 22e221ec..1f702b52 100644 --- a/packages/wallet-react-ui/src/signing/pages/Erc20Approval.tsx +++ b/packages/wallet-react-ui/src/signing/pages/Erc20Approval.tsx @@ -1,9 +1,8 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, DataRowSkeleton, Icon, Text } from '@zerodev/react-ui' import { type Address, erc20Abi, formatUnits, type Hex, maxUint256 } from 'viem' import { useReadContract } from 'wagmi' import { shortenHex } from '../../shared/utils/common' import { ArrowCardPair } from '../components/ArrowCardPair' -import { DataRow, DataRowSkeleton } from '../components/DataRow' import { InfoCard } from '../components/InfoCard' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' @@ -112,7 +111,12 @@ export function Erc20Approval({ + } /> ) : ( diff --git a/packages/wallet-react-ui/src/signing/pages/Erc20Transfer.tsx b/packages/wallet-react-ui/src/signing/pages/Erc20Transfer.tsx index da62866a..000b9790 100644 --- a/packages/wallet-react-ui/src/signing/pages/Erc20Transfer.tsx +++ b/packages/wallet-react-ui/src/signing/pages/Erc20Transfer.tsx @@ -1,9 +1,8 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, DataRowSkeleton, Icon, Text } from '@zerodev/react-ui' import { type Address, erc20Abi, formatUnits, type Hex } from 'viem' import { useReadContract } from 'wagmi' import { shortenHex } from '../../shared/utils/common' import { ArrowCardPair } from '../components/ArrowCardPair' -import { DataRow, DataRowSkeleton } from '../components/DataRow' import { InfoCard } from '../components/InfoCard' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' @@ -109,7 +108,12 @@ export function Erc20Transfer({ + } /> ) : ( diff --git a/packages/wallet-react-ui/src/signing/pages/EthTransfer.tsx b/packages/wallet-react-ui/src/signing/pages/EthTransfer.tsx index 6d307e1b..95f7bc69 100644 --- a/packages/wallet-react-ui/src/signing/pages/EthTransfer.tsx +++ b/packages/wallet-react-ui/src/signing/pages/EthTransfer.tsx @@ -1,8 +1,7 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, DataRowSkeleton, Icon, Text } from '@zerodev/react-ui' import { type Address, formatEther, type Hex } from 'viem' import { shortenHex } from '../../shared/utils/common' import { ArrowCardPair } from '../components/ArrowCardPair' -import { DataRow, DataRowSkeleton } from '../components/DataRow' import { InfoCard } from '../components/InfoCard' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' @@ -68,7 +67,12 @@ export function EthTransfer({ to, value, confirm, reject }: EthTransferProps) { + } /> ) : ( diff --git a/packages/wallet-react-ui/src/signing/pages/GenericRequest.tsx b/packages/wallet-react-ui/src/signing/pages/GenericRequest.tsx index 65d2428b..e220b185 100644 --- a/packages/wallet-react-ui/src/signing/pages/GenericRequest.tsx +++ b/packages/wallet-react-ui/src/signing/pages/GenericRequest.tsx @@ -1,4 +1,4 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, DataRowSkeleton, Icon, Text } from '@zerodev/react-ui' import { type Address, formatEther, @@ -9,7 +9,6 @@ import { } from 'viem' import { shortenHex } from '../../shared/utils/common' import type { Request } from '../../types.js' -import { DataRow, DataRowSkeleton } from '../components/DataRow' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' import { useGasEstimate } from '../hooks/useGasEstimate' @@ -112,7 +111,12 @@ function GenericSendTransaction({ + } /> ) : ( diff --git a/packages/wallet-react-ui/src/signing/pages/MintNft.tsx b/packages/wallet-react-ui/src/signing/pages/MintNft.tsx index 84da96d1..27878716 100644 --- a/packages/wallet-react-ui/src/signing/pages/MintNft.tsx +++ b/packages/wallet-react-ui/src/signing/pages/MintNft.tsx @@ -1,7 +1,6 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, DataRowSkeleton, Icon, Text } from '@zerodev/react-ui' import type { Address, Hex } from 'viem' import { useReadContract } from 'wagmi' -import { DataRow, DataRowSkeleton } from '../components/DataRow' import { InfoCard } from '../components/InfoCard' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' @@ -83,7 +82,12 @@ export function MintNft({ contract, data, confirm, reject }: MintNftProps) { + } /> ) : ( diff --git a/packages/wallet-react-ui/src/signing/pages/SignTypedData.tsx b/packages/wallet-react-ui/src/signing/pages/SignTypedData.tsx index 9d4cd672..483f3b91 100644 --- a/packages/wallet-react-ui/src/signing/pages/SignTypedData.tsx +++ b/packages/wallet-react-ui/src/signing/pages/SignTypedData.tsx @@ -1,7 +1,6 @@ -import { Text } from '@zerodev/react-ui' +import { DataRow, Text } from '@zerodev/react-ui' import type { Hex } from 'viem' import { shortenHex } from '../../shared/utils/common' -import { DataRow } from '../components/DataRow' import { Section } from '../components/Section' import { SigningLayout } from '../components/SigningLayout' import { TypedDataMessage } from '../components/TypedDataMessage' From 9e55cfda311410eac0b013c376d750badf597c6d Mon Sep 17 00:00:00 2001 From: Omar Basem Date: Wed, 15 Jul 2026 09:36:20 +0400 Subject: [PATCH 5/5] review changset --- .changeset/kind-tigers-gather.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/kind-tigers-gather.md diff --git a/.changeset/kind-tigers-gather.md b/.changeset/kind-tigers-gather.md new file mode 100644 index 00000000..1ae27e87 --- /dev/null +++ b/.changeset/kind-tigers-gather.md @@ -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`.