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:
@@ -40,6 +40,7 @@ import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows, isActive } from "../lib/tableColumns";
|
||||
import { useFilterFold } from "../lib/useFilterFold";
|
||||
import { FilterFoldButton } from "./ui/FilterFold";
|
||||
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
@@ -56,6 +57,9 @@ import { useMe } from "../lib/useMe";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
|
||||
const SURFACED_FILTERS = ["tags"];
|
||||
|
||||
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
|
||||
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
|
||||
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||
@@ -743,19 +747,31 @@ export default function Channels() {
|
||||
rowClassName={() => "group"}
|
||||
keyboardNav
|
||||
reveal={follow}
|
||||
surfacedFilters={SURFACED_FILTERS}
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedCards}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
moreKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowMenu.triggerProps}
|
||||
rowClassName={() => "group"}
|
||||
/>
|
||||
<>
|
||||
{/* Cards have no funnels: a Priority or name filter set in the table would narrow them
|
||||
unseen. The tags have their own chip row. */}
|
||||
<HiddenFilterCue
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
by="cards"
|
||||
surfaced={SURFACED_FILTERS}
|
||||
onFilter={(key, value) => setFilters({ ...filters, [key]: value })}
|
||||
/>
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedCards}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
moreKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowMenu.triggerProps}
|
||||
rowClassName={() => "group"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
|
||||
</div>
|
||||
|
||||
@@ -62,6 +62,7 @@ import { Input } from "./ui/Input";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
import { useReturnFocus } from "./ui/useReturnFocus";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
||||
import { useCopyItems } from "./copyItems";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
@@ -987,6 +988,13 @@ export default function Playlists() {
|
||||
{/* Not clipped, so the header's filter popovers can overflow the table box. A container,
|
||||
so a narrow column hides by the table's width, not the window's (`narrowHidden`). */}
|
||||
<div className="@container max-w-5xl mx-auto">
|
||||
{/* A narrow table drops Channel and its funnel with it: the cue keeps its filter named. */}
|
||||
<HiddenFilterCue
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
by="width"
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
{/* table-fixed, not auto: every column but the title has a declared width, so the
|
||||
title absorbs the rest and its `truncate` works. Auto layout sizes columns from
|
||||
min-content, and a nowrap title cell's min-content is the entire title. */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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\("/);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,11 @@
|
||||
"rowsPerPage": "Rows per page",
|
||||
"noOptions": "No options",
|
||||
"empty": "Nothing to show.",
|
||||
"hiddenFilter": {
|
||||
"label_one": "Filtered by a hidden column:",
|
||||
"label_other": "Filtered by hidden columns:",
|
||||
"remove": "Remove filter: {{name}}"
|
||||
},
|
||||
"pager": {
|
||||
"first": "First page",
|
||||
"prev": "Previous page",
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
"rowsPerPage": "Sor/oldal",
|
||||
"noOptions": "Nincs lehetőség",
|
||||
"empty": "Nincs megjeleníthető elem.",
|
||||
"hiddenFilter": {
|
||||
"label_one": "Rejtett oszlop szűr:",
|
||||
"label_other": "Rejtett oszlopok szűrnek:",
|
||||
"remove": "Szűrő eltávolítása: {{name}}"
|
||||
},
|
||||
"pager": {
|
||||
"first": "Első oldal",
|
||||
"prev": "Előző oldal",
|
||||
|
||||
@@ -51,15 +51,26 @@ export interface Column<T> {
|
||||
// The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and,
|
||||
// in a fixed layout, for its <col> too: a hidden cell under a <col> that keeps its width would leave an
|
||||
// empty stripe. The table's OWN width decides, so the caller wraps the table in an `@container`.
|
||||
// `cue` is the inverse: shown ONLY while the column is hidden — the hidden-filter cue (E28 F2) names a
|
||||
// filter whose funnel went off screen, and by the same container query, so it cannot disagree with
|
||||
// the table about which columns are gone (not at any text size: both are rem).
|
||||
// Whole literals, because Tailwind only generates a class it can read in one piece.
|
||||
const HIDE_BELOW = {
|
||||
narrow: { cell: " hidden @2xl:table-cell", col: " hidden @2xl:table-column" },
|
||||
tablet: { cell: " hidden @5xl:table-cell", col: " hidden @5xl:table-column" },
|
||||
narrow: {
|
||||
cell: " hidden @2xl:table-cell",
|
||||
col: " hidden @2xl:table-column",
|
||||
cue: " @2xl:hidden",
|
||||
},
|
||||
tablet: {
|
||||
cell: " hidden @5xl:table-cell",
|
||||
col: " hidden @5xl:table-column",
|
||||
cue: " @5xl:hidden",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function columnHideClass(
|
||||
col: { narrowHidden?: boolean; tabletHidden?: boolean },
|
||||
part: "cell" | "col" = "cell",
|
||||
part: "cell" | "col" | "cue" = "cell",
|
||||
): string {
|
||||
if (col.narrowHidden) return HIDE_BELOW.narrow[part];
|
||||
if (col.tabletHidden) return HIDE_BELOW.tablet[part];
|
||||
@@ -86,3 +97,11 @@ export function filterRows<T>(rows: T[], columns: Column<T>[], filters: FilterMa
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** What a set filter says, in the words its funnel uses: an option's label, not its value. */
|
||||
export function filterValueLabel<T>(col: Column<T>, value: string | string[]): string {
|
||||
const f = col.filter;
|
||||
if (!f || f.kind === "text") return String(value);
|
||||
const label = (v: string) => f.options.find((o) => o.value === v)?.label ?? v;
|
||||
return Array.isArray(value) ? value.map(label).join(", ") : label(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user