import { createContext, useContext, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; // The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its // fixed band as the default target; a module can override it with its own // to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the // toolbar rides with the tabs instead of the shell's top band. export const PageToolbarSlot = createContext(null); // 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; } // 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. // - `banners` — in-flow, fixed notices (demo/version) above the scroll region. // - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a // module via . 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({ header, banners, children, scrollKey, fadeTop = true, }: { header?: ReactNode; banners?: ReactNode; children: ReactNode; // Identifies the logical view for the scroll restore/reset policy — see PageScroller. scrollKey?: string; // Off when this page's scroll region starts with a sticky header — see PageScroller. fadeTop?: boolean; }) { const [toolbarSlot, setToolbarSlot] = useState(null); return ( {header}
{banners}
{children}
); }