diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx
index e6cf2ee7..09fc18de 100644
--- a/frontend/src/components/DataTable.tsx
+++ b/frontend/src/components/DataTable.tsx
@@ -1,58 +1,29 @@
import { useEffect, useRef, useState, type ReactNode } 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 { sortRows, cycleSort, type SortState } from "../lib/columnSort";
import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell";
+import {
+ ColumnHead,
+ alignClass,
+ filterRows,
+ type Column,
+ type FilterMap,
+} from "./tableHeader";
// 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 (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
-// list built from the same column definitions.
+// 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 + the filter predicate) lives in
+// `tableHeader.tsx` — the playlist manager renders its own drag-reorderable body under the same
+// headers without inheriting this component's ordering state.
-// The multi-select filter's option list, capped and scrollable. Its own component (module level,
-// not nested in DataTable) because it owns a hook: FilterPopover is redeclared on every DataTable
-// render, so React remounts it — state parked in there would churn.
-function FilterOptionList({ children }: { children: ReactNode }) {
- const fade = useScrollFade();
- return (
-
- {children}
-
- );
-}
+// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
+export type { Column } from "./tableHeader";
-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;
-}
-
-type FilterMap = Record;
interface Persisted {
sort: SortState;
filters: FilterMap;
@@ -71,10 +42,6 @@ function loadPersist(key?: string): Persisted {
}
}
-function isActive(value: string | string[] | undefined): boolean {
- return Array.isArray(value) ? value.length > 0 : !!value;
-}
-
export default function DataTable({
rows,
columns,
@@ -132,8 +99,6 @@ export default function DataTable({
const [filters, setFilters] = useState(initial.filters);
const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize);
- const [openFilter, setOpenFilter] = useState(null);
- const popRef = useRef(null);
useEffect(() => {
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
@@ -158,21 +123,7 @@ export default function DataTable({
setPage(0);
}, [resetFiltersToken]);
- useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
-
- const filtered = 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[]);
- })
- );
-
- const sorted = sortRows(filtered, columns, sort);
+ const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
// size <= 0 means "All" — one page with every row.
const allRows = size <= 0;
@@ -191,95 +142,6 @@ export default function DataTable({
setPage(0);
}
- const align = (a?: "left" | "right" | "center") =>
- a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
-
- function FilterPopover({ col }: { col: Column }) {
- const f = col.filter!;
- const val = filters[col.key];
- return (
-
- );
- }
-
// 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.
@@ -329,57 +191,13 @@ export default function DataTable({
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
- {/* Sticky header row: the column headers stay put at the top of the page scroller while
- the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the
- border rides a pseudo-element (box-shadow) since 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 (the header itself
- stays crisp — the page scroller's own top fade is off on these pages). */}
-
-
diff --git a/frontend/src/components/tableHeader.tsx b/frontend/src/components/tableHeader.tsx
new file mode 100644
index 00000000..2d7cd3fe
--- /dev/null
+++ b/frontend/src/components/tableHeader.tsx
@@ -0,0 +1,267 @@
+import { 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 (
+
+ );
+}
+
+/** 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);
+ useDismiss(!!openFilter, () => setOpenFilter(null), panelRef);
+
+ return (
+
+