mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 17:11:09 -05:00
- 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.
221 lines
8.1 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
|
|
})();
|