import { useEffect, useState, type CSSProperties } from "react"; // 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 // 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 // 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. // `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a // table's sticky header): fading there would eat into the header instead of hinting at content, and // the header would visibly change as the fade turns on with the first scroll. export function useScrollFade( fadePx = 20, fadeTop = true, ): { ref: (node: HTMLElement | null) => void; style: CSSProperties; } { const [el, setEl] = useState(null); const [fade, setFade] = useState({ up: false, down: false }); useEffect(() => { if (!el) return; // Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's // hook sits above the whole page tree, so a new object here would re-render on every frame // of every scroll. The two booleans only flip at the ends of the range. 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(); 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); ro.observe(el); for (const child of Array.from(el.children)) ro.observe(child); // 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.) So follow the child list: the records name exactly // which nodes arrived and left, which keeps this O(1) per change rather than re-observing // every child of a long list on each 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]); const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up && fadeTop ? fadePx : 0}px, #000 calc(100% - ${ fade.down ? fadePx : 0 }px), transparent)`; return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; }