抽出全屏子页外壳并接入导出面板

components/modal/FullscreenSubpage.tsx:新增共享全屏子页,portal + 焦点陷阱 + role=dialog/aria-modal,Esc 不关闭、只能由内容自己的按钮退出。
components/modal/windowChrome.ts:把标题栏放行判据从 ThemedModal 抽出,供两种模态复用。
components/modal/ThemedModal.tsx:改为引用抽出的判据。
view/project-development/export/index.tsx:导出面板改用 FullscreenSubpage,删掉自写的 portal 包裹。
This commit is contained in:
2026-10-06 21:41:52 +08:00
parent 230c9a2d28
commit 1ae7ae41c9
4 changed files with 89 additions and 30 deletions
@@ -0,0 +1,65 @@
import { FocusTrap } from 'focus-trap-react';
import { type ReactNode, useRef } from 'react';
import { createPortal } from 'react-dom';
import { isWindowChromeBarTarget } from './windowChrome';
/**
* 全屏子页外壳:portal 到 `document.body`,整屏接管,并把键盘焦点锁在里面。
*
* 与 [`ThemedModal`] 的分工:弹窗是页面上盖一层卡片,子页是整屏换页(导出产物面板、UI 编辑器
* 这类)。两者都要求焦点留在模态内,但子页**没有关闭语义**:
* - 只有内容自己提供关闭按钮,**Esc 永远不关闭**——编辑 / 填表到一半误按 Esc 就丢工作不可接受;
* - 背景也不接收点击,没有「点遮罩关闭」。
*
* `platform-theme--light` 只为共享组件提供 `--platform-*` 变量;子页自己的版式与配色由调用方
* 用 Tailwind 内联值决定。
*/
export function FullscreenSubpage({
ariaLabel,
children,
className,
}: {
ariaLabel: string;
children: ReactNode;
/** 追加到固定覆盖层上的类名,例如 UI 编辑器要的 `!bg-transparent !p-0`。 */
className?: string;
}) {
const panelRef = useRef<HTMLElement | null>(null);
if (typeof document === 'undefined') return <>{children}</>;
return createPortal(
<FocusTrap
focusTrapOptions={{
// 子页没有「按 Esc 取消」的语义:整屏接管期间按 Esc 不能把用户弹出去。
escapeDeactivates: false,
// 与 ThemedModal 一致:只放行窗口标题栏的最小化 / 最大化 / 关闭,其余外部点击仍被挡。
allowOutsideClick: (event) => isWindowChromeBarTarget(event.target),
fallbackFocus: () => panelRef.current!,
returnFocusOnDeactivate: true,
}}
>
<div
className={[
'platform-theme platform-theme--light fixed inset-0 z-50',
className,
]
.filter(Boolean)
.join(' ')}
>
<section
ref={panelRef}
className="h-full w-full rounded-none p-0 outline-none focus:outline-none"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
>
{children}
</section>
</div>
</FocusTrap>,
document.body,
);
}
@@ -2,25 +2,10 @@ import { FocusTrap } from 'focus-trap-react';
import { type CSSProperties, type ReactNode, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { isWindowChromeBarTarget } from './windowChrome';
type ThemedModalTheme = 'light' | 'dark';
/**
* 自绘标题栏的标记:它是窗口边框,不属于模态内容。
*
* focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接
* `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏
* 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱
* 挡在模态之外,点空白处不会误触底层界面。
*/
const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]';
function isWindowChromeBarTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null
);
}
export type ThemedModalProps = {
open: boolean;
ariaLabel: string;
@@ -0,0 +1,19 @@
/**
* 自绘标题栏的标记:它是窗口边框,不属于模态内容。
*
* focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接
* `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏
* 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱
* 挡在模态之外,点空白处不会误触底层界面。
*
* 单独成文件(而不是从 `ThemedModal` 导出):那边是纯组件文件,多导出一个函数会触发
* `react-refresh/only-export-components`。
*/
const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]';
export function isWindowChromeBarTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null
);
}
@@ -1,5 +1,4 @@
import { createPortal } from 'react-dom';
import { FullscreenSubpage } from '../../../components/modal/FullscreenSubpage';
import type {
DirectProjectConversationReadGate,
DirectProjectConversationWriteGate,
@@ -66,16 +65,7 @@ export default function ArtifactsPanel({
</Shell>
);
if (typeof document === 'undefined') return content;
return createPortal(
// `platform-theme--light` 只为共享组件(工具卡 / 思考块 / 输入区)提供 `--platform-*`
// 变量;本面板自己的版式与配色仍全部走 Tailwind 内联值。
<div className="platform-theme platform-theme--light fixed inset-0 z-50">
<section className="h-full w-full rounded-none p-0 outline-none focus:outline-none">
{content}
</section>
</div>,
document.body,
return (
<FullscreenSubpage ariaLabel="导出产物面板">{content}</FullscreenSubpage>
);
}