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