补齐主题弹窗焦点管理

接入 focus-trap-react 统一管理弹窗焦点生命周期

覆盖初始焦点、Tab 循环及关闭后焦点归还

更新 AI 游戏创作弹窗无障碍契约
This commit is contained in:
2026-08-19 13:04:05 +08:00
parent fb5c4a002b
commit 5528fdf64a
5 changed files with 198 additions and 58 deletions
+26 -1
View File
@@ -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",
+1
View File
@@ -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",
@@ -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<boolean | null>(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<HTMLElement | null>(null);
if (!open || typeof document === 'undefined') return null;
return createPortal(
<div
className={joinClassNames(
'platform-theme',
`platform-theme--${theme}`,
'fixed inset-0 z-50 grid place-items-center bg-black/35 p-4',
overlayClassName,
)}
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 stayedOnBackdrop = backdropPointerSequenceRef.current !== false;
backdropPointerSequenceRef.current = null;
if (
closeOnBackdrop &&
stayedOnBackdrop &&
event.target === event.currentTarget
) {
onClose();
}
<FocusTrap
focusTrapOptions={{
escapeDeactivates: false,
fallbackFocus: () => panelRef.current!,
returnFocusOnDeactivate: true,
}}
>
<section
className={panelClassName}
style={{
background: 'var(--platform-subpanel-fill)',
color: 'var(--platform-text-strong)',
...panelStyle,
<div
className={joinClassNames(
'platform-theme',
`platform-theme--${theme}`,
'fixed inset-0 z-50 grid place-items-center bg-black/35 p-4',
overlayClassName,
)}
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 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}
</section>
</div>,
<section
ref={panelRef}
className={panelClassName}
style={{
background: 'var(--platform-subpanel-fill)',
color: 'var(--platform-text-strong)',
...panelStyle,
}}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (closeOnEscape && event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
}
}}
>
{children}
</section>
</div>
</FocusTrap>,
document.body,
);
}
@@ -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 (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
<ThemedModal
open={open}
onClose={() => setOpen(false)}
ariaLabel="测试弹窗"
>
{noFocusableContent ? (
<p></p>
) : (
<>
<button type="button" onClick={() => setOpen(false)}>
</button>
<button type="button"></button>
</>
)}
</ThemedModal>
</>
);
}
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(<ModalHarness />);
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(<ModalHarness noFocusableContent />);
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(<ModalHarness />);
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);
});
});
@@ -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` 使用焦点陷阱:打开时移入 dialogTab / 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`,不得显示生成重试动作。