chunks
2 weeks ago
vendor
9 months ago
admin.build.js
2 weeks ago
admin.js
3 months ago
analytics-tracker.js
1 month ago
analytics.build.js
2 weeks ago
blocks.build.js
3 days ago
carousel.js
1 year ago
custom-player.build.js
2 weeks ago
documents-viewer-script.js
3 months ago
ep-gr-elementor-control.js
3 days ago
ep-pdf-lightbox.js
3 months ago
ep-view-count.js
2 weeks ago
ep-yt-queue.js
1 month ago
feature-notices.js
7 months ago
feature-preview-modal.js
3 days ago
front.js
3 days ago
frontend.build.js
3 months ago
gallery-justify.js
2 weeks ago
google-reviews.build.js
3 days ago
google-reviews.js
3 days ago
gutneberg-script.js
2 months ago
index.html
7 years ago
initCarousel.js
2 years ago
initplyr.js
2 months ago
instafeed.js
1 month ago
instagram-shortcode-generator.js
1 month ago
lazy-load.js
6 months ago
license.js
3 months ago
meetup-timezone.js
3 months ago
onboarding.build.js
2 weeks ago
pdf-gallery-elementor-editor.js
3 months ago
pdf-gallery.js
3 months ago
preview.js
9 months ago
settings.js
3 months ago
sponsored.js
9 months ago
feature-preview-modal.js
558 lines
| 1 | /** |
| 2 | * EmbedPress — "What's New" Feature Preview Modal (frontend behaviour). |
| 3 | * |
| 4 | * Reads the JSON payload printed by FeaturePreviewModal::render(), builds the |
| 5 | * split modal DOM, and drives it: |
| 6 | * - single feature → no navigation |
| 7 | * - 2+ features → carousel with pager dots + Back/Next; "n of N" in the |
| 8 | * eyebrow; Next becomes "Done ✓" on the last step; the |
| 9 | * changelog link becomes "Skip · See the full changelog". |
| 10 | * |
| 11 | * Accessibility: dialog role, focus trap, ESC to close, focus returned to the |
| 12 | * trigger element on close. Any close path (X / ESC / backdrop / CTA / Done) |
| 13 | * persists the dismissal via admin-ajax so the modal won't re-fire for the |
| 14 | * same release version. |
| 15 | * |
| 16 | * Source file: static/js/feature-preview-modal.js |
| 17 | * Build output: assets/js/feature-preview-modal.js (mirror until vite build). |
| 18 | * |
| 19 | * Depends on the localized global `EmbedPressWhatsNew` (ajaxUrl/nonce/action/i18n). |
| 20 | */ |
| 21 | (function () { |
| 22 | 'use strict'; |
| 23 | |
| 24 | var cfg = window.EmbedPressWhatsNew || {}; |
| 25 | var i18n = cfg.i18n || {}; |
| 26 | |
| 27 | function sprintf(tpl, a, b) { |
| 28 | return String(tpl).replace('%1$d', a).replace('%2$d', b); |
| 29 | } |
| 30 | |
| 31 | // Emit a tracking beacon for any button/link click inside the What's New |
| 32 | // modal. Two sinks, both optional and non-blocking: |
| 33 | // - a `document` CustomEvent ('embedpress:whatsnew:click') following the |
| 34 | // same convention analytics-tracker.js uses ('embedpress:view'), so any |
| 35 | // listener (site analytics, integrations) can subscribe; |
| 36 | // - a GTM-style `window.dataLayer` push when a dataLayer is present. |
| 37 | // `action` names the control (cta / close / next / back / done / dot / |
| 38 | // changelog / whatWeCollect); `detail` carries slide + release context. |
| 39 | function beacon(action, extra) { |
| 40 | var detail = { action: action }; |
| 41 | if (extra) { |
| 42 | Object.keys(extra).forEach(function (k) { detail[k] = extra[k]; }); |
| 43 | } |
| 44 | try { |
| 45 | document.dispatchEvent(new CustomEvent('embedpress:whatsnew:click', { detail: detail })); |
| 46 | } catch (e) { /* no-op */ } |
| 47 | try { |
| 48 | window.dataLayer = window.dataLayer || []; |
| 49 | window.dataLayer.push({ |
| 50 | event: 'embedpress_whatsnew_click', |
| 51 | embedpress: detail |
| 52 | }); |
| 53 | } catch (e) { /* no-op */ } |
| 54 | } |
| 55 | |
| 56 | function el(tag, cls, attrs) { |
| 57 | var node = document.createElement(tag); |
| 58 | if (cls) { node.className = cls; } |
| 59 | if (attrs) { |
| 60 | Object.keys(attrs).forEach(function (k) { node.setAttribute(k, attrs[k]); }); |
| 61 | } |
| 62 | return node; |
| 63 | } |
| 64 | |
| 65 | // True when `url` points off this site — such links must open in a new tab |
| 66 | // so they never navigate the admin away from the current wp-admin screen. |
| 67 | // Internal admin URLs (e.g. the Release Notes page) resolve to the same |
| 68 | // host and stay in-tab. Empty / "#" / relative anchors are treated internal. |
| 69 | function isExternalUrl(url) { |
| 70 | if (!url || url === '#') { return false; } |
| 71 | try { |
| 72 | var target = new URL(url, window.location.href); |
| 73 | if (target.protocol !== 'http:' && target.protocol !== 'https:') { return false; } |
| 74 | return target.host !== window.location.host; |
| 75 | } catch (e) { |
| 76 | return false; |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // Apply target/rel for an anchor based on whether its href is external. |
| 81 | function applyLinkTarget(anchor, external) { |
| 82 | if (external) { |
| 83 | anchor.setAttribute('target', '_blank'); |
| 84 | anchor.setAttribute('rel', 'noopener noreferrer'); |
| 85 | } else { |
| 86 | anchor.removeAttribute('target'); |
| 87 | anchor.removeAttribute('rel'); |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | function init() { |
| 92 | var root = document.getElementById('embedpress-whatsnew-root'); |
| 93 | var dataNode = document.getElementById('embedpress-whatsnew-data'); |
| 94 | if (!root || !dataNode) { return; } |
| 95 | |
| 96 | var data; |
| 97 | try { |
| 98 | data = JSON.parse(dataNode.textContent); |
| 99 | } catch (e) { |
| 100 | return; |
| 101 | } |
| 102 | if (!data || !data.features || !data.features.length) { return; } |
| 103 | |
| 104 | var modal = new WhatsNewModal(root, data); |
| 105 | |
| 106 | // Developer switch (server-side `embedpress_whatsnew_autoopen` filter, |
| 107 | // default true). Auto-open: the modal opens as soon as the user lands on |
| 108 | // an EmbedPress page. Click-mode: it stays closed until the user clicks |
| 109 | // the flagged EmbedPress menu item — the blinking bubble is the cue. |
| 110 | if (cfg.autoOpen === false) { |
| 111 | bindMenuOpen(modal); |
| 112 | } else { |
| 113 | modal.open(); |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | /** |
| 118 | * Click-mode: open the modal when the user clicks the EmbedPress admin menu |
| 119 | * (the item carrying the blinking "New" bubble). We're already on an |
| 120 | * EmbedPress page, so the top-level/current menu link is a same-page click — |
| 121 | * intercept it and open the modal instead of just reloading. |
| 122 | */ |
| 123 | function bindMenuOpen(modal) { |
| 124 | var slug = cfg.menuSlug || 'embedpress'; |
| 125 | // The bubble marks the exact menu item; prefer it, then fall back to the |
| 126 | // menu anchor by slug. |
| 127 | var badge = document.querySelector('#adminmenu .ep-whatsnew-badge'); |
| 128 | var anchor = badge ? badge.closest('a') : null; |
| 129 | if (!anchor) { |
| 130 | anchor = document.querySelector('#adminmenu a[href*="page=' + slug + '"]'); |
| 131 | } |
| 132 | if (!anchor) { return; } |
| 133 | |
| 134 | anchor.addEventListener('click', function (e) { |
| 135 | // Only hijack a same-page click (already on an EmbedPress page); |
| 136 | // let normal navigation proceed otherwise. |
| 137 | e.preventDefault(); |
| 138 | modal.open(); |
| 139 | }); |
| 140 | } |
| 141 | |
| 142 | function WhatsNewModal(root, data) { |
| 143 | this.root = root; |
| 144 | this.data = data; |
| 145 | this.features = data.features; |
| 146 | this.multi = this.features.length > 1; |
| 147 | this.index = 0; |
| 148 | this.dismissed = false; |
| 149 | this.lastFocused = null; |
| 150 | this.build(); |
| 151 | } |
| 152 | |
| 153 | WhatsNewModal.prototype.build = function () { |
| 154 | var self = this; |
| 155 | this.root.removeAttribute('aria-hidden'); |
| 156 | this.root.className = 'epwn' + (this.multi ? ' epwn--multi' : ''); |
| 157 | this.root.setAttribute('role', 'dialog'); |
| 158 | this.root.setAttribute('aria-modal', 'true'); |
| 159 | this.root.setAttribute('aria-labelledby', 'epwn-title'); |
| 160 | |
| 161 | // --- modal shell --- |
| 162 | var modal = el('div', 'epwn__modal'); |
| 163 | |
| 164 | var close = el('button', 'epwn__close', { |
| 165 | type: 'button', |
| 166 | 'aria-label': i18n.close || 'Close' |
| 167 | }); |
| 168 | close.innerHTML = '✕'; |
| 169 | close.addEventListener('click', function () { self.beaconClick('close'); self.close(); }); |
| 170 | modal.appendChild(close); |
| 171 | |
| 172 | // --- left / preview --- |
| 173 | var left = el('div', 'epwn__left'); |
| 174 | this.badge = el('span', 'epwn__badge'); |
| 175 | left.appendChild(this.badge); |
| 176 | this.demo = el('div', 'epwn__demo'); |
| 177 | var bar = el('div', 'epwn__bar'); |
| 178 | bar.innerHTML = '<i></i><i></i><i></i>'; |
| 179 | this.stage = el('div', 'epwn__stage'); |
| 180 | this.demo.appendChild(bar); |
| 181 | this.demo.appendChild(this.stage); |
| 182 | left.appendChild(this.demo); |
| 183 | modal.appendChild(left); |
| 184 | |
| 185 | // --- right / copy --- |
| 186 | var right = el('div', 'epwn__right'); |
| 187 | this.right = right; |
| 188 | |
| 189 | // brand header: EmbedPress logo (version lives in the eyebrow) |
| 190 | var header = el('div', 'epwn__header'); |
| 191 | if (cfg.logoUrl) { |
| 192 | var logo = el('img', 'epwn__logo', { src: cfg.logoUrl, alt: 'EmbedPress' }); |
| 193 | header.appendChild(logo); |
| 194 | } |
| 195 | right.appendChild(header); |
| 196 | |
| 197 | this.eyebrow = el('span', 'epwn__eyebrow'); |
| 198 | this.title = el('h2', 'epwn__title', { id: 'epwn-title' }); |
| 199 | this.desc = el('p', 'epwn__desc'); |
| 200 | this.cta = el('a', 'epwn__cta', { href: '#' }); |
| 201 | this.cta.addEventListener('click', function (e) { |
| 202 | self.beaconClick('cta', { label: self.cta.textContent, url: self.cta.getAttribute('href') }); |
| 203 | // CTA counts as engagement → dismiss, then let the link proceed. |
| 204 | self.persistDismiss(); |
| 205 | if (!self.cta.getAttribute('href') || self.cta.getAttribute('href') === '#') { |
| 206 | e.preventDefault(); |
| 207 | self.close(); |
| 208 | } |
| 209 | }); |
| 210 | |
| 211 | right.appendChild(this.eyebrow); |
| 212 | right.appendChild(this.title); |
| 213 | right.appendChild(this.desc); |
| 214 | right.appendChild(el('div', 'epwn__grow')); |
| 215 | right.appendChild(this.cta); |
| 216 | |
| 217 | // nav (always built; shown only when multi via CSS) |
| 218 | var nav = el('div', 'epwn__nav'); |
| 219 | this.backBtn = el('button', 'epwn__back', { type: 'button' }); |
| 220 | this.backBtn.textContent = i18n.back || '← Back'; |
| 221 | this.dots = el('div', 'epwn__dots'); |
| 222 | this.nextBtn = el('button', 'epwn__next', { type: 'button' }); |
| 223 | this.backBtn.addEventListener('click', function () { self.beaconClick('back'); self.prev(); }); |
| 224 | this.nextBtn.addEventListener('click', function () { |
| 225 | self.beaconClick(self.index < self.features.length - 1 ? 'next' : 'done'); |
| 226 | self.next(); |
| 227 | }); |
| 228 | nav.appendChild(this.backBtn); |
| 229 | nav.appendChild(this.dots); |
| 230 | nav.appendChild(this.nextBtn); |
| 231 | right.appendChild(nav); |
| 232 | |
| 233 | // Bottom link. Text + href are set PER SLIDE in render(): the changelog |
| 234 | // link on every slide, except the last slide becomes "What we collect" |
| 235 | // → privacy policy when tracking isn't opted-in. |
| 236 | // - changelog link → opens in a new tab AND dismisses the modal. |
| 237 | // - "What we collect" → opens the policy in a new tab ONLY; the popup |
| 238 | // stays open (it's not a dismissal, just a reference link). |
| 239 | var chg = el('div', 'epwn__chglink'); |
| 240 | this.chgLink = el('a', null, { href: this.data.changelogUrl || '#', target: '_blank', rel: 'noopener noreferrer' }); |
| 241 | this.chgLink.addEventListener('click', function (e) { |
| 242 | e.preventDefault(); |
| 243 | self.beaconClick(self.chgLinkIsWhatWeCollect ? 'whatWeCollect' : 'changelog', { |
| 244 | url: self.chgLink.getAttribute('href') |
| 245 | }); |
| 246 | var url = self.chgLink.getAttribute('href'); |
| 247 | if (url && url !== '#') { |
| 248 | window.open(url, '_blank', 'noopener,noreferrer'); |
| 249 | } |
| 250 | // "What we collect" is a reference link — do NOT close/dismiss. |
| 251 | if (!self.chgLinkIsWhatWeCollect) { |
| 252 | self.close(); |
| 253 | } |
| 254 | }); |
| 255 | chg.appendChild(this.chgLink); |
| 256 | right.appendChild(chg); |
| 257 | |
| 258 | modal.appendChild(right); |
| 259 | this.root.appendChild(modal); |
| 260 | |
| 261 | // backdrop click closes |
| 262 | this.root.addEventListener('mousedown', function (e) { |
| 263 | if (e.target === self.root) { self.close(); } |
| 264 | }); |
| 265 | |
| 266 | // keyboard: ESC + focus trap |
| 267 | this.keyHandler = function (e) { self.onKeydown(e); }; |
| 268 | document.addEventListener('keydown', this.keyHandler); |
| 269 | |
| 270 | this.render(); |
| 271 | }; |
| 272 | |
| 273 | WhatsNewModal.prototype.render = function () { |
| 274 | var f = this.features[this.index]; |
| 275 | var multi = this.multi; |
| 276 | |
| 277 | // media |
| 278 | this.stage.innerHTML = ''; |
| 279 | var m = f.media || {}; |
| 280 | this.badge.textContent = m.badge || ''; |
| 281 | this.badge.style.display = m.badge ? '' : 'none'; |
| 282 | |
| 283 | // File media (image/gif/video) has a natural aspect ratio and should |
| 284 | // size to its content — the demo panel then centers in the column |
| 285 | // rather than stretching to full height (which gaps a landscape clip). |
| 286 | // html demos (e.g. Google Reviews) are built to fill, so they keep it. |
| 287 | this.demo.classList.toggle('epwn__demo--flat', m.type !== 'html'); |
| 288 | |
| 289 | if (m.type === 'html' && m.html) { |
| 290 | this.stage.innerHTML = m.html; |
| 291 | } else if (m.type === 'video' && m.src) { |
| 292 | // A CDN-hosted clip can lag on first load; show the shimmer until |
| 293 | // the first frame is ready, then reveal (same affordance as images). |
| 294 | var vstage = this.stage; |
| 295 | vstage.classList.add('epwn__stage--loading'); |
| 296 | var v = el('video', null, { src: m.src, autoplay: '', muted: '', loop: '', playsinline: '', preload: 'auto' }); |
| 297 | v.muted = true; |
| 298 | if (m.poster) { v.setAttribute('poster', m.poster); } |
| 299 | var vreveal = function () { vstage.classList.remove('epwn__stage--loading'); }; |
| 300 | v.addEventListener('loadeddata', vreveal); |
| 301 | v.addEventListener('error', vreveal); |
| 302 | this.stage.appendChild(v); |
| 303 | } else if (m.src) { |
| 304 | // Large images/GIFs may still be downloading when the modal opens |
| 305 | // (they're preloaded on page load, but a cold cache can lag). Show a |
| 306 | // shimmer on the stage until the image decodes, then reveal it. |
| 307 | var stage = this.stage; |
| 308 | stage.classList.add('epwn__stage--loading'); |
| 309 | var img = el('img', null, { src: m.src, alt: f.title || '' }); |
| 310 | var reveal = function () { stage.classList.remove('epwn__stage--loading'); }; |
| 311 | if (img.complete) { reveal(); } |
| 312 | else { |
| 313 | img.addEventListener('load', reveal); |
| 314 | img.addEventListener('error', reveal); |
| 315 | } |
| 316 | stage.appendChild(img); |
| 317 | } |
| 318 | |
| 319 | // copy |
| 320 | this.eyebrow.textContent = multi |
| 321 | ? f.eyebrow + ' · ' + sprintf(i18n.counter || '%1$d of %2$d', this.index + 1, this.features.length) |
| 322 | : f.eyebrow; |
| 323 | this.title.textContent = f.title; |
| 324 | this.desc.innerHTML = f.desc; // server-side wp_kses_post'd |
| 325 | |
| 326 | // cta |
| 327 | if (f.cta && f.cta.label) { |
| 328 | this.cta.textContent = f.cta.label; |
| 329 | this.cta.style.display = ''; |
| 330 | this.cta.setAttribute('href', f.cta.url || '#'); |
| 331 | // Honour the explicit `external` flag, but also open in a new tab |
| 332 | // for any off-site URL so a doc/changelog CTA never navigates the |
| 333 | // admin away from the current screen even if the flag was omitted. |
| 334 | applyLinkTarget(this.cta, f.cta.external || isExternalUrl(f.cta.url)); |
| 335 | } else { |
| 336 | this.cta.style.display = 'none'; |
| 337 | } |
| 338 | |
| 339 | // bottom-link copy + href, per slide. On the FIRST slide, and only when |
| 340 | // whatWeCollectUrl is set (tracking not yet opted-in), the link becomes |
| 341 | // "What we collect" → privacy policy — the disclosure shown up front, |
| 342 | // before the user clicks Next (which is what grants consent). Every |
| 343 | // other slide keeps the changelog / "Skip" link. |
| 344 | var isFirst = this.index === 0; |
| 345 | if (this.data.whatWeCollectUrl && isFirst) { |
| 346 | this.chgLink.textContent = i18n.whatWeCollect || 'What we collect'; |
| 347 | this.chgLink.setAttribute('href', this.data.whatWeCollectUrl); |
| 348 | // Mark this as the reference link so its click won't dismiss. |
| 349 | this.chgLinkIsWhatWeCollect = true; |
| 350 | } else { |
| 351 | this.chgLink.textContent = multi ? (i18n.skip || 'Skip') : (i18n.changelog || 'See the full changelog'); |
| 352 | this.chgLink.setAttribute('href', this.data.changelogUrl || '#'); |
| 353 | this.chgLinkIsWhatWeCollect = false; |
| 354 | } |
| 355 | |
| 356 | // fade the copy column |
| 357 | this.right.classList.remove('epwn__fade'); |
| 358 | void this.right.offsetWidth; |
| 359 | this.right.classList.add('epwn__fade'); |
| 360 | |
| 361 | // nav state |
| 362 | if (multi) { |
| 363 | this.dots.innerHTML = ''; |
| 364 | for (var k = 0; k < this.features.length; k++) { |
| 365 | var dot = el('button', 'epwn__dot' + (k === this.index ? ' is-on' : ''), { |
| 366 | type: 'button', |
| 367 | 'aria-label': sprintf(i18n.counter || '%1$d of %2$d', k + 1, this.features.length) |
| 368 | }); |
| 369 | (function (idx, modalRef) { |
| 370 | dot.addEventListener('click', function () { |
| 371 | modalRef.beaconClick('dot', { to: idx + 1 }); |
| 372 | modalRef.goTo(idx); |
| 373 | }); |
| 374 | })(k, this); |
| 375 | this.dots.appendChild(dot); |
| 376 | } |
| 377 | this.backBtn.style.visibility = this.index > 0 ? 'visible' : 'hidden'; |
| 378 | this.nextBtn.textContent = this.index < this.features.length - 1 |
| 379 | ? (i18n.next || 'Next →') |
| 380 | : (i18n.done || 'Done ✓'); |
| 381 | } |
| 382 | }; |
| 383 | |
| 384 | // Emit a click beacon carrying this modal's context (release version + |
| 385 | // current slide) merged with per-control extras. Every interactive control |
| 386 | // in the modal routes through here so a listener sees a uniform payload. |
| 387 | WhatsNewModal.prototype.beaconClick = function (action, extra) { |
| 388 | var f = this.features[this.index] || {}; |
| 389 | var base = { |
| 390 | version: this.data.version || '', |
| 391 | release: this.data.id || '', |
| 392 | slide: this.index + 1, |
| 393 | slides: this.features.length, |
| 394 | title: f.title || '' |
| 395 | }; |
| 396 | if (extra) { |
| 397 | Object.keys(extra).forEach(function (k) { base[k] = extra[k]; }); |
| 398 | } |
| 399 | beacon(action, base); |
| 400 | // Consent is Next/Done only — advancing past the "What we collect" |
| 401 | // disclosure on slide 1 is the affirmative action. Dismissing (close / |
| 402 | // X / ESC / backdrop / Back / dots / reference link) never enables |
| 403 | // tracking. Fired once; server-side is idempotent and only wired when |
| 404 | // tracking isn't already on. |
| 405 | if (action === 'next' || action === 'done') { |
| 406 | this.persistConsent(); |
| 407 | } |
| 408 | }; |
| 409 | |
| 410 | // Fire-and-forget the consent opt-in. Guarded so it posts at most once per |
| 411 | // modal instance, and only when the server flagged consent as needed |
| 412 | // (tracking not already enabled). |
| 413 | WhatsNewModal.prototype.persistConsent = function () { |
| 414 | if (this.consented) { return; } |
| 415 | if (!cfg.consentNeeded || !cfg.ajaxUrl || !cfg.consentAction) { return; } |
| 416 | this.consented = true; |
| 417 | var body = new URLSearchParams(); |
| 418 | body.set('action', cfg.consentAction); |
| 419 | body.set('nonce', cfg.nonce || ''); |
| 420 | fetch(cfg.ajaxUrl, { |
| 421 | method: 'POST', |
| 422 | credentials: 'same-origin', |
| 423 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 424 | body: body.toString(), |
| 425 | keepalive: true |
| 426 | }).catch(function () {}); |
| 427 | }; |
| 428 | |
| 429 | WhatsNewModal.prototype.goTo = function (i) { |
| 430 | if (i < 0 || i > this.features.length - 1 || i === this.index) { return; } |
| 431 | this.index = i; |
| 432 | this.render(); |
| 433 | }; |
| 434 | |
| 435 | WhatsNewModal.prototype.next = function () { |
| 436 | if (this.index < this.features.length - 1) { |
| 437 | this.index++; |
| 438 | this.render(); |
| 439 | } else { |
| 440 | // "Done ✓" on the last step closes + dismisses. |
| 441 | this.close(); |
| 442 | } |
| 443 | }; |
| 444 | |
| 445 | WhatsNewModal.prototype.prev = function () { |
| 446 | if (this.index > 0) { |
| 447 | this.index--; |
| 448 | this.render(); |
| 449 | } |
| 450 | }; |
| 451 | |
| 452 | WhatsNewModal.prototype.focusables = function () { |
| 453 | return this.root.querySelectorAll( |
| 454 | 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])' |
| 455 | ); |
| 456 | }; |
| 457 | |
| 458 | WhatsNewModal.prototype.onKeydown = function (e) { |
| 459 | if (e.key === 'Escape') { |
| 460 | e.preventDefault(); |
| 461 | this.close(); |
| 462 | return; |
| 463 | } |
| 464 | if (e.key === 'Tab') { |
| 465 | var items = Array.prototype.filter.call(this.focusables(), function (n) { |
| 466 | return n.offsetParent !== null && n.style.display !== 'none' && n.style.visibility !== 'hidden'; |
| 467 | }); |
| 468 | if (!items.length) { return; } |
| 469 | var first = items[0]; |
| 470 | var last = items[items.length - 1]; |
| 471 | if (e.shiftKey && document.activeElement === first) { |
| 472 | e.preventDefault(); |
| 473 | last.focus(); |
| 474 | } else if (!e.shiftKey && document.activeElement === last) { |
| 475 | e.preventDefault(); |
| 476 | first.focus(); |
| 477 | } |
| 478 | } |
| 479 | }; |
| 480 | |
| 481 | WhatsNewModal.prototype.open = function () { |
| 482 | var self = this; |
| 483 | this.lastFocused = document.activeElement; |
| 484 | // Mark "opened" (NOT "seen"): clears the menu "New" indicator now that |
| 485 | // the modal has been opened at least once — but does NOT dismiss the |
| 486 | // modal. The modal keeps re-showing until a real dismiss/Done stamps |
| 487 | // "seen" via persistDismiss(). These are two independent markers. |
| 488 | this.persistOpened(); |
| 489 | var badge = document.querySelector('#adminmenu .ep-whatsnew-badge'); |
| 490 | if (badge && badge.parentNode) { badge.parentNode.removeChild(badge); } |
| 491 | // next frame so the transition runs |
| 492 | requestAnimationFrame(function () { |
| 493 | self.root.classList.add('is-open'); |
| 494 | var first = self.focusables()[0]; |
| 495 | if (first) { first.focus(); } |
| 496 | }); |
| 497 | }; |
| 498 | |
| 499 | // Fire-and-forget the "opened" marker. Guarded so it only posts once per |
| 500 | // modal instance. |
| 501 | WhatsNewModal.prototype.persistOpened = function () { |
| 502 | if (this.opened) { return; } |
| 503 | this.opened = true; |
| 504 | if (!cfg.ajaxUrl || !cfg.openedAction) { return; } |
| 505 | var body = new URLSearchParams(); |
| 506 | body.set('action', cfg.openedAction); |
| 507 | body.set('nonce', cfg.nonce || ''); |
| 508 | body.set('version', this.data.version || ''); |
| 509 | fetch(cfg.ajaxUrl, { |
| 510 | method: 'POST', |
| 511 | credentials: 'same-origin', |
| 512 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 513 | body: body.toString(), |
| 514 | keepalive: true |
| 515 | }).catch(function () {}); |
| 516 | }; |
| 517 | |
| 518 | WhatsNewModal.prototype.persistDismiss = function () { |
| 519 | if (this.dismissed) { return; } |
| 520 | this.dismissed = true; |
| 521 | if (!cfg.ajaxUrl || !cfg.action) { return; } |
| 522 | var body = new URLSearchParams(); |
| 523 | body.set('action', cfg.action); |
| 524 | body.set('nonce', cfg.nonce || ''); |
| 525 | body.set('version', this.data.version || ''); |
| 526 | // Fire-and-forget; keepalive so it survives navigation on CTA click. |
| 527 | fetch(cfg.ajaxUrl, { |
| 528 | method: 'POST', |
| 529 | credentials: 'same-origin', |
| 530 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 531 | body: body.toString(), |
| 532 | keepalive: true |
| 533 | }).catch(function () {}); |
| 534 | }; |
| 535 | |
| 536 | WhatsNewModal.prototype.close = function () { |
| 537 | var self = this; |
| 538 | this.persistDismiss(); |
| 539 | // (The menu badge was already removed on open() — clearing it there is |
| 540 | // what decouples "seen the badge" from "dismissed the modal".) |
| 541 | this.root.classList.remove('is-open'); |
| 542 | document.removeEventListener('keydown', this.keyHandler); |
| 543 | setTimeout(function () { |
| 544 | self.root.setAttribute('aria-hidden', 'true'); |
| 545 | self.root.innerHTML = ''; |
| 546 | }, 240); |
| 547 | if (this.lastFocused && this.lastFocused.focus) { |
| 548 | this.lastFocused.focus(); |
| 549 | } |
| 550 | }; |
| 551 | |
| 552 | if (document.readyState === 'loading') { |
| 553 | document.addEventListener('DOMContentLoaded', init); |
| 554 | } else { |
| 555 | init(); |
| 556 | } |
| 557 | })(); |
| 558 |