1/** 2 * Spectra Modal Script 3 * 4 * Wrapped in an IIFE with a load guard to prevent errors when the script 5 * is inadvertently loaded multiple times (e.g., slider and modal blocks on same page). 6 */ 7( function() { 8 'use strict'; 9 10 // Prevent double execution if script is loaded multiple times. 11 if ( window.spectraModalScriptLoaded ) { 12 return; 13 } 14 window.spectraModalScriptLoaded = true; 15 16 // Constants 17 const CLOSE_BUTTON_RENDER_DELAY = 50; // Delay for close button to render 18 19 // Store modal-specific data 20 const modalHandlers = new Map(); 21 22/** 23 * Get the correct document element (handles iframe-based themes like Twenty Twenty-Five) 24 * This is critical for theme compatibility 25 */ 26function getDocumentElement() { 27 // For frontend, always use main document (Twenty Twenty-Five doesn't use iframe on frontend) 28 if ( !document.body.classList.contains( 'wp-admin' ) && !document.body.classList.contains( 'block-editor-page' ) ) { 29 return document; 30 } 31 32 // For editor, check for iframe 33 let document_element = document; 34 const getEditorIframe = document.querySelectorAll( 'iframe[name="editor-canvas"]' ); 35 36 if ( getEditorIframe?.length ) { 37 const iframeDocument = 38 getEditorIframe?.[ 0 ]?.contentWindow?.document || getEditorIframe?.[ 0 ]?.contentDocument; 39 if ( iframeDocument ) { 40 document_element = iframeDocument; 41 } 42 } 43 return document_element; 44} 45 46/** 47 * Attach global handlers for modal accessibility and interaction 48 * Need to attach to BOTH main document AND iframe for full coverage 49 */ 50function attachGlobalModalHandlers() { 51 // Always attach to main document (for trigger elements) 52 attachKeyboardHandlersToDocument( document ); 53 54 // Also attach to iframe document if it exists (for Twenty Twenty-Five theme) 55 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' ); 56 if ( iframe ) { 57 const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument; 58 if ( iframeDoc ) { 59 attachKeyboardHandlersToDocument( iframeDoc ); 60 } else { 61 // Iframe not loaded yet, wait for it 62 iframe.addEventListener( 'load', () => { 63 const iframeDocLoaded = iframe.contentWindow?.document || iframe.contentDocument; 64 if ( iframeDocLoaded ) { 65 attachKeyboardHandlersToDocument( iframeDocLoaded ); 66 } 67 } ); 68 } 69 } else { 70 // Try with delay to catch dynamically created iframes 71 setTimeout( () => { 72 const delayedIframe = document.querySelector( 'iframe[name="editor-canvas"]' ); 73 if ( delayedIframe ) { 74 const iframeDoc = delayedIframe.contentWindow?.document || delayedIframe.contentDocument; 75 if ( iframeDoc && !iframeDoc._spectraHandlersAttached ) { 76 attachKeyboardHandlersToDocument( iframeDoc ); 77 } 78 } 79 }, 1000 ); 80 } 81} 82 83/** 84 * Attach keyboard handlers to a specific document 85 * 86 * @param {Document} doc - The document to attach handlers to 87 */ 88function attachKeyboardHandlersToDocument( doc ) { 89 // Prevent duplicate attachment 90 if ( doc._spectraHandlersAttached ) { 91 return; 92 } 93 doc._spectraHandlersAttached = true; 94 95 96 // Global keyboard handler for modal triggers and close buttons 97 doc.addEventListener( 'keydown', ( e ) => { 98 // Only element targets carry classList/getAttribute. A keydown dispatched 99 // on the document itself (or a text node) has neither and used to throw here. 100 const target = e.target; 101 if ( ! target || typeof target.getAttribute !== 'function' || ! target.classList ) { 102 return; 103 } 104 105 // 1. Handle modal trigger elements (Enter/Space to open) 106 if ( ( target.classList.contains( 'modal-trigger-element' ) || 107 target.getAttribute( 'data-wp-on--click' ) === 'spectra/modal::actions.toggle' || 108 target.getAttribute( 'data-wp-on--click' ) === 'spectra/modal::actions.open' ) ) { 109 110 if ( e.key === 'Enter' || e.key === ' ' ) { 111 e.preventDefault(); 112 113 // Just trigger a click event - let the existing click handler do the work 114 target.click(); 115 } 116 } 117 118 // 2. Handle close button elements (Enter/Space to close) 119 else if ( target.classList.contains( 'spectra-modal-popup-close' ) || 120 target.getAttribute( 'data-wp-on--click' ) === 'spectra/modal::actions.close' ) { 121 122 if ( e.key === 'Enter' || e.key === ' ' ) { 123 e.preventDefault(); 124 125 // Just trigger a click event 126 target.click(); 127 } 128 } 129 } ); 130 131 // ESC key handler - attach to both documents to catch ESC from anywhere 132 doc.addEventListener( 'keydown', ( e ) => { 133 if ( e.key === 'Escape' ) { 134 // Look for active modal - the active class is on the popup element, not wrapper 135 let activeModal = document.querySelector( '.spectra-modal-popup.active' ); 136 137 // If not found in main document, check iframe 138 if ( !activeModal ) { 139 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' ); 140 if ( iframe ) { 141 const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument; 142 if ( iframeDoc ) { 143 activeModal = iframeDoc.querySelector( '.spectra-modal-popup.active' ); 144 } 145 } 146 } 147 148 if ( activeModal && activeModal.id ) { 149 // Check if ESC key is enabled for this modal 150 const escPress = activeModal.getAttribute( 'data-esc-press' ); 151 152 // Only close if ESC is explicitly enabled. 153 if ( escPress !== 'true' ) { 154 return; // Don't close if ESC is not explicitly enabled 155 } 156 157 e.preventDefault(); 158 e.stopPropagation(); 159 160 // Use closeModal function if available, otherwise manual close 161 if ( typeof closeModal === 'function' ) { 162 closeModal( activeModal.id ); 163 } else { 164 activeModal.classList.remove( 'active' ); 165 } 166 } 167 } 168 } ); 169 170 // Note: Overlay click is handled by attachOverlayClickClose function 171 // This global handler is kept minimal to avoid conflicts 172} 173 174// Attach global modal handlers immediately when script loads 175// This ensures all modal interactions work even if modal initialization events don't fire properly 176if ( document.readyState === 'loading' ) { 177 document.addEventListener( 'DOMContentLoaded', attachGlobalModalHandlers ); 178} else { 179 attachGlobalModalHandlers(); 180} 181 182// Listen for modal initialization events and attach behaviours. 183// Listen on both main document and iframe document for full compatibility 184function setupModalInitializationListener() { 185 const handleInitialization = ( event ) => { 186 const { 187 blockId, 188 overlayClick, 189 escPress, 190 } = event.detail; 191 192 // Normalize values into args with default fallbacks. 193 const args = { 194 blockId, 195 overlayClick: overlayClick || false, 196 escPress: escPress || false, 197 }; 198 199 // Clean up any existing handlers for this modal 200 cleanupModalHandlers( blockId ); 201 202 // Attach the necessary modal behavior handlers. 203 // Close modal on overlay click. 204 attachOverlayClickClose( args ); 205 // Close modal on Escape key. 206 attachEscKeyClose( args ); 207 // Trap focus inside modal and manage keyboard accessibility 208 attachKeyboardHandlers( args ); 209 // Add keyboard support for close button 210 attachCloseButtonKeyboard( args ); 211 }; 212 213 // Listen on main document 214 document.addEventListener( 'spectra:modal:initialized', handleInitialization ); 215 216 // Also listen on iframe document if exists 217 const iframe = document.querySelector( 'iframe[name="editor-canvas"]' ); 218 if ( iframe ) { 219 const iframeDoc = iframe.contentWindow?.document || iframe.contentDocument; 220 if ( iframeDoc ) { 221 iframeDoc.addEventListener( 'spectra:modal:initialized', handleInitialization ); 222 } 223 } 224} 225 226// Setup listeners when DOM is ready 227if ( document.readyState === 'loading' ) { 228 document.addEventListener( 'DOMContentLoaded', setupModalInitializationListener ); 229} else { 230 setupModalInitializationListener(); 231} 232 233 234/** 235 * Clean up any existing event handlers for a modal 236 * 237 * @param {string} blockId - The modal block ID 238 */ 239function cleanupModalHandlers( blockId ) { 240 const handlers = modalHandlers.get( blockId ); 241 if ( handlers ) { 242 // Remove all stored event listeners
243 handlers.forEach( ( { element, event, handler } ) => { 244 element.removeEventListener( event, handler ); 245 } ); 246 modalHandlers.delete( blockId ); 247 } 248} 249 250/** 251 * Store an event handler for later cleanup 252 * 253 * @param {string} blockId - The modal block ID 254 * @param {Element} element - The DOM element 255 * @param {string} event - The event name 256 * @param {Function} handler - The event handler function 257 */ 258function storeHandler( blockId, element, event, handler ) { 259 if ( !modalHandlers.has( blockId ) ) { 260 modalHandlers.set( blockId, [] ); 261 } 262 modalHandlers.get( blockId ).push( { element, event, handler } ); 263 element.addEventListener( event, handler ); 264} 265 266/** 267 * Attach handler to close modal when clicking outside modal content (on the overlay). 268 * 269 * @param {Object} args - Contains modal settings like blockId and overlayClick. 270 */ 271function attachOverlayClickClose( args ) { 272 const modal = getDocumentElement().getElementById( args.blockId ); 273 if ( !modal ) { 274 return; 275 } 276 if ( args.overlayClick ) { 277 const handler = ( e ) => { 278 // The modal structure is: 279 // <div id="blockId" class="active"> <!-- modal wrapper --> 280 // <div class="spectra-modal-popup active"> <!-- popup/overlay --> 281 // <div class="spectra-modal-popup-wrap"> <!-- content wrapper --> 282 283 // If click target is the wrapper (modal element itself), close it 284 if ( e.currentTarget === modal && e.target === modal ) { 285 closeModal( args.blockId ); 286 return; 287 } 288 289 // Find the popup element 290 const popup = modal.querySelector( '.spectra-modal-popup' ); 291 if ( !popup || !popup.classList.contains( 'active' ) ) {return;} 292 293 const popupWrap = popup.querySelector( '.spectra-modal-popup-wrap' ); 294 if ( !popupWrap ) {return;} 295 296 // Click is on overlay if click is on popup but not inside popup-wrap 297 if ( e.target === popup ) { 298 closeModal( args.blockId ); 299 } 300 }; 301 302 // Attach to both modal wrapper and popup for complete coverage 303 storeHandler( args.blockId, modal, 'click', handler ); 304 305 const popup = modal.querySelector( '.spectra-modal-popup' ); 306 if ( popup ) { 307 const popupHandler = ( e ) => { 308 const popupWrap = popup.querySelector( '.spectra-modal-popup-wrap' ); 309 if ( popupWrap && e.target === popup ) { 310 e.stopPropagation(); 311 closeModal( args.blockId ); 312 } 313 }; 314 storeHandler( args.blockId, popup, 'click', popupHandler ); 315 } 316 } 317} 318 319/** 320 * Attach handler to close modal when Escape key is pressed. 321 * 322 * @param {Object} args - Contains modal settings including escPress flag. 323 */ 324function attachEscKeyClose( args ) { 325 if ( args.escPress ) { 326 const handler = ( e ) => { 327 if ( e.key === 'Escape' ) { 328 const modal = getDocumentElement().getElementById( args.blockId ); 329 if ( modal && modal.classList.contains( 'active' ) ) { 330 closeModal( args.blockId ); 331 } 332 } 333 }; 334 storeHandler( args.blockId, getDocumentElement(), 'keydown', handler ); 335 } 336} 337 338/** 339 * Close the modal by hiding it and removing active state. 340 * 341 * @param {string} blockId - The modal block ID to close. 342 */ 343function closeModal( blockId ) { 344 const modal = getDocumentElement().getElementById( blockId ); 345 if ( ! modal ) {return;} 346 347 modal.classList.remove( 'active' ); 348 349 // Also remove active from popup element if it exists 350 const popup = modal.querySelector( '.spectra-modal-popup' ); 351 if ( popup ) { 352 popup.classList.remove( 'active' ); 353 popup.setAttribute( 'aria-hidden', 'true' ); 354 popup.removeAttribute( 'aria-modal' ); 355 } 356 357 // Restore focus to previously focused element 358 const state = modalHandlers.get( blockId )?.state; 359 if ( state?.previouslyFocusedElement ) { 360 // Use setTimeout to ensure focus is restored after modal is fully closed 361 setTimeout( () => { 362 if ( state.previouslyFocusedElement && typeof state.previouslyFocusedElement.focus === 'function' ) { 363 state.previouslyFocusedElement.focus(); 364 } 365 }, 0 ); 366 } 367 368 // Dispatch close event for any cleanup 369 const event = new CustomEvent( 'spectra:modal:closed', { 370 detail: { blockId }, 371 bubbles: true 372 } ); 373 document.dispatchEvent( event ); 374} 375 376/** 377 * Enable keyboard accessibility for modal including focus trap and restoring focus. 378 * 379 * @param {Object} args - Modal configuration. 380 */ 381function attachKeyboardHandlers( args ) { 382 // Store modal-specific state in the handlers map 383 if ( !modalHandlers.has( args.blockId ) ) { 384 modalHandlers.set( args.blockId, [] ); 385 } 386 387 // Add state properties 388 modalHandlers.get( args.blockId ).state = { 389 previouslyFocusedElement: null, 390 isFirstTab: true 391 }; 392 393 // Trap tab focus inside the modal 394 const keydownHandler = ( e ) => { 395 const modal = getDocumentElement().getElementById( args.blockId ); 396 if ( ! modal || ! modal.classList.contains( 'active' ) ) {return;} 397 398 // Check if the popup element is hidden (not the wrapper) 399 const popup = modal.querySelector( '.spectra-modal-popup' ); 400 if ( popup && popup.getAttribute( 'aria-hidden' ) === 'true' ) {return;} 401 402 const state = modalHandlers.get( args.blockId )?.state; 403 if ( ! state ) {return;} 404 405 const focusable = modal.querySelectorAll(
406 'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])' 407 ); 408 if ( !focusable.length ) {return;} 409 410 const first = focusable[0]; 411 const last = focusable[focusable.length - 1]; 412 const activeElement = modal.ownerDocument.activeElement; 413 414 if ( e.key === 'Tab' ) { 415 // Check if modal was triggered automatically and this is first tab 416 const modalTrigger = modal.getAttribute( 'data-modal-trigger' ) || 'manual'; 417 418 if ( state.isFirstTab && modalTrigger === 'automatic' && !modal.contains( activeElement ) ) { 419 // First tab on automatic modal - focus close button 420 e.preventDefault(); 421 first.focus(); 422 state.isFirstTab = false; 423 return; 424 } 425 426 // Trap focus inside modal. 427 if ( e.shiftKey && activeElement === first ) { 428 e.preventDefault(); 429 last.focus(); 430 } else if ( !e.shiftKey && activeElement === last ) { 431 e.preventDefault(); 432 first.focus(); 433 } else if ( !modal.contains( activeElement ) ) { 434 // If focus is outside modal, bring it back inside 435 e.preventDefault(); 436 if ( e.shiftKey ) { 437 last.focus(); 438 } else { 439 first.focus(); 440 } 441 } 442 } else if ( e.key === 'Escape' && args.escPress ) { 443 // Close modal and restore previous focus only if escPress is enabled. 444 closeModal( args.blockId ); 445 if ( state.previouslyFocusedElement ) { 446 state.previouslyFocusedElement.focus(); 447 } 448 } 449 }; 450 storeHandler( args.blockId, getDocumentElement(), 'keydown', keydownHandler ); 451 452 // Prevent focus on elements outside modal when clicking 453 const focusinHandler = ( e ) => { 454 const modal = getDocumentElement().getElementById( args.blockId ); 455 if ( ! modal || ! modal.classList.contains( 'active' ) ) {return;} 456 457 // If focus moves outside the modal, bring it back 458 if ( !modal.contains( e.target ) ) { 459 e.preventDefault(); 460 e.stopPropagation(); 461 462 const focusable = modal.querySelectorAll( 463 'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])' 464 ); 465 if ( focusable.length > 0 ) { 466 focusable[0].focus(); 467 } 468 } 469 }; 470 storeHandler( args.blockId, getDocumentElement(), 'focusin', focusinHandler ); 471 472 // Set initial focus to first focusable element on modal open. 473 const openHandler = ( event ) => { 474 // Check if this event is for our modal 475 if ( event.detail?.blockId && event.detail.blockId !== args.blockId ) {return;} 476 477 const modal = getDocumentElement().getElementById( args.blockId ); 478 if ( !modal || !modal.classList.contains( 'active' ) ) {return;} 479 480 const state = modalHandlers.get( args.blockId )?.state; 481 if ( ! state ) {return;} 482 483 const focusable = modal.querySelectorAll( 484 'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])' 485 ); 486 if ( !focusable.length ) {return;} 487 const activeElement = modal.ownerDocument.activeElement; 488 489 // Save currently focused element to return focus when modal closes. 490 // Prefer trigger element from event detail, otherwise use active element 491 if ( event.detail?.triggerElement ) { 492 state.previouslyFocusedElement = event.detail.triggerElement; 493 } else { 494 state.previouslyFocusedElement = activeElement; 495 } 496 497 // Reset first tab flag 498 state.isFirstTab = true; 499 500 // Get modal trigger type from event detail if available 501 const modalTrigger = event.detail?.modalTrigger || modal.getAttribute( 'data-modal-trigger' ) || 'manual'; 502 503 // Don't auto-focus any element when modal opens 504 // The user should control focus with Tab key 505 // Only exception: automatic modals might need different handling 506 if ( modalTrigger === 'automatic' ) { 507 // For automatic modals, we might want different behavior 508 // Currently, we don't focus anything 509 } 510 }; 511 storeHandler( args.blockId, getDocumentElement(), 'spectra:modal:opened', openHandler ); 512} 513 514/** 515 * Add keyboard support for close button (Enter/Space to close) 516 * 517 * @param {Object} args - Modal configuration. 518 */ 519function attachCloseButtonKeyboard( args ) { 520 const modal = getDocumentElement().getElementById( args.blockId ); 521 if ( !modal ) {return;} 522 523 // Wait a bit for modal content to render 524 setTimeout( () => { 525 // Find close button with the specific class 526 const closeButtons = modal.querySelectorAll( '.spectra-modal-popup-close' ); 527 528 closeButtons.forEach( button => { 529 // Make sure the element is focusable if it's not a button/link 530 if ( button.tagName !== 'BUTTON' && button.tagName !== 'A' && !button.hasAttribute( 'tabindex' ) ) { 531 button.setAttribute( 'tabindex', '0' ); 532 } 533 534 // Add role button if not already a button 535 if ( button.tagName !== 'BUTTON' && !button.hasAttribute( 'role' ) ) { 536 button.setAttribute( 'role', 'button' ); 537 } 538 539 const handler = ( e ) => { 540 // Handle Enter key (Note: Tab should not trigger close, it's for navigation) 541 if ( e.key === 'Enter' || e.key === ' ' ) { 542 e.preventDefault(); 543 e.stopPropagation(); 544 545 // Trigger click event on the close button 546 button.click(); 547 548 // Or directly close the modal (which will restore focus) 549 closeModal( args.blockId ); 550 } 551 }; 552 storeHandler( args.blockId, button, 'keydown', handler ); 553 } ); 554 }, CLOSE_BUTTON_RENDER_DELAY ); 555} 556 557} )(); // End IIFE
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.