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