/** * TreeView — unit / DOM tests (Vitest + happy-dom). * * These exercise the public API and the ARIA contract documented in * `TreeView.ts` against a real (happy-dom) document. Focus-driven keyboard * navigation (arrow keys, Home/End, type-ahead, `*`) depends on real * `element.focus()` / `activeElement`, which happy-dom does not model reliably, * so those are covered by the Playwright `TreeView.e2e.ts` suite. Here we assert * through the public API and resulting attributes. */ import { describe, it, expect } from 'vitest'; import { TreeView, type TreeViewOptions } from './TreeView'; interface NodeSpec { label: string; open?: boolean; selected?: boolean; children?: readonly NodeSpec[]; } const TREE: readonly NodeSpec[] = [ { label: 'README.md' }, { label: 'src', open: true, children: [ { label: 'index.ts', selected: true }, { label: 'components', open: true, children: [{ label: 'Accordion.ts' }, { label: 'TreeView.ts' }], }, ], }, { label: 'docs', children: [{ label: 'getting-started.md' }, { label: 'accessibility.md' }], }, ]; function buildItems(nodes: readonly NodeSpec[]): HTMLLIElement[] { return nodes.map((node) => { const li = document.createElement('li'); li.className = 'treeview__item'; if (node.open) li.setAttribute('data-expanded', ''); if (node.selected) li.setAttribute('data-selected', ''); const label = document.createElement('span'); label.className = 'treeview__label'; label.textContent = node.label; li.append(label); if (node.children?.length) { const group = document.createElement('ul'); group.className = 'treeview__group'; group.append(...buildItems(node.children)); li.append(group); } return li; }); } /** Build the semantic nested-list markup and enhance it. */ function build( nodes: readonly NodeSpec[] = TREE, options: TreeViewOptions = {}, ): { root: HTMLElement; instance: TreeView } { const root = document.createElement('ul'); root.className = 'treeview'; root.append(...buildItems(nodes)); document.body.append(root); const instance = new TreeView(root, { label: 'Project files', ...options }); return { root, instance }; } /** All treeitem `
  • ` elements in document order. */ function items(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('.treeview__item')); } /** Find the index (document order) of the item whose own label is `text`. */ function indexOfLabel(root: HTMLElement, text: string): number { return items(root).findIndex( (li) => li.querySelector(':scope > .treeview__label')?.textContent === text, ); } describe('TreeView — construction', () => { it('throws when root is not an element', () => { // @ts-expect-error deliberately wrong type expect(() => new TreeView(undefined)).toThrow(TypeError); }); it('reports node count via `size`', () => { const { instance } = build(); expect(instance.size).toBe(9); }); }); describe('TreeView — roles and structure', () => { it('applies tree/treeitem/group roles', () => { const { root } = build(); expect(root.getAttribute('role')).toBe('tree'); for (const li of items(root)) { expect(li.getAttribute('role')).toBe('treeitem'); } for (const group of root.querySelectorAll('.treeview__group')) { expect(group.getAttribute('role')).toBe('group'); } }); it('sets aria-level, aria-setsize and aria-posinset', () => { const { root } = build(); const readme = items(root)[indexOfLabel(root, 'README.md')]!; expect(readme.getAttribute('aria-level')).toBe('1'); expect(readme.getAttribute('aria-setsize')).toBe('3'); // README.md, src, docs expect(readme.getAttribute('aria-posinset')).toBe('1'); const accordion = items(root)[indexOfLabel(root, 'Accordion.ts')]!; expect(accordion.getAttribute('aria-level')).toBe('3'); expect(accordion.getAttribute('aria-setsize')).toBe('2'); expect(accordion.getAttribute('aria-posinset')).toBe('1'); }); it('puts aria-expanded only on parents, reflecting data-expanded', () => { const { root } = build(); const src = items(root)[indexOfLabel(root, 'src')]!; const docs = items(root)[indexOfLabel(root, 'docs')]!; const readme = items(root)[indexOfLabel(root, 'README.md')]!; expect(src.getAttribute('aria-expanded')).toBe('true'); // data-expanded expect(docs.getAttribute('aria-expanded')).toBe('false'); expect(readme.hasAttribute('aria-expanded')).toBe(false); // leaf }); it('hides groups of collapsed parents and shows groups of expanded ones', () => { const { root } = build(); const src = items(root)[indexOfLabel(root, 'src')]!; const docs = items(root)[indexOfLabel(root, 'docs')]!; expect(src.querySelector(':scope > .treeview__group')!.hidden).toBe(false); expect(docs.querySelector(':scope > .treeview__group')!.hidden).toBe(true); }); it('removes data-expanded / data-selected authoring hints', () => { const { root } = build(); for (const li of items(root)) { expect(li.hasAttribute('data-expanded')).toBe(false); expect(li.hasAttribute('data-selected')).toBe(false); } }); }); describe('TreeView — roving tabindex', () => { it('leaves exactly one item with tabindex 0 and the rest -1', () => { const { root } = build(); const all = items(root); const zeros = all.filter((li) => li.tabIndex === 0); expect(zeros.length).toBe(1); expect(zeros[0]).toBe(all[0]); // first node is the initial tab stop for (const li of all.slice(1)) expect(li.tabIndex).toBe(-1); }); }); describe('TreeView — expand / collapse / toggle API', () => { it('expands and collapses a parent, syncing group.hidden', () => { const { root, instance } = build(); const i = indexOfLabel(root, 'docs'); const group = items(root)[i]!.querySelector(':scope > .treeview__group')!; expect(instance.isExpanded(i)).toBe(false); instance.expand(i); expect(instance.isExpanded(i)).toBe(true); expect(group.hidden).toBe(false); instance.collapse(i); expect(instance.isExpanded(i)).toBe(false); expect(group.hidden).toBe(true); }); it('toggle flips expansion', () => { const { root, instance } = build(); const i = indexOfLabel(root, 'docs'); instance.toggle(i); expect(instance.isExpanded(i)).toBe(true); instance.toggle(i); expect(instance.isExpanded(i)).toBe(false); }); it('expand is a no-op on leaf nodes', () => { const { root, instance } = build(); const i = indexOfLabel(root, 'README.md'); instance.expand(i); expect(instance.isExpanded(i)).toBe(false); expect(items(root)[i]!.hasAttribute('aria-expanded')).toBe(false); }); }); describe('TreeView — selection modes', () => { it('single mode reflects initial data-selected and dedups selection', () => { const { root, instance } = build(TREE, { selectionMode: 'single' }); const index = indexOfLabel(root, 'index.ts'); const treeview = indexOfLabel(root, 'TreeView.ts'); expect(instance.isSelected(index)).toBe(true); // data-selected instance.select(treeview); expect(instance.isSelected(treeview)).toBe(true); expect(instance.isSelected(index)).toBe(false); // previous cleared }); it('multiple mode toggles independently and sets aria-multiselectable', () => { const { root, instance } = build(TREE, { selectionMode: 'multiple' }); expect(root.getAttribute('aria-multiselectable')).toBe('true'); const readme = indexOfLabel(root, 'README.md'); const index = indexOfLabel(root, 'index.ts'); instance.select(readme); expect(instance.isSelected(readme)).toBe(true); expect(instance.isSelected(index)).toBe(true); // initial selection kept instance.deselect(index); expect(instance.isSelected(index)).toBe(false); expect(instance.isSelected(readme)).toBe(true); }); it('none mode applies no aria-selected and ignores select()', () => { const { root, instance } = build(TREE, { selectionMode: 'none' }); expect(root.hasAttribute('aria-multiselectable')).toBe(false); for (const li of items(root)) { expect(li.hasAttribute('aria-selected')).toBe(false); } instance.select(0); expect(items(root)[0]!.hasAttribute('aria-selected')).toBe(false); }); }); describe('TreeView — events', () => { it('fires treeview:toggle with detail on expand', () => { const { root, instance } = build(); let detail: { index: number; expanded: boolean } | null = null; root.addEventListener('treeview:toggle', (e) => { const d = (e as CustomEvent<{ index: number; expanded: boolean }>).detail; detail = { index: d.index, expanded: d.expanded }; }); const i = indexOfLabel(root, 'docs'); instance.expand(i); expect(detail).toEqual({ index: i, expanded: true }); }); it('fires treeview:select with detail', () => { const { root, instance } = build(); let detail: { index: number; selected: boolean } | null = null; root.addEventListener('treeview:select', (e) => { const d = (e as CustomEvent<{ index: number; selected: boolean }>).detail; detail = { index: d.index, selected: d.selected }; }); const i = indexOfLabel(root, 'README.md'); instance.select(i); expect(detail).toEqual({ index: i, selected: true }); }); it('fires treeview:activate on click and toggles a clicked parent', () => { const { root, instance } = build(); const activated: number[] = []; root.addEventListener('treeview:activate', (e) => { activated.push((e as CustomEvent<{ index: number }>).detail.index); }); const i = indexOfLabel(root, 'docs'); const label = items(root)[i]!.querySelector(':scope > .treeview__label')!; label.click(); expect(activated).toEqual([i]); expect(instance.isExpanded(i)).toBe(true); }); }); describe('TreeView — destroy', () => { it('removes listeners so later clicks do nothing', () => { const { root, instance } = build(); instance.destroy(); const i = indexOfLabel(root, 'docs'); items(root)[i]!.querySelector(':scope > .treeview__label')!.click(); expect(instance.isExpanded(i)).toBe(false); }); });