1/** 2 * minCore Powermail Integration 3 * ============================= 4 * AJAX Form Submission, Enhanced Validation, and Accessibility 5 * WCAG 2.2 AA konform 6 * 7 * @version 1.0.0 8 * @author made in nature Projektagentur 9 */ 10 11(function () { 12 'use strict'; 13 14 // Default configuration 15 const defaultConfig = { 16 ajaxSubmit: true, 17 nativeValidation: true, 18 focusOnError: true, 19 scrollToError: true, 20 scrollOffset: 100, 21 animateDuration: 300, 22 messages: { 23 sending: 'Formular wird gesendet...', 24 success: 'Vielen Dank! Ihre Nachricht wurde erfolgreich gesendet.', 25 error: 'Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.', 26 validation: 'Bitte korrigieren Sie die markierten Felder.' 27 }, 28 classes: { 29 form: 'min-form', 30 field: 'min-form__field', 31 label: 'min-form__label', 32 input: 'min-form__input', 33 error: 'min-form__field--error', 34 success: 'min-form__field--success', 35 loading: 'min-form--loading', 36 submitted: 'min-form--submitted' 37 } 38 }; 39 40 // Merge with global config if available 41 const config = Object.assign({}, defaultConfig, window.MINCORE_POWERMAIL_CONFIG || {}); 42 43 /** 44 * MincorePowermail Class 45 * Main controller for Powermail form enhancements 46 */ 47 class MincorePowermail { 48 constructor(form) { 49 this.form = form; 50 this.submitButton = form.querySelector('[type="submit"]'); 51 this.messageContainer = null; 52 this.isSubmitting = false; 53 this.announcer = null; 54 55 this.init(); 56 } 57 58 /** 59 * Initialize the form enhancements 60 */ 61 init() { 62 this.createMessageContainer(); 63 this.createAnnouncer(); 64 this.bindEvents(); 65 this.enhanceAccessibility(); 66 } 67 68 /** 69 * Create message container for success/error messages 70 */ 71 createMessageContainer() { 72 this.messageContainer = document.createElement('div'); 73 this.messageContainer.className = 'min-form__messages'; 74 this.messageContainer.setAttribute('role', 'alert'); 75 this.messageContainer.setAttribute('aria-live', 'polite'); 76 this.messageContainer.setAttribute('aria-atomic', 'true'); 77 this.messageContainer.style.display = 'none'; 78 this.form.insertBefore(this.messageContainer, this.form.firstChild); 79 } 80 81 /** 82 * Create ARIA announcer for screen readers 83 */ 84 createAnnouncer() { 85 this.announcer = document.createElement('div'); 86 this.announcer.className = 'sr-only'; 87 this.announcer.setAttribute('role', 'status'); 88 this.announcer.setAttribute('aria-live', 'assertive'); 89 this.announcer.setAttribute('aria-atomic', 'true'); 90 this.announcer.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;'; 91 document.body.appendChild(this.announcer); 92 } 93 94 /** 95 * Announce message to screen readers 96 * @param {string} message - Message to announce 97 */ 98 announce(message) { 99 if (this.announcer) { 100 this.announcer.textContent = ''; 101 setTimeout(() => { 102 this.announcer.textContent = message; 103 }, 100); 104 } 105 } 106 107 /** 108 * Bind form events 109 */ 110 bindEvents() { 111 // Form submission 112 if (config.ajaxSubmit) { 113 this.form.addEventListener('submit', this.handleSubmit.bind(this)); 114 } 115 116 // Real-time validation 117 const inputs = this.form.querySelectorAll('input, textarea, select'); 118 inputs.forEach(input => { 119 input.addEventListener('blur', this.validateField.bind(this)); 120 input.addEventListener('input', this.clearFieldError.bind(this)); 121 }); 122 123 // Enhanced keyboard navigation for radio/checkbox groups 124 const groups = this.form.querySelectorAll('.min-form__radio-group, .min-form__checkbox-group'); 125 groups.forEach(group => { 126 group.addEventListener('keydown', this.handleGroupKeydown.bind(this)); 127 }); 128 } 129 130 /** 131 * Enhance accessibility features 132 */ 133 enhanceAccessibility() { 134 // Add aria-required to required fields 135 const requiredFields = this.form.querySelectorAll('[required]'); 136 requiredFields.forEach(field => { 137 field.setAttribute('aria-required', 'true'); 138 139 // Find and update label 140 const label = this.findLabel(field); 141 if (label && !label.querySelector('.sr-only')) { 142 const srText = document.createElement('span'); 143 srText.className = 'sr-only'; 144 srText.textContent = ' (Pflichtfeld)'; 145 label.appendChild(srText); 146 } 147 }); 148 149 // Connect error messages with aria-describedby 150 const errorMessages = this.form.querySelectorAll('.min-form__error-message');
151 errorMessages.forEach(msg => { 152 const field = msg.closest('.min-form__field'); 153 if (field) { 154 const input = field.querySelector('input, textarea, select'); 155 if (input && !msg.id) { 156 msg.id = 'error-' + (input.id || input.name || Math.random().toString(36).substr(2, 9)); 157 input.setAttribute('aria-describedby', msg.id); 158 } 159 } 160 }); 161 } 162 163 /** 164 * Find label for a field 165 * @param {HTMLElement} field - Input field 166 * @returns {HTMLElement|null} Label element 167 */ 168 findLabel(field) { 169 // Try for attribute 170 if (field.id) { 171 const label = document.querySelector(`label[for="${field.id}"]`); 172 if (label) return label; 173 } 174 175 // Try parent label 176 const parentLabel = field.closest('label'); 177 if (parentLabel) return parentLabel; 178 179 // Try sibling label 180 const wrapper = field.closest('.min-form__field'); 181 if (wrapper) { 182 return wrapper.querySelector('.min-form__label'); 183 } 184 185 return null; 186 } 187 188 /** 189 * Handle form submission 190 * @param {Event} e - Submit event 191 */ 192 async handleSubmit(e) { 193 e.preventDefault(); 194 195 if (this.isSubmitting) return; 196 197 // Validate form 198 if (!this.validateForm()) { 199 this.showMessage(config.messages.validation, 'error'); 200 this.announce(config.messages.validation); 201 return; 202 } 203 204 this.isSubmitting = true; 205 this.setLoadingState(true); 206 this.announce(config.messages.sending); 207 208 try { 209 const formData = new FormData(this.form); 210 const response = await fetch(this.form.action, { 211 method: 'POST', 212 body: formData, 213 headers: { 214 'X-Requested-With': 'XMLHttpRequest' 215 } 216 }); 217 218 if (!response.ok) { 219 throw new Error('Network response was not ok'); 220 } 221 222 const result = await response.text(); 223 224 // Check if response contains success indicators 225 if (this.isSuccessResponse(result)) { 226 this.handleSuccess(result); 227 } else { 228 // Response might contain validation errors 229 this.handleValidationErrors(result); 230 } 231 } catch (error) { 232 console.error('Form submission error:', error); 233 this.handleError(error); 234 } finally { 235 this.isSubmitting = false; 236 this.setLoadingState(false); 237 } 238 } 239 240 /** 241 * Check if response indicates success 242 * @param {string} response - Server response 243 * @returns {boolean} 244 */ 245 isSuccessResponse(response) { 246 // Look for typical powermail success indicators 247 return response.includes('min-form__success-message') || 248 response.includes('powermail_confirmation') || 249 response.includes('tx-powermail-pi1-thx') || 250 !response.includes('error'); 251 } 252 253 /** 254 * Handle successful submission 255 * @param {string} response - Server response 256 */ 257 handleSuccess(response) { 258 // Try to extract success message from response 259 const tempDiv = document.createElement('div'); 260 tempDiv.innerHTML = response; 261 const successMsg = tempDiv.querySelector('.min-form__success-message, .powermail_confirmation'); 262 263 if (successMsg) { 264 this.showMessage(successMsg.innerHTML, 'success', true); 265 } else { 266 this.showMessage(config.messages.success, 'success'); 267 } 268 269 this.form.classList.add(config.classes.submitted); 270 this.announce(config.messages.success); 271 272 // Optionally hide the form 273 const formContent = this.form.querySelector('.min-form__content'); 274 if (formContent) { 275 formContent.style.display = 'none'; 276 } 277 278 // Reset form 279 this.form.reset(); 280 this.clearAllErrors(); 281 } 282 283 /** 284 * Handle validation errors from server 285 * @param {string} response - Server response with errors 286 */ 287 handleValidationErrors(response) { 288 const tempDiv = document.createElement('div'); 289 tempDiv.innerHTML = response; 290 291 // Find error messages in response 292 const errors = tempDiv.querySelectorAll('.min-form__error-message, .powermail_error'); 293 294 if (errors.length > 0) { 295 // Update form with error messages 296 this.form.innerHTML = response; 297 this.bindEvents(); 298 this.enhanceAccessibility(); 299 300 // Focus first error 301 const firstError = this.form.querySelector('.min-form__field--error input, .min-form__field--error textarea, .min-form__field--error select'); 302 if (firstError && config.focusOnError) { 303 firstError.focus(); 304 } 305
306 this.announce(config.messages.validation); 307 } else { 308 this.handleError(new Error('Unknown validation error')); 309 } 310 } 311 312 /** 313 * Handle submission error 314 * @param {Error} error - Error object 315 */ 316 handleError(error) { 317 this.showMessage(config.messages.error, 'error'); 318 this.announce(config.messages.error); 319 console.error('Powermail error:', error); 320 } 321 322 /** 323 * Set loading state 324 * @param {boolean} loading - Loading state 325 */ 326 setLoadingState(loading) { 327 if (loading) { 328 this.form.classList.add(config.classes.loading); 329 if (this.submitButton) { 330 this.submitButton.disabled = true; 331 this.submitButton.setAttribute('aria-busy', 'true'); 332 } 333 } else { 334 this.form.classList.remove(config.classes.loading); 335 if (this.submitButton) { 336 this.submitButton.disabled = false; 337 this.submitButton.removeAttribute('aria-busy'); 338 } 339 } 340 } 341 342 /** 343 * Show message 344 * @param {string} message - Message text 345 * @param {string} type - Message type (success, error) 346 * @param {boolean} isHtml - Whether message is HTML 347 */ 348 showMessage(message, type, isHtml = false) { 349 if (isHtml) { 350 this.messageContainer.innerHTML = message; 351 } else { 352 this.messageContainer.innerHTML = ` 353 <div class="min-form__${type}-message"> 354 ${this.escapeHtml(message)} 355 </div> 356 `; 357 } 358 359 this.messageContainer.style.display = 'block'; 360 361 if (config.scrollToError && type === 'error') { 362 this.scrollToElement(this.messageContainer); 363 } 364 } 365 366 /** 367 * Escape HTML characters 368 * @param {string} text - Text to escape 369 * @returns {string} Escaped text 370 */ 371 escapeHtml(text) { 372 const div = document.createElement('div'); 373 div.textContent = text; 374 return div.innerHTML; 375 } 376 377 /** 378 * Validate entire form 379 * @returns {boolean} Form validity 380 */ 381 validateForm() { 382 let isValid = true; 383 const fields = this.form.querySelectorAll('input, textarea, select'); 384 385 fields.forEach(field => { 386 if (!this.validateField({ target: field }, false)) { 387 isValid = false; 388 } 389 }); 390 391 // Focus first invalid field 392 if (!isValid && config.focusOnError) { 393 const firstInvalid = this.form.querySelector('.min-form__field--error input, .min-form__field--error textarea, .min-form__field--error select'); 394 if (firstInvalid) { 395 if (config.scrollToError) { 396 this.scrollToElement(firstInvalid); 397 } 398 firstInvalid.focus(); 399 } 400 } 401 402 return isValid; 403 } 404 405 /** 406 * Validate single field 407 * @param {Event} e - Blur event 408 * @param {boolean} showMessage - Whether to show error message 409 * @returns {boolean} Field validity 410 */ 411 validateField(e, showMessage = true) { 412 const field = e.target; 413 const wrapper = field.closest('.min-form__field'); 414 415 if (!wrapper) return true; 416 417 // Skip hidden fields 418 if (field.type === 'hidden' || field.offsetParent === null) { 419 return true; 420 } 421 422 let isValid = true; 423 let errorMessage = ''; 424 425 // Check required 426 if (field.required && !field.value.trim()) { 427 isValid = false; 428 errorMessage = 'Dieses Feld ist erforderlich.'; 429 } 430 431 // Check pattern 432 if (isValid && field.pattern && field.value) { 433 const regex = new RegExp(field.pattern); 434 if (!regex.test(field.value)) { 435 isValid = false;
436 errorMessage = field.title || 'Bitte geben Sie einen gueltigen Wert ein.'; 437 } 438 } 439 440 // Check email 441 if (isValid && field.type === 'email' && field.value) { 442 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 443 if (!emailRegex.test(field.value)) { 444 isValid = false; 445 errorMessage = 'Bitte geben Sie eine gueltige E-Mail-Adresse ein.'; 446 } 447 } 448 449 // Check min/max length 450 if (isValid && field.minLength && field.value.length < field.minLength) { 451 isValid = false; 452 errorMessage = `Mindestens ${field.minLength} Zeichen erforderlich.`; 453 } 454 455 if (isValid && field.maxLength && field.value.length > field.maxLength) { 456 isValid = false; 457 errorMessage = `Maximal ${field.maxLength} Zeichen erlaubt.`; 458 } 459 460 // Update UI 461 if (!isValid) { 462 this.setFieldError(wrapper, field, errorMessage, showMessage); 463 } else { 464 this.clearFieldErrorElement(wrapper, field); 465 } 466 467 return isValid; 468 } 469 470 /** 471 * Set field error state 472 * @param {HTMLElement} wrapper - Field wrapper 473 * @param {HTMLElement} field - Input field 474 * @param {string} message - Error message 475 * @param {boolean} showMessage - Whether to show error message 476 */ 477 setFieldError(wrapper, field, message, showMessage = true) { 478 wrapper.classList.add(config.classes.error); 479 wrapper.classList.remove(config.classes.success); 480 481 field.setAttribute('aria-invalid', 'true'); 482 483 if (showMessage) { 484 let errorEl = wrapper.querySelector('.min-form__error-message'); 485 if (!errorEl) { 486 errorEl = document.createElement('div'); 487 errorEl.className = 'min-form__error-message'; 488 errorEl.id = 'error-' + (field.id || field.name || Math.random().toString(36).substr(2, 9)); 489 wrapper.appendChild(errorEl); 490 } 491 492 errorEl.textContent = message; 493 field.setAttribute('aria-describedby', errorEl.id); 494 } 495 } 496 497 /** 498 * Clear error on input 499 * @param {Event} e - Input event 500 */ 501 clearFieldError(e) { 502 const field = e.target; 503 const wrapper = field.closest('.min-form__field'); 504 505 if (wrapper && wrapper.classList.contains(config.classes.error)) { 506 // Re-validate on input 507 if (field.value.trim()) { 508 this.validateField(e, false); 509 } 510 } 511 } 512 513 /** 514 * Clear field error element 515 * @param {HTMLElement} wrapper - Field wrapper 516 * @param {HTMLElement} field - Input field 517 */ 518 clearFieldErrorElement(wrapper, field) { 519 wrapper.classList.remove(config.classes.error); 520 wrapper.classList.add(config.classes.success); 521 522 field.removeAttribute('aria-invalid'); 523 524 const errorEl = wrapper.querySelector('.min-form__error-message'); 525 if (errorEl) { 526 field.removeAttribute('aria-describedby'); 527 errorEl.remove(); 528 } 529 } 530 531 /** 532 * Clear all errors 533 */ 534 clearAllErrors() { 535 const errorWrappers = this.form.querySelectorAll(`.${config.classes.error}`); 536 errorWrappers.forEach(wrapper => { 537 wrapper.classList.remove(config.classes.error); 538 const errorEl = wrapper.querySelector('.min-form__error-message'); 539 if (errorEl) errorEl.remove(); 540 }); 541 542 const invalidFields = this.form.querySelectorAll('[aria-invalid="true"]'); 543 invalidFields.forEach(field => { 544 field.removeAttribute('aria-invalid'); 545 field.removeAttribute('aria-describedby'); 546 }); 547 } 548 549 /** 550 * Handle keyboard navigation in radio/checkbox groups 551 * @param {KeyboardEvent} e - Keydown event 552 */ 553 handleGroupKeydown(e) { 554 const inputs = Array.from(e.currentTarget.querySelectorAll('input[type="radio"], input[type="checkbox"]'));
555 const currentIndex = inputs.indexOf(document.activeElement); 556 557 if (currentIndex === -1) return; 558 559 let newIndex; 560 561 switch (e.key) { 562 case 'ArrowDown': 563 case 'ArrowRight': 564 e.preventDefault(); 565 newIndex = (currentIndex + 1) % inputs.length; 566 inputs[newIndex].focus(); 567 if (inputs[newIndex].type === 'radio') { 568 inputs[newIndex].checked = true; 569 } 570 break; 571 572 case 'ArrowUp': 573 case 'ArrowLeft': 574 e.preventDefault(); 575 newIndex = (currentIndex - 1 + inputs.length) % inputs.length; 576 inputs[newIndex].focus(); 577 if (inputs[newIndex].type === 'radio') { 578 inputs[newIndex].checked = true; 579 } 580 break; 581 } 582 } 583 584 /** 585 * Scroll to element 586 * @param {HTMLElement} element - Element to scroll to 587 */ 588 scrollToElement(element) { 589 const rect = element.getBoundingClientRect(); 590 const scrollTop = window.pageYOffset || document.documentElement.scrollTop; 591 const top = rect.top + scrollTop - config.scrollOffset; 592 593 window.scrollTo({ 594 top: top, 595 behavior: 'smooth' 596 }); 597 } 598 } 599 600 /** 601 * PowermailManager - Handles multiple forms on page 602 */ 603 class PowermailManager { 604 constructor() { 605 this.forms = []; 606 this.init(); 607 } 608 609 init() { 610 // Initialize existing forms 611 this.initForms(); 612 613 // Watch for dynamically added forms 614 this.observeMutations(); 615 } 616 617 initForms() { 618 const forms = document.querySelectorAll('.min-form, .tx-powermail form'); 619 forms.forEach(form => { 620 if (!form.dataset.mincoreInitialized) { 621 this.forms.push(new MincorePowermail(form)); 622 form.dataset.mincoreInitialized = 'true'; 623 } 624 }); 625 } 626 627 observeMutations() { 628 const observer = new MutationObserver((mutations) => { 629 mutations.forEach((mutation) => { 630 mutation.addedNodes.forEach((node) => { 631 if (node.nodeType === 1) { 632 if (node.matches && (node.matches('.min-form') || node.matches('.tx-powermail form'))) { 633 if (!node.dataset.mincoreInitialized) { 634 this.forms.push(new MincorePowermail(node)); 635 node.dataset.mincoreInitialized = 'true'; 636 } 637 } 638 639 // Check descendants 640 const nestedForms = node.querySelectorAll && node.querySelectorAll('.min-form, .tx-powermail form'); 641 if (nestedForms) { 642 nestedForms.forEach(form => { 643 if (!form.dataset.mincoreInitialized) { 644 this.forms.push(new MincorePowermail(form)); 645 form.dataset.mincoreInitialized = 'true'; 646 } 647 }); 648 } 649 } 650 }); 651 }); 652 }); 653 654 observer.observe(document.body, { 655 childList: true, 656 subtree: true 657 }); 658 } 659 } 660 661 // Initialize when DOM is ready 662 if (document.readyState === 'loading') { 663 document.addEventListener('DOMContentLoaded', () => new PowermailManager()); 664 } else { 665 new PowermailManager(); 666 } 667 668 // Export for external access 669 window.MincorePowermail = MincorePowermail; 670 window.PowermailManager = PowermailManager; 671 672})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.