import type { ReactNode } from "react"; // The column contract every table-shaped view shares — `DataTable`, the playlist manager's hybrid // table, the channel card grid — plus the filter predicate that reads it. // // It lives in `lib/`, not beside the header component in `components/ui/tableHeader`, because `lib/` // helpers need it too (`sortOptions`, `useFilterState`, `channelsUrlState`) and the dependency // direction is one-way: `lib/` may not import from `components/` (lint-enforced, see // eslint.config.js). The contract is data + closures; nothing in it renders. type ColumnFilter = | { kind: "text"; get: (row: T) => string } | { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean; } | { kind: "multi"; options: { value: string; label: string }[]; test: (row: T, values: string[]) => boolean; }; export interface Column { key: string; header: string; render: (row: T) => ReactNode; align?: "left" | "right" | "center"; width?: string; sortable?: boolean; sortValue?: (row: T) => string | number; filter?: ColumnFilter; // Keep the cell on one line so the column auto-sizes to its content (table-layout: auto). nowrap?: boolean; // Card fallback (below md): `cardPrimary` renders as the card heading (no label); `hideInCard` // omits the column; `cardLabel:false` shows the value without its header label. cardPrimary?: boolean; hideInCard?: boolean; cardLabel?: boolean; // …and `cardRow` gives the value a line of its own: a row's lone "…" flowed in after the facts, // at the end of one card's line and on the next line of another — never in the same place twice. cardRow?: boolean; // A column a table WITHOUT the card fallback (the playlist's drag-reorderable hybrid) leaves out // while the TABLE is narrower than 42rem: its fixed widths alone overran the box and left the title // no width. The table's own width, not the viewport's (an `@container` round it): beside the // playlist rail a desktop window gave the table 432 px, and the title column was 0 px wide. narrowHidden?: boolean; // …and one a table leaves out while it is narrower than 64rem — a 1024 tablet window, or a desktop // one beside a panel: a secondary column that gives its room back to the name (the owner, // 2026-09-27: at 1024 the channel name had 60 px while About took as much). Same `@container` rule. tabletHidden?: boolean; } // The classes that hide a column below its width (`narrowHidden` / `tabletHidden`), for its cells and, // in a fixed layout, for its 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 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", 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" | "cue" = "cell", ): string { if (col.narrowHidden) return HIDE_BELOW.narrow[part]; if (col.tabletHidden) return HIDE_BELOW.tablet[part]; return ""; } export type FilterMap = Record; export function isActive(value: string | string[] | undefined): boolean { return Array.isArray(value) ? value.length > 0 : !!value; } /** Apply every column's own filter predicate. A column with no filter, or an empty value, passes. */ export function filterRows(rows: T[], columns: Column[], filters: FilterMap): T[] { return rows.filter((row) => columns.every((col) => { const f = col.filter; if (!f) return true; const val = filters[col.key]; if (!isActive(val)) return true; if (f.kind === "text") return f.get(row).toLowerCase().includes(String(val).toLowerCase()); if (f.kind === "select") return f.test(row, String(val)); return f.test(row, val as string[]); }), ); } /** 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); }