feat(shell): channel chrome is fully fixed, only the grid scrolls
Reworks the channel detail per UAT: instead of the banner scrolling away and a sticky sub-header (which felt odd to scroll and needed an opaque bar that clashed with the design even at rest), the ENTIRE chrome — banner, identity, tabs, toolbar — is fixed and only the video grid scrolls. Mechanism: ChannelPage wraps its whole chrome in <PageToolbar> so it portals into the shell's fixed band (like the feed toolbar); the channelScoped Feed portals its toolbar into a slot at the bottom of that chrome. No sticky, no opaque bar — the chrome is transparent over the page bg because nothing scrolls under it (the band sits above the scroller). E2E updated: the channel chrome (title + toolbar) stays at a fixed viewport position while the grid scrolls. Suite 15/15, tsc clean.
This commit is contained in:
@@ -56,15 +56,17 @@ test.describe("channel view", () => {
|
||||
.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 }) => {
|
||||
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
|
||||
// put while only the video grid scrolls.
|
||||
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
|
||||
await openApp(page);
|
||||
await page.getByTestId("video-card-channel").first().click();
|
||||
const count = page.getByTestId("feed-result-count");
|
||||
const title = page.getByTestId("channel-title");
|
||||
await expect(count).toBeVisible();
|
||||
await expect(page.getByTestId("video-card").first()).toBeVisible();
|
||||
await expect(page.getByTestId("channel-title")).toBeInViewport();
|
||||
const titleTop = await title.evaluate((el) => el.getBoundingClientRect().top);
|
||||
const countTop = await count.evaluate((el) => el.getBoundingClientRect().top);
|
||||
|
||||
// Keep nudging until the grid is tall enough to actually scroll (it may still be filling in).
|
||||
await expect
|
||||
@@ -74,9 +76,8 @@ test.describe("channel view", () => {
|
||||
})
|
||||
.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);
|
||||
// The chrome didn't move — banner title and toolbar are in exactly the same place.
|
||||
expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop);
|
||||
expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +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 { PageToolbar, PageToolbarSlot } from "./PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
@@ -77,9 +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.
|
||||
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
|
||||
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
|
||||
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
|
||||
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
|
||||
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
|
||||
@@ -210,10 +211,12 @@ export default function ChannelPage({
|
||||
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
|
||||
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
||||
|
||||
// The channel page owns no scroller of its own: it's a PageShell config (no shared header, no
|
||||
// rail, its banner scrolls from the top) so there's ONE page scroller in the app, not two.
|
||||
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
||||
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
|
||||
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
|
||||
return (
|
||||
<>
|
||||
<PageToolbar>
|
||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||
<div className="relative px-4 sm:px-6 pt-3">
|
||||
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||
@@ -299,16 +302,11 @@ 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="px-4 sm:px-6 pt-2 flex items-center gap-4">
|
||||
<div className="flex items-center gap-4 mt-3 border-b border-border">
|
||||
<button onClick={() => setTab("videos")} className={tabClass(tab === "videos")}>
|
||||
{t("channel.tabVideos")}
|
||||
</button>
|
||||
@@ -375,10 +373,14 @@ export default function ChannelPage({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={setChromeSlot} />
|
||||
</div>
|
||||
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
|
||||
just under the tabs. */}
|
||||
<div ref={setChromeSlot} />
|
||||
</PageToolbar>
|
||||
|
||||
{/* Tab content — the feed's toolbar portals up into the sticky sub-header via this override. */}
|
||||
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
|
||||
only the grid (and About) render here, in the scroller. */}
|
||||
<PageToolbarSlot.Provider value={chromeSlot}>
|
||||
{tab === "videos" ? (
|
||||
<>
|
||||
|
||||
@@ -655,7 +655,7 @@ export default function Feed({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4 pt-3" : "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
|
||||
|
||||
Reference in New Issue
Block a user