import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Tree, type TreeNode } from './tree'; const treeData: TreeNode[] = [ { key: 'src', title: 'src', children: [ { key: 'button', title: 'button.tsx' }, { key: 'input', title: 'input.tsx' }, ], }, { key: 'docs', title: 'docs', children: [{ key: 'intro', title: 'intro.mdx' }], }, { key: 'readme', title: 'readme.md' }, ]; const item = (name: string) => screen.getByRole('treeitem', { name }); const visibleNames = () => screen.getAllByRole('treeitem').map((node) => node.textContent?.trim()); describe('Tree', () => { it('shows only the roots until something is expanded', () => { render(); expect(visibleNames()).toEqual(['src', 'docs', 'readme.md']); }); it('opens everything with defaultExpandAll', () => { render(); expect(visibleNames()).toEqual([ 'src', 'button.tsx', 'input.tsx', 'docs', 'intro.mdx', 'readme.md', ]); }); it('opens only the branches named by defaultExpandedKeys', () => { render(); expect(visibleNames()).toEqual(['src', 'docs', 'intro.mdx', 'readme.md']); }); describe('accessibility', () => { it('exposes the tree and its levels', () => { render(); expect(screen.getByRole('tree', { name: 'Files' })).toBeInTheDocument(); expect(item('src')).toHaveAttribute('aria-level', '1'); expect(item('button.tsx')).toHaveAttribute('aria-level', '2'); }); it('reports expansion only on branches', () => { render(); expect(item('src')).toHaveAttribute('aria-expanded', 'false'); expect(item('readme.md')).not.toHaveAttribute('aria-expanded'); }); it('keeps a single tab stop', async () => { const user = userEvent.setup(); render(); await user.tab(); expect(item('src')).toHaveFocus(); /* Tab leaves the tree rather than walking every node in it. */ await user.tab(); expect(item('button.tsx')).not.toHaveFocus(); }); }); describe('expanding', () => { it('toggles from the switcher without selecting the node', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); const { container } = render( ); const switcher = container.querySelector('[data-slot="tree-switcher"]')!; await user.click(switcher); expect(visibleNames()).toContain('button.tsx'); expect(onSelect).not.toHaveBeenCalled(); }); it('reports onExpand', async () => { const user = userEvent.setup(); const onExpand = vi.fn(); const { container } = render( ); await user.click(container.querySelector('[data-slot="tree-switcher"]')!); expect(onExpand).toHaveBeenCalledWith(['src'], expect.objectContaining({ expanded: true })); }); it('leaves the open set to the parent when controlled', async () => { const user = userEvent.setup(); const onExpand = vi.fn(); const { container } = render( ); expect(visibleNames()).toContain('intro.mdx'); await user.click(container.querySelector('[data-slot="tree-switcher"]')!); expect(onExpand).toHaveBeenCalled(); expect(visibleNames()).not.toContain('button.tsx'); }); }); describe('selection', () => { it('selects on click and reports the node', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); render(); await user.click(item('readme.md')); expect(onSelect).toHaveBeenCalledWith( ['readme'], expect.objectContaining({ selected: true }) ); expect(item('readme.md')).toHaveAttribute('aria-selected', 'true'); }); it('deselects when the selected node is clicked again', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); render( ); await user.click(item('readme.md')); expect(onSelect).toHaveBeenLastCalledWith([], expect.objectContaining({ selected: false })); }); it('ignores a disabled node', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); render( ); await user.click(item('locked')); expect(onSelect).not.toHaveBeenCalled(); expect(item('locked')).toHaveAttribute('aria-disabled', 'true'); }); }); describe('checkboxes', () => { it('checking a branch checks every leaf under it', async () => { const user = userEvent.setup(); const onCheck = vi.fn(); const { container } = render( ); await user.click(container.querySelectorAll('[data-slot="tree-checkbox"]')[0]); expect(onCheck).toHaveBeenCalledWith( ['button', 'input'], expect.objectContaining({ checked: true }) ); expect(item('src')).toHaveAttribute('aria-checked', 'true'); expect(item('button.tsx')).toHaveAttribute('aria-checked', 'true'); }); it('a partly checked branch reads as mixed', () => { render( ); expect(item('src')).toHaveAttribute('aria-checked', 'mixed'); expect(item('button.tsx')).toHaveAttribute('aria-checked', 'true'); expect(item('input.tsx')).toHaveAttribute('aria-checked', 'false'); }); it('completing the leaves promotes the branch to checked', async () => { const user = userEvent.setup(); render( ); await user.click( item('input.tsx').querySelector('[data-slot="tree-checkbox"]')! ); expect(item('src')).toHaveAttribute('aria-checked', 'true'); }); it('unchecking a branch clears its leaves', async () => { const user = userEvent.setup(); const onCheck = vi.fn(); render( ); await user.click(item('src').querySelector('[data-slot="tree-checkbox"]')!); expect(onCheck).toHaveBeenCalledWith([], expect.objectContaining({ checked: false })); }); it('reports the half-checked branches', async () => { const user = userEvent.setup(); const onCheck = vi.fn(); render( ); await user.click(item('button.tsx').querySelector('[data-slot="tree-checkbox"]')!); expect(onCheck).toHaveBeenCalledWith( ['button'], expect.objectContaining({ halfCheckedKeys: ['src'] }) ); }); it('skips a leaf whose checkbox is disabled', async () => { const user = userEvent.setup(); const onCheck = vi.fn(); render( ); await user.click(item('root').querySelector('[data-slot="tree-checkbox"]')!); expect(onCheck).toHaveBeenCalledWith(['a'], expect.objectContaining({ checked: true })); }); }); describe('keyboard', () => { const setup = async () => { const user = userEvent.setup(); render(); await user.tab(); return user; }; it('moves down and up the visible rows', async () => { const user = await setup(); await user.keyboard('{ArrowDown}'); expect(item('button.tsx')).toHaveFocus(); await user.keyboard('{ArrowUp}'); expect(item('src')).toHaveFocus(); }); it('opens a closed branch with ArrowRight, then steps into it', async () => { const user = userEvent.setup(); render(); await user.tab(); await user.keyboard('{ArrowRight}'); expect(visibleNames()).toContain('button.tsx'); await user.keyboard('{ArrowRight}'); expect(item('button.tsx')).toHaveFocus(); }); it('closes an open branch with ArrowLeft, then climbs to the parent', async () => { const user = await setup(); await user.keyboard('{ArrowLeft}'); expect(visibleNames()).not.toContain('button.tsx'); await user.keyboard('{ArrowRight}{ArrowDown}{ArrowLeft}'); expect(item('src')).toHaveFocus(); }); it('jumps to the ends with Home and End', async () => { const user = await setup(); await user.keyboard('{End}'); expect(item('readme.md')).toHaveFocus(); await user.keyboard('{Home}'); expect(item('src')).toHaveFocus(); }); it('selects with Enter', async () => { const user = userEvent.setup(); const onSelect = vi.fn(); render(); await user.tab(); await user.keyboard('{ArrowDown}{Enter}'); expect(onSelect).toHaveBeenCalledWith(['button'], expect.objectContaining({ selected: true })); }); it('Space checks when checkable, selects otherwise', async () => { const user = userEvent.setup(); const onCheck = vi.fn(); render( ); await user.tab(); await user.keyboard('{ArrowDown}[Space]'); expect(onCheck).toHaveBeenCalledWith(['button'], expect.objectContaining({ checked: true })); }); it('jumps to a node by typing its name', async () => { const user = await setup(); await user.keyboard('re'); expect(item('readme.md')).toHaveFocus(); }); it('opens every sibling branch with *', async () => { const user = userEvent.setup(); render(); await user.tab(); await user.keyboard('*'); expect(visibleNames()).toContain('button.tsx'); expect(visibleNames()).toContain('intro.mdx'); }); }); describe('virtual', () => { const many: TreeNode[] = Array.from({ length: 1000 }, (_, index) => ({ key: index, title: `Node ${index}`, })); it('mounts only the rows in view', () => { render(); /* 200px ÷ 20px is 10 rows, plus overscan — nothing like 1000. */ expect(screen.getAllByRole('treeitem').length).toBeLessThan(25); expect(screen.getByRole('treeitem', { name: 'Node 0' })).toBeInTheDocument(); }); it('renders every row when virtual is off', () => { render(); expect(screen.getAllByRole('treeitem')).toHaveLength(50); }); }); });