Files
lislgosms/src/components/ui/Modal.tsx
T

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,
);
}