抽出全屏子页外壳并接入导出面板
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user