document.addEventListener('DOMContentLoaded', () => { // 1. Close all comboboxes when clicking outside document.addEventListener('click', (event) => { document.querySelectorAll('.autocomplete-combobox').forEach(combo => { const results = combo.querySelector('.combobox-results'); if (results && !combo.contains(event.target)) { results.classList.add('hidden'); } }); }); // Helper to get only currently visible combobox items function getVisibleItems(combo) { const results = combo.querySelector('.combobox-results'); if (!results || results.classList.contains('hidden')) { return []; } return Array.from(results.querySelectorAll('.combobox-item')).filter(item => { return !item.classList.contains('hidden') && item.offsetParent !== null; }); } // Helper to show dropdown results function showDropdown(input) { const combo = input.closest('.autocomplete-combobox'); if (!combo) return; if (combo.dataset.justSelected === 'true') return; const results = combo.querySelector('.combobox-results'); if (!results) return; if (!input.hasAttribute('hx-get')) { // Client-side: filter and display results.classList.remove('hidden'); const query = input.value.toLowerCase().trim(); const items = results.querySelectorAll('.combobox-item'); let visibleCount = 0; items.forEach(item => { const text = item.getAttribute('data-name').toLowerCase(); if (text.includes(query)) { item.classList.remove('hidden'); visibleCount++; } else { item.classList.add('hidden'); } }); if (visibleCount === 0 && query !== '') { results.classList.add('hidden'); } } else { // Server-side HTMX search: only show if results contain elements if (results.querySelector('.combobox-item') || results.querySelector('div')) { results.classList.remove('hidden'); } } } // 2. Open dropdown on focusin and click document.addEventListener('focusin', (event) => { const input = event.target.closest('.combobox-input'); if (input) { showDropdown(input); } }); document.addEventListener('click', (event) => { const input = event.target.closest('.combobox-input'); if (input) { showDropdown(input); } }); // 3. Clear values on input delete and perform client-side filtering document.addEventListener('input', (event) => { const input = event.target.closest('.combobox-input'); if (!input) return; const combo = input.closest('.autocomplete-combobox'); const valueInput = combo.querySelector('.combobox-value'); const results = combo.querySelector('.combobox-results'); if (input.value.trim() === '') { if (valueInput) valueInput.value = ''; if (results) results.classList.add('hidden'); return; } // Apply client-side search filtering immediately if (!input.hasAttribute('hx-get')) { showDropdown(input); } }); // 4. Keydown navigation delegation (Arrows, Escape, Enter, Tab/Shift-Tab) document.addEventListener('keydown', (event) => { const input = event.target.closest('.combobox-input'); const item = event.target.closest('.combobox-item'); if (input) { const combo = input.closest('.autocomplete-combobox'); const results = combo.querySelector('.combobox-results'); if (event.key === 'Escape') { if (results) results.classList.add('hidden'); input.blur(); } else if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) { const visibleItems = getVisibleItems(combo); if (visibleItems.length > 0) { event.preventDefault(); visibleItems[0].focus(); } } return; } if (item) { const combo = item.closest('.autocomplete-combobox'); const results = combo.querySelector('.combobox-results'); const inputField = combo.querySelector('.combobox-input'); const visibleItems = getVisibleItems(combo); const index = visibleItems.indexOf(item); if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); selectItem(combo, item); } else if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) { event.preventDefault(); if (index >= 0 && index + 1 < visibleItems.length) { visibleItems[index + 1].focus(); } else { // Loop back to first item if (visibleItems.length > 0) visibleItems[0].focus(); } } else if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) { event.preventDefault(); if (index > 0) { visibleItems[index - 1].focus(); } else if (inputField) { // Return focus back to search input inputField.focus(); } } else if (event.key === 'Escape') { event.preventDefault(); if (results) results.classList.add('hidden'); if (inputField) inputField.focus(); } } }); // 5. Click event delegation for selection document.addEventListener('click', (event) => { const item = event.target.closest('.combobox-item'); if (item) { const combo = item.closest('.autocomplete-combobox'); selectItem(combo, item); } }); // 6. HTMX Swap Integration to show results document.addEventListener('htmx:afterSwap', (event) => { const results = event.target.querySelector('.combobox-results') || event.target.closest('.combobox-results'); if (results) { results.classList.remove('hidden'); } }); // Core helper to perform selection swap function selectItem(combo, item) { const input = combo.querySelector('.combobox-input'); const valueInput = combo.querySelector('.combobox-value'); const results = combo.querySelector('.combobox-results'); const id = item.getAttribute('data-id'); const name = item.getAttribute('data-name'); if (valueInput) { valueInput.value = id; valueInput.dispatchEvent(new Event('change', { bubbles: true })); } if (input) { input.value = name; input.focus(); input.dispatchEvent(new Event('change', { bubbles: true })); } if (results) { results.classList.add('hidden'); // Prevent immediate reopening on focus combo.dataset.justSelected = 'true'; setTimeout(() => { delete combo.dataset.justSelected; }, 200); } } });