ui/MenuCheck: a box filled with the accent for a set (a minus for a group only partly in), a bare tick for one choice, in the row's leading slot. Replaces seven hand-drawn marks: three box sizes, a tinted box, a cross for "on" in the table filter, a trailing tick in the language menu.
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
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 { 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 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 (
|
|
<>
|
|
<IconButton
|
|
variant={filterOn ? "ghost-accent" : "ghost"}
|
|
ref={pop.refs.setReference}
|
|
{...pop.getReferenceProps()}
|
|
label={t("datatable.filter")}
|
|
// The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
|
|
className="-my-1.5"
|
|
>
|
|
<ListFilter className="size-3.5!" />
|
|
</IconButton>
|
|
<PopoverSurface
|
|
popover={pop}
|
|
ariaLabel={col.header}
|
|
initialFocus={isText ? inputRef : undefined}
|
|
className={`${SURFACE.popover} w-52 p-2 animate-[popIn_0.16s_ease]`}
|
|
>
|
|
{f.kind === "text" && (
|
|
<Input
|
|
className="w-full"
|
|
size="sm"
|
|
ref={inputRef}
|
|
value={(value as string) ?? ""}
|
|
onChange={(e) => onApply(col.key, e.target.value)}
|
|
placeholder={col.header}
|
|
/>
|
|
)}
|
|
{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-lg outline-hidden transition ${
|
|
!filterOn ? "bg-accent text-accent-fg" : "text-muted state-layer"
|
|
}`}
|
|
>
|
|
{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-lg outline-hidden transition ${
|
|
value === o.value ? "bg-accent text-accent-fg" : "text-muted state-layer"
|
|
}`}
|
|
>
|
|
{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-lg outline-hidden transition ${
|
|
on ? "text-fg" : "text-muted"
|
|
}`}
|
|
>
|
|
<MenuCheck kind="many" on={on} />
|
|
{o.label}
|
|
</PopoverItem>
|
|
);
|
|
})}
|
|
</FilterOptionList>
|
|
)}
|
|
{filterOn && (
|
|
<Button
|
|
variant="link-muted"
|
|
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
|
className="mt-1.5 w-full text-xs"
|
|
>
|
|
{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 fade-below">
|
|
<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)}${col.phoneHidden ? " hidden sm:table-cell" : ""}`}
|
|
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 ? (
|
|
<Pressable
|
|
onClick={() => onToggleSort(col.key)}
|
|
className="inline-flex min-h-6 items-center gap-1 hover:text-fg cursor-pointer touch-target"
|
|
>
|
|
{col.header}
|
|
{isSorted &&
|
|
sort &&
|
|
(sort.dir === "asc" ? (
|
|
<ArrowUp className="w-3 h-3" />
|
|
) : (
|
|
<ArrowDown className="w-3 h-3" />
|
|
))}
|
|
</Pressable>
|
|
) : (
|
|
<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>
|
|
);
|
|
}
|