Addresses the S7 mobile-fix code-review findings: - (altitude) The header-height→content-pad contract lived across two files with an implicit DOM coupling: Header measured itself and wrote --hdr-h onto `el.parentElement`, assuming PageShell rendered it as a direct child of the padded column. Move the whole contract into PageShell, which owns BOTH the header slot and the padded content wrapper: Header is now forwardRef and PageShell attaches a ref (via cloneElement), measures the header with a ResizeObserver, and writes --hdr-h straight onto the content wrapper that consumes it — no parent assumption, producer and consumer together. - (simplification) The `+ 8` gap is now documented as the header's own gap-y-2 row gap reused below it, not a bare magic number. - (test-coverage) PageShell.dom.test.tsx: stub ResizeObserver + the header's offset metrics and assert --hdr-h is published onto the content wrapper (and absent with no header). Re-verified live at 440 (mobile, no overlap) and desktop (one row). FE gate: tsc / knip / lint clean, 220 vitest pass.
108 lines
4.9 KiB
TypeScript
108 lines
4.9 KiB
TypeScript
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 <PageToolbarSlot.Provider>
|
||
// 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<HTMLElement | null>(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 <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({
|
||
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<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
|
||
const contentRef = useRef<HTMLDivElement | null>(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<HTMLDivElement> }>, {
|
||
ref: headerRef,
|
||
})
|
||
: header;
|
||
return (
|
||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||
{measuredHeader}
|
||
<div
|
||
ref={contentRef}
|
||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
|
||
>
|
||
{banners}
|
||
<div ref={setToolbarSlot} className="shrink-0" />
|
||
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
||
{children}
|
||
</PageScroller>
|
||
</div>
|
||
</PageToolbarSlot.Provider>
|
||
);
|
||
}
|