Skip to content

Commit 7215700

Browse files
authored
Merge branch 'main' into i18n-copy-modules-a11y-notifications-batch-682-686
2 parents aa03ee0 + 7bd6823 commit 7215700

12 files changed

Lines changed: 1001 additions & 359 deletions

File tree

.gitignore

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ contracts/**/test_snapshots/
2626
coverage/
2727

2828
# TypeScript build info cache
29-
tsconfig.tsbuildinfo
29+
*.tsbuildinfo
3030

3131
# Playwright test results and reports
3232
test-results/
@@ -47,3 +47,22 @@ pnpm-lock.yaml
4747
.vscode/
4848
.DS_Store
4949
Thumbs.db
50+
51+
# Build output
52+
dist/
53+
54+
# Monorepo / Turborepo cache
55+
.turbo/
56+
57+
# Broader env file catch
58+
*.local
59+
.env
60+
61+
# Storybook static output
62+
storybook-static/
63+
64+
# Istanbul / NYC coverage output
65+
.nyc_output/
66+
67+
# CI test reports
68+
junit.xml
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* Tests for components/ui/select.tsx
3+
*
4+
* Covers three key states of the primitive:
5+
* 1. Placeholder — shown when no value is selected
6+
* 2. Value selection — selecting an item updates the displayed value
7+
* 3. Disabled — interaction is blocked when the select is disabled
8+
*
9+
* Implementation notes
10+
* ─────────────────────
11+
* @base-ui/react Select renders its popup via a Portal into document.body.
12+
* jsdom supports this, so we use userEvent.click to open the popup and
13+
* select an item exactly as a user would.
14+
*
15+
* Placeholder state is signalled by `data-placeholder` on SelectTrigger
16+
* (and SelectValue), per the base-ui Select API:
17+
* https://base-ui.com/react/components/select#trigger
18+
*
19+
* Disabled state is signalled by `data-disabled` on SelectTrigger and the
20+
* native HTML `disabled` attribute on the underlying <button>.
21+
*/
22+
23+
import { describe, it, expect, vi } from 'vitest'
24+
import { render, screen, within } from '@testing-library/react'
25+
import userEvent from '@testing-library/user-event'
26+
import {
27+
Select,
28+
SelectContent,
29+
SelectItem,
30+
SelectTrigger,
31+
SelectValue,
32+
} from '@/components/ui/select'
33+
34+
// ─── Shared test fixture ──────────────────────────────────────────────────────
35+
36+
interface TestSelectProps {
37+
value?: string
38+
defaultValue?: string
39+
onValueChange?: (value: string) => void
40+
disabled?: boolean
41+
placeholder?: string
42+
}
43+
44+
/**
45+
* Minimal but realistic Select assembly that mirrors how the component is
46+
* used throughout the app. SelectContent is rendered without animation
47+
* classes in jsdom so the items are immediately accessible after the popup
48+
* opens.
49+
*/
50+
function TestSelect({
51+
value,
52+
defaultValue,
53+
onValueChange,
54+
disabled = false,
55+
placeholder = 'Pick an option',
56+
}: TestSelectProps) {
57+
return (
58+
<Select
59+
value={value}
60+
defaultValue={defaultValue}
61+
onValueChange={onValueChange}
62+
disabled={disabled}
63+
>
64+
<SelectTrigger aria-label="test-select">
65+
<SelectValue placeholder={placeholder} />
66+
</SelectTrigger>
67+
<SelectContent>
68+
<SelectItem value="apple">Apple</SelectItem>
69+
<SelectItem value="banana">Banana</SelectItem>
70+
<SelectItem value="cherry">Cherry</SelectItem>
71+
</SelectContent>
72+
</Select>
73+
)
74+
}
75+
76+
// ─── Placeholder ──────────────────────────────────────────────────────────────
77+
78+
describe('placeholder', () => {
79+
it('shows the placeholder text when no value is selected', () => {
80+
render(<TestSelect />)
81+
82+
// The SelectValue span renders the placeholder text as its content when
83+
// no value is set.
84+
expect(screen.getByText('Pick an option')).toBeInTheDocument()
85+
})
86+
87+
it('marks the trigger with data-placeholder when no value is selected', () => {
88+
render(<TestSelect />)
89+
90+
const trigger = screen.getByRole('button', { name: 'test-select' })
91+
92+
// base-ui sets data-placeholder on the trigger element when the select
93+
// has no value — this is what drives the muted text-color in the CSS.
94+
expect(trigger).toHaveAttribute('data-placeholder')
95+
})
96+
97+
it('does not show data-placeholder once a value is set via defaultValue', () => {
98+
render(<TestSelect defaultValue="apple" />)
99+
100+
const trigger = screen.getByRole('button', { name: 'test-select' })
101+
expect(trigger).not.toHaveAttribute('data-placeholder')
102+
})
103+
104+
it('does not render the placeholder text when a defaultValue is provided', () => {
105+
render(<TestSelect defaultValue="banana" placeholder="Pick an option" />)
106+
107+
expect(screen.queryByText('Pick an option')).not.toBeInTheDocument()
108+
})
109+
})
110+
111+
// ─── Value selection ──────────────────────────────────────────────────────────
112+
113+
describe('value selection', () => {
114+
it('calls onValueChange with the selected item value when an option is clicked', async () => {
115+
const user = userEvent.setup()
116+
const onValueChange = vi.fn()
117+
118+
render(<TestSelect onValueChange={onValueChange} />)
119+
120+
// Open the popup
121+
await user.click(screen.getByRole('button', { name: 'test-select' }))
122+
123+
// The popup is portalled into document.body — query from there
124+
const listbox = await screen.findByRole('listbox')
125+
await user.click(within(listbox).getByText('Cherry'))
126+
127+
expect(onValueChange).toHaveBeenCalledOnce()
128+
expect(onValueChange).toHaveBeenCalledWith('cherry')
129+
})
130+
131+
it('removes data-placeholder from the trigger after a selection is made', async () => {
132+
const user = userEvent.setup()
133+
let currentValue = ''
134+
const onValueChange = vi.fn((v: string) => {
135+
currentValue = v
136+
})
137+
138+
const { rerender } = render(
139+
<TestSelect value={currentValue} onValueChange={onValueChange} />,
140+
)
141+
142+
const trigger = screen.getByRole('button', { name: 'test-select' })
143+
expect(trigger).toHaveAttribute('data-placeholder')
144+
145+
// Open and select
146+
await user.click(trigger)
147+
const listbox = await screen.findByRole('listbox')
148+
await user.click(within(listbox).getByText('Apple'))
149+
150+
expect(onValueChange).toHaveBeenCalledWith('apple')
151+
152+
// Rerender with the new value to reflect controlled state
153+
rerender(<TestSelect value={currentValue} onValueChange={onValueChange} />)
154+
expect(trigger).not.toHaveAttribute('data-placeholder')
155+
})
156+
157+
it('displays the selected item label in the trigger after selection', async () => {
158+
const user = userEvent.setup()
159+
let currentValue = ''
160+
const onValueChange = vi.fn((v: string) => {
161+
currentValue = v
162+
})
163+
164+
const { rerender } = render(
165+
<TestSelect value={currentValue} onValueChange={onValueChange} />,
166+
)
167+
168+
await user.click(screen.getByRole('button', { name: 'test-select' }))
169+
const listbox = await screen.findByRole('listbox')
170+
await user.click(within(listbox).getByText('Banana'))
171+
172+
rerender(<TestSelect value={currentValue} onValueChange={onValueChange} />)
173+
174+
// The trigger should now display the chosen item's label
175+
expect(screen.getByRole('button', { name: 'test-select' })).toHaveTextContent('Banana')
176+
})
177+
})
178+
179+
// ─── Disabled state ───────────────────────────────────────────────────────────
180+
181+
describe('disabled state', () => {
182+
it('renders the trigger with the disabled attribute when the select is disabled', () => {
183+
render(<TestSelect disabled />)
184+
185+
const trigger = screen.getByRole('button', { name: 'test-select' })
186+
expect(trigger).toBeDisabled()
187+
})
188+
189+
it('marks the trigger with data-disabled when disabled', () => {
190+
render(<TestSelect disabled />)
191+
192+
const trigger = screen.getByRole('button', { name: 'test-select' })
193+
expect(trigger).toHaveAttribute('data-disabled')
194+
})
195+
196+
it('does not open the popup when the trigger is clicked while disabled', async () => {
197+
const user = userEvent.setup()
198+
199+
render(<TestSelect disabled />)
200+
201+
await user.click(screen.getByRole('button', { name: 'test-select' }))
202+
203+
// No listbox should appear in the document after clicking a disabled trigger
204+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
205+
})
206+
207+
it('does not call onValueChange when disabled', async () => {
208+
const user = userEvent.setup()
209+
const onValueChange = vi.fn()
210+
211+
render(<TestSelect disabled onValueChange={onValueChange} />)
212+
213+
await user.click(screen.getByRole('button', { name: 'test-select' }))
214+
215+
expect(onValueChange).not.toHaveBeenCalled()
216+
})
217+
})

0 commit comments

Comments
 (0)