feat(design): a page's background menu on the five list pages (E28 S3e4c)
A right-click on the EMPTY area of a page opens a menu of that page's own toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear filters), the Plex library grid (sort, watch state, clear), the channel manager (layout, the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions), a playlist (play all, group by, reset view, save order, undo/redo, the list's own menu) and the notification center (mark all read). No destructive row and no backfill. PageShell listens on the page area; lib/backgroundTarget decides empty (not text, a control, a link, media, or a row/card with its own menu). Shift+right- click, a touch long-press and a right-click on selected words keep the browser's menu. ui/PageMenu registers while mounted, so a loading or error state keeps the browser's menu too. The rows mirror each control through ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu). pageMenuScope.contract.test.ts pins the five pages.
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
// @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>
|
||||
<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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user