From 89000538d47c24a56c5ac4bbc3c61bb07325a67d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 20 Jul 2026 23:18:46 +0200 Subject: [PATCH] refactor(table): extract the column header out of DataTable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sort cell, filter funnel/popover and filter predicate move to components/tableHeader.tsx so a non-DataTable body can render under the same headers — the playlist manager's drag-reorderable list needs the headers without inheriting DataTable's internal ordering state. FilterPopover reaches module scope in the process. It was declared inside DataTable's body, so it was a new component type on every render and React remounted it on each keystroke; verified against the pre-change build, where the input's DOM node is replaced on all four keystrokes of a test word. Also picks up two things that live in this markup: aria-sort on the sortable headers, and a real hit target on the filter funnel (it was 14x14). --- frontend/src/components/DataTable.tsx | 230 +++----------------- frontend/src/components/tableHeader.tsx | 267 ++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 206 deletions(-) create mode 100644 frontend/src/components/tableHeader.tsx 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 ( -
- {f.kind === "text" && ( - applyFilter(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 = (val as string[]) ?? []; - const on = arr.includes(o.value); - return ( - - ); - })} - - )} - {isActive(val) && ( - - )} -
- ); - } - // 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). */} - - - {columns.map((col) => { - const sorted_ = sort?.key === col.key; - const filterOn = isActive(filters[col.key]); - return ( - - ); - })} - - + {paged.map((row) => ( ({ 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 ( +
+ {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); + useDismiss(!!openFilter, () => setOpenFilter(null), panelRef); + + return ( + + + {columns.map((col) => { + const isSorted = sort?.key === col.key; + const filterOn = isActive(filters[col.key]); + return ( + + ); + })} + + + ); +}
- - - {col.filter && ( - - )} - - {openFilter === col.key && col.filter && } -
{col.render(row)}
+ + + {col.filter && ( + + )} + + {openFilter === col.key && col.filter && ( + + )} +