PageSourceSearch

https://ncpro.org/lib/com/web/popup.js?ver=20260913-A

js ncpro.org collected 2026-09-28 06:45:47 UTC 11,972 bytes, 384 lines download raw bytes

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">&times;</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.