improvement(scroll): fade the page scroller's clipped edges
The subdued edge-fade the rail and side panels use now hints at more content on the page scroller too, which the feed, the channel page and Plex all scroll inside. Both <main>s move into one PageScroller so the invariants they share — the load-bearing scrollbar gutter, and the bar staying visible because hiding it would zero that gutter — are stated once. Two fixes the hook needed first: it set fresh state on every scroll event, which above a page tree would re-render it per frame; and it watched a single child captured at mount, so a lazy page swapping out its Suspense fallback left the observer on a detached node and the fade frozen at its pre-load value (measured on the Plex page: no bottom fade over a 3119px list).
This commit is contained in:
@@ -54,6 +54,7 @@ import NavSidebar from "./components/NavSidebar";
|
|||||||
import Sidebar from "./components/Sidebar";
|
import Sidebar from "./components/Sidebar";
|
||||||
import PlaylistsRail from "./components/PlaylistsRail";
|
import PlaylistsRail from "./components/PlaylistsRail";
|
||||||
import BackToTop from "./components/BackToTop";
|
import BackToTop from "./components/BackToTop";
|
||||||
|
import PageScroller from "./components/PageScroller";
|
||||||
import GlassTuner from "./components/GlassTuner";
|
import GlassTuner from "./components/GlassTuner";
|
||||||
import ChatDock from "./components/ChatDock";
|
import ChatDock from "./components/ChatDock";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
@@ -898,13 +899,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
{meQuery.data!.is_demo && <DemoBanner />}
|
{meQuery.data!.is_demo && <DemoBanner />}
|
||||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||||
{/* scrollbar-gutter: stable is load-bearing, not cosmetic. The feed derives its column
|
<PageScroller>
|
||||||
count from this element's clientWidth and its thumbnails are aspect-ratio boxes, so
|
|
||||||
when the content lands within a scrollbar's width of the viewport height the two feed
|
|
||||||
each other: scrollbar appears -> 15px narrower -> shorter thumbs -> content fits ->
|
|
||||||
scrollbar goes -> wider -> taller -> repeat, every frame. Reserving the gutter keeps
|
|
||||||
clientWidth constant and breaks the loop. */}
|
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]">
|
|
||||||
<Suspense fallback={pageFallback}>
|
<Suspense fallback={pageFallback}>
|
||||||
{page === "channels" ? (
|
{page === "channels" ? (
|
||||||
<Channels
|
<Channels
|
||||||
@@ -986,7 +981,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</PageScroller>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
|
||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
|
import PageScroller from "./PageScroller";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
@@ -208,10 +209,7 @@ export default function ChannelPage({
|
|||||||
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the
|
<PageScroller>
|
||||||
// tall Videos tab (scrollbar) and the short About tab (no scrollbar) no longer shifts the
|
|
||||||
// header/logo/buttons horizontally as the scrollbar appears/disappears.
|
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]">
|
|
||||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||||
<div className="relative px-4 sm:px-6 pt-3">
|
<div className="relative px-4 sm:px-6 pt-3">
|
||||||
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||||
@@ -470,6 +468,6 @@ export default function ChannelPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</PageScroller>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
|
|
||||||
|
// The page's scroll container. Two of these exist and are never both mounted: App renders one for
|
||||||
|
// the normal pages, ChannelPage renders its own (it's a separate branch of App's tree, not a page
|
||||||
|
// inside the usual one). The feed, the channel page and Plex browse all scroll inside it rather
|
||||||
|
// than owning a scroller — which is why the edge-fade lives here once instead of three times.
|
||||||
|
//
|
||||||
|
// Two invariants this element carries, both learned the hard way:
|
||||||
|
//
|
||||||
|
// - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count
|
||||||
|
// from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content
|
||||||
|
// lands within a scrollbar's width of the viewport height the two feed each other: scrollbar
|
||||||
|
// appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider ->
|
||||||
|
// taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the
|
||||||
|
// loop. On the channel page it also stops the header/logo/buttons shifting sideways when you
|
||||||
|
// switch from the tall Videos tab to the short About tab.
|
||||||
|
// - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero
|
||||||
|
// the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
|
||||||
|
// fade next to its scrollbar, not instead of it.
|
||||||
|
//
|
||||||
|
// The fade state lives in here rather than in App so that a flip re-renders this component alone —
|
||||||
|
// `children` arrives as an already-built element, so React skips the page subtree underneath.
|
||||||
|
export default function PageScroller({ children }: { children: ReactNode }) {
|
||||||
|
const fade = useScrollFade();
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
ref={fade.ref}
|
||||||
|
style={fade.style}
|
||||||
|
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,34 +1,54 @@
|
|||||||
import { useEffect, useState, type CSSProperties } from "react";
|
import { useEffect, useState, type CSSProperties } from "react";
|
||||||
|
|
||||||
// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`):
|
// Scroll-affordance for a scroll container (typically one whose native scrollbar is hidden with
|
||||||
|
// `.no-scrollbar`, but the page scroller keeps its bar and takes the fade anyway):
|
||||||
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
|
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
|
||||||
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
|
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
|
||||||
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
|
// 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
|
// 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.
|
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
|
||||||
export function useScrollFade(fadePx = 20): {
|
export function useScrollFade(fadePx = 20): {
|
||||||
ref: (node: HTMLDivElement | null) => void;
|
ref: (node: HTMLElement | null) => void;
|
||||||
style: CSSProperties;
|
style: CSSProperties;
|
||||||
} {
|
} {
|
||||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||||
const [fade, setFade] = useState({ up: false, down: false });
|
const [fade, setFade] = useState({ up: false, down: false });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const update = () =>
|
// Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's
|
||||||
setFade({
|
// hook sits above the whole page tree, so a new object here would re-render on every frame
|
||||||
up: el.scrollTop > 1,
|
// of every scroll. The two booleans only flip at the ends of the range.
|
||||||
down: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
|
const update = () => {
|
||||||
});
|
const up = el.scrollTop > 1;
|
||||||
|
const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
|
||||||
|
setFade((prev) => (prev.up === up && prev.down === down ? prev : { up, down }));
|
||||||
|
};
|
||||||
update();
|
update();
|
||||||
el.addEventListener("scroll", update, { passive: true });
|
el.addEventListener("scroll", update, { passive: true });
|
||||||
|
// Watch the scroller AND its content. Content that grows or shrinks — a lazy page loading in,
|
||||||
|
// a thread's messages decrypting, a list being filtered — changes neither the scroller's own
|
||||||
|
// box nor its scrollTop, so without the children nothing would re-evaluate the edges.
|
||||||
const ro = new ResizeObserver(update);
|
const ro = new ResizeObserver(update);
|
||||||
ro.observe(el);
|
const observeAll = () => {
|
||||||
// Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates.
|
ro.disconnect();
|
||||||
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
ro.observe(el);
|
||||||
|
for (const child of Array.from(el.children)) ro.observe(child);
|
||||||
|
};
|
||||||
|
observeAll();
|
||||||
|
// Children get SWAPPED, and a ResizeObserver holding a detached node simply never fires
|
||||||
|
// again. Measured: the Plex page opened with no bottom fade over a 3119px list, because the
|
||||||
|
// node observed at mount was the Suspense fallback the real page then replaced. (Same
|
||||||
|
// lazy-page race that broke BackToTop.) Re-observe whenever the child list changes.
|
||||||
|
const mo = new MutationObserver(() => {
|
||||||
|
observeAll();
|
||||||
|
update();
|
||||||
|
});
|
||||||
|
mo.observe(el, { childList: true });
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener("scroll", update);
|
el.removeEventListener("scroll", update);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
|
mo.disconnect();
|
||||||
};
|
};
|
||||||
}, [el]);
|
}, [el]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user