From af915883f9b2234ddc1f34265764e801316a586a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 05:43:19 +0200 Subject: [PATCH] fix(datatable): the sticky header no longer shifts or fades on first scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sticky 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. --- frontend/src/App.tsx | 3 +++ frontend/src/components/Channels.tsx | 5 ++++- frontend/src/components/PageScroller.tsx | 5 ++++- frontend/src/components/PageShell.tsx | 7 ++++++- frontend/src/lib/useScrollFade.ts | 7 +++++-- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f470160..3767d49 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -734,6 +734,9 @@ export default function App() { ) : ( ) : ( -
+ /* 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). */ +
{/* 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 ? ( diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/PageScroller.tsx index 25d34ed..7c64ebc 100644 --- a/frontend/src/components/PageScroller.tsx +++ b/frontend/src/components/PageScroller.tsx @@ -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(null); // Stable identity: an inline ref callback changes every render, which makes React detach and diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index 3833b27..beb3197 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -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(null); return ( @@ -55,7 +58,9 @@ export default function PageShell({
{banners}
- {children} + + {children} +
); diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index 642e787..f147483 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -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 } };