fix(feed): pin the feed toolbar in the shell's fixed band (bug 4)

The feed's show/content chips, source, count, sort and view switcher —
plus the active-filter chips — lived inside the scroller, so they
scrolled away with the cards; only the SearchBar strip stayed fixed.

Give PageShell a fixed toolbar band (filled by a module via the new
PageToolbar portal) above the scroller. On the feed page the toolbar +
chips portal into it and stay put — and removable — while cards scroll.
The channel-embedded Feed (channelScoped) keeps its inline toolbar,
which rides with the banner/tabs as before.

Verified both entry points at desktop + 1000px (toolbar wraps in-flow,
band grows, still pinned); E2E 11/11; tsc + knip clean.
This commit is contained in:
2026-07-18 03:25:48 +02:00
parent d81f10b1cf
commit a6cbc45334
2 changed files with 43 additions and 18 deletions
+23 -8
View File
@@ -1,6 +1,22 @@
import { type ReactNode } from "react";
import { createContext, useContext, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
// The DOM node of the shell's fixed toolbar band, published so a module deep inside the scroller
// can render its own toolbar UP into the fixed region (see PageToolbar below).
const ToolbarSlot = 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(ToolbarSlot);
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
@@ -11,8 +27,8 @@ import PageScroller from "./PageScroller";
// 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` — in-flow, fixed per-module band (filter chips / sort / counts) that must NOT scroll
// with the content.
// - 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.
//
@@ -21,22 +37,21 @@ import PageScroller from "./PageScroller";
export default function PageShell({
header,
banners,
toolbar,
children,
}: {
header?: ReactNode;
banners?: ReactNode;
toolbar?: ReactNode;
children: ReactNode;
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
return (
<>
<ToolbarSlot.Provider value={toolbarSlot}>
{header}
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
{banners}
{toolbar}
<div ref={setToolbarSlot} className="shrink-0" />
<PageScroller>{children}</PageScroller>
</div>
</>
</ToolbarSlot.Provider>
);
}