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:
2026-09-26 20:48:53 +02:00
parent f8ced6d819
commit 97381e65d0
19 changed files with 877 additions and 56 deletions
@@ -5,7 +5,7 @@
// the row's "…" or a right-click; a hidden / watched notice's Find in feed and Undo live only there.
// The band keeps Mark all read; Clear everything moved into the header's "…" and asks first.
// i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type AppNotification } from "../lib/api";
@@ -55,6 +55,7 @@ vi.mock("./ui/PageShell", async (importOriginal) => ({
}));
import NotificationsPanel from "./NotificationsPanel";
import PageShell from "./ui/PageShell";
afterEach(() => {
cleanup();
@@ -267,3 +268,38 @@ describe("NotificationsPanel · the header's '…'", () => {
expect(h.clearAll).toHaveBeenCalled();
});
});
describe("NotificationsPanel · the page's background menu", () => {
// DESIGN "Background menu": it mirrors the band, and a right-click on blank page must never be one
// click from a destructive row — Clear everything stays behind the "…" and its confirm.
it("holds Mark all read, never Clear everything", async () => {
// PageShell measures its header and the scroller its edges; jsdom has no ResizeObserver.
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
);
onTestFinished(() => {
vi.unstubAllGlobals();
});
h.server = [server(1)];
h.client = [client(8)];
const client_ = new QueryClient();
const { container } = render(
<QueryClientProvider client={client_}>
<PageShell>
<NotificationsPanel />
</PageShell>
</QueryClientProvider>,
);
const blank = container.querySelector("main") ?? container.firstElementChild!;
await openMenu(() => fireEvent.contextMenu(blank, { clientX: 5, clientY: 5 }));
expect(screen.getByRole("menu", { hidden: true }).getAttribute("aria-label")).toBe(
"common.pageMenu",
);
expect(menuRows()).toEqual(["inbox.markAllRead"]);
});
});