补齐主题弹窗焦点管理
接入 focus-trap-react 统一管理弹窗焦点生命周期 覆盖初始焦点、Tab 循环及关闭后焦点归还 更新 AI 游戏创作弹窗无障碍契约
This commit is contained in:
+26
-1
@@ -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",
|
||||
|
||||
@@ -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` 使用焦点陷阱:打开时移入 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`,不得显示生成重试动作。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user