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.
This commit is contained in:
pacnpal
2025-12-23 16:41:42 -05:00
parent ae31e889d7
commit edcd8f2076
155 changed files with 22046 additions and 4645 deletions

View File

@@ -518,6 +518,44 @@
}
}
/* =================================================================
Accessibility - Forced Colors Mode (Windows High Contrast)
================================================================= */
@media (forced-colors: active) {
/* Ensure interactive elements have visible outlines */
button,
a,
input,
select,
textarea,
[role="button"],
[role="link"],
[tabindex="0"] {
outline: 2px solid ButtonText;
}
/* Enhanced focus indicators for forced colors mode */
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[role="button"]:focus-visible,
[role="menuitem"]:focus-visible,
[role="option"]:focus-visible,
[tabindex="0"]:focus-visible {
outline: 3px solid Highlight;
outline-offset: 2px;
}
/* Ensure disabled states are visible */
[disabled],
[aria-disabled="true"] {
outline-style: dashed;
}
}
/* =================================================================
Accessibility - Screen Reader Utilities
================================================================= */
@@ -1011,6 +1049,22 @@
outline-offset: -2px;
}
/* Enhanced focus ring for complex components */
.focus-ring-thick:focus-visible {
outline: 4px solid var(--color-primary);
outline-offset: 2px;
}
/* Focus visible for custom interactive elements (ARIA roles) */
[role="button"]:focus-visible,
[role="menuitem"]:focus-visible,
[role="option"]:focus-visible,
[role="tab"]:focus-visible,
[role="listitem"]:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Skip link styling */
.skip-link {
position: absolute;
@@ -1126,4 +1180,68 @@
opacity: 0.3;
}
}
/* =================================================================
Lazy Loading Image Utilities
================================================================= */
/* Base lazy image styles */
img.lazy {
opacity: 0;
transition: opacity var(--duration-300) var(--ease-out);
}
img.lazy-loading {
opacity: 0.5;
filter: blur(5px);
transition: opacity var(--duration-300) var(--ease-out),
filter var(--duration-300) var(--ease-out);
}
img.lazy-loaded {
opacity: 1;
filter: none;
}
img.lazy-error {
opacity: 0.3;
filter: grayscale(100%);
}
/* Placeholder styling for lazy images */
img[data-src] {
background-color: var(--color-muted);
background-image: linear-gradient(
90deg,
var(--color-muted) 0%,
var(--color-secondary-200) 50%,
var(--color-muted) 100%
);
background-size: 200% 100%;
}
@media (prefers-reduced-motion: no-preference) {
img[data-src]:not(.lazy-loaded) {
animation: skeleton-loading 1.5s ease-in-out infinite;
}
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* Blur-up placeholder effect */
.blur-up {
filter: blur(10px);
transition: filter var(--duration-500) var(--ease-out);
}
.blur-up.lazy-loaded {
filter: blur(0);
}
}

View File

@@ -1,33 +1,336 @@
// Lightbox Functionality
/**
* ThrillWiki Performance Optimization Module
*
* Features:
* - Advanced lazy loading with Intersection Observer
* - Image preloading and progressive loading
* - Dynamic module loading (code splitting)
* - Memory leak prevention utilities
* - Event listener cleanup management
*/
// =============================================================================
// Event Manager - Memory Leak Prevention
// =============================================================================
/**
* EventManager class for preventing memory leaks from orphaned event listeners.
* Use this for dynamic elements that may be removed from the DOM.
*/
class EventManager {
constructor() {
this.listeners = new Map();
}
/**
* Add an event listener with automatic cleanup tracking
* @param {Element} element - The DOM element
* @param {string} event - Event type (e.g., 'click')
* @param {Function} handler - Event handler function
* @param {Object} options - Event listener options
*/
addEventListener(element, event, handler, options = {}) {
if (!element) return;
if (!this.listeners.has(element)) {
this.listeners.set(element, []);
}
this.listeners.get(element).push({ event, handler, options });
element.addEventListener(event, handler, options);
}
/**
* Remove all tracked event listeners for an element
* @param {Element} element - The DOM element to clean up
*/
removeListeners(element) {
if (!this.listeners.has(element)) return;
const handlers = this.listeners.get(element);
handlers.forEach(({ event, handler, options }) => {
element.removeEventListener(event, handler, options);
});
this.listeners.delete(element);
}
/**
* Clean up all tracked event listeners
*/
cleanup() {
this.listeners.forEach((handlers, element) => {
handlers.forEach(({ event, handler, options }) => {
element.removeEventListener(event, handler, options);
});
});
this.listeners.clear();
}
}
// Global event manager instance
window.ThrillWikiEventManager = new EventManager();
// =============================================================================
// Advanced Lazy Loading with Intersection Observer
// =============================================================================
/**
* LazyLoader class for efficient image lazy loading using Intersection Observer.
* Supports progressive loading with blur-up placeholder technique.
*/
class LazyLoader {
constructor(options = {}) {
this.options = {
rootMargin: options.rootMargin || '50px 0px',
threshold: options.threshold || 0.01,
loadingClass: options.loadingClass || 'lazy-loading',
loadedClass: options.loadedClass || 'lazy-loaded',
errorClass: options.errorClass || 'lazy-error',
};
this.observer = null;
this.init();
}
init() {
// Check for Intersection Observer support
if (!('IntersectionObserver' in window)) {
// Fallback: load all images immediately
this.loadAllImages();
return;
}
this.observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
observer.unobserve(entry.target);
}
});
}, {
rootMargin: this.options.rootMargin,
threshold: this.options.threshold,
});
// Observe all lazy images
this.observeImages();
}
observeImages() {
const lazyImages = document.querySelectorAll('img[data-src], img.lazy');
lazyImages.forEach(img => {
if (this.observer) {
this.observer.observe(img);
}
});
}
loadImage(img) {
const src = img.dataset.src || img.getAttribute('data-src');
const srcset = img.dataset.srcset || img.getAttribute('data-srcset');
if (!src && !srcset) return;
img.classList.add(this.options.loadingClass);
// Create a temporary image to preload
const tempImage = new Image();
tempImage.onload = () => {
if (src) img.src = src;
if (srcset) img.srcset = srcset;
img.classList.remove(this.options.loadingClass);
img.classList.add(this.options.loadedClass);
img.classList.remove('lazy');
// Trigger custom event for tracking
img.dispatchEvent(new CustomEvent('lazyloaded', { bubbles: true }));
};
tempImage.onerror = () => {
img.classList.remove(this.options.loadingClass);
img.classList.add(this.options.errorClass);
console.warn('Failed to load image:', src);
};
// Start loading
tempImage.src = src || '';
if (srcset) tempImage.srcset = srcset;
}
loadAllImages() {
const lazyImages = document.querySelectorAll('img[data-src], img.lazy');
lazyImages.forEach(img => this.loadImage(img));
}
// Add new images to observation (for dynamically added content)
observe(img) {
if (this.observer) {
this.observer.observe(img);
} else {
this.loadImage(img);
}
}
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
}
}
// =============================================================================
// Dynamic Module Loading (Code Splitting)
// =============================================================================
/**
* ModuleLoader for lazy loading JavaScript modules only when needed.
* Reduces initial bundle size and improves page load time.
*/
class ModuleLoader {
constructor() {
this.loadedModules = new Set();
this.loadingPromises = new Map();
}
/**
* Dynamically load a JavaScript module
* @param {string} modulePath - Path to the module
* @returns {Promise} - Resolves when module is loaded
*/
async load(modulePath) {
// Return cached if already loaded
if (this.loadedModules.has(modulePath)) {
return Promise.resolve();
}
// Return existing promise if currently loading
if (this.loadingPromises.has(modulePath)) {
return this.loadingPromises.get(modulePath);
}
// Create loading promise
const loadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = modulePath;
script.async = true;
script.onload = () => {
this.loadedModules.add(modulePath);
this.loadingPromises.delete(modulePath);
resolve();
};
script.onerror = () => {
this.loadingPromises.delete(modulePath);
reject(new Error(`Failed to load module: ${modulePath}`));
};
document.head.appendChild(script);
});
this.loadingPromises.set(modulePath, loadPromise);
return loadPromise;
}
/**
* Conditionally load a module if an element exists
* @param {string} selector - CSS selector to check
* @param {string} modulePath - Path to the module to load
*/
async loadIfNeeded(selector, modulePath) {
if (document.querySelector(selector)) {
await this.load(modulePath);
}
}
}
// Global module loader instance
window.ThrillWikiModuleLoader = new ModuleLoader();
// =============================================================================
// Lightbox Functionality (Alpine.js Component)
// =============================================================================
document.addEventListener('alpine:init', () => {
Alpine.data('lightbox', () => ({
isOpen: false,
imgSrc: '',
imgAlt: '',
open(event) {
const img = event.target.tagName === 'IMG' ? event.target : event.target.querySelector('img');
if (img) {
this.imgSrc = img.src;
// Use full-size image if available via data-full-src
this.imgSrc = img.dataset.fullSrc || img.src;
this.imgAlt = img.alt;
this.isOpen = true;
document.body.style.overflow = 'hidden';
}
},
close() {
this.isOpen = false;
this.imgSrc = '';
this.imgAlt = '';
document.body.style.overflow = '';
},
// Cleanup method to prevent memory leaks
destroy() {
this.close();
}
}));
});
// Add lazy loading to all images
// =============================================================================
// Initialization
// =============================================================================
// Global lazy loader instance
let lazyLoader = null;
document.addEventListener('DOMContentLoaded', () => {
// Initialize lazy loader
lazyLoader = new LazyLoader();
window.ThrillWikiLazyLoader = lazyLoader;
// Add native lazy loading to images that don't have it
const images = document.querySelectorAll('img:not([loading])');
images.forEach(img => {
img.setAttribute('loading', 'lazy');
// Set eager for above-the-fold images (first 3 visible images)
const rect = img.getBoundingClientRect();
const isAboveFold = rect.top < window.innerHeight && rect.top >= 0;
img.setAttribute('loading', isAboveFold ? 'eager' : 'lazy');
});
// Handle HTMX content updates
document.body.addEventListener('htmx:afterSwap', (event) => {
// Re-observe any new lazy images after HTMX updates
if (lazyLoader) {
const newImages = event.detail.target.querySelectorAll('img[data-src], img.lazy');
newImages.forEach(img => lazyLoader.observe(img));
}
});
});
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
if (window.ThrillWikiEventManager) {
window.ThrillWikiEventManager.cleanup();
}
if (lazyLoader) {
lazyLoader.destroy();
}
});
// =============================================================================
// Export for use in other modules
// =============================================================================
window.ThrillWikiOptimization = {
EventManager,
LazyLoader,
ModuleLoader,
};

View File

@@ -0,0 +1,411 @@
/**
* ThrillWiki Client-Side Performance Monitoring
*
* This module provides comprehensive client-side performance metrics collection
* and monitoring. It tracks:
* - Page load performance (Navigation Timing API)
* - Paint metrics (First Paint, First Contentful Paint)
* - Layout shifts (Cumulative Layout Shift)
* - Largest Contentful Paint
* - First Input Delay
* - Memory usage (Chrome only)
* - Long tasks (> 50ms)
* - Resource timing
*
* Usage:
* Include this script after DOMContentLoaded.
* Metrics are logged to console in development and can be sent to a backend endpoint.
*/
(function() {
'use strict';
// Configuration
const CONFIG = {
enableConsoleLogging: true, // Log to console in development
enableBeacon: false, // Send metrics to backend (set endpoint below)
beaconEndpoint: '/api/v1/metrics/performance/',
memoryCheckInterval: 30000, // Check memory every 30 seconds
memoryWarningThreshold: 0.9, // Warn at 90% memory usage
longTaskThreshold: 50, // Long task threshold in ms
reportDelay: 3000, // Delay before reporting metrics
};
// Metrics storage
const metrics = {
navigation: {},
paint: {},
resources: [],
longTasks: [],
layoutShifts: [],
largestContentfulPaint: null,
firstInputDelay: null,
memory: [],
errors: [],
};
/**
* Collect Navigation Timing metrics
*/
function collectNavigationTiming() {
if (!performance.getEntriesByType) return;
const navEntries = performance.getEntriesByType('navigation');
if (navEntries.length === 0) return;
const nav = navEntries[0];
metrics.navigation = {
// DNS lookup time
dnsLookup: nav.domainLookupEnd - nav.domainLookupStart,
// TCP connection time
tcpConnect: nav.connectEnd - nav.connectStart,
// TLS handshake time (if HTTPS)
tlsHandshake: nav.secureConnectionStart > 0
? nav.connectEnd - nav.secureConnectionStart
: 0,
// Time to First Byte (TTFB)
ttfb: nav.responseStart - nav.requestStart,
// Response download time
responseTime: nav.responseEnd - nav.responseStart,
// DOM parsing time
domParsing: nav.domContentLoadedEventEnd - nav.domInteractive,
// DOM Content Loaded
domContentLoaded: nav.domContentLoadedEventEnd - nav.startTime,
// Full page load
loadComplete: nav.loadEventEnd - nav.startTime,
// Redirect time (if any)
redirectTime: nav.redirectEnd - nav.redirectStart,
// Transfer size
transferSize: nav.transferSize,
// Encoded body size
encodedBodySize: nav.encodedBodySize,
// Decoded body size
decodedBodySize: nav.decodedBodySize,
};
}
/**
* Collect Paint Timing metrics
*/
function collectPaintTiming() {
if (!performance.getEntriesByType) return;
const paintEntries = performance.getEntriesByType('paint');
paintEntries.forEach(entry => {
if (entry.name === 'first-paint') {
metrics.paint.firstPaint = entry.startTime;
} else if (entry.name === 'first-contentful-paint') {
metrics.paint.firstContentfulPaint = entry.startTime;
}
});
}
/**
* Observe Largest Contentful Paint
*/
function observeLCP() {
if (!('PerformanceObserver' in window)) return;
try {
const lcpObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
const lastEntry = entries[entries.length - 1];
metrics.largestContentfulPaint = {
time: lastEntry.startTime,
element: lastEntry.element ? lastEntry.element.tagName : 'unknown',
size: lastEntry.size,
url: lastEntry.url || null,
};
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
} catch (e) {
// LCP not supported
}
}
/**
* Observe First Input Delay
*/
function observeFID() {
if (!('PerformanceObserver' in window)) return;
try {
const fidObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach(entry => {
if (!metrics.firstInputDelay) {
metrics.firstInputDelay = {
delay: entry.processingStart - entry.startTime,
eventType: entry.name,
target: entry.target ? entry.target.tagName : 'unknown',
};
}
});
});
fidObserver.observe({ type: 'first-input', buffered: true });
} catch (e) {
// FID not supported
}
}
/**
* Observe Cumulative Layout Shift
*/
function observeCLS() {
if (!('PerformanceObserver' in window)) return;
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];
try {
const clsObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach(entry => {
// Only count layout shifts without recent user input
if (!entry.hadRecentInput) {
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
// Start a new session if gap > 1 second or session > 5 seconds
if (sessionValue &&
(entry.startTime - lastSessionEntry.startTime > 1000 ||
entry.startTime - firstSessionEntry.startTime > 5000)) {
clsValue = Math.max(clsValue, sessionValue);
sessionValue = entry.value;
sessionEntries = [entry];
} else {
sessionValue += entry.value;
sessionEntries.push(entry);
}
metrics.layoutShifts.push({
value: entry.value,
time: entry.startTime,
sources: entry.sources ? entry.sources.length : 0,
});
}
});
});
clsObserver.observe({ type: 'layout-shift', buffered: true });
} catch (e) {
// CLS not supported
}
}
/**
* Observe Long Tasks
*/
function observeLongTasks() {
if (!('PerformanceObserver' in window)) return;
try {
const longTaskObserver = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
entries.forEach(entry => {
if (entry.duration > CONFIG.longTaskThreshold) {
metrics.longTasks.push({
duration: entry.duration,
startTime: entry.startTime,
name: entry.name,
});
}
});
});
longTaskObserver.observe({ entryTypes: ['longtask'] });
} catch (e) {
// Long tasks not supported
}
}
/**
* Monitor memory usage (Chrome only)
*/
function monitorMemory() {
if (!performance.memory) return;
const checkMemory = () => {
const memoryInfo = performance.memory;
const usedRatio = memoryInfo.usedJSHeapSize / memoryInfo.jsHeapSizeLimit;
const memorySnapshot = {
timestamp: Date.now(),
usedHeapSize: memoryInfo.usedJSHeapSize,
totalHeapSize: memoryInfo.totalJSHeapSize,
heapSizeLimit: memoryInfo.jsHeapSizeLimit,
usedRatio: usedRatio,
};
metrics.memory.push(memorySnapshot);
// Keep only last 10 snapshots
if (metrics.memory.length > 10) {
metrics.memory.shift();
}
// Warn if memory usage is high
if (usedRatio > CONFIG.memoryWarningThreshold) {
console.warn('ThrillWiki Performance: High memory usage detected', {
usedPercent: (usedRatio * 100).toFixed(1) + '%',
usedMB: (memoryInfo.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
limitMB: (memoryInfo.jsHeapSizeLimit / 1024 / 1024).toFixed(2) + ' MB',
});
}
};
// Initial check
checkMemory();
// Periodic checks
setInterval(checkMemory, CONFIG.memoryCheckInterval);
}
/**
* Collect resource timing
*/
function collectResourceTiming() {
if (!performance.getEntriesByType) return;
const resources = performance.getEntriesByType('resource');
metrics.resources = resources.slice(0, 50).map(resource => ({
name: resource.name.split('?')[0], // Remove query params
type: resource.initiatorType,
duration: resource.duration,
transferSize: resource.transferSize,
startTime: resource.startTime,
}));
}
/**
* Generate performance summary
*/
function generateSummary() {
const summary = {
timestamp: new Date().toISOString(),
url: window.location.href,
userAgent: navigator.userAgent,
navigation: metrics.navigation,
paint: metrics.paint,
lcp: metrics.largestContentfulPaint,
fid: metrics.firstInputDelay,
cls: metrics.layoutShifts.reduce((sum, shift) => sum + shift.value, 0),
longTaskCount: metrics.longTasks.length,
longTaskTotalTime: metrics.longTasks.reduce((sum, task) => sum + task.duration, 0),
resourceCount: metrics.resources.length,
totalTransferSize: metrics.resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
};
// Calculate Web Vitals ratings
summary.webVitals = {
lcp: rateMetric('lcp', summary.lcp?.time),
fid: rateMetric('fid', summary.fid?.delay),
cls: rateMetric('cls', summary.cls),
};
return summary;
}
/**
* Rate a metric according to Web Vitals thresholds
*/
function rateMetric(metric, value) {
if (value === null || value === undefined) return 'unknown';
const thresholds = {
lcp: { good: 2500, needsImprovement: 4000 },
fid: { good: 100, needsImprovement: 300 },
cls: { good: 0.1, needsImprovement: 0.25 },
};
const threshold = thresholds[metric];
if (!threshold) return 'unknown';
if (value <= threshold.good) return 'good';
if (value <= threshold.needsImprovement) return 'needs-improvement';
return 'poor';
}
/**
* Log metrics to console
*/
function logMetrics() {
if (!CONFIG.enableConsoleLogging) return;
const summary = generateSummary();
console.group('ThrillWiki Performance Metrics');
console.log('Page Load:', summary.navigation.loadComplete?.toFixed(0) + 'ms');
console.log('TTFB:', summary.navigation.ttfb?.toFixed(0) + 'ms');
console.log('First Paint:', summary.paint.firstPaint?.toFixed(0) + 'ms');
console.log('First Contentful Paint:', summary.paint.firstContentfulPaint?.toFixed(0) + 'ms');
console.log('Largest Contentful Paint:', summary.lcp?.time?.toFixed(0) + 'ms', `(${summary.webVitals.lcp})`);
console.log('First Input Delay:', summary.fid?.delay?.toFixed(0) + 'ms', `(${summary.webVitals.fid})`);
console.log('Cumulative Layout Shift:', summary.cls?.toFixed(3), `(${summary.webVitals.cls})`);
console.log('Long Tasks:', summary.longTaskCount, `(${summary.longTaskTotalTime.toFixed(0)}ms total)`);
console.log('Resources:', summary.resourceCount, `(${(summary.totalTransferSize / 1024).toFixed(0)}KB)`);
console.groupEnd();
}
/**
* Send metrics to backend
*/
function sendMetrics() {
if (!CONFIG.enableBeacon || !navigator.sendBeacon) return;
const summary = generateSummary();
const blob = new Blob([JSON.stringify(summary)], { type: 'application/json' });
try {
navigator.sendBeacon(CONFIG.beaconEndpoint, blob);
} catch (e) {
console.warn('Failed to send performance metrics:', e);
}
}
/**
* Initialize performance monitoring
*/
function init() {
// Start observers early
observeLCP();
observeFID();
observeCLS();
observeLongTasks();
// Collect metrics after page load
window.addEventListener('load', () => {
// Delay to ensure all metrics are available
setTimeout(() => {
collectNavigationTiming();
collectPaintTiming();
collectResourceTiming();
logMetrics();
sendMetrics();
}, CONFIG.reportDelay);
});
// Start memory monitoring
monitorMemory();
// Report on page unload
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
sendMetrics();
}
});
}
// Expose API
window.ThrillWikiPerformance = {
getMetrics: () => metrics,
getSummary: generateSummary,
logMetrics: logMetrics,
};
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,220 @@
/**
* 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);
}
});
})();