import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Select } from './select';
import { SELECT_CHANGE_EVENT, type SelectChangeEvent } from './select.types';
const MARKUP = `
`;
function setup(): HTMLElement {
document.body.innerHTML = MARKUP;
return document.body.firstElementChild as HTMLElement;
}
function trigger(root: HTMLElement): HTMLElement {
return root.querySelector('[data-c42-select-trigger]')!;
}
function listbox(root: HTMLElement): HTMLElement {
return root.querySelector('[data-c42-select-listbox]')!;
}
function options(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll('[data-c42-select-option]'));
}
describe('Select', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('wires ARIA attributes and starts closed', () => {
const root = setup();
new Select(root);
const t = trigger(root);
const lb = listbox(root);
expect(t.getAttribute('aria-haspopup')).toBe('listbox');
expect(t.getAttribute('aria-expanded')).toBe('false');
expect(t.getAttribute('aria-controls')).toBe(lb.id);
expect(lb.getAttribute('role')).toBe('listbox');
expect(lb.hasAttribute('hidden')).toBe(true);
expect(options(root)[0].getAttribute('role')).toBe('option');
expect(options(root)[0].getAttribute('aria-selected')).toBe('false');
});
it('throws when trigger or listbox is missing', () => {
document.body.innerHTML = '';
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Select(root)).toThrow(/data-c42-select-trigger/);
});
it('opens and closes on trigger click', () => {
const root = setup();
new Select(root);
const t = trigger(root);
t.click();
expect(t.getAttribute('aria-expanded')).toBe('true');
expect(listbox(root).hasAttribute('hidden')).toBe(false);
expect(root.dataset.state).toBe('open');
t.click();
expect(t.getAttribute('aria-expanded')).toBe('false');
expect(listbox(root).hasAttribute('hidden')).toBe(true);
});
it('selects an option on click and updates the value text', () => {
const root = setup();
new Select(root);
trigger(root).click();
options(root)[1].click();
expect(root.querySelector('[data-c42-select-value]')!.textContent).toBe('Banana');
expect(options(root)[1].getAttribute('aria-selected')).toBe('true');
expect(listbox(root).hasAttribute('hidden')).toBe(true);
});
it('honors defaultValue', () => {
const root = setup();
new Select(root, { defaultValue: 'a' });
expect(root.querySelector('[data-c42-select-value]')!.textContent).toBe('Apple');
expect(options(root)[0].getAttribute('aria-selected')).toBe('true');
});
it('navigates with arrow keys and selects with Enter', () => {
const root = setup();
new Select(root);
const lb = listbox(root);
trigger(root).click();
lb.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
lb.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(root.querySelector('[data-c42-select-value]')!.textContent).toBe('Banana');
});
it('closes on Escape', () => {
const root = setup();
new Select(root);
trigger(root).click();
listbox(root).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(root.dataset.state).toBe('closed');
});
it('skips disabled options when selecting via keyboard', () => {
const root = setup();
const select = new Select(root);
trigger(root).click();
const lb = listbox(root);
// Move to the last enabled option (Banana) and beyond — disabled Cherry must be skipped.
lb.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
lb.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(select.value).toBe('b');
});
it('ignores clicks on disabled options', () => {
const root = setup();
const select = new Select(root);
trigger(root).click();
options(root)[2].click();
expect(select.value).toBeNull();
});
it('emits change events with value and label', () => {
const root = setup();
const select = new Select(root);
const spy = vi.fn();
select.on(SELECT_CHANGE_EVENT, spy);
select.setValue('a');
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].detail).toEqual({ value: 'a', label: 'Apple' });
});
it('removes listeners on destroy', () => {
const root = setup();
const select = new Select(root);
select.destroy();
trigger(root).click();
expect(root.dataset.state).toBe('closed');
});
});