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 = [];
}
}