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
+74 -67
View File
@@ -8,6 +8,7 @@ import { ColumnHead } from "./tableHeader";
import { alignClass } from "./tableAlign";
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -134,6 +135,7 @@ export default function DataTable<T>({
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
const wide = useMediaQuery("(min-width: 48rem)");
const bodyRef = useRef<HTMLTableSectionElement>(null);
const cardsRef = useRef<HTMLDivElement>(null);
useListNav(bodyRef, { enabled: keyboardNav });
@@ -249,83 +251,88 @@ export default function DataTable<T>({
return (
<div>
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
<div className="hidden md:block">
<table className={`w-full border-collapse text-sm ${fixedLayout ? "table-fixed" : ""}`}>
{/* In fixed-layout mode a colgroup drives the column widths: those with an explicit
{/* ONE layout, chosen by width — not both mounted with CSS hiding one: that doubled every
row's DOM and render cost, duplicated ids and test hooks, and handed keyboard nav and any
DOM query two copies of each row. `md` (48rem) is the breakpoint the CSS used.
Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
{wide ? (
<div>
<table className={`w-full border-collapse text-sm ${fixedLayout ? "table-fixed" : ""}`}>
{/* In fixed-layout mode a colgroup drives the column widths: those with an explicit
`width` keep it, and the unspecified (fluid) columns split the remainder — so the
table never exceeds its container and the fluid cells truncate instead. */}
{fixedLayout && (
<colgroup>
{columns.map((col) => (
<col key={col.key} style={col.width ? { width: col.width } : undefined} />
))}
</colgroup>
)}
<ColumnHead
columns={columns}
sort={sort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<tbody ref={bodyRef}>
{paged.map((row, i) => (
<tr
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? ""
}`}
>
{fixedLayout && (
<colgroup>
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
>
{col.render(row)}
</td>
<col key={col.key} style={col.width ? { width: col.width } : undefined} />
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Narrow screens: a compact card per row — the primary column as the heading, the
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
<div ref={cardsRef} className="md:hidden flex flex-col gap-2">
{paged.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => c.cardPrimary)
.map((col) => (
<div key={col.key} className="font-medium">
{col.render(row)}
</div>
</colgroup>
)}
<ColumnHead
columns={columns}
sort={sort}
onToggleSort={toggleSort}
filters={filters}
onFilter={applyFilter}
/>
<tbody ref={bodyRef}>
{paged.map((row, i) => (
<tr
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? ""
}`}
>
{columns.map((col) => (
<td
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
>
{col.render(row)}
</td>
))}
</tr>
))}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2 text-sm">
</tbody>
</table>
</div>
) : (
/* Narrow screens: a compact card per row — the primary column as the heading, the
rest flowing in a single wrapping meta line (no full-width label/value gaps). */
<div ref={cardsRef} className="flex flex-col gap-2">
{paged.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => !c.hideInCard && !c.cardPrimary)
.filter((c) => c.cardPrimary)
.map((col) => (
<span key={col.key} className="inline-flex items-center gap-1">
{col.cardLabel !== false && (
<span className="text-muted text-xs">{col.header}</span>
)}
<div key={col.key} className="font-medium">
{col.render(row)}
</span>
</div>
))}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2 text-sm">
{columns
.filter((c) => !c.hideInCard && !c.cardPrimary)
.map((col) => (
<span key={col.key} className="inline-flex items-center gap-1">
{col.cardLabel !== false && (
<span className="text-muted text-xs">{col.header}</span>
)}
{col.render(row)}
</span>
))}
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
{paged.length === 0 && (
<div className="text-muted py-8 text-center text-sm">