import { useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDown, ArrowUp, ListFilter } from "lucide-react"; import { MenuCheck } from "./MenuCheck"; import { PopoverItem, PopoverSurface } from "./AnchoredPopover"; import { useAnchoredPopover } from "./anchoredPopoverHooks"; import { useScrollFade } from "../../lib/useScrollFade"; import { type SortState } from "../../lib/columnSort"; import { columnHideClass, isActive, type Column, type FilterMap } from "../../lib/tableColumns"; import { alignClass } from "./tableAlign"; import { useStickyTop } from "./stickyTop"; import { Button } from "./Button"; import { IconButton } from "./IconButton"; import { Pressable } from "./Pressable"; import { Input } from "./Input"; import { SURFACE } from "./surfaces"; // 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 // ChannelLayoutGrid, which builds a card view straight from the `Column` 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 { ref: fadeRef, style: fadeStyle } = useScrollFade(); return (
{children}
); } // 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 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 , and the useDismiss // registration that had to include the trigger so its own mousedown didn't close-then-reopen. function FilterFunnel({ col, value, onApply, open, onOpenChange, }: { col: Column; 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(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 { setReference } = pop.refs; const filterOn = isActive(value); return ( <> {f.kind === "text" && ( onApply(col.key, e.target.value)} placeholder={col.header} /> )} {f.kind === "select" && (
onApply(col.key, "")} label={t("datatable.all")} className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${ !filterOn ? "text-fg" : "text-muted" }`} > {t("datatable.all")} {f.options.map((o) => ( onApply(col.key, o.value)} label={o.label} className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${ value === o.value ? "text-fg" : "text-muted" }`} > {o.label} ))}
)} {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 ( 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-lg outline-hidden transition ${ on ? "text-fg" : "text-muted" }`} > {o.label} ); })} )} {filterOn && ( )}
); } /** 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 * it switches the page scroller's own top fade off while it is mounted (`useStickyTop`). */ 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; }) { useStickyTop(); // Only one column's filter panel is open at a time; each funnel owns its own anchoring. const [openFilter, setOpenFilter] = useState(null); return ( {columns.map((col) => { const isSorted = sort?.key === col.key; return ( {/* 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. */} {/* min-w-6 as well as min-h-6: a short header ("Ki", Hungarian "Who") was a 12px target beside its funnel, under the 24px floor. */} {col.sortable ? ( onToggleSort(col.key)} className="inline-flex min-h-6 min-w-6 items-center gap-1 hover:text-fg cursor-pointer touch-target" > {col.header} {isSorted && sort && (sort.dir === "asc" ? ( ) : ( ))} ) : ( {col.header} )} {col.filter && ( setOpenFilter(o ? col.key : null)} /> )} ); })} ); }