import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Combobox } from './combobox'; import { COMBOBOX_CHANGE_EVENT, type ComboboxChangeEvent } from './combobox.types'; const MARKUP = `
`; function setup(): HTMLElement { document.body.innerHTML = MARKUP; return document.body.firstElementChild as HTMLElement; } function input(root: HTMLElement): HTMLInputElement { return root.querySelector('[data-c42-combobox-input]')!; } function options(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('[data-c42-combobox-option]')); } describe('Combobox', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('throws without input or list', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new Combobox(root)).toThrow(/input and a list/); }); it('wires ARIA roles and hides the list', () => { const root = setup(); new Combobox(root); expect(input(root).getAttribute('role')).toBe('combobox'); expect(input(root).getAttribute('aria-expanded')).toBe('false'); const list = root.querySelector('[data-c42-combobox-list]')!; expect(list.getAttribute('role')).toBe('listbox'); expect(list.hasAttribute('hidden')).toBe(true); expect(options(root)[0].getAttribute('role')).toBe('option'); }); it('opens on focus', () => { const root = setup(); const combobox = new Combobox(root); input(root).dispatchEvent(new FocusEvent('focus')); expect(combobox.isOpen).toBe(true); expect(input(root).getAttribute('aria-expanded')).toBe('true'); }); it('selects a single option and reflects label', () => { const root = setup(); const combobox = new Combobox(root); combobox.openList(); options(root)[1].click(); expect(combobox.values).toEqual(['banana']); expect(input(root).value).toBe('Banana'); expect(combobox.isOpen).toBe(false); }); it('toggles multiple options', () => { const root = setup(); const combobox = new Combobox(root, { multiple: true }); combobox.openList(); options(root)[0].click(); options(root)[2].click(); expect(combobox.values).toEqual(['apple', 'cherry']); expect(combobox.isOpen).toBe(true); options(root)[0].click(); expect(combobox.values).toEqual(['cherry']); }); it('filters options as the user types', () => { const root = setup(); new Combobox(root); input(root).value = 'ban'; input(root).dispatchEvent(new Event('input')); const visible = options(root).filter((o) => !o.hasAttribute('hidden')); expect(visible).toHaveLength(1); expect(visible[0].dataset.value).toBe('banana'); }); it('navigates with arrow keys and selects with Enter', () => { const root = setup(); const combobox = new Combobox(root); const field = input(root); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); expect(combobox.values).toEqual(['banana']); }); it('honors defaultValue', () => { const root = setup(); const combobox = new Combobox(root, { multiple: true, defaultValue: ['cherry'] }); expect(combobox.values).toEqual(['cherry']); expect(options(root)[2].getAttribute('aria-selected')).toBe('true'); }); it('closes on Escape and outside pointerdown', () => { const root = setup(); const combobox = new Combobox(root); combobox.openList(); input(root).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); expect(combobox.isOpen).toBe(false); combobox.openList(); document.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); expect(combobox.isOpen).toBe(false); }); it('emits change events', () => { const root = setup(); const combobox = new Combobox(root, { multiple: true }); const spy = vi.fn(); combobox.on(COMBOBOX_CHANGE_EVENT, spy); combobox.openList(); options(root)[0].click(); expect(spy).toHaveBeenCalledOnce(); expect(spy.mock.calls[0][0].detail.values).toEqual(['apple']); }); });