From 51f881681bd776617f9fccdf10726e52d5442404 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 16:22:26 +0200 Subject: [PATCH] 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). --- frontend/src/components/Channels.tsx | 38 +++-- frontend/src/components/Playlists.tsx | 8 + frontend/src/components/ui/DataTable.tsx | 68 +++++---- .../ui/HiddenFilterCue.dom.test.tsx | 137 ++++++++++++++++++ .../src/components/ui/HiddenFilterCue.tsx | 77 ++++++++++ frontend/src/components/ui/README.md | 2 +- .../ui/hiddenFilterCue.contract.test.ts | 17 +++ frontend/src/i18n/locales/en/datatable.json | 5 + frontend/src/i18n/locales/hu/datatable.json | 5 + frontend/src/lib/tableColumns.ts | 25 +++- 10 files changed, 342 insertions(+), 40 deletions(-) create mode 100644 frontend/src/components/ui/HiddenFilterCue.dom.test.tsx create mode 100644 frontend/src/components/ui/HiddenFilterCue.tsx create mode 100644 frontend/src/components/ui/hiddenFilterCue.contract.test.ts diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 9daa0461..6778926b 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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} /> ) : ( - 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. */} + setFilters({ ...filters, [key]: value })} + /> + c.id} + leadKey="priority" + aboutKey="about" + moreKey="actions" + emptyText={t("channels.empty")} + rowProps={rowMenu.triggerProps} + rowClassName={() => "group"} + /> + )} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 0d6fb62b..d9f14932 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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`). */}
+ {/* A narrow table drops Channel and its funnel with it: the cue keeps its filter named. */} + {/* 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. */} diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index de0aea38..bf2f83e6 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -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({ fixedLayout = false, keyboardNav = false, reveal, + surfacedFilters, }: { rows: T[]; columns: Column[]; @@ -126,6 +128,9 @@ export default function DataTable({ // `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({ commitFilters({ ...filters, [key]: value }); setPage(0); } + const cue = (by: "width" | "cards") => ( + + ); // 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({ {wide ? ( // A container, so a column hides by the table's own width (`tabletHidden`), not the window's.
+ {cue("width")} {/* 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({ ) : ( /* 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). */ -
- {paged.map((row, i) => ( -
- {columns - .filter((c) => c.cardPrimary) - .map((col) => ( -
- {col.render(row)} -
- ))} -
+ <> + {cue("cards")} +
+ {paged.map((row, i) => ( +
{columns - .filter((c) => !c.hideInCard && !c.cardPrimary) + .filter((c) => c.cardPrimary) .map((col) => ( - - {col.cardLabel !== false && ( - {col.header} - )} +
{col.render(row)} - +
))} +
+ {columns + .filter((c) => !c.hideInCard && !c.cardPrimary) + .map((col) => ( + + {col.cardLabel !== false && ( + {col.header} + )} + {col.render(row)} + + ))} +
-
- ))} -
+ ))} +
+ )} {paged.length === 0 && ( diff --git a/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx b/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx new file mode 100644 index 00000000..00ab192c --- /dev/null +++ b/frontend/src/components/ui/HiddenFilterCue.dom.test.tsx @@ -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(); + 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[] = [ + { 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( + 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(""); + }); +}); diff --git a/frontend/src/components/ui/HiddenFilterCue.tsx b/frontend/src/components/ui/HiddenFilterCue.tsx new file mode 100644 index 00000000..d82e7205 --- /dev/null +++ b/frontend/src/components/ui/HiddenFilterCue.tsx @@ -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({ + columns, + filters, + onFilter, + by, + surfaced = [], +}: { + columns: Column[]; + 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) => (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 ( +
+ {t("datatable.hiddenFilter.label", { count: named.length })} + {named.map((col) => { + const text = `${col.header}: ${filterValueLabel(col, filters[col.key]!)}`; + return ( + ) => { + // The chip goes with its filter: hand the focus on first, not to . + focusNeighbour(e.currentTarget); + onFilter(col.key, Array.isArray(filters[col.key]) ? [] : ""); + }} + > + {text} + + ); + })} +
+ ); +} diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index 0ae35ce2..c6cb00a7 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -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 `
too: a hidden cell under a 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(rows: T[], columns: Column[], filters: FilterMa }), ); } + +/** What a set filter says, in the words its funnel uses: an option's label, not its value. */ +export function filterValueLabel(col: Column, 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); +}