fix(datatable): the sticky header no longer shifts or fades on first scroll

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.
This commit is contained in:
2026-07-18 05:43:19 +02:00
parent 867daa0dcf
commit af915883f9
5 changed files with 22 additions and 5 deletions
+3
View File
@@ -734,6 +734,9 @@ export default function App() {
) : (
<PageShell
scrollKey={normalScrollKey}
// Channels + Audit render a table with a sticky header at the scroller top; the top
// fade would sit on it and flip on with the first scroll, so turn it off there.
fadeTop={page !== "channels" && page !== "audit"}
header={
<Header
me={meQuery.data!}
+4 -1
View File
@@ -577,7 +577,10 @@ export default function Channels({
)}
</div>
) : (
<div className="px-4 pb-4 pt-2 max-w-7xl mx-auto">
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */
<div className="px-4 pb-4 max-w-7xl mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). */}
{channelsQuery.isLoading ? (
+4 -1
View File
@@ -63,11 +63,14 @@ function restoreScroll(el: HTMLElement, top: number): () => void {
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();
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
+6 -1
View File
@@ -41,12 +41,15 @@ export default function PageShell({
banners,
children,
scrollKey,
fadeTop = true,
}: {
header?: ReactNode;
banners?: ReactNode;
children: ReactNode;
// Identifies the logical view for the scroll restore/reset policy — see PageScroller.
scrollKey?: string;
// Off when this page's scroll region starts with a sticky header — see PageScroller.
fadeTop?: boolean;
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
return (
@@ -55,7 +58,9 @@ export default function PageShell({
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
<PageScroller scrollKey={scrollKey}>{children}</PageScroller>
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
{children}
</PageScroller>
</div>
</PageToolbarSlot.Provider>
);
+5 -2
View File
@@ -7,7 +7,10 @@ import { useEffect, useState, type CSSProperties } from "react";
// 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.
export function useScrollFade(fadePx = 20): {
// `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;
} {
@@ -53,7 +56,7 @@ export function useScrollFade(fadePx = 20): {
};
}, [el]);
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${
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 } };