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:
@@ -55,4 +55,28 @@ test.describe("channel view", () => {
|
||||
.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop), { timeout: 5000 })
|
||||
.toBeGreaterThan(saved - 500);
|
||||
});
|
||||
|
||||
// Platform S4b: the channel page's banner/identity scroll away, but the tabs + feed toolbar stick
|
||||
// to the top (a sticky sub-header) so only the video grid scrolls.
|
||||
test("tabs + toolbar stay pinned while the channel videos scroll", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
await expect(count).toBeVisible();
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await expect(page.getByTestId("channel-title")).toBeInViewport();
|
||||
|
||||
// Keep nudging until the grid is tall enough to actually scroll (it may still be filling in).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500));
|
||||
return page.getByRole("main").evaluate((el) => el.scrollTop);
|
||||
})
|
||||
.toBeGreaterThan(400);
|
||||
|
||||
// Banner/identity scrolled away, but the tabs+toolbar stuck near the top.
|
||||
await expect(page.getByTestId("channel-title")).not.toBeInViewport();
|
||||
await expect(count).toBeInViewport();
|
||||
expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBeLessThan(130);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useNavigationActions } from "./NavigationProvider";
|
||||
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||
import Avatar from "./Avatar";
|
||||
import Feed from "./Feed";
|
||||
import { PageToolbarSlot } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -76,6 +77,10 @@ export default function ChannelPage({
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||
// The sticky sub-header hosts the tabs AND the channel feed's toolbar: the Feed portals its
|
||||
// toolbar into this node (via the overridden PageToolbarSlot below) so both ride together and
|
||||
// stay put while the banner/identity scroll away above them.
|
||||
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
|
||||
// The banner URL is valid, but googleusercontent intermittently throttles it when the channel
|
||||
// page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the
|
||||
// browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would
|
||||
@@ -294,11 +299,16 @@ export default function ChannelPage({
|
||||
{t("channel.loadingVideos")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky sub-header: the tabs and (on the Videos tab) the feed toolbar ride here and stay put
|
||||
while the banner + identity scroll away above them. The toolbar portals into `chromeSlot`
|
||||
below via the overridden PageToolbarSlot; only the video grid scrolls. */}
|
||||
<div className="sticky top-0 z-20 bg-surface border-b border-border">
|
||||
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
|
||||
corner: there are only ever two tabs, so there's room, and it keeps the things you click
|
||||
within a few pixels of each other instead of across the page. */}
|
||||
<div className="flex items-center gap-4 mt-3 border-b border-border">
|
||||
<div className="px-4 sm:px-6 pt-2 flex items-center gap-4">
|
||||
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
||||
{t("channel.tabVideos")}
|
||||
</button>
|
||||
@@ -365,9 +375,11 @@ export default function ChannelPage({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={setChromeSlot} />
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */}
|
||||
<PageToolbarSlot.Provider value={chromeSlot}>
|
||||
{tab === "videos" ? (
|
||||
<>
|
||||
<Feed
|
||||
@@ -467,6 +479,7 @@ export default function ChannelPage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PageToolbarSlot.Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -655,27 +655,24 @@ export default function Feed({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "p-4" : "px-4 pb-4 pt-2"}>
|
||||
{/* On the feed page the toolbar + active-filter chips are FIXED chrome: they pin in the
|
||||
shell's toolbar band (above the scroller) so they stay put — and reachable — while the
|
||||
cards scroll (bug 4). On a channel page the Feed is embedded below the channel's own
|
||||
banner/tabs, which scroll, so there the toolbar rides inline with the content as before.
|
||||
Channel pages get no chips: their Feed starts from its OWN baseline (channel-scoped,
|
||||
whole-catalog, show-all), so each would read as a "filter you applied" when it's just the
|
||||
page being itself. */}
|
||||
{channelScoped ? (
|
||||
toolbar
|
||||
) : (
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-4 pb-1">
|
||||
{toolbar}
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
|
||||
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
|
||||
applied" when it's just the page being itself. */}
|
||||
<PageToolbar>
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-2" : "px-4 pt-4 pb-1"}>
|
||||
{toolbar}
|
||||
{!channelScoped && (
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
/>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted">
|
||||
<p>{t("feed.noMatches")}</p>
|
||||
|
||||
@@ -67,7 +67,9 @@ export default function PageScroller({
|
||||
children: ReactNode;
|
||||
scrollKey?: string;
|
||||
}) {
|
||||
const fade = useScrollFade();
|
||||
// Only the bottom edge fades: the top is bounded by the shell's fixed chrome (header / toolbar
|
||||
// band) or a module's sticky sub-header, so a top fade would just eat into that chrome.
|
||||
const fade = useScrollFade(20, { top: false });
|
||||
const fadeRef = fade.ref;
|
||||
const elRef = useRef<HTMLElement | null>(null);
|
||||
// Stable identity: an inline ref callback changes every render, which makes React detach and
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,17 @@ import { useEffect, useState, type CSSProperties } from "react";
|
||||
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
|
||||
// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that
|
||||
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
|
||||
export function useScrollFade(fadePx = 20): {
|
||||
// `edges` gates which ends fade. The page scroller passes `{ top: false }`: its top is delimited by
|
||||
// the fixed/sticky module chrome (header, toolbar band, a sticky sub-header), so a top fade there
|
||||
// would only bite into that chrome instead of hinting at more content.
|
||||
export function useScrollFade(
|
||||
fadePx = 20,
|
||||
edges: { top?: boolean; bottom?: boolean } = {},
|
||||
): {
|
||||
ref: (node: HTMLElement | null) => void;
|
||||
style: CSSProperties;
|
||||
} {
|
||||
const { top: fadeTop = true, bottom: fadeBottom = true } = edges;
|
||||
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||
const [fade, setFade] = useState({ up: false, down: false });
|
||||
|
||||
@@ -53,8 +60,8 @@ export function useScrollFade(fadePx = 20): {
|
||||
};
|
||||
}, [el]);
|
||||
|
||||
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${
|
||||
fade.down ? fadePx : 0
|
||||
}px), transparent)`;
|
||||
const topPx = fadeTop && fade.up ? fadePx : 0;
|
||||
const bottomPx = fadeBottom && fade.down ? fadePx : 0;
|
||||
const mask = `linear-gradient(to bottom, transparent, #000 ${topPx}px, #000 calc(100% - ${bottomPx}px), transparent)`;
|
||||
return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user