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.
64 lines
3.5 KiB
TypeScript
64 lines
3.5 KiB
TypeScript
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<HTMLElement | null>(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 } };
|
|
}
|