// @vitest-environment jsdom // // docs/DESIGN.md "Background menu": a right-click on the EMPTY area of a page opens the page's menu, // which only mirrors its toolbar. Everything else keeps what it had: text, a control, a link, media // or a row with its own menu; Shift+right-click (the way to the browser's menu); a touch long-press // (desktop only); selected words; and a page that renders no . The shell listens, so it is // held here through PageShell, with floating-ui's observers stubbed as in ContextMenu.dom.test.tsx. import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { FloatingTree } from "@floating-ui/react"; import PageShell from "./PageShell"; import { PageMenu } from "./PageMenu"; import { sortSubmenu, choiceSubmenu, multiSubmenu } from "./pageMenuItems"; import { isBackgroundPoint } from "../../lib/backgroundTarget"; import { type ActionMenuItem } from "./ActionMenu"; vi.mock("./PageScroller", () => ({ default: ({ children }: { children: React.ReactNode }) =>
{children}
, })); 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: without this a pointerdown carries no pointerType at all, and the // touch test would pass for the wrong reason (see ContextMenu.dom.test.tsx). 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(() => { window.getSelection()!.removeAllRanges(); cleanup(); }); const menuNames = () => screen.queryAllByRole("menu", { hidden: true }).map((m) => m.getAttribute("aria-label")); function Page({ withMenu = true }: { withMenu?: boolean }) { return ( {withMenu && ( {} }]} /> )}

Some words

{ e.preventDefault(); e.stopPropagation(); }} >
{/* eslint-disable-next-line no-restricted-syntax -- the native element whose browser menu is kept */}
); } /** Right-click `el`; true when the page left it to the browser (not prevented). */ const rightClick = (el: Element, init: MouseEventInit = {}) => fireEvent.contextMenu(el, { clientX: 5, clientY: 5, ...init }); describe("the page's background menu", () => { it("opens on the empty area, and takes the event from the browser", () => { render(); expect(rightClick(screen.getByTestId("empty"))).toBe(false); expect(menuNames()).toEqual(["Page actions"]); }); it("leaves text, a control, media and a row to their own menus", () => { render(); for (const id of ["text", "img"]) { expect(rightClick(screen.getByTestId(id)), id).toBe(true); } expect(rightClick(screen.getByRole("button", { name: "A control" }))).toBe(true); // The row's own handler claims it (prevents and stops): the page's never runs. rightClick(screen.getByTestId("row-inner")); expect(menuNames()).toEqual([]); }); it("gives Shift+right-click to the browser", () => { render(); expect(rightClick(screen.getByTestId("empty"), { shiftKey: true })).toBe(true); expect(menuNames()).toEqual([]); }); it("is desktop only: a touch long-press on the background opens nothing", () => { render(); const empty = screen.getByTestId("empty"); fireEvent.pointerDown(empty, { pointerType: "touch" }); expect(rightClick(empty)).toBe(true); expect(menuNames()).toEqual([]); fireEvent.pointerDown(empty, { pointerType: "mouse" }); expect(rightClick(empty)).toBe(false); }); it("leaves a right-click on selected words to the browser", () => { render(); const range = document.createRange(); range.selectNodeContents(screen.getByTestId("empty")); window.getSelection()!.addRange(range); expect(rightClick(screen.getByTestId("empty"))).toBe(true); expect(menuNames()).toEqual([]); }); it("is off on a page that renders no PageMenu", () => { render(); expect(rightClick(screen.getByTestId("empty"))).toBe(true); }); }); describe("isBackgroundPoint", () => { const root = document.createElement("div"); root.innerHTML = `

words

`; const $ = (id: string) => root.querySelector(`#${id}`); it("is the element's own emptiness, not a control's, a text's or an object's inside", () => { expect(isBackgroundPoint($("gap"), root)).toBe(true); expect(isBackgroundPoint(root, root)).toBe(true); expect(isBackgroundPoint($("p"), root)).toBe(false); expect(isBackgroundPoint($("in-a"), root)).toBe(false); expect(isBackgroundPoint($("path"), root)).toBe(false); expect(isBackgroundPoint($("in-item"), root)).toBe(false); expect(isBackgroundPoint(document.body, root)).toBe(false); }); }); describe("the toolbar-mirroring rows", () => { const leaf = (items: readonly ActionMenuItem[], id: string) => { const it = items.find((i) => i.id === id); if (!it || it.items) throw new Error(`no leaf ${id}`); return it; }; const options = [ { key: "date", label: "Date", defaultDir: "desc" as const }, { key: "shuffle", label: "Surprise me", directionless: true }, ]; it("sort: the keys as pick-one, the direction only while the key has one", () => { const onChange = vi.fn(); const make = (sort: { key: string; dir: "asc" | "desc" }) => sortSubmenu({ id: "s", label: "Sort", options, sort, onChange, ascLabel: "Asc", descLabel: "Desc", }).items!; const byDate = make({ key: "date", dir: "asc" }); expect(byDate.map((i) => [i.id, "check" in i ? i.check : undefined])).toEqual([ ["s.date", { kind: "one", checked: true }], ["s.shuffle", { kind: "one", checked: false }], ["s.dir.asc", { kind: "one", checked: true }], ["s.dir.desc", { kind: "one", checked: false }], ]); expect(byDate[2]!.separatorBefore).toBe(true); leaf(byDate, "s.dir.desc").onSelect(); expect(onChange).toHaveBeenLastCalledWith({ key: "date", dir: "desc" }); // Re-picking the active key keeps its direction (pickSort, as the SortControl does). leaf(byDate, "s.date").onSelect(); expect(onChange).toHaveBeenLastCalledWith({ key: "date", dir: "asc" }); expect(make({ key: "shuffle", dir: "desc" }).map((i) => i.id)).toEqual(["s.date", "s.shuffle"]); }); it("choice picks one; multi toggles one in or out of the set", () => { const onPick = vi.fn(); const choice = choiceSubmenu({ id: "v", label: "View", options: [ { value: "grid", label: "Grid" }, { value: "list", label: "List" }, ], selected: "grid", onPick, }).items!; expect(choice.map((i) => ("check" in i ? i.check?.checked : null))).toEqual([true, false]); leaf(choice, "v.list").onSelect(); expect(onPick).toHaveBeenCalledWith("list"); const onChange = vi.fn(); const multi = multiSubmenu({ id: "c", label: "Content", options: [ { value: "normal", label: "Normal" }, { value: "shorts", label: "Shorts" }, ], selected: ["normal"], onChange, }).items!; expect(multi.map((i) => ("check" in i ? i.check : null))).toEqual([ { kind: "many", checked: true }, { kind: "many", checked: false }, ]); leaf(multi, "c.shorts").onSelect(); expect(onChange).toHaveBeenLastCalledWith(["normal", "shorts"]); leaf(multi, "c.normal").onSelect(); expect(onChange).toHaveBeenLastCalledWith([]); }); });