import type { ReactNode, RefObject } from 'react'; import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { AlertTriangle, X } from 'lucide-react'; import { Button } from '@/components/ui/Button'; export type ModalCloseControls = { requestClose: () => void; }; type ModalProps = { open: boolean; title: ReactNode; children: ReactNode; footer?: ReactNode | ((controls: ModalCloseControls) => ReactNode); size?: 'md' | 'xl'; onClose: () => void; dirty?: boolean; initialFocusRef?: RefObject; closeGuardTitle?: string; closeGuardDescription?: string; }; const focusableSelector = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled]):not([type="hidden"])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]', ].join(','); const modalStack: HTMLElement[] = []; let documentLockCount = 0; let bodyOverflow = ''; let bodyPaddingRight = ''; let backgroundState: Array<{ element: HTMLElement; inert: boolean; ariaHidden: string | null }> = []; function modalLayer() { let layer = document.getElementById('ui-modal-layer'); if (!layer) { layer = document.createElement('div'); layer.id = 'ui-modal-layer'; document.body.appendChild(layer); } return layer; } function lockDocument(layer: HTMLElement) { documentLockCount += 1; if (documentLockCount !== 1) return; bodyOverflow = document.body.style.overflow; bodyPaddingRight = document.body.style.paddingRight; const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; document.body.style.overflow = 'hidden'; if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`; backgroundState = Array.from(document.body.children) .filter((child): child is HTMLElement => child instanceof HTMLElement && child !== layer) .map((element) => ({ element, inert: element.inert, ariaHidden: element.getAttribute('aria-hidden') })); backgroundState.forEach(({ element }) => { element.inert = true; element.setAttribute('aria-hidden', 'true'); }); } function unlockDocument() { documentLockCount = Math.max(0, documentLockCount - 1); if (documentLockCount !== 0) return; document.body.style.overflow = bodyOverflow; document.body.style.paddingRight = bodyPaddingRight; backgroundState.forEach(({ element, inert, ariaHidden }) => { element.inert = inert; if (ariaHidden === null) element.removeAttribute('aria-hidden'); else element.setAttribute('aria-hidden', ariaHidden); }); backgroundState = []; } function focusableElements(root: HTMLElement) { return Array.from(root.querySelectorAll(focusableSelector)) .filter((element) => !element.hidden && element.getClientRects().length > 0); } export function Modal({ open, title, children, footer, size = 'md', onClose, dirty = false, initialFocusRef, closeGuardTitle = '放弃未保存的修改?', closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。', }: ModalProps) { const titleId = useId(); const guardTitleId = useId(); const guardDescriptionId = useId(); const panelRef = useRef(null); const guardRef = useRef(null); const restoreFocusRef = useRef(null); const guardRestoreFocusRef = useRef(null); const [showCloseGuard, setShowCloseGuard] = useState(false); const [layer] = useState(() => modalLayer()); const requestClose = useCallback(() => { if (dirty) { guardRestoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; setShowCloseGuard(true); return; } onClose(); }, [dirty, onClose]); const discardAndClose = useCallback(() => { setShowCloseGuard(false); onClose(); }, [onClose]); useEffect(() => { if (!open) { setShowCloseGuard(false); return undefined; } const panel = panelRef.current; if (!panel) return undefined; restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; modalStack.push(panel); lockDocument(layer); const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel; requestAnimationFrame(() => focusTarget.focus()); return () => { const stackIndex = modalStack.lastIndexOf(panel); if (stackIndex >= 0) modalStack.splice(stackIndex, 1); unlockDocument(); const restoreTarget = restoreFocusRef.current; queueMicrotask(() => { if (restoreTarget?.isConnected && !restoreTarget.inert) restoreTarget.focus(); else modalStack[modalStack.length - 1]?.focus(); }); }; }, [initialFocusRef, layer, open]); useEffect(() => { if (!open) return undefined; function handleKeyDown(event: KeyboardEvent) { const panel = panelRef.current; if (!panel || modalStack[modalStack.length - 1] !== panel) return; const trapRoot = showCloseGuard ? guardRef.current : panel; if (!trapRoot) return; if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); if (showCloseGuard) setShowCloseGuard(false); else requestClose(); return; } if (event.key !== 'Tab') return; const items = focusableElements(trapRoot); if (!items.length) { event.preventDefault(); trapRoot.focus(); return; } const first = items[0]; const last = items[items.length - 1]; const active = document.activeElement; if (event.shiftKey && (active === first || !trapRoot.contains(active))) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && (active === last || !trapRoot.contains(active))) { event.preventDefault(); first.focus(); } } document.addEventListener('keydown', handleKeyDown, true); return () => document.removeEventListener('keydown', handleKeyDown, true); }, [open, requestClose, showCloseGuard]); useEffect(() => { if (!showCloseGuard) return; const panel = panelRef.current; if (panel) panel.inert = true; requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus()); return () => { if (panel) panel.inert = false; const restoreTarget = guardRestoreFocusRef.current; queueMicrotask(() => { if (restoreTarget?.isConnected && panel?.isConnected) restoreTarget.focus(); }); }; }, [showCloseGuard]); if (!open) return null; const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer; return createPortal(
, layer, ); }