Files
Genarrative/src/components/common/UnifiedModal.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

305 lines
8.7 KiB
TypeScript

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<typeof PlatformModalCloseButton>['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<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)]',
};
function joinClassNames(
...classNames: Array<string | false | null | undefined>
) {
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<UnifiedModalProps, 'portal' | 'portalTheme'>) {
const generatedTitleId = useId();
const descriptionId = useId();
const titleId = titleIdProp ?? generatedTitleId;
const backdropPointerSequenceRef = useRef<boolean | null>(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 (
<div
className={joinClassNames(
overlayClasses,
zIndexClassName,
overlayClassName,
)}
style={overlayStyle}
onPointerDownCapture={(event) => {
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();
}
}}
>
<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={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 ?? '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,
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 <UnifiedModalContent {...resolvedProps} />;
}
return createPortal(
<UnifiedModalContent {...resolvedProps} />,
document.body,
);
}