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');
});
});