feat(shell): sticky tabs+toolbar on the channel page

Generalise the fixed-chrome pattern to the channel detail page: the
banner + identity now scroll away, while the tab row and the channel
feed's toolbar stick to the top as a sticky sub-header — only the video
grid scrolls.

Mechanism: PageToolbarSlot (the toolbar portal target) is now an exported,
overridable context. ChannelPage provides its own sticky sub-header node
as the target, so the channelScoped Feed portals its toolbar up next to
the tabs instead of into the shell's top band. Feed always portals its
toolbar now (the slot decides where it lands).

Also: the page scroller now fades only its BOTTOM edge — the top is
bounded by the fixed/sticky chrome, so a top fade only bit into it.

E2E: adds a lock that the channel tabs/toolbar stay pinned while the
banner scrolls away; suite 15/15 on :5173, tsc + knip clean.
This commit is contained in:
2026-07-18 04:45:11 +02:00
parent 10f4baab3b
commit 0e03e561eb
6 changed files with 75 additions and 30 deletions
+8 -6
View File
@@ -2,9 +2,11 @@ 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);
// 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
@@ -13,7 +15,7 @@ const ToolbarSlot = createContext<HTMLElement | null>(null);
// 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);
const slot = useContext(PageToolbarSlot);
return slot ? createPortal(children, slot) : null;
}
@@ -48,13 +50,13 @@ export default function PageShell({
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
return (
<ToolbarSlot.Provider value={toolbarSlot}>
<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}>{children}</PageScroller>
</div>
</ToolbarSlot.Provider>
</PageToolbarSlot.Provider>
);
}