import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { TreeSelect, type TreeSelectNode } from './tree-select';
const treeData: TreeSelectNode[] = [
{
value: 'eng',
title: 'Engineering',
children: [
{ value: 'frontend', title: 'Frontend' },
{ value: 'backend', title: 'Backend' },
],
},
{
value: 'design',
title: 'Design',
children: [{ value: 'brand', title: 'Brand' }],
},
];
const trigger = () => screen.getByRole('combobox');
const node = (name: string) => screen.getByRole('treeitem', { name });
describe('TreeSelect', () => {
it('shows the placeholder until something is selected', () => {
render();
expect(trigger()).toHaveTextContent('Pick a team');
});
it('opens the tree panel', async () => {
const user = userEvent.setup();
render();
await user.click(trigger());
expect(await screen.findByRole('tree')).toBeInTheDocument();
expect(trigger()).toHaveAttribute('aria-expanded', 'true');
});
it('names the trigger, falling back to the placeholder', () => {
const { unmount } = render();
expect(screen.getByRole('combobox', { name: 'Pick a team' })).toBeInTheDocument();
unmount();
render();
expect(screen.getByRole('combobox', { name: 'Team' })).toBeInTheDocument();
});
describe('single selection', () => {
it('reports the picked value and closes', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(trigger());
await user.click(node('Frontend'));
expect(onChange).toHaveBeenCalledWith('frontend');
await waitFor(() => expect(screen.queryByRole('tree')).not.toBeInTheDocument());
});
it('shows the selected label on the trigger', () => {
render();
expect(trigger()).toHaveTextContent('Backend');
});
it('holds a controlled value until the parent updates it', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(trigger());
await user.click(node('Frontend'));
expect(onChange).toHaveBeenCalledWith('frontend');
expect(trigger()).toHaveTextContent('Backend');
});
});
describe('multiple selection', () => {
it('accumulates picks as chips', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(trigger());
await user.click(node('Frontend'));
expect(onChange).toHaveBeenLastCalledWith(['frontend']);
await user.click(node('Backend'));
expect(onChange).toHaveBeenLastCalledWith(['frontend', 'backend']);
});
it('removes one from its chip', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(screen.getByRole('button', { name: 'Remove Frontend' }));
expect(onChange).toHaveBeenCalledWith(['backend']);
});
});
describe('treeCheckable', () => {
it('checking a branch reports every leaf under it', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(trigger());
await user.click(node('Engineering').querySelector('[data-slot="tree-checkbox"]')!);
expect(onChange).toHaveBeenCalledWith(['frontend', 'backend']);
});
it('stays open so several branches can be checked', async () => {
const user = userEvent.setup();
render(
);
await user.click(trigger());
await user.click(node('Frontend').querySelector('[data-slot="tree-checkbox"]')!);
expect(screen.getByRole('tree')).toBeInTheDocument();
});
});
describe('search', () => {
it('filters the tree', async () => {
const user = userEvent.setup();
render();
await user.click(trigger());
await user.type(await screen.findByPlaceholderText('Search…'), 'brand');
expect(screen.getByRole('treeitem', { name: 'Brand' })).toBeInTheDocument();
expect(screen.queryByRole('treeitem', { name: 'Frontend' })).not.toBeInTheDocument();
});
it('keeps the parent of a matching child', async () => {
const user = userEvent.setup();
render();
await user.click(trigger());
await user.type(await screen.findByPlaceholderText('Search…'), 'front');
/* Dropping the parent would orphan the match. */
expect(screen.getByRole('treeitem', { name: 'Engineering' })).toBeInTheDocument();
expect(screen.getByRole('treeitem', { name: 'Frontend' })).toBeInTheDocument();
});
it('shows the empty message when nothing matches', async () => {
const user = userEvent.setup();
render();
await user.click(trigger());
await user.type(await screen.findByPlaceholderText('Search…'), 'zzz');
expect(screen.getByText('Nothing here')).toBeInTheDocument();
});
});
describe('clearing', () => {
it('clears the selection', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(screen.getByRole('button', { name: 'Clear selection' }));
expect(onChange).toHaveBeenCalledWith(null);
});
it('clears to an empty list when multiple', async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
);
await user.click(screen.getByRole('button', { name: 'Clear selection' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('hides the control while nothing is selected', () => {
render();
expect(screen.queryByRole('button', { name: 'Clear selection' })).not.toBeInTheDocument();
});
});
it('does not open when disabled', async () => {
const user = userEvent.setup();
render();
await user.click(trigger());
expect(screen.queryByRole('tree')).not.toBeInTheDocument();
});
});