The sticky <thead> jumped up ~8px and picked up the scroller's top fade the moment you scrolled 1px — so it read as 'not fixed'. Two causes: - the table sat under a pt-2 gap, so its natural position was 8px below the sticky top:0 it snapped to. Drop the top padding (the fixed band's controls row already gives the visual gap) → no shift. - the page scroller's top edge fade turned on with the first scroll and masked the header's top. Add a fadeTop flag (PageShell → PageScroller → useScrollFade), off for the Channels + Audit pages whose scroll region starts with a sticky header. Feed/channel keep the top fade (it fades cards under the fixed toolbar, as intended). Verified: header stays at a fixed offset with no fade change from scroll 0 through scrolled, on both the manager and the audit log. Suite 15/15.
116 lines
5.2 KiB
TypeScript
116 lines
5.2 KiB
TypeScript
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
|
|
// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
|
|
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
|
|
// the same view returns. Keyed by the shell's `scrollKey`.
|
|
const savedScroll = new Map<string, number>();
|
|
|
|
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
|
|
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
|
|
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
|
|
function restoreScroll(el: HTMLElement, top: number): () => void {
|
|
if (top <= 0) {
|
|
el.scrollTop = 0;
|
|
return () => {};
|
|
}
|
|
let raf = 0;
|
|
let frames = 0;
|
|
let cancelled = false;
|
|
const tick = () => {
|
|
if (cancelled) return;
|
|
if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top;
|
|
if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
raf = requestAnimationFrame(tick);
|
|
return () => {
|
|
cancelled = true;
|
|
cancelAnimationFrame(raf);
|
|
};
|
|
}
|
|
|
|
// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the
|
|
// channel page and Plex browse all scroll inside it rather than owning a scroller of their own,
|
|
// which is why the edge-fade and the scroll restore/reset policy live 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.
|
|
//
|
|
// Scroll restore/reset policy (`scrollKey` identifies the logical view):
|
|
// - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged —
|
|
// restores where you were (bug 1). The channel page is a different scroller, so the feed's
|
|
// scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied.
|
|
// - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3).
|
|
// The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup
|
|
// runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read
|
|
// saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic
|
|
// unmount, which would otherwise persist a 0 too).
|
|
//
|
|
// 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,
|
|
scrollKey,
|
|
fadeTop = true,
|
|
}: {
|
|
children: ReactNode;
|
|
scrollKey?: string;
|
|
// Off when the scroller's top is a sticky element (a table's sticky header) — see useScrollFade.
|
|
fadeTop?: boolean;
|
|
}) {
|
|
const fade = useScrollFade(20, fadeTop);
|
|
const fadeRef = fade.ref;
|
|
const elRef = useRef<HTMLElement | null>(null);
|
|
// Stable identity: an inline ref callback changes every render, which makes React detach and
|
|
// re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
|
|
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
|
|
// once here is safe.
|
|
const setRef = useCallback(
|
|
(node: HTMLElement | null) => {
|
|
elRef.current = node;
|
|
fadeRef(node);
|
|
},
|
|
[fadeRef],
|
|
);
|
|
|
|
useLayoutEffect(() => {
|
|
const el = elRef.current;
|
|
if (!el || scrollKey == null) return;
|
|
const key = scrollKey;
|
|
const want = savedScroll.get(key) ?? 0;
|
|
const cancelRestore = restoreScroll(el, want);
|
|
// Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
|
|
// time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
|
|
const onScroll = () => {
|
|
savedScroll.set(key, el.scrollTop);
|
|
};
|
|
el.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => {
|
|
cancelRestore();
|
|
el.removeEventListener("scroll", onScroll);
|
|
};
|
|
}, [scrollKey]);
|
|
|
|
return (
|
|
<main
|
|
ref={setRef}
|
|
style={fade.style}
|
|
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
|
|
>
|
|
{children}
|
|
</main>
|
|
);
|
|
}
|