import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NestedSelect } from './nested-select';
import { NESTED_SELECT_CHANGE_EVENT, NESTED_SELECT_OPEN_EVENT } from './nested-select.types';
const MARKUP = `
`;
function setup(): HTMLElement {
document.body.innerHTML = MARKUP;
return document.body.firstElementChild as HTMLElement;
}
function option(root: HTMLElement, value: string): HTMLElement {
return root.querySelector(`[data-c42-nested-select-option][data-value="${value}"]`)!;
}
function parent(root: HTMLElement, index: number): HTMLElement {
return Array.from(root.querySelectorAll('[data-c42-nested-select-parent]'))[index];
}
describe('NestedSelect', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('throws when required parts are missing', () => {
document.body.innerHTML = '';
expect(() => new NestedSelect(document.body.firstElementChild as HTMLElement)).toThrow(
/\[42\/nested-select\]/,
);
});
it('wires ARIA roles and hides the panel', () => {
const root = setup();
new NestedSelect(root);
const trigger = root.querySelector('[data-c42-nested-select-trigger]')!;
const list = root.querySelector('[data-c42-nested-select-list]')!;
expect(trigger.getAttribute('aria-haspopup')).toBe('listbox');
expect(list.getAttribute('aria-multiselectable')).toBe('true');
expect(option(root, 'apple').getAttribute('role')).toBe('option');
expect(root.querySelector('[data-c42-nested-select-panel]')!.hasAttribute('hidden')).toBe(true);
});
it('applies defaultValue', () => {
const root = setup();
const ns = new NestedSelect(root, { defaultValue: ['apple'] });
expect(ns.values).toEqual(['apple']);
expect(option(root, 'apple').dataset.selected).toBe('true');
});
it('toggles an option and emits change', () => {
const root = setup();
const ns = new NestedSelect(root);
const spy = vi.fn();
ns.on(NESTED_SELECT_CHANGE_EVENT, spy);
option(root, 'banana').click();
expect(ns.values).toEqual(['banana']);
expect((spy.mock.calls[0][0] as CustomEvent).detail.values).toEqual(['banana']);
option(root, 'banana').click();
expect(ns.values).toEqual([]);
});
it('parent selects all children and reflects tri-state', () => {
const root = setup();
const ns = new NestedSelect(root);
const fruits = parent(root, 0);
expect(fruits.dataset.state).toBe('unchecked');
option(root, 'apple').click();
expect(fruits.dataset.state).toBe('partial');
expect(fruits.getAttribute('aria-checked')).toBe('mixed');
fruits.click(); // partial -> select all
expect(ns.values.sort()).toEqual(['apple', 'banana']);
expect(fruits.dataset.state).toBe('checked');
fruits.click(); // checked -> deselect all
expect(ns.values).toEqual([]);
expect(fruits.dataset.state).toBe('unchecked');
});
it('filters options by search and hides empty groups', () => {
const root = setup();
const ns = new NestedSelect(root);
const input = root.querySelector('[data-c42-nested-select-search]')!;
input.value = 'carrot';
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(option(root, 'apple').hasAttribute('hidden')).toBe(true);
expect(option(root, 'carrot').hasAttribute('hidden')).toBe(false);
// Fruits group hidden (no visible options)
const groups = root.querySelectorAll('[data-c42-nested-select-group]');
expect(groups[0].hasAttribute('hidden')).toBe(true);
expect(groups[1].hasAttribute('hidden')).toBe(false);
expect(ns.isOpen).toBe(true);
});
it('navigates options with arrow keys and toggles on Enter', () => {
const root = setup();
const ns = new NestedSelect(root);
ns.openPanel();
const panel = root.querySelector('[data-c42-nested-select-panel]')!;
panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
panel.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(ns.values).toEqual(['apple']);
});
it('renders selected labels in the value element', () => {
const root = setup();
new NestedSelect(root, { defaultValue: ['apple', 'carrot'], placeholder: 'Pick' });
const value = root.querySelector('[data-c42-nested-select-value]')!;
expect(value.textContent).toBe('Apple, Carrot');
expect(root.dataset.count).toBe('2');
});
it('shows placeholder when empty', () => {
const root = setup();
new NestedSelect(root, { placeholder: 'Pick some' });
const value = root.querySelector('[data-c42-nested-select-value]')!;
expect(value.textContent).toBe('Pick some');
expect(value.hasAttribute('data-placeholder')).toBe(true);
});
it('setValue and clear update selection', () => {
const root = setup();
const ns = new NestedSelect(root);
ns.setValue(['apple', 'carrot']);
expect(ns.values.sort()).toEqual(['apple', 'carrot']);
ns.clear();
expect(ns.values).toEqual([]);
});
it('emits open and closes on outside pointerdown', () => {
const root = setup();
const ns = new NestedSelect(root);
const spy = vi.fn();
ns.on(NESTED_SELECT_OPEN_EVENT, spy);
ns.openPanel();
expect(spy).toHaveBeenCalledOnce();
document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }));
expect(ns.isOpen).toBe(false);
});
it('stops reacting after destroy', () => {
const root = setup();
const ns = new NestedSelect(root);
ns.destroy();
option(root, 'apple').click();
expect(ns.values).toEqual([]);
});
});