feat(admin): 管理端 UI/UX 深度优化——错误/加载/空状态、表单快捷键、移动端适配与无障碍
This commit is contained in:
125
apps/admin/src/components/ux.integration.test.tsx
Normal file
125
apps/admin/src/components/ux.integration.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes, useNavigate } from 'react-router';
|
||||
import { RefreshButton } from './RefreshButton';
|
||||
import { BackTop } from './BackTop';
|
||||
import { ScrollToTop } from './ScrollToTop';
|
||||
import { useSubmitShortcut } from '../hooks/useSubmitShortcut';
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const mount = (node: React.ReactNode) => {
|
||||
const host = document.createElement('div');
|
||||
container = host;
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
act(() => root?.render(node));
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await act(async () => root?.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('UX 组件与交互', () => {
|
||||
it('RefreshButton 点击触发 onRefresh,loading 时展示加载态', async () => {
|
||||
const onRefresh = vi.fn();
|
||||
mount(<RefreshButton onRefresh={onRefresh} />);
|
||||
const button = container?.querySelector('button');
|
||||
if (!button) throw new Error('button not rendered');
|
||||
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||
await flush();
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
mount(<RefreshButton onRefresh={onRefresh} loading />);
|
||||
expect(container?.querySelector('.ant-btn-loading')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('useSubmitShortcut 未激活时不响应 Cmd+Enter', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const Harness = () => {
|
||||
useSubmitShortcut(false, onSubmit);
|
||||
return <button type="button">ok</button>;
|
||||
};
|
||||
mount(<Harness />);
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true }),
|
||||
);
|
||||
await flush();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('useSubmitShortcut 激活时响应 Cmd/Ctrl+Enter 且阻止默认行为', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const Harness = () => {
|
||||
useSubmitShortcut(true, onSubmit);
|
||||
return <button type="button">ok</button>;
|
||||
};
|
||||
mount(<Harness />);
|
||||
|
||||
const metaEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Enter',
|
||||
metaKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(metaEvent);
|
||||
expect(metaEvent.defaultPrevented).toBe(true);
|
||||
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }),
|
||||
);
|
||||
await flush();
|
||||
expect(onSubmit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('BackTop 超过阈值后出现,点击回到顶部', async () => {
|
||||
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
mount(<BackTop threshold={-1} />);
|
||||
await flush();
|
||||
const button = container?.querySelector('button');
|
||||
expect(button).toBeTruthy();
|
||||
if (button) {
|
||||
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||
}
|
||||
await flush();
|
||||
expect(scrollSpy).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
|
||||
});
|
||||
|
||||
it('ScrollToTop 在路由切换时把滚动位置复位到顶部', async () => {
|
||||
const scrollSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
|
||||
const Nav = () => {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button type="button" onClick={() => navigate('/other')}>
|
||||
go
|
||||
</button>
|
||||
);
|
||||
};
|
||||
mount(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route path="/" element={<Nav />} />
|
||||
<Route path="/other" element={<div>other</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
scrollSpy.mockClear();
|
||||
|
||||
const button = container?.querySelector('button');
|
||||
if (!button) throw new Error('button not rendered');
|
||||
act(() => button.dispatchEvent(new MouseEvent('click', { bubbles: true })));
|
||||
await flush();
|
||||
expect(scrollSpy).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user