PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.18.0
GiveWP – Donation Plugin and Fundraising Platform v4.18.0
4.18.0 4.17.0 4.16.9 4.16.8.1 4.16.8 4.16.7.2 4.16.7.1 4.16.7 4.16.6.1 4.16.6 4.16.5.1 4.16.5 4.16.4 4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 All 257 releases
give / src / DonationForms / resources / externalEmbed / index.ts

index.ts in GiveWP – Donation Plugin and Fundraising Platform 4.18.0, at src/DonationForms/resources/externalEmbed/index.ts

753 lines 27.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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