// @vitest-environment jsdom import { act, createElement, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Select, type SelectOption } from './select.js'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const roots: ReturnType[] = []; function render(element: React.ReactElement) { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); roots.push(root); act(() => { root.render(element); }); return { container, root }; } afterEach(async () => { await act(async () => { roots.splice(0).forEach((root) => root.unmount()); }); document.body.innerHTML = ''; vi.clearAllMocks(); }); const sampleOptions: SelectOption[] = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date', disabled: 'Out of stock' }, { value: 'elderberry', label: 'Elderberry' }, ]; const longOptions: SelectOption[] = [ { value: 'us-east', label: 'US East (N. Virginia)', code: 'US-E', group: 'Americas' }, { value: 'us-west', label: 'US West (Oregon)', code: 'US-W', group: 'Americas' }, { value: 'ca-central', label: 'Canada (Central)', code: 'CA-C', group: 'Americas' }, { value: 'eu-west', label: 'EU (Ireland)', code: 'EU-W', group: 'Europe' }, { value: 'eu-north', label: 'EU (Stockholm)', code: 'EU-N', group: 'Europe' }, { value: 'eu-south', label: 'EU (Milan)', code: 'EU-S', group: 'Europe' }, { value: 'asia-se', label: 'Asia (Singapore)', code: 'AS-SE', group: 'Asia-Pacific' }, { value: 'asia-ne', label: 'Asia (Tokyo)', code: 'AS-NE', group: 'Asia-Pacific' }, { value: 'asia-s', label: 'Asia (Mumbai)', code: 'AS-S', group: 'Asia-Pacific' }, ]; describe('Select component', () => { it('renders trigger button with placeholder when empty and opens on click', async () => { const { container } = render( ); const trigger = container.querySelector('button.ut-select')!; await act(async () => { trigger.click(); }); // Search field owns the active option, so the trigger must not point at one. expect(trigger.hasAttribute('aria-activedescendant')).toBe(false); await act(async () => { container.querySelectorAll('.ut-option')[1]?.click(); }); expect(trigger.getAttribute('data-pointer-focus')).toBe('true'); await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Shift', bubbles: true })); }); expect(trigger.hasAttribute('data-pointer-focus')).toBe(false); }); it('handles choosing an option and triggers onChange', async () => { const onChange = vi.fn(); function Controlled() { const [val, setVal] = useState(''); return ( , ); expect(container.querySelector('select')).toBeNull(); const form = container.querySelector('form')!; expect(new FormData(form).get('fruit')).toBe('cherry'); const trigger = container.querySelector('button.ut-select')!; await act(async () => { trigger.click(); }); await act(async () => { container.querySelectorAll('.ut-option')[1]?.click(); }); expect(new FormData(form).get('fruit')).toBe('banana'); }); it('keeps focus on the trigger after a pointer choice and does not swallow keys', async () => { const { container } = render(); const trigger = container.querySelector('button.ut-select')!; await act(async () => { trigger.click(); }); const searchInput = container.querySelector('.ut-menu__search input')!; searchInput.focus(); let prevented = true; await act(async () => { const tab = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true, }); searchInput.dispatchEvent(tab); prevented = tab.defaultPrevented; }); expect(prevented).toBe(false); expect(container.querySelector('.ut-menu')).toBeNull(); expect(document.activeElement).toBe(trigger); }); it('closes on Escape and returns focus to trigger', async () => { const { container } = render( , ); const trigger = container.querySelector('button.ut-select')!; await act(async () => { trigger.click(); }); expect(container.querySelector('.ut-menu')).toBeTruthy(); let tabPrevented = false; await act(async () => { const tabEvent = new window.KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true, }); trigger.dispatchEvent(tabEvent); tabPrevented = tabEvent.defaultPrevented; }); expect(tabPrevented).toBe(false); expect(container.querySelector('.ut-menu')).toBeNull(); }); it('closes when clicking outside without changing value', async () => { const onChange = vi.fn(); const { container } = render(
, ); const trigger = container.querySelector('button.ut-select')!; // Enter opens menu await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); expect(container.querySelector('.ut-menu')).toBeTruthy(); // ArrowDown moves down (Apple -> Banana) await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); }); const options = container.querySelectorAll('.ut-option'); expect(options[1]?.getAttribute('data-active')).toBe('true'); // ArrowDown moves down (Banana -> Cherry) await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); }); expect(options[2]?.getAttribute('data-active')).toBe('true'); // ArrowDown skips disabled 'Date' -> Elderberry (index 4) await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); }); expect(options[4]?.getAttribute('data-active')).toBe('true'); // Home jumps to first enabled (Apple, index 0) await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Home', bubbles: true })); }); expect(options[0]?.getAttribute('data-active')).toBe('true'); // End jumps to last enabled (Elderberry, index 4) await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'End', bubbles: true })); }); expect(options[4]?.getAttribute('data-active')).toBe('true'); // Enter chooses Elderberry await act(async () => { trigger.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); }); expect(onChange).toHaveBeenCalledWith('elderberry'); expect(container.querySelector('.ut-menu')).toBeNull(); }); it('supports typeahead jumping', async () => { const { container } = render( , ); const trigger = container.querySelector('button.ut-select')!; expect(trigger.disabled).toBe(true); await act(async () => { trigger.click(); }); expect(container.querySelector('.ut-menu')).toBeNull(); // Now test disabled option inside enabled select const { container: enabledContainer } = render( , ); const trigger = container.querySelector('button.ut-select')!; await act(async () => { trigger.click(); }); const searchInput = container.querySelector('.ut-menu__search input')!; expect(searchInput).toBeTruthy(); // Filter by typing 'tokyo' await act(async () => { const nativeSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value', )?.set; (searchInput as any)._valueTracker?.setValue(''); nativeSetter?.call(searchInput, 'tokyo'); searchInput.dispatchEvent(new Event('input', { bubbles: true })); searchInput.dispatchEvent(new Event('change', { bubbles: true })); }); // Options should now only contain Tokyo (Asia-NE) const options = container.querySelectorAll('.ut-option'); expect(options.length).toBe(1); expect(options[0]?.textContent).toContain('Tokyo'); // Enter chooses first search match await act(async () => { searchInput.dispatchEvent( new window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), ); }); // Search query 'tokyo' matches 'asia-ne' expect(onChange).toHaveBeenCalledWith('asia-ne'); }); it('highlights the first enabled filtered option for keyboard selection', async () => { const onChange = vi.fn(); const options = longOptions.map((option) => option.value === 'us-east' ? { ...option, disabled: 'Plan limit' } : option, ); const { container } = render(, ); const trigger = container.querySelector('button.ut-select')!; await act(async () => { trigger.click(); }); const searchInput = container.querySelector('.ut-menu__search input')!; await act(async () => { const nativeSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value', )?.set; (searchInput as any)._valueTracker?.setValue(''); nativeSetter?.call(searchInput, 'nonexistent'); searchInput.dispatchEvent(new Event('input', { bubbles: true })); searchInput.dispatchEvent(new Event('change', { bubbles: true })); }); const empty = container.querySelector('.ut-menu__empty'); expect(empty).toBeTruthy(); expect(empty?.textContent).toContain('No options match "nonexistent".'); }); it('supports multi-select with checkboxes, count chips, and clear button', async () => { const onChange = vi.fn(); function Multi() { const [vals, setVals] = useState(['us-east']); return (