import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { describe, expect, it, vi } from 'vitest'; import { ChevronDown } from '../../icons/glyphs/chevron-down'; import { MagnifyingGlass } from '../../icons/glyphs/magnifying-glass'; import { Button } from '../button'; import { Field } from '../field'; import { Icon } from '../icon'; import { InputGroup } from '../input-group'; import { Combobox } from './combobox'; function FloatingCombobox(props?: { onValueChange?: (value: string | null) => void; anchor?: HTMLElement }) { return ( } > Apple Banana ); } describe('Mosaic Combobox', () => { it.each([undefined, 'ghost', 'default'] as const)('supports the %s variant outside a group', variant => { render( , ); expect(screen.getByRole('combobox')).toHaveAttribute('data-variant', variant ?? 'default'); }); it('respects an explicit default variant inside a group', () => { render( , ); expect(screen.getByRole('combobox')).toHaveAttribute('data-variant', 'default'); }); it('removes the check and selection when the input is cleared', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); render(); await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); await user.click(screen.getByRole('option', { name: 'Apple' })); await user.clear(screen.getByRole('combobox')); expect(onValueChange).toHaveBeenLastCalledWith(null); expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', 'false'); await user.keyboard('{Escape}'); expect(screen.getByRole('combobox')).toHaveValue(''); }); it('keeps the check on the selection while another option is highlighted', async () => { const user = userEvent.setup(); render(); const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); await user.click(trigger); expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); await user.click(screen.getByRole('option', { name: 'Apple' })); await user.click(trigger); await user.hover(screen.getByRole('option', { name: 'Banana' })); expect(screen.getByTestId('apple-check')).toBeVisible(); expect(screen.getByTestId('apple-check')).toHaveAttribute('aria-hidden', 'true'); expect(screen.queryByTestId('banana-check')).not.toBeInTheDocument(); expect(screen.getByRole('option', { name: 'Apple' })).toHaveAttribute('aria-selected', 'true'); await user.click(screen.getByRole('option', { name: 'Banana' })); await user.click(trigger); expect(screen.getByTestId('banana-check')).toBeVisible(); expect(screen.queryByTestId('apple-check')).not.toBeInTheDocument(); }); it('positions the popup against the full input group', async () => { const user = userEvent.setup(); render(); const measureGroup = vi.spyOn(screen.getByTestId('fruit-group'), 'getBoundingClientRect'); await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); await waitFor(() => expect(measureGroup).toHaveBeenCalled()); expect(screen.getByRole('combobox', { name: 'Fruit' })).toHaveFocus(); }); it('opens its options from a composed trigger', async () => { const user = userEvent.setup(); render(); const input = screen.getByRole('combobox', { name: 'Fruit' }); const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); expect(trigger).toHaveClass('cl-button', 'cl-combobox-trigger'); expect(trigger).toHaveAttribute('data-size', 'sm'); expect(trigger).toHaveAttribute('data-variant', 'ghost'); expect(trigger).toHaveAttribute('data-color', 'neutral'); await user.click(trigger); expect(screen.getByRole('listbox')).toBeInTheDocument(); expect(trigger).toHaveAttribute('data-open', ''); expect(input).toHaveFocus(); }); it('allows an explicit anchor to override the input group', async () => { const user = userEvent.setup(); const anchor = document.createElement('div'); const measureAnchor = vi.spyOn(anchor, 'getBoundingClientRect'); render(); const measureGroup = vi.spyOn(screen.getByTestId('fruit-group'), 'getBoundingClientRect'); await user.click(screen.getByRole('button', { name: 'Toggle fruit options' })); await waitFor(() => expect(measureAnchor).toHaveBeenCalled()); expect(measureGroup).not.toHaveBeenCalled(); }); it('anchors to the input without a group and opens with the keyboard', async () => { const user = userEvent.setup(); render( Apple , ); const input = screen.getByRole('combobox', { name: 'Fruit' }); const measureInput = vi.spyOn(input, 'getBoundingClientRect'); await user.tab(); await user.keyboard('{ArrowDown}'); await waitFor(() => expect(measureInput).toHaveBeenCalled()); expect(screen.getByRole('listbox')).toBeInTheDocument(); expect(input).toHaveFocus(); }); it('renders a field-aware, sized input', () => { render( Fruit Apple Choose a fruit , ); const input = screen.getByRole('combobox', { name: 'Fruit' }); expect(input).toHaveClass('cl-input', 'cl-combobox-input'); expect(input).toHaveAttribute('data-size', 'lg'); expect(input).toBeRequired(); expect(input).toHaveAttribute('aria-invalid', 'true'); expect(input).toHaveAccessibleDescription('Choose a fruit'); }); it('opens a floating popup when the user types', async () => { const user = userEvent.setup(); render(); await user.type(screen.getByRole('combobox', { name: 'Fruit' }), 'a'); const listbox = screen.getByRole('listbox'); const popup = listbox.querySelector('.cl-combobox-popup'); const viewport = popup?.querySelector('.cl-combobox-viewport'); expect(listbox).toHaveClass('cl-combobox-positioner'); expect(popup).toBeInTheDocument(); expect(viewport).toBeInTheDocument(); expect(screen.getByRole('option', { name: 'Apple' }).closest('.cl-combobox-viewport')).toBe(viewport); }); it('opens its options when the input is clicked', async () => { const user = userEvent.setup(); render(); await user.click(screen.getByRole('combobox', { name: 'Fruit' })); expect(screen.getByRole('listbox')).toBeInTheDocument(); }); it('selects an option and restores its label to the input', async () => { const onValueChange = vi.fn(); const user = userEvent.setup(); render(); const input = screen.getByRole('combobox', { name: 'Fruit' }); await user.type(input, 'b'); await user.click(screen.getByRole('option', { name: 'Banana' })); expect(onValueChange).toHaveBeenCalledWith('banana'); expect(input).toHaveValue('Banana'); expect(input).toHaveFocus(); }); it('supports keyboard selection', async () => { const onValueChange = vi.fn(); const user = userEvent.setup(); render(); const input = screen.getByRole('combobox', { name: 'Fruit' }); await user.type(input, 'a'); await user.keyboard('{Enter}'); expect(onValueChange).toHaveBeenCalledWith('apple'); }); it('discards search text on dismiss without changing the selected value', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); render(); const input = screen.getByRole('combobox', { name: 'Fruit' }); await user.type(input, 'a'); await user.keyboard('{Enter}'); await user.type(input, ' not a fruit'); await user.keyboard('{Escape}'); expect(input).toHaveValue('Apple'); expect(onValueChange).toHaveBeenCalledExactlyOnceWith('apple'); }); it('keeps the selected label while reopening the unfiltered collection', async () => { const user = userEvent.setup(); render( item} empty={No matches} > {item => ( {item} )} , ); const input = screen.getByRole('combobox'); await user.type(input, 'Ban'); expect(screen.getAllByRole('option')).toHaveLength(1); await user.click(screen.getByRole('option', { name: 'Banana' })); await user.click(screen.getByRole('button', { name: 'Show fruits' })); expect(input).toHaveValue('Banana'); expect(screen.getAllByRole('option')).toHaveLength(2); await user.type(input, 'unknown'); expect(screen.getByText('No matches')).toBeInTheDocument(); await user.keyboard('{Escape}'); expect(input).toHaveValue('Banana'); }); it('styles disabled options and prevents their selection', async () => { const onValueChange = vi.fn(); const user = userEvent.setup(); render( Apple , ); const option = screen.getByRole('option', { name: 'Apple' }); expect(option).toHaveClass('cl-combobox-option'); expect(option).toHaveAttribute('aria-disabled', 'true'); await user.click(option); expect(onValueChange).not.toHaveBeenCalled(); }); it('defaults to the ghost Input variant inside an input group', () => { render( United States , ); expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveClass('cl-input', 'cl-combobox-input'); expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-size', 'lg'); expect(screen.getByRole('combobox', { name: 'Search countries' })).toHaveAttribute('data-variant', 'ghost'); expect(screen.getByRole('listbox')).toHaveClass('cl-combobox-list'); expect(screen.getByRole('option', { name: 'United States' })).toHaveClass('cl-combobox-option'); }); it('renders a reusable empty state', () => { render(No matches); expect(screen.getByText('No matches')).toHaveClass('cl-combobox-empty'); }); function ClearableCombobox(props?: { onValueChange?: (value: string | null) => void }) { return ( Apple ); } it('clears the input and selection from the Clear button and keeps focus on the input', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); render(); await user.click(screen.getByRole('button', { name: 'Clear' })); expect(onValueChange).toHaveBeenCalledWith(null); expect(screen.getByRole('combobox', { name: 'Fruit' })).toHaveValue(''); expect(screen.getByRole('combobox', { name: 'Fruit' })).toHaveFocus(); }); it('renders Clear only while there is an input value or a selection', async () => { const user = userEvent.setup(); render( , ); expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); await user.type(screen.getByRole('combobox', { name: 'Fruit' }), 'a'); expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); }); it('keeps the trigger toggling the list with its own name while there is text', async () => { const user = userEvent.setup(); render(); const trigger = screen.getByRole('button', { name: 'Toggle fruit options' }); expect(trigger).toHaveAttribute('aria-expanded', 'false'); await user.click(trigger); expect(trigger).toHaveAttribute('aria-expanded', 'true'); await user.click(trigger); expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); });