Files
siftlode/frontend/src/components/ui/tableHeader.tsx
T
peter 6fda49369f fix(ui): a sticky table header turns the scroller's top fade off itself; DataTable renders one layout
- "A page with a sticky ColumnHead must declare fadeTop: false" lived in
  prose: a registry flag on three pages plus an App special case for the
  Channels card layout, and the next table page would have re-acquired the
  fade-on-the-header defect with every lane green. ColumnHead now registers
  itself with the enclosing PageScroller (ui/stickyTop) while mounted; the
  registry flag, the App branch and the prop plumbing are gone. Measured on
  localdev: the top mask is 0px under Channels' and Audit's headers, 20px on
  Home and the Feed.
- ui/DataTable mounted its table AND its card list at every width, one
  hidden by CSS: double DOM and render cost, duplicate ids, two keyboard
  copies of every row. It now mounts the one the `md` media query calls for
  (lib/useMediaQuery); the keyboard test asserts one layout at each width.
- The Plex module's content width went px → rem with the rest of the page.
2026-09-19 07:51:33 +02:00

265 lines
11 KiB
TypeScript

import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { type SortState } from "../../lib/columnSort";
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
import { alignClass } from "./tableAlign";
import { useStickyTop } from "./stickyTop";
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
// and the filter predicate it reads live in `lib/tableColumns` (lib helpers need them, and lib may
// not import from components).
//
// 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.
// 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. The panel 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".
//
// The funnel and its panel live in ONE component because the R8 popover platform anchors the panel
// to its trigger. That also retires this file's three hand-rolled focus chores: capturing the
// trigger element at open time (a `ref` scoped to the open column nulled out before focus could go
// back), the restore-focus effect that stopped Escape stranding focus on <body>, and the useDismiss
// registration that had to include the trigger so its own mousedown didn't close-then-reopen.
function FilterFunnel<T>({
col,
value,
onApply,
open,
onOpenChange,
}: {
col: Column<T>;
value: string | string[] | undefined;
onApply: (key: string, value: string | string[]) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const f = col.filter!;
const inputRef = useRef<HTMLInputElement | null>(null);
// A text filter is a free-text field, so it is a dialog whose input takes focus; the option lists
// are real menus with roving focus. `select` is one exclusive choice (radio), `multi` is several
// independent ones (checkbox).
const isText = f.kind === "text";
const pop = useAnchoredPopover({
open,
onOpenChange,
mode: isText ? "dialog" : "menu",
role: isText ? "dialog" : "menu",
});
const filterOn = isActive(value);
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
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>
<PopoverSurface
popover={pop}
ariaLabel={col.header}
initialFocus={isText ? inputRef : undefined}
className="glass-menu w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
ref={inputRef}
value={(value as string) ?? ""}
onChange={(e) => onApply(col.key, e.target.value)}
placeholder={col.header}
className="w-full bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
)}
{f.kind === "select" && (
<div className="flex flex-col gap-0.5">
<PopoverItem
role="menuitemradio"
checked={!filterOn}
keepSelectedPaint
onSelect={() => onApply(col.key, "")}
label={t("datatable.all")}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
!filterOn ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{t("datatable.all")}
</PopoverItem>
{f.options.map((o) => (
<PopoverItem
key={o.value}
role="menuitemradio"
checked={value === o.value}
keepSelectedPaint
onSelect={() => onApply(col.key, o.value)}
label={o.label}
className={`text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
value === o.value ? "bg-accent text-accent-fg" : "text-muted hover:bg-card"
}`}
>
{o.label}
</PopoverItem>
))}
</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 (
<PopoverItem
key={o.value}
role="menuitemcheckbox"
checked={on}
onSelect={() =>
onApply(col.key, on ? arr.filter((x) => x !== o.value) : [...arr, o.value])
}
label={o.label}
className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
<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}
</PopoverItem>
);
})}
</FilterOptionList>
)}
{filterOn && (
<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>
)}
</PopoverSurface>
</>
);
}
/** 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
* it switches the page scroller's own top fade off while it is mounted (`useStickyTop`). */
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;
}) {
useStickyTop();
// Only one column's filter panel is open at a time; each funnel owns its own anchoring.
const [openFilter, setOpenFilter] = useState<string | null>(null);
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;
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">
{/* 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 ? (
<button
onClick={() => onToggleSort(col.key)}
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
>
{col.header}
{isSorted &&
sort &&
(sort.dir === "asc" ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
))}
</button>
) : (
<span>{col.header}</span>
)}
{col.filter && (
<FilterFunnel
col={col}
value={filters[col.key]}
onApply={onFilter}
open={openFilter === col.key}
onOpenChange={(o) => setOpenFilter(o ? col.key : null)}
/>
)}
</span>
</th>
);
})}
</tr>
</thead>
);
}