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
@@ -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([]);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { type ActionMenuItem } from "./ActionMenu";
import { ContextMenu } from "./ContextMenu";
import { usePageMenu } from "./pageMenuSlot";
/** The page's background menu (./pageMenuSlot). Render it in the branch of the page it belongs to — while
* mounted, a right-click on the page's empty area opens it; unmounted (a loading or error state, a
* different mode of the page), the browser's menu stays. `items` mirror the page's toolbar. */
export function PageMenu({ ariaLabel, items }: { ariaLabel: string; items: ActionMenuItem[] }) {
const menu = usePageMenu();
return <ContextMenu menu={menu} ariaLabel={ariaLabel} items={menu.open ? items : []} />;
}
+46 -13
View File
@@ -4,6 +4,7 @@ import {
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactElement,
type ReactNode,
@@ -13,6 +14,9 @@ import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
import { PageToolbarSlot } from "./pageToolbarSlot";
import { PageHeadingProvider } from "./pageHeading";
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
import { isBackgroundPoint } from "../../lib/backgroundTarget";
import { pointOnSelection } from "../../lib/textSelection";
// A module renders its per-module fixed band (filter chips / sort / counts) through this. The
// content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content
@@ -139,6 +143,29 @@ export default function PageShell({
setOwnHeadings((n) => n + 1);
return () => setOwnHeadings((n) => n - 1);
}, []);
// The page's background menu (./pageMenuSlot): the latest registered opener answers.
const pageMenus = useRef<PageMenuOpener[]>([]);
const registerPageMenu = useCallback((opener: PageMenuOpener) => {
pageMenus.current = [...pageMenus.current, opener];
return () => {
pageMenus.current = pageMenus.current.filter((o) => o !== opener);
};
}, []);
// What pressed last, for an engine whose contextmenu event carries no pointerType.
const lastPointer = useRef("mouse");
const onContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
const open = pageMenus.current[pageMenus.current.length - 1];
// Shift+right-click is the way to the browser's own menu; a row's or a link's handler has claimed
// the event already (they stop it); selected words keep the browser's Copy / Search.
if (!open || e.shiftKey || e.defaultPrevented) return;
// Desktop only: a touch long-press on the background belongs to scrolling and text selection.
const pointer = (e.nativeEvent as Partial<PointerEvent>).pointerType || lastPointer.current;
if (pointer !== "mouse") return;
if (pointOnSelection(e.target, e.clientX, e.clientY)) return;
if (!isBackgroundPoint(e.target, e.currentTarget)) return;
e.preventDefault();
open(e.clientX, e.clientY, e.currentTarget);
};
const measuredHeader = isValidElement(header)
? cloneElement(header as ReactElement<{ ref?: Ref<HTMLDivElement> }>, {
ref: setHeaderEl,
@@ -146,20 +173,26 @@ export default function PageShell({
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
<PageHeadingProvider value={registerOwnHeading}>
{measuredHeader}
<div
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
{banners}
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
<PageMenuSlot.Provider value={registerPageMenu}>
<PageHeadingProvider value={registerOwnHeading}>
{measuredHeader}
<div
ref={setContentEl}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
onPointerDown={(e) => {
lastPointer.current = e.pointerType;
}}
onContextMenu={onContextMenu}
>
{title && ownHeadings === 0 && <h1 className="sr-only">{title}</h1>}
{banners}
{/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
</div>
</PageHeadingProvider>
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
</div>
</PageHeadingProvider>
</PageMenuSlot.Provider>
</PageToolbarSlot.Provider>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { type ActionMenuItem } from "./ActionMenu";
import { menuGroups } from "./menuGroups";
import { pickSort, type SortOption, type SortState } from "../../lib/columnSort";
// The rows a page's background menu (./pageMenu) mirrors its toolbar with. Each takes the SAME
// vocabulary and setter as the control it mirrors (a SortControl's options, a FilterSelect's options),
// so the menu can never offer a choice the toolbar does not, or set it another way.
/** "Sort ▸": the keys as a pick-one set, then the direction when the active key has one. Picking a
* key keeps its direction, a new one starts at its default — `pickSort`, as the SortControl does.
* `defaultLabel` leads with the "no sort" choice, for a control that offers one (a table's). */
export function sortSubmenu({
id,
label,
options,
sort,
onChange,
ascLabel,
descLabel,
defaultLabel,
}: {
id: string;
label: string;
options: readonly SortOption[];
sort: SortState;
onChange: (next: SortState) => void;
ascLabel: string;
descLabel: string;
defaultLabel?: string;
}): ActionMenuItem {
const active = sort ? options.find((o) => o.key === sort.key) : undefined;
const none: ActionMenuItem[] = defaultLabel
? [
{
id: `${id}.none`,
label: defaultLabel,
check: { kind: "one", checked: !active },
onSelect: () => onChange(null),
},
]
: [];
const keys: ActionMenuItem[] = options.map((o) => ({
id: `${id}.${o.key}`,
label: o.label,
check: { kind: "one", checked: active?.key === o.key },
onSelect: () => onChange(pickSort([...options], sort, o.key)),
}));
const dirs: ActionMenuItem[] =
active && sort && !active.directionless
? (["asc", "desc"] as const).map((dir) => ({
id: `${id}.dir.${dir}`,
label: dir === "asc" ? ascLabel : descLabel,
check: { kind: "one", checked: sort.dir === dir },
onSelect: () => onChange({ key: sort.key, dir }),
}))
: [];
return { id, label, items: menuGroups([...none, ...keys], dirs) };
}
/** "Label ▸" over one choice of several (a FilterSelect without `multiple`, a view switcher). */
export function choiceSubmenu<V extends string>({
id,
label,
options,
selected,
onPick,
}: {
id: string;
label: string;
options: readonly { value: V; label: string }[];
selected: V;
onPick: (value: V) => void;
}): ActionMenuItem {
return {
id,
label,
items: options.map((o) => ({
id: `${id}.${o.value}`,
label: o.label,
check: { kind: "one", checked: o.value === selected },
onSelect: () => onPick(o.value),
})),
};
}
/** "Label ▸" over a set that can all be on (a `multiple` FilterSelect): each toggles in place. */
export function multiSubmenu<V extends string>({
id,
label,
options,
selected,
onChange,
}: {
id: string;
label: string;
options: readonly { value: V; label: string }[];
selected: readonly V[];
onChange: (next: V[]) => void;
}): ActionMenuItem {
return {
id,
label,
items: options.map((o) => {
const on = selected.includes(o.value);
return {
id: `${id}.${o.value}`,
label: o.label,
check: { kind: "many", checked: on },
onSelect: () =>
onChange(on ? selected.filter((v) => v !== o.value) : [...selected, o.value]),
};
}),
};
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
// docs/DESIGN.md "Background menu": which pages have one is the owner's call (2026-09-26, scope A) —
// the list pages whose toolbar sorts, filters or switches a view: the feed, the Plex library, the
// channel manager, a playlist and the notification center. The tab-only pages (settings, config,
// stats, users, downloads) keep the browser's menu. A page that starts rendering one fails here until
// that decision is taken again.
const files = import.meta.glob(["../**/*.tsx", "!../**/*.test.*", "!../design-lab/**", "!./**"], {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
describe("the pages with a background menu", () => {
it("are the five the owner chose", () => {
const users = Object.entries(files)
.filter(([, code]) => /<PageMenu\b|\busePageMenu\(/.test(code))
.map(([path]) => path.replace(/^\.\.\//, ""))
.sort();
expect(users).toEqual([
"Channels.tsx",
"Feed.tsx",
"NotificationsPanel.tsx",
"Playlists.tsx",
"PlexBrowse.tsx",
]);
});
});
@@ -0,0 +1,33 @@
import { createContext, useContext, useEffect } from "react";
import { useContextMenu, type ContextMenuController } from "./useContextMenu";
// docs/DESIGN.md "Background menu": a right-click on the EMPTY area of a page opens a menu of that
// page's own toolbar actions (sort, view, quick filters) — never a new function, and desktop only.
// PageShell listens on the page area and decides "empty" (lib/backgroundTarget); a page that has such a
// menu renders `<PageMenu>` (./PageMenu), which registers here while mounted and draws the same
// `ContextMenu` as a row's, from the page's live state. A page that registers nothing keeps the
// browser's menu everywhere.
//
// Its own module (not in PageShell.tsx / PageMenu.tsx) so those export only components (Fast Refresh).
/** Opens the page's menu at a viewport point, anchored to the page area. */
export type PageMenuOpener = (x: number, y: number, contextElement: Element) => void;
/** PageShell's registry: the latest registered opener answers (a feed inside a channel page takes
* over while mounted); the returned function unregisters. */
export const PageMenuSlot = createContext<((opener: PageMenuOpener) => () => void) | null>(null);
/** The page's background menu: render `<ContextMenu menu={this} …/>` with the page's actions, in
* every state where `enabled` — a menu registered with no surface drawn would eat the right-click
* and show nothing. `<PageMenu>` is the short form; a page that needs the open state (a row that
* fetches on open) takes the hook. */
export function usePageMenu(enabled = true): ContextMenuController<"page"> {
const register = useContext(PageMenuSlot);
const menu = useContextMenu<"page">();
const { openAt } = menu;
useEffect(
() => (enabled ? register?.((x, y, el) => openAt(x, y, "page", el)) : undefined),
[enabled, register, openAt],
);
return menu;
}
+4 -1
View File
@@ -72,6 +72,9 @@ export interface ContextMenuController<T> {
* click opens the SAME menu under the button; a second click on it while its menu is open closes
* it, like any menu button. Pair it with `aria-expanded` from the caller, which knows its row. */
buttonProps: (payload: T) => Record<string, unknown>;
/** Opens the menu at a viewport point for `payload`, anchored to `contextElement` — for an input
* path the two props above do not cover (the page's background menu, `ui/PageMenu`). */
openAt: (x: number, y: number, payload: T, contextElement?: Element) => void;
/** Consumed by `<ContextMenu>`; not part of the call site's vocabulary. */
popover: ReturnType<typeof useAnchoredPopover>;
}
@@ -259,7 +262,7 @@ export function useContextMenu<T>({
[openAt, close, keyOf],
);
return { target, anchor, open, close, triggerProps, buttonProps, popover: pop };
return { target, anchor, open, close, triggerProps, buttonProps, openAt, popover: pop };
}
/** One trigger per item, stable while the items and the menu are — for a list whose card component