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