1/** 2 * TODO: This file was compiled manually from TypeScript. 3 * In the future, set up Webpack to handle TS compilation automatically. 4 */ 5 6/** 7 * Global Modal Service for Ziptrak Project 8 */ 9 10// Configuration for selectors and CSS classes 11const MODAL_CONFIG = { 12 selectors: { 13 root: '#ziptrak-modal-root', 14 templateBase: '#ziptrak-tmpl-modal-', 15 trigger: '[data-ziptrak-modal-trigger]', 16 closeButton: '.ziptrak-modal-close, .ziptrak-modal-back', 17 container: '.ziptrak-modal-container', 18 focusable: [ 19 'a[href]', 20 'button:not([disabled])', 21 'input:not([disabled])', 22 'select:not([disabled])', 23 'textarea:not([disabled])', 24 '[tabindex]:not([tabindex="-1"])', 25 ].join(','), 26 slider: { 27 sliderSection: '.ziptrak-modal-slider-section', 28 swiperContainer: '.swiper', 29 swiperWrapper: '.swiper-wrapper', 30 btnPrev: '.swiper-button-prev', 31 btnNext: '.swiper-button-next', 32 pagination: '.swiper-pagination', 33 }, 34 templates: { 35 testimonial: { 36 text: '.testimonial-text', 37 author: '.testimonial-author', 38 location: '.testimonial-location', 39 stars: '.testimonial-stars', 40 sliderSection: '.ziptrak-modal-slider-section', 41 sliderImage: '.slider-current-image', 42 sliderDots: '.slider-dots', 43 sliderDot: '.slider-dot', 44 sliderControls: '.slider-controls', 45 btnPrev: '.slider-prev', 46 btnNext: '.slider-next', 47 }, 48 blindGallery: { 49 sliderSection: '.ziptrak-modal-slider-section', 50 sliderImage: '.slider-current-image', 51 sliderDots: '.slider-dots', 52 sliderDot: '.slider-dot', 53 sliderControls: '.slider-controls', 54 btnPrev: '.slider-prev', 55 btnNext: '.slider-next', 56 }, 57 overview: { 58 label: '.overview__label', 59 title: '.overview__title', 60 description: '.overview__description', 61 list: '.overview__list', 62 } 63 } 64 }, 65 classes: { 66 active: 'is-active', 67 hasImages: 'has-images', 68 noScroll: 'no-scroll' 69 } 70}; 71 72class ZiptrakModalService { 73 root; 74 swiperInstance = null; 75 activeTrigger = null; 76 returnFocusElement = null; 77 inlineSource = null; 78 inlineContent = null; 79 setupTemplate = { 80 testimonial: (data) => this.setupTestimonial(data), 81 gallery: (data) => this.setupGallery(data), 82 overview: (data) => this.setupOverview(data), 83 }; 84 85 constructor() { 86 this.root = document.querySelector(MODAL_CONFIG.selectors.root); 87 this.initGlobalEvents(); 88 } 89 90 /** 91 * Initialize static event listeners 92 */ 93 initGlobalEvents() { 94 if (!this.root) { 95 return; 96 } 97 this.root.addEventListener('click', this.handleRootClick); 98 document.addEventListener('click', this.handleDocumentClick); 99 document.addEventListener('keydown', this.handleKeyDown); 100 } 101 102 /** 103 * Open editor-authored content in the shared modal window. 104 */ 105 handleDocumentClick = (event) => { 106 const target = event.target; 107 if (!(target instanceof Element)) { 108 return; 109 } 110 const trigger = target.closest(MODAL_CONFIG.selectors.trigger); 111 if (!trigger) { 112 return; 113 } 114 const sourceId = trigger.dataset.ziptrakModalTarget || trigger.getAttribute('aria-controls'); 115 const source = sourceId ? document.getElementById(sourceId) : null; 116 if (!source) { 117 return; 118 } 119 event.preventDefault(); 120 this.openInlineContent(source, trigger); 121 }; 122 123 /** 124 * Handle clicks outside the content area (on backdrop/root) 125 */ 126 handleRootClick = (e) => { 127 const target = e.target; 128 if (target === this.root) { 129 this.close(); 130 return; 131 } 132 if (target instanceof Element && target.closest(MODAL_CONFIG.selectors.closeButton)) { 133 this.close(); 134 } 135 }; 136 /** 137 * Handle global keyboard navigation (ESC key) 138 */ 139 handleKeyDown = (e) => { 140 if (!this.root?.classList.contains(MODAL_CONFIG.classes.active)) { 141 return; 142 } 143 if (e.key === 'Escape') { 144 e.preventDefault(); 145 this.close(); 146 } else if (e.key === 'Tab') { 147 this.trapFocus(e); 148 } 149 }; 150 151 /** 152 * Open modal by template ID and inject data 153 */ 154 open(templateId, data) { 155 if (!this.root) { 156 return; 157 } 158 const template = document.querySelector(`${MODAL_CONFIG.selectors.templateBase}${templateId}`); 159 if (!template) { 160 console.warn(`Template with id ${templateId} not found`); 161 return; 162 } 163 // 1. Clear and render new content 164 this.prepareRoot(); 165 const clone = template.content.cloneNode(true); 166 this.root.appendChild(clone); 167 // 2. Initialize specific logic based on template type 168 this.setupTemplate[templateId](data); 169 // 3. Show modal and lock scroll 170 this.finishOpen(null, templateId); 171 } 172 173 /** 174 * Move the live inner-block content into the shared modal root. Moving the 175 * element preserves any state and listeners initialized by nested blocks. 176 */ 177 openInlineContent(source, trigger) { 178 if (!this.root) { 179 return; 180 } 181 const content = source.querySelector(MODAL_CONFIG.selectors.container); 182 if (!content) { 183 return; 184 } 185 this.prepareRoot(); 186 this.inlineSource = source; 187 this.inlineContent = content; 188 this.root.appendChild(content); 189 this.finishOpen(trigger, 'content'); 190 } 191 192 /** 193 * Close modal and clear content after animation 194 */ 195 close() { 196 if (!this.root || !this.root.classList.contains(MODAL_CONFIG.classes.active)) { 197 return; 198 } 199 this.destroySwiper(); 200 this.toggleModalState(false); 201 const closedDialog = this.root.querySelector(MODAL_CONFIG.selectors.container); 202 this.restoreInlineContent(); 203 this.restoreTrigger(); 204 this.dispatchModalEvent('closed', {dialog: closedDialog}); 205 } 206 207 prepareRoot() { 208 this.destroySwiper(); 209 this.clearTriggerState(); 210 this.restoreInlineContent(); 211 if (this.root) { 212 this.root.innerHTML = ''; 213 } 214 } 215 216 finishOpen(trigger, templateId) { 217 if (!this.root) { 218 return; 219 } 220 this.setupTriggerState(trigger); 221 const dialog = this.root.querySelector(MODAL_CONFIG.selectors.container); 222 this.setupDialogAccessibility(dialog); 223 this.toggleModalState(true); 224 this.setInitialFocus(dialog); 225 this.dispatchModalOpenedEvents(templateId, dialog); 226 } 227 228 setupTriggerState(trigger) { 229 this.activeTrigger = trigger; 230 this.returnFocusElement = trigger || (document.activeElement instanceof HTMLElement ? document.activeElement : null); 231 this.activeTrigger?.setAttribute('aria-expanded', 'true'); 232 } 233 234 setupDialogAccessibility(dialog) { 235 if (dialog) { 236 dialog.setAttribute('role', 'dialog'); 237 dialog.setAttribute('aria-modal', 'true'); 238 if (!dialog.hasAttribute('tabindex')) { 239 dialog.tabIndex = -1; 240 } 241 } 242 } 243 244 setInitialFocus(dialog) { 245 if (!this.root) { 246 return; 247 } 248 const initialFocus = this.root.querySelector(MODAL_CONFIG.selectors.closeButton) || dialog; 249 initialFocus?.focus({preventScroll: true}); 250 } 251 252 dispatchModalEvent(eventName, detail) { 253 window.dispatchEvent(new CustomEvent(`ziptrak:modal-${eventName}`, {detail})); 254 } 255 256 dispatchModalOpenedEvents(templateId, dialog) { 257 this.dispatchModalEvent('opened', {templateId, dialog}); 258 window.dispatchEvent(new Event('resize')); 259 } 260 261 clearTriggerState() { 262 this.activeTrigger?.setAttribute('aria-expanded', 'false'); 263 this.activeTrigger = null; 264 this.returnFocusElement = null; 265 } 266 267 restoreTrigger() { 268 const triggerToRestore = this.returnFocusElement; 269 this.clearTriggerState(); 270 triggerToRestore?.focus({preventScroll: true}); 271 } 272 273 restoreInlineContent() {
274 if (this.inlineSource && this.inlineContent) { 275 this.inlineSource.appendChild(this.inlineContent); 276 } 277 this.inlineSource = null; 278 this.inlineContent = null; 279 } 280 281 trapFocus(event) { 282 if (!this.root) { 283 return; 284 } 285 const focusable = Array.from(this.root.querySelectorAll(MODAL_CONFIG.selectors.focusable)) 286 .filter((element) => !element.hasAttribute('hidden') && element.offsetParent !== null); 287 if (!focusable.length) { 288 event.preventDefault(); 289 this.root.querySelector(MODAL_CONFIG.selectors.container)?.focus({preventScroll: true}); 290 return; 291 } 292 const first = focusable[0]; 293 const last = focusable[focusable.length - 1]; 294 if (event.shiftKey && document.activeElement === first) { 295 event.preventDefault(); 296 last.focus({preventScroll: true}); 297 } else if (!event.shiftKey && document.activeElement === last) { 298 event.preventDefault(); 299 first.focus({preventScroll: true}); 300 } 301 } 302 303 setText(selector, val) { 304 const element = this.root.querySelector(selector); 305 if (element) { 306 element.textContent = val || ''; 307 } 308 } 309 ; 310 311 /** 312 * Toggles modal active state and body scroll lock 313 * @param isOpen - Pass true to open, false to close 314 */ 315 toggleModalState(isOpen) { 316 if (!this.root) { 317 return; 318 } 319 this.root.classList.toggle(MODAL_CONFIG.classes.active, isOpen); 320 this.root.setAttribute('aria-hidden', isOpen ? 'false' : 'true'); 321 document.body.classList.toggle(MODAL_CONFIG.classes.noScroll, isOpen); 322 } 323 324 /** 325 * Specifically handles testimonial content 326 */ 327 setupTestimonial(data) { 328 const selectors = MODAL_CONFIG.selectors.templates.testimonial; 329 this.setText(selectors.text, data.fullText); 330 this.setText(selectors.author, data.author); 331 this.setText(selectors.location, data.location); 332 const starsEl = this.root?.querySelector(selectors.stars); 333 if (starsEl) { 334 starsEl.innerHTML = Array.from({length: data.rating}, () => '<div class="star-icon"></div>').join(''); 335 } 336 this.setupSlider(JSON.parse(data.images || '[]')); 337 } 338 339 /** 340 * Handles Gallery conent 341 */ 342 setupGallery(data) { 343 this.setupSlider(data.images || [], data.index); 344 } 345 346 /** 347 * Handles overview content 348 */ 349 setupOverview(data) { 350 const selectors = MODAL_CONFIG.selectors.templates.overview; 351 this.setText(selectors.label, data.modalLabel); 352 this.setText(selectors.title, data.modalTitle); 353 this.setText(selectors.description, data.modalDescription); 354 // Render list items 355 const listContainer = this.root?.querySelector(selectors.list); 356 if (listContainer) { 357 listContainer.innerHTML = data.modalList.map((text) => `<li class="list-item-warning">${text}</li>`).join(''); 358 } 359 this.setupSlider(data.modalImages || []); 360 } 361 362 /** 363 * Setup Slider 364 */ 365 setupSlider(images, initialIndex = 0) { 366 if (!this.root) { 367 return; 368 } 369 const selectors = MODAL_CONFIG.selectors.slider; 370 const sliderSection = this.root.querySelector(selectors.sliderSection); 371 const swiperElement = this.root.querySelector(selectors.swiperContainer); 372 const wrapper = this.root.querySelector(selectors.swiperWrapper); 373 const container = this.root.querySelector(MODAL_CONFIG.selectors.container); 374 if (!images?.length || !sliderSection || !wrapper || !swiperElement) { 375 if (sliderSection) { 376 sliderSection.style.display = 'none'; 377 } 378 return; 379 } 380 sliderSection.style.display = ''; 381 container?.classList.add(MODAL_CONFIG.classes.hasImages); 382 wrapper.innerHTML = images 383 .map((src) => `<div class="swiper-slide"><img src="${src}" alt="Modal image" /></div>`) 384 .join(''); 385 this.swiperInstance = new Swiper(swiperElement, { 386 initialSlide: initialIndex, 387 loop: false, 388 spaceBetween: 20, 389 slidesPerView: 1, 390 pagination: { 391 el: this.root.querySelector(selectors.pagination), 392 clickable: true, 393 }, 394 navigation: { 395 nextEl: this.root.querySelector(selectors.btnNext), 396 prevEl: this.root.querySelector(selectors.btnPrev), 397 }, 398 }); 399 } 400 401 destroySwiper() { 402 if (this.swiperInstance) { 403 this.swiperInstance.destroy(true, true); 404 this.swiperInstance = null; 405 } 406 } 407} 408 409function initializeModalService() { 410 window.ModalService = new ZiptrakModalService(); 411} 412 413if (document.readyState === 'loading') { 414 document.addEventListener('DOMContentLoaded', initializeModalService, {once: true}); 415} else { 416 initializeModalService(); 417}
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.