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 && (
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
//
// E28 review F2: a column filter is shown only by its column's header funnel, so a filter set on a
// column that is off screen — a `tabletHidden` column on a 1024 window, any column in the card layout
// — narrowed the rows with nothing on screen saying so. The cue names it, and its chip removes it.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import DataTable, { type Column } from "./DataTable";
import { columnHideClass, type FilterMap } from "../../lib/tableColumns";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, o?: { name?: string; count?: number }) =>
o?.name ? `${key}(${o.name})` : o?.count !== undefined ? `${key}#${o.count}` : key,
}),
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
type Row = { id: string; prio: number; tag: string };
const rows: Row[] = [
{ id: "a", prio: 10, tag: "x" },
{ id: "b", prio: 3, tag: "y" },
];
const columns: Column<Row>[] = [
{ key: "name", header: "Name", cardPrimary: true, render: (r) => r.id },
{
key: "prio",
header: "Prio",
tabletHidden: true,
hideInCard: true,
render: (r) => r.prio,
filter: {
kind: "select",
options: [
{ value: "10", label: "Ten" },
{ value: "3", label: "Three" },
],
test: (r, v) => String(r.prio) === v,
},
},
{
key: "tag",
header: "Tag",
render: (r) => r.tag,
filter: {
kind: "multi",
options: [{ value: "x", label: "Ex" }],
test: (r, vs) => vs.includes(r.tag),
},
},
];
function viewport(wide: boolean) {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(min-width: 48rem)" ? wide : false,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
}
function table(filters: FilterMap, surfaced?: string[]) {
const onFiltersChange = vi.fn();
const r = render(
<DataTable
rows={rows}
columns={columns}
rowKey={(r) => r.id}
filters={filters}
onFiltersChange={onFiltersChange}
surfacedFilters={surfaced}
/>,
);
return { ...r, onFiltersChange };
}
const cue = () => screen.queryByTestId("hidden-filter-cue");
describe("the hidden-filter cue", () => {
it("names a filter in the card layout, in its funnel's words, and its chip clears it", () => {
viewport(false);
const { onFiltersChange } = table({ prio: "10" });
expect(cue()).not.toBeNull();
const chip = screen.getByRole("button", { name: "datatable.hiddenFilter.remove(Prio: Ten)" });
fireEvent.click(chip);
expect(onFiltersChange).toHaveBeenCalledWith({ prio: "" });
});
it("clears a multi filter to an empty list, and leaves out one the caller shows itself", () => {
viewport(false);
const { onFiltersChange } = table({ prio: "10", tag: ["x"] }, ["tag"]);
expect(screen.queryByText(/Tag: Ex/)).toBeNull();
cleanup();
const second = table({ tag: ["x"] });
fireEvent.click(screen.getByRole("button", { name: "datatable.hiddenFilter.remove(Tag: Ex)" }));
expect(second.onFiltersChange).toHaveBeenCalledWith({ tag: [] });
expect(onFiltersChange).not.toHaveBeenCalled();
});
it("in a table, names only a column that can hide, shown by that column's own container query", () => {
viewport(true);
table({ prio: "10", tag: ["x"] });
const chip = screen.getByRole("button", { name: "datatable.hiddenFilter.remove(Prio: Ten)" });
// Visible exactly while the column is not: the same `@5xl` its header cell uses.
expect(chip.className).toContain("@5xl:hidden");
expect(cue()!.className).toContain("@5xl:hidden");
// The Tag column never hides, its funnel is on screen: not named.
expect(screen.queryByText(/Tag: Ex/)).toBeNull();
// …and the cue sits inside the table's @container, where its query can read the table's width.
expect(cue()!.closest(".\\@container")).not.toBeNull();
});
it("says nothing when no filter is set", () => {
viewport(false);
table({});
expect(cue()).toBeNull();
});
});
describe("the cue's class", () => {
it("is the exact inverse of the column's hide class, at every breakpoint", () => {
for (const col of [{ narrowHidden: true }, { tabletHidden: true }]) {
const size = columnHideClass(col).match(/@(\w+):table-cell/)![1];
expect(columnHideClass(col, "cue").match(/^ @(\w+):hidden$/)?.[1]).toBe(size);
}
expect(columnHideClass({}, "cue")).toBe("");
});
});
@@ -0,0 +1,77 @@
import type { MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import { focusNeighbour } from "../../lib/useFocusKeeper";
import {
columnHideClass,
filterValueLabel,
isActive,
type Column,
type FilterMap,
} from "../../lib/tableColumns";
import { Chip } from "./Chip";
// E28 F2: a column filter lives in its column's header funnel, so when the column is off screen — a
// `tabletHidden` / `narrowHidden` column below its width, or any column in a card layout, which has no
// header at all — the rows are narrowed by something nobody can see ("12 of 392", and no why). This
// names each such filter above the rows, and its chip removes it (owner's pick A, 2026-09-27).
//
// `by="width"` (a table): only the columns that CAN hide are candidates, and each chip is shown by the
// inverse of its column's own container query (`columnHideClass(col, "cue")`), so the cue and the
// table agree at every width and text size without measuring anything. It must therefore sit inside
// the table's `@container`. `by="cards"`: nothing has a header; every filter not `surfaced` elsewhere
// (the channel manager's tag chips) is named.
export function HiddenFilterCue<T>({
columns,
filters,
onFilter,
by,
surfaced = [],
}: {
columns: Column<T>[];
filters: FilterMap;
/** The header funnel's own setter: a chip sets its filter to empty. */
onFilter: (key: string, value: string | string[]) => void;
by: "width" | "cards";
surfaced?: string[];
}) {
const { t } = useTranslation();
const named = columns.filter(
(col) =>
col.filter &&
isActive(filters[col.key]) &&
!surfaced.includes(col.key) &&
(by === "cards" || !!columnHideClass(col)),
);
if (named.length === 0) return null;
const cue = (col: Column<T>) => (by === "width" ? columnHideClass(col, "cue") : "");
// The row shows while ANY of its chips does: hidden only at the widest width that brings every
// named column back. Tablet-hidden columns come back last, so one of those sets the row's class.
const rowCue = cue(named.find((col) => col.tabletHidden) ?? named[0]!);
return (
<div
data-testid="hidden-filter-cue"
className={clsx("mb-2 flex flex-wrap items-center gap-2 text-xs text-muted", rowCue)}
>
<span>{t("datatable.hiddenFilter.label", { count: named.length })}</span>
{named.map((col) => {
const text = `${col.header}: ${filterValueLabel(col, filters[col.key]!)}`;
return (
<Chip
key={col.key}
variant="token"
className={cue(col)}
removeLabel={t("datatable.hiddenFilter.remove", { name: text })}
onClick={(e: MouseEvent<HTMLButtonElement>) => {
// The chip goes with its filter: hand the focus on first, not to <body>.
focusNeighbour(e.currentTarget);
onFilter(col.key, Array.isArray(filters[col.key]) ? [] : "");
}}
>
<span className="max-w-56 truncate">{text}</span>
</Chip>
);
})}
</div>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ domain-free.
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy") |
| data display | `DataTable`, `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy") |
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
@@ -0,0 +1,17 @@
import { readdirSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// E28 review F2: a view that applies the column filters itself (`filterRows`) — DataTable, the
// playlist manager's own table, the channel manager's cards — can hide the column a filter lives in,
// or have no header at all. Each renders the hidden-filter cue, so no filter narrows its rows unseen.
describe("every view that filters rows by column", () => {
it("renders the hidden-filter cue", () => {
const root = new URL("../", import.meta.url);
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
.map((f) => [f, readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")] as const)
.filter(([, src]) => /filterRows\(/.test(src));
expect(sites.length).toBeGreaterThanOrEqual(3);
for (const [f, src] of sites) expect(src, f).toMatch(/<HiddenFilterCue\b|\{cue\("/);
});
});