| 1 |
import {iframeResize} from 'iframe-resizer'; |
| 2 |
import EMBED_CSS from './styles.scss?inline'; |
| 3 |
|
| 4 |
/** |
| 5 |
* External embed script for GiveWP donation forms. |
| 6 |
* |
| 7 |
* Loaded on non-WordPress sites, so it must stay self-contained: no |
| 8 |
* WordPress packages, no React. It registers the <givewp-donation-form> |
| 9 |
* custom element, which renders the form in an iframe pointed at the |
| 10 |
* donation-form-view route on the WordPress site. |
| 11 |
* |
| 12 |
* Display styles mirror the WordPress embeds: `onpage` (default) renders the |
| 13 |
* form inline, `modal` renders a button that opens the form in an overlay, |
| 14 |
* `newTab` renders a button that links to the standalone form page. |
| 15 |
* |
| 16 |
* Everything about the WordPress site comes from the WordPress site: the |
| 17 |
* route that serves this script prints window.givewpDonationFormEmbed ahead |
| 18 |
* of it (see GetExternalEmbedScriptData) with the home URL, the route URLs |
| 19 |
* the iframe loads, the form's own page URL, the offsite return parameters, |
| 20 |
* and the donor-facing strings in the site's locale. The snippet only has |
| 21 |
* to name the form; text attributes override per element. |
| 22 |
* |
| 23 |
* @since 4.17.0 |
| 24 |
*/ |
| 25 |
|
| 26 |
const LOAD_TIMEOUT_MS = 10000; |
| 27 |
|
| 28 |
type EmbedData = { |
| 29 |
homeUrl: string; |
| 30 |
formViewUrl: string; |
| 31 |
receiptViewUrl: string; |
| 32 |
formPageUrl: string; |
| 33 |
receiptReturn: { |
| 34 |
match: Record<string, string>; |
| 35 |
embedIdParam: string; |
| 36 |
receiptIdParam: string; |
| 37 |
}; |
| 38 |
i18n: { |
| 39 |
donate: string; |
| 40 |
loading: string; |
| 41 |
formTitle: string; |
| 42 |
openForm: string; |
| 43 |
close: string; |
| 44 |
}; |
| 45 |
/** Server-rendered skeleton markup by form id, for the forms the script URL named. */ |
| 46 |
skeletons?: Record<string, string>; |
| 47 |
}; |
| 48 |
|
| 49 |
declare const window: { |
| 50 |
givewpDonationFormEmbed?: EmbedData; |
| 51 |
givewpDonationFormEmbedSkeletons?: Record<string, string>; |
| 52 |
} & Window; |
| 53 |
|
| 54 |
/* |
| 55 |
* Absent only when the file is loaded from somewhere other than the route |
| 56 |
* (a direct build path), which is unsupported: without the site's URLs the |
| 57 |
* element has nothing to embed, and connectedCallback says so. |
| 58 |
*/ |
| 59 |
const DATA: EmbedData | undefined = window.givewpDonationFormEmbed; |
| 60 |
|
| 61 |
/** |
| 62 |
* Skeletons from every script instance on the page, keyed by form id. A page |
| 63 |
* with two forms loads the script once per `?form-id`; the second run skips |
| 64 |
* defining the element but still merges its skeletons here, then (at the end |
| 65 |
* of this file) tells the elements already waiting to pick theirs up. |
| 66 |
*/ |
| 67 |
const SKELETONS: Record<string, string> = (window.givewpDonationFormEmbedSkeletons ??= {}); |
| 68 |
Object.assign(SKELETONS, DATA?.skeletons); |
| 69 |
|
| 70 |
const I18N: EmbedData['i18n'] = { |
| 71 |
donate: 'Donate', |
| 72 |
loading: 'Loading', |
| 73 |
formTitle: 'Donation Form', |
| 74 |
openForm: 'Open donation form', |
| 75 |
close: 'Close', |
| 76 |
...DATA?.i18n, |
| 77 |
}; |
| 78 |
|
| 79 |
/** |
| 80 |
* Embed ids must be stable across page loads: an offsite gateway (e.g. |
| 81 |
* PayPal) returns the donor to this page with the embed id in the URL, and |
| 82 |
* the matching element swaps itself to the receipt view. A DOM-order counter |
| 83 |
* is stable; a random or time-based id is not. |
| 84 |
*/ |
| 85 |
let embedInstance = 0; |
| 86 |
|
| 87 |
const STYLE_ID = 'givewp-embed-styles'; |
| 88 |
|
| 89 |
/** |
| 90 |
* The same icon the WordPress block's modal close button draws |
| 91 |
* (Campaigns/Blocks/shared/components/ModalForm/ModalClose.tsx). |
| 92 |
*/ |
| 93 |
const CLOSE_ICON_SVG = |
| 94 |
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false">' + |
| 95 |
'<path stroke="black" stroke-width="2" d="M13 11.8l6.1-6.3-1-1-6.1 6.2-6.1-6.2-1 1 6.1 6.3-6.5 6.7 1 1 6.5-6.6 6.5 6.6 1-1z"></path>' + |
| 96 |
'</svg>'; |
| 97 |
|
| 98 |
const EXIT_ANIMATION_MS = 150; |
| 99 |
|
| 100 |
/** |
| 101 |
* A ceiling on the height the shell message may ask for, so a broken |
| 102 |
* measurement cannot stretch the host page. iframe-resizer corrects it at the |
| 103 |
* handshake. |
| 104 |
*/ |
| 105 |
const MAX_SHELL_HEIGHT_PX = 5000; |
| 106 |
|
| 107 |
/** |
| 108 |
* Runs the callback once all deferred scripts on the page have executed, which |
| 109 |
* is DOMContentLoaded, or at once when that has already happened. Deferred |
| 110 |
* scripts run at readyState "interactive", the same value the document keeps |
| 111 |
* after DOMContentLoaded until load, so the event itself is checked through |
| 112 |
* navigation timing instead. |
| 113 |
*/ |
| 114 |
function afterDeferredScripts(callback: () => void) { |
| 115 |
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined; |
| 116 |
const fired = document.readyState === 'complete' || (navigation?.domContentLoadedEventEnd ?? 0) > 0; |
| 117 |
|
| 118 |
if (fired) { |
| 119 |
callback(); |
| 120 |
return; |
| 121 |
} |
| 122 |
|
| 123 |
document.addEventListener('DOMContentLoaded', callback, {once: true}); |
| 124 |
} |
| 125 |
|
| 126 |
function injectStyles() { |
| 127 |
if (document.getElementById(STYLE_ID)) { |
| 128 |
return; |
| 129 |
} |
| 130 |
|
| 131 |
const style = document.createElement('style'); |
| 132 |
style.id = STYLE_ID; |
| 133 |
style.textContent = EMBED_CSS; |
| 134 |
document.head.appendChild(style); |
| 135 |
} |
| 136 |
|
| 137 |
class GiveWPDonationForm extends HTMLElement { |
| 138 |
iframe: HTMLIFrameElement | null = null; |
| 139 |
wpOrigin: string = ''; |
| 140 |
formId: string = ''; |
| 141 |
embedId: string = ''; |
| 142 |
overlay: HTMLElement | null = null; |
| 143 |
modalButton: HTMLButtonElement | null = null; |
| 144 |
initialized: boolean = false; |
| 145 |
scrollOnInit: boolean = false; |
| 146 |
keydownHandler: ((event: KeyboardEvent) => void) | null = null; |
| 147 |
|
| 148 |
/** |
| 149 |
* Where focus returns when the modal closes: whatever had focus when it |
| 150 |
* was last opened. Tracked on the element because the overlay is built |
| 151 |
* once and reopened, and a receipt return opens it with nothing focused. |
| 152 |
*/ |
| 153 |
launcher: HTMLElement | null = null; |
| 154 |
|
| 155 |
/** |
| 156 |
* Runs when the form view announces its shell (see below); set by |
| 157 |
* renderForm for the on-page style only. |
| 158 |
*/ |
| 159 |
onShell: ((height: number) => void) | null = null; |
| 160 |
|
| 161 |
/** |
| 162 |
* The on-page loading state, while it is showing. |
| 163 |
*/ |
| 164 |
loading: HTMLElement | null = null; |
| 165 |
hasSkeleton: boolean = false; |
| 166 |
|
| 167 |
/** |
| 168 |
* Messages from the form inside the iframe. Only the WordPress origin and |
| 169 |
* this element's own iframe window are listened to. |
| 170 |
* |
| 171 |
* `givewp-embed-shell`: the form view has painted its server-rendered |
| 172 |
* skeleton and says how tall it is, so the on-page embed can show the |
| 173 |
* iframe before the app bundles finish loading. |
| 174 |
* |
| 175 |
* `givewp-navigate`: the form app asks the parent page to navigate when |
| 176 |
* it cannot navigate window.top itself (see navigateTop.ts). Only a valid |
| 177 |
* http(s) URL is honored. |
| 178 |
*/ |
| 179 |
messageHandler = (event: MessageEvent) => { |
| 180 |
if (event.origin !== this.wpOrigin) { |
| 181 |
return; |
| 182 |
} |
| 183 |
|
| 184 |
if (!event.data || typeof event.data !== 'object') { |
| 185 |
return; |
| 186 |
} |
| 187 |
|
| 188 |
if (event.source !== this.iframe?.contentWindow) { |
| 189 |
return; |
| 190 |
} |
| 191 |
|
| 192 |
if (event.data.type === 'givewp-embed-shell') { |
| 193 |
const height = Number(event.data.height); |
| 194 |
if (Number.isFinite(height) && height > 0) { |
| 195 |
this.onShell?.(Math.min(height, MAX_SHELL_HEIGHT_PX)); |
| 196 |
} |
| 197 |
return; |
| 198 |
} |
| 199 |
|
| 200 |
if (event.data.type !== 'givewp-navigate') { |
| 201 |
return; |
| 202 |
} |
| 203 |
|
| 204 |
let url: URL; |
| 205 |
try { |
| 206 |
url = new URL(event.data.url); |
| 207 |
} catch (e) { |
| 208 |
return; |
| 209 |
} |
| 210 |
|
| 211 |
if (url.protocol === 'http:' || url.protocol === 'https:') { |
| 212 |
window.location.assign(url.toString()); |
| 213 |
} |
| 214 |
}; |
| 215 |
|
| 216 |
/** |
| 217 |
* Reads the attributes and renders the chosen display style. Runs again |
| 218 |
* when an SPA reattaches the element, so everything after the initialized |
| 219 |
* guard happens once. |
| 220 |
*/ |
| 221 |
connectedCallback() { |
| 222 |
const formId = this.getAttribute('form-id'); |
| 223 |
|
| 224 |
if (!formId) { |
| 225 |
console.error('givewp-donation-form requires a form-id attribute.'); |
| 226 |
return; |
| 227 |
} |
| 228 |
|
| 229 |
if (!DATA) { |
| 230 |
console.error( |
| 231 |
"givewp-donation-form: load the script from the WordPress site's embed URL (see the form builder's embed snippet)." |
| 232 |
); |
| 233 |
return; |
| 234 |
} |
| 235 |
|
| 236 |
window.addEventListener('message', this.messageHandler); |
| 237 |
if (this.keydownHandler) { |
| 238 |
document.addEventListener('keydown', this.keydownHandler); |
| 239 |
} |
| 240 |
|
| 241 |
// SPA frameworks detach and reattach elements; the children and state |
| 242 |
// survive that, so only listeners need re-adding. |
| 243 |
if (this.initialized) { |
| 244 |
return; |
| 245 |
} |
| 246 |
this.initialized = true; |
| 247 |
|
| 248 |
injectStyles(); |
| 249 |
|
| 250 |
// The launcher button is host-page chrome, so the host page styles it: |
| 251 |
// set --givewp-primary-color on givewp-donation-form in its CSS. The |
| 252 |
// attribute is a shortcut for the same property. Nothing about the |
| 253 |
// form's own colors is baked into the snippet; the form inside the |
| 254 |
// iframe resolves those itself on every load. |
| 255 |
const primaryColor = this.getAttribute('primary-color'); |
| 256 |
if (primaryColor) { |
| 257 |
this.style.setProperty('--givewp-primary-color', primaryColor); |
| 258 |
} |
| 259 |
|
| 260 |
this.formId = formId; |
| 261 |
this.wpOrigin = new URL(DATA.homeUrl).origin; |
| 262 |
this.embedId = `givewp-embed-external-${embedInstance++}`; |
| 263 |
|
| 264 |
const displayStyle = this.getAttribute('display-style') || 'onpage'; |
| 265 |
const isReceiptReturn = this.isReceiptReturn(); |
| 266 |
const src = isReceiptReturn ? this.getReceiptViewUrl() : this.getFormViewUrl(formId); |
| 267 |
|
| 268 |
if (displayStyle === 'newTab') { |
| 269 |
this.renderNewTabButton(); |
| 270 |
} else if (displayStyle === 'modal') { |
| 271 |
this.renderModalButton(src); |
| 272 |
|
| 273 |
// A donor returning from a gateway redirect needs their receipt |
| 274 |
// without having to find the button again. |
| 275 |
if (isReceiptReturn) { |
| 276 |
this.openModal(src); |
| 277 |
} |
| 278 |
} else { |
| 279 |
this.scrollOnInit = isReceiptReturn; |
| 280 |
this.renderForm(src, this); |
| 281 |
} |
| 282 |
|
| 283 |
if (isReceiptReturn) { |
| 284 |
this.consumeReturnParams(); |
| 285 |
} |
| 286 |
} |
| 287 |
|
| 288 |
/** |
| 289 |
* The return params are one-time input; leaving them in the address bar |
| 290 |
* makes the URL ugly to share and replays the receipt on every reload. |
| 291 |
*/ |
| 292 |
consumeReturnParams() { |
| 293 |
const {match, embedIdParam, receiptIdParam} = DATA.receiptReturn; |
| 294 |
const url = new URL(window.location.href); |
| 295 |
[...Object.keys(match), embedIdParam, receiptIdParam].forEach((param) => url.searchParams.delete(param)); |
| 296 |
window.history.replaceState(window.history.state, '', url.toString()); |
| 297 |
} |
| 298 |
|
| 299 |
/** |
| 300 |
* Drops the document-level listeners; the DOM subtree is left intact so a |
| 301 |
* reattach does not rebuild the iframe. |
| 302 |
*/ |
| 303 |
disconnectedCallback() { |
| 304 |
window.removeEventListener('message', this.messageHandler); |
| 305 |
if (this.keydownHandler) { |
| 306 |
document.removeEventListener('keydown', this.keydownHandler); |
| 307 |
} |
| 308 |
} |
| 309 |
|
| 310 |
/** |
| 311 |
* Label for the modal and new-tab launchers. |
| 312 |
*/ |
| 313 |
getButtonText(): string { |
| 314 |
return this.getAttribute('button-text') || I18N.donate; |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* The iframe src for the form, pointed at the donation-form-view route |
| 319 |
* with the host page as origin-url so gateway redirects can come back. |
| 320 |
*/ |
| 321 |
getFormViewUrl(formId: string): string { |
| 322 |
// Origin and pathname only: the page's query string and fragment may |
| 323 |
// carry data that should not be forwarded to the WordPress site, and |
| 324 |
// the offsite return flow appends its own parameters anyway. |
| 325 |
const originUrl = new URL(window.location.href); |
| 326 |
originUrl.search = ''; |
| 327 |
originUrl.hash = ''; |
| 328 |
|
| 329 |
const url = new URL(DATA.formViewUrl); |
| 330 |
url.searchParams.set('form-id', formId); |
| 331 |
url.searchParams.set('origin-url', originUrl.toString()); |
| 332 |
url.searchParams.set('embed-id', this.embedId); |
| 333 |
|
| 334 |
const locale = this.getAttribute('locale'); |
| 335 |
if (locale) { |
| 336 |
url.searchParams.set('locale', locale); |
| 337 |
} |
| 338 |
|
| 339 |
return url.toString(); |
| 340 |
} |
| 341 |
|
| 342 |
/** |
| 343 |
* The form on its own page, for the new-tab launcher and the fallback |
| 344 |
* link: the give_forms single, the same URL the WordPress block's new-tab |
| 345 |
* launcher opens, rather than the bare donation-form-view route the |
| 346 |
* iframe loads. |
| 347 |
*/ |
| 348 |
getStandaloneFormUrl(): string { |
| 349 |
const url = new URL(DATA.formPageUrl); |
| 350 |
url.searchParams.set('p', this.formId); |
| 351 |
|
| 352 |
const locale = this.getAttribute('locale'); |
| 353 |
if (locale) { |
| 354 |
url.searchParams.set('locale', locale); |
| 355 |
} |
| 356 |
|
| 357 |
return url.toString(); |
| 358 |
} |
| 359 |
|
| 360 |
/** |
| 361 |
* Mirrors the return-flow params the WordPress block handles server-side |
| 362 |
* (RouteListener), as the site describes them. |
| 363 |
*/ |
| 364 |
isReceiptReturn(): boolean { |
| 365 |
const {match, embedIdParam, receiptIdParam} = DATA.receiptReturn; |
| 366 |
const params = new URLSearchParams(window.location.search); |
| 367 |
|
| 368 |
return ( |
| 369 |
Object.entries(match).every(([param, value]) => params.get(param) === value) && |
| 370 |
params.get(embedIdParam) === this.embedId && |
| 371 |
/^[a-z0-9]{32}$/i.test(params.get(receiptIdParam) || '') |
| 372 |
); |
| 373 |
} |
| 374 |
|
| 375 |
/** |
| 376 |
* The iframe src for the receipt of a donation that just returned from an |
| 377 |
* offsite gateway; the receipt id comes from the return params. |
| 378 |
*/ |
| 379 |
getReceiptViewUrl(): string { |
| 380 |
const params = new URLSearchParams(window.location.search); |
| 381 |
const url = new URL(DATA.receiptViewUrl); |
| 382 |
url.searchParams.set('receipt-id', params.get(DATA.receiptReturn.receiptIdParam)); |
| 383 |
|
| 384 |
return url.toString(); |
| 385 |
} |
| 386 |
|
| 387 |
/** |
| 388 |
* Renders the loading state and the hidden iframe into target, reveals the |
| 389 |
* iframe on the resizer handshake, and falls back to a link on timeout. |
| 390 |
* onInit runs after the reveal. |
| 391 |
* |
| 392 |
* On the page (target is the element itself) the iframe is also revealed |
| 393 |
* early, on the form view's shell message, so the skeleton the WordPress |
| 394 |
* site rendered inside it shows while the app bundles load. In the modal |
| 395 |
* the overlay waits for the handshake and the launcher keeps its spinner. |
| 396 |
*/ |
| 397 |
renderForm(src: string, target: HTMLElement, onInit?: () => void) { |
| 398 |
const loading = document.createElement('div'); |
| 399 |
loading.className = 'givewp-embed__loading'; |
| 400 |
loading.setAttribute('role', 'status'); |
| 401 |
loading.setAttribute('aria-label', this.getAttribute('loading-text') || I18N.loading); |
| 402 |
|
| 403 |
const iframe = document.createElement('iframe'); |
| 404 |
iframe.src = src; |
| 405 |
// Matches the title the WordPress embeds use, so tooling and donors see one name. |
| 406 |
iframe.title = this.getAttribute('form-title') || I18N.formTitle; |
| 407 |
// Hidden but laid out at full width, so the form view measures its |
| 408 |
// skeleton at the width it will be shown at. |
| 409 |
iframe.style.cssText = |
| 410 |
'width: 1px; min-width: 100%; border: 0; visibility: hidden; position: absolute; top: 0; left: 0;'; |
| 411 |
iframe.setAttribute('data-givewp-embed', 'true'); |
| 412 |
iframe.setAttribute('data-givewp-embed-id', this.embedId); |
| 413 |
// The Payment Request API (Apple Pay, Google Pay, Link) is off for |
| 414 |
// cross-origin frames unless the host delegates it. The same-origin |
| 415 |
// block embed inherits it and needs nothing. |
| 416 |
iframe.setAttribute('allow', 'payment'); |
| 417 |
|
| 418 |
// Browsers fire `load` even for error pages, so the signal that the |
| 419 |
// form is actually running is the iframe-resizer handshake (onInit). |
| 420 |
// Until it arrives - frame-blocking headers, ad blockers, network |
| 421 |
// failure - the timeout degrades to a plain link to the form. |
| 422 |
const timeout = window.setTimeout(() => this.renderFallbackLink(loading, target), LOAD_TIMEOUT_MS); |
| 423 |
|
| 424 |
target.append(loading, iframe); |
| 425 |
this.iframe = iframe; |
| 426 |
|
| 427 |
const reveal = () => { |
| 428 |
loading.remove(); |
| 429 |
iframe.style.visibility = ''; |
| 430 |
iframe.style.position = ''; |
| 431 |
this.onShell = null; |
| 432 |
}; |
| 433 |
|
| 434 |
const showSpinner = () => { |
| 435 |
if (this.hasSkeleton || !loading.isConnected) { |
| 436 |
return; |
| 437 |
} |
| 438 |
const spinner = document.createElement('span'); |
| 439 |
spinner.className = 'givewp-embed__spinner'; |
| 440 |
loading.appendChild(spinner); |
| 441 |
}; |
| 442 |
|
| 443 |
if (target === this) { |
| 444 |
this.loading = loading; |
| 445 |
this.onShell = (height) => { |
| 446 |
iframe.style.height = `${height}px`; |
| 447 |
reveal(); |
| 448 |
}; |
| 449 |
this.applySkeleton(); |
| 450 |
// Another script instance later in the document may still bring |
| 451 |
// this form's skeleton, so the spinner waits until every deferred |
| 452 |
// script has run rather than flashing before the skeleton. |
| 453 |
afterDeferredScripts(showSpinner); |
| 454 |
} else { |
| 455 |
showSpinner(); |
| 456 |
} |
| 457 |
|
| 458 |
iframeResize( |
| 459 |
{ |
| 460 |
checkOrigin: [this.wpOrigin], |
| 461 |
heightCalculationMethod: 'taggedElement', |
| 462 |
onInit: () => { |
| 463 |
window.clearTimeout(timeout); |
| 464 |
reveal(); |
| 465 |
|
| 466 |
// A gateway-redirect return lands at the top of the page; |
| 467 |
// bring the receipt back into view. |
| 468 |
if (this.scrollOnInit) { |
| 469 |
this.scrollOnInit = false; |
| 470 |
this.scrollIntoView({behavior: 'smooth', block: 'start'}); |
| 471 |
} |
| 472 |
|
| 473 |
onInit?.(); |
| 474 |
}, |
| 475 |
}, |
| 476 |
iframe |
| 477 |
); |
| 478 |
} |
| 479 |
|
| 480 |
/** |
| 481 |
* Swaps the on-page spinner for the form's server-rendered skeleton when |
| 482 |
* the script data carries one. Runs at render, and again when a later |
| 483 |
* script instance merges more skeletons. With the skeleton in place the |
| 484 |
* shell reveal is skipped: the iframe draws the same markup, so revealing |
| 485 |
* it early would change nothing, and the reveal waits for the handshake |
| 486 |
* as the WordPress block's does. |
| 487 |
*/ |
| 488 |
applySkeleton() { |
| 489 |
const html = SKELETONS[this.formId]; |
| 490 |
|
| 491 |
if (!html || this.hasSkeleton || !this.loading?.isConnected) { |
| 492 |
return; |
| 493 |
} |
| 494 |
|
| 495 |
this.hasSkeleton = true; |
| 496 |
this.loading.classList.add('givewp-embed__loading--skeleton'); |
| 497 |
this.loading.innerHTML = html; |
| 498 |
this.onShell = null; |
| 499 |
} |
| 500 |
|
| 501 |
/** |
| 502 |
* Replaces the loading state with a link to the standalone form when the |
| 503 |
* iframe never completed the handshake. After a shell reveal the loading |
| 504 |
* state is already gone, so the link takes the iframe's place instead. |
| 505 |
*/ |
| 506 |
renderFallbackLink(loading: HTMLElement, target: HTMLElement) { |
| 507 |
const link = document.createElement('a'); |
| 508 |
link.href = this.getStandaloneFormUrl(); |
| 509 |
link.target = '_blank'; |
| 510 |
link.rel = 'noopener'; |
| 511 |
link.className = 'givewp-donation-form-link'; |
| 512 |
link.textContent = this.getAttribute('fallback-text') || I18N.openForm; |
| 513 |
|
| 514 |
if (loading.isConnected) { |
| 515 |
loading.replaceWith(link); |
| 516 |
} else { |
| 517 |
target.appendChild(link); |
| 518 |
} |
| 519 |
this.iframe?.remove(); |
| 520 |
this.iframe = null; |
| 521 |
this.onShell = null; |
| 522 |
|
| 523 |
// In the modal the overlay waits for the handshake; show it now so |
| 524 |
// the donor can reach the link. |
| 525 |
this.setLauncherLoading(false); |
| 526 |
this.showOverlay(); |
| 527 |
} |
| 528 |
|
| 529 |
/** |
| 530 |
* A styled link that opens the standalone form in a new tab. |
| 531 |
*/ |
| 532 |
renderNewTabButton() { |
| 533 |
const link = document.createElement('a'); |
| 534 |
link.href = this.getStandaloneFormUrl(); |
| 535 |
link.target = '_blank'; |
| 536 |
link.rel = 'noopener'; |
| 537 |
link.className = 'givewp-donation-form-link'; |
| 538 |
link.textContent = this.getButtonText(); |
| 539 |
|
| 540 |
this.appendChild(link); |
| 541 |
} |
| 542 |
|
| 543 |
/** |
| 544 |
* A button that opens the form in the modal overlay. While the form loads |
| 545 |
* it shows a spinner in place of its label, as the WordPress block does. |
| 546 |
*/ |
| 547 |
renderModalButton(src: string) { |
| 548 |
const button = document.createElement('button'); |
| 549 |
button.type = 'button'; |
| 550 |
button.className = 'givewp-donation-form-modal__open'; |
| 551 |
|
| 552 |
const label = document.createElement('span'); |
| 553 |
label.className = 'givewp-donation-form-modal__open__label'; |
| 554 |
label.textContent = this.getButtonText(); |
| 555 |
button.appendChild(label); |
| 556 |
|
| 557 |
button.addEventListener('click', () => this.openModal(src)); |
| 558 |
|
| 559 |
this.appendChild(button); |
| 560 |
this.modalButton = button; |
| 561 |
} |
| 562 |
|
| 563 |
/** |
| 564 |
* Swaps the launcher label for a spinner while the form loads: the same |
| 565 |
* markup and attribute as the block's launcher, so its styles apply. |
| 566 |
*/ |
| 567 |
setLauncherLoading(isLoading: boolean) { |
| 568 |
const button = this.modalButton; |
| 569 |
if (!button) { |
| 570 |
return; |
| 571 |
} |
| 572 |
|
| 573 |
// The visible label is the button's accessible name; only while loading is it replaced. |
| 574 |
// data-pending is the attribute react-aria's Button sets in the block, so the shared CSS applies. |
| 575 |
button.toggleAttribute('data-pending', isLoading); |
| 576 |
button.querySelector('.givewp-donation-form-modal__open__spinner')?.remove(); |
| 577 |
|
| 578 |
if (!isLoading) { |
| 579 |
button.removeAttribute('aria-label'); |
| 580 |
return; |
| 581 |
} |
| 582 |
|
| 583 |
button.setAttribute('aria-label', this.getAttribute('loading-text') || I18N.loading); |
| 584 |
const spinner = document.createElement('span'); |
| 585 |
spinner.className = 'givewp-donation-form-modal__open__spinner'; |
| 586 |
spinner.setAttribute('aria-hidden', 'true'); |
| 587 |
button.appendChild(spinner); |
| 588 |
} |
| 589 |
|
| 590 |
/** |
| 591 |
* Opens the modal, building it on first open. Like the WordPress block, |
| 592 |
* the overlay stays hidden and the launcher shows a spinner until the |
| 593 |
* form inside has completed the resizer handshake. Focus returns to the |
| 594 |
* launcher on close and stays inside the dialog while open. |
| 595 |
*/ |
| 596 |
openModal(src: string) { |
| 597 |
this.launcher = document.activeElement as HTMLElement | null; |
| 598 |
|
| 599 |
if (this.overlay) { |
| 600 |
this.showOverlay(); |
| 601 |
return; |
| 602 |
} |
| 603 |
|
| 604 |
const overlay = document.createElement('div'); |
| 605 |
overlay.className = 'givewp-donation-form-modal__overlay'; |
| 606 |
overlay.style.display = 'none'; |
| 607 |
|
| 608 |
const dialog = document.createElement('div'); |
| 609 |
dialog.className = 'givewp-donation-form-modal'; |
| 610 |
dialog.setAttribute('role', 'dialog'); |
| 611 |
dialog.setAttribute('aria-modal', 'true'); |
| 612 |
dialog.setAttribute('aria-label', this.getAttribute('form-title') || I18N.formTitle); |
| 613 |
dialog.tabIndex = -1; |
| 614 |
|
| 615 |
const close = document.createElement('button'); |
| 616 |
close.type = 'button'; |
| 617 |
close.className = 'givewp-donation-form-modal__close'; |
| 618 |
close.setAttribute('aria-label', this.getAttribute('close-text') || I18N.close); |
| 619 |
close.innerHTML = CLOSE_ICON_SVG; |
| 620 |
|
| 621 |
close.addEventListener('click', () => this.hideOverlay()); |
| 622 |
overlay.addEventListener('click', (event) => { |
| 623 |
if (event.target === overlay) { |
| 624 |
this.hideOverlay(); |
| 625 |
} |
| 626 |
}); |
| 627 |
this.keydownHandler = (event: KeyboardEvent) => { |
| 628 |
if (this.isOverlayOpen() && event.key === 'Escape') { |
| 629 |
this.hideOverlay(); |
| 630 |
} |
| 631 |
}; |
| 632 |
document.addEventListener('keydown', this.keydownHandler); |
| 633 |
|
| 634 |
/* |
| 635 |
* Focus guards bracket the dialog's contents. Tabbing past either end |
| 636 |
* lands on a guard, still inside the dialog, which hands focus to the |
| 637 |
* opposite end. A key listener cannot do this: the browser moves |
| 638 |
* focus after keydown, and while focus is inside the cross-origin |
| 639 |
* iframe the host document sees no key events at all. |
| 640 |
* |
| 641 |
* The iframe is hidden until the resizer handshake; a hidden element |
| 642 |
* cannot take focus, so only rendered elements count. |
| 643 |
*/ |
| 644 |
const focusables = () => |
| 645 |
Array.from(dialog.querySelectorAll<HTMLElement>('a[href], button, iframe')).filter( |
| 646 |
(element) => element.getClientRects().length > 0 |
| 647 |
); |
| 648 |
const startGuard = this.createFocusGuard(() => focusables().pop()?.focus()); |
| 649 |
const endGuard = this.createFocusGuard(() => focusables().shift()?.focus()); |
| 650 |
|
| 651 |
// Same structure as the block: the zoom animates this wrapper, and the |
| 652 |
// close button stays outside it so its fixed position holds. |
| 653 |
const content = document.createElement('div'); |
| 654 |
content.className = 'givewp-donation-form-modal__dialog__content'; |
| 655 |
|
| 656 |
dialog.append(startGuard, close, content); |
| 657 |
overlay.appendChild(dialog); |
| 658 |
this.appendChild(overlay); |
| 659 |
this.overlay = overlay; |
| 660 |
|
| 661 |
// The iframe lives on across open/close so form state survives. |
| 662 |
this.setLauncherLoading(true); |
| 663 |
this.renderForm(src, content, () => { |
| 664 |
this.setLauncherLoading(false); |
| 665 |
this.showOverlay(); |
| 666 |
}); |
| 667 |
dialog.appendChild(endGuard); |
| 668 |
} |
| 669 |
|
| 670 |
isOverlayOpen(): boolean { |
| 671 |
return !!this.overlay && this.overlay.style.display !== 'none'; |
| 672 |
} |
| 673 |
|
| 674 |
/** |
| 675 |
* Reveals the overlay with the block's enter animation and moves focus |
| 676 |
* into the dialog. |
| 677 |
*/ |
| 678 |
showOverlay() { |
| 679 |
const overlay = this.overlay; |
| 680 |
const dialog = overlay?.querySelector<HTMLElement>('.givewp-donation-form-modal'); |
| 681 |
if (!overlay || !dialog) { |
| 682 |
return; |
| 683 |
} |
| 684 |
|
| 685 |
overlay.style.display = ''; |
| 686 |
// The iframe was laid out while hidden; ask for a fresh height, as the block does on init. |
| 687 |
(this.iframe as any)?.iFrameResizer?.resize(); |
| 688 |
overlay.removeAttribute('data-exiting'); |
| 689 |
overlay.setAttribute('data-entering', 'true'); |
| 690 |
dialog.setAttribute('data-entering', 'true'); |
| 691 |
dialog.addEventListener( |
| 692 |
'animationend', |
| 693 |
() => { |
| 694 |
overlay.removeAttribute('data-entering'); |
| 695 |
dialog.removeAttribute('data-entering'); |
| 696 |
}, |
| 697 |
{once: true} |
| 698 |
); |
| 699 |
|
| 700 |
this.focusDialog(); |
| 701 |
} |
| 702 |
|
| 703 |
/** |
| 704 |
* Plays the block's exit animation, then hides the overlay and returns |
| 705 |
* focus to the launcher. A timer rather than animationend, so a host |
| 706 |
* page that disables animations still closes the modal. |
| 707 |
*/ |
| 708 |
hideOverlay() { |
| 709 |
const overlay = this.overlay; |
| 710 |
if (!overlay || !this.isOverlayOpen() || overlay.hasAttribute('data-exiting')) { |
| 711 |
return; |
| 712 |
} |
| 713 |
|
| 714 |
overlay.removeAttribute('data-entering'); |
| 715 |
overlay.setAttribute('data-exiting', 'true'); |
| 716 |
window.setTimeout(() => { |
| 717 |
overlay.style.display = 'none'; |
| 718 |
overlay.removeAttribute('data-exiting'); |
| 719 |
this.launcher?.focus(); |
| 720 |
}, EXIT_ANIMATION_MS); |
| 721 |
} |
| 722 |
|
| 723 |
createFocusGuard(onFocus: () => void): HTMLElement { |
| 724 |
const guard = document.createElement('span'); |
| 725 |
guard.className = 'givewp-embed__focus-guard'; |
| 726 |
guard.tabIndex = 0; |
| 727 |
guard.addEventListener('focus', onFocus); |
| 728 |
|
| 729 |
return guard; |
| 730 |
} |
| 731 |
|
| 732 |
/** |
| 733 |
* Initial focus goes to the close button, the first control, rather than |
| 734 |
* the dialog container: the container is tabindex -1, so a Shift+Tab from |
| 735 |
* it would walk backwards to the host page before reaching a guard. |
| 736 |
*/ |
| 737 |
focusDialog() { |
| 738 |
const dialog = this.overlay?.querySelector<HTMLElement>('.givewp-donation-form-modal'); |
| 739 |
|
| 740 |
(dialog?.querySelector<HTMLElement>('.givewp-donation-form-modal__close') ?? dialog)?.focus(); |
| 741 |
} |
| 742 |
} |
| 743 |
|
| 744 |
if (!customElements.get('givewp-donation-form')) { |
| 745 |
customElements.define('givewp-donation-form', GiveWPDonationForm); |
| 746 |
} |
| 747 |
|
| 748 |
// Defining the element upgraded every instance on the page during the first |
| 749 |
// script; a later script instance hands its skeletons to the ones still waiting. |
| 750 |
document.querySelectorAll<GiveWPDonationForm>('givewp-donation-form').forEach((element) => { |
| 751 |
element.applySkeleton?.(); |
| 752 |
}); |
| 753 |
|