Skip to content
Closed
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
57 changes: 57 additions & 0 deletions packages/react-kit/src/shared/components/Select/Select.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/react-vite'

import { Select } from '.'

const meta = {
title: 'Shared/Select',
component: Select,
parameters: { layout: 'centered' },
tags: ['autodocs'],
decorators: [
(Story) => (
<div style={{ width: 240 }}>
<Story />
</div>
),
],
} satisfies Meta<typeof Select>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
args: {
label: 'Token',
},
}

export const WithLeadingImageAndChain: Story = {
args: {
label: 'Token',
subtitle: 'Arbitrum',
leadingImage:
'https://img.icons8.com/external-black-fill-lafs/64/external-USDC-cryptocurrency-black-fill-lafs.png',
chainImage: 'https://img.icons8.com/color/1200/ethereum.jpg',
},
}

export const NoTrailingIcon: Story = {
args: {
label: 'Token',
trailingIcon: false,
},
}

export const Disabled: Story = {
args: {
label: 'Token',
disabled: true,
},
}

export const Loading: Story = {
args: {
label: '',
loading: true,
},
}
58 changes: 58 additions & 0 deletions packages/react-kit/src/shared/components/Select/Select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import { Select } from '.'

afterEach(() => cleanup())

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

it('fires onClick when pressed', () => {
const onClick = vi.fn()
render(<Select label="Token" onClick={onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledTimes(1)
})

it('renders subtitle and chain image when provided', () => {
render(
<Select
label="USDC"
subtitle="Arbitrum"
chainImage="http://example.com/arb.png"
/>,
)
expect(screen.getByText('USDC')).toBeDefined()
expect(screen.getByText('Arbitrum')).toBeDefined()
})

it('renders the trailing chevron by default', () => {
const { container } = render(<Select label="Token" />)
expect(container.querySelector('svg')).toBeDefined()
})

it('hides the trailing icon when trailingIcon=false', () => {
const { container } = render(<Select label="Token" trailingIcon={false} />)
expect(container.querySelector('svg')).toBeNull()
})

it('does not fire onClick when disabled', () => {
const onClick = vi.fn()
render(<Select label="Token" onClick={onClick} disabled />)
fireEvent.click(screen.getByRole('button'))
expect(onClick).not.toHaveBeenCalled()
})

it('renders the skeleton when loading', () => {
render(<Select label="Token" loading />)
// Skeleton renders a div, not a button — no role="button" present.
expect(screen.queryByRole('button')).toBeNull()
})
})
107 changes: 107 additions & 0 deletions packages/react-kit/src/shared/components/Select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { ButtonHTMLAttributes } from 'react'

import { cn } from '../../utils/common'
import { Icon } from '../Icon'
import { Text } from '../Text'
import { Wrapper } from '../Wrapper'

export interface SelectProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
/** Primary label (e.g. token symbol). */
label: string
/** Optional subtitle row (e.g. chain name). */
subtitle?: string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Are subtitle and chainImage going to be used anywhere for SRA?
  2. Maybe we should rename chainImage to trailingImage to make it consistent with being content-agnostic?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subtitle and chainImage are used in the Select component in other pages

I think using trailingImage here wouldn't be appropriate, if you look at the design here you would see it's not really a trailing image

/** Optional leading image (e.g. token logo). Renders a 44x44 rounded square. */
leadingImage?: string
/** Optional small icon shown next to the subtitle (e.g. chain badge). */
chainImage?: string
/** Show the trailing chevron-down. Defaults to `true`. */
trailingIcon?: boolean
/** Render a skeleton placeholder instead of the content. */
loading?: boolean
}

export function SelectSkeleton({
className,
}: {
className?: string | undefined
}) {
return (
<Wrapper className={cn('h-16 rounded-xl p-1.5 opacity-60', className)}>
<div className="size-13 rounded-xl border-offWhite border-[0.3px] bg-offWhite/40 animate-pulse" />
</Wrapper>
)
}

export function Select({
label,
subtitle,
leadingImage,
chainImage,
trailingIcon = true,
loading = false,
className,
...rest
}: SelectProps) {
if (loading) return <SelectSkeleton className={className} />

const hasLeadingBlock = !!leadingImage || !!subtitle

return (
<Wrapper className={cn('h-13 rounded-2xl', className)}>
<button
type="button"
className={cn(
'w-full h-full flex flex-row items-center justify-between gap-2 transition-colors',
leadingImage ? 'pl-1' : 'pl-4',
'pr-2 py-1',
rest.disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:bg-offWhite/40',
)}
{...rest}
>
<div className="flex flex-row items-center gap-1.5 min-w-0">
{leadingImage && (
<div className="size-11 shrink-0 rounded-xl bg-white overflow-hidden flex items-center justify-center">
<img
src={leadingImage}
alt=""
className="size-full object-cover"
/>
</div>
)}
{hasLeadingBlock ? (
<div className="flex flex-col items-start gap-1.5 min-w-0">
<Text className="text-body1 truncate">{label}</Text>
{subtitle && (
<div className="flex flex-row items-center gap-1">
{chainImage && (
<span className="inline-block size-3 rounded-full overflow-hidden bg-white shrink-0">
<img
src={chainImage}
alt=""
className="size-full object-cover"
/>
</span>
)}
<Text className="text-body3 text-greyScale/50 truncate">
{subtitle}
</Text>
</div>
)}
</div>
) : (
<Text className="text-body1 truncate">{label}</Text>
)}
</div>
{trailingIcon && (
<Icon
name="chevronDown"
className="size-4.5 text-greyScale shrink-0"
/>
)}
</button>
</Wrapper>
)
}