feat: add unified modal shell

This commit is contained in:
2026-04-25 23:51:50 +08:00
parent 5eb37d595b
commit c5d783d3e6
6 changed files with 433 additions and 78 deletions

View File

@@ -16,6 +16,7 @@ import type {
BigFishSessionSnapshotResponse,
ExecuteBigFishActionRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BigFishAssetStudioTarget =
@@ -537,38 +538,37 @@ function BigFishResultErrorModal({
onClose: () => void;
}) {
return (
<div className="fixed inset-0 z-[160] flex items-center justify-center bg-slate-950/58 px-4 py-6 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-labelledby="big-fish-result-error-title"
className="w-full max-w-sm rounded-[1.6rem] border border-red-100/80 bg-white p-5 text-slate-950 shadow-2xl"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div
id="big-fish-result-error-title"
className="text-base font-black text-slate-950"
>
</div>
<div className="mt-2 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</div>
<UnifiedModal
open
title="发布失败"
onClose={onClose}
closeOnBackdrop={false}
showCloseButton={false}
size="sm"
zIndexClassName="z-[160]"
overlayClassName="bg-slate-950/58"
panelClassName="border-red-100/80 bg-white text-slate-950 shadow-2xl"
bodyClassName="p-5"
footer={(
<button
type="button"
onClick={onClose}
className="mt-5 inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
className="inline-flex w-full items-center justify-center rounded-full bg-slate-950 px-4 py-2.5 text-sm font-bold text-white"
>
</button>
)}
footerClassName="border-t-0 px-5 pb-5 pt-0"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-red-50 text-red-600">
<Waves className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-sm leading-6 text-slate-600">
{message}
</div>
</div>
</div>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,58 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import { UnifiedModal } from './UnifiedModal';
test('renders an accessible platform modal', () => {
render(
<UnifiedModal open title="统一弹窗" onClose={() => {}} portal={false}>
<div></div>
</UnifiedModal>,
);
expect(screen.getByRole('dialog', { name: '统一弹窗' })).toBeTruthy();
expect(screen.getByText('窗口内容')).toBeTruthy();
});
test('closes through backdrop and escape', () => {
const onClose = vi.fn();
const { rerender } = render(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<div></div>
</UnifiedModal>,
);
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
expect(onClose).toHaveBeenCalledTimes(1);
rerender(
<UnifiedModal open title="统一弹窗" onClose={onClose} portal={false}>
<div></div>
</UnifiedModal>,
);
fireEvent.keyDown(window, { key: 'Escape' });
expect(onClose).toHaveBeenCalledTimes(2);
});
test('respects closeDisabled for every default close path', () => {
const onClose = vi.fn();
render(
<UnifiedModal
open
title="生成中"
onClose={onClose}
closeDisabled
portal={false}
>
<div></div>
</UnifiedModal>,
);
fireEvent.click(screen.getByRole('dialog').parentElement as HTMLElement);
fireEvent.keyDown(window, { key: 'Escape' });
fireEvent.click(screen.getByRole('button', { name: '关闭' }));
expect(onClose).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,220 @@
import { X } from 'lucide-react';
import {
type CSSProperties,
type ReactNode,
useEffect,
useId,
} from 'react';
import { createPortal } from 'react-dom';
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
type UnifiedModalVariant = 'platform' | 'pixel';
type UnifiedModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'fullscreen';
type UnifiedModalProps = {
open: boolean;
title: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
onClose: () => void;
variant?: UnifiedModalVariant;
size?: UnifiedModalSize;
closeDisabled?: boolean;
closeOnBackdrop?: boolean;
showCloseButton?: boolean;
closeLabel?: string;
portal?: boolean;
zIndexClassName?: string;
overlayClassName?: string;
panelClassName?: string;
headerClassName?: 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,
description,
children,
footer,
onClose,
variant = 'platform',
size = 'md',
closeDisabled = false,
closeOnBackdrop = true,
showCloseButton = true,
closeLabel = '关闭',
zIndexClassName = 'z-[90]',
overlayClassName,
panelClassName,
headerClassName,
bodyClassName,
footerClassName,
panelStyle,
}: Omit<UnifiedModalProps, 'portal'>) {
const titleId = useId();
const descriptionId = useId();
useEffect(() => {
if (!open || closeDisabled) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeDisabled, 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';
const closeButtonClasses = isPixel
? 'rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white disabled:cursor-not-allowed disabled:opacity-45'
: 'platform-icon-button disabled:cursor-not-allowed disabled:opacity-45';
return (
<div
className={joinClassNames(overlayClasses, zIndexClassName, overlayClassName)}
onClick={(event) => {
if (
closeOnBackdrop &&
!closeDisabled &&
event.target === event.currentTarget
) {
onClose();
}
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
className={joinClassNames(panelClasses, sizeClassName, panelClassName)}
style={getPanelStyle(variant, panelStyle)}
onClick={(event) => event.stopPropagation()}
>
<div className={joinClassNames(headerClasses, headerClassName)}>
<div className="min-w-0">
<div id={titleId} className={titleClasses}>
{title}
</div>
{description ? (
<div id={descriptionId} className={descriptionClasses}>
{description}
</div>
) : null}
</div>
{showCloseButton ? (
<button
type="button"
aria-label={closeLabel}
onClick={onClose}
disabled={closeDisabled}
className={closeButtonClasses}
>
<X className="h-4 w-4" />
</button>
) : null}
</div>
<div className={joinClassNames(bodyClasses, bodyClassName)}>
{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);
}

View File

@@ -1,5 +1,6 @@
import { ArrowRight, X } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import { UnifiedModal } from '../common/UnifiedModal';
import { PLATFORM_CREATION_TYPES } from './platformEntryCreationTypes';
export interface PlatformEntryCreationTypeModalProps {
@@ -79,58 +80,40 @@ export function PlatformEntryCreationTypeModal({
}
return (
<div className="platform-overlay fixed inset-0 z-[90] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
<div className="platform-modal-shell w-full max-w-3xl overflow-hidden rounded-[1.8rem]">
<div className="bg-transparent">
<div className="flex items-start justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-4 sm:px-5">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-xs text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
onClick={onClose}
disabled={isBusy}
className="platform-icon-button disabled:cursor-not-allowed disabled:opacity-45"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="px-4 py-4 sm:px-5 sm:py-5">
<div className="grid gap-3 sm:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}
}}
/>
))}
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
<UnifiedModal
open={isOpen}
title="选择创作类型"
description="先选玩法类型,再进入对应创作工作台。"
onClose={onClose}
closeDisabled={isBusy}
size="lg"
>
<div className="grid gap-3 sm:grid-cols-5">
{PLATFORM_CREATION_TYPES.map((item) => (
<CreationTypeCard
key={item.id}
item={item}
busy={isBusy}
onSelect={() => {
if (item.id === 'rpg') {
onSelectRpg();
}
if (item.id === 'big-fish') {
onSelectBigFish();
}
if (item.id === 'puzzle') {
onSelectPuzzle();
}
}}
/>
))}
</div>
</div>
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</UnifiedModal>
);
}