Files
breadcrumb-the-shire/web/js/core/app-confirm-dialog.js
2026-03-24 19:20:23 +01:00

329 lines
10 KiB
JavaScript

/**
* 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 };