import { cloneElement, createContext, isValidElement, useContext, useLayoutEffect, useRef, useState, type ReactElement, type ReactNode, type Ref, } 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); // 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 (E5 S7). Measure the header (a ref forwarded onto // its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so // the two live together with no assumption about the header's DOM parent. const headerRef = useRef(null); const contentRef = useRef(null); useLayoutEffect(() => { const h = headerRef.current; const c = contentRef.current; if (!h || !c) return; // `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it. const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`); apply(); const ro = new ResizeObserver(apply); ro.observe(h); return () => { ro.disconnect(); c.style.removeProperty("--hdr-h"); }; }, []); const measuredHeader = isValidElement(header) ? cloneElement(header as ReactElement<{ ref?: Ref }>, { ref: headerRef, }) : header; return ( {measuredHeader}
{banners}
{children}
); }