/** * Enhanced Form Functionality * Provides enhanced HTMX form interactions and state management */ class FormEnhancement { constructor() { this.init(); } init() { this.setupHTMXEnhancements(); this.setupFormValidation(); this.setupLoadingStates(); this.setupFormPersistence(); this.setupAccessibilityEnhancements(); } setupHTMXEnhancements() { // Global HTMX configuration document.addEventListener('DOMContentLoaded', () => { if (!document.body) { console.warn('FormEnhancement: document.body not available'); return; } // Add loading indicators to HTMX requests document.body.addEventListener('htmx:beforeRequest', (e) => { this.showRequestLoading(e.target); }); document.body.addEventListener('htmx:afterRequest', (e) => { this.hideRequestLoading(e.target); // Re-initialize components after HTMX swaps this.reinitializeComponents(e.target); }); // Handle HTMX errors gracefully document.body.addEventListener('htmx:responseError', (e) => { this.handleHTMXError(e); }); // Form-specific HTMX handling document.body.addEventListener('htmx:beforeSwap', (e) => { this.handleBeforeSwap(e); }); document.body.addEventListener('htmx:afterSwap', (e) => { this.handleAfterSwap(e); }); }); } setupFormValidation() { // Enhanced form validation with real-time feedback document.addEventListener('input', (e) => { if (e.target.matches('input, textarea, select')) { this.validateField(e.target); } }); document.addEventListener('blur', (e) => { if (e.target.matches('input, textarea, select')) { this.validateField(e.target, true); } }); } setupLoadingStates() { // Enhanced loading states for better UX const style = document.createElement('style'); style.textContent = ` .form-loading { position: relative; pointer-events: none; opacity: 0.7; } .form-loading::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; } .form-loading::before { content: ''; position: absolute; top: 50%; left: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px; border: 2px solid #dbdbdb; border-top-color: #3273dc; border-radius: 50%; animation: spin 1s linear infinite; z-index: 1001; } @keyframes spin { to { transform: rotate(360deg); } } .field-error { animation: shake 0.5s ease-in-out; } @keyframes shake { 0%, 20%, 40%, 60%, 80%, 100% { transform: translateX(0); } 10%, 30%, 50%, 70%, 90% { transform: translateX(-3px); } } .venue-search-container { position: relative; } .venue-suggestions { position: absolute; top: 100%; left: 0; right: 0; border: 1px solid #dbdbdb; border-top: none; border-radius: 0 0 4px 4px; box-shadow: 0 8px 16px rgba(10, 10, 10, 0.1); z-index: 1000; max-height: 300px; overflow-y: auto; display: none; } .venue-suggestion-item { padding: 12px; border-bottom: 1px solid #f5f5f5; cursor: pointer; transition: background-color 0.2s; } .venue-suggestion-item:hover, .venue-suggestion-item.is-active { background-color: #f5f5f5; } .venue-suggestion-item:last-child { border-bottom: none; } .venue-name { font-weight: 600; color: #363636; } .venue-address { color: #757575; font-size: 0.875rem; margin-top: 2px; } .venue-category-icon { color: #3273dc; margin-right: 8px; } .venue-quality { margin-top: 4px; } .venue-quality .icon { color: #ffdd57; } .venue-selected { border: 1px solid #48c774; border-radius: 6px; padding: 16px; background-color: #f6fbf6; } .venue-info { margin-bottom: 12px; } .venue-map-preview { height: 120px; border-radius: 4px; margin-bottom: 12px; background-color: #f5f5f5; position: relative; overflow: hidden; } .event-mini-map { height: 150px; border-radius: 6px; margin: 12px 0; background-color: #f5f5f5; position: relative; overflow: hidden; } .map-loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #757575; } .show-full-map { margin-top: 8px; } .location-info { display: flex; align-items: center; margin-bottom: 8px; } .location-info .icon { margin-right: 8px; color: #3273dc; } .enhanced-event-view { border: 1px solid #dbdbdb; border-radius: 6px; padding: 20px; margin-bottom: 20px; background: white; box-shadow: 0 2px 4px rgba(10, 10, 10, 0.1); transition: box-shadow 0.3s ease; } .enhanced-event-view:hover { box-shadow: 0 4px 8px rgba(10, 10, 10, 0.15); } .event-location-enhanced { margin: 12px 0; padding: 12px; background-color: #f8f9fa; border-radius: 6px; border-left: 4px solid #3273dc; } .rsvp-actions { margin-top: 12px; } .rsvp-actions .button { margin-right: 8px; margin-bottom: 8px; } .rsvp-counts { display: flex; gap: 16px; margin-bottom: 8px; } .rsvp-count { display: flex; align-items: center; gap: 4px; color: #757575; font-size: 0.875rem; } .rsvp-count .icon { color: #3273dc; } .rsvp-count .count { font-weight: 600; color: #363636; } `; document.head.appendChild(style); } setupFormPersistence() { // Save form data to localStorage to prevent data loss const formElements = document.querySelectorAll('form[hx-post]'); formElements.forEach(form => { const formId = this.getFormId(form); // Load saved data this.loadFormData(form, formId); // Save data on input form.addEventListener('input', () => { this.saveFormData(form, formId); }); // Clear saved data on successful submit form.addEventListener('htmx:afterRequest', (e) => { if (e.detail.successful) { this.clearFormData(formId); } }); }); } setupAccessibilityEnhancements() { // Enhanced accessibility features document.addEventListener('DOMContentLoaded', () => { // Add skip links for better keyboard navigation this.addSkipLinks(); // Enhance focus management this.setupFocusManagement(); // Add ARIA live regions for dynamic content this.setupLiveRegions(); }); } showRequestLoading(element) { // Add loading state to the target element const target = element.hasAttribute('hx-target') ? document.querySelector(element.getAttribute('hx-target')) : element; if (target) { target.classList.add('form-loading'); } // Also add loading to button if it's a button if (element.tagName === 'BUTTON') { element.classList.add('is-loading'); element.disabled = true; } } hideRequestLoading(element) { // Remove loading state const target = element.hasAttribute('hx-target') ? document.querySelector(element.getAttribute('hx-target')) : element; if (target) { target.classList.remove('form-loading'); } // Remove button loading state if (element.tagName === 'BUTTON') { element.classList.remove('is-loading'); element.disabled = false; } } reinitializeComponents(target) { // Reinitialize venue search if needed const venueInput = target.querySelector('#venue-search-input'); const venueSuggestions = target.querySelector('#venue-suggestions'); if (venueInput && venueSuggestions && !window.venueSearch) { window.venueSearch = new VenueSearch('venue-search-input', 'venue-suggestions'); } // Reinitialize mini maps const miniMaps = target.querySelectorAll('.event-mini-map[data-lat][data-lng]'); miniMaps.forEach(mapContainer => { const lat = parseFloat(mapContainer.dataset.lat); const lng = parseFloat(mapContainer.dataset.lng); const venueName = mapContainer.dataset.venueName || 'Event Location'; if (lat && lng && !mapContainer.querySelector('.maplibregl-map')) { initializeMiniMap(mapContainer, lat, lng, venueName); } }); } handleHTMXError(e) { console.error('HTMX request error:', e); // Show user-friendly error message this.showErrorNotification('Something went wrong. Please try again.'); // Remove loading states this.hideRequestLoading(e.target); } handleBeforeSwap(e) { // Store focus information before swap const activeElement = document.activeElement; if (activeElement && activeElement.id) { this.lastFocusedId = activeElement.id; } } handleAfterSwap(e) { // Restore focus after swap if possible if (this.lastFocusedId) { const element = document.getElementById(this.lastFocusedId); if (element) { element.focus(); } this.lastFocusedId = null; } // Announce content change to screen readers this.announceContentChange('Form updated'); } validateField(field, showErrors = false) { const value = field.value.trim(); const fieldContainer = field.closest('.field'); const helpElement = fieldContainer?.querySelector('.help'); // Remove existing error states field.classList.remove('is-danger'); fieldContainer?.classList.remove('field-error'); // Basic validation if (field.required && !value) { if (showErrors) { this.showFieldError(field, 'This field is required'); } return false; } // Email validation if (field.type === 'email' && value) { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(value)) { if (showErrors) { this.showFieldError(field, 'Please enter a valid email address'); } return false; } } // Length validation if (field.minLength && value.length < field.minLength) { if (showErrors) { this.showFieldError(field, `Minimum length is ${field.minLength} characters`); } return false; } // Custom validation for event name if (field.name === 'name' && value.length < 10) { if (showErrors) { this.showFieldError(field, 'Event name must be at least 10 characters'); } return false; } // Clear any existing errors this.clearFieldError(field); return true; } showFieldError(field, message) { field.classList.add('is-danger'); const fieldContainer = field.closest('.field'); fieldContainer?.classList.add('field-error'); let helpElement = fieldContainer?.querySelector('.help.is-danger'); if (!helpElement) { helpElement = document.createElement('p'); helpElement.className = 'help is-danger'; field.parentNode.appendChild(helpElement); } helpElement.textContent = message; // Announce error to screen readers this.announceError(message); } clearFieldError(field) { field.classList.remove('is-danger'); const fieldContainer = field.closest('.field'); fieldContainer?.classList.remove('field-error'); const errorHelp = fieldContainer?.querySelector('.help.is-danger'); if (errorHelp) { errorHelp.remove(); } } getFormId(form) { // Generate a unique ID for form persistence const action = form.getAttribute('hx-post') || form.action; return `form_${btoa(action).replace(/[^a-zA-Z0-9]/g, '')}`; } saveFormData(form, formId) { const formData = new FormData(form); const data = {}; for (let [key, value] of formData.entries()) { data[key] = value; } try { localStorage.setItem(formId, JSON.stringify(data)); } catch (e) { console.warn('Could not save form data:', e); } } loadFormData(form, formId) { try { const savedData = localStorage.getItem(formId); if (!savedData) return; const data = JSON.parse(savedData); Object.keys(data).forEach(key => { const field = form.querySelector(`[name="${key}"]`); if (field && !field.value) { field.value = data[key]; } }); } catch (e) { console.warn('Could not load form data:', e); } } clearFormData(formId) { try { localStorage.removeItem(formId); } catch (e) { console.warn('Could not clear form data:', e); } } addSkipLinks() { const skipLinks = document.createElement('div'); skipLinks.className = 'skip-links'; skipLinks.innerHTML = ` `; // Add styles for skip links const style = document.createElement('style'); style.textContent = ` .skip-links { position: absolute; top: -100px; left: 0; z-index: 9999; } .skip-link:focus { position: absolute; top: 10px; left: 10px; } `; document.head.appendChild(style); document.body.insertBefore(skipLinks, document.body.firstChild); } setupFocusManagement() { // Enhance focus management for better keyboard navigation document.addEventListener('keydown', (e) => { // Escape key handling if (e.key === 'Escape') { // Close any open modals or dropdowns this.closeOpenElements(); } }); } setupLiveRegions() { // Add ARIA live regions for announcements const liveRegion = document.createElement('div'); liveRegion.id = 'live-announcements'; liveRegion.setAttribute('aria-live', 'polite'); liveRegion.setAttribute('aria-atomic', 'true'); liveRegion.style.position = 'absolute'; liveRegion.style.left = '-10000px'; liveRegion.style.width = '1px'; liveRegion.style.height = '1px'; liveRegion.style.overflow = 'hidden'; document.body.appendChild(liveRegion); this.liveRegion = liveRegion; } announceContentChange(message) { if (this.liveRegion) { this.liveRegion.textContent = message; setTimeout(() => { this.liveRegion.textContent = ''; }, 1000); } } announceError(message) { if (this.liveRegion) { this.liveRegion.textContent = `Error: ${message}`; setTimeout(() => { this.liveRegion.textContent = ''; }, 3000); } } showErrorNotification(message) { const notification = document.createElement('div'); notification.className = 'notification is-danger'; notification.innerHTML = ` ${message} `; // Insert at top of page const container = document.querySelector('.container') || document.body; container.insertBefore(notification, container.firstChild); // Auto-remove after 5 seconds setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 5000); // Handle delete button const deleteBtn = notification.querySelector('.delete'); if (deleteBtn) { deleteBtn.addEventListener('click', () => { notification.parentNode.removeChild(notification); }); } // Announce error this.announceError(message); } closeOpenElements() { // Close venue suggestions if (window.venueSearch) { window.venueSearch.hideSuggestions(); } // Close map modal if (window.closeMapPicker) { window.closeMapPicker(); } // Close any Bulma modals const activeModals = document.querySelectorAll('.modal.is-active'); activeModals.forEach(modal => { modal.classList.remove('is-active'); }); } } // Initialize form enhancements document.addEventListener('DOMContentLoaded', function() { window.formEnhancement = new FormEnhancement(); }); // Global utility functions window.showFullEventMap = function(lat, lng, eventName) { // Create a full-size map modal for mobile devices const modal = document.createElement('div'); modal.className = 'modal is-active'; modal.innerHTML = ` `; document.body.appendChild(modal); // Initialize full map setTimeout(() => { if (window.maplibregl) { const fullMap = new maplibregl.Map({ container: 'full-event-map', style: { 'version': 8, 'sources': { 'osm': { 'type': 'raster', 'tiles': ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], 'tileSize': 256, 'attribution': '© OpenStreetMap contributors' } }, 'layers': [{ 'id': 'osm', 'type': 'raster', 'source': 'osm' }] }, center: [lng, lat], zoom: 15 }); new maplibregl.Marker() .setLngLat([lng, lat]) .setPopup(new maplibregl.Popup().setText(eventName)) .addTo(fullMap); } else { // Fallback when MapLibreGL is not available document.getElementById('full-event-map').innerHTML = `

${eventName}
${lat.toFixed(4)}, ${lng.toFixed(4)}
`; } }, 100); };