c38d07044a
- ThemedModal 的焦点陷阱只放行落在 [data-window-chrome-bar] 内的点击,标题栏拖拽与最小化/最大化/关闭恢复可用,工作区内容点击仍被拦住 - WindowChrome 标题栏加 data-window-chrome-bar 标记,作为这条约定的唯一契约点 - styles.css 明确「全屏弹层一律从标题栏下方开始」,.app-update-overlay 从 inset:0 改为标题栏下方,.game-publish-progress-overlay 显式声明 top - 新增 tests/windowChromeOverlayContract.test.ts 覆盖 7 个全屏弹层;themedModal / WindowChrome 用例补「标题栏点击放行 + 工作区点击仍被拦」回归;gamePublishFeedback 用例按新口径断言 - pitfalls 记录该静默失效的机制与现行口径
156 lines
4.6 KiB
TypeScript
156 lines
4.6 KiB
TypeScript
// @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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。
|
|
* 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。
|
|
*/
|
|
function WindowChromeHarness({
|
|
onMinimize,
|
|
onWorkspaceClick,
|
|
}: {
|
|
onMinimize: () => void;
|
|
onWorkspaceClick: () => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
<div className="window-chrome__bar" data-window-chrome-bar>
|
|
<button type="button" onClick={onMinimize}>
|
|
最小化
|
|
</button>
|
|
</div>
|
|
<button type="button" onClick={onWorkspaceClick}>
|
|
工作区按钮
|
|
</button>
|
|
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
|
|
<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);
|
|
});
|
|
|
|
it('lets window title bar clicks through while workspace clicks stay trapped', async () => {
|
|
const user = userEvent.setup();
|
|
const onMinimize = vi.fn();
|
|
const onWorkspaceClick = vi.fn();
|
|
render(
|
|
<WindowChromeHarness
|
|
onMinimize={onMinimize}
|
|
onWorkspaceClick={onWorkspaceClick}
|
|
/>,
|
|
);
|
|
await screen.findByRole('dialog', { name: '测试弹窗' });
|
|
|
|
await user.click(screen.getByRole('button', { name: '最小化' }));
|
|
expect(onMinimize).toHaveBeenCalledTimes(1);
|
|
|
|
await user.click(screen.getByRole('button', { name: '工作区按钮' }));
|
|
expect(onWorkspaceClick).not.toHaveBeenCalled();
|
|
});
|
|
});
|