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