import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Select } from './select'; import { SELECT_CHANGE_EVENT, type SelectChangeEvent } from './select.types'; const MARKUP = `
Apple
Banana
Cherry
`; 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'); }); });