329 lines
10 KiB
JavaScript
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 };
|