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:
@@ -31,6 +31,7 @@ import {
|
|||||||
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
|
||||||
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
|
||||||
import ActiveFilterChips from "./ActiveFilterChips";
|
import ActiveFilterChips from "./ActiveFilterChips";
|
||||||
|
import { PageToolbar } from "./PageShell";
|
||||||
import ViewSwitcher from "./ViewSwitcher";
|
import ViewSwitcher from "./ViewSwitcher";
|
||||||
import VirtualFeed from "./VirtualFeed";
|
import VirtualFeed from "./VirtualFeed";
|
||||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||||
@@ -654,17 +655,26 @@ export default function Feed({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className={channelScoped ? "p-4" : "px-4 pb-4 pt-2"}>
|
||||||
{toolbar}
|
{/* On the feed page the toolbar + active-filter chips are FIXED chrome: they pin in the
|
||||||
{/* Channel pages get no chips: they have no filter panel to reveal (App hides the sidebar for
|
shell's toolbar band (above the scroller) so they stay put — and reachable — while the
|
||||||
them), and their Feed starts from its OWN baseline — channel-scoped, whole-catalog,
|
cards scroll (bug 4). On a channel page the Feed is embedded below the channel's own
|
||||||
show-all — so every one of those would render as a "filter you applied" when it's just the
|
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. */}
|
page being itself. */}
|
||||||
{!channelScoped && (
|
{channelScoped ? (
|
||||||
<ActiveFilterChips
|
toolbar
|
||||||
filters={activeFilters}
|
) : (
|
||||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
<PageToolbar>
|
||||||
/>
|
<div className="px-4 pt-4 pb-1">
|
||||||
|
{toolbar}
|
||||||
|
<ActiveFilterChips
|
||||||
|
filters={activeFilters}
|
||||||
|
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PageToolbar>
|
||||||
)}
|
)}
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="py-16 text-center text-muted">
|
<div className="py-16 text-center text-muted">
|
||||||
|
|||||||
@@ -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";
|
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
|
// 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
|
// 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
|
// (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
|
// 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.
|
// 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.
|
// - `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
|
// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
|
||||||
// with the content.
|
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
|
||||||
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
|
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
|
||||||
// invariants (`scrollbar-gutter: stable`, edge fade); see that file.
|
// invariants (`scrollbar-gutter: stable`, edge fade); see that file.
|
||||||
//
|
//
|
||||||
@@ -21,22 +37,21 @@ import PageScroller from "./PageScroller";
|
|||||||
export default function PageShell({
|
export default function PageShell({
|
||||||
header,
|
header,
|
||||||
banners,
|
banners,
|
||||||
toolbar,
|
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
banners?: ReactNode;
|
banners?: ReactNode;
|
||||||
toolbar?: ReactNode;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
|
||||||
return (
|
return (
|
||||||
<>
|
<ToolbarSlot.Provider value={toolbarSlot}>
|
||||||
{header}
|
{header}
|
||||||
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
|
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
|
||||||
{banners}
|
{banners}
|
||||||
{toolbar}
|
<div ref={setToolbarSlot} className="shrink-0" />
|
||||||
<PageScroller>{children}</PageScroller>
|
<PageScroller>{children}</PageScroller>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</ToolbarSlot.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user