The last of the deep-review deferred reuse items: - extract the scroll-affordance machinery (watch the scroller AND its swappable/ lazily-growing children, diff two edge booleans) that useScrollFade (vertical) and Carousel's useHScroll (horizontal) each hand-duplicated into one shared lib/useScrollEdges(axis); both hooks now just map start/end to their own shape. - migrate PlexInfo's two remaining hand-rolled `flex overflow-x-auto` strips — the cast row and the collection strips — to the S1 <Carousel> primitive, so they get the edge fade + hover/focus scroll buttons (only on the clipped side) for free, matching the Discover rails / CastStrip / RelatedStrip. Pure refactor, no behaviour change. Gate green (tsc, vitest 351, eslint, prettier, knip). Browser-verified on a movie info page: cast row + collection strip render as Carousels with the edge fade and the right-scroll button on the clipped cast row.
63 lines
3.1 KiB
TypeScript
63 lines
3.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
|
|
// The shared scroll-affordance core behind `useScrollFade` (vertical edge fade) and the Carousel's
|
|
// `useHScroll` (horizontal scroll buttons). Both need the exact same machinery — track whether a
|
|
// scroller is clipped at the START and END of ONE axis, watching the scroller AND its children so the
|
|
// edges re-evaluate even when no `scroll` event fires (a rail whose cards load in later grows
|
|
// scrollWidth/scrollHeight silently; a Suspense fallback gets SWAPPED for the real content). This is
|
|
// that machinery, parameterised by axis; the two hooks just map `start`/`end` to their own shape.
|
|
//
|
|
// Uses a node-state callback 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.
|
|
// `start`/`end` = there is more content before / after the visible window ("y": top/bottom, "x":
|
|
// left/right). The boolean-diff bail-out matters: `scroll` fires per frame and the page scroller's hook
|
|
// sits above the whole tree, so returning a new object each frame would re-render everything.
|
|
export function useScrollEdges(axis: "x" | "y"): {
|
|
ref: (node: HTMLElement | null) => void;
|
|
el: HTMLElement | null;
|
|
start: boolean;
|
|
end: boolean;
|
|
} {
|
|
const [el, setEl] = useState<HTMLElement | null>(null);
|
|
const [edge, setEdge] = useState({ start: false, end: false });
|
|
|
|
useEffect(() => {
|
|
if (!el) return;
|
|
const update = () => {
|
|
const pos = axis === "x" ? el.scrollLeft : el.scrollTop;
|
|
const client = axis === "x" ? el.clientWidth : el.clientHeight;
|
|
const scroll = axis === "x" ? el.scrollWidth : el.scrollHeight;
|
|
const start = pos > 1;
|
|
const end = pos + client < scroll - 1;
|
|
setEdge((prev) => (prev.start === start && prev.end === end ? prev : { start, end }));
|
|
};
|
|
update();
|
|
el.addEventListener("scroll", update, { passive: true });
|
|
// Watch the scroller AND its content: content that grows/shrinks/decrypts/filters changes neither
|
|
// the scroller's own box nor its scroll offset, so without the children nothing re-evaluates.
|
|
const ro = new ResizeObserver(update);
|
|
ro.observe(el);
|
|
for (const child of Array.from(el.children)) ro.observe(child);
|
|
// Children get SWAPPED (a lazy page replacing its Suspense fallback), and a ResizeObserver holding a
|
|
// detached node simply never fires again — so follow the child list, observing/unobserving exactly
|
|
// the nodes named in each record (O(1) per change, not a re-observe of a long list on every append).
|
|
const mo = new MutationObserver((records) => {
|
|
for (const rec of records) {
|
|
for (const node of Array.from(rec.addedNodes))
|
|
if (node instanceof Element) ro.observe(node);
|
|
for (const node of Array.from(rec.removedNodes))
|
|
if (node instanceof Element) ro.unobserve(node);
|
|
}
|
|
update();
|
|
});
|
|
mo.observe(el, { childList: true });
|
|
return () => {
|
|
el.removeEventListener("scroll", update);
|
|
ro.disconnect();
|
|
mo.disconnect();
|
|
};
|
|
}, [el, axis]);
|
|
|
|
return { ref: setEl, el, start: edge.start, end: edge.end };
|
|
}
|