1class XmPopupModal extends HTMLElement { 2 constructor() { 3 super(); 4 this.attachShadow({ mode: 'open' }); 5 6 // Instance variables 7 this.lastFocused = null; 8 this.revealTimer = null; 9 this.rafId = null; 10 this.isClosing = false; 11 this.xmEditingItem = null; 12 13 // Body-height messages can arrive in bursts while a form initializes. 14 // Debounce those, but keep applyHeight() available for an immediate 15 // re-clamp when the host's usable viewport changes. 16 this.setHeights = this.debounce((px) => this.applyHeight(px), 250); 17 18 this.render(); 19 this.bindEvents(); 20 } 21 22 render() { 23 this.shadowRoot.innerHTML = ` 24 <style> 25 :host { 26 --xmpop-bg: rgba(0,0,0,.6); 27 --xmpop-width: min(1000px, 92vw); 28 --xmpop-chrome: 32px; 29 --xmpop-top: 0px; 30 --xmpop-bottom: 0px; 31 } 32 33 .xmpop-overlay { 34 position: fixed; 35 inset: var(--xmpop-top) 0 var(--xmpop-bottom) 0; 36 background: var(--xmpop-bg); 37 display: flex; align-items: center; justify-content: center; 38 visibility: hidden; opacity: 0; transition: opacity .18s ease, visibility .18s ease; 39 z-index: 1000; 40 } 41 .xmpop-overlay.active { visibility: visible; opacity: 1; } 42 43 .xmpop-content { 44 position: relative; 45 width: var(--xmpop-width); 46 max-height: calc(100vh - var(--xmpop-top) - var(--xmpop-bottom) - var(--xmpop-chrome)); 47 background: #fff; border-radius: 10px; overflow: hidden; 48 box-shadow: 0 12px 32px rgba(0,0,0,.28); 49 display: flex; flex-direction: column; 50 transition: height 120ms ease, transform .18s ease; 51 transform: scale(1); 52 } 53 54 #xmpop-iframe { 55 width: 100%; border: 0; display: block; opacity: 0; 56 transition: opacity .16s ease; 57 } 58 #xmpop-iframe.xmpop-visible { opacity: 1; } 59 60 .xmpop-close { 61 position: absolute; top: 8px; right: 12px; 62 background: transparent; border: 0; font-size: 24px; cursor: pointer; line-height: 1; 63 z-index: 3; 64 } 65 66 .xmpop-loader { 67 position: absolute; inset: 0; display: grid; place-items: center; 68 background: rgba(255,255,255,.75); z-index: 2; transition: opacity .16s ease; 69 } 70 .xmpop-loader::before { 71 content: ""; width: 36px; height: 36px; border-radius: 50%; 72 border: 3px solid currentColor; border-right-color: transparent; 73 animation: xmpop-spin .8s linear infinite; 74 } 75 @keyframes xmpop-spin { to { transform: rotate(360deg); } } 76 @media (prefers-reduced-motion: reduce) { .xmpop-loader::before { animation: none; } } 77 78 .xmpop-hidden { display: none !important; } 79 80 .xmpop-overlay.closing { 81 visibility: visible; 82 opacity: 0; 83 } 84 85 .xmpop-overlay.closing .xmpop-content { 86 transform: scale(.97); 87 } 88 </style> 89 90 <div class="xmpop-overlay" id="xmpop-popup" aria-hidden="true"> 91 <div class="xmpop-content" role="dialog" aria-modal="true" aria-label="Edit"> 92 <div class="xmpop-loader" id="xmpop-loader" aria-live="polite" aria-busy="true"></div> 93 <button class="xmpop-close" id="xmpop-close" aria-label="Close">×</button> 94 <iframe id="xmpop-iframe" src="" frameborder="0"></iframe> 95 </div> 96 </div> 97 `; 98 } 99 100 bindEvents() { 101 // Get shadow DOM elements 102 this.overlay = this.shadowRoot.getElementById('xmpop-popup'); 103 this.shell = this.shadowRoot.querySelector('.xmpop-content'); 104 this.loader = this.shadowRoot.getElementById('xmpop-loader'); 105 this.closeBtn = this.shadowRoot.getElementById('xmpop-close'); 106 this.frame = this.shadowRoot.getElementById('xmpop-iframe'); 107 108 // Bind methods to preserve 'this' context 109 this.handleMessage = this.handleMessage.bind(this); 110 this.handleResize = this.handleResize.bind(this); 111 this.handleOverlayClick = this.handleOverlayClick.bind(this); 112 this.handleCloseClick = this.handleCloseClick.bind(this); 113 114 // Event listeners 115 window.addEventListener('message', this.handleMessage); 116 window.addEventListener('resize', this.handleResize); 117 this.closeBtn.addEventListener('click', this.handleCloseClick); 118 119 // Custom events for external integration
120 window.addEventListener('onEmbedFormCancel', (e) => this.handleFormCancel(e)); 121 122 // Initial height setup 123 this.setHeights(Math.min(600, Math.round(window.innerHeight * 0.8))); 124 } 125 126 connectedCallback() { 127 // Wire up triggers when component is added to DOM 128 this.setupTriggers(); 129 } 130 131 disconnectedCallback() { 132 // Cleanup 133 window.removeEventListener('message', this.handleMessage); 134 window.removeEventListener('resize', this.handleResize); 135 if (this.revealTimer) clearTimeout(this.revealTimer); 136 if (this.rafId) cancelAnimationFrame(this.rafId); 137 } 138 139 setupTriggers() { 140 // Use event delegation on document body (or a closer parent container) 141 document.body.addEventListener('click', (e) => { 142 // Check if the clicked element or any of its parents has the class 'xmpop-open' 143 const trigger = e.target.closest('.xmpop-open'); 144 145 if (!trigger) return; 146 147 // Prevent default action if needed 148 e.preventDefault(); 149 150 const url = trigger.getAttribute('data-url'); 151 if (!url) return; 152 153 this.lastFocused = document.activeElement; 154 this.showLoader(); 155 this.frame.src = url; 156 this.showPopupOverlay(); 157 158 if (this.revealTimer) { 159 clearTimeout(this.revealTimer); 160 this.revealTimer = null; 161 } 162 this.revealTimer = setTimeout(() => this.hideLoaderAndReveal(), 3000); 163 this.xmEditingItem = trigger; 164 }); 165 } 166 167 showPopupOverlay() { 168 this.fitToViewport(); 169 this.overlay.classList.add('active'); 170 this.overlay.setAttribute('aria-hidden', 'false'); 171 this.trapFocus(this.overlay); 172 this.closeBtn.focus(); 173 } 174 175 hidePopupOverlay() { 176 if (this.isClosing) return; 177 this.isClosing = true; 178 179 this.overlay.classList.add('active'); 180 // Force reflow 181 this.overlay.offsetHeight; 182 this.overlay.classList.add('closing'); 183 184 let completed = false; 185 let fallbackTimer; 186 const onDone = (e) => { 187 if (completed || (e && e.target !== this.overlay)) return; 188 completed = true; 189 clearTimeout(fallbackTimer); 190 this.overlay.removeEventListener('transitionend', onDone); 191 192 this.overlay.classList.remove('active', 'closing'); 193 this.overlay.setAttribute('aria-hidden', 'true'); 194 195 this.frame.src = ''; 196 this.frame.classList.remove('xmpop-visible'); 197 this.showLoader(); 198 if (this.lastFocused) this.lastFocused.focus(); 199 200 this.isClosing = false; 201 }; 202 203 this.overlay.addEventListener('transitionend', onDone); 204 fallbackTimer = setTimeout(onDone, 240); 205 } 206 207 showLoader() { 208 this.loader.classList.remove('xmpop-hidden'); 209 this.loader.setAttribute('aria-busy', 'true'); 210 this.frame.classList.remove('xmpop-visible'); 211 } 212 213 hideLoaderAndReveal() { 214 this.loader.classList.add('xmpop-hidden'); 215 this.loader.setAttribute('aria-busy', 'false'); 216 this.frame.classList.add('xmpop-visible'); 217 } 218 219 clamp(v, min, max) { 220 return Math.max(min, Math.min(max, v)); 221 } 222 223 cssPixels(name, fallback = 0) { 224 const value = parseFloat(getComputedStyle(this).getPropertyValue(name)); 225 return Number.isFinite(value) ? Math.max(0, value) : fallback; 226 } 227 228 maxContentHeight() { 229 const viewportH = window.innerHeight || document.documentElement.clientHeight || 0; 230 const top = this.cssPixels('--xmpop-top'); 231 const bottom = this.cssPixels('--xmpop-bottom'); 232 const chrome = this.cssPixels('--xmpop-chrome', 32); 233 return Math.max(160, Math.floor(viewportH - top - bottom - chrome)); 234 } 235 236 applyHeight(px) { 237 if (!this.shell || !this.frame) return; 238 const maxH = this.maxContentHeight(); 239 const requested = parseInt(px, 10); 240 const h = this.clamp(Number.isFinite(requested) ? requested : 600, 160, maxH); 241 this.shell.style.height = h + 'px'; 242 this.frame.style.height = h + 'px'; 243 } 244 245 // Public hosts can change --xmpop-top / --xmpop-bottom and call this 246 // without knowing anything about the component's shadow DOM. 247 fitToViewport() { 248 const current = parseInt(this.frame?.style.height || '0', 10) || 249 Math.min(600, Math.round(this.maxContentHeight() * 0.8)); 250 this.applyHeight(current); 251 this.requestMeasure(); 252 } 253 254 debounce(func, delay) { 255 let timeoutId; 256 return (...args) => { 257 clearTimeout(timeoutId); 258 timeoutId = setTimeout(() => func.apply(this, args), delay); 259 }; 260 } 261 262 handleMessage(evt) { 263 let data = evt.data; 264 if (typeof data === 'string') {
265 try { data = JSON.parse(data); } catch {} 266 } 267 268 if (data === 'xmpop:ready' || (data && data.xmpop_ready === true)) { 269 if (this.revealTimer) { 270 clearTimeout(this.revealTimer); 271 this.revealTimer = null; 272 } 273 this.hideLoaderAndReveal(); 274 return; 275 } 276 277 if (!data || (typeof data.height !== 'number' && typeof data.body_height !== 'number')) return; 278 const raw = Number.isFinite(data.height) ? data.height 279 : Number.isFinite(data.body_height) ? data.body_height 280 : null; 281 if (!raw) return; 282 283 if (this.rafId) cancelAnimationFrame(this.rafId); 284 this.rafId = requestAnimationFrame(() => this.setHeights(raw + 2)); 285 } 286 287 handleResize() { 288 this.fitToViewport(); 289 } 290 291 handleOverlayClick(e) { 292 if (e.target === this.overlay) this.hidePopupOverlay(); 293 } 294 295 handleCloseClick() { 296 this.hidePopupOverlay(); 297 } 298 299 requestMeasure() { 300 try { 301 this.frame.contentWindow?.postMessage('xmpop:measure', '*'); 302 } catch {} 303 } 304 305 trapFocus(root) { 306 const focusables = root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); 307 const first = focusables[0]; 308 const last = focusables[focusables.length - 1]; 309 310 root.onkeydown = (e) => { 311 if (e.key !== 'Tab') return; 312 if (e.shiftKey && document.activeElement === first) { 313 e.preventDefault(); 314 last.focus(); 315 } else if (!e.shiftKey && document.activeElement === last) { 316 e.preventDefault(); 317 first.focus(); 318 } 319 }; 320 } 321 322 updateScheduleProgress($scope) { 323 const $details = $scope; 324 const $rows = $details.find('tbody tr'); 325 const total = $rows.length; 326 327 if (!total) return; 328 329 const scheduled = $rows.find('td.dateScheduled').filter(function () { 330 const txt = $(this).text().replace(/\u00A0/g, ' ').trim(); 331 return txt.length > 0; 332 }).length; 333 334 const pct = Math.round((scheduled / total) * 100); 335 336 const $panel = $details.find('.progress_panel').first(); 337 $panel.attr('data-tippy-content', pct + '%'); 338 $panel.find('.title').text(pct + '%'); 339 $panel.find('.prog-bar > span').css('width', pct + '%'); 340 } 341 342 handleFormCancel(e) { 343 this.hidePopupOverlay(); 344 } 345 346 // Public API methods - these should work from outside 347 open(url) { 348 if (!this.frame) { 349 console.error('Modal not ready - DOM elements not found'); 350 return; 351 } 352 353 this.lastFocused = document.activeElement; 354 this.showLoader(); 355 this.frame.src = url; 356 this.showPopupOverlay(); 357 358 if (this.revealTimer) { 359 clearTimeout(this.revealTimer); 360 this.revealTimer = null; 361 } 362 this.revealTimer = setTimeout(() => this.hideLoaderAndReveal(), 3000); 363 } 364 365 close() { 366 if (!this.overlay) { 367 console.error('Modal not ready - overlay element not found'); 368 return; 369 } 370 this.hidePopupOverlay(); 371 } 372 373 // Alternative method names for clarity 374 show(url) { 375 return this.open(url); 376 } 377 378 hide() { 379 return this.close(); 380 } 381} 382 383// Register the custom element 384customElements.define('xm-popup-modal', XmPopupModal);
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.