Files
siftlode/frontend/src/components/ui/PageShell.tsx
T
peter b443ac0c84 fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic
Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
2026-10-23 13:45:40 +02:00

257 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
cloneElement,
isValidElement,
useCallback,
useContext,
useLayoutEffect,
useRef,
useState,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
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";
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
// scrolls and is never touched by the scroller's edge-fade mask. It's a portal, so the toolbar's
// event handlers and closures still belong to the module's React subtree (setFilters etc. work as
// if it were rendered inline). Renders nothing until the band mounts (one tick on first mount) and
// nothing when there's no shell — so a module works with or without one.
export function PageToolbar({ children }: { children: ReactNode }) {
const slot = useContext(PageToolbarSlot);
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);
const shown = !!slot?.target && !slot.claimed;
const showDefault = slot?.showDefault;
useLayoutEffect(() => {
showDefault?.(shown);
return () => showDefault?.(false);
}, [shown, showDefault]);
return shown && slot?.target ? 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
// branch that rendered its OWN scroller) collapses to one contract and one scroller.
//
// Regions, top to bottom:
// - `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.
// - `banners` — in-flow, fixed notices (verify/version) above the scroll region.
// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
// invariants (`scrollbar-gutter: stable`, edge fade); see that file.
//
// The shell renders a fragment so it drops straight into App's `relative` content column — the
// floating header keeps resolving its `absolute` position against that same ancestor.
export default function PageShell({
title,
header,
banners,
children,
scrollKey,
maxWidth,
}: {
title?: string;
header?: ReactNode;
banners?: ReactNode;
children: ReactNode;
// Identifies the logical view for the scroll restore/reset policy — see PageScroller.
scrollKey?: string;
// The content column's max width (e.g. "1600px"); the header + toolbar centre on the same width via
// --content-mw. Omit for a full-width page (feed). See index.css --content-mw.
maxWidth?: string;
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
// Publish this page's content max-width so the floating header (and toolbar band) can centre on the
// same width the content column uses. Global (one page shell renders at a time). Also decide whether
// a PINNED filter rail should stay OVERLAY (keep the toolbar's fixed leading gutter, don't push the
// centred content) or go in-flow: overlay only when the centred content leaves a wide-enough left
// margin for the rail to sit in — else pinning would overlap the content, so it pushes (in-flow).
useLayoutEffect(() => {
const root = document.documentElement;
root.style.setProperty("--content-mw", maxWidth ?? "none");
// Room the pinned rail needs: its own width (~16rem) on each side of the centred content, plus the
// nav rail (~4rem) = ~36rem. Below that, or on a full-width page (no maxWidth), pin in-flow (w-67).
let lastPxPerRem = 0;
const apply = () => {
// Convert the rem maxWidth (and the 36rem slack) against the LIVE root font-size, not a hardcoded
// 16: index.css scales the root by --font-scale (default 1.06 ≈ 17px, slider 0.9–1.3), and the page's
// own content wrapper is a rem `max-w-*`, so a fixed 16 mis-sizes the threshold at every text size.
const pxPerRem = parseFloat(getComputedStyle(root).fontSize) || 16;
lastPxPerRem = pxPerRem;
const mwPx = maxWidth
? maxWidth.endsWith("rem")
? parseFloat(maxWidth) * pxPerRem
: parseFloat(maxWidth)
: null;
const roomy = mwPx != null && window.innerWidth >= mwPx + 36 * pxPerRem;
root.style.setProperty("--rail-pinned-w", roomy ? "2.25rem" : "16.75rem");
};
apply();
window.addEventListener("resize", apply);
// The text-size slider changes --font-scale (hence the root font-size) WITHOUT firing resize, so the
// rem→px threshold would go stale. Re-run when the root font actually changes; the pxPerRem guard keeps
// our own --rail-pinned-w write (a rem string, no font change) from re-triggering the observer.
const fontObserver = new MutationObserver(() => {
if ((parseFloat(getComputedStyle(root).fontSize) || 16) !== lastPxPerRem) apply();
});
fontObserver.observe(root, { attributes: true, attributeFilter: ["style"] });
return () => {
window.removeEventListener("resize", apply);
fontObserver.disconnect();
root.style.removeProperty("--content-mw");
root.style.removeProperty("--rail-pinned-w");
};
}, [maxWidth]);
// The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL
// height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad
// would let the content toolbar slide up under it. Measure the header and write its height
// straight onto the content wrapper that consumes `--hdr-h`.
//
// Both nodes are held in STATE (callback refs), NOT plain refs behind a one-shot `useLayoutEffect([])`:
// PageShell is reused — never remounted — across feed↔channel navigation (same component position in
// App.tsx), so the header DOM node is swapped out from under a stable PageShell. A one-shot effect keeps
// observing the FIRST header node; when that node detaches on channel-open, its ResizeObserver fires with
// a 0 box and rewrites --hdr-h to `0+0+4`=4px on the (still-mounted) content div. On Back the pad class
// returns with that stale 4px → the content toolbar slides up under the header, stuck until reload. Keying
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
// 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);
// Bumped by every claim and every release. The count alone cannot say the pills changed: when one
// page's PageHeader replaces another's in one commit (feed → playlists), the release and the claim
// net to the same count, and the measurement below would not re-run.
const [headerGeneration, setHeaderGeneration] = useState(0);
const claimHeader = useCallback(() => {
setHeaderClaims((n) => n + 1);
setHeaderGeneration((g) => g + 1);
return () => {
setHeaderClaims((n) => n - 1);
setHeaderGeneration((g) => g + 1);
};
}, []);
// Whether the shell's default is in the header (PageHeaderDefault reports it).
const [defaultShown, setDefaultShown] = useState(false);
// The pills arrive by portal AFTER this shell mounted (with the page's chunk), so the first measurement
// saw an empty header; the ResizeObserver catches up only a frame or more later, and until then the
// content sat under the pills (the feed toolbar jumped 55px on a tablet). So the measurement also re-runs
// here, before paint, whenever what fills the header changes (a claim made or released, the default
// shown or gone); the observer still covers a change within a page's own pills (Plex's view switch
// appearing).
useLayoutEffect(() => {
if (!headerEl || !contentEl) {
// No header (channel page) → drop any stale pad so a leftover value can't linger.
contentEl?.style.removeProperty("--hdr-h");
return;
}
// `+ 4` = a tight gap below the floating header before the content toolbar starts.
const apply = () =>
contentEl.style.setProperty("--hdr-h", `${headerEl.offsetTop + headerEl.offsetHeight + 4}px`);
apply();
const ro = new ResizeObserver(apply);
ro.observe(headerEl);
return () => ro.disconnect();
}, [headerEl, contentEl, headerGeneration, defaultShown]);
const headerSlot = {
target: headerEl,
claimed: headerClaims > 0,
claim: claimHeader,
showDefault: setDefaultShown,
};
// How many views below render their own page `h1` right now (see ./pageHeading).
const [ownHeadings, setOwnHeadings] = useState(0);
const registerOwnHeading = useCallback(() => {
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,
})
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
<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>
</PageHeaderSlot.Provider>
</PageToolbarSlot.Provider>
);
}