Files
siftlode/frontend/src/components/ui/PageMenu.dom.test.tsx
T
peter 383d394e32 fix(ui): a picture that fails to load shows the card's own frame, then retries
A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
2026-10-14 20:51:08 +02:00

227 lines
8.6 KiB
TypeScript

// @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 <PageMenu>. 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 }) => <div>{children}</div>,
}));
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 (
<FloatingTree>
<PageShell>
{withMenu && (
<PageMenu
ariaLabel="Page actions"
items={[{ id: "a", label: "Sort", onSelect: () => {} }]}
/>
)}
<div data-testid="empty" style={{ height: 200 }}>
<p data-testid="text">Some words</p>
<button type="button">A control</button>
<div
data-context-menu-target=""
data-testid="row"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<span data-testid="row-inner" />
</div>
{/* eslint-disable-next-line no-restricted-syntax -- the native element whose browser menu is kept */}
<img data-testid="img" alt="" />
</div>
</PageShell>
</FloatingTree>
);
}
/** 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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
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(<Page />);
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(<Page withMenu={false} />);
expect(rightClick(screen.getByTestId("empty"))).toBe(true);
});
});
describe("isBackgroundPoint", () => {
const root = document.createElement("div");
root.innerHTML = `<div id="gap"><p id="p">words</p><a id="a" href="#"><span id="in-a"></span></a>
<svg id="svg"><path id="path"/></svg><div data-nav-index="0"><div id="in-item"></div></div></div>`;
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([]);
});
});