import { type ComponentProps, type CSSProperties, type ReactNode, useEffect, useId, } from 'react'; import { createPortal } from 'react-dom'; import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { PlatformModalCloseButton } from './PlatformModalCloseButton'; type UnifiedModalVariant = 'platform' | 'pixel'; type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen'; type UnifiedModalCloseVariant = NonNullable< ComponentProps['variant'] >; type UnifiedModalCloseIcon = ComponentProps< typeof PlatformModalCloseButton >['icon']; type UnifiedModalProps = { open: boolean; title: string; ariaLabel?: string; titleId?: string; description?: ReactNode; children: ReactNode; footer?: ReactNode; onClose: () => void; variant?: UnifiedModalVariant; size?: UnifiedModalSize; showHeader?: boolean; closeDisabled?: boolean; closeOnBackdrop?: boolean; closeOnEscape?: boolean; showCloseButton?: boolean; closeLabel?: string; closeVariant?: UnifiedModalCloseVariant; closeIcon?: UnifiedModalCloseIcon; portal?: boolean; 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)]', }; const PIXEL_SIZE_CLASS: Record = { sm: 'max-w-sm', md: 'max-w-md', lg: 'max-w-3xl', xl: 'max-w-5xl', fullscreen: 'max-w-[min(96vw,64rem)]', }; function joinClassNames(...classNames: Array) { return classNames.filter(Boolean).join(' '); } function getPanelStyle( variant: UnifiedModalVariant, panelStyle: CSSProperties | undefined, ) { if (variant !== 'pixel') { return panelStyle; } return { ...getNineSliceStyle(UI_CHROME.modalPanel), ...panelStyle, }; } function UnifiedModalContent({ open, title, ariaLabel, titleId: titleIdProp, description, children, footer, onClose, variant = 'platform', 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; 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 isPixel = variant === 'pixel'; const sizeClassName = isPixel ? PIXEL_SIZE_CLASS[size] : PLATFORM_SIZE_CLASS[size]; const overlayClasses = isPixel ? 'fixed inset-0 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4' : 'platform-overlay fixed inset-0 flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4'; const panelClasses = isPixel ? 'pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,58rem)] w-full flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]' : '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 = isPixel ? 'flex items-start justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4' : 'flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5'; const titleClasses = isPixel ? 'truncate text-sm font-semibold text-white' : 'text-base font-semibold text-[var(--platform-text-strong)]'; const descriptionClasses = isPixel ? 'mt-1 text-xs leading-5 text-zinc-400' : 'mt-1 text-xs leading-5 text-[var(--platform-text-base)]'; const bodyClasses = isPixel ? 'min-h-0 flex-1 overflow-y-auto p-4 sm:p-5' : 'min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5'; const footerClasses = isPixel ? 'flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5 sm:py-4' : 'flex flex-wrap items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5'; return (
{ if ( closeOnBackdrop && !closeDisabled && 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, ...props }: UnifiedModalProps) { if (!portal || typeof document === 'undefined') { return ; } return createPortal(, document.body); }