fix(ui): a sticky table header turns the scroller's top fade off itself; DataTable renders one layout

- "A page with a sticky ColumnHead must declare fadeTop: false" lived in
  prose: a registry flag on three pages plus an App special case for the
  Channels card layout, and the next table page would have re-acquired the
  fade-on-the-header defect with every lane green. ColumnHead now registers
  itself with the enclosing PageScroller (ui/stickyTop) while mounted; the
  registry flag, the App branch and the prop plumbing are gone. Measured on
  localdev: the top mask is 0px under Channels' and Audit's headers, 20px on
  Home and the Feed.
- ui/DataTable mounted its table AND its card list at every width, one
  hidden by CSS: double DOM and render cost, duplicate ids, two keyboard
  copies of every row. It now mounts the one the `md` media query calls for
  (lib/useMediaQuery); the keyboard test asserts one layout at each width.
- The Plex module's content width went px → rem with the rest of the page.
This commit is contained in:
2026-09-19 07:51:33 +02:00
parent 8018cd4685
commit 6fda49369f
10 changed files with 225 additions and 110 deletions
+11 -6
View File
@@ -1,5 +1,6 @@
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useCallback, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useScrollFade } from "../../lib/useScrollFade";
import { StickyTopProvider } from "./stickyTop";
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
@@ -78,14 +79,18 @@ 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(20, fadeTop);
// How many sticky elements sit at the scroller's top right now (see ./stickyTop): the top fade is
// off while any is mounted — a table layout — and back on the moment none is (a card layout).
const [stickyTops, setStickyTops] = useState(0);
const registerStickyTop = useCallback(() => {
setStickyTops((n) => n + 1);
return () => setStickyTops((n) => n - 1);
}, []);
const fade = useScrollFade(20, stickyTops === 0);
const fadeRef = fade.ref;
const elRef = useRef<HTMLElement | null>(null);
// Stable identity: an inline ref callback changes every render, which makes React detach and
@@ -151,7 +156,7 @@ export default function PageScroller({
style={fade.style}
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable"
>
{children}
<StickyTopProvider value={registerStickyTop}>{children}</StickyTopProvider>
</main>
);
}