Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
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};