1/** 2 * MX Cookie Consent Manager 3 * GDPR v4 & Google Consent Mode v2 Compatible 4 * 5 * @version 2.1.0 6 * @license MIT 7 * @author MaxiCMS 8 * 9 * NOTE: Google Consent Mode v2 defaults are set inline in the template 10 * to ensure they load before gtag.js. This script handles the UI and 11 * consent updates. 12 * 13 * Features: 14 * - Google Consent Mode v2 (GCM v2) compliant 15 * - GDPR v4 compliant with granular consent 16 * - Cloudflare Zaraz integration 17 * - Conditional script/iframe loading 18 * - Cookie + localStorage fallback 19 * - Accessible (ARIA, keyboard navigation) 20 * - No dependencies (vanilla JS) 21 */ 22 23'use strict'; 24 25// ============================================================================ 26// Cookie Consent Manager 27// ============================================================================ 28 29/** 30 * @typedef {Object} ConsentState 31 * @property {boolean} necessary - Always true, required for site functionality 32 * @property {boolean} functional - Functional cookies (preferences, language) 33 * @property {boolean} statistics - Analytics cookies (Google Analytics, etc.) 34 * @property {boolean} marketing - Marketing cookies (ads, remarketing) 35 */ 36 37/** 38 * @typedef {Object} ManagerConfig 39 * @property {boolean} forceModal - Force modal display instead of banner 40 * @property {string|null} cfStatistics - Cloudflare Zaraz statistics event name 41 * @property {string|null} cfMarketing - Cloudflare Zaraz marketing event name 42 */ 43 44/** 45 * Cookie Consent Manager Class 46 * Handles GDPR-compliant cookie consent with Google Consent Mode v2 integration 47 */ 48class CookieConsentManager { 49 // =========================================================================== 50 // Static Configuration 51 // =========================================================================== 52 53 /** 54 * Default configuration values 55 * @type {Object} 56 */ 57 static CONFIG = Object.freeze({ 58 STORAGE_KEY: 'mx_cookie_consent_v2', 59 STORAGE_DAYS: 365, 60 ANIMATION_DELAY_MS: 50, 61 TRANSITION_DURATION_MS: 300, 62 VERSION: '2.1.0' 63 }); 64 65 /** 66 * Default consent state (GDPR compliant - deny all optional) 67 * @type {ConsentState} 68 */ 69 static DEFAULT_CONSENT = Object.freeze({ 70 necessary: true, 71 functional: false, 72 statistics: false, 73 marketing: false 74 }); 75 76 /** 77 * Full consent state (accept all) 78 * @type {ConsentState} 79 */ 80 static FULL_CONSENT = Object.freeze({ 81 necessary: true, 82 functional: true, 83 statistics: true, 84 marketing: true 85 }); 86 87 /** 88 * DOM element IDs 89 * @type {Object} 90 */ 91 static ELEMENT_IDS = Object.freeze({ 92 CONTAINER: 'mx_cookie_container', 93 BANNER: 'mx_cookie_banner', 94 MODAL: 'mx_cookie_modal', 95 RESET: 'mx_cookie_reset', 96 ACCEPT: 'mx_cookie_accept', 97 REJECT: 'mx_cookie_reject', 98 SETTINGS: 'mx_cookie_settings', 99 CLOSE: 'mx_cookie_close', 100 ACCEPT_ALL: 'mx_cookie_accept_all', 101 REJECT_ALL: 'mx_cookie_reject_all', 102 SAVE: 'mx_cookie_save', 103 FUNCTIONAL: 'mx_cookie_functional', 104 STATISTICS: 'mx_cookie_statistics', 105 MARKETING: 'mx_cookie_marketing' 106 }); 107 108 /** 109 * Legacy DOM element IDs (for backwards compatibility with old template) 110 * @type {Object} 111 */ 112 static LEGACY_ELEMENT_IDS = Object.freeze({ 113 CONTAINER: 'mx_cookie', 114 ALLOW: 'mx_cookie_allow', 115 DISALLOW: 'mx_cookie_disallow' 116 }); 117 118 /** 119 * CSS classes used by the manager 120 * @type {Object} 121 */ 122 static CSS_CLASSES = Object.freeze({ 123 SHOW: 'mx_show', 124 WARNING: 'mx_no_cookies' 125 }); 126 127 // =========================================================================== 128 // Instance Properties 129 // =========================================================================== 130 131 /** @type {Object<string, HTMLElement|null>} */ 132 #elements = {}; 133 134 /** @type {ManagerConfig} */ 135 #config = {}; 136 137 /** @type {boolean} */ 138 #useCookies = true; 139 140 /** @type {boolean} */ 141 #initialized = false; 142 143 // =========================================================================== 144 // Constructor & Initialization 145 // =========================================================================== 146 147 /** 148 * Create a new CookieConsentManager instance 149 */ 150 constructor() { 151 this.#useCookies = this.#detectCookieSupport(); 152 this.#initialize(); 153 } 154 155 /** 156 * Initialize the consent manager 157 * @private 158 */ 159 #initialize() { 160 const container = document.getElementById(CookieConsentManager.ELEMENT_IDS.CONTAINER); 161 162 if (!container) { 163 // Try legacy mode for backwards compatibility with old template 164 if (this.#initializeLegacy()) { 165 return; // Legacy mode active 166 } 167 console.warn('CookieConsent: Container element not found'); 168 return; 169 } 170 171 try { 172 // Migrate old consent format if needed (before applying stored consent) 173 this.#migrateOldConsent(); 174 this.#removeICheck(); 175 this.#cacheElements(); 176 this.#loadConfiguration(container); 177 this.#bindEventListeners();
178 this.#applyStoredConsent(); 179 this.#updateUIState(); 180 this.#exposePublicAPI(); 181 this.#initialized = true; 182 } catch (error) { 183 console.error('CookieConsent: Initialization failed', error); 184 } 185 } 186 187 /** 188 * Initialize legacy mode for backwards compatibility with old Cookie module template 189 * Supports old element IDs: #mx_cookie, #mx_cookie_allow, #mx_cookie_disallow 190 * @returns {boolean} True if legacy mode was activated, false otherwise 191 * @private 192 */ 193 #initializeLegacy() { 194 const legacyContainer = document.getElementById(CookieConsentManager.LEGACY_ELEMENT_IDS.CONTAINER); 195 const allowBtn = document.getElementById(CookieConsentManager.LEGACY_ELEMENT_IDS.ALLOW); 196 const disallowBtn = document.getElementById(CookieConsentManager.LEGACY_ELEMENT_IDS.DISALLOW); 197 198 if (!legacyContainer) { 199 return false; 200 } 201 202 // Migrate old consent if needed 203 this.#migrateOldConsent(); 204 205 // Check if consent already given 206 const consent = this.getConsent(); 207 if (consent) { 208 legacyContainer.style.display = 'none'; 209 document.documentElement.classList.remove('mx_cookie'); 210 this.#exposePublicAPI(); 211 this.#initialized = true; 212 return true; 213 } 214 215 // Bind legacy button handlers 216 if (allowBtn) { 217 allowBtn.addEventListener('click', (e) => { 218 e.preventDefault(); 219 this.#setStorage({ ...CookieConsentManager.FULL_CONSENT }); 220 legacyContainer.style.display = 'none'; 221 document.documentElement.classList.remove('mx_cookie'); 222 window.location.reload(); 223 }); 224 } 225 226 if (disallowBtn) { 227 disallowBtn.addEventListener('click', (e) => { 228 e.preventDefault(); 229 this.#setStorage({ ...CookieConsentManager.DEFAULT_CONSENT }); 230 legacyContainer.style.display = 'none'; 231 document.documentElement.classList.remove('mx_cookie'); 232 window.location.reload(); 233 }); 234 } 235 236 this.#exposePublicAPI(); 237 this.#initialized = true; 238 return true; 239 } 240 241 /** 242 * Remove iCheck styling from cookie toggle checkboxes 243 * iCheck library wraps checkboxes with custom elements that interfere with our toggle switches 244 * @private 245 */ 246 #removeICheck() { 247 const container = document.getElementById(CookieConsentManager.ELEMENT_IDS.CONTAINER); 248 if (!container) return; 249 250 // Find all checkboxes in the cookie container 251 const checkboxes = container.querySelectorAll('input[type="checkbox"]'); 252 253 checkboxes.forEach(checkbox => { 254 // Remove iCheck helper elements 255 const parent = checkbox.parentElement; 256 if (parent) { 257 const iCheckHelper = parent.querySelector('.iCheck-helper'); 258 if (iCheckHelper) { 259 iCheckHelper.remove(); 260 } 261 } 262 263 // Reset inline styles that iCheck may have added 264 checkbox.style.position = ''; 265 checkbox.style.opacity = ''; 266 267 // If iCheck jQuery plugin is available, destroy it 268 if (typeof jQuery !== 'undefined' && jQuery.fn.iCheck) { 269 try { 270 jQuery(checkbox).iCheck('destroy'); 271 } catch (e) { 272 // iCheck destroy may fail silently 273 } 274 } 275 }); 276 } 277 278 /** 279 * Cache DOM elements for performance 280 * @private 281 */ 282 #cacheElements() { 283 const ids = CookieConsentManager.ELEMENT_IDS; 284 285 Object.entries(ids).forEach(([key, id]) => { 286 this.#elements[key.toLowerCase()] = document.getElementById(id); 287 }); 288 289 // Cache warning placeholder elements 290 this.#elements.warnings = document.querySelectorAll( 291 `.${CookieConsentManager.CSS_CLASSES.WARNING}` 292 ); 293 } 294 295 /** 296 * Load configuration from container data attributes 297 * @param {HTMLElement} container - Container element 298 * @private 299 */ 300 #loadConfiguration(container) { 301 this.#config = { 302 forceModal: container.dataset.modal === 'true', 303 cfStatistics: container.dataset.cfStatistics || null, 304 cfMarketing: container.dataset.cfMarketing || null 305 }; 306 } 307 308 // =========================================================================== 309 // Storage Management 310 // =========================================================================== 311 312 /** 313 * Detect if cookies are supported in the browser 314 * @returns {boolean} True if cookies are supported 315 * @private 316 */ 317 #detectCookieSupport() { 318 try { 319 const testKey = '__mx_cookie_test__'; 320 document.cookie = `${testKey}=1;SameSite=Lax`; 321 const supported = document.cookie.includes(`${testKey}=1`); 322 document.cookie = `${testKey}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;SameSite=Lax`; 323 return supported; 324 } catch { 325 return false;
326 } 327 } 328 329 /** 330 * Legacy cookie name used by PHP/Twig for cookie.allowed 331 * @type {string} 332 */ 333 static LEGACY_ALLOWED_KEY = 'mx_cookie_allowed'; 334 335 /** 336 * Store consent data in cookie or localStorage 337 * @param {ConsentState} value - Consent state to store 338 * @private 339 */ 340 #setStorage(value) { 341 const key = CookieConsentManager.CONFIG.STORAGE_KEY; 342 const data = { 343 ...value, 344 version: CookieConsentManager.CONFIG.VERSION, 345 timestamp: Date.now() 346 }; 347 348 try { 349 const encoded = encodeURIComponent(JSON.stringify(data)); 350 351 if (this.#useCookies) { 352 const days = CookieConsentManager.CONFIG.STORAGE_DAYS; 353 const expires = new Date(Date.now() + days * 86400000).toUTCString(); 354 const secure = location.protocol === 'https:' ? ';Secure' : ''; 355 document.cookie = `${key}=${encoded};path=/;expires=${expires};SameSite=Lax${secure}`; 356 // Sync legacy cookie so PHP/Twig cookie.allowed is true on next page load 357 document.cookie = `${CookieConsentManager.LEGACY_ALLOWED_KEY}=1;path=/;expires=${expires};SameSite=Lax${secure}`; 358 } else { 359 localStorage.setItem(key, encoded); 360 localStorage.setItem(CookieConsentManager.LEGACY_ALLOWED_KEY, '1'); 361 } 362 } catch (error) { 363 console.error('CookieConsent: Failed to store consent', error); 364 } 365 } 366 367 /** 368 * Retrieve consent data from storage 369 * @returns {ConsentState|null} Stored consent state or null 370 * @private 371 */ 372 #getStorage() { 373 const key = CookieConsentManager.CONFIG.STORAGE_KEY; 374 375 try { 376 let value = null; 377 378 if (this.#useCookies) { 379 const match = document.cookie.match(new RegExp(`(^| )${key}=([^;]+)`)); 380 value = match ? decodeURIComponent(match[2]) : null; 381 } else { 382 const stored = localStorage.getItem(key); 383 value = stored ? decodeURIComponent(stored) : null; 384 } 385 386 if (!value) return null; 387 388 const parsed = JSON.parse(value); 389 390 // Return normalized consent object (strip metadata) 391 return { 392 necessary: true, 393 functional: Boolean(parsed.functional), 394 statistics: Boolean(parsed.statistics), 395 marketing: Boolean(parsed.marketing) 396 }; 397 } catch { 398 return null; 399 } 400 } 401 402 /** 403 * Clear consent data from storage 404 * @private 405 */ 406 #clearStorage() { 407 const key = CookieConsentManager.CONFIG.STORAGE_KEY; 408 409 try { 410 if (this.#useCookies) { 411 document.cookie = `${key}=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT;SameSite=Lax`; 412 document.cookie = `${CookieConsentManager.LEGACY_ALLOWED_KEY}=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT;SameSite=Lax`; 413 } 414 localStorage.removeItem(key); 415 localStorage.removeItem(CookieConsentManager.LEGACY_ALLOWED_KEY); 416 } catch (error) { 417 console.error('CookieConsent: Failed to clear consent', error); 418 } 419 } 420 421 /** 422 * Migrate old consent format (mx_cookie_allowed) to new format (mx_cookie_consent_v2) 423 * This ensures backwards compatibility with the old Cookie module 424 * @private 425 */ 426 #migrateOldConsent() { 427 // Check if old consent exists without new consent 428 const hasOldCookie = document.cookie.includes('mx_cookie_allowed=1'); 429 const hasNewCookie = document.cookie.includes(CookieConsentManager.CONFIG.STORAGE_KEY); 430 431 if (hasOldCookie && !hasNewCookie) { 432 // Migrate: old "allowed" = accept all in new format 433 this.#setStorage({ ...CookieConsentManager.FULL_CONSENT }); 434 } 435 } 436 437 // =========================================================================== 438 // Consent Management (Public API) 439 // =========================================================================== 440 441 /** 442 * Get current consent state 443 * @returns {ConsentState|null} Current consent state or null if not set 444 */ 445 getConsent() { 446 return this.#getStorage(); 447 } 448 449 /** 450 * Check if a specific consent type is granted 451 * @param {string} type - Consent type (necessary|functional|statistics|marketing) 452 * @returns {boolean} True if consent is granted 453 */ 454 hasConsent(type) { 455 const consent = this.getConsent(); 456 return consent ? consent[type] === true : type === 'necessary'; 457 } 458 459 /** 460 * Set consent state and apply changes 461 * @param {ConsentState} consent - New consent state 462 */ 463 setConsent(consent) { 464 const validatedConsent = this.#validateConsent(consent); 465 466 this.#setStorage(validatedConsent);
467 this.#applyGoogleConsentMode(validatedConsent); 468 this.#applyCloudflareZaraz(validatedConsent); 469 this.#processConsentElements(validatedConsent); 470 471 // Reload to apply consent changes fully 472 window.location.reload(); 473 } 474 475 /** 476 * Reset consent and reload page 477 */ 478 resetConsent() { 479 this.#clearStorage(); 480 window.location.reload(); 481 } 482 483 /** 484 * Accept all cookies 485 */ 486 acceptAll() { 487 this.setConsent({ ...this.#getAcceptAllConsent() }); 488 } 489 490 /** 491 * Reject all optional cookies (keep only necessary) 492 */ 493 rejectAll() { 494 this.setConsent({ ...CookieConsentManager.DEFAULT_CONSENT }); 495 } 496 497 /** 498 * Save current toggle selections from the modal 499 */ 500 savePreferences() { 501 const consent = { 502 necessary: true, 503 functional: this.#elements.functional?.checked ?? false, 504 statistics: this.#elements.statistics?.checked ?? false, 505 marketing: this.#elements.marketing?.checked ?? false 506 }; 507 508 this.setConsent(consent); 509 } 510 511 /** 512 * Show the settings modal 513 */ 514 showModal() { 515 const consent = this.getConsent() || CookieConsentManager.DEFAULT_CONSENT; 516 517 this.#updateToggles(consent); 518 this.#hideBanner(); 519 520 if (this.#elements.reset) { 521 this.#elements.reset.style.display = 'none'; 522 } 523 524 const modal = this.#elements.modal; 525 if (modal) { 526 modal.style.display = 'flex'; 527 modal.setAttribute('aria-hidden', 'false'); 528 529 // Focus management for accessibility 530 const firstFocusable = modal.querySelector('button, [tabindex="0"]'); 531 firstFocusable?.focus(); 532 533 setTimeout(() => { 534 modal.classList.add(CookieConsentManager.CSS_CLASSES.SHOW); 535 }, CookieConsentManager.CONFIG.ANIMATION_DELAY_MS); 536 } 537 538 // Hide close button if modal is forced and no consent yet 539 if (this.#elements.close) { 540 const hideClose = this.#config.forceModal && !this.getConsent(); 541 this.#elements.close.style.display = hideClose ? 'none' : ''; 542 } 543 } 544 545 // =========================================================================== 546 // Consent Validation & Application 547 // =========================================================================== 548 549 /** 550 * Validate and normalize consent object 551 * @param {Object} consent - Raw consent object 552 * @returns {ConsentState} Validated consent state 553 * @private 554 */ 555 #validateConsent(consent) { 556 if (!consent || typeof consent !== 'object') { 557 return { ...CookieConsentManager.DEFAULT_CONSENT }; 558 } 559 560 return { 561 necessary: true, // Always required 562 functional: Boolean(consent.functional), 563 statistics: Boolean(consent.statistics), 564 marketing: Boolean(consent.marketing) 565 }; 566 } 567 568 /** 569 * Apply stored consent on page load 570 * @private 571 */ 572 #applyStoredConsent() { 573 const consent = this.getConsent(); 574 575 if (consent) { 576 this.#applyGoogleConsentMode(consent); 577 this.#applyCloudflareZaraz(consent); 578 this.#processConsentElements(consent); 579 } 580 } 581 582 // =========================================================================== 583 // Google Consent Mode v2 584 // =========================================================================== 585 586 /** 587 * Update Google Consent Mode with current consent state 588 * @param {ConsentState} consent - Current consent state 589 * @private 590 */ 591 #applyGoogleConsentMode(consent) { 592 if (typeof window.gtag !== 'function') return; 593 594 try { 595 window.gtag('consent', 'update', { 596 functionality_storage: consent.functional ? 'granted' : 'denied', 597 security_storage: 'granted', 598 ad_storage: consent.marketing ? 'granted' : 'denied', 599 ad_user_data: consent.marketing ? 'granted' : 'denied', 600 ad_personalization: consent.marketing ? 'granted' : 'denied', 601 analytics_storage: consent.statistics ? 'granted' : 'denied' 602 }); 603 } catch (error) { 604 console.warn('CookieConsent: Failed to update Google Consent Mode', error); 605 } 606 } 607 608 // =========================================================================== 609 // Cloudflare Zaraz Integration 610 // =========================================================================== 611 612 /** 613 * Trigger Cloudflare Zaraz events based on consent 614 * @param {ConsentState} consent - Current consent state 615 * @private 616 */ 617 #applyCloudflareZaraz(consent) { 618 if (typeof window.zaraz === 'undefined') return; 619 620 try { 621 if (consent.statistics && this.#config.cfStatistics) { 622 window.zaraz.track(this.#config.cfStatistics); 623 } 624 625 if (consent.marketing && this.#config.cfMarketing) { 626 window.zaraz.track(this.#config.cfMarketing); 627 } 628 } catch (error) { 629 console.warn('CookieConsent: Failed to trigger Zaraz events', error); 630 } 631 } 632 633 // ===========================================================================
634 // Conditional Script/Element Loading 635 // =========================================================================== 636 637 /** 638 * Process elements with data-consent attribute 639 * @param {ConsentState} consent - Current consent state 640 * @private 641 */ 642 #processConsentElements(consent) { 643 const elements = document.querySelectorAll('[data-consent]'); 644 645 elements.forEach(element => { 646 const type = element.dataset.consent; 647 const isAllowed = consent[type] === true; 648 649 if (isAllowed) { 650 this.#activateElement(element); 651 } else if (element.tagName === 'SCRIPT') { 652 // Remove blocked scripts 653 element.remove(); 654 } 655 }); 656 } 657 658 /** 659 * Activate a consent-gated element 660 * @param {HTMLElement} element - Element to activate 661 * @private 662 */ 663 #activateElement(element) { 664 const { src: dataSrc } = element.dataset; 665 666 // Handle iframe src 667 if (dataSrc && element.tagName === 'IFRAME' && !element.src) { 668 element.src = dataSrc; 669 return; 670 } 671 672 // Handle script injection 673 if (element.tagName === 'SCRIPT') { 674 this.#injectScript(element); 675 } 676 } 677 678 /** 679 * Inject a script element into the DOM 680 * @param {HTMLScriptElement} original - Original script element 681 * @private 682 */ 683 #injectScript(original) { 684 const src = original.dataset.src; 685 686 // Prevent duplicate injection 687 if (src && document.querySelector(`script[src="${src}"]`)) { 688 return; 689 } 690 691 const script = document.createElement('script'); 692 693 if (src) { 694 script.src = src; 695 if (original.hasAttribute('defer')) script.defer = true; 696 if (original.hasAttribute('async')) script.async = true; 697 } else { 698 script.textContent = original.textContent; 699 } 700 701 script.type = original.type || 'text/javascript'; 702 document.head.appendChild(script); 703 } 704 705 // =========================================================================== 706 // UI Management 707 // =========================================================================== 708 709 /** 710 * Update UI based on current consent state 711 * @private 712 */ 713 #updateUIState() { 714 const consent = this.getConsent(); 715 716 // No consent given yet - show banner or modal 717 if (!consent) { 718 if (this.#config.forceModal) { 719 this.showModal(); 720 } else { 721 this.#showBanner(); 722 } 723 return; 724 } 725 726 // Consent already given - show reset button 727 this.#hideBanner(); 728 this.#hideModal(); 729 this.#showReset(); 730 731 if (this.#elements.close) { 732 this.#elements.close.style.display = ''; 733 } 734 } 735 736 /** 737 * Get consent state for "Accept all" based on which categories are enabled (have a toggle in the DOM). 738 * Only grants consent for categories that are present; disabled categories stay false. 739 * @returns {ConsentState} Consent state for accept-all 740 * @private 741 */ 742 #getAcceptAllConsent() { 743 const { functional, statistics, marketing } = this.#elements; 744 return { 745 necessary: true, 746 functional: Boolean(functional), 747 statistics: Boolean(statistics), 748 marketing: Boolean(marketing) 749 }; 750 } 751 752 /** 753 * Update toggle checkboxes to reflect consent state 754 * @param {ConsentState} consent - Consent state 755 * @private 756 */ 757 #updateToggles(consent) { 758 const { functional, statistics, marketing } = this.#elements; 759 760 if (functional) functional.checked = consent.functional; 761 if (statistics) statistics.checked = consent.statistics; 762 if (marketing) marketing.checked = consent.marketing; 763 } 764 765 /** 766 * Show the cookie banner 767 * @private 768 */ 769 #showBanner() { 770 const { banner, modal, reset } = this.#elements; 771 772 if (modal) modal.style.display = 'none'; 773 if (reset) reset.style.display = 'none'; 774 775 if (banner) { 776 banner.style.display = 'flex'; 777 banner.setAttribute('aria-hidden', 'false'); 778 779 setTimeout(() => { 780 banner.classList.add(CookieConsentManager.CSS_CLASSES.SHOW); 781 }, CookieConsentManager.CONFIG.ANIMATION_DELAY_MS); 782 } 783 } 784 785 /** 786 * Hide the cookie banner 787 * @private 788 */ 789 #hideBanner() { 790 const { banner } = this.#elements; 791 792 if (!banner) return; 793 794 banner.classList.remove(CookieConsentManager.CSS_CLASSES.SHOW); 795 banner.setAttribute('aria-hidden', 'true'); 796 797 setTimeout(() => { 798 banner.style.display = 'none'; 799 }, CookieConsentManager.CONFIG.TRANSITION_DURATION_MS); 800 } 801 802 /** 803 * Hide the settings modal 804 * @private 805 */ 806 #hideModal() { 807 const { modal } = this.#elements; 808 809 if (!modal) return; 810 811 modal.classList.remove(CookieConsentManager.CSS_CLASSES.SHOW); 812 modal.setAttribute('aria-hidden', 'true'); 813 814 setTimeout(() => { 815 modal.style.display = 'none'; 816 }, CookieConsentManager.CONFIG.TRANSITION_DURATION_MS); 817 } 818 819 /** 820 * Show the reset/settings button 821 * @private 822 */ 823 #showReset() { 824 const { reset } = this.#elements; 825 if (reset) reset.style.display = 'flex'; 826 } 827 828 // =========================================================================== 829 // Event Handling 830 // =========================================================================== 831 832 /** 833 * Bind all event listeners 834 * @private 835 */ 836 #bindEventListeners() { 837 const els = this.#elements; 838 839 // Banner buttons 840 els.accept?.addEventListener('click', this.#handleAccept.bind(this)); 841 els.reject?.addEventListener('click', this.#handleReject.bind(this)); 842 els.settings?.addEventListener('click', this.showModal.bind(this)); 843 844 // Modal buttons 845 els.accept_all?.addEventListener('click', this.#handleAccept.bind(this)); 846 els.reject_all?.addEventListener('click', this.#handleReject.bind(this)); 847 els.save?.addEventListener('click', this.#handleSave.bind(this)); 848 els.close?.addEventListener('click', this.#handleClose.bind(this)); 849 850 // Reset button 851 els.reset?.addEventListener('click', this.showModal.bind(this)); 852 853 // Warning placeholders (e.g., YouTube embeds) 854 els.warnings?.forEach(warnEl => { 855 warnEl.addEventListener('click', (event) => { 856 event.preventDefault(); 857 event.stopPropagation(); 858 if (typeof event.stopImmediatePropagation === 'function') { 859 event.stopImmediatePropagation(); 860 } 861 this.showModal(); 862 }); 863 }); 864 865 // Keyboard navigation 866 document.addEventListener('keydown', this.#handleKeyboard.bind(this)); 867 } 868 869 /** 870 * Handle accept button click 871 * @private 872 */ 873 #handleAccept() { 874 const consent = this.#getAcceptAllConsent(); 875 this.#updateToggles(consent); 876 877 setTimeout(() => { 878 this.setConsent({ ...consent }); 879 }, CookieConsentManager.CONFIG.TRANSITION_DURATION_MS); 880 } 881 882 /** 883 * Handle reject button click 884 * @private 885 */ 886 #handleReject() { 887 this.#updateToggles(CookieConsentManager.DEFAULT_CONSENT); 888 889 setTimeout(() => { 890 this.rejectAll(); 891 }, CookieConsentManager.CONFIG.TRANSITION_DURATION_MS); 892 } 893 894 /** 895 * Handle save preferences button click 896 * @private 897 */ 898 #handleSave() { 899 this.savePreferences(); 900 } 901 902 /** 903 * Handle close button click 904 * @private 905 */ 906 #handleClose() { 907 this.#hideModal(); 908 this.#updateUIState(); 909 } 910 911 /** 912 * Handle keyboard events (accessibility) 913 * @param {KeyboardEvent} event - Keyboard event 914 * @private 915 */ 916 #handleKeyboard(event) { 917 const { modal } = this.#elements; 918 const isModalVisible = modal?.classList.contains(CookieConsentManager.CSS_CLASSES.SHOW); 919 920 // Escape key closes modal (unless forced) 921 if (event.key === 'Escape' && isModalVisible && !this.#config.forceModal) { 922 event.preventDefault(); 923 this.#handleClose(); 924 } 925 926 // Tab trap within modal for accessibility 927 if (event.key === 'Tab' && isModalVisible) { 928 this.#handleTabTrap(event, modal); 929 } 930 } 931 932 /** 933 * Trap focus within modal for accessibility 934 * @param {KeyboardEvent} event - Keyboard event 935 * @param {HTMLElement} modal - Modal element 936 * @private 937 */ 938 #handleTabTrap(event, modal) { 939 const focusableSelector = 'button, [href], input, select, textarea, [tabindex]:not
939([tabindex="-1"])'; 940 const focusable = modal.querySelectorAll(focusableSelector); 941 const first = focusable[0]; 942 const last = focusable[focusable.length - 1]; 943 944 if (event.shiftKey && document.activeElement === first) { 945 event.preventDefault(); 946 last?.focus(); 947 } else if (!event.shiftKey && document.activeElement === last) { 948 event.preventDefault(); 949 first?.focus(); 950 } 951 } 952 953 // =========================================================================== 954 // Public API Exposure 955 // =========================================================================== 956 957 /** 958 * Expose public API on window.CookieConsent 959 * @private 960 */ 961 #exposePublicAPI() { 962 /** 963 * Public API for external access 964 * @namespace CookieConsent 965 */ 966 window.CookieConsent = Object.freeze({ 967 /** 968 * Get current consent state 969 * @returns {ConsentState|null} 970 */ 971 get: () => this.getConsent(), 972 973 /** 974 * Check if specific consent type is granted 975 * @param {string} type - Consent type 976 * @returns {boolean} 977 */ 978 has: (type) => this.hasConsent(type), 979 980 /** 981 * Set consent state 982 * @param {ConsentState} consent 983 */ 984 set: (consent) => this.setConsent(consent), 985 986 /** 987 * Reset consent 988 */ 989 reset: () => this.resetConsent(), 990 991 /** 992 * Accept all cookies 993 */ 994 acceptAll: () => this.acceptAll(), 995 996 /** 997 * Reject all optional cookies 998 */ 999 rejectAll: () => this.rejectAll(), 1000 1001 /** 1002 * Show settings modal 1003 */ 1004 showModal: () => this.showModal(), 1005 1006 /** 1007 * Version number 1008 */ 1009 version: CookieConsentManager.CONFIG.VERSION 1010 }); 1011 } 1012} 1013 1014// ============================================================================ 1015// Initialize on DOM Ready 1016// ============================================================================ 1017if (document.readyState === 'loading') { 1018 document.addEventListener('DOMContentLoaded', () => { 1019 new CookieConsentManager(); 1020 }); 1021} else { 1022 // DOM already loaded 1023 new CookieConsentManager(); 1024}
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.