feat(tables): name a filter whose column is off screen, and let its chip clear it

A tabletHidden/narrowHidden column below its width, or any column in a card
layout, took its header funnel with it: the rows were narrowed by a filter
nobody could see. The cue is shown by the inverse of the column's own
container query, so it agrees with the table at every width and text size.
Wired into DataTable, the playlist table and the channel cards (E28 F2).
This commit is contained in:
2026-09-27 16:22:26 +02:00
parent 27483ae1d6
commit 51f881681b
10 changed files with 342 additions and 40 deletions
+43 -25
View File
@@ -10,6 +10,7 @@ import { columnHideClass, filterRows, type Column, type FilterMap } from "../../
import { navItemAttrs, useListNav } from "../../lib/listNav";
import { useMediaQuery } from "../../lib/useMediaQuery";
import { SURFACE } from "./surfaces";
import { HiddenFilterCue } from "./HiddenFilterCue";
// 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
@@ -70,6 +71,7 @@ export default function DataTable<T>({
fixedLayout = false,
keyboardNav = false,
reveal,
surfacedFilters,
}: {
rows: T[];
columns: Column<T>[];
@@ -126,6 +128,9 @@ export default function DataTable<T>({
// `seq` turns to the page that now holds the row `key`. Focus is the caller's (it knows which of
// the row's controls it came from); the page is the table's, so it is decided here.
reveal?: { key: string; seq: number } | null;
// Filter keys the caller shows somewhere besides their column (the channel manager's tag chips):
// the hidden-filter cue leaves those out, it would name them twice.
surfacedFilters?: string[];
}) {
const { t } = useTranslation();
const initial = loadPersist(persistKey);
@@ -234,6 +239,15 @@ export default function DataTable<T>({
commitFilters({ ...filters, [key]: value });
setPage(0);
}
const cue = (by: "width" | "cards") => (
<HiddenFilterCue
columns={columns}
filters={filters}
by={by}
surfaced={surfacedFilters}
onFilter={applyFilter}
/>
);
// The pager + rows-per-page cluster on its own, so it can be rendered a second time at the bottom
// (controlsPosition "both") WITHOUT repeating the leading controls (e.g. the status chips) — those
@@ -288,6 +302,7 @@ export default function DataTable<T>({
{wide ? (
// A container, so a column hides by the table's own width (`tabletHidden`), not the window's.
<div className="@container">
{cue("width")}
<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
@@ -337,36 +352,39 @@ export default function DataTable<T>({
) : (
/* 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={`${SURFACE.card} p-3 ${rowClassName?.(row) ?? ""}`}
>
{columns
.filter((c) => c.cardPrimary)
.map((col) => (
<div key={col.key} className="font-medium">
{col.render(row)}
</div>
))}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2 text-sm">
<>
{cue("cards")}
<div ref={cardsRef} className="flex flex-col gap-2">
{paged.map((row, i) => (
<div
key={rowKey(row)}
{...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`${SURFACE.card} 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 && (