/** * Enhanced Parks Search and Filter Management * Provides comprehensive UX improvements for the parks listing page */ class ParkSearchManager { constructor() { this.debounceTimers = new Map(); this.filterState = new Map(); this.requestCount = 0; this.lastRequestTime = 0; this.init(); } init() { this.setupEventListeners(); this.initializeLazyLoading(); this.setupKeyboardNavigation(); this.restoreFilterState(); this.setupPerformanceOptimizations(); } setupEventListeners() { // Enhanced HTMX request handling document.addEventListener('htmx:configRequest', (evt) => this.handleConfigRequest(evt)); document.addEventListener('htmx:beforeRequest', (evt) => this.handleBeforeRequest(evt)); document.addEventListener('htmx:afterRequest', (evt) => this.handleAfterRequest(evt)); document.addEventListener('htmx:responseError', (evt) => this.handleResponseError(evt)); document.addEventListener('htmx:historyRestore', (evt) => this.handleHistoryRestore(evt)); document.addEventListener('htmx:afterSwap', (evt) => this.handleAfterSwap(evt)); // Enhanced form interactions document.addEventListener('input', (evt) => this.handleInput(evt)); document.addEventListener('change', (evt) => this.handleChange(evt)); document.addEventListener('focus', (evt) => this.handleFocus(evt)); document.addEventListener('blur', (evt) => this.handleBlur(evt)); // Search suggestions document.addEventListener('keydown', (evt) => this.handleKeydown(evt)); // Window events window.addEventListener('beforeunload', () => this.saveFilterState()); window.addEventListener('resize', this.debounce(() => this.handleResize(), 250)); } handleConfigRequest(evt) { // Preserve view mode const parkResults = document.getElementById('park-results'); if (parkResults) { const viewMode = parkResults.getAttribute('data-view-mode'); if (viewMode) { evt.detail.parameters['view_mode'] = viewMode; } } // Preserve search terms const searchInput = document.querySelector('input[name="search"]'); if (searchInput && searchInput.value) { evt.detail.parameters['search'] = searchInput.value; } // Add request tracking evt.detail.parameters['_req_id'] = ++this.requestCount; this.lastRequestTime = Date.now(); } handleBeforeRequest(evt) { const target = evt.detail.target; if (target) { target.classList.add('htmx-requesting'); this.showLoadingIndicator(target); } // Disable form elements during request this.toggleFormElements(false); // Track request analytics this.trackFilterUsage(evt); } handleAfterRequest(evt) { const target = evt.detail.target; if (target) { target.classList.remove('htmx-requesting'); this.hideLoadingIndicator(target); } // Re-enable form elements this.toggleFormElements(true); // Handle response timing const responseTime = Date.now() - this.lastRequestTime; if (responseTime > 3000) { this.showPerformanceWarning(); } } handleResponseError(evt) { this.hideLoadingIndicator(evt.detail.target); this.toggleFormElements(true); this.showErrorMessage('Failed to load results. Please try again.'); } handleAfterSwap(evt) { if (evt.detail.target.id === 'results-container') { this.initializeLazyLoading(evt.detail.target); this.updateResultsInfo(evt.detail.target); this.animateResults(evt.detail.target); } } handleHistoryRestore(evt) { const parkResults = document.getElementById('park-results'); if (parkResults && evt.detail.path) { const url = new URL(evt.detail.path, window.location.origin); const viewMode = url.searchParams.get('view_mode'); if (viewMode) { parkResults.setAttribute('data-view-mode', viewMode); } } // Restore filter state from URL this.restoreFiltersFromURL(evt.detail.path); } handleInput(evt) { if (evt.target.type === 'search' || evt.target.type === 'text') { this.debounceInput(evt.target); } } handleChange(evt) { if (evt.target.closest('#filter-form')) { this.updateFilterState(); this.saveFilterState(); } } handleFocus(evt) { if (evt.target.type === 'search') { this.highlightSearchSuggestions(evt.target); } } handleBlur(evt) { if (evt.target.type === 'search') { // Delay hiding suggestions to allow for clicks setTimeout(() => this.hideSearchSuggestions(), 150); } } handleKeydown(evt) { if (evt.target.type === 'search') { this.handleSearchKeyboard(evt); } } handleResize() { // Responsive adjustments this.adjustLayoutForViewport(); } debounceInput(input) { const key = input.name || input.id; if (this.debounceTimers.has(key)) { clearTimeout(this.debounceTimers.get(key)); } const delay = input.type === 'search' ? 300 : 500; const timer = setTimeout(() => { if (input.form) { htmx.trigger(input.form, 'change'); } this.debounceTimers.delete(key); }, delay); this.debounceTimers.set(key, timer); } handleSearchKeyboard(evt) { const suggestions = document.getElementById('search-results'); if (!suggestions) return; const items = suggestions.querySelectorAll('[role="option"]'); let activeIndex = Array.from(items).findIndex(item => item.classList.contains('active') || item.classList.contains('highlighted') ); switch (evt.key) { case 'ArrowDown': evt.preventDefault(); activeIndex = Math.min(activeIndex + 1, items.length - 1); this.highlightSuggestion(items, activeIndex); break; case 'ArrowUp': evt.preventDefault(); activeIndex = Math.max(activeIndex - 1, -1); this.highlightSuggestion(items, activeIndex); break; case 'Enter': if (activeIndex >= 0 && items[activeIndex]) { evt.preventDefault(); items[activeIndex].click(); } break; case 'Escape': this.hideSearchSuggestions(); evt.target.blur(); break; } } highlightSuggestion(items, activeIndex) { items.forEach((item, index) => { item.classList.toggle('active', index === activeIndex); item.classList.toggle('highlighted', index === activeIndex); }); } highlightSearchSuggestions(input) { const suggestions = document.getElementById('search-results'); if (suggestions && input.value) { suggestions.style.display = 'block'; } } hideSearchSuggestions() { const suggestions = document.getElementById('search-results'); if (suggestions) { suggestions.style.display = 'none'; } } initializeLazyLoading(container = document) { if (!('IntersectionObserver' in window)) return; const imageObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; if (img.dataset.src) { img.src = img.dataset.src; img.removeAttribute('data-src'); img.classList.add('loaded'); imageObserver.unobserve(img); } } }); }, { threshold: 0.1, rootMargin: '50px' }); container.querySelectorAll('img[data-src]').forEach(img => { imageObserver.observe(img); }); } setupKeyboardNavigation() { // Tab navigation for filter cards document.addEventListener('keydown', (evt) => { if (evt.key === 'Tab' && evt.target.closest('.park-card')) { this.handleCardNavigation(evt); } }); } setupPerformanceOptimizations() { // Prefetch next page if pagination exists this.setupPrefetching(); // Optimize scroll performance this.setupScrollOptimization(); } setupPrefetching() { const nextPageLink = document.querySelector('a[rel="next"]'); if (nextPageLink && 'IntersectionObserver' in window) { const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { this.prefetchPage(nextPageLink.href); observer.unobserve(entry.target); } }); }); const trigger = document.querySelector('.pagination'); if (trigger) { observer.observe(trigger); } } } setupScrollOptimization() { let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(() => { this.handleScroll(); ticking = false; }); ticking = true; } }); } handleScroll() { // Show/hide back to top button const backToTop = document.getElementById('back-to-top'); if (backToTop) { backToTop.style.display = window.scrollY > 500 ? 'block' : 'none'; } } prefetchPage(url) { const link = document.createElement('link'); link.rel = 'prefetch'; link.href = url; document.head.appendChild(link); } showLoadingIndicator(target) { // Add subtle loading animation target.style.transition = 'opacity 0.3s ease-in-out'; target.style.opacity = '0.7'; } hideLoadingIndicator(target) { target.style.opacity = '1'; } toggleFormElements(enabled) { const form = document.getElementById('filter-form'); if (form) { const elements = form.querySelectorAll('input, select, button'); elements.forEach(el => { el.disabled = !enabled; }); } } updateFilterState() { const form = document.getElementById('filter-form'); if (!form) return; const formData = new FormData(form); this.filterState.clear(); for (const [key, value] of formData.entries()) { if (value && value !== '') { this.filterState.set(key, value); } } } saveFilterState() { try { const state = Object.fromEntries(this.filterState); localStorage.setItem('parkFilters', JSON.stringify(state)); } catch (e) { console.warn('Failed to save filter state:', e); } } restoreFilterState() { try { const saved = localStorage.getItem('parkFilters'); if (saved) { const state = JSON.parse(saved); this.applyFilterState(state); } } catch (e) { console.warn('Failed to restore filter state:', e); } } restoreFiltersFromURL(path) { const url = new URL(path, window.location.origin); const params = new URLSearchParams(url.search); const form = document.getElementById('filter-form'); if (!form) return; // Clear existing values form.reset(); // Apply URL parameters for (const [key, value] of params.entries()) { const input = form.querySelector(`[name="${key}"]`); if (input) { if (input.type === 'checkbox') { input.checked = value === 'on' || value === 'true'; } else { input.value = value; } } } } applyFilterState(state) { const form = document.getElementById('filter-form'); if (!form) return; Object.entries(state).forEach(([key, value]) => { const input = form.querySelector(`[name="${key}"]`); if (input) { if (input.type === 'checkbox') { input.checked = value === 'on' || value === 'true'; } else { input.value = value; } } }); } updateResultsInfo(container) { // Update any result count displays const countElements = container.querySelectorAll('[data-result-count]'); countElements.forEach(el => { const count = container.querySelectorAll('.park-card').length; el.textContent = count; }); } animateResults(container) { // Subtle animation for new results const cards = container.querySelectorAll('.park-card'); cards.forEach((card, index) => { card.style.opacity = '0'; card.style.transform = 'translateY(20px)'; setTimeout(() => { card.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; card.style.opacity = '1'; card.style.transform = 'translateY(0)'; }, index * 50); }); } adjustLayoutForViewport() { const viewport = window.innerWidth; // Adjust grid columns based on viewport const grid = document.querySelector('.park-card-grid'); if (grid) { if (viewport < 768) { grid.style.gridTemplateColumns = '1fr'; } else if (viewport < 1024) { grid.style.gridTemplateColumns = 'repeat(2, 1fr)'; } else { grid.style.gridTemplateColumns = 'repeat(3, 1fr)'; } } } trackFilterUsage(evt) { // Track which filters are being used for analytics if (window.gtag) { const formData = new FormData(evt.detail.elt); const activeFilters = []; for (const [key, value] of formData.entries()) { if (value && value !== '' && key !== 'csrfmiddlewaretoken') { activeFilters.push(key); } } window.gtag('event', 'filter_usage', { 'filters_used': activeFilters.join(','), 'filter_count': activeFilters.length }); } } showPerformanceWarning() { // Show a subtle warning for slow responses const warning = document.createElement('div'); warning.className = 'fixed top-4 right-4 bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded z-50'; warning.innerHTML = ` Search is taking longer than expected... `; document.body.appendChild(warning); setTimeout(() => { if (warning.parentElement) { warning.remove(); } }, 5000); } showErrorMessage(message) { // Show error message with retry option const errorDiv = document.createElement('div'); errorDiv.className = 'fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50'; errorDiv.innerHTML = ` ${message} `; document.body.appendChild(errorDiv); setTimeout(() => { if (errorDiv.parentElement) { errorDiv.remove(); } }, 10000); } // Utility method for debouncing debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } } // Initialize the enhanced search manager document.addEventListener('DOMContentLoaded', () => { window.parkSearchManager = new ParkSearchManager(); }); // Export for potential module usage if (typeof module !== 'undefined' && module.exports) { module.exports = ParkSearchManager; }