Files
webmail/components/ui/__tests__/context-menu-keyboard.test.tsx
T

229 lines
9.2 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, isContextMenuOpen } from '../context-menu';
import { isContextMenuKey, useContextMenu } from '@/hooks/use-context-menu';
function rect(left: number, top: number, width: number, height: number): DOMRect {
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
function Menu({ onPick = () => {}, withSub = false }: { onPick?: (what: string) => void; withSub?: boolean }) {
const { contextMenu, openContextMenu, openContextMenuFrom, closeContextMenu, menuRef } = useContextMenu<string>();
const pick = (what: string) => { closeContextMenu(); onPick(what); };
return (
<>
<button type="button" onClick={(e) => openContextMenu(e, 'row')}>More</button>
<button
type="button"
onContextMenu={(e) => openContextMenu(e, 'row')}
onKeyDown={(e) => { if (isContextMenuKey(e)) { e.preventDefault(); openContextMenuFrom(e.currentTarget, 'row', 'end'); } }}
>
Row
</button>
<ContextMenu ref={menuRef} isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem label="Open" onClick={() => pick('open')} />
<ContextMenuItem label="Rename" onClick={() => pick('rename')} />
<ContextMenuItem label="Disabled" onClick={() => pick('disabled')} disabled />
{withSub && (
<ContextMenuSubMenu label="Move to" testId="move">
<ContextMenuItem label="Inbox" onClick={() => pick('inbox')} />
<ContextMenuItem label="Spam" onClick={() => pick('spam')} />
</ContextMenuSubMenu>
)}
<ContextMenuItem label="Delete" onClick={() => pick('delete')} destructive />
</ContextMenu>
</>
);
}
async function openByKeyboard(props: Parameters<typeof Menu>[0] = {}) {
render(<Menu {...props} />);
const trigger = screen.getByText('More');
trigger.focus();
// Enter or Space on a button fires a click with detail 0.
fireEvent.click(trigger, { detail: 0 });
await waitFor(() => expect(document.activeElement).toBe(screen.getByText('Open').closest('button')));
return trigger;
}
function key(k: string, init: Partial<KeyboardEventInit> = {}) {
fireEvent.keyDown(document.activeElement ?? document.body, { key: k, ...init });
}
const item = (label: string) => screen.getByText(label).closest('[role="menuitem"]') as HTMLElement;
describe('ContextMenu keyboard', () => {
it('focuses the first item and moves with the arrows, Home and End, skipping disabled items', async () => {
await openByKeyboard();
key('ArrowDown');
expect(document.activeElement).toBe(item('Rename'));
key('ArrowDown');
expect(document.activeElement).toBe(item('Delete'));
key('ArrowDown');
expect(document.activeElement).toBe(item('Open'));
key('ArrowUp');
expect(document.activeElement).toBe(item('Delete'));
key('Home');
expect(document.activeElement).toBe(item('Open'));
key('End');
expect(document.activeElement).toBe(item('Delete'));
});
it('closes on Escape and on Tab and gives the focus back to the trigger', async () => {
const trigger = await openByKeyboard();
key('Escape');
expect(screen.queryByRole('menu')).toBeNull();
expect(document.activeElement).toBe(trigger);
fireEvent.click(trigger, { detail: 0 });
await waitFor(() => expect(document.activeElement).toBe(item('Open')));
key('Tab');
expect(screen.queryByRole('menu')).toBeNull();
expect(document.activeElement).toBe(trigger);
});
it('keeps Escape, arrows, Enter and letters from the app while open', async () => {
const appKeys = vi.fn();
document.addEventListener('keydown', appKeys);
try {
await openByKeyboard();
expect(isContextMenuOpen()).toBe(true);
expect(document.documentElement).toHaveAttribute('data-context-menu-open');
key('ArrowDown');
key('r');
key('Enter');
key('Escape');
expect(appKeys).not.toHaveBeenCalled();
expect(screen.queryByRole('menu')).toBeNull();
expect(isContextMenuOpen()).toBe(false);
expect(document.documentElement).not.toHaveAttribute('data-context-menu-open');
key('Escape');
expect(appKeys).toHaveBeenCalledTimes(1);
} finally {
document.removeEventListener('keydown', appKeys);
}
});
});
describe('ContextMenu placement', () => {
function mockRects() {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.getAttribute('role') === 'menu') return rect(0, 0, 200, 120);
if (this.textContent === 'More') return rect(300, 100, 40, 32);
if (this.textContent === 'Row') return rect(500, 400, 300, 40);
return rect(0, 0, 0, 0);
});
}
const menuStyle = () => screen.getByRole('menu').style;
it('anchors under the trigger when opened by keyboard', async () => {
mockRects();
await openByKeyboard();
expect(menuStyle().left).toBe('300px');
expect(menuStyle().top).toBe('136px');
});
it('opens at the pointer when clicked with a mouse', async () => {
mockRects();
render(<Menu />);
fireEvent.click(screen.getByText('More'), { detail: 1, clientX: 50, clientY: 60 });
await waitFor(() => expect(menuStyle().visibility).toBe('visible'));
expect(menuStyle().left).toBe('50px');
expect(menuStyle().top).toBe('60px');
});
it('anchors a keyboard contextmenu (no pointer position) to its element', async () => {
mockRects();
render(<Menu />);
fireEvent.contextMenu(screen.getByText('Row'), { clientX: 0, clientY: 0 });
await waitFor(() => expect(menuStyle().visibility).toBe('visible'));
expect(menuStyle().left).toBe('500px');
expect(menuStyle().top).toBe('444px');
});
it('opens from Shift+F10 through the helper, lined up with the end edge', async () => {
mockRects();
render(<Menu />);
const row = screen.getByText('Row');
row.focus();
fireEvent.keyDown(row, { key: 'F10', shiftKey: true });
await waitFor(() => expect(menuStyle().visibility).toBe('visible'));
expect(menuStyle().left).toBe('600px');
expect(menuStyle().top).toBe('444px');
expect(document.activeElement).toBe(item('Open'));
});
});
describe('ContextMenuSubMenu', () => {
it('opens on a tap and stays open after the emulated mouse events', async () => {
render(<Menu withSub />);
fireEvent.click(screen.getByText('More'), { detail: 1, clientX: 10, clientY: 10 });
await screen.findByText('Move to');
vi.useFakeTimers();
const trigger = screen.getByTestId('move');
fireEvent.pointerDown(trigger, { pointerType: 'touch' });
fireEvent.mouseEnter(trigger.parentElement!);
fireEvent.mouseLeave(trigger.parentElement!);
// The emulated mouse lands on a neighbour as the submenu panel opens.
fireEvent.mouseEnter(item('Delete'));
fireEvent.click(trigger);
act(() => { vi.advanceTimersByTime(500); });
expect(screen.getByText('Spam')).toBeInTheDocument();
expect(trigger).toHaveAttribute('aria-expanded', 'true');
});
it('closes when a mouse hovers another entry of its menu', async () => {
render(<Menu withSub />);
fireEvent.click(screen.getByText('More'), { detail: 1, clientX: 10, clientY: 10 });
await screen.findByText('Move to');
vi.useFakeTimers();
fireEvent.click(screen.getByTestId('move'));
expect(screen.getByText('Spam')).toBeInTheDocument();
fireEvent.mouseLeave(screen.getByTestId('move').parentElement!);
act(() => { vi.advanceTimersByTime(500); });
expect(screen.getByText('Spam')).toBeInTheDocument();
fireEvent.mouseEnter(item('Delete'));
act(() => { vi.advanceTimersByTime(200); });
expect(screen.queryByText('Spam')).toBeNull();
});
it('opens with → and Enter, focuses its first item, and ← or Escape closes only the submenu', async () => {
await openByKeyboard({ withSub: true });
key('End');
key('ArrowUp');
const trigger = screen.getByTestId('move');
expect(document.activeElement).toBe(trigger);
key('ArrowRight');
await waitFor(() => expect(document.activeElement).toBe(item('Inbox')));
key('ArrowDown');
expect(document.activeElement).toBe(item('Spam'));
key('ArrowLeft');
expect(screen.queryByText('Spam')).toBeNull();
expect(document.activeElement).toBe(trigger);
key('Enter');
await waitFor(() => expect(document.activeElement).toBe(item('Inbox')));
key('Escape');
expect(screen.queryByText('Inbox')).toBeNull();
expect(screen.getByRole('menu')).toBeInTheDocument();
expect(document.activeElement).toBe(trigger);
});
it('runs a submenu item chosen by keyboard', async () => {
const onPick = vi.fn();
await openByKeyboard({ withSub: true, onPick });
key('End');
key('ArrowUp');
key('ArrowRight');
await waitFor(() => expect(document.activeElement).toBe(item('Inbox')));
// Enter's default action on a button is a click.
fireEvent.click(document.activeElement!);
expect(onPick).toHaveBeenCalledWith('inbox');
});
});