PageSourceSearch

https://sdc.me.uk/wp-content/plugins/spectra-blocks/assets/js/modal-script.js?ver=1.0.8

js sdc.me.uk collected 2026-09-25 00:50:17 UTC 18,054 bytes, 557 lines download raw bytes

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.