import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { sortRows, cycleSort, type SortState } from "../../lib/columnSort"; import Pager from "./Pager"; import { hasPagerContent } from "./pagerContent"; import { PageToolbar } from "./PageShell"; import { ColumnHead } from "./tableHeader"; import { alignClass } from "./tableAlign"; import { columnHideClass, filterRows, type Column, type FilterMap } from "../../lib/tableColumns"; import { navItemAttrs, useListNav } from "../../lib/listNav"; import { useMediaQuery } from "../../lib/useMediaQuery"; import { SURFACE } from "./surfaces"; import { HiddenFilterCue } from "./HiddenFilterCue"; import { useProgressiveRows } from "./progressiveRows"; // 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 // the view. Columns are declared by the caller; the table is generic over the row type, so // other modules can reuse it. Below `md` it falls back to a card list built from the same column // definitions. // // The header row itself (sort cell + filter funnel/popover) lives in `tableHeader.tsx`, the column // contract and the filter predicate in `lib/tableColumns` — the playlist manager renders its own // drag-reorderable body under the same headers without inheriting this component's ordering state. // Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working. export type { Column } from "../../lib/tableColumns"; interface Persisted { sort: SortState; filters: FilterMap; page: number; size?: number; } // Stable empty-map identity for the controlled-but-value-omitted fallback, so `filters` keeps a // constant reference across renders (a fresh `{}` would churn the persist effect + re-filter every // render). Mirrors controlled sort's stable `null` fallback. const EMPTY_FILTERS: FilterMap = {}; function loadPersist(key?: string): Persisted { const empty: Persisted = { sort: null, filters: {}, page: 0 }; if (!key) return empty; try { const v = JSON.parse(localStorage.getItem(key) || "{}"); return { sort: v.sort ?? null, filters: v.filters ?? {}, page: v.page ?? 0, size: v.size }; } catch { return empty; } } interface TableRowProps { row: T; index: number; columns: Column[]; rowProps?: (row: T) => Record; rowClassName?: (row: T) => string; keyboardNav: boolean; } // A row is its own component so the React Compiler caches its cells by (row, columns, …): a long page // grows in steps (progressiveRows), and a step must not draw the rows it already holds again. Inline in // the body's map they all were, and the steps grew from ~70 to ~200 ms (dev build) as the page filled. function TableRow({ row, index, columns, rowProps, rowClassName, keyboardNav, }: TableRowProps) { return ( {columns.map((col) => ( {col.render(row)} ))} ); } export default function DataTable({ rows, columns, rowKey, pageSize = 10, pageSizeOptions = [10, 25, 50, 100], persistKey, emptyText, rowClassName, rowProps, controlsPosition = "bottom", controlsLeading, controlsInBand = false, controlsBandClassName, externalFilter, resetFiltersToken, sort: sortProp, onSortChange, filters: filtersProp, onFiltersChange, fixedLayout = false, keyboardNav = false, reveal, surfacedFilters, narrowedBy = "", }: { rows: T[]; columns: Column[]; rowKey: (row: T) => string; pageSize?: number; pageSizeOptions?: number[]; persistKey?: string; emptyText?: string; rowClassName?: (row: T) => string; // Extra DOM props per row — the escape hatch a caller needs to hang an INPUT PATH on a row it does // not own the markup of. Today's only consumer is `useContextMenu().triggerProps` (right-click / // long-press / Shift+F10), and both row shapes get it, so the wide table and the narrow card // answer the same gesture. Deliberately not a bag of named callbacks: the table stays domain-free // and never learns what a menu is. rowProps?: (row: T) => Record; controlsPosition?: "top" | "bottom" | "both"; controlsLeading?: ReactNode; // Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead // of inline, so it stays put above the sticky header while the rows scroll. `controlsBandClassName` // aligns it with the page's fixed chrome (e.g. the same max-width wrapper). controlsInBand?: boolean; controlsBandClassName?: string; // Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied // whenever its value changes; the user can still clear it from the header afterwards. externalFilter?: { key: string; value: string } | null; // Bump this to clear all column filters — for navigation intents that mean "show me this // set" (e.g. the header's "without full history" link), so a stale persisted filter can't // hide the rows the user was just sent to see. resetFiltersToken?: number; // CONTROLLED sort (optional): when `onSortChange` is given, the caller owns the sort — the table // reflects `sort` and reports header clicks instead of keeping its own — so another view of the // same rows (the channel manager's cards) can share one sort. Omit both for the default internal, // persistKey-backed sort. sort?: SortState; onSortChange?: (next: SortState) => void; // CONTROLLED filters (optional): symmetric to controlled sort. When `onFiltersChange` is given the // caller owns the column-filter map (and its persistence) — so the channel manager's top status // chips, the Sync column funnel, and the card layout all share ONE filter state — and the table // reflects `filters` and reports funnel changes instead of keeping its own. Omit both for the // default internal, persistKey-backed filters. filters?: FilterMap; onFiltersChange?: (next: FilterMap) => void; // Opt into `table-layout: fixed` (default is `auto`). In fixed mode the table width == its // container at every zoom/text-size — the columns with an explicit `width` keep it, and the rest // (the fluid text columns) split what remains and TRUNCATE instead of pushing the table wider than // the viewport. Off by default so existing content-sized tables (playlists, discovery, Plex) are // untouched; the channel manager turns it on so its row never scrolls horizontally out of view. fixedLayout?: boolean; // Take part in page keyboard navigation (`lib/listNav`): `j`/`k` and the arrows walk the rows, and // Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row // shapes register; listNav walks only the one on screen, so the hidden copy is never a stop. keyboardNav?: boolean; // Follow a row the caller just MOVED (a channel re-sorted by the priority its menu set): each new // `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[]; // What the caller narrowed `rows` by before passing them in (the channel manager's name search lives // in the shared top bar): a change starts a long page over from its first rows, as the table's own // sort and filter changes do (progressiveRows). Without it every key re-rendered the whole page. narrowedBy?: string; }) { const { t, i18n } = useTranslation(); const lang = i18n.language; const initial = loadPersist(persistKey); // Sort is controlled when the caller passes onSortChange (it then owns/persists it); otherwise // the table keeps its own, seeded from and saved to persistKey. const controlledSort = onSortChange !== undefined; const [internalSort, setInternalSort] = useState(initial.sort); const sort = controlledSort ? (sortProp ?? null) : internalSort; // Filters are controlled when the caller passes onFiltersChange (it then owns/persists the map); // otherwise the table keeps its own, seeded from and saved to persistKey — symmetric to sort. const controlledFilters = onFiltersChange !== undefined; const [internalFilters, setInternalFilters] = useState(initial.filters); const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters; const [page, setPage] = useState(initial.page); const [size, setSize] = useState(initial.size ?? pageSize); const wide = useMediaQuery("(min-width: 48rem)"); const bodyRef = useRef(null); const cardsRef = useRef(null); // Enabled with the layout that is mounted: `useListNav` reads its ref when `enabled` changes, so a // flag that stayed on across a breakpoint left the newly mounted layout unregistered (no j/k) and // the unmounted one registered. useListNav(bodyRef, { enabled: keyboardNav && wide }); useListNav(cardsRef, { enabled: keyboardNav && !wide }); // Commit a whole new filter map through the owner (controlled) or internal state. const commitFilters = (next: FilterMap) => { if (controlledFilters) onFiltersChange!(next); else setInternalFilters(next); }; // The externalFilter/reset effects below read the latest filters and callback through effect // events, so they re-run only on their own trigger, not on every filter change or on the inline // `onFiltersChange` a controlled caller passes each render. (They used to ride render-written refs, // which made the React Compiler skip this whole component — and with it every row's memo.) const applyExternalFilter = useEffectEvent((key: string, value: string | string[]) => { commitFilters({ ...filters, [key]: value }); setPage(0); }); const clearFilters = useEffectEvent(() => { commitFilters({}); setPage(0); }); // Persist page/size always, but only the sort/filter slices this table actually OWNS — a controlled // caller persists those itself, so writing them here would just leave a stale, never-read duplicate. useEffect(() => { if (!persistKey) return; localStorage.setItem( persistKey, JSON.stringify({ sort: controlledSort ? null : sort, filters: controlledFilters ? {} : filters, page, size, }), ); }, [persistKey, controlledSort, controlledFilters, sort, filters, page, size]); // Destructured so the deps are the values the body actually reads. `externalFilter` itself is an // object literal at every call site, so depending on it would re-apply the filter — and reset the // page — on every parent render. const externalKey = externalFilter?.key; const externalValue = externalFilter?.value; useEffect(() => { if (externalKey === undefined || externalValue === undefined) return; // eslint-disable-next-line react-hooks/set-state-in-effect -- a controlled filter commits through the PARENT's setter, which may not run during this table's render; the effect fires once per new external value applyExternalFilter(externalKey, externalValue); }, [externalKey, externalValue]); // Clear column filters when the reset token advances. The ref starts at 0 (not at the prop) // so a navigation that bumped the token still clears even when it remounts this table fresh // (e.g. arriving from another page); a plain reload keeps the token at 0 and the effect is a // no-op, so the user's persisted filters survive. const resetRef = useRef(0); useEffect(() => { if (resetFiltersToken === undefined || resetFiltersToken === resetRef.current) return; resetRef.current = resetFiltersToken; clearFilters(); }, [resetFiltersToken]); const sorted = sortRows(filterRows(rows, columns, filters), columns, sort, lang); // size <= 0 means "All" — one page with every row. const allRows = size <= 0; const totalPages = allRows ? 1 : Math.max(1, Math.ceil(sorted.length / size)); const safePage = Math.min(page, totalPages - 1); // Adjusted DURING render against the last seq seen (the house's key-change reset), so the frame // that re-sorted the row already shows its new page. Seeded with the mount's seq: arriving with a // stale reveal must not move a page nobody asked to move. const viewOf = (p: number) => `${p}:${size}:${JSON.stringify(sort)}:${JSON.stringify(filters)}:${narrowedBy}`; const [revealSeen, setRevealSeen] = useState(reveal?.seq); // The view the reveal was made for: the floor below holds only there. A caller keeps its reveal // after the focus (Channels never clears `follow`), and a floor that outlived its view mounted every // row up to that channel on each later sort or search. const [revealView, setRevealView] = useState(null); if (reveal && reveal.seq !== revealSeen) { setRevealSeen(reveal.seq); const at = sorted.findIndex((row) => rowKey(row) === reveal.key); const target = at >= 0 && !allRows ? Math.floor(at / size) : safePage; if (target !== safePage) setPage(target); setRevealView(viewOf(target)); } const view = viewOf(safePage); const paged = allRows ? sorted : sorted.slice(safePage * size, safePage * size + size); // A long page ("All", 100) mounts its first rows at once and the rest in the background — but // always through the revealed row, which the caller focuses as soon as this frame commits. const shown = useProgressiveRows( paged, view, reveal && revealView === view ? paged.findIndex((row) => rowKey(row) === reveal.key) : -1, ); function toggleSort(key: string) { const next = cycleSort(sort, key); if (controlledSort) onSortChange!(next); else setInternalSort(next); setPage(0); } function applyFilter(key: string, value: string | string[]) { 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 // are header controls and belong only at the top. // Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always // -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table // with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart. const pagerNode = hasPagerContent(sorted.length, size) ? ( { setSize(s); setPage(0); }} sizeLabel={t("datatable.rowsPerPage")} /> ) : null; const controls = controlsLeading != null || pagerNode ? (
{controlsLeading}
{pagerNode}
) : null; // Bottom row for "both": just the pager, right-aligned (the leading controls stay at the top). const bottomControls = pagerNode ? (
{pagerNode}
) : null; const topControls = controlsInBand && controls ? (
{controls}
) : ( controls ); return (
{(controlsPosition === "top" || controlsPosition === "both") && topControls} {/* ONE layout, chosen by width — not both mounted with CSS hiding one: that doubled every row's DOM and render cost, duplicated ids and test hooks, and handed keyboard nav and any DOM query two copies of each row. `md` (48rem) is the breakpoint the CSS used. Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */} {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 table never exceeds its container and the fluid cells truncate instead. */} {fixedLayout && ( {columns.map((col) => ( ))} )} {shown.map((row, i) => ( ))}
) : ( /* 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). */ <> {cue("cards")}
{shown.map((row, i) => (
{columns .filter((c) => c.cardPrimary) .map((col) => (
{col.render(row)}
))}
{columns .filter((c) => !c.hideInCard && !c.cardPrimary) .map((col) => ( {col.cardLabel !== false && ( {col.header} )} {col.render(row)} ))}
))}
)} {paged.length === 0 && (
{emptyText ?? t("datatable.empty")}
)} {controlsPosition === "bottom" && controls} {controlsPosition === "both" && bottomControls}
); }