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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user