Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
at main 24 kB view raw
1/** 2 * Enhanced Form Functionality 3 * Provides enhanced HTMX form interactions and state management 4 */ 5class FormEnhancement { 6 constructor() { 7 this.init(); 8 } 9 10 init() { 11 this.setupHTMXEnhancements(); 12 this.setupFormValidation(); 13 this.setupLoadingStates(); 14 this.setupFormPersistence(); 15 this.setupAccessibilityEnhancements(); 16 } 17 18 setupHTMXEnhancements() { 19 // Global HTMX configuration 20 document.addEventListener('DOMContentLoaded', () => { 21 if (!document.body) { 22 console.warn('FormEnhancement: document.body not available'); 23 return; 24 } 25 26 // Add loading indicators to HTMX requests 27 document.body.addEventListener('htmx:beforeRequest', (e) => { 28 this.showRequestLoading(e.target); 29 }); 30 31 document.body.addEventListener('htmx:afterRequest', (e) => { 32 this.hideRequestLoading(e.target); 33 34 // Re-initialize components after HTMX swaps 35 this.reinitializeComponents(e.target); 36 }); 37 38 // Handle HTMX errors gracefully 39 document.body.addEventListener('htmx:responseError', (e) => { 40 this.handleHTMXError(e); 41 }); 42 43 // Form-specific HTMX handling 44 document.body.addEventListener('htmx:beforeSwap', (e) => { 45 this.handleBeforeSwap(e); 46 }); 47 48 document.body.addEventListener('htmx:afterSwap', (e) => { 49 this.handleAfterSwap(e); 50 }); 51 }); 52 } 53 54 setupFormValidation() { 55 // Enhanced form validation with real-time feedback 56 document.addEventListener('input', (e) => { 57 if (e.target.matches('input, textarea, select')) { 58 this.validateField(e.target); 59 } 60 }); 61 62 document.addEventListener('blur', (e) => { 63 if (e.target.matches('input, textarea, select')) { 64 this.validateField(e.target, true); 65 } 66 }); 67 } 68 69 setupLoadingStates() { 70 // Enhanced loading states for better UX 71 const style = document.createElement('style'); 72 style.textContent = ` 73 .form-loading { 74 position: relative; 75 pointer-events: none; 76 opacity: 0.7; 77 } 78 79 .form-loading::after { 80 content: ''; 81 position: absolute; 82 top: 0; 83 left: 0; 84 right: 0; 85 bottom: 0; 86 background: rgba(255, 255, 255, 0.8); 87 display: flex; 88 align-items: center; 89 justify-content: center; 90 z-index: 1000; 91 } 92 93 .form-loading::before { 94 content: ''; 95 position: absolute; 96 top: 50%; 97 left: 50%; 98 width: 20px; 99 height: 20px; 100 margin: -10px 0 0 -10px; 101 border: 2px solid #dbdbdb; 102 border-top-color: #3273dc; 103 border-radius: 50%; 104 animation: spin 1s linear infinite; 105 z-index: 1001; 106 } 107 108 @keyframes spin { 109 to { transform: rotate(360deg); } 110 } 111 112 .field-error { 113 animation: shake 0.5s ease-in-out; 114 } 115 116 @keyframes shake { 117 0%, 20%, 40%, 60%, 80%, 100% { transform: translateX(0); } 118 10%, 30%, 50%, 70%, 90% { transform: translateX(-3px); } 119 } 120 121 .venue-search-container { 122 position: relative; 123 } 124 125 .venue-suggestions { 126 position: absolute; 127 top: 100%; 128 left: 0; 129 right: 0; 130 border: 1px solid #dbdbdb; 131 border-top: none; 132 border-radius: 0 0 4px 4px; 133 box-shadow: 0 8px 16px rgba(10, 10, 10, 0.1); 134 z-index: 1000; 135 max-height: 300px; 136 overflow-y: auto; 137 display: none; 138 } 139 140 .venue-suggestion-item { 141 padding: 12px; 142 border-bottom: 1px solid #f5f5f5; 143 cursor: pointer; 144 transition: background-color 0.2s; 145 } 146 147 .venue-suggestion-item:hover, 148 .venue-suggestion-item.is-active { 149 background-color: #f5f5f5; 150 } 151 152 .venue-suggestion-item:last-child { 153 border-bottom: none; 154 } 155 156 .venue-name { 157 font-weight: 600; 158 color: #363636; 159 } 160 161 .venue-address { 162 color: #757575; 163 font-size: 0.875rem; 164 margin-top: 2px; 165 } 166 167 .venue-category-icon { 168 color: #3273dc; 169 margin-right: 8px; 170 } 171 172 .venue-quality { 173 margin-top: 4px; 174 } 175 176 .venue-quality .icon { 177 color: #ffdd57; 178 } 179 180 .venue-selected { 181 border: 1px solid #48c774; 182 border-radius: 6px; 183 padding: 16px; 184 background-color: #f6fbf6; 185 } 186 187 .venue-info { 188 margin-bottom: 12px; 189 } 190 191 .venue-map-preview { 192 height: 120px; 193 border-radius: 4px; 194 margin-bottom: 12px; 195 background-color: #f5f5f5; 196 position: relative; 197 overflow: hidden; 198 } 199 200 .event-mini-map { 201 height: 150px; 202 border-radius: 6px; 203 margin: 12px 0; 204 background-color: #f5f5f5; 205 position: relative; 206 overflow: hidden; 207 } 208 209 .map-loading { 210 position: absolute; 211 top: 50%; 212 left: 50%; 213 transform: translate(-50%, -50%); 214 color: #757575; 215 } 216 217 .show-full-map { 218 margin-top: 8px; 219 } 220 221 .location-info { 222 display: flex; 223 align-items: center; 224 margin-bottom: 8px; 225 } 226 227 .location-info .icon { 228 margin-right: 8px; 229 color: #3273dc; 230 } 231 232 .enhanced-event-view { 233 border: 1px solid #dbdbdb; 234 border-radius: 6px; 235 padding: 20px; 236 margin-bottom: 20px; 237 background: white; 238 box-shadow: 0 2px 4px rgba(10, 10, 10, 0.1); 239 transition: box-shadow 0.3s ease; 240 } 241 242 .enhanced-event-view:hover { 243 box-shadow: 0 4px 8px rgba(10, 10, 10, 0.15); 244 } 245 246 .event-location-enhanced { 247 margin: 12px 0; 248 padding: 12px; 249 background-color: #f8f9fa; 250 border-radius: 6px; 251 border-left: 4px solid #3273dc; 252 } 253 254 .rsvp-actions { 255 margin-top: 12px; 256 } 257 258 .rsvp-actions .button { 259 margin-right: 8px; 260 margin-bottom: 8px; 261 } 262 263 .rsvp-counts { 264 display: flex; 265 gap: 16px; 266 margin-bottom: 8px; 267 } 268 269 .rsvp-count { 270 display: flex; 271 align-items: center; 272 gap: 4px; 273 color: #757575; 274 font-size: 0.875rem; 275 } 276 277 .rsvp-count .icon { 278 color: #3273dc; 279 } 280 281 .rsvp-count .count { 282 font-weight: 600; 283 color: #363636; 284 } 285 `; 286 document.head.appendChild(style); 287 } 288 289 setupFormPersistence() { 290 // Save form data to localStorage to prevent data loss 291 const formElements = document.querySelectorAll('form[hx-post]'); 292 293 formElements.forEach(form => { 294 const formId = this.getFormId(form); 295 296 // Load saved data 297 this.loadFormData(form, formId); 298 299 // Save data on input 300 form.addEventListener('input', () => { 301 this.saveFormData(form, formId); 302 }); 303 304 // Clear saved data on successful submit 305 form.addEventListener('htmx:afterRequest', (e) => { 306 if (e.detail.successful) { 307 this.clearFormData(formId); 308 } 309 }); 310 }); 311 } 312 313 setupAccessibilityEnhancements() { 314 // Enhanced accessibility features 315 document.addEventListener('DOMContentLoaded', () => { 316 // Add skip links for better keyboard navigation 317 this.addSkipLinks(); 318 319 // Enhance focus management 320 this.setupFocusManagement(); 321 322 // Add ARIA live regions for dynamic content 323 this.setupLiveRegions(); 324 }); 325 } 326 327 showRequestLoading(element) { 328 // Add loading state to the target element 329 const target = element.hasAttribute('hx-target') ? 330 document.querySelector(element.getAttribute('hx-target')) : element; 331 332 if (target) { 333 target.classList.add('form-loading'); 334 } 335 336 // Also add loading to button if it's a button 337 if (element.tagName === 'BUTTON') { 338 element.classList.add('is-loading'); 339 element.disabled = true; 340 } 341 } 342 343 hideRequestLoading(element) { 344 // Remove loading state 345 const target = element.hasAttribute('hx-target') ? 346 document.querySelector(element.getAttribute('hx-target')) : element; 347 348 if (target) { 349 target.classList.remove('form-loading'); 350 } 351 352 // Remove button loading state 353 if (element.tagName === 'BUTTON') { 354 element.classList.remove('is-loading'); 355 element.disabled = false; 356 } 357 } 358 359 reinitializeComponents(target) { 360 // Reinitialize venue search if needed 361 const venueInput = target.querySelector('#venue-search-input'); 362 const venueSuggestions = target.querySelector('#venue-suggestions'); 363 364 if (venueInput && venueSuggestions && !window.venueSearch) { 365 window.venueSearch = new VenueSearch('venue-search-input', 'venue-suggestions'); 366 } 367 368 // Reinitialize mini maps 369 const miniMaps = target.querySelectorAll('.event-mini-map[data-lat][data-lng]'); 370 miniMaps.forEach(mapContainer => { 371 const lat = parseFloat(mapContainer.dataset.lat); 372 const lng = parseFloat(mapContainer.dataset.lng); 373 const venueName = mapContainer.dataset.venueName || 'Event Location'; 374 375 if (lat && lng && !mapContainer.querySelector('.maplibregl-map')) { 376 initializeMiniMap(mapContainer, lat, lng, venueName); 377 } 378 }); 379 } 380 381 handleHTMXError(e) { 382 console.error('HTMX request error:', e); 383 384 // Show user-friendly error message 385 this.showErrorNotification('Something went wrong. Please try again.'); 386 387 // Remove loading states 388 this.hideRequestLoading(e.target); 389 } 390 391 handleBeforeSwap(e) { 392 // Store focus information before swap 393 const activeElement = document.activeElement; 394 if (activeElement && activeElement.id) { 395 this.lastFocusedId = activeElement.id; 396 } 397 } 398 399 handleAfterSwap(e) { 400 // Restore focus after swap if possible 401 if (this.lastFocusedId) { 402 const element = document.getElementById(this.lastFocusedId); 403 if (element) { 404 element.focus(); 405 } 406 this.lastFocusedId = null; 407 } 408 409 // Announce content change to screen readers 410 this.announceContentChange('Form updated'); 411 } 412 413 validateField(field, showErrors = false) { 414 const value = field.value.trim(); 415 const fieldContainer = field.closest('.field'); 416 const helpElement = fieldContainer?.querySelector('.help'); 417 418 // Remove existing error states 419 field.classList.remove('is-danger'); 420 fieldContainer?.classList.remove('field-error'); 421 422 // Basic validation 423 if (field.required && !value) { 424 if (showErrors) { 425 this.showFieldError(field, 'This field is required'); 426 } 427 return false; 428 } 429 430 // Email validation 431 if (field.type === 'email' && value) { 432 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 433 if (!emailRegex.test(value)) { 434 if (showErrors) { 435 this.showFieldError(field, 'Please enter a valid email address'); 436 } 437 return false; 438 } 439 } 440 441 // Length validation 442 if (field.minLength && value.length < field.minLength) { 443 if (showErrors) { 444 this.showFieldError(field, `Minimum length is ${field.minLength} characters`); 445 } 446 return false; 447 } 448 449 // Custom validation for event name 450 if (field.name === 'name' && value.length < 10) { 451 if (showErrors) { 452 this.showFieldError(field, 'Event name must be at least 10 characters'); 453 } 454 return false; 455 } 456 457 // Clear any existing errors 458 this.clearFieldError(field); 459 return true; 460 } 461 462 showFieldError(field, message) { 463 field.classList.add('is-danger'); 464 const fieldContainer = field.closest('.field'); 465 fieldContainer?.classList.add('field-error'); 466 467 let helpElement = fieldContainer?.querySelector('.help.is-danger'); 468 if (!helpElement) { 469 helpElement = document.createElement('p'); 470 helpElement.className = 'help is-danger'; 471 field.parentNode.appendChild(helpElement); 472 } 473 474 helpElement.textContent = message; 475 476 // Announce error to screen readers 477 this.announceError(message); 478 } 479 480 clearFieldError(field) { 481 field.classList.remove('is-danger'); 482 const fieldContainer = field.closest('.field'); 483 fieldContainer?.classList.remove('field-error'); 484 485 const errorHelp = fieldContainer?.querySelector('.help.is-danger'); 486 if (errorHelp) { 487 errorHelp.remove(); 488 } 489 } 490 491 getFormId(form) { 492 // Generate a unique ID for form persistence 493 const action = form.getAttribute('hx-post') || form.action; 494 return `form_${btoa(action).replace(/[^a-zA-Z0-9]/g, '')}`; 495 } 496 497 saveFormData(form, formId) { 498 const formData = new FormData(form); 499 const data = {}; 500 501 for (let [key, value] of formData.entries()) { 502 data[key] = value; 503 } 504 505 try { 506 localStorage.setItem(formId, JSON.stringify(data)); 507 } catch (e) { 508 console.warn('Could not save form data:', e); 509 } 510 } 511 512 loadFormData(form, formId) { 513 try { 514 const savedData = localStorage.getItem(formId); 515 if (!savedData) return; 516 517 const data = JSON.parse(savedData); 518 519 Object.keys(data).forEach(key => { 520 const field = form.querySelector(`[name="${key}"]`); 521 if (field && !field.value) { 522 field.value = data[key]; 523 } 524 }); 525 } catch (e) { 526 console.warn('Could not load form data:', e); 527 } 528 } 529 530 clearFormData(formId) { 531 try { 532 localStorage.removeItem(formId); 533 } catch (e) { 534 console.warn('Could not clear form data:', e); 535 } 536 } 537 538 addSkipLinks() { 539 const skipLinks = document.createElement('div'); 540 skipLinks.className = 'skip-links'; 541 skipLinks.innerHTML = ` 542 <a href="#main-content" class="button is-small is-primary skip-link">Skip to main content</a> 543 <a href="#venue-search-input" class="button is-small is-info skip-link">Skip to venue search</a> 544 `; 545 546 // Add styles for skip links 547 const style = document.createElement('style'); 548 style.textContent = ` 549 .skip-links { 550 position: absolute; 551 top: -100px; 552 left: 0; 553 z-index: 9999; 554 } 555 556 .skip-link:focus { 557 position: absolute; 558 top: 10px; 559 left: 10px; 560 } 561 `; 562 563 document.head.appendChild(style); 564 document.body.insertBefore(skipLinks, document.body.firstChild); 565 } 566 567 setupFocusManagement() { 568 // Enhance focus management for better keyboard navigation 569 document.addEventListener('keydown', (e) => { 570 // Escape key handling 571 if (e.key === 'Escape') { 572 // Close any open modals or dropdowns 573 this.closeOpenElements(); 574 } 575 }); 576 } 577 578 setupLiveRegions() { 579 // Add ARIA live regions for announcements 580 const liveRegion = document.createElement('div'); 581 liveRegion.id = 'live-announcements'; 582 liveRegion.setAttribute('aria-live', 'polite'); 583 liveRegion.setAttribute('aria-atomic', 'true'); 584 liveRegion.style.position = 'absolute'; 585 liveRegion.style.left = '-10000px'; 586 liveRegion.style.width = '1px'; 587 liveRegion.style.height = '1px'; 588 liveRegion.style.overflow = 'hidden'; 589 590 document.body.appendChild(liveRegion); 591 this.liveRegion = liveRegion; 592 } 593 594 announceContentChange(message) { 595 if (this.liveRegion) { 596 this.liveRegion.textContent = message; 597 setTimeout(() => { 598 this.liveRegion.textContent = ''; 599 }, 1000); 600 } 601 } 602 603 announceError(message) { 604 if (this.liveRegion) { 605 this.liveRegion.textContent = `Error: ${message}`; 606 setTimeout(() => { 607 this.liveRegion.textContent = ''; 608 }, 3000); 609 } 610 } 611 612 showErrorNotification(message) { 613 const notification = document.createElement('div'); 614 notification.className = 'notification is-danger'; 615 notification.innerHTML = ` 616 <button class="delete"></button> 617 <span class="icon"><i class="fas fa-exclamation-triangle"></i></span> 618 <span>${message}</span> 619 `; 620 621 // Insert at top of page 622 const container = document.querySelector('.container') || document.body; 623 container.insertBefore(notification, container.firstChild); 624 625 // Auto-remove after 5 seconds 626 setTimeout(() => { 627 if (notification.parentNode) { 628 notification.parentNode.removeChild(notification); 629 } 630 }, 5000); 631 632 // Handle delete button 633 const deleteBtn = notification.querySelector('.delete'); 634 if (deleteBtn) { 635 deleteBtn.addEventListener('click', () => { 636 notification.parentNode.removeChild(notification); 637 }); 638 } 639 640 // Announce error 641 this.announceError(message); 642 } 643 644 closeOpenElements() { 645 // Close venue suggestions 646 if (window.venueSearch) { 647 window.venueSearch.hideSuggestions(); 648 } 649 650 // Close map modal 651 if (window.closeMapPicker) { 652 window.closeMapPicker(); 653 } 654 655 // Close any Bulma modals 656 const activeModals = document.querySelectorAll('.modal.is-active'); 657 activeModals.forEach(modal => { 658 modal.classList.remove('is-active'); 659 }); 660 } 661} 662 663// Initialize form enhancements 664document.addEventListener('DOMContentLoaded', function() { 665 window.formEnhancement = new FormEnhancement(); 666}); 667 668// Global utility functions 669window.showFullEventMap = function(lat, lng, eventName) { 670 // Create a full-size map modal for mobile devices 671 const modal = document.createElement('div'); 672 modal.className = 'modal is-active'; 673 modal.innerHTML = ` 674 <div class="modal-background" onclick="this.parentElement.remove()"></div> 675 <div class="modal-content"> 676 <div class="box"> 677 <h3 class="title is-4">${eventName}</h3> 678 <div id="full-event-map" style="height: 400px; width: 100%;"></div> 679 <div class="mt-4"> 680 <button class="button" onclick="this.closest('.modal').remove()">Close</button> 681 </div> 682 </div> 683 </div> 684 <button class="modal-close is-large" onclick="this.parentElement.remove()"></button> 685 `; 686 687 document.body.appendChild(modal); 688 689 // Initialize full map 690 setTimeout(() => { 691 if (window.maplibregl) { 692 const fullMap = new maplibregl.Map({ 693 container: 'full-event-map', 694 style: { 695 'version': 8, 696 'sources': { 697 'osm': { 698 'type': 'raster', 699 'tiles': ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], 700 'tileSize': 256, 701 'attribution': '© OpenStreetMap contributors' 702 } 703 }, 704 'layers': [{ 705 'id': 'osm', 706 'type': 'raster', 707 'source': 'osm' 708 }] 709 }, 710 center: [lng, lat], 711 zoom: 15 712 }); 713 714 new maplibregl.Marker() 715 .setLngLat([lng, lat]) 716 .setPopup(new maplibregl.Popup().setText(eventName)) 717 .addTo(fullMap); 718 } else { 719 // Fallback when MapLibreGL is not available 720 document.getElementById('full-event-map').innerHTML = ` 721 <div class="map-fallback" style="height: 400px; display: flex; align-items: center; justify-content: center; background: #f5f5f5; border: 1px solid #ddd;"> 722 <div class="has-text-centered"> 723 <span class="icon is-large"><i class="fas fa-map-marker-alt fa-2x"></i></span><br> 724 <strong>${eventName}</strong><br> 725 <small>${lat.toFixed(4)}, ${lng.toFixed(4)}</small> 726 </div> 727 </div> 728 `; 729 } 730 }, 100); 731};