/** * Search Results Keyboard Navigation * Handles Arrow Up/Down, Enter, and Escape keys for accessible search * * This module enhances search inputs with keyboard navigation for WCAG compliance: * - Arrow Down: Navigate to next search result * - Arrow Up: Navigate to previous search result (or back to input) * - Enter: Select current result (navigate to link) * - Escape: Close search results and blur input * * Usage: * The script automatically initializes on DOMContentLoaded for all search inputs * with hx-target attribute pointing to a results container. * * HTMX Integration: * Results should include role="option" on each selectable item. * The script listens for htmx:afterSwap to reinitialize when results change. */ (function() { 'use strict'; /** * Initialize keyboard navigation for a search input * @param {HTMLInputElement} input - The search input element */ function initSearchAccessibility(input) { const targetSelector = input.getAttribute('hx-target'); if (!targetSelector) return; const resultsContainer = document.querySelector(targetSelector); if (!resultsContainer) return; // Track if we've already initialized this input if (input.dataset.searchAccessibilityInit) return; input.dataset.searchAccessibilityInit = 'true'; let currentIndex = -1; /** * Update visual and ARIA selection state for results * @param {NodeList} results - List of result option elements * @param {number} index - Index of currently selected item (-1 for none) */ function updateSelection(results, index) { results.forEach((result, i) => { if (i === index) { result.setAttribute('aria-selected', 'true'); result.classList.add('bg-accent'); result.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } else { result.setAttribute('aria-selected', 'false'); result.classList.remove('bg-accent'); } }); // Update aria-activedescendant on the input if (index >= 0 && results[index]) { const resultId = results[index].id || `search-result-${index}`; if (!results[index].id) { results[index].id = resultId; } input.setAttribute('aria-activedescendant', resultId); } else { input.removeAttribute('aria-activedescendant'); } } /** * Announce result count to screen readers * @param {number} count - Number of results found */ function announceResults(count) { const statusId = input.getAttribute('aria-describedby'); if (statusId) { const statusEl = document.getElementById(statusId); if (statusEl) { if (count === 0) { statusEl.textContent = 'No results found'; } else if (count === 1) { statusEl.textContent = '1 result found. Use arrow keys to navigate.'; } else { statusEl.textContent = `${count} results found. Use arrow keys to navigate.`; } // Clear after a delay to allow re-announcement on new searches setTimeout(() => { statusEl.textContent = ''; }, 1500); } } } /** * Handle keydown events for navigation * @param {KeyboardEvent} e - The keyboard event */ function handleKeydown(e) { const results = resultsContainer.querySelectorAll('[role="option"]'); // If no results and not escape, let default behavior happen if (results.length === 0 && e.key !== 'Escape') { return; } switch(e.key) { case 'ArrowDown': e.preventDefault(); if (results.length > 0) { currentIndex = Math.min(currentIndex + 1, results.length - 1); updateSelection(results, currentIndex); } break; case 'ArrowUp': e.preventDefault(); if (currentIndex > 0) { currentIndex = currentIndex - 1; updateSelection(results, currentIndex); } else if (currentIndex === 0) { // Move back to input currentIndex = -1; updateSelection(results, currentIndex); input.focus(); } break; case 'Enter': if (currentIndex >= 0 && results[currentIndex]) { e.preventDefault(); const link = results[currentIndex].querySelector('a'); if (link) { link.click(); } else if (results[currentIndex].tagName === 'A') { results[currentIndex].click(); } } break; case 'Escape': e.preventDefault(); // Clear results and reset resultsContainer.innerHTML = ''; currentIndex = -1; input.blur(); break; case 'Home': if (results.length > 0) { e.preventDefault(); currentIndex = 0; updateSelection(results, currentIndex); } break; case 'End': if (results.length > 0) { e.preventDefault(); currentIndex = results.length - 1; updateSelection(results, currentIndex); } break; } } // Add keydown listener input.addEventListener('keydown', handleKeydown); // Reset index when input changes input.addEventListener('input', function() { currentIndex = -1; }); // Reset on focus input.addEventListener('focus', function() { currentIndex = -1; }); // Listen for HTMX swap events to announce results resultsContainer.addEventListener('htmx:afterSwap', function() { currentIndex = -1; const results = resultsContainer.querySelectorAll('[role="option"]'); announceResults(results.length); }); } /** * Initialize all search inputs on the page */ function initAllSearchInputs() { const searchInputs = document.querySelectorAll('input[type="search"][hx-target], input[type="text"][hx-target][name="q"]'); searchInputs.forEach(initSearchAccessibility); } // Initialize on DOMContentLoaded if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initAllSearchInputs); } else { initAllSearchInputs(); } // Re-initialize after HTMX swaps that might add new search inputs document.body.addEventListener('htmx:afterSettle', function(evt) { // Only reinitialize if new search inputs were added if (evt.target.querySelector && evt.target.querySelector('input[type="search"], input[name="q"]')) { initAllSearchInputs(); } }); // Also listen for status announcements from HTMX responses document.body.addEventListener('announceStatus', function(evt) { const announcer = document.getElementById('status-announcer'); if (announcer && evt.detail && evt.detail.message) { announcer.textContent = evt.detail.message; setTimeout(() => { announcer.textContent = ''; }, 1500); } }); })();