import { type ComponentProps, type CSSProperties, type ReactNode, useEffect, useId, useRef, } from 'react'; import { createPortal } from 'react-dom'; import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime'; import { useAuthUi } from '../auth/AuthUiContext'; import { PlatformModalCloseButton } from './PlatformModalCloseButton'; type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; type UnifiedModalCloseVariant = NonNullable< ComponentProps['variant'] >; type UnifiedModalCloseIcon = ComponentProps< typeof PlatformModalCloseButton >['icon']; export type UnifiedModalPortalTheme = PlatformTheme | 'auto' | 'none'; type UnifiedModalProps = { open: boolean; title: string; ariaLabel?: string; titleId?: string; description?: ReactNode; children: ReactNode; footer?: ReactNode; onClose: () => void; size?: UnifiedModalSize; showHeader?: boolean; closeDisabled?: boolean; closeOnBackdrop?: boolean; closeOnEscape?: boolean; showCloseButton?: boolean; closeLabel?: string; closeVariant?: UnifiedModalCloseVariant; closeIcon?: UnifiedModalCloseIcon; portal?: boolean; portalTheme?: UnifiedModalPortalTheme; zIndexClassName?: string; overlayClassName?: string; overlayStyle?: CSSProperties; panelClassName?: string; headerClassName?: string; titleClassName?: string; descriptionClassName?: string; bodyClassName?: string; footerClassName?: string; panelStyle?: CSSProperties; }; const PLATFORM_SIZE_CLASS: Record = { sm: 'max-w-md', md: 'max-w-xl', lg: 'max-w-3xl', xl: 'max-w-5xl', fullscreen: 'max-w-[min(100vw,76rem)] sm:h-[min(92vh,60rem)]', }; function joinClassNames( ...classNames: Array ) { return classNames.filter(Boolean).join(' '); } function resolvePortalOverlayClassName({ overlayClassName, portalTheme, contextualTheme, }: { overlayClassName?: string; portalTheme: UnifiedModalPortalTheme; contextualTheme: PlatformTheme; }) { if (portalTheme === 'none') { return overlayClassName; } const classNames = overlayClassName?.split(/\s+/u).filter(Boolean) ?? []; const explicitTheme = classNames.find( (className): className is `platform-theme--${PlatformTheme}` => className === 'platform-theme--light' || className === 'platform-theme--dark', ); const resolvedTheme = portalTheme === 'auto' ? ((explicitTheme?.replace('platform-theme--', '') as | PlatformTheme | undefined) ?? contextualTheme) : portalTheme; const remainingClassNames = classNames.filter( (className) => className !== 'platform-theme' && className !== 'platform-theme--light' && className !== 'platform-theme--dark', ); return joinClassNames( 'platform-theme', `platform-theme--${resolvedTheme}`, ...remainingClassNames, ); } function UnifiedModalContent({ open, title, ariaLabel, titleId: titleIdProp, description, children, footer, onClose, size = 'md', showHeader = true, closeDisabled = false, closeOnBackdrop = true, closeOnEscape = true, showCloseButton = true, closeLabel = '关闭', closeVariant, closeIcon, zIndexClassName = 'z-[90]', overlayClassName, overlayStyle, panelClassName, headerClassName, titleClassName, descriptionClassName, bodyClassName, footerClassName, panelStyle, }: Omit) { const generatedTitleId = useId(); const descriptionId = useId(); const titleId = titleIdProp ?? generatedTitleId; const backdropPointerSequenceRef = useRef(null); useEffect(() => { if (!open || closeDisabled || !closeOnEscape) { return; } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [closeDisabled, closeOnEscape, onClose, open]); if (!open) { return null; } const sizeClassName = PLATFORM_SIZE_CLASS[size]; const overlayClasses = 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4'; const panelClasses = 'platform-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden rounded-t-[1.75rem] sm:rounded-[1.75rem]'; const headerClasses = 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5'; const titleClasses = 'text-base font-semibold text-[var(--platform-text-strong)]'; const descriptionClasses = 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]'; const bodyClasses = 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5'; const footerClasses = 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5'; return (
{ backdropPointerSequenceRef.current = event.target === event.currentTarget; }} onPointerUpCapture={(event) => { backdropPointerSequenceRef.current = backdropPointerSequenceRef.current === true && event.target === event.currentTarget; }} onPointerCancelCapture={() => { backdropPointerSequenceRef.current = false; }} onClick={(event) => { const pointerSequenceStayedOnBackdrop = backdropPointerSequenceRef.current !== false; backdropPointerSequenceRef.current = null; if ( closeOnBackdrop && !closeDisabled && pointerSequenceStayedOnBackdrop && event.target === event.currentTarget ) { onClose(); } }} >
event.stopPropagation()} > {showHeader ? (
{title}
{description ? (
{description}
) : null}
{showCloseButton ? ( ) : null}
) : null}
{/* 无头部弹窗仍需要把描述挂到 aria-describedby,避免只剩可访问名称没有上下文。 */} {!showHeader && description ? (
{description}
) : null} {children}
{footer ? (
{footer}
) : null}
); } /** * 统一模态窗口外壳。 * 业务组件只传入标题、内容和操作区;遮罩、无障碍属性、Escape 与移动端布局在这里收口。 */ export function UnifiedModal({ portal = true, portalTheme = 'auto', overlayClassName, ...props }: UnifiedModalProps) { const contextualTheme = useAuthUi()?.platformTheme ?? 'light'; const resolvedProps = { ...props, overlayClassName: portal ? resolvePortalOverlayClassName({ overlayClassName, portalTheme, contextualTheme, }) : overlayClassName, }; if (!portal || typeof document === 'undefined') { return ; } return createPortal( , document.body, ); }