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