import { TREE_CHANGE_EVENT, TREE_TOGGLE_EVENT, type TreeChangeDetail, type TreeOptions } from './tree.types'; interface TreeNode { el: HTMLElement; value: string; checkbox: HTMLInputElement | null; toggle: HTMLElement | null; group: HTMLElement | null; parent: TreeNode | null; children: TreeNode[]; } const SELECTORS = { item: '[data-c42-tree-item]', row: '[data-c42-tree-row]', toggle: '[data-c42-tree-toggle]', checkbox: '[data-c42-tree-checkbox]', group: '[data-c42-tree-group]', } as const; /** * Headless tree / nested list. Handles expand-collapse, tri-state checkbox * selection with parent/child propagation, ARIA wiring and keyboard navigation. * * Markup: * ```html * * ``` */ export class Tree { private readonly root: HTMLElement; private readonly treeSelection: boolean; private readonly expandAll: boolean; private nodes: TreeNode[] = []; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: TreeOptions = {}) { this.root = root; this.treeSelection = options.treeSelection ?? true; this.expandAll = options.expandAll ?? false; this.init(); } private init(): void { this.root.setAttribute('role', 'tree'); this.nodes = this.build(this.root, null); this.flat.forEach((node) => this.setupNode(node)); const onKeydown = (event: Event): void => this.onKeydown(event as KeyboardEvent); this.root.addEventListener('keydown', onKeydown); this.cleanups.push(() => this.root.removeEventListener('keydown', onKeydown)); this.recomputeAll(); } private build(container: HTMLElement, parent: TreeNode | null): TreeNode[] { const itemEls = Array.from(container.querySelectorAll(':scope > ' + SELECTORS.item)); return itemEls.map((el) => { const row = el.querySelector(':scope > ' + SELECTORS.row); const scope = row ?? el; const node: TreeNode = { el, value: el.dataset.value ?? '', checkbox: scope.querySelector(SELECTORS.checkbox), toggle: scope.querySelector(SELECTORS.toggle), group: el.querySelector(':scope > ' + SELECTORS.group), parent, children: [], }; if (node.group) { node.children = this.build(node.group, node); } return node; }); } private get flat(): TreeNode[] { const out: TreeNode[] = []; const walk = (nodes: TreeNode[]): void => { nodes.forEach((node) => { out.push(node); walk(node.children); }); }; walk(this.nodes); return out; } private setupNode(node: TreeNode): void { node.el.setAttribute('role', 'treeitem'); if (node.group) { node.group.setAttribute('role', 'group'); const expanded = this.expandAll || node.el.hasAttribute('data-expanded'); this.setExpanded(node, expanded, false); if (node.toggle) { const onToggle = (): void => this.toggleNode(node); node.toggle.addEventListener('click', onToggle); this.cleanups.push(() => node.toggle?.removeEventListener('click', onToggle)); } } if (node.checkbox) { const onChange = (): void => this.onCheckboxChange(node); node.checkbox.addEventListener('change', onChange); this.cleanups.push(() => node.checkbox?.removeEventListener('change', onChange)); } } private setExpanded(node: TreeNode, expanded: boolean, emit = true): void { if (!node.group) { return; } node.el.setAttribute('aria-expanded', String(expanded)); node.el.dataset.state = expanded ? 'expanded' : 'collapsed'; node.group.toggleAttribute('hidden', !expanded); if (emit) { this.root.dispatchEvent( new CustomEvent(TREE_TOGGLE_EVENT, { detail: { value: node.value, expanded }, bubbles: true }), ); } } toggleNode(node: TreeNode): void { const expanded = node.el.getAttribute('aria-expanded') === 'true'; this.setExpanded(node, !expanded); } private onCheckboxChange(node: TreeNode): void { if (!node.checkbox) { return; } const checked = node.checkbox.checked; node.checkbox.indeterminate = false; if (this.treeSelection) { this.propagateDown(node, checked); this.recomputeAncestors(node); } this.emit(); } private propagateDown(node: TreeNode, checked: boolean): void { node.children.forEach((child) => { if (child.checkbox) { child.checkbox.checked = checked; child.checkbox.indeterminate = false; } this.propagateDown(child, checked); }); } private recomputeAncestors(node: TreeNode): void { let current = node.parent; while (current) { this.recomputeNode(current); current = current.parent; } } private recomputeNode(node: TreeNode): void { if (!node.checkbox || node.children.length === 0) { return; } const boxes = node.children.map((child) => child.checkbox).filter((cb): cb is HTMLInputElement => cb !== null); if (boxes.length === 0) { return; } const all = boxes.every((cb) => cb.checked && !cb.indeterminate); const some = boxes.some((cb) => cb.checked || cb.indeterminate); node.checkbox.checked = all; node.checkbox.indeterminate = some && !all; } private recomputeAll(): void { if (!this.treeSelection) { return; } const postOrder = (nodes: TreeNode[]): void => { nodes.forEach((node) => { postOrder(node.children); this.recomputeNode(node); }); }; postOrder(this.nodes); } private visibleNodes(): TreeNode[] { return this.flat.filter((node) => { let current = node.parent; while (current) { if (current.el.getAttribute('aria-expanded') === 'false') { return false; } current = current.parent; } return true; }); } private nodeFromActive(): TreeNode | null { const item = (document.activeElement as HTMLElement | null)?.closest(SELECTORS.item); return this.flat.find((node) => node.el === item) ?? null; } private focusNode(node: TreeNode | null | undefined): void { node?.checkbox?.focus(); } private onKeydown(event: KeyboardEvent): void { const node = this.nodeFromActive(); if (!node) { return; } const visible = this.visibleNodes(); const index = visible.indexOf(node); switch (event.key) { case 'ArrowDown': event.preventDefault(); this.focusNode(visible[index + 1]); break; case 'ArrowUp': event.preventDefault(); this.focusNode(visible[index - 1]); break; case 'ArrowRight': event.preventDefault(); if (node.group && node.el.getAttribute('aria-expanded') === 'false') { this.setExpanded(node, true); } else { this.focusNode(node.children[0]); } break; case 'ArrowLeft': event.preventDefault(); if (node.group && node.el.getAttribute('aria-expanded') === 'true') { this.setExpanded(node, false); } else { this.focusNode(node.parent); } break; default: break; } } private emit(): void { const detail: TreeChangeDetail = { values: this.value }; this.root.dispatchEvent(new CustomEvent(TREE_CHANGE_EVENT, { detail, bubbles: true })); } get value(): string[] { return this.flat .filter((node) => node.checkbox?.checked && !node.checkbox.indeterminate) .map((node) => node.value); } on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }