The second half of the pending-button sweep: where the pressed control does not merely disable but LEAVES the page — an invite row gone after Approve, the inbox's Clear all gone with the last notification, a section gone once its last row is handled — the browser dropped the focus to <body> and the next Tab started over at the top. useFocusKeeper, on the page scroller: when the element holding the focus is removed and the focus fell to <body>, the first focusable thing in its slot takes it (what followed, else what preceded, else the nearest ancestor still on the page). Portaled layers (menus, dialogs) sit outside the scroller and return focus themselves; listNav's keyboard lists are left alone — their `j` already goes on from the slot, and a row scrolled out of a virtualized list must not drag the focus after it.
168 lines
8.0 KiB
TypeScript
168 lines
8.0 KiB
TypeScript
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
import { useScrollFade } from "../../lib/useScrollFade";
|
|
import { useFocusKeeper } from "../../lib/useFocusKeeper";
|
|
import { StickyTopProvider } from "./stickyTop";
|
|
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
|
|
|
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
|
|
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
|
|
// at the top; the scrollKey ties it to the logical content, so a new feed filter still starts at the
|
|
// top WITHIN the same entry. 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 it returns.
|
|
const savedScroll = new Map<string, number>();
|
|
// The key embeds an ever-increasing history-entry id, so without a bound this map would grow one
|
|
// entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves
|
|
// insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap.
|
|
const SCROLL_MEMORY_CAP = 200;
|
|
function rememberScroll(key: string, top: number): void {
|
|
savedScroll.delete(key);
|
|
savedScroll.set(key, top);
|
|
if (savedScroll.size > SCROLL_MEMORY_CAP) {
|
|
const oldest = savedScroll.keys().next().value;
|
|
if (oldest !== undefined) savedScroll.delete(oldest);
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
}: {
|
|
children: ReactNode;
|
|
scrollKey?: string;
|
|
}) {
|
|
// How many sticky elements sit at the scroller's top right now (see ./stickyTop): the top fade is
|
|
// off while any is mounted — a table layout — and back on the moment none is (a card layout).
|
|
const [stickyTops, setStickyTops] = useState(0);
|
|
const registerStickyTop = useCallback(() => {
|
|
setStickyTops((n) => n + 1);
|
|
return () => setStickyTops((n) => n - 1);
|
|
}, []);
|
|
const fade = useScrollFade(20, stickyTops === 0);
|
|
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],
|
|
);
|
|
|
|
// A focused control removed by its own action (a row gone after Approve, a toolbar after Clear
|
|
// all) leaves the focus in its slot rather than on <body> — see useFocusKeeper.
|
|
useFocusKeeper(elRef);
|
|
|
|
// Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
|
|
const scrollKeyRef = useRef(scrollKey);
|
|
scrollKeyRef.current = scrollKey;
|
|
const lastComposite = useRef<string | null>(null);
|
|
const cancelRef = useRef<() => void>(() => {});
|
|
|
|
// Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
|
|
// navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
|
|
// unchanged, so it's safe to call from several triggers.
|
|
const resolve = useCallback(() => {
|
|
const el = elRef.current;
|
|
if (!el || scrollKeyRef.current == null) return;
|
|
const key = `${ensureNavId()}:${scrollKeyRef.current}`;
|
|
if (key === lastComposite.current) return;
|
|
lastComposite.current = key;
|
|
cancelRef.current();
|
|
cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
|
|
}, []);
|
|
|
|
// Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
|
|
useLayoutEffect(() => {
|
|
resolve();
|
|
}, [scrollKey, resolve]);
|
|
|
|
// Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
|
|
// re-renders this component. Also records the position continuously (not at unmount): the scroller's
|
|
// scrollTop is unreliable by the time an unmount cleanup runs, so a save-on-scroll survives teardown.
|
|
useLayoutEffect(() => {
|
|
const el = elRef.current;
|
|
if (!el) return;
|
|
const onScroll = () => {
|
|
if (scrollKeyRef.current == null) return;
|
|
rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
|
|
};
|
|
el.addEventListener("scroll", onScroll, { passive: true });
|
|
window.addEventListener("popstate", resolve);
|
|
const unsubNav = subscribeNav(resolve);
|
|
return () => {
|
|
cancelRef.current();
|
|
el.removeEventListener("scroll", onScroll);
|
|
window.removeEventListener("popstate", resolve);
|
|
unsubNav();
|
|
};
|
|
}, [resolve]);
|
|
|
|
return (
|
|
<main
|
|
ref={setRef}
|
|
style={fade.style}
|
|
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
|
|
>
|
|
<StickyTopProvider value={registerStickyTop}>{children}</StickyTopProvider>
|
|
</main>
|
|
);
|
|
}
|