/** * @file ContextMenu.test.tsx * @description Keyboard model of the shared context menu: focus lands on the * first item, arrows/Home/End move (wrapping, skipping disabled items), Escape * and Tab close and hand focus back to the trigger, and a keyboard-opened menu * (clientX/Y 0) anchors under its element. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import { ContextMenu, MenuItem, useContextMenu } from "../ContextMenu"; function Harness({ onPick = () => {} }: { onPick?: (s: string) => void }) { const { menu, open, close } = useContextMenu(); return ( <> {menu && ( (onPick("a"), close())}>Alpha {}}> Beta (onPick("c"), close())}>Gamma )} ); } const item = (name: string) => screen.getByRole("menuitem", { name }); describe("ContextMenu keyboard", () => { it("focuses the first item and moves with arrows, Home and End", () => { render(); const trigger = screen.getByText("trigger"); trigger.focus(); fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 }); expect(item("Alpha")).toHaveFocus(); fireEvent.keyDown(item("Alpha"), { key: "ArrowDown" }); expect(item("Gamma")).toHaveFocus(); // Beta is disabled fireEvent.keyDown(item("Gamma"), { key: "ArrowDown" }); expect(item("Alpha")).toHaveFocus(); // wraps fireEvent.keyDown(item("Alpha"), { key: "ArrowUp" }); expect(item("Gamma")).toHaveFocus(); fireEvent.keyDown(item("Gamma"), { key: "Home" }); expect(item("Alpha")).toHaveFocus(); fireEvent.keyDown(item("Alpha"), { key: "End" }); expect(item("Gamma")).toHaveFocus(); }); it.each(["Escape", "Tab"])("%s closes and returns focus to the trigger", (key) => { render(); const trigger = screen.getByText("trigger"); trigger.focus(); fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 }); fireEvent.keyDown(item("Alpha"), { key }); expect(screen.queryByRole("menu")).toBeNull(); expect(trigger).toHaveFocus(); }); it("Escape in the menu does not reach a document listener (a dialog behind)", () => { const behind = vi.fn(); document.addEventListener("keydown", behind); render(); fireEvent.contextMenu(screen.getByText("trigger"), { clientX: 40, clientY: 50 }); fireEvent.keyDown(item("Alpha"), { key: "Escape" }); document.removeEventListener("keydown", behind); expect(behind).not.toHaveBeenCalled(); }); it("returns focus to the trigger after an item runs", () => { const pick = vi.fn(); render(); const trigger = screen.getByText("trigger"); trigger.focus(); fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 }); fireEvent.click(item("Gamma")); expect(pick).toHaveBeenCalledWith("c"); expect(trigger).toHaveFocus(); }); it("anchors a keyboard-opened menu (clientX/Y 0) under the element", () => { render(); const trigger = screen.getByText("trigger"); trigger.getBoundingClientRect = () => ({ left: 120, bottom: 80, top: 60, right: 200, width: 80, height: 20 }) as DOMRect; fireEvent.contextMenu(trigger, { clientX: 0, clientY: 0 }); const menu = screen.getByRole("menu"); expect(menu.style.left).toBe("120px"); expect(menu.style.top).toBe("80px"); }); });