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); +}