071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
305 lines
8.7 KiB
TypeScript
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,
|
|
);
|
|
}
|