// @vitest-environment jsdom // // Contract of the right-click / long-press context menu. Same floating-ui recipe as // ActionMenu.dom.test.tsx: stub the two observers autoUpdate wires and wrap in a FloatingTree, and // query with `{ hidden: true }` because floating-ui never positions in jsdom, so the surface stays // `visibility: hidden` and the a11y tree treats its contents as hidden. // // What is worth asserting here is not "a menu opens" but the two things that make it a CONTEXT menu: // the left button keeps the row's own action, and the gesture that opens it doesn't also fire that // action underneath. import { describe, it, expect, vi, afterEach, beforeAll, onTestFinished } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import { type ActionMenuItem } from "./ActionMenu"; import { ContextMenu } from "./ContextMenu"; import { useContextMenu } from "./useContextMenu"; beforeAll(() => { class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; // jsdom ships no PointerEvent, so fireEvent.pointerDown would deliver pointerType/clientX/clientY // as null — and the long-press path is ENTIRELY about those three. Without this polyfill the touch // tests would pass for the wrong reason (every press looks like an untyped press at 0,0) and the // "a mouse never starts the timer" guard could rot unnoticed. MouseEvent already carries the // coordinates; only pointerType has to be added. class PointerEventStub extends MouseEvent { pointerType: string; constructor(type: string, init: MouseEventInit & { pointerType?: string } = {}) { super(type, init); this.pointerType = init.pointerType ?? ""; } } globalThis.PointerEvent ??= PointerEventStub as unknown as typeof PointerEvent; }); afterEach(cleanup); /** A two-row list wired exactly the way a real consumer wires it: one menu, `triggerProps` per row. */ function List({ build, onRowClick = () => {}, }: { build: (row: string) => ActionMenuItem[]; onRowClick?: (row: string) => void; }) { const menu = useContextMenu(); return ( <> {["alpha", "beta"].map((row) => ( ))} ); } const renderList = (build: (row: string) => ActionMenuItem[], onRowClick = vi.fn()) => { render( , ); return { onRowClick }; }; const row = (name: string) => screen.getByRole("button", { name }); const menuitems = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent); // The surface is visibility:hidden in jsdom (floating-ui never positions), and an accessible NAME is // empty inside a hidden subtree — so items are matched on their text, as in ActionMenu.dom.test.tsx. const item = (label: string) => { const found = screen .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === label); if (!found) throw new Error(`no menuitem "${label}" in [${menuitems().join(", ")}]`); return found as HTMLButtonElement; }; describe("useContextMenu / ContextMenu", () => { it("stays closed until a right-click, and builds the menu from the row that got it", () => { renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]); expect(menuitems()).toEqual([]); fireEvent.contextMenu(row("beta"), { clientX: 40, clientY: 80 }); expect(menuitems()).toEqual(["Play beta"]); }); it("leaves the LEFT button to the row's own action", () => { // The whole point of the sprint: right-click is a new input path, not a replacement for the // row's default action. const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]); fireEvent.click(row("alpha")); expect(onRowClick).toHaveBeenCalledWith("alpha"); expect(menuitems()).toEqual([]); }); describe("on selected words the browser's own menu opens (DESIGN: Text stays selectable)", () => { const selectText = (el: Element) => { const range = document.createRange(); range.selectNodeContents(el); window.getSelection()!.removeAllRanges(); window.getSelection()!.addRange(range); }; /** Right-click `el` at (x, y); true when the app left the event to the browser (not prevented). */ const rightClick = (el: Element, x = 10, y = 10) => fireEvent.contextMenu(el, { clientX: x, clientY: y }); afterEach(() => { window.getSelection()!.removeAllRanges(); }); it("lets the browser have a right-click on the selection, and keeps the app menu elsewhere", () => { renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]); selectText(row("beta")); expect(rightClick(row("beta"))).toBe(true); // not prevented: the native menu opens expect(menuitems()).toEqual([]); expect(rightClick(row("alpha"))).toBe(false); expect(menuitems()).toEqual(["Play alpha"]); }); it("where ranges are laid out, only a point ON the selected words counts", () => { renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]); // A real engine: the selected words' line box. // jsdom lays nothing out and has no Range.getClientRects at all; lend it one for this test. const box = { left: 0, top: 0, right: 50, bottom: 20 } as DOMRect; Object.defineProperty(Range.prototype, "getClientRects", { configurable: true, value: () => [box], }); onTestFinished(() => { delete (Range.prototype as { getClientRects?: unknown }).getClientRects; }); selectText(row("beta")); expect(rightClick(row("beta"), 10, 10)).toBe(true); expect(menuitems()).toEqual([]); // The same row, beside its selected words (its picture, its padding): the object's menu. expect(rightClick(row("beta"), 200, 10)).toBe(false); expect(menuitems()).toEqual(["Play beta"]); }); }); it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => { const onSelect = vi.fn(); renderList(() => [{ id: "a", label: "Remove", onSelect }]); fireEvent.contextMenu(row("alpha")); fireEvent.click(item("Remove")); expect(menuitems()).toEqual([]); await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1)); }); it("Shift+F10 opens it from the keyboard", () => { renderList((r) => [{ id: "a", label: `Play ${r}`, onSelect: vi.fn() }]); fireEvent.keyDown(row("alpha"), { key: "F10", shiftKey: true }); expect(menuitems()).toEqual(["Play alpha"]); }); it("Escape closes it", () => { renderList(() => [{ id: "a", label: "Remove", onSelect: vi.fn() }]); fireEvent.contextMenu(row("alpha")); fireEvent.keyDown(document, { key: "Escape" }); expect(menuitems()).toEqual([]); }); // A card the keyboard is on is focused as a whole, through a tabindex=-1 root (lib/listNav), and // holds tabbable controls. floating-ui handed the focus back to the first of them instead of the // card — measured in Chrome: j, Shift+F10, Escape left it on the thumbnail. // Closed here by picking the row (the other way out; jsdom delivers no Escape to a keyboard-opened // menu — Escape is measured in the browser). it("gives the focus back to a card focused as a whole, not to a control inside it", async () => { function Card() { const menu = useContextMenu(); return ( <>
thumbnail
); } render( , ); const card = screen.getByTestId("card"); act(() => card.focus()); fireEvent.keyDown(card, { key: "F10", shiftKey: true }); expect(menuitems()).toEqual(["Pin"]); await vi.waitFor(() => expect(card.contains(document.activeElement)).toBe(false)); fireEvent.click(item("Pin")); expect(menuitems()).toEqual([]); await vi.waitFor(() => expect(document.activeElement).toBe(card)); }); describe("long press (touch)", () => { const press = (el: HTMLElement, x = 10, y = 10) => fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y }); it("opens after the hold and SWALLOWS the click that follows the release", () => { vi.useFakeTimers(); try { const { onRowClick } = renderList((r) => [ { id: "a", label: `Play ${r}`, onSelect: vi.fn() }, ]); press(row("alpha")); act(() => void vi.advanceTimersByTime(600)); expect(menuitems()).toEqual(["Play alpha"]); // Without the click guard the row would ALSO play the video under the open menu. fireEvent.click(row("alpha")); expect(onRowClick).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("a scroll (pointer moved past the slop) cancels it", () => { vi.useFakeTimers(); try { const { onRowClick } = renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]); press(row("alpha"), 10, 10); fireEvent.pointerMove(row("alpha"), { pointerType: "touch", clientX: 10, clientY: 60 }); act(() => void vi.advanceTimersByTime(600)); expect(menuitems()).toEqual([]); // …and a cancelled long press leaves the row's own click intact. fireEvent.click(row("alpha")); expect(onRowClick).toHaveBeenCalledWith("alpha"); } finally { vi.useRealTimers(); } }); it("a MOUSE press never starts the timer (it has a right button)", () => { vi.useFakeTimers(); try { renderList((r) => [{ id: "a", label: r, onSelect: vi.fn() }]); fireEvent.pointerDown(row("alpha"), { pointerType: "mouse", clientX: 10, clientY: 10 }); act(() => void vi.advanceTimersByTime(600)); expect(menuitems()).toEqual([]); } finally { vi.useRealTimers(); } }); }); describe("submenus", () => { const withSub = (): ActionMenuItem[] => [ { id: "play", label: "Play", onSelect: vi.fn() }, { id: "move", label: "Move to", items: [ { id: "t1", label: "Watch later", onSelect: vi.fn() }, { id: "t2", label: "Homelab", onSelect: vi.fn() }, ], }, ]; it("marks the submenu row as such and keeps its items unmounted until opened", () => { renderList(withSub); fireEvent.contextMenu(row("alpha")); const trigger = item("Move to"); expect(trigger.getAttribute("aria-haspopup")).toBe("menu"); expect(trigger.getAttribute("aria-expanded")).toBe("false"); expect(menuitems()).toEqual(["Play", "Move to"]); }); it("opens on click and on ArrowRight, and ArrowLeft steps back out of it", () => { renderList(withSub); fireEvent.contextMenu(row("alpha")); fireEvent.click(item("Move to")); expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]); // ArrowLeft is pressed INSIDE the submenu (that is where focus goes when it opens) and closes // only that layer — Escape would close the whole stack, which is not what "back" means. fireEvent.keyDown(item("Watch later"), { key: "ArrowLeft" }); expect(menuitems()).toEqual(["Play", "Move to"]); fireEvent.keyDown(item("Move to"), { key: "ArrowRight" }); expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]); }); it("choosing inside a submenu closes the WHOLE stack, not just the submenu", async () => { const chosen = vi.fn(); renderList(() => [ { id: "play", label: "Play", onSelect: vi.fn() }, { id: "move", label: "Move to", items: [{ id: "t1", label: "Homelab", onSelect: chosen }] }, ]); fireEvent.contextMenu(row("alpha")); fireEvent.click(item("Move to")); fireEvent.click(item("Homelab")); expect(menuitems()).toEqual([]); await vi.waitFor(() => expect(chosen).toHaveBeenCalledTimes(1)); }); }); it("a disabled item is inert and never fires", () => { const onSelect = vi.fn(); renderList(() => [{ id: "a", label: "Move to", disabled: true, onSelect }]); fireEvent.contextMenu(row("alpha")); const disabled = item("Move to"); expect(disabled.disabled).toBe(true); fireEvent.click(disabled); expect(onSelect).not.toHaveBeenCalled(); }); });