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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user