PageSourceSearch

https://swvasun.com/sys-popup.js?v=1790616651

js swvasun.com collected 2026-09-29 05:15:29 UTC 21,104 bytes, 482 lines download raw bytes

1// sys-popup.js — Global popup handler for the CMS.
2// Spec: docs/superpowers/specs/2026-05-19-popup-menu-and-shortcode-design.md
3//
4// Public API:
5//   window.cmsOpenPopup(url, title)   - open a modal showing url's #MainContent
6//   window.cmsClosePopup()            - close the current modal
7//
8// Triggers wired automatically:
9//   - any <a data-cms-popup="1" href="..."> click
10//   - ?popup=<url>[&popup_title=<title>] on page load
11//
12// Inside the modal: clicks on same-origin anchors and GET form submissions
13// swap the modal body to the new URL (wizard-style). A back arrow in the
14// header pops the history stack. Modal only fully closes on ×, ESC, or
15// backdrop click.
16//
17// Security: only same-origin URLs are honored.
18
19(function () {
20  'use strict';
21
22  var state = {
23    overlay: null,
24    lastTrigger: null,
25    history: [],         // stack of { url, title } for the back button
26    currentUrl: null,
27    currentTitle: ''
28  };
29
30  function isSameOrigin(url) {
31    try {
32      var u = new URL(url, window.location.origin);
33      return u.origin === window.location.origin;
34    } catch (e) { return false; }
35  }
36
37  function escapeHtml(s) {
38    return String(s || '').replace(/[&<>"']/g, function (c) {
39      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
40    });
41  }
42
43  // Inline SVG icons -- predictable centering (no font-baseline drift) and
44  // strokeWidth gives them a "bold" look without depending on font-weight.
45  var BACK_ICON_SVG =
46    '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" ' +
47      'stroke="currentColor" stroke-width="3" stroke-linecap="round" ' +
48      'stroke-linejoin="round" aria-hidden="true">' +
49      '<path d="M15 6 L9 12 L15 18"/>' +
50    '</svg>';
51  var CLOSE_ICON_SVG =
52    '<svg viewBox="0 0 24 24" width="20" height="20" fill="none" ' +
53      'stroke="currentColor" stroke-width="3" stroke-linecap="round" ' +
54      'stroke-linejoin="round" aria-hidden="true">' +
55      '<path d="M6 6 L18 18 M18 6 L6 18"/>' +
56    '</svg>';
57
58  // Native <dialog> + showModal() puts the popup in the browser's top layer,
59  // bypassing any ancestor transforms/filters that would otherwise pin a
60  // position:fixed overlay to a non-viewport containing block. This is what
61  // makes the popup actually viewport-centered on layouts that wrap their
62  // body in a transformed container (as the host site does).
63  function buildSkeleton(title) {
64    var dlg = document.createElement('dialog');
65    dlg.className = 'cms-popup-dialog';
66    dlg.innerHTML =
67      '<div class="cms-popup-header">' +
68        '<button class="cms-popup-back" aria-label="Back" type="button" hidden>' + BACK_ICON_SVG + '</button>' +
69        '<h2 class="cms-popup-title">' + escapeHtml(title || '') + '</h2>' +
70        '<button class="cms-popup-close" aria-label="Close" type="button">' + CLOSE_ICON_SVG + '</button>' +
71      '</div>' +
72      '<div class="cms-popup-body"><div class="cms-popup-loading">Loading…</div></div>';
73    return dlg;
74  }
75
76  function updateBackButton() {
77    if (!state.overlay) return;
78    var back = state.overlay.querySelector('.cms-popup-back');
79    if (!back) return;
80    if (state.history.length > 0) back.removeAttribute('hidden');
81    else back.setAttribute('hidden', '');
82  }
83
84  function closePopup() {
85    if (!state.overlay) return;
86    var dlg = state.overlay;
87    state.overlay = null;
88    state.history = [];
89    state.currentUrl = null;
90    state.currentTitle = '';
91    document.body.classList.remove('cms-popup-open');
92    // close() if it's still open; the 'close' listener (set in openPopup)
93    // will unmount the element from the DOM.
94    if (dlg.open && typeof dlg.close === 'function') {
95      try { dlg.close(); } catch (e) { if (dlg.parentNode) dlg.parentNode.removeChild(dlg); }
96    } else if (dlg.parentNode) {
97      dlg.parentNode.removeChild(dlg);
98    }
99    if (state.lastTrigger && typeof state.lastTrigger.focus === 'function') {
100      try { state.lastTrigger.focus(); } catch (e) {}
101    }
102    // A reCAPTCHA-protected form rendered in the popup loads gre
102captcha, which
103    // appends its floating badge to document.body. The badge-hiding <style>
104    // lived inside the popup content (now removed), so the badge would reappear
105    // visibly after close. Remove the leaked badge(s) -- the host page itself
106    // doesn't use reCAPTCHA, so this only clears what the popup introduced.
107    try {
108      var badges = document.querySelectorAll('.grecaptcha-badge');
109      for (var b = 0; b < badges.length; b++) {
110        if (badges[b].parentNode) badges[b].parentNode.removeChild(badges[b]);
111      }
112    } catch (e) {}
113  }
114
115  // Try module-specific wrappers first (they contain ONLY the module payload,
116  // no page chrome), then #MainContent (which on some layouts wraps too much
117  // and brings in sub-headers and side columns), then <body> as a last resort.
118  var POPUP_CONTENT_SELECTORS = [
119    '[data-cms-popup-root]',    // explicit opt-in -- highest priority
120    '[data-cms-zone="main"]',   // modern block-based layouts -- the main
121                                // content column; chrome (top-links, social,
122                                // logo, nav) lives outside any data-cms-zone,
123                                // sidebar lives in data-cms-zone="sidebar".
124    '.directory-container',
125    '.calendar-container',
126    '.classifieds-container',
127    '.properties-container',
128    '.shop-container',
129    '.article-container',
130    '.events-container',
131    '.form-container',          // src/forms/index.js -- custom Submit forms
132    '.form-container-wrapper'   // src/services/view/* -- view/edit/details
133  ];
134
135  // Collect <link rel=stylesheet> and <style> blocks that are siblings of the
136  // matched wrapper inside its parent (each module generator emits its CSS
137  // alongside the container -- we'd drop the styling without these). Browsers
138  // honor stylesheet links inserted via innerHTML, so this works at runtime.
139  function collectSiblingStyles(el) {
140    var parent = el.parentNode;
141    if (!parent) return '';
142    var out = '';
143    var siblings = parent.children;
144    for (var i = 0; i < siblings.length; i++) {
145      var s = siblings[i];
146      if (s === el) continue;
147      var tag = s.tagName ? s.tagName.toUpperCase() : '';
148      if (tag === 'LINK' || tag === 'STYLE') out += s.outerHTML;
149    }
150    return out;
151  }
152
153  // Wait for any <link rel=stylesheet> in `container` to finish loading.
154  // Without this the inserted module content paints unstyled for a frame or
155  // two while its CSS is still in flight (FOUC). We resolve on each link's
156  // load OR error, and cap the total wait so a broken stylesheet doesn't
157  // keep the popup spinner hanging forever.
158  function waitForStylesheets(container, timeoutMs) {
159    var links = container.querySelectorAll('link[rel="stylesheet"]');
160    if (!links.length) return Promise.resolve();
161    var promises = [];
162    for (var i = 0; i < links.length; i++) {
163      (function (link) {
164        // Already loaded? .sheet is non-null and readable -> done.
165        try { if (link.sheet) { promises.push(Promise.resolve()); return; } } catch (_) {}
166        promises.push(new Promise(function (resolve) {
167          var done = false;
168          var finish = function () { if (done) return; done = true; resolve(); };
169          link.addEventListener('load', finish);
170          link.addEventListener('error', finish);
171          setTimeout(finish, timeoutMs || 1500);
172        }));
173      })(links[i]);
174    }
175    return Promise.all(promises);
176  }
177
178  // Scripts inserted via innerHTML are parsed but NEVER executed by the browser.
179  // Module payloads (the form submit handler unifiedSubmitForm, spam protection,
180  // AI assist, etc.) ship as inline <script> blocks, so without this they wire up
181  // nothing and the form silently fails to submit. Re-create each <script> as a
182  // fresh element so it runs. Only the fragment's own scripts are present here
183  // (head libraries live outside the extracted wrapper), so this stays cheap.
184  function executeScripts(container) {
185    var scripts = container.querySelectorAll('script');
186    // Legacy CMS layouts render their fragments through inline calls like
187    // AdGroup6(), Section1(), GetLogo() -- each emitted as
188    // `function X(){document.write(`…`)}`. When such a fragment is pulled into
189    // the popup, re-running that script calls document.write() AFTER the host
190    // page has finished loading, which implicitly fires document.open() and
191    // WIPES the entire current document -- the popup and the page behind it
192    // both go blank. Guard document.write/writeln for the duration of this
193    // batch and redirect any output to the executing script's own position, so
194    // the markup lands inside the popup instead of nuking the page.
195    var origWrite = document.write;
196    var origWriteln = document.writeln;
197    var writeTarget = null; // the <script> currently executing
198    function patchedWrite() {
199      var html = Array.prototype.join.call(arguments, '');
200      if (writeTarget && writeTarget.parentNode) {
201        try { writeTarget.insertAdjacentHTML('beforebegin', html); } catch (e) {}
202      }
203    }
204    try {
205      document.write = patchedWrite;
206      document.writeln = patchedWrite;
207      for (var i = 0; i < scripts.length; i++) {
208        var old = scripts[i];
209        var type = (old.getAttribute('type') || '').toLowerCase();
210        // Skip data/template blocks (JSON-LD, x-template, etc.) -- only run JS.
211        if (type && !/^(text\/javascript|application\/javascript|module)$/.test(type)) continue;
212        var s = document.createElement('script');
213        for (var a = 0; a < old.attributes.length; a++) {
214          s.setAttribute(old.attributes[a].name, old.attributes[a].value);
215        }
216        s.textContent = old.textContent;
217        if (!old.parentNode) continue;
218        // Insert the live script BEFORE the placeholder (so it already has a
219        // parent -- patchedWrite needs one for insertAdjacentHTML), which runs
220        // it synchronously for inline scripts, then drop the placeholder.
221        writeTarget = s;
222        old.parentNode.insertBefore(s, old);
223        old.parentNode.removeChild(old);
224      }
225    } finally {
226      document.write = origWrite;
227      document.writeln = origWriteln;
228      writeTarget = null;
229    }
230  }
231
232  // In a popup the form is rendered on a HOST page, so the form's submit handler
233  // default target (window.location.pathname) points at the host page, not the
234  // form's own URL. Stamp the real form path onto every <form> so the handler
235  // (cmsResolvePostUrl in TemplateProcessor) POSTs to the right endpoint.
236  function stampFormAction(container, sourceUrl) {
237    try {
238      var path = new URL(sourceUrl, window.location.origin).pathname;
239      var forms = container.querySelectorAll('form');
240      for (var i = 0; i < forms.length; i++) {
241        if (!forms[i].getAttribute('data-form-action')) {
242          forms[i].setAttribute('data-form-action', path);
243        }
244      }
245    } catch (e) {}
246  }
247
248  function extractMainContent(html) {
249    var doc;
250    try {
251      doc = new DOMParser().parseFromString(html, 'text/html');
252    } catch (e) { return null; }
253    for (var i = 0; i < POPUP_CONTENT_SELECTORS.length; i++) {
254      var el = doc.querySelector(POPUP_CONTENT_SELECTORS[i]);
255      if (el && el.innerHTML.trim()) {
256        return {
257          html: collectSiblingStyles(el) + el.outerHTML,
258          title: doc.title || ''
259        };
260      }
261    }
262    var main = doc.getElementById('MainContent');
263    if (main && main.innerHTML.trim()) return { html: main.innerHTML, title: doc.title || '' };
264    var body = doc.body;
265    if (body && body.innerHTML.trim()) return { html: body.innerHTML, title: doc.title || '' };
266    return null;
267  }
268
269  // Render a URL into the existing modal body. When pushHistory is true and
270  // we already had a URL loaded, push that previous one onto the back stack.
271  // On any failure, fall back to a full-page navigation rather than leaving
272  // the user staring at a half-loaded modal.
273  function renderUrl(url, title, pushHistory) {
274    if (!state.overlay) return;
275    if (pushHistory && state.currentUrl) {
276      state.history.push({ url: state.currentUrl, title: state.currentTitle });
277    }
278    state.currentUrl = url;
279    state.currentTitle = title || '';
280
281    var bodyEl = state.overlay.querySelector('.cms-popup-body');
282    var titleEl = state.overlay.querySelector('.cms-popup-title');
283    bodyEl.innerHTML = '<div class="cms-popup-loading">Loading…</div>';
284    titleEl.textContent = state.currentTitle;
285    updateBackButton();
286
287    fetch(url, { credentials: 'same-origin' })
288      .then(function (resp) {
289        if (!resp.ok) throw new Error('HTTP ' + resp.status);
290        return resp.text();
291      })
292      .then(function (html) {
293        var extracted = extractMainContent(html);
294        if (!extracted) throw new Error('no extractable content');
295        // Only mutate if the modal is still open and still showing this URL
296        // (user may have clicked back or closed during the fetch).
297        if (!state.overlay || state.currentUrl !== url) return;
298        if (!title && extracted.title) {
299          state.currentTitle = extracted.title;
300          titleEl.textContent = extracted.title;
301        }
302        // Stage content hidden while its <link rel=stylesheet> tags load,
303        // keep the spinner visible, then swap. Avoids the unstyled flash.
304        var stage = document.createElement('div');
305        stage.className = 'cms-popup-stage';
306        stage.style.visibility = 'hidden';
307        bodyEl.appendChild(stage);
308        stage.innerHTML = extracted.html;
309        // innerHTML doesn't run <script> tags -- stamp the form's real URL, then
310        // re-execute its scripts so the submit handler actually wires up.
311        stampFormAction(stage, url);
312        executeScripts(stage);
313        waitForStylesheets(stage, 1500).then(function () {
314          if (!state.overlay || state.currentUrl !== url) return;
315          var loader = bodyEl.querySelector('.cms-popup-loading');
316          if (loader && loader.parentNode) loader.parentNode.removeChild(loader);
317          stage.style.visibility = '';
318        });
319      })
320      .catch(function () {
321        closePopup();
322        window.location.href = url;
323      });
324  }
325
326  function goBack() {
327    if (state.history.length === 0) return;
328    var prev = state.history.pop();
329    renderUrl(prev.url, prev.title, false);
330  }
331
332  // Should an in-popup click be intercepted? Same rules as the outer handler
333  // -- modifier keys, middle-click, download attribute, off-origin, and
334  // mailto:/tel:/javascript: hrefs all pass through to the browser.
335  function shouldInterceptAnchor(e, a) {
336    if (e.button !== 0) return false;
337    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false;
338    if (a.target === '_blank' || a.target === '_top') return false;
339    if (a.hasAttribute('download')) return false;
340    var href = a.getAttribute('href');
341    if (!href || href.charAt(0) === '#') return false;
342    if (/^(mailto:|tel:|javascript:)/i.test(href)) return false;
343    if (!a.href || !isSameOrigin(a.href)) return false;
344    return true;
345  }
346
347  function handleInnerClick(e) {
348    if (!state.overlay) return;
349    var bodyEl = state.overlay.querySelector('.cms-popup-body');
350    if (!bodyEl || !bodyEl.contains(e.target)) return;
351    var a = e.target.closest && e.target.closest('a');
352    if (!a) return;
353    if (!shouldInterceptAnchor(e, a)) return;
354    e.preventDefault();
355    var title = a.getAttribute('data-popup-title') || (a.textContent || '').trim();
356    renderUrl(a.href, title, true);
357  }
358
359  // Keep GET form submissions inside the modal too (the directory search box
360  // is GET; classifieds/calendar searches likewise). POST forms pass through.
361  function handleInnerSubmit(e) {
362    if (!state.overlay) return;
363    var bodyEl = state.overlay.querySelector('.cms-popup-body');
364    if (!bodyEl || !bodyEl.contains(e.target)) return;
365    var form = e.target;
366    if (!form || form.tagName !== 'FORM') return;
367    var method = (form.getAttribute('method') || 'GET').toUpperCase();
368    if (method !== 'GET') return;
369    var action = form.getAttribute('action') || state.currentUrl || window.location.href;
370    var resolved;
371    try { resolved = new URL(action, window.location.href); } catch (_) { return; }
372    if (resolved.origin !== window.location.origin) return;
373    e.preventDefault();
374    var params = new URLSearchParams(new FormData(form));
375    // Strip out any unnamed/empty submitter entries
376    var qs = params.toString();
377    var sep = resolved.search ? '&' : '?';
378    var fullUrl = resolved.origin + resolved.pathname + resolved.search + (qs ? sep + qs : '') + resolved.hash;
379    renderUrl(fullUrl, state.currentTitle, true);
380  }
381
382  function openPopup(url, title) {
383    if (!url || !isSameOrigin(new URL(url, window.location.origin).href)) return;
384
385    if (state.overlay) closePopup();
386
387    state.history = [];
388    state.currentUrl = null;
389    state.currentTitle = '';
390
391    var dlg = buildSkeleton(title);
392    state.overlay = dlg;
393    document.body.appendChild(dlg);
394    document.body.classList.add('cms-popup-open');
395
396    var closeBtn = dlg.querySelector('.cms-popup-close');
397    var backBtn = dlg.querySelector('.cms-popup-back');
398    closeBtn.addEventListener('click', closePopup);
399    backBtn.addEventListener('click', goBack);
400
401    // Click on the backdrop (the dialog element itself, outside its visible
402    // box) closes the modal. With <dialog>, ::backdrop clicks bubble up to
403    // the dialog element, so we detect by checking whether the click point
404    // is inside the dialog's rendered rect.
405    dlg.addEventListener('click', function (e) {
406      if (e.target !== dlg) return;
407      var rect = dlg.getBoundingClientRect();
408      var inside = e.clientX >= rect.left && e.clientX <= rect.right
409                && e.clientY >= rect.top  && e.clientY <= rect.bottom;
410      if (!inside) closePopup();
411    });
412    dlg.addEventListener('click', handleInnerClick);
413    dlg.addEventListener('submit', handleInnerSubmit);
414
415    // ESC fires the 'cancel' event; default behavior is to close. We want
416    // our full cleanup, so route both 'cancel' and 'close' through closePopup,
417    // and remove the element from the DOM on actual close.
418    dlg.addEventListener('cancel', function (e) {
419      e.preventDefault();
420      closePopup();
421    });
422    dlg.addEventListener('close', function () {
423      if (dlg.parentNode) dlg.parentNode.removeChild(dlg);
424    });
425
426    // showModal() puts the dialog in the browser's top layer -- guaranteed
427    // to be viewport-positioned regardless of ancestor transforms. Some old
428    // browsers may not support it; fall back to appending visibly.
429    if (typeof dlg.showModal === 'function') {
430      try { dlg.showModal(); } catch (e) { dlg.setAttribute('open', ''); }
431    } else {
432      dlg.setAttribute('open', '');
433    }
434
435    try { closeBtn.focus(); } catch (e) {}
436
437    renderUrl(url, title, false);
438  }
439
440  // Delegated click handler. Plain left-clicks on a[data-cms-popup="1"] open
441  // the modal regardless of any target=_blank/_top inherited from the menu's
442  // link_window setting -- the popup flag is the user's explicit intent.
443  // Modifier keys and middle-click still bail so power users keep the
444  // "open in new tab" escape hatch via Ctrl/Cmd-click / middle-click.
445  document.addEventListener('click', function (e) {
446    if (e.button !== 0) return;
447    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
448    var a = e.target.closest && e.target.closest('a[data-cms-popup="1"]');
449    if (!a) return;
450    if (a.hasAttribute('download')) return;
451    if (!isSameOrigin(a.href)) return;
452    e.preventDefault();
453    state.lastTrigger = a;
454    var title = a.getAttribute('data-popup-title') || (a.textContent || '').trim();
455    openPopup(a.href, title);
456  });
457
458  // ESC closes
459  document.addEventListener('keydown', function (e) {
460    if (e.key === 'Escape' && state.overlay) closePopup();
461  });
462
463  // URL parameter — auto-open on page load
464  function autoOpenFromUrl() {
465    try {
466      var params = new URLSearchParams(window.location.search);
467      var url = params.get('popup');
468      if (!url) return;
469      var resolved = new URL(url, window.location.origin).href;
470      if (!isSameOrigin(resolved)) return;
471      openPopup(resolved, params.get('popup_title') || '');
472    } catch (e) {}
473  }
474  if (document.readyState === 'loading') {
475    document.addEventListener('DOMContentLoaded', autoOpenFromUrl);
476  } else {
477    autoOpenFromUrl();
478  }
479
480  window.cmsOpenPopup = openPopup;
481  window.cmsClosePopup = closePopup;
482})();

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.