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:
@@ -39,6 +39,10 @@ import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "../lib/tableColumns";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
@@ -516,8 +520,65 @@ export default function Channels() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the
|
||||
// layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill
|
||||
// stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click.
|
||||
const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }));
|
||||
const pageMenuItems = menuGroups(
|
||||
hasLayout
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "layout",
|
||||
label: t("channels.layoutLabel"),
|
||||
options: layoutOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: layout,
|
||||
onPick: setLayout,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" && isCards
|
||||
? [
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("channels.sort.label"),
|
||||
options: optionsFromColumns(columns),
|
||||
sort,
|
||||
onChange: setSort,
|
||||
ascLabel: t("channels.sort.asc"),
|
||||
descLabel: t("channels.sort.desc"),
|
||||
defaultLabel: t("channels.sort.default"),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed"
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "status",
|
||||
label: t("channels.filters.syncLabel"),
|
||||
options: syncOptions,
|
||||
selected: syncFilter,
|
||||
onPick: (sync) => setFilters({ ...filters, sync }),
|
||||
}),
|
||||
{ id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) },
|
||||
...(tagFilter.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clearTags",
|
||||
label: t("channels.tags.clearFilter"),
|
||||
onSelect: () => setFilters({ ...filters, tags: [] }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageMenuItems.length > 0 && (
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
)}
|
||||
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
|
||||
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
|
||||
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
|
||||
|
||||
@@ -44,6 +44,9 @@ import SelectedViewChip from "./SelectedViewChip";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
||||
import { useWizard } from "./wizardContext";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
@@ -636,29 +639,51 @@ export default function Feed({
|
||||
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||
// same controls render on both the feed and channel-scoped rows.
|
||||
// One setter per toolbar control, shared with the page's background menu (the same choices, the
|
||||
// same effect, two doors).
|
||||
const setSort = (next: SortState) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
};
|
||||
const reshuffle = () => setFilters({ ...filters, seed: rollSeed() });
|
||||
const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }));
|
||||
const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] });
|
||||
const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) }));
|
||||
const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key);
|
||||
const setContent = (next: string[]) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
};
|
||||
const sourceOptions = [
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
];
|
||||
const setSource = (source: string) =>
|
||||
setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] });
|
||||
const viewOptions = FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}));
|
||||
|
||||
const sortView = (
|
||||
<>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
onChange={setSort}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
label={t("sidebar.reshuffle")}
|
||||
>
|
||||
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
|
||||
<RefreshCw />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
@@ -667,11 +692,7 @@ export default function Feed({
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}))}
|
||||
options={viewOptions}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// The mode's name only earns its place once the window is wide.
|
||||
@@ -698,21 +719,17 @@ export default function Feed({
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
testId="feed-show"
|
||||
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
|
||||
options={showOptions}
|
||||
selected={[filters.show]}
|
||||
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
|
||||
onChange={(next) => next[0] && setShow(next[0])}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
|
||||
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
|
||||
onChange={(next) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
}}
|
||||
options={contentOptions}
|
||||
selected={contentSelected}
|
||||
onChange={setContent}
|
||||
/>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog
|
||||
concept — it selects how videos ENTERED the library. On a channel-scoped view we
|
||||
@@ -722,18 +739,9 @@ export default function Feed({
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={[
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
]}
|
||||
options={sourceOptions}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
librarySource: next[0] as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
onChange={(next) => next[0] && setSource(next[0])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -746,8 +754,70 @@ export default function Feed({
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
||||
const pageMenuItems = menuGroups(
|
||||
[
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("feed.sortLabel"),
|
||||
options: sortOptions,
|
||||
sort: { key: sortKey, dir: sortDir },
|
||||
onChange: setSort,
|
||||
ascLabel: t("feed.dirAsc"),
|
||||
descLabel: t("feed.dirDesc"),
|
||||
}),
|
||||
...(sortKey === "shuffle"
|
||||
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
|
||||
: []),
|
||||
choiceSubmenu({
|
||||
id: "view",
|
||||
label: t("feed.viewLabel"),
|
||||
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: view,
|
||||
onPick: setView,
|
||||
}),
|
||||
],
|
||||
[
|
||||
choiceSubmenu({
|
||||
id: "show",
|
||||
label: t("feed.showLabel"),
|
||||
options: showOptions,
|
||||
selected: filters.show,
|
||||
onPick: setShow,
|
||||
}),
|
||||
multiSubmenu({
|
||||
id: "content",
|
||||
label: t("feed.contentLabel"),
|
||||
options: contentOptions,
|
||||
selected: contentSelected,
|
||||
onChange: setContent,
|
||||
}),
|
||||
...(channelScoped
|
||||
? []
|
||||
: [
|
||||
choiceSubmenu({
|
||||
id: "source",
|
||||
label: t("feed.source.label"),
|
||||
options: sourceOptions,
|
||||
selected: filters.librarySource ?? "organic",
|
||||
onPick: setSource,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
!channelScoped && activeFilters.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clear",
|
||||
label: t("sidebar.clearAll"),
|
||||
onSelect: () => setFilters(clearedFilters(filters)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LEVEL_STYLE } from "./ui/toastLevels";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { useChannelsActions } from "./channelsContext";
|
||||
@@ -322,6 +323,14 @@ export default function NotificationsPanel() {
|
||||
{/* The inbox band (B4): Mark all read in sight, Clear everything in its "…" (E28 C1), fixed
|
||||
while the list scrolls. No title card: the nav rail and the tab title name the page, and the
|
||||
shell's hidden h1 names it to a screen reader. */}
|
||||
{/* The page's background menu (DESIGN "Background menu"): the band's own non-destructive
|
||||
action — Clear everything stays behind its "…" and its confirm. */}
|
||||
{hasAny && (
|
||||
<PageMenu
|
||||
ariaLabel={t("common.pageMenu")}
|
||||
items={headerItems.filter((it) => !("danger" in it && it.danger))}
|
||||
/>
|
||||
)}
|
||||
{hasAny && (
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 pb-1 max-w-3xl w-full mx-auto flex justify-end items-center gap-2">
|
||||
|
||||
@@ -37,14 +37,16 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
}));
|
||||
vi.mock("./playlistsContext", () => ({ usePlaylists: () => ({ selectedId: h.selectedId }) }));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => h.confirm }));
|
||||
// The list's own menu is usePlaylistActions' (pinned in its own test); here only its rename row,
|
||||
// which opens THIS page's title field.
|
||||
// The list's own menu is usePlaylistActions' (pinned in its own test); here its rename row, which
|
||||
// opens THIS page's title field, and its two destructive rows as that hook marks them (danger).
|
||||
vi.mock("./usePlaylistActions", () => ({
|
||||
usePlaylistActions: () => ({
|
||||
remove: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
items: (_pl: unknown, o: { onRename: () => void }) => [
|
||||
{ id: "rename", label: "playlists.rename", onSelect: o.onRename },
|
||||
{ id: "revert", label: "playlists.revert", danger: true, onSelect: vi.fn() },
|
||||
{ id: "delete", label: "playlists.delete", danger: true, onSelect: vi.fn() },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
@@ -67,6 +69,7 @@ vi.mock("./PlayerModal", async () => {
|
||||
|
||||
import Playlists from "./Playlists";
|
||||
import { PageToolbarSlot } from "./ui/pageToolbarSlot";
|
||||
import { PageMenuSlot, type PageMenuOpener } from "./ui/pageMenuSlot";
|
||||
import { api, HttpError, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { patchCachedProgress } from "../lib/progressCache";
|
||||
@@ -633,3 +636,46 @@ describe("Playlists · the header", () => {
|
||||
expect(await screen.findByText("playlists.groupByChannel")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// DESIGN "Background menu": the page's blank area mirrors the band (Play all, the view controls) and
|
||||
// the list's own "…" — without its destructive rows, which stay one deliberate "…" away.
|
||||
describe("Playlists · the page's background menu", () => {
|
||||
it("offers Play all, Group by and the list's menu, never Delete", async () => {
|
||||
let open: PageMenuOpener | null = null;
|
||||
vi.spyOn(api, "playlists").mockResolvedValue([list(1, [vid("A", 0), vid("B", 0)])] as never);
|
||||
vi.spyOn(api, "playlist").mockImplementation(
|
||||
async () => list(1, [vid("A", 0), vid("B", 0)]) as never,
|
||||
);
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {}, preferences: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PageMenuSlot.Provider
|
||||
value={(o) => {
|
||||
open = o;
|
||||
return () => {
|
||||
if (open === o) open = null;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Playlists />
|
||||
</PageMenuSlot.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByRole("button", { name: "card.play — Video A" });
|
||||
expect(open, "registered once a list is on screen").not.toBeNull();
|
||||
act(() => open!(5, 5, document.body));
|
||||
const rows = await waitFor(() => {
|
||||
const r = screen
|
||||
.getAllByRole("menu", { hidden: true })
|
||||
.find((m) => m.getAttribute("aria-label") === "common.pageMenu");
|
||||
expect(r).toBeTruthy();
|
||||
return [...r!.querySelectorAll("[role^=menuitem]")].map((b) => b.textContent);
|
||||
});
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining(["playlists.playAll", "playlists.groupByChannel", "playlists.rename"]),
|
||||
);
|
||||
expect(rows).not.toContain("playlists.delete");
|
||||
expect(rows).not.toContain("playlists.revert");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { useComfortPin, pinPlaylist, pinVideo } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { usePageMenu } from "./ui/pageMenuSlot";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
@@ -268,6 +270,9 @@ export default function Playlists() {
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const detail = detailQuery.data;
|
||||
// The page's background menu (DESIGN "Background menu"), live only while a list is on screen.
|
||||
const pageMenu = usePageMenu(selectedId != null && !!detail);
|
||||
const pagePin = useComfortPin(pageMenu.open);
|
||||
|
||||
// Destructured so the effect below can name what it calls: both are stable useCallbacks, while
|
||||
// the object around them is rebuilt every render (lib/useUndoable).
|
||||
@@ -704,6 +709,57 @@ export default function Playlists() {
|
||||
pin: headerPin.item(pinPlaylist(headerPl.id), plName(headerPl)),
|
||||
});
|
||||
|
||||
// The page's background menu mirrors the band: Play all, the view controls, the order's undo, and
|
||||
// the list's own "…" without its destructive rows (Delete, Revert) — a stray right-click on the
|
||||
// page's blank area must not be one click from those.
|
||||
const pageItems: ActionMenuItem[] = !detail
|
||||
? []
|
||||
: menuGroups(
|
||||
[
|
||||
{
|
||||
id: "playAll",
|
||||
label: t("playlists.playAll"),
|
||||
disabled: !shown.length,
|
||||
onSelect: () => shown.length && openPlayer(shown[0]!.id),
|
||||
},
|
||||
],
|
||||
items.length > 1 || viewActive
|
||||
? [
|
||||
{
|
||||
id: "groupBy",
|
||||
label: t("playlists.groupByChannel"),
|
||||
check: { kind: "many", checked: groupBy },
|
||||
onSelect: () => setGroupBy(!groupBy),
|
||||
},
|
||||
...(viewActive
|
||||
? [{ id: "clearView", label: t("playlists.clearView"), onSelect: clearView }]
|
||||
: []),
|
||||
...(canSaveOrder
|
||||
? [{ id: "saveOrder", label: t("playlists.saveOrder"), onSelect: saveOrder }]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
canEdit && (order.canUndo || order.canRedo)
|
||||
? [
|
||||
...(order.canUndo
|
||||
? [{ id: "undo", label: t("common.undo"), onSelect: order.undo }]
|
||||
: []),
|
||||
...(order.canRedo
|
||||
? [{ id: "redo", label: t("common.redo"), onSelect: order.redo }]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
plActions
|
||||
.items(detail, {
|
||||
onRename: () => {
|
||||
setRenameValue(detail.name);
|
||||
setRenaming(true);
|
||||
},
|
||||
pin: pagePin.item(pinPlaylist(detail.id), plName(detail)),
|
||||
})
|
||||
.filter((it) => !("danger" in it && it.danger)),
|
||||
);
|
||||
|
||||
// The open player, at ONE place in the tree whichever state the page below it is in. Selecting
|
||||
// another list under an open player (the command palette works over it) puts the page into
|
||||
// "Loading…" until that list arrives; with the player rendered only in the loaded branch, that
|
||||
@@ -966,6 +1022,11 @@ export default function Playlists() {
|
||||
{/* One surface for the whole table, outside the rows — it anchors to the pointer, not the row. */}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("playlists.rowMenu")} items={ctxItems} />
|
||||
<ContextMenu menu={headerMenu} ariaLabel={t("playlists.railMenu")} items={headerItems} />
|
||||
<ContextMenu
|
||||
menu={pageMenu}
|
||||
ariaLabel={t("common.pageMenu")}
|
||||
items={pageMenu.open ? pageItems : []}
|
||||
/>
|
||||
</div>
|
||||
{player}
|
||||
</>
|
||||
|
||||
@@ -30,6 +30,9 @@ import FilterSelect from "./ui/FilterSelect";
|
||||
import PlexDiscover from "./PlexDiscover";
|
||||
import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
||||
import ActiveFilterChips from "./ActiveFilterChips";
|
||||
import SelectedViewChip from "./SelectedViewChip";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
@@ -531,8 +534,35 @@ export default function PlexBrowse() {
|
||||
);
|
||||
}
|
||||
|
||||
const showOptions = SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }));
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
||||
const pageMenuItems = menuGroups(
|
||||
[
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("plex.filter.sort"),
|
||||
options: sortOptions,
|
||||
sort: sortState,
|
||||
onChange: (next) => setSort(next ?? PLEX_SORT_FALLBACK),
|
||||
ascLabel: t("plex.filter.dir.asc"),
|
||||
descLabel: t("plex.filter.dir.desc"),
|
||||
}),
|
||||
choiceSubmenu({
|
||||
id: "show",
|
||||
label: t("plex.filter.show"),
|
||||
options: showOptions,
|
||||
selected: show,
|
||||
onPick: setShow,
|
||||
}),
|
||||
],
|
||||
plexChips.length > 0
|
||||
? [{ id: "clear", label: t("sidebar.clearAll"), onSelect: clearAllFilters }]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-400 mx-auto">
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||
while only the poster grid scrolls. */}
|
||||
<PageToolbar>
|
||||
@@ -546,7 +576,7 @@ export default function PlexBrowse() {
|
||||
<FilterSelect
|
||||
label={t("plex.filter.show")}
|
||||
testId="plex-show"
|
||||
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
|
||||
options={showOptions}
|
||||
selected={[show]}
|
||||
onChange={(next) => setShow(next[0] ?? "all")}
|
||||
/>
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 : []} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
},
|
||||
"tags": {
|
||||
"label": "Tags",
|
||||
"clearFilter": "Clear tag filter",
|
||||
"edit": "Edit tags…",
|
||||
"add": "Tag",
|
||||
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the Feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
|
||||
"noneYet": "No tags yet — create one below.",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"scrollRight": "Scroll right",
|
||||
"search": "Search",
|
||||
"noResults": "No matches",
|
||||
"pageMenu": "Page actions",
|
||||
"priority": {
|
||||
"raise": "Raise priority",
|
||||
"lower": "Lower priority",
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
},
|
||||
"tags": {
|
||||
"label": "Címkék",
|
||||
"clearFilter": "Címkeszűrő törlése",
|
||||
"edit": "Címkék szerkesztése…",
|
||||
"add": "Címke",
|
||||
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a Feedet címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
|
||||
"noneYet": "Még nincs címke — hozz létre egyet lent.",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"scrollRight": "Görgetés jobbra",
|
||||
"search": "Keresés",
|
||||
"noResults": "Nincs találat",
|
||||
"pageMenu": "Az oldal műveletei",
|
||||
"priority": {
|
||||
"raise": "Prioritás növelése",
|
||||
"lower": "Prioritás csökkentése",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// docs/DESIGN.md "Background menu": a right-click on EMPTY content area opens the page's menu; over
|
||||
// text, a control, a link, media or an object with its own menu the browser's (or the object's) stays.
|
||||
// This decides "empty", from the element the pointer is over up to the page root that listens.
|
||||
|
||||
/** An element that is not background: a control, a link, a field, media, or an object (a row or card
|
||||
* with its own menu, a list item the keyboard walks). Anything inside one is not background either. */
|
||||
const NOT_BACKGROUND = [
|
||||
"a",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"label",
|
||||
"summary",
|
||||
"[contenteditable]",
|
||||
// A widget by its role (a div acting as a button, a menu, a listbox and its options, …).
|
||||
...[
|
||||
"button",
|
||||
"link",
|
||||
"menuitem",
|
||||
"tab",
|
||||
"option",
|
||||
"slider",
|
||||
"checkbox",
|
||||
"switch",
|
||||
"dialog",
|
||||
"menu",
|
||||
"listbox",
|
||||
].map((role) => `[role="${role}"]`),
|
||||
"img",
|
||||
"picture",
|
||||
"video",
|
||||
"audio",
|
||||
"canvas",
|
||||
"svg",
|
||||
"iframe",
|
||||
"[data-context-menu-target]",
|
||||
"[data-nav-index]",
|
||||
].join(",");
|
||||
|
||||
/** True when `target` is text itself: an element holding words of its own (not only inside children).
|
||||
* A right-click on a paragraph, a heading or a label's padding is on text: its menu is the browser's. */
|
||||
function holdsText(el: Element): boolean {
|
||||
for (const node of el.childNodes)
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True when a right-click on `target` falls on the empty area of `root` (the page). */
|
||||
export function isBackgroundPoint(target: EventTarget | null, root: Element): boolean {
|
||||
if (!(target instanceof Element) || !root.contains(target)) return false;
|
||||
if (holdsText(target)) return false;
|
||||
const hit = target.closest(NOT_BACKGROUND);
|
||||
return !hit || !root.contains(hit);
|
||||
}
|
||||
Reference in New Issue
Block a user