refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowDown, ArrowUp, ListFilter, X } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
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. 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
|
||||
* 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;
|
||||
}) {
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user