refactor(table): extract the column header out of DataTable
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).
This commit is contained in:
@@ -1,58 +1,29 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { sortRows, cycleSort, type SortState } from "../lib/columnSort";
|
||||||
import Pager, { hasPagerContent } from "./Pager";
|
import Pager, { hasPagerContent } from "./Pager";
|
||||||
import { PageToolbar } from "./PageShell";
|
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,
|
// 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
|
// 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
|
// 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
|
// other modules can reuse it. Below `md` it falls back to a card list built from the same column
|
||||||
// list built from the same column definitions.
|
// 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,
|
// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
|
||||||
// not nested in DataTable) because it owns a hook: FilterPopover is redeclared on every DataTable
|
export type { Column } from "./tableHeader";
|
||||||
// render, so React remounts it — state parked in there would churn.
|
|
||||||
function FilterOptionList({ children }: { children: ReactNode }) {
|
|
||||||
const fade = useScrollFade();
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={fade.ref}
|
|
||||||
style={fade.style}
|
|
||||||
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type ColumnFilter<T> =
|
|
||||||
| { 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<T> {
|
|
||||||
key: string;
|
|
||||||
header: string;
|
|
||||||
render: (row: T) => ReactNode;
|
|
||||||
align?: "left" | "right" | "center";
|
|
||||||
width?: string;
|
|
||||||
sortable?: boolean;
|
|
||||||
sortValue?: (row: T) => string | number;
|
|
||||||
filter?: ColumnFilter<T>;
|
|
||||||
// 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<string, string | string[]>;
|
|
||||||
interface Persisted {
|
interface Persisted {
|
||||||
sort: SortState;
|
sort: SortState;
|
||||||
filters: FilterMap;
|
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<T>({
|
export default function DataTable<T>({
|
||||||
rows,
|
rows,
|
||||||
columns,
|
columns,
|
||||||
@@ -132,8 +99,6 @@ export default function DataTable<T>({
|
|||||||
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
const [filters, setFilters] = useState<FilterMap>(initial.filters);
|
||||||
const [page, setPage] = useState(initial.page);
|
const [page, setPage] = useState(initial.page);
|
||||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||||
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
|
||||||
const popRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
if (persistKey) localStorage.setItem(persistKey, JSON.stringify({ sort, filters, page, size }));
|
||||||
@@ -158,21 +123,7 @@ export default function DataTable<T>({
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
}, [resetFiltersToken]);
|
}, [resetFiltersToken]);
|
||||||
|
|
||||||
useDismiss(!!openFilter, () => setOpenFilter(null), popRef);
|
const sorted = sortRows(filterRows(rows, columns, filters), columns, sort);
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// size <= 0 means "All" — one page with every row.
|
// size <= 0 means "All" — one page with every row.
|
||||||
const allRows = size <= 0;
|
const allRows = size <= 0;
|
||||||
@@ -191,95 +142,6 @@ export default function DataTable<T>({
|
|||||||
setPage(0);
|
setPage(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const align = (a?: "left" | "right" | "center") =>
|
|
||||||
a === "right" ? "text-right" : a === "center" ? "text-center" : "text-left";
|
|
||||||
|
|
||||||
function FilterPopover({ col }: { col: Column<T> }) {
|
|
||||||
const f = col.filter!;
|
|
||||||
const val = filters[col.key];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={popRef}
|
|
||||||
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
|
||||||
>
|
|
||||||
{f.kind === "text" && (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={(val as string) ?? ""}
|
|
||||||
onChange={(e) => 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" && (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={() => applyFilter(col.key, "")}
|
|
||||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
|
||||||
!isActive(val) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t("datatable.all")}
|
|
||||||
</button>
|
|
||||||
{f.options.map((o) => (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
onClick={() => applyFilter(col.key, o.value)}
|
|
||||||
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
|
||||||
val === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{f.kind === "multi" && (
|
|
||||||
<FilterOptionList>
|
|
||||||
{f.options.length === 0 && (
|
|
||||||
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
|
||||||
)}
|
|
||||||
{f.options.map((o) => {
|
|
||||||
const arr = (val as string[]) ?? [];
|
|
||||||
const on = arr.includes(o.value);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
onClick={() =>
|
|
||||||
applyFilter(
|
|
||||||
col.key,
|
|
||||||
on ? arr.filter((x) => x !== o.value) : [...arr, o.value]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
|
||||||
on ? "text-fg" : "text-muted hover:bg-card"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
|
||||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{on && <X className="w-2.5 h-2.5" />}
|
|
||||||
</span>
|
|
||||||
{o.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</FilterOptionList>
|
|
||||||
)}
|
|
||||||
{isActive(val) && (
|
|
||||||
<button
|
|
||||||
onClick={() => applyFilter(col.key, f.kind === "multi" ? [] : "")}
|
|
||||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
|
||||||
>
|
|
||||||
{t("datatable.clear")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pager + rows-per-page cluster on its own, so it can be rendered a second time at the bottom
|
// 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
|
// (controlsPosition "both") WITHOUT repeating the leading controls (e.g. the status chips) — those
|
||||||
// are header controls and belong only at the top.
|
// are header controls and belong only at the top.
|
||||||
@@ -329,57 +191,13 @@ export default function DataTable<T>({
|
|||||||
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="w-full border-collapse text-sm">
|
||||||
{/* Sticky header row: the column headers stay put at the top of the page scroller while
|
<ColumnHead
|
||||||
the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the
|
columns={columns}
|
||||||
border rides a pseudo-element (box-shadow) since a sticky <tr>'s own border scrolls
|
sort={sort}
|
||||||
away with the collapsed table box in some browsers. The `::after` paints a short
|
onToggleSort={toggleSort}
|
||||||
fade just BELOW the header so rows dissolve as they slide under it (the header itself
|
filters={filters}
|
||||||
stays crisp — the page scroller's own top fade is off on these pages). */}
|
onFilter={applyFilter}
|
||||||
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
|
/>
|
||||||
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
|
|
||||||
{columns.map((col) => {
|
|
||||||
const sorted_ = sort?.key === col.key;
|
|
||||||
const filterOn = isActive(filters[col.key]);
|
|
||||||
return (
|
|
||||||
<th
|
|
||||||
key={col.key}
|
|
||||||
style={col.width ? { width: col.width } : undefined}
|
|
||||||
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${align(col.align)}`}
|
|
||||||
>
|
|
||||||
<span className="inline-flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => col.sortable && toggleSort(col.key)}
|
|
||||||
className={`inline-flex items-center gap-1 ${
|
|
||||||
col.sortable ? "hover:text-fg cursor-pointer" : "cursor-default"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{col.header}
|
|
||||||
{col.sortable && sorted_ && sort && (
|
|
||||||
sort.dir === "asc" ? (
|
|
||||||
<ArrowUp className="w-3 h-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="w-3 h-3" />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{col.filter && (
|
|
||||||
<button
|
|
||||||
onClick={() => setOpenFilter((o) => (o === col.key ? null : col.key))}
|
|
||||||
aria-label={t("datatable.filter")}
|
|
||||||
className={`transition ${
|
|
||||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<ListFilter className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
{openFilter === col.key && col.filter && <FilterPopover col={col} />}
|
|
||||||
</th>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{paged.map((row) => (
|
{paged.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
@@ -392,7 +210,7 @@ export default function DataTable<T>({
|
|||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
style={col.width ? { width: col.width } : undefined}
|
style={col.width ? { width: col.width } : undefined}
|
||||||
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${align(col.align)}`}
|
className={`px-2 py-2 align-middle ${col.nowrap ? "whitespace-nowrap" : ""} ${alignClass(col.align)}`}
|
||||||
>
|
>
|
||||||
{col.render(row)}
|
{col.render(row)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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<T>` render closures.
|
||||||
|
|
||||||
|
type ColumnFilter<T> =
|
||||||
|
| { 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<T> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
align?: "left" | "right" | "center";
|
||||||
|
width?: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
sortValue?: (row: T) => string | number;
|
||||||
|
filter?: ColumnFilter<T>;
|
||||||
|
// 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<string, string | string[]>;
|
||||||
|
|
||||||
|
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<T>(rows: T[], columns: Column<T>[], 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 (
|
||||||
|
<div
|
||||||
|
ref={fade.ref}
|
||||||
|
style={fade.style}
|
||||||
|
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<T>({
|
||||||
|
col,
|
||||||
|
value,
|
||||||
|
onApply,
|
||||||
|
panelRef,
|
||||||
|
}: {
|
||||||
|
col: Column<T>;
|
||||||
|
value: string | string[] | undefined;
|
||||||
|
onApply: (key: string, value: string | string[]) => void;
|
||||||
|
panelRef: RefObject<HTMLDivElement>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const f = col.filter!;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
|
||||||
|
>
|
||||||
|
{f.kind === "text" && (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={(value as string) ?? ""}
|
||||||
|
onChange={(e) => 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" && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onApply(col.key, "")}
|
||||||
|
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
!isActive(value) ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t("datatable.all")}
|
||||||
|
</button>
|
||||||
|
{f.options.map((o) => (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() => onApply(col.key, o.value)}
|
||||||
|
className={`text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{f.kind === "multi" && (
|
||||||
|
<FilterOptionList>
|
||||||
|
{f.options.length === 0 && (
|
||||||
|
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
|
||||||
|
)}
|
||||||
|
{f.options.map((o) => {
|
||||||
|
const arr = (value as string[]) ?? [];
|
||||||
|
const on = arr.includes(o.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
onClick={() =>
|
||||||
|
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
|
||||||
|
}
|
||||||
|
className={`flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
|
on ? "text-fg" : "text-muted hover:bg-card"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||||
|
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{on && <X className="w-2.5 h-2.5" />}
|
||||||
|
</span>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</FilterOptionList>
|
||||||
|
)}
|
||||||
|
{isActive(value) && (
|
||||||
|
<button
|
||||||
|
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||||
|
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||||
|
>
|
||||||
|
{t("datatable.clear")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sticky `<thead>`: 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 `<tr>`'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<T>({
|
||||||
|
columns,
|
||||||
|
sort,
|
||||||
|
onToggleSort,
|
||||||
|
filters,
|
||||||
|
onFilter,
|
||||||
|
}: {
|
||||||
|
columns: Column<T>[];
|
||||||
|
sort: SortState;
|
||||||
|
onToggleSort: (key: string) => void;
|
||||||
|
filters: FilterMap;
|
||||||
|
onFilter: (key: string, value: string | string[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [openFilter, setOpenFilter] = useState<string | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useDismiss(!!openFilter, () => setOpenFilter(null), panelRef);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
|
||||||
|
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
|
||||||
|
{columns.map((col) => {
|
||||||
|
const isSorted = sort?.key === col.key;
|
||||||
|
const filterOn = isActive(filters[col.key]);
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
style={col.width ? { width: col.width } : undefined}
|
||||||
|
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${alignClass(col.align)}`}
|
||||||
|
aria-sort={
|
||||||
|
col.sortable
|
||||||
|
? isSorted
|
||||||
|
? sort!.dir === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: "descending"
|
||||||
|
: "none"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => col.sortable && onToggleSort(col.key)}
|
||||||
|
className={`inline-flex items-center gap-1 ${
|
||||||
|
col.sortable ? "hover:text-fg cursor-pointer" : "cursor-default"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{col.header}
|
||||||
|
{col.sortable && isSorted && sort && (
|
||||||
|
sort.dir === "asc" ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{col.filter && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpenFilter((o) => (o === col.key ? null : col.key))}
|
||||||
|
aria-label={t("datatable.filter")}
|
||||||
|
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||||
|
className={`-m-1.5 p-1.5 transition ${
|
||||||
|
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ListFilter className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{openFilter === col.key && col.filter && (
|
||||||
|
<FilterPopover
|
||||||
|
col={col}
|
||||||
|
value={filters[col.key]}
|
||||||
|
onApply={onFilter}
|
||||||
|
panelRef={panelRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user