import type * as React from 'react' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { axe } from 'vitest-axe' import { Select, SelectContent, SelectGroup, SelectGroupLabel, SelectItem, SelectSeparator, SelectTrigger, SelectValue, } from './select' type SelectArgs = { defaultValue?: string | null value?: string | null onValueChange?: (value: string | null) => void size?: 'sm' | 'md' | 'lg' variant?: 'outline' | 'soft' alignItemWithTrigger?: boolean clearable?: boolean placeholder?: string startSlot?: React.ReactNode endSlot?: React.ReactNode } function renderSelect({ defaultValue, value, onValueChange, size, variant, alignItemWithTrigger, clearable, placeholder = 'Pick a fruit', startSlot, endSlot, }: SelectArgs = {}) { return render( , ) } // Base UI's popup animation toggles pointer-events while entering, which // trips user-event's default safety check. Disable the check across tests. const setupUser = () => userEvent.setup({ pointerEventsCheck: 0 }) describe('Select', () => { it('tags the trigger with data-slot', () => { renderSelect({ size: 'lg', variant: 'soft' }) const trigger = screen.getByRole('combobox') expect(trigger.getAttribute('data-slot')).toBe('select-trigger') }) it('does not render content before being opened', () => { renderSelect() expect(screen.queryByText('Orange')).toBeNull() }) it('opens on click and renders popup with size-driven radius', async () => { const user = setupUser() renderSelect({ size: 'sm' }) await user.click(screen.getByRole('combobox')) const item = await screen.findByText('Orange') const popup = item.closest('[data-slot="select-content"]') as HTMLElement expect(popup).not.toBeNull() expect(popup.className).toContain('rounded-md') expect(popup.className).toContain('bg-background') expect(popup.className).toContain('border-border-overlay') }) it('selects an item, closes the popup, and fires onValueChange', async () => { const user = setupUser() const onValueChange = vi.fn() renderSelect({ onValueChange }) await user.click(screen.getByRole('combobox')) await user.click(await screen.findByRole('option', { name: 'Lemon' })) expect(onValueChange).toHaveBeenCalledWith('lemon', expect.anything()) await waitFor(() => { expect(screen.queryByRole('option', { name: 'Strawberry' })).toBeNull() }) }) it('renders the check indicator on the selected item', async () => { const user = setupUser() renderSelect({ defaultValue: 'orange' }) await user.click(screen.getByRole('combobox')) const items = await screen.findAllByRole('option') const selected = items.find((item) => item.getAttribute('data-selected') !== null) expect(selected).toBeDefined() expect(selected!.querySelector('[data-slot="select-item-indicator"] svg')).not.toBeNull() }) it('renders chevron-down (with rotate on open) when alignItemWithTrigger=false', async () => { const user = setupUser() renderSelect({ alignItemWithTrigger: false }) const trigger = screen.getByRole('combobox') const icon = trigger.querySelector('[data-slot="select-icon"]') as SVGElement expect(icon).not.toBeNull() expect(icon.getAttribute('class') ?? '').toContain('data-popup-open:rotate-180') await user.click(trigger) await screen.findByRole('option', { name: 'Orange' }) expect(trigger.getAttribute('data-popup-open')).toBe('') }) it('renders the chevrons icon when alignItemWithTrigger=true', () => { renderSelect({ alignItemWithTrigger: true }) const trigger = screen.getByRole('combobox') const icon = trigger.querySelector('[data-slot="select-icon"]') as SVGElement expect(icon).not.toBeNull() expect(icon.getAttribute('class') ?? '').not.toContain('rotate-180') // The two-arrow icon has two elements (up + down chevrons combined as one path). expect(icon.querySelector('path')).not.toBeNull() }) it('shows the clear control only when a value is present and clears on click', async () => { const user = setupUser() const onValueChange = vi.fn() renderSelect({ defaultValue: 'orange', clearable: true, onValueChange }) const clearControl = document.querySelector('[data-slot="select-clear"]') as HTMLElement expect(clearControl).toBeTruthy() await user.click(clearControl) expect(onValueChange).toHaveBeenCalledWith(null, expect.anything()) await waitFor(() => { expect(document.querySelector('[data-slot="select-clear"]')).toBeNull() }) }) it('clears via Delete/Backspace on the trigger without remounting or losing focus', async () => { const user = setupUser() const onValueChange = vi.fn() renderSelect({ defaultValue: 'orange', clearable: true, onValueChange }) const trigger = screen.getByRole('combobox') trigger.focus() expect(document.activeElement).toBe(trigger) await user.keyboard('{Backspace}') expect(onValueChange).toHaveBeenCalledWith(null, expect.anything()) await waitFor(() => { expect(document.querySelector('[data-slot="select-clear"]')).toBeNull() }) expect(screen.getByRole('combobox')).toBe(trigger) expect(document.activeElement).toBe(trigger) }) it('does not render the clear control when there is no value', () => { renderSelect({ clearable: true }) expect(document.querySelector('[data-slot="select-clear"]')).toBeNull() }) it('closes on Escape', async () => { const user = setupUser() renderSelect() await user.click(screen.getByRole('combobox')) await screen.findByRole('option', { name: 'Orange' }) await user.keyboard('{Escape}') await waitFor(() => { expect(screen.queryByRole('option', { name: 'Orange' })).toBeNull() }) }) it('renders startSlot and endSlot inside the trigger', () => { renderSelect({ startSlot: @, endSlot: , }) const trigger = screen.getByRole('combobox') expect(trigger.querySelector('[data-slot="select-trigger-start"]')).not.toBeNull() expect(trigger.querySelector('[data-slot="select-trigger-end"]')).not.toBeNull() expect(screen.getByTestId('start')).toBeTruthy() expect(screen.getByTestId('end')).toBeTruthy() }) it('omits slot wrappers when slots are not provided', () => { renderSelect() const trigger = screen.getByRole('combobox') expect(trigger.querySelector('[data-slot="select-trigger-start"]')).toBeNull() expect(trigger.querySelector('[data-slot="select-trigger-end"]')).toBeNull() }) it('mirrors a standalone aria-invalid to data-invalid on the trigger', () => { render( , ) const trigger = screen.getByRole('combobox') expect(trigger.getAttribute('data-invalid')).toBe('') expect(trigger.getAttribute('aria-invalid')).toBe('true') }) it('does not set data-invalid when aria-invalid is absent', () => { renderSelect() expect(screen.getByRole('combobox').getAttribute('data-invalid')).toBeNull() }) it('has no accessibility violations (closed state)', async () => { const { container } = renderSelect() expect(await axe(container)).toHaveNoViolations() }) })