import { autoUpdate, computePosition, flip, offset as offsetMiddleware, shift } from '@floating-ui/dom';
import { uid } from '../shared/dom';
import {
NESTED_SELECT_CHANGE_EVENT,
NESTED_SELECT_CLOSE_EVENT,
NESTED_SELECT_OPEN_EVENT,
type GroupState,
type NestedSelectChangeDetail,
type NestedSelectOptions,
type NestedSelectState,
type Placement,
} from './nested-select.types';
const SELECTORS = {
trigger: '[data-c42-nested-select-trigger]',
panel: '[data-c42-nested-select-panel]',
value: '[data-c42-nested-select-value]',
search: '[data-c42-nested-select-search]',
list: '[data-c42-nested-select-list]',
group: '[data-c42-nested-select-group]',
parent: '[data-c42-nested-select-parent]',
option: '[data-c42-nested-select-option]',
} as const;
interface Group {
groupEl: HTMLElement;
parentEl: HTMLElement | null;
options: HTMLElement[];
}
/**
* Headless nested (hierarchical) multiselect. Parents toggle all their children
* and reflect a tri-state (`checked` / `partial` / `unchecked`); leaf options
* toggle individually. Supports search filtering, full keyboard navigation and
* ARIA wiring. State is reflected via `data-selected` / `data-state` and
* `aria-selected` / `aria-checked`; positioning via floating-ui.
*
* Markup:
* ```html
*
* ```
*/
export class NestedSelect {
private readonly root: HTMLElement;
private readonly trigger: HTMLElement;
private readonly panel: HTMLElement;
private readonly valueEl: HTMLElement | null;
private readonly searchInput: HTMLInputElement | null;
private readonly list: HTMLElement;
private readonly filterEnabled: boolean;
private readonly placeholder: string;
private readonly placement: Placement;
private readonly offset: number;
private readonly groups: Group[];
private readonly options: HTMLElement[];
private readonly selected = new Set();
private open = false;
private activeIndex = -1;
private stopAutoUpdate: (() => void) | null = null;
private cleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: NestedSelectOptions = {}) {
const trigger = root.querySelector(SELECTORS.trigger);
const panel = root.querySelector(SELECTORS.panel);
const list = root.querySelector(SELECTORS.list);
if (!trigger || !panel || !list) {
throw new Error('[42/nested-select] Needs a trigger, a panel and a list element.');
}
this.root = root;
this.trigger = trigger;
this.panel = panel;
this.list = list;
this.valueEl = root.querySelector(SELECTORS.value);
this.searchInput = panel.querySelector(SELECTORS.search);
this.filterEnabled = options.filter ?? true;
this.placeholder = options.placeholder ?? '';
this.placement = options.placement ?? 'bottom-start';
this.offset = options.offset ?? 4;
this.groups = Array.from(list.querySelectorAll(SELECTORS.group)).map((groupEl) => ({
groupEl,
parentEl: groupEl.querySelector(SELECTORS.parent),
options: Array.from(groupEl.querySelectorAll(SELECTORS.option)),
}));
this.options = this.groups.flatMap((group) => group.options);
this.init(options.defaultValue ?? []);
}
private valueOf(option: HTMLElement): string {
return option.dataset.value ?? option.textContent?.trim() ?? '';
}
private init(defaultValue: string[]): void {
const panelId = this.panel.id || uid('nested-select-panel');
this.panel.id = panelId;
this.trigger.setAttribute('aria-haspopup', 'listbox');
this.trigger.setAttribute('aria-controls', panelId);
this.trigger.setAttribute('aria-expanded', 'false');
this.panel.setAttribute('hidden', '');
this.panel.dataset.state = 'closed';
this.trigger.dataset.state = 'closed';
this.list.setAttribute('role', 'listbox');
this.list.setAttribute('aria-multiselectable', 'true');
defaultValue.forEach((value) => this.selected.add(value));
this.options.forEach((option) => {
const optionId = option.id || uid('nested-select-option');
option.id = optionId;
option.setAttribute('role', 'option');
});
this.groups.forEach((group) => {
group.parentEl?.setAttribute('role', 'button');
});
const onTriggerClick = (): void => this.toggle();
const onListClick = (event: Event): void => this.onListClick(event);
const onSearch = (): void => this.onSearch();
const onKeydown = (event: Event): void => this.onKeydown(event as KeyboardEvent);
const onOutside = (event: Event): void => this.onOutside(event);
this.trigger.addEventListener('click', onTriggerClick);
this.list.addEventListener('click', onListClick);
this.searchInput?.addEventListener('input', onSearch);
this.panel.addEventListener('keydown', onKeydown);
document.addEventListener('pointerdown', onOutside, true);
this.cleanups.push(
() => this.trigger.removeEventListener('click', onTriggerClick),
() => this.list.removeEventListener('click', onListClick),
() => this.searchInput?.removeEventListener('input', onSearch),
() => this.panel.removeEventListener('keydown', onKeydown),
() => document.removeEventListener('pointerdown', onOutside, true),
);
this.render();
}
private get visibleOptions(): HTMLElement[] {
return this.options.filter((option) => !option.hasAttribute('hidden'));
}
/* ---------- interaction ---------- */
private onListClick(event: Event): void {
const target = event.target as HTMLElement;
const parent = target.closest(SELECTORS.parent);
if (parent) {
const group = this.groups.find((g) => g.parentEl === parent);
if (group) {
this.toggleGroup(group);
return;
}
}
const option = target.closest(SELECTORS.option);
if (option && this.options.includes(option)) {
this.toggleOption(option);
}
}
private onSearch(): void {
if (this.filterEnabled) {
this.filter(this.searchInput?.value ?? '');
}
if (!this.open) {
this.openPanel();
}
}
private onKeydown(event: KeyboardEvent): void {
const visible = this.visibleOptions;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.activeIndex = Math.min(this.activeIndex + 1, visible.length - 1);
this.updateActive();
break;
case 'ArrowUp':
event.preventDefault();
this.activeIndex = Math.max(this.activeIndex - 1, 0);
this.updateActive();
break;
case 'Enter': {
const active = visible[this.activeIndex];
if (active) {
event.preventDefault();
this.toggleOption(active);
}
break;
}
case 'Escape':
event.preventDefault();
this.close();
this.trigger.focus();
break;
default:
break;
}
}
private updateActive(): void {
this.options.forEach((option) => delete option.dataset.active);
const active = this.visibleOptions[this.activeIndex];
if (active) {
active.dataset.active = '';
this.searchInput?.setAttribute('aria-activedescendant', active.id);
} else {
this.searchInput?.removeAttribute('aria-activedescendant');
}
}
private filter(query: string): void {
const needle = query.trim().toLowerCase();
this.groups.forEach((group) => {
let anyVisible = false;
group.options.forEach((option) => {
const haystack = (option.dataset.search ?? option.textContent ?? '').toLowerCase();
const match = needle === '' || haystack.includes(needle);
option.toggleAttribute('hidden', !match);
if (match) {
anyVisible = true;
}
});
group.groupEl.toggleAttribute('hidden', !anyVisible);
});
this.activeIndex = -1;
this.updateActive();
}
/* ---------- selection ---------- */
private toggleOption(option: HTMLElement): void {
const value = this.valueOf(option);
if (this.selected.has(value)) {
this.selected.delete(value);
} else {
this.selected.add(value);
}
this.render();
this.emitChange();
}
private toggleGroup(group: Group): void {
const state = this.groupState(group);
const selectAll = state !== 'checked';
group.options.forEach((option) => {
const value = this.valueOf(option);
if (selectAll) {
this.selected.add(value);
} else {
this.selected.delete(value);
}
});
this.render();
this.emitChange();
}
private groupState(group: Group): GroupState {
const total = group.options.length;
const count = group.options.filter((option) => this.selected.has(this.valueOf(option))).length;
if (count === 0) {
return 'unchecked';
}
return count === total ? 'checked' : 'partial';
}
/* ---------- rendering ---------- */
private render(): void {
this.options.forEach((option) => {
const isSelected = this.selected.has(this.valueOf(option));
option.setAttribute('aria-selected', String(isSelected));
option.dataset.selected = String(isSelected);
});
this.groups.forEach((group) => {
const state = this.groupState(group);
if (group.parentEl) {
group.parentEl.dataset.state = state;
group.parentEl.setAttribute(
'aria-checked',
state === 'checked' ? 'true' : state === 'partial' ? 'mixed' : 'false',
);
}
});
this.renderValue();
}
private renderValue(): void {
if (!this.valueEl) {
return;
}
const labels = this.options
.filter((option) => this.selected.has(this.valueOf(option)))
.map((option) => option.textContent?.trim() ?? '');
this.valueEl.textContent = labels.length > 0 ? labels.join(', ') : this.placeholder;
this.valueEl.toggleAttribute('data-placeholder', labels.length === 0);
this.root.dataset.count = String(this.selected.size);
}
private emitChange(): void {
const detail: NestedSelectChangeDetail = { values: [...this.selected] };
this.root.dispatchEvent(new CustomEvent(NESTED_SELECT_CHANGE_EVENT, { detail, bubbles: true }));
}
private onOutside(event: Event): void {
if (this.open && !this.root.contains(event.target as Node)) {
this.close();
}
}
/* ---------- public API ---------- */
toggle(): void {
if (this.open) {
this.close();
} else {
this.openPanel();
}
}
openPanel(): void {
if (this.open) {
return;
}
this.open = true;
this.panel.removeAttribute('hidden');
this.panel.dataset.state = 'open';
this.trigger.dataset.state = 'open';
this.trigger.setAttribute('aria-expanded', 'true');
this.stopAutoUpdate = autoUpdate(this.trigger, this.panel, () => void this.position());
this.searchInput?.focus();
this.root.dispatchEvent(new CustomEvent(NESTED_SELECT_OPEN_EVENT, { bubbles: true }));
}
close(): void {
if (!this.open) {
return;
}
this.open = false;
this.panel.setAttribute('hidden', '');
this.panel.dataset.state = 'closed';
this.trigger.dataset.state = 'closed';
this.trigger.setAttribute('aria-expanded', 'false');
this.activeIndex = -1;
this.updateActive();
this.stopAutoUpdate?.();
this.stopAutoUpdate = null;
this.root.dispatchEvent(new CustomEvent(NESTED_SELECT_CLOSE_EVENT, { bubbles: true }));
}
/** Replace the current selection. */
setValue(values: string[]): void {
this.selected.clear();
values.forEach((value) => this.selected.add(value));
this.render();
this.emitChange();
}
clear(): void {
if (this.selected.size === 0) {
return;
}
this.selected.clear();
this.render();
this.emitChange();
}
private async position(): Promise {
const { x, y } = await computePosition(this.trigger, this.panel, {
placement: this.placement,
middleware: [offsetMiddleware(this.offset), flip(), shift({ padding: 5 })],
});
Object.assign(this.panel.style, { left: `${x}px`, top: `${y}px`, position: 'absolute' });
}
get values(): string[] {
return [...this.selected];
}
get isOpen(): boolean {
return this.open;
}
getState(): NestedSelectState {
return { values: this.values, open: this.open };
}
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.close();
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
}
}