update
This commit is contained in:
168
tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
Normal file
168
tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
// tests/unit/renderer/components/ui/tabs-keyboard-nav.test.tsx
|
||||
//
|
||||
// 守 Tabs 的 **WAI-ARIA tab pattern 键盘契约**(src/renderer/src/components/ui/Tabs.tsx
|
||||
// 头注指向本文件,audit-r111 登记,本轮补齐 —— 原先标 🔒(守卫缺失))。
|
||||
//
|
||||
// 这条契约全站只有 Tabs 一处实现,漏了不会有第二个实现提醒你:
|
||||
// 1. ← / → 在 tab 间移动(带环绕),Home / End 跳首尾;
|
||||
// 2. **roving tabindex** —— 只有 selected 的 tab tabIndex=0,其余 -1,
|
||||
// 整个 tablist 在 Tab 序列里只占一个停靠点;
|
||||
// 3. 选中态随键盘移动,且**焦点跟着走**(WAI-ARIA 要求焦点与 selection
|
||||
// 同步,否则 SR 读到的选中项与视觉焦点不一致);
|
||||
// 4. active 指向一个不存在的 tab ⇒ 键盘处理整体 no-op(不吞默认行为);
|
||||
// 5. 单 tab 列表:←/→/Home/End 算回原位 ⇒ **不** preventDefault
|
||||
// (否则会吞掉 textarea 里方向键移动光标等默认行为,audit 2026-09-07)。
|
||||
//
|
||||
// 变异思路:每条断言钉一个具体会被改坏的点(环绕 / roving / 焦点跟随 /
|
||||
// no-op 分支 / 单 tab 不拦默认)。
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { useState } from 'react';
|
||||
import { render, fireEvent, act } from '@testing-library/react';
|
||||
import { Tabs } from '../../../../../src/renderer/src/components/ui/Tabs';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'a', label: 'A' },
|
||||
{ id: 'b', label: 'B' },
|
||||
{ id: 'c', label: 'C' },
|
||||
];
|
||||
|
||||
/** rAF 在 jsdom 里是 ~16ms 的定时器,键盘切换后要把那一帧跑完。 */
|
||||
async function flushFrame() {
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTabs(active = 'b', tabs = TABS) {
|
||||
const onChange = vi.fn();
|
||||
const { container, unmount } = render(
|
||||
<Tabs tabs={tabs} active={active} onChange={onChange} ariaLabel="模式" />,
|
||||
);
|
||||
const list = container.querySelector('[role="tablist"]');
|
||||
if (!list) throw new Error('Tabs 未渲染 tablist');
|
||||
return { list, onChange, container, unmount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 受控外壳 —— Tabs 是受控组件(active 由 caller 持有),键盘导航的
|
||||
* **环绕**行为只有在 active 真的跟着变时才能观察到。静态 active 恒为
|
||||
* 'b' 时,每次 → 都只能算出同一个 next,环绕是看不见的。
|
||||
*/
|
||||
function renderControlled(initial = 'b', tabs = TABS) {
|
||||
const onChange = vi.fn();
|
||||
function Harness() {
|
||||
const [active, setActive] = useState(initial);
|
||||
return (
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
active={active}
|
||||
onChange={(id) => {
|
||||
onChange(id);
|
||||
setActive(id);
|
||||
}}
|
||||
ariaLabel="模式"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { container, unmount } = render(<Harness />);
|
||||
const list = container.querySelector('[role="tablist"]');
|
||||
if (!list) throw new Error('Tabs 未渲染 tablist');
|
||||
return { list, onChange, container, unmount };
|
||||
}
|
||||
|
||||
function tabButtons(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll<HTMLButtonElement>('[role="tab"]'));
|
||||
}
|
||||
|
||||
describe('Tabs 键盘导航契约(audit-r111 补齐)', () => {
|
||||
it('→ 前进一档(到末尾后环绕回首),← 后退一档(到首位后环绕到末)', () => {
|
||||
const { list, onChange, unmount } = renderControlled('b');
|
||||
fireEvent.keyDown(list, { key: 'ArrowRight' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('c');
|
||||
|
||||
fireEvent.keyDown(list, { key: 'ArrowRight' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('a'); // 环绕
|
||||
|
||||
fireEvent.keyDown(list, { key: 'ArrowLeft' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('c'); // 反向环绕
|
||||
expect(onChange).toHaveBeenCalledTimes(3);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('Home / End 跳到首 / 尾', () => {
|
||||
const { list, onChange, unmount } = renderTabs('b');
|
||||
fireEvent.keyDown(list, { key: 'End' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('c');
|
||||
fireEvent.keyDown(list, { key: 'Home' });
|
||||
expect(onChange).toHaveBeenLastCalledWith('a');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('roving tabindex:只有 selected 的 tab 可 Tab 进入,其余 -1', () => {
|
||||
const { container, unmount } = renderTabs('b');
|
||||
const btns = tabButtons(container);
|
||||
expect(btns.map((b) => b.getAttribute('tabindex'))).toEqual(['-1', '0', '-1']);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('键盘切换后焦点跟到新选中的 tab(WAI-ARIA:焦点与 selection 同步)', async () => {
|
||||
const { list, container, unmount } = renderTabs('b');
|
||||
fireEvent.keyDown(list, { key: 'ArrowRight' });
|
||||
await flushFrame();
|
||||
const btns = tabButtons(container);
|
||||
// 组件是受控的(active 不变则仍渲染旧选中态),但**焦点**已经移到 c
|
||||
expect(document.activeElement).toBe(btns[2]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('active 指向不存在的 tab ⇒ 键盘处理整体 no-op(不吞默认行为)', () => {
|
||||
const { list, onChange, unmount } = renderTabs('missing');
|
||||
const ev = fireEvent.keyDown(list, { key: 'ArrowRight' });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(ev).toBe(true); // preventDefault 未被调用 ⇒ 返回 true
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('单 tab 列表:←/→/Home/End 不 preventDefault、不触发 onChange', () => {
|
||||
const one = [{ id: 'only', label: 'O' }];
|
||||
const { list, onChange, unmount } = renderTabs('only', one);
|
||||
for (const key of ['ArrowLeft', 'ArrowRight', 'Home', 'End']) {
|
||||
const ev = fireEvent.keyDown(list, { key });
|
||||
expect(ev, `${key} 不应被 preventDefault 拦下`).toBe(true);
|
||||
}
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('非导航键(Enter / 空格 / 普通字符)完全不管', () => {
|
||||
const { list, onChange, unmount } = renderTabs('b');
|
||||
for (const key of ['Enter', ' ', 'x', 'Tab']) {
|
||||
fireEvent.keyDown(list, { key });
|
||||
}
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('ARIA 接线:tablist/tab 角色、aria-selected、id 与 aria-controls 配对', () => {
|
||||
const { container, unmount } = renderTabs('b');
|
||||
const list = container.querySelector('[role="tablist"]');
|
||||
expect(list?.getAttribute('aria-label')).toBe('模式');
|
||||
const btns = tabButtons(container);
|
||||
expect(btns.map((b) => b.id)).toEqual(['tab-a', 'tab-b', 'tab-c']);
|
||||
expect(btns.map((b) => b.getAttribute('aria-selected'))).toEqual(['false', 'true', 'false']);
|
||||
expect(btns.map((b) => b.getAttribute('aria-controls'))).toEqual([
|
||||
'panel-a',
|
||||
'panel-b',
|
||||
'panel-c',
|
||||
]);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('鼠标点击某个 tab ⇒ onChange(该 id)', () => {
|
||||
const { container, onChange, unmount } = renderTabs('a');
|
||||
const btns = tabButtons(container);
|
||||
fireEvent.click(btns[2]);
|
||||
expect(onChange).toHaveBeenCalledWith('c');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user