/** * Branded replacement for the native alert() and confirm() browser dialogs. * * Built on the native element, so focus trapping, Esc handling and the * inert backdrop come from the browser. Exposed as `window.adtDialog` so the * non-bundled legacy scripts can use it too. * * @since 13.5.9 */ import { __ } from '@wordpress/i18n'; import './dialog.scss'; export type TDialogVariant = 'primary' | 'danger' | 'info' | 'error'; export interface IDialogOptions { message: string; title?: string; confirmText?: string; cancelText?: string; variant?: TDialogVariant; } /** * `confirm()` resolves `true` for the Confirm button and `false` for Cancel, Esc or a click on the * backdrop. `alert()` resolves once the dialog is dismissed, however it was dismissed. * * @since 13.5.9 */ export interface IAdtDialog { confirm: (options: IDialogOptions | string) => Promise; alert: (options: IDialogOptions | string) => Promise; } const ICONS: Record = { primary: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-2h2v2Zm0-4h-2V7h2v6Z', danger: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z', info: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z', error: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-2h2v2Zm0-4h-2V7h2v6Z', }; let uid = 0; const BACKDROP_GRACE_MS = 400; const el = (tag: string, className: string, text?: string): HTMLElement => { const node = document.createElement(tag); node.className = className; if (text !== undefined) { node.textContent = text; } return node; }; const normalize = (options: IDialogOptions | string): IDialogOptions => typeof options === 'string' ? { message: options } : options; /** * Open the dialog. Resolves `true` when the confirm button is used and `false` * for the cancel button, Esc or a click on the backdrop. */ const open = (options: IDialogOptions, withCancel: boolean): Promise => new Promise((resolve) => { // Browsers without support would throw on showModal(), so fall back to the native dialogs. if (typeof HTMLDialogElement === 'undefined' || typeof HTMLDialogElement.prototype.showModal !== 'function') { if (withCancel) { // eslint-disable-next-line no-alert resolve(window.confirm(options.message)); } else { // eslint-disable-next-line no-alert window.alert(options.message); resolve(true); } return; } const variant: TDialogVariant = options.variant && options.variant in ICONS ? options.variant : withCancel ? 'primary' : 'error'; const id = `adt-dialog-${++uid}`; const previouslyFocused = document.activeElement as HTMLElement | null; const dialog = el('dialog', `adt-dialog adt-dialog--${variant}`) as HTMLDialogElement; dialog.setAttribute('role', withCancel ? 'dialog' : 'alertdialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', `${id}-title`); dialog.setAttribute('aria-describedby', `${id}-message`); const icon = el('span', 'adt-dialog__icon'); icon.setAttribute('aria-hidden', 'true'); icon.innerHTML = ``; const title = el( 'h2', 'adt-dialog__title', options.title ?? (withCancel ? __('Are you sure?', 'woo-product-feed-pro') : __('Notice', 'woo-product-feed-pro')) ); title.id = `${id}-title`; const header = el('div', 'adt-dialog__header'); header.append(icon, title); const message = el('p', 'adt-dialog__message', options.message); message.id = `${id}-message`; const actions = el('div', 'adt-dialog__actions'); const confirmButton = el( 'button', `adt-dialog__button adt-dialog__button--${variant === 'danger' || variant === 'error' ? 'danger' : 'primary'}`, options.confirmText ?? (withCancel ? __('Confirm', 'woo-product-feed-pro') : __('OK', 'woo-product-feed-pro')) ) as HTMLButtonElement; confirmButton.type = 'button'; if (withCancel) { const cancelButton = el( 'button', 'adt-dialog__button adt-dialog__button--secondary', options.cancelText ?? __('Cancel', 'woo-product-feed-pro') ) as HTMLButtonElement; cancelButton.type = 'button'; cancelButton.addEventListener('click', () => dialog.close('cancel')); actions.append(cancelButton); } actions.append(confirmButton); dialog.append(header, message, actions); confirmButton.addEventListener('click', () => dialog.close('confirm')); // A click on the backdrop lands on the element itself, but so does one on the dialog's own // padding. Only cancel when the press and the release both happened outside the card. const isOutside = (event: MouseEvent): boolean => { const rect = dialog.getBoundingClientRect(); return event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom; }; // The second click of a double-click on the trigger lands on the backdrop right after the dialog // opens, so backdrop clicks are ignored for a moment. let openedAt = 0; let pressedOnBackdrop = false; dialog.addEventListener('pointerdown', (event) => { pressedOnBackdrop = event.target === dialog && isOutside(event) && Date.now() - openedAt > BACKDROP_GRACE_MS; }); dialog.addEventListener('click', (event) => { if (pressedOnBackdrop && event.target === dialog && isOutside(event)) { dialog.close('cancel'); } pressedOnBackdrop = false; }); dialog.addEventListener('close', () => { dialog.remove(); if (previouslyFocused && document.contains(previouslyFocused)) { previouslyFocused.focus(); } resolve(dialog.returnValue === 'confirm'); }); document.body.append(dialog); dialog.showModal(); openedAt = Date.now(); // Destructive actions start on Cancel so Enter can't delete by accident. const initialFocus = withCancel && variant === 'danger' ? actions.firstElementChild : confirmButton; (initialFocus as HTMLElement).focus(); }); /** * Shared dialog helper. * * @since 13.5.9 */ export const adtDialog: IAdtDialog = { confirm: (options) => open(normalize(options), true), alert: (options) => open(normalize(options), false).then(() => undefined), }; declare global { interface Window { adtDialog: IAdtDialog; } } window.adtDialog = adtDialog;