The sticky <thead> jumped up ~8px and picked up the scroller's top fade the moment you scrolled 1px — so it read as 'not fixed'. Two causes: - the table sat under a pt-2 gap, so its natural position was 8px below the sticky top:0 it snapped to. Drop the top padding (the fixed band's controls row already gives the visual gap) → no shift. - the page scroller's top edge fade turned on with the first scroll and masked the header's top. Add a fadeTop flag (PageShell → PageScroller → useScrollFade), off for the Channels + Audit pages whose scroll region starts with a sticky header. Feed/channel keep the top fade (it fades cards under the fixed toolbar, as intended). Verified: header stays at a fixed offset with no fade change from scroll 0 through scrolled, on both the manager and the audit log. Suite 15/15.
68 lines
3.5 KiB
TypeScript
68 lines
3.5 KiB
TypeScript
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 <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);
|
|
return (
|
|
<PageToolbarSlot.Provider value={toolbarSlot}>
|
|
{header}
|
|
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
|
|
{banners}
|
|
<div ref={setToolbarSlot} className="shrink-0" />
|
|
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
|
|
{children}
|
|
</PageScroller>
|
|
</div>
|
|
</PageToolbarSlot.Provider>
|
|
);
|
|
}
|