refactor(frontend): each page fills the floating header itself; the header knows no page

The header used to pick its pills by `page === …` and import five module controls, and Plex's
Discover/Browse toggle needed a context bridge to reach the sub-view that lives in PlexBrowse.
Now the header is an empty band: PageShell hands its root box out as a slot, a page portals its
own pills into it (PageHeader), and the palette's Jump-to pill shows where no page does
(PageHeaderDefault, rendered inside the page's Suspense so a loading page shows no stand-in).
Plex's toggles take the sub-view as a prop; PlexViewBridge and its context are gone, and with
them the header's five layer suppressions.

No visible change: every header pill's box measured identical against the previous build on eight
pages, in Hungarian and English.
This commit is contained in:
2026-10-23 00:23:38 +02:00
parent 0b24f2d8fa
commit 4ef963b005
36 changed files with 530 additions and 316 deletions
+54 -19
View File
@@ -13,6 +13,7 @@ import {
import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
import { PageToolbarSlot } from "./pageToolbarSlot";
import { PageHeaderSlot } from "./pageHeaderSlot";
import { PageHeadingProvider } from "./pageHeading";
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
import { isBackgroundPoint } from "../../lib/backgroundTarget";
@@ -29,6 +30,25 @@ export function PageToolbar({ children }: { children: ReactNode }) {
return slot ? createPortal(children, slot) : null;
}
// A page renders its controls in the floating header (its search box, scope toggles, sync chip)
// through this, the same way as PageToolbar: a portal, so the controls stay in the page's own React
// subtree and reach its state directly (Plex's toggles drive the sub-view that lives in PlexBrowse).
// The header itself knows no page. Claiming the header hides the shell's default (PageHeaderDefault).
export function PageHeader({ children }: { children: ReactNode }) {
const slot = useContext(PageHeaderSlot);
const claim = slot?.claim;
useLayoutEffect(() => claim?.(), [claim]);
return slot?.target ? createPortal(children, slot.target) : null;
}
// What the header shows on a page that renders no PageHeader of its own (the palette's entry). App
// renders it beside the page, inside the page's Suspense, so a page still loading shows no default
// that its own controls would replace a moment later.
export function PageHeaderDefault({ children }: { children: ReactNode }) {
const slot = useContext(PageHeaderSlot);
return slot?.target && !slot.claimed ? createPortal(children, slot.target) : null;
}
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
@@ -38,6 +58,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
// and no fixed top band, so the module's own banner can scroll from the very top.
// The header is an empty band: the page fills it through PageHeader.
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
// the visible identity, and a screen reader still lands on a named page. A view that
// shows its own title renders it through `PageHeading`, and this one steps aside.
@@ -137,6 +158,18 @@ export default function PageShell({
ro.observe(headerEl);
return () => ro.disconnect();
}, [headerEl, contentEl]);
// How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
// that leaves releases only its own claim.
const [headerClaims, setHeaderClaims] = useState(0);
const claimHeader = useCallback(() => {
setHeaderClaims((n) => n + 1);
return () => setHeaderClaims((n) => n - 1);
}, []);
const headerSlot = {
target: headerEl,
claimed: headerClaims > 0,
claim: claimHeader,
};
// How many views below render their own page `h1` right now (see ./pageHeading).
const [ownHeadings, setOwnHeadings] = useState(0);
const registerOwnHeading = useCallback(() => {
@@ -173,26 +206,28 @@ export default function PageShell({
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
<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
<PageHeaderSlot.Provider value={headerSlot}>
<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>
</PageMenuSlot.Provider>
<div ref={setToolbarSlot} className="shrink-0 pr-[var(--sbw)]" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
</div>
</PageHeadingProvider>
</PageMenuSlot.Provider>
</PageHeaderSlot.Provider>
</PageToolbarSlot.Provider>
);
}