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