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