账号设置弹窗复用认证壳层并保留 direct mode 唯一 dialog 语义 拼图运行态新增本地弹窗壳层收口确认设置退出失败和通关结算 抓大鹅与跳一跳结算弹窗提取本地结算结构并补测试 拼图 onboarding 登录保存覆盖层迁入 UnifiedModal 更新 PlatformUiKit 收口文档和 Hermes 决策记录
260 lines
7.6 KiB
TypeScript
260 lines
7.6 KiB
TypeScript
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);
|
||
}
|