Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle `@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed matches in pure group order and always highlighted the first one. - CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint, keywords or group name; an item ranks by its worst word, then the sum. The group order stays fixed, rows are ranked inside each group, and the best row overall starts highlighted. The highlight is held by id and follows the best match until the arrows or the pointer pick a row, so a channel list landing mid-typing no longer shifts it. - A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing element, and "Nothing matches" sits outside it; a polite status announces the count once the typing pauses. - `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`, and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib helpers imported the column contract from a ui component, and `playerModalHooks` its types from a feature component. - eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included; the boundary contract test proves it fires. CLAUDE.md and the ui README say so. - keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
62 lines
2.3 KiB
TypeScript
62 lines
2.3 KiB
TypeScript
import type { ReactNode } from "react";
|
|
|
|
// The column contract every table-shaped view shares — `DataTable`, the playlist manager's hybrid
|
|
// table, the channel card grid — plus the filter predicate that reads it.
|
|
//
|
|
// It lives in `lib/`, not beside the header component in `components/ui/tableHeader`, because `lib/`
|
|
// helpers need it too (`sortOptions`, `useFilterState`, `channelsUrlState`) and the dependency
|
|
// direction is one-way: `lib/` may not import from `components/` (lint-enforced, see
|
|
// eslint.config.js). The contract is data + closures; nothing in it renders.
|
|
|
|
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[]);
|
|
}),
|
|
);
|
|
}
|