import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react"; import { useDismiss } from "../lib/useDismiss"; import { useScrollFade } from "../lib/useScrollFade"; import { type SortState } from "../lib/columnSort"; // The column-header machinery — the column contract, the sortable header row with its filter // funnel + popover, and the filter predicate — lifted OUT of DataTable so a non-DataTable body can // render under the same headers. // // Why it lives here and not in DataTable: DataTable owns its filter/sort/page state internally, // while the playlist manager's body is a drag-reorderable list whose order is the persisted // server-side one. Two owners of the same ordering fight; composing these pieces instead lets the // playlist keep its stored order and still get real column headers. Same move as E4 S3's // ChannelLayoutGrid, which builds a card view straight from the `Column` render closures. 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; } 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[]); }), ); } export const alignClass = (a?: "left" | "right" | "center") => a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left"; // The multi-select filter's option list, capped and scrollable. Its own component because it owns a // hook — see the FilterPopover note below for why nesting either of these would remount them. function FilterOptionList({ children }: { children: ReactNode }) { const fade = useScrollFade(); return (
{children}
); } // MODULE LEVEL, deliberately. This used to be declared inside DataTable's body, which made it a NEW // component type on every render: React unmounted and remounted the popover on each keystroke, so // the caret jumped to the end and IME composition (accents, any CJK input) broke mid-word. The // `autoFocus` masked it well enough that it read as "the filter is just twitchy". function FilterPopover({ col, value, onApply, panelRef, }: { col: Column; value: string | string[] | undefined; onApply: (key: string, value: string | string[]) => void; panelRef: RefObject; }) { const { t } = useTranslation(); const f = col.filter!; return (
{f.kind === "text" && ( onApply(col.key, e.target.value)} placeholder={col.header} className="w-full bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" /> )} {f.kind === "select" && (
{f.options.map((o) => ( ))}
)} {f.kind === "multi" && ( {f.options.length === 0 && ( {t("datatable.noOptions")} )} {f.options.map((o) => { const arr = (value as string[]) ?? []; const on = arr.includes(o.value); return ( ); })} )} {isActive(value) && ( )}
); } /** The sticky ``: one sortable/filterable cell per column. * * Sticky-header notes (kept from DataTable, where this look was designed): the header stays put at * the top of the page scroller while rows scroll under it. `bg-bg` on each cell stops rows showing * through; the bottom border rides a `box-shadow` because a sticky ``'s own border scrolls away * with the collapsed table box in some browsers. The `::after` paints a short fade just BELOW the * header so rows dissolve as they slide under it while the header itself stays crisp — which is why * pages using this pass `PageShell fadeTop={false}`. */ export function ColumnHead({ columns, sort, onToggleSort, filters, onFilter, }: { columns: Column[]; sort: SortState; onToggleSort: (key: string) => void; filters: FilterMap; onFilter: (key: string, value: string | string[]) => void; }) { const { t } = useTranslation(); const [openFilter, setOpenFilter] = useState(null); const panelRef = useRef(null); // The funnel that opened the panel. `useDismiss` must know about it too, or the trigger's own // mousedown closes the panel and the click that follows immediately reopens it — clicking the // funnel a second time then looks like it does nothing. Also the thing to hand focus back to. const triggerRef = useRef(null); const restoreFocus = useRef(false); useDismiss(!!openFilter, () => setOpenFilter(null), [panelRef, triggerRef]); // Escape (or an outside click) used to unmount the focused input and strand focus on , // so the next Tab restarted from the top of the page. useEffect(() => { if (openFilter) { restoreFocus.current = true; } else if (restoreFocus.current) { restoreFocus.current = false; triggerRef.current?.focus(); } }, [openFilter]); return ( {columns.map((col) => { const isSorted = sort?.key === col.key; const filterOn = isActive(filters[col.key]); return ( {/* Only a sortable header is a control. A non-sortable one used to render a focusable no-op button, which put unlabeled dead stops in the tab order — two of them per playlist table, whose grip and actions headers are empty. */} {col.sortable ? ( ) : ( {col.header} )} {col.filter && ( )} {openFilter === col.key && col.filter && ( )} ); })} ); }