import { createPortal } from 'preact/compat'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { TriangleAlert } from 'lucide-preact'; import { t } from '@/lib/i18n'; interface ConfirmDialogProps { open: boolean; title: string; message: string; variant?: 'default' | 'warning'; showIcon?: boolean; confirmText?: string; cancelText?: string; danger?: boolean; hideCancel?: boolean; confirmDisabled?: boolean; cancelDisabled?: boolean; onConfirm: () => void; onCancel: () => void; children?: ComponentChildren; afterActions?: ComponentChildren; } function incrementDialogBodyLock() { if (typeof document === 'undefined') return; const body = document.body; const nextCount = Number(body.dataset.dialogCount || '0') + 1; body.dataset.dialogCount = String(nextCount); body.classList.add('dialog-open'); } function decrementDialogBodyLock() { if (typeof document === 'undefined') return; const body = document.body; const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1); if (nextCount === 0) { delete body.dataset.dialogCount; body.classList.remove('dialog-open'); return; } body.dataset.dialogCount = String(nextCount); } const FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', ].join(','); let dialogIdCounter = 0; function getFocusableElements(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).filter((element) => { if (element.hasAttribute('disabled') || element.getAttribute('aria-hidden') === 'true') return false; return !!(element.offsetWidth || element.offsetHeight || element.getClientRects().length); }); } export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) { useEffect(() => { if (!active) return; incrementDialogBodyLock(); return () => decrementDialogBodyLock(); }, [active]); useEffect(() => { if (!active || !onCancel || typeof window === 'undefined') return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; event.preventDefault(); onCancel(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [active, onCancel]); } export default function ConfirmDialog(props: ConfirmDialogProps) { const [present, setPresent] = useState(props.open); const [closing, setClosing] = useState(false); const cardRef = useRef(null); const maskPointerStartedRef = useRef(false); const restoreFocusRef = useRef(null); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const titleId = `${dialogId}-title`; const messageId = `${dialogId}-message`; const canDismiss = !props.cancelDisabled && !closing; useEffect(() => { if (props.open) { setPresent(true); setClosing(false); return; } if (!present) return; setClosing(true); const timer = window.setTimeout(() => { setPresent(false); setClosing(false); }, 240); return () => window.clearTimeout(timer); }, [props.open, present]); useDialogLifecycle(present, canDismiss ? props.onCancel : null); useEffect(() => { if (!props.open || typeof document === 'undefined') return; const activeElement = document.activeElement; restoreFocusRef.current = activeElement instanceof HTMLElement ? activeElement : null; const frameId = window.requestAnimationFrame(() => { const card = cardRef.current; if (!card) return; const focusable = getFocusableElements(card); const firstField = focusable.find((element) => ( element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement )); const cancelButton = focusable.find((element) => element.dataset.dialogCancel === 'true'); const confirmButton = focusable.find((element) => element.dataset.dialogConfirm === 'true'); const target = firstField || (props.danger ? cancelButton : confirmButton) || cancelButton || focusable[0] || card; target.focus({ preventScroll: true }); }); return () => window.cancelAnimationFrame(frameId); }, [props.open, props.danger]); useEffect(() => { if (props.open || present || typeof document === 'undefined') return; const target = restoreFocusRef.current; restoreFocusRef.current = null; if (!target || !document.contains(target)) return; target.focus({ preventScroll: true }); }, [props.open, present]); useEffect(() => { return () => { const target = restoreFocusRef.current; if (!target || typeof document === 'undefined' || !document.contains(target)) return; target.focus({ preventScroll: true }); }; }, []); function handleDialogKeyDown(event: KeyboardEvent) { if (event.key !== 'Tab') return; const card = cardRef.current; if (!card) return; const focusable = getFocusableElements(card); if (focusable.length === 0) { event.preventDefault(); card.focus({ preventScroll: true }); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const activeElement = document.activeElement; if (event.shiftKey) { if (activeElement === first || activeElement === card || !card.contains(activeElement)) { event.preventDefault(); last.focus({ preventScroll: true }); } return; } if (activeElement === last || activeElement === card || !card.contains(activeElement)) { event.preventDefault(); first.focus({ preventScroll: true }); } } if (!present || typeof document === 'undefined') return null; return createPortal((
{ maskPointerStartedRef.current = event.target === event.currentTarget; }} onClick={(event) => { if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return; props.onCancel(); }} >
{ e.preventDefault(); if (props.confirmDisabled || closing) return; props.onConfirm(); }} > {props.variant === 'warning' ? ( <>
), document.body); }