feat: harden platform workflows and UI governance
This commit is contained in:
+226
-21
@@ -1,51 +1,256 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
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;
|
||||
footer?: ReactNode | ((controls: ModalCloseControls) => ReactNode);
|
||||
size?: 'md' | 'xl';
|
||||
onClose: () => void;
|
||||
dirty?: boolean;
|
||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
closeGuardTitle?: string;
|
||||
closeGuardDescription?: string;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, children, footer, size = 'md', onClose }: ModalProps) {
|
||||
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') {
|
||||
onClose();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, requestClose, showCloseGuard]);
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
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;
|
||||
}
|
||||
if (!open) return null;
|
||||
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
|
||||
|
||||
return createPortal(
|
||||
<div className="ui-modal" role="presentation">
|
||||
<button className="ui-modal__mask" type="button" aria-label="关闭弹窗" onClick={onClose} />
|
||||
<section aria-modal="true" className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')} role="dialog">
|
||||
<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">{title}</div>
|
||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={onClose}>
|
||||
<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>
|
||||
{footer ? <footer className="ui-modal__footer">{footer}</footer> : null}
|
||||
{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>,
|
||||
document.body,
|
||||
layer,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user