257 lines
8.4 KiB
TypeScript
257 lines
8.4 KiB
TypeScript
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<HTMLElement | null>;
|
|
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<HTMLElement>(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<HTMLElement>(null);
|
|
const guardRef = useRef<HTMLElement>(null);
|
|
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
|
const guardRestoreFocusRef = useRef<HTMLElement | null>(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(
|
|
<div className="ui-modal" data-ui-modal-root>
|
|
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget) requestClose();
|
|
}} />
|
|
<section
|
|
aria-labelledby={titleId}
|
|
aria-modal="true"
|
|
className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')}
|
|
ref={panelRef}
|
|
role="dialog"
|
|
tabIndex={-1}
|
|
>
|
|
<header className="ui-modal__header">
|
|
<div className="ui-modal__title" id={titleId}>{title}</div>
|
|
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
|
|
关闭
|
|
</Button>
|
|
</header>
|
|
<div className="ui-modal__body">{children}</div>
|
|
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null}
|
|
</section>
|
|
{showCloseGuard ? (
|
|
<div className="ui-modal__guard-layer">
|
|
<section
|
|
aria-describedby={guardDescriptionId}
|
|
aria-labelledby={guardTitleId}
|
|
aria-modal="true"
|
|
className="ui-modal__guard"
|
|
ref={guardRef}
|
|
role="alertdialog"
|
|
tabIndex={-1}
|
|
>
|
|
<AlertTriangle aria-hidden="true" size={22} />
|
|
<div>
|
|
<h2 id={guardTitleId}>{closeGuardTitle}</h2>
|
|
<p id={guardDescriptionId}>{closeGuardDescription}</p>
|
|
</div>
|
|
<footer>
|
|
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">继续编辑</Button>
|
|
<Button onClick={discardAndClose} variant="danger">放弃并关闭</Button>
|
|
</footer>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
</div>,
|
|
layer,
|
|
);
|
|
}
|