fix(ui): the palette ranks what you typed, and lib stops leaning on components
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`.
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
hasChannelsParams,
|
||||
type ChannelsUrlState,
|
||||
} from "./channelsUrlState";
|
||||
import type { FilterMap } from "../components/ui/tableHeader";
|
||||
import type { FilterMap } from "./tableColumns";
|
||||
|
||||
// A concrete non-default filter map exercising both wire shapes: scalars (the sync chip, a select) and
|
||||
// an array (a multi column).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// emitted, so URLs stay clean. The manager's name search is deliberately EPHEMERAL (like the feed/plex
|
||||
// rail search) and stays OUT of the shared contract.
|
||||
import { decodeSortParam, encodeSortParam, type SortState } from "./columnSort";
|
||||
import { type FilterMap } from "../components/ui/tableHeader";
|
||||
import { type FilterMap } from "./tableColumns";
|
||||
|
||||
// The content state S8 mirrors. `view` is the raw tab string (the provider clamps it); `sort` is the
|
||||
// raw provider SortState (healed against live columns only at the point of use), so the user's real
|
||||
|
||||
@@ -188,10 +188,6 @@ export function matches(e: KeyboardEvent, chord: string): boolean {
|
||||
return keyMatches(e, key);
|
||||
}
|
||||
|
||||
/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides
|
||||
* whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped
|
||||
* type over the scope's ids, so the table and the implementation cannot drift without tsc saying
|
||||
* so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */
|
||||
/** Test an event against ONE row's chords, by id. The escape hatch for a `handledLocally` row: it is
|
||||
* matched at the element level (Shift+F10 belongs to whichever row has focus, not to the window),
|
||||
* but it must still go through `matches` or the table's `keys` become decoration. They had: the
|
||||
@@ -210,6 +206,10 @@ export function matchesShortcut<S extends KeyScope>(
|
||||
return !!row?.keys.some((k) => matches(e, k));
|
||||
}
|
||||
|
||||
/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides
|
||||
* whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped
|
||||
* type over the scope's ids, so the table and the implementation cannot drift without tsc saying
|
||||
* so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */
|
||||
export function dispatch<S extends KeyScope>(
|
||||
e: KeyboardEvent,
|
||||
scope: S,
|
||||
|
||||
@@ -21,7 +21,7 @@ import { api, type Video } from "./api";
|
||||
import { qk } from "./queryKeys";
|
||||
import { patchCachedProgress } from "./progressCache";
|
||||
import type { YTNamespace, YTPlayer, YTPlayerEvent } from "./youtube";
|
||||
import type { AutoMode, LoopMode } from "../components/playerModalControls";
|
||||
import type { AutoMode, LoopMode } from "./playerModes";
|
||||
|
||||
// How close to the end (seconds) counts as "finished" → auto-mark watched.
|
||||
const FINISH_MARGIN = 10;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// Persistent playback settings of the YouTube modal player (stored in users.preferences).
|
||||
// Auto-advance = what plays when a video ends; loop = whether it repeats the current video ("one"),
|
||||
// wraps the list at its ends ("all"), or neither ("off"). Both apply to any queued player (feed or
|
||||
// playlist).
|
||||
//
|
||||
// Here rather than in `components/playerModalControls` because `lib/playerModalHooks` reads them
|
||||
// too, and `lib/` may not import from `components/` (lint-enforced, see eslint.config.js).
|
||||
export type AutoMode = "off" | "next" | "prev" | "random";
|
||||
export type LoopMode = "off" | "one" | "all";
|
||||
export const AUTO_MODES: AutoMode[] = ["off", "next", "prev", "random"];
|
||||
export const LOOP_MODES: LoopMode[] = ["off", "one", "all"];
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyView, sameOrder } from "./playlistView";
|
||||
import type { Column } from "../components/ui/tableHeader";
|
||||
import type { Column } from "./tableColumns";
|
||||
import type { Video } from "./api";
|
||||
|
||||
// The playlist's view derivation (E4 S4). What makes it worth a test: the column sort and the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Column } from "../components/ui/tableHeader";
|
||||
import { type Column } from "./tableColumns";
|
||||
import { type SortOption } from "./columnSort";
|
||||
|
||||
/** The `SortOption[]` a table exposes for sorting — its sortable columns, labelled by header. Bridges
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
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[]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -61,3 +61,36 @@ describe("components/ui/ boundary rule", () => {
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S3: one layer down, `lib/` may not import from `components/` at all. The type-only and the
|
||||
// re-export spellings are in here on purpose — both are how a lib helper most easily leans on a
|
||||
// component (`lib/sortOptions` imported `Column` from `ui/tableHeader` as a type).
|
||||
describe("lib/ boundary rule", () => {
|
||||
const AS_LIB = "src/lib/__boundary_probe.ts";
|
||||
|
||||
it("refuses components/ by every spelling: value, type-only, re-export, and from a nested lib dir", async () => {
|
||||
const source =
|
||||
`import F from "../components/Feed";\n` +
|
||||
`import type { Column } from "../components/ui/tableHeader";\n` +
|
||||
`export type { FilterMap } from "../components/ui/tableHeader";\n` +
|
||||
`export default F;\n`;
|
||||
const found = await restrictedIn(source, AS_LIB);
|
||||
const nested = await restrictedIn(
|
||||
`import M from "../../components/ui/Modal";\nexport default M;\n`,
|
||||
"src/lib/api/__boundary_probe.ts",
|
||||
);
|
||||
expect(found).toHaveLength(3);
|
||||
expect(nested).toHaveLength(1);
|
||||
expect([...found, ...nested].every((m) => m.includes("may not import from components/"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows lib's own modules and the i18n setup", async () => {
|
||||
const found = await restrictedIn(
|
||||
`import { qk } from "./queryKeys";\nimport { api } from "./api";\nimport i18n from "../i18n";\nexport default [qk, api, i18n];\n`,
|
||||
AS_LIB,
|
||||
);
|
||||
expect(found).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { getAccountRaw, setAccountRaw } from "./storage";
|
||||
import { useAccountRehydrate } from "./useAccountRehydrate";
|
||||
import type { FilterMap } from "../components/ui/tableHeader";
|
||||
import type { FilterMap } from "./tableColumns";
|
||||
|
||||
// The per-account persisted column-filter map for a DataTable whose filters are CONTROLLED by the
|
||||
// caller (so a second view of the same rows — the channel manager's card grid, the top status chips
|
||||
|
||||
Reference in New Issue
Block a user