Files
thrillwiki_django_no_react/static/js/search-accessibility.js
pacnpal edcd8f2076 Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols.
- Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage.
- Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
2025-12-23 16:41:42 -05:00

221 lines
8.1 KiB
JavaScript

/**
* 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);
}
});
})();