/** * Tests for components/custom_dropdown.js * * Tests the custom dropdown component's core behavior: * setup, filtering, selection, keyboard navigation, and cleanup. */ import '@js/security/xss-protection.js'; import '@js/components/custom_dropdown.js'; const setupCustomDropdown = window.setupCustomDropdown; const updateDropdownOptions = window.updateDropdownOptions; describe('setupCustomDropdown', () => { let input, hiddenInput, dropdownList, onSelect, options; beforeEach(() => { input = document.createElement('input'); input.type = 'text'; input.id = 'test-dropdown-input'; hiddenInput = document.createElement('input'); hiddenInput.type = 'hidden'; hiddenInput.id = 'test-dropdown-input_hidden'; dropdownList = document.createElement('div'); dropdownList.id = 'test-dropdown-list'; const wrapper = document.createElement('div'); wrapper.appendChild(input); wrapper.appendChild(hiddenInput); wrapper.appendChild(dropdownList); document.body.appendChild(wrapper); onSelect = vi.fn(); options = [ { value: 'gpt4', label: 'GPT-4' }, { value: 'claude', label: 'Claude' }, { value: 'llama', label: 'Llama 3' }, { value: 'mistral', label: 'Mistral' }, ]; }); afterEach(() => { const wrapper = input.closest('div'); if (wrapper && wrapper.parentNode) { wrapper.remove(); } // Clean up any detached dropdown lists const detached = document.getElementById('test-dropdown-list'); if (detached) detached.remove(); }); it('returns control functions', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); expect(dd.updateDropdown).toBeTypeOf('function'); expect(dd.showDropdown).toBeTypeOf('function'); expect(dd.hideDropdown).toBeTypeOf('function'); expect(dd.destroy).toBeTypeOf('function'); expect(dd.setValue).toBeTypeOf('function'); dd.destroy(); }); it('initially hides the dropdown', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); expect(dropdownList.style.display).toBe('none'); dd.destroy(); }); it('shows dropdown on input click', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); expect(dropdownList.style.display).toBe('block'); dd.destroy(); }); it('populates dropdown with all options on click', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); expect(items.length).toBe(4); dd.destroy(); }); it('filters options when typing', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.value = 'Cl'; input.dispatchEvent(new Event('input')); const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); expect(items.length).toBe(1); expect(items[0].getAttribute('data-value')).toBe('claude'); dd.destroy(); }); it('shows "no results" when nothing matches', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.value = 'zzzzz'; input.dispatchEvent(new Event('input')); expect(dropdownList.querySelector('.ldr-custom-dropdown-no-results')).not.toBeNull(); dd.destroy(); }); it('calls onSelect when an option is clicked', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); items[1].click(); // Click "Claude" expect(onSelect).toHaveBeenCalledWith('claude', options[1]); dd.destroy(); }); it('updates input value on selection', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); items[0].click(); // Click "GPT-4" expect(input.value).toBe('GPT-4'); dd.destroy(); }); it('updates hidden input on selection', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); items[0].click(); expect(hiddenInput.value).toBe('gpt4'); dd.destroy(); }); it('hides dropdown after selection', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); items[0].click(); expect(dropdownList.style.display).toBe('none'); dd.destroy(); }); it('hides dropdown on Escape key', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); expect(dropdownList.style.display).toBe('block'); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(dropdownList.style.display).toBe('none'); dd.destroy(); }); it('sets ARIA attributes for accessibility', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); // Initially closed expect(input.getAttribute('aria-expanded')).toBe('false'); // Open input.click(); expect(input.getAttribute('aria-expanded')).toBe('true'); // Items have role="option" const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); items.forEach(item => { expect(item.getAttribute('role')).toBe('option'); }); dd.destroy(); }); describe('setValue', () => { it('sets value by matching option value', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); dd.setValue('mistral'); expect(input.value).toBe('Mistral'); expect(hiddenInput.value).toBe('mistral'); dd.destroy(); }); it('calls onSelect callback', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); dd.setValue('claude'); expect(onSelect).toHaveBeenCalledWith('claude', options[1]); dd.destroy(); }); it('clears input for unknown value when custom values disallowed', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect, false); dd.setValue('unknown-model'); expect(input.value).toBe(''); dd.destroy(); }); it('sets raw value when custom values are allowed', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect, true); dd.setValue('custom-model'); expect(input.value).toBe('custom-model'); dd.destroy(); }); }); describe('destroy', () => { it('cleans up event listeners and registry', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); dd.destroy(); // After destroy, input events should not trigger dropdown input.click(); // Dropdown should stay hidden (no listener to open it) expect(dropdownList.style.display).toBe('none'); }); }); describe('keyboard navigation', () => { it('ArrowDown opens dropdown and selects first item', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); expect(dropdownList.style.display).toBe('block'); const active = dropdownList.querySelector('.active'); expect(active).not.toBeNull(); dd.destroy(); }); it('Enter selects highlighted item', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); // Open and select first input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); // Select it input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(onSelect).toHaveBeenCalled(); dd.destroy(); }); }); describe('group headers', () => { const grouped = [ { value: 'fav1', label: 'Pinned One', group_label: 'Favorites' }, { value: 'arxiv', label: 'ArXiv', group_label: 'Academic' }, { value: 'pubmed', label: 'PubMed', group_label: 'Academic' }, { value: 'tavily', label: 'Tavily', group_label: 'API key' }, ]; it('renders one non-selectable header per band, in order', () => { const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect); input.click(); const headers = dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header'); expect(Array.from(headers).map(h => h.textContent)).toEqual([ 'Favorites', 'Academic', 'API key', ]); headers.forEach(h => expect(h.classList.contains('ldr-custom-dropdown-item')).toBe(false) ); dd.destroy(); }); it('keeps the selectable option count equal to items (headers excluded)', () => { const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect); input.click(); const items = dropdownList.querySelectorAll('.ldr-custom-dropdown-item'); expect(items.length).toBe(4); dd.destroy(); }); it('shows a band header once even when it has multiple items', () => { const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect); input.click(); const academic = Array.from( dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header') ).filter(h => h.textContent === 'Academic'); expect(academic.length).toBe(1); dd.destroy(); }); it('hides a band header when filtering removes all its items', () => { const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect); input.value = 'arxiv'; input.dispatchEvent(new Event('input')); const headerTexts = Array.from( dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header') ).map(h => h.textContent); expect(headerTexts).toEqual(['Academic']); dd.destroy(); }); it('renders no headers when options have no group_label', () => { const dd = setupCustomDropdown(input, dropdownList, () => options, onSelect); input.click(); const headers = dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header'); expect(headers.length).toBe(0); dd.destroy(); }); it('marks headers as presentational so assistive tech skips them', () => { const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect); input.click(); const header = dropdownList.querySelector('.ldr-custom-dropdown-group-header'); expect(header.getAttribute('role')).toBe('presentation'); expect(header.getAttribute('aria-hidden')).toBe('true'); dd.destroy(); }); it('keyboard navigation lands on an item, never a header', () => { const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect); // First ArrowDown must select the first ITEM (fav1), not the // 'Favorites' header that precedes it. input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); const active = dropdownList.querySelector('.active'); expect(active.classList.contains('ldr-custom-dropdown-item')).toBe(true); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(onSelect).toHaveBeenCalledWith('fav1', expect.objectContaining({ value: 'fav1' })); dd.destroy(); }); it('renders both favorite stars and band headers when both apply', () => { const onFavoriteToggle = vi.fn(); const dd = setupCustomDropdown( input, dropdownList, () => grouped, onSelect, false, 'No results found.', onFavoriteToggle ); input.click(); expect(dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header').length).toBe(3); expect(dropdownList.querySelectorAll('.ldr-dropdown-favorite-star').length).toBe(4); dd.destroy(); }); it('updateDropdownOptions re-renders band headers for the open list', () => { const dd = setupCustomDropdown(input, dropdownList, () => grouped, onSelect); input.click(); const newOptions = [ { value: 'wiki', label: 'Wikipedia', group_label: 'No API key' }, { value: 'serper', label: 'Serper', group_label: 'API key' }, ]; updateDropdownOptions(input, newOptions); const headerTexts = Array.from( dropdownList.querySelectorAll('.ldr-custom-dropdown-group-header') ).map(h => h.textContent); expect(headerTexts).toEqual(['No API key', 'API key']); expect(dropdownList.querySelectorAll('.ldr-custom-dropdown-item').length).toBe(2); dd.destroy(); }); }); }); describe('updateDropdownOptions', () => { it('does nothing for null input', () => { expect(() => updateDropdownOptions(null, [])).not.toThrow(); }); });