From 5528fdf64a7e52a0ae0f160b11d5c389dc123379 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 19 Aug 2026 13:04:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=BB=E9=A2=98=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E7=84=A6=E7=82=B9=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入 focus-trap-react 统一管理弹窗焦点生命周期 覆盖初始焦点、Tab 循环及关闭后焦点归还 更新 AI 游戏创作弹窗无障碍契约 --- apps/ai-game-creator-shell/package-lock.json | 27 +++- apps/ai-game-creator-shell/package.json | 1 + .../src/components/modal/ThemedModal.tsx | 118 +++++++++--------- .../tests/themedModal.test.tsx | 108 ++++++++++++++++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 5 files changed, 198 insertions(+), 58 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/themedModal.test.tsx diff --git a/apps/ai-game-creator-shell/package-lock.json b/apps/ai-game-creator-shell/package-lock.json index bd7bbd328..fe4de9a94 100644 --- a/apps/ai-game-creator-shell/package-lock.json +++ b/apps/ai-game-creator-shell/package-lock.json @@ -17,6 +17,7 @@ "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@vitejs/plugin-react": "^5.0.4", + "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", "react": "^19.0.0", @@ -2403,7 +2404,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2797,6 +2797,31 @@ } } }, + "node_modules/focus-trap": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.2.tgz", + "integrity": "sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==", + "license": "MIT", + "dependencies": { + "tabbable": "^6.5.0" + } + }, + "node_modules/focus-trap-react": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-12.0.3.tgz", + "integrity": "sha512-4eXtzhRTtFrxle9Tkl0Wkj8SFJnWz3i3yb5e53Mdn4E8XZQ/Lzqom9U4uUAJ8wd7yDmyGVP96dxwQZXLXd4sbg==", + "license": "MIT", + "dependencies": { + "focus-trap": "^8.2.2", + "tabbable": "^6.5.0" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 5662ea4e4..f165a8ee7 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -43,6 +43,7 @@ "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@vitejs/plugin-react": "^5.0.4", + "focus-trap-react": "^12.0.3", "lexical": "^0.47.0", "lucide-react": "^0.546.0", "react": "^19.0.0", diff --git a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx index ee2feee63..4794bb2ac 100644 --- a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx +++ b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx @@ -1,9 +1,5 @@ -import { - type CSSProperties, - type ReactNode, - useEffect, - useRef, -} from 'react'; +import { FocusTrap } from 'focus-trap-react'; +import { type CSSProperties, type ReactNode, useRef } from 'react'; import { createPortal } from 'react-dom'; type ThemedModalTheme = 'light' | 'dark'; @@ -40,64 +36,74 @@ export function ThemedModal({ panelStyle, }: ThemedModalProps) { const backdropPointerSequenceRef = useRef(null); - - useEffect(() => { - if (!open || !closeOnEscape) return; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [closeOnEscape, onClose, open]); + const panelRef = useRef(null); if (!open || typeof document === 'undefined') return null; return createPortal( -
{ - backdropPointerSequenceRef.current = event.target === event.currentTarget; - }} - onPointerUpCapture={(event) => { - backdropPointerSequenceRef.current = - backdropPointerSequenceRef.current === true && - event.target === event.currentTarget; - }} - onPointerCancelCapture={() => { - backdropPointerSequenceRef.current = false; - }} - onClick={(event) => { - const stayedOnBackdrop = backdropPointerSequenceRef.current !== false; - backdropPointerSequenceRef.current = null; - if ( - closeOnBackdrop && - stayedOnBackdrop && - event.target === event.currentTarget - ) { - onClose(); - } + panelRef.current!, + returnFocusOnDeactivate: true, }} > -
{ + backdropPointerSequenceRef.current = + event.target === event.currentTarget; + }} + onPointerUpCapture={(event) => { + backdropPointerSequenceRef.current = + backdropPointerSequenceRef.current === true && + event.target === event.currentTarget; + }} + onPointerCancelCapture={() => { + backdropPointerSequenceRef.current = false; + }} + onClick={(event) => { + const stayedOnBackdrop = backdropPointerSequenceRef.current !== false; + backdropPointerSequenceRef.current = null; + if ( + closeOnBackdrop && + stayedOnBackdrop && + event.target === event.currentTarget + ) { + onClose(); + } }} - role="dialog" - aria-modal="true" - aria-label={ariaLabel} - onClick={(event) => event.stopPropagation()} > - {children} -
-
, +
event.stopPropagation()} + onKeyDown={(event) => { + if (closeOnEscape && event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }} + > + {children} +
+ + , document.body, ); } diff --git a/apps/ai-game-creator-shell/tests/themedModal.test.tsx b/apps/ai-game-creator-shell/tests/themedModal.test.tsx new file mode 100644 index 000000000..5242722e0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/themedModal.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ThemedModal } from '../src/components/modal/ThemedModal'; + +function ModalHarness({ noFocusableContent = false }) { + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} + ariaLabel="测试弹窗" + > + {noFocusableContent ? ( +

没有可聚焦控件

+ ) : ( + <> + + + + )} +
+ + ); +} + +describe('ThemedModal', () => { + beforeEach(() => { + vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation( + () => + [ + { + width: 1, + height: 1, + top: 0, + right: 1, + bottom: 1, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }, + ] as unknown as DOMRectList, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('moves focus into the dialog, traps Tab, and restores the opener after close', async () => { + const user = userEvent.setup(); + render(); + + const opener = screen.getByRole('button', { name: '打开弹窗' }); + await user.click(opener); + + const cancel = await screen.findByRole('button', { name: '取消' }); + const confirm = screen.getByRole('button', { name: '确认' }); + await waitFor(() => expect(document.activeElement).toBe(cancel)); + + await user.tab(); + expect(document.activeElement).toBe(confirm); + await user.tab(); + expect(document.activeElement).toBe(cancel); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(confirm); + + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(document.activeElement).toBe(opener); + }); + + it('focuses the dialog itself when it has no focusable content', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: '打开弹窗' })); + + const dialog = await screen.findByRole('dialog', { name: '测试弹窗' }); + await waitFor(() => expect(document.activeElement).toBe(dialog)); + }); + + it('restores focus after a backdrop close', async () => { + const user = userEvent.setup(); + render(); + + const opener = screen.getByRole('button', { name: '打开弹窗' }); + await user.click(opener); + + const dialog = await screen.findByRole('dialog', { name: '测试弹窗' }); + fireEvent.click(dialog.parentElement!); + + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(document.activeElement).toBe(opener); + }); +}); diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8eabae4fe..dbbf715f3 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1081,7 +1081,7 @@ game-project/ - 资源编辑账本把远端明确失败固化为 `remote-failed`,只保存稳定失败分类和终态时间;该状态不得再 POST 或轮询,只能由用户显式置为 `archived` 后移出活动恢复队列。归档不删账本、不伪装 `committed`;`result-unknown` 和 `reconciliation-required` 不允许归档或换键重试。 - 派生资产提交使用 `game-creator-resource-edit-asset-transaction.v1` durable journal,绑定 operation/project/source、最终路径和媒体摘要、manifest before/after 及 base/target project revision,阶段固定为 `prepared -> media-installed -> manifest-written -> revision-written -> committed`。重启后只对可证明组合前向补齐;manifest 已 after 但 revision 仍为 base 时只补目标 revision,身份、摘要、重复 asset 或 revision 任一冲突则进入 `reconciliation-required`。ledger 只能在文件、manifest、revision 与 journal 全部回读一致后进入 `committed`。`committed` 后若仍有 staging,只有在 staging 与 journal 摘要一致、正式媒体摘要一致,且 manifest 按 asset ID 或路径只能找到唯一一条并与 journal asset 精确相等时才尽力清理;删除 staging 的 I/O 失败不改变 durable committed 结果,任何身份或媒体漂移都保留 staging,并把 journal 与 ledger 转入对账。 - 派生子版本 journal 同时冻结 project revision before/after 的完整身份与目标 after 记录。manifest 已存在目标子版本但 journal 缺失时不得据此补造提交证明;升级前 journal 缺少 revision 身份时,只有当前 revision 仍精确处于 base 才能补齐 before/after 身份,已经推进且无法证明由本事务写入时必须失败关闭并进入 `reconciliation-required`。 -- 工作台顶部恢复入口只打开独立 modal,不在当前面板下追加列表。面板展示后端权威队列的全部 operation:可继续项可独立恢复,`remote-failed` 只能移出活动队列,`reconciliation-required` 只读展示对账。读取失败保留错误并允许重试,任一操作后重读权威队列;项目切换后的迟到结果必须丢弃。modal 支持 Escape、初始焦点和关闭后焦点归还,窄屏操作按钮改为纵向全宽布局。 +- 工作台顶部恢复入口只打开独立 modal,不在当前面板下追加列表。面板展示后端权威队列的全部 operation:可继续项可独立恢复,`remote-failed` 只能移出活动队列,`reconciliation-required` 只读展示对账。读取失败保留错误并允许重试,任一操作后重读权威队列;项目切换后的迟到结果必须丢弃。通用 `ThemedModal` 使用焦点陷阱:打开时移入 dialog,Tab / Shift+Tab 不得逸出;Escape 或背景关闭后归还原触发元素,无可聚焦子项时聚焦 dialog 本身。窄屏操作按钮改为纵向全宽布局。 - 草稿应用统一经过单调 revision 门禁:generation progress、保存队列、生成/提交回包与延迟 `loadDraft` 只能推进当前 scope 的最高可信 revision;同 revision 只允许完整相等的幂等回包,低 revision 和旧 scope 一律丢弃。Tauri 指针与键盘 Shift 选择统一调用共享 `resolveLayerPointerSelection`;移动、缩放和背景平移只在首次真实变化时 capture 一次 history,零位移不增加 undo、documentVersion 或草稿保存。 - `canvas.failed` 必须带 `generation / draft-save / asset-commit / recovery / cancellation` 五类稳定 operation。只有 `generation` 失败允许“返回修改/重新确认”;草稿保存或 CAS 冲突只提供重试保存/重新加载,资产提交和恢复只提供安全恢复/对账,取消故障只允许保留草稿继续编辑。初始恢复读取失败也归 `recovery`,不得显示生成重试动作。