/** * Promise-based confirm dialog controller with variant inference, focus trap, and native fallback. */ import { warnOnce } from './app-dom.js'; const VARIANTS = new Set(['default', 'warning', 'danger']); const FOCUS_TARGETS = new Set(['confirm', 'cancel']); const DANGER_KEYWORDS = /(delete|deleted|purge|revoke|remove|destroy|danger|erase|drop|wipe|lösch|loesch|entfern|widerruf)/i; const FOCUSABLE_SELECTOR = [ 'button:not([disabled])', 'a[href]', 'input:not([type="hidden"]):not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])' ].join(','); const trimValue = (value) => String(value ?? '').trim(); const getNativeConfirm = () => { const fallback = window?.['confirm']; return typeof fallback === 'function' ? fallback.bind(window) : null; }; const normalizeVariant = (value) => { const variant = trimValue(value).toLowerCase(); return VARIANTS.has(variant) ? variant : ''; }; const normalizeFocus = (value) => { const focus = trimValue(value).toLowerCase(); return FOCUS_TARGETS.has(focus) ? focus : ''; }; const inferVariant = ({ variant = '', actionKind = '', message = '' } = {}) => { const explicit = normalizeVariant(variant); if (explicit !== '') { return explicit; } const action = trimValue(actionKind).toLowerCase(); if (action.includes('danger') || action.includes('delete') || action.includes('purge') || action.includes('revoke')) { return 'danger'; } return DANGER_KEYWORDS.test(trimValue(message)) ? 'danger' : 'default'; }; const getFocusableElements = (dialog) => Array.from(dialog.querySelectorAll(FOCUSABLE_SELECTOR)) .filter((element) => !element.hasAttribute('hidden')) .filter((element) => element.getAttribute('aria-hidden') !== 'true'); const requestSubmitWithFallback = (form, submitter) => { if (!(form instanceof HTMLFormElement)) { return; } if (typeof form.requestSubmit === 'function') { if (submitter instanceof HTMLElement) { try { form.requestSubmit(submitter); return; } catch { // Fallback below. } } form.requestSubmit(); return; } if (submitter instanceof HTMLElement && typeof submitter.click === 'function') { submitter.click(); return; } form.submit(); }; class ConfirmDialogController { constructor() { this.queue = Promise.resolve(); } enqueue(task) { const next = this.queue.then(task, task); this.queue = next.then(() => undefined, () => undefined); return next; } resolveElements() { const dialog = document.querySelector('[data-app-confirm-dialog]'); if (!(dialog instanceof HTMLDialogElement)) { return null; } const titleEl = dialog.querySelector('[data-app-confirm-title]'); const messageEl = dialog.querySelector('[data-app-confirm-message]'); const noteEl = dialog.querySelector('[data-app-confirm-note]'); const cancelButton = dialog.querySelector('[data-app-confirm-cancel]'); const confirmButton = dialog.querySelector('[data-app-confirm-confirm]'); const closeButton = dialog.querySelector('[data-app-confirm-close]'); if (!(titleEl instanceof HTMLElement) || !(messageEl instanceof HTMLElement) || !(cancelButton instanceof HTMLButtonElement) || !(confirmButton instanceof HTMLButtonElement)) { warnOnce('UI_CONFIRM_DIALOG_INVALID', 'Missing confirm dialog elements', { module: 'confirm-dialog' }); return null; } return { dialog, titleEl, messageEl, noteEl: noteEl instanceof HTMLElement ? noteEl : null, cancelButton, confirmButton, closeButton: closeButton instanceof HTMLButtonElement ? closeButton : null }; } fallbackConfirm(options = {}) { const message = trimValue(options.message); if (message === '') { return Promise.resolve(true); } const fallback = getNativeConfirm(); if (!fallback) { return Promise.resolve(false); } return Promise.resolve(Boolean(fallback(message))); } run(options = {}) { const message = trimValue(options.message); if (message === '') { return Promise.resolve(true); } const elements = this.resolveElements(); if (!elements || typeof elements.dialog.showModal !== 'function') { return this.fallbackConfirm({ message }); } return new Promise((resolve) => { const { dialog, titleEl, messageEl, noteEl, cancelButton, confirmButton, closeButton } = elements; const variant = inferVariant(options); const focusTarget = normalizeFocus(options.focus) || (variant === 'danger' ? 'cancel' : 'confirm'); const dataset = dialog.dataset || {}; const title = trimValue(options.title) || trimValue(dataset.defaultTitle) || 'Please confirm'; const confirmLabel = trimValue(options.confirmLabel) || trimValue(dataset.defaultConfirmLabel) || 'Confirm'; const cancelLabel = trimValue(options.cancelLabel) || trimValue(dataset.defaultCancelLabel) || 'Cancel'; const describedByExternal = trimValue(options.describedBy); const note = trimValue(options.note) || (variant === 'danger' ? trimValue(dataset.defaultDangerNote) : ''); const activeElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; const cleanupCallbacks = []; let resolved = false; const addCleanup = (fn) => { cleanupCallbacks.push(fn); }; const cleanup = () => { while (cleanupCallbacks.length > 0) { const fn = cleanupCallbacks.pop(); try { fn(); } catch { // no-op } } }; const finish = (approved) => { if (resolved) { return; } resolved = true; cleanup(); try { if (dialog.open) { dialog.close(approved ? 'confirm' : 'cancel'); } } catch { // no-op } document.body.classList.remove('modal-is-open'); if (activeElement && document.contains(activeElement)) { window.requestAnimationFrame(() => { activeElement.focus({ preventScroll: true }); }); } resolve(Boolean(approved)); }; const onCancelClick = (event) => { event.preventDefault(); finish(false); }; const onCloseClick = (event) => { event.preventDefault(); finish(false); }; const onConfirmClick = (event) => { event.preventDefault(); finish(true); }; const onDialogCancel = (event) => { event.preventDefault(); finish(false); }; const onBackdropClick = (event) => { if (event.target === dialog) { finish(false); } }; const onClose = () => { finish(dialog.returnValue === 'confirm'); }; const onKeyDown = (event) => { if (event.key !== 'Tab') { return; } const focusable = getFocusableElements(dialog); if (!focusable.length) { event.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = document.activeElement; if (event.shiftKey) { if (active === first || !dialog.contains(active)) { event.preventDefault(); last.focus(); } return; } if (active === last) { event.preventDefault(); first.focus(); } }; titleEl.textContent = title; messageEl.textContent = message; cancelButton.textContent = cancelLabel; confirmButton.textContent = confirmLabel; dialog.setAttribute('data-confirm-variant', variant); confirmButton.classList.remove('app-action-success', 'warning', 'danger'); if (variant === 'danger') { confirmButton.classList.add('danger'); } else if (variant === 'warning') { confirmButton.classList.add('warning'); } else { confirmButton.classList.add('app-action-success'); } if (noteEl) { if (note === '') { noteEl.hidden = true; noteEl.textContent = ''; } else { noteEl.hidden = false; noteEl.textContent = note; } } const describedByIds = [ trimValue(messageEl.id), noteEl && !noteEl.hidden ? trimValue(noteEl.id) : '', describedByExternal ].filter(Boolean); if (describedByIds.length) { dialog.setAttribute('aria-describedby', describedByIds.join(' ')); } else { dialog.removeAttribute('aria-describedby'); } cancelButton.addEventListener('click', onCancelClick); if (closeButton) { closeButton.addEventListener('click', onCloseClick); } confirmButton.addEventListener('click', onConfirmClick); dialog.addEventListener('cancel', onDialogCancel); dialog.addEventListener('click', onBackdropClick); dialog.addEventListener('close', onClose); dialog.addEventListener('keydown', onKeyDown); addCleanup(() => cancelButton.removeEventListener('click', onCancelClick)); if (closeButton) { addCleanup(() => closeButton.removeEventListener('click', onCloseClick)); } addCleanup(() => confirmButton.removeEventListener('click', onConfirmClick)); addCleanup(() => dialog.removeEventListener('cancel', onDialogCancel)); addCleanup(() => dialog.removeEventListener('click', onBackdropClick)); addCleanup(() => dialog.removeEventListener('close', onClose)); addCleanup(() => dialog.removeEventListener('keydown', onKeyDown)); document.body.classList.add('modal-is-open'); try { dialog.showModal(); } catch { cleanup(); document.body.classList.remove('modal-is-open'); resolve(this.fallbackConfirm({ message })); return; } window.requestAnimationFrame(() => { if (focusTarget === 'cancel') { cancelButton.focus(); return; } confirmButton.focus(); }); }); } confirm(options = {}) { return this.enqueue(() => this.run(options)); } } export const confirmDialog = new ConfirmDialogController(); export { inferVariant as inferConfirmVariant, normalizeVariant as normalizeConfirmVariant, normalizeFocus as normalizeConfirmFocus, requestSubmitWithFallback };