Files
Genarrative/src/components/common/UnifiedModal.tsx
kdletters ed2c386603 继续收口账号与运行态弹窗壳层
账号设置弹窗复用认证壳层并保留 direct mode 唯一 dialog 语义

拼图运行态新增本地弹窗壳层收口确认设置退出失败和通关结算

抓大鹅与跳一跳结算弹窗提取本地结算结构并补测试

拼图 onboarding 登录保存覆盖层迁入 UnifiedModal

更新 PlatformUiKit 收口文档和 Hermes 决策记录
2026-06-11 21:53:10 +08:00

260 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof PlatformModalCloseButton>['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<UnifiedModalSize, string> = {
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<UnifiedModalSize, string> = {
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<string | false | null | undefined>) {
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<UnifiedModalProps, 'portal'>) {
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 (
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
style={overlayStyle}
onClick={(event) => {
if (
closeOnBackdrop &&
!closeDisabled &&
event.target === event.currentTarget
) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={showHeader && !ariaLabel ? titleId : undefined}
aria-label={ariaLabel ?? (!showHeader ? title : undefined)}
aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
style={getPanelStyle(variant, panelStyle)}
onClick={(event) => event.stopPropagation()}
>
{showHeader ? (
<div className={joinClassNames(headerClasses, headerClassName)}>
<div className="min-w-0">
<div
id={titleId}
className={joinClassNames(titleClasses, titleClassName)}
>
{title}
</div>
{description ? (
<div
id={descriptionId}
className={joinClassNames(
descriptionClasses,
descriptionClassName,
)}
>
{description}
</div>
) : null}
</div>
{showCloseButton ? (
<PlatformModalCloseButton
label={closeLabel}
onClick={onClose}
disabled={closeDisabled}
variant={closeVariant ?? (isPixel ? 'pixel' : 'platformIcon')}
icon={closeIcon}
/>
) : null}
</div>
) : null}
<div className={joinClassNames(bodyClasses, bodyClassName)}>
{/* 无头部弹窗仍需要把描述挂到 aria-describedby避免只剩可访问名称没有上下文。 */}
{!showHeader && description ? (
<div id={descriptionId} className="sr-only">
{description}
</div>
) : null}
{children}
</div>
{footer ? (
<div className={joinClassNames(footerClasses, footerClassName)}>
{footer}
</div>
) : null}
</div>
</div>
);
}
/**
* 统一模态窗口外壳。
* 业务组件只传入标题、内容和操作区遮罩、无障碍属性、Escape 与移动端布局在这里收口。
*/
export function UnifiedModal({ portal = true, ...props }: UnifiedModalProps) {
if (!portal || typeof document === 'undefined') {
return <UnifiedModalContent {...props} />;
}
return createPortal(<UnifiedModalContent {...props} />, document.body);
}