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:
@@ -63,8 +63,9 @@ Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` rout
|
||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
||||
feature component or a domain API slice (`no-restricted-imports` in `eslint.config.js`, with its own
|
||||
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
|
||||
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
|
||||
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||
Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
|
||||
`lib/api/*` slices over `core.req<T>`.
|
||||
|
||||
|
||||
@@ -109,5 +109,27 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// E11 S3: the same one-way rule one layer down. `lib/` is what components are built ON (hooks,
|
||||
// the API slices, pure helpers), so it may not reach back UP into `components/` — not even for a
|
||||
// type. That is how the `Column` contract came to be owned by a ui component that three lib
|
||||
// helpers had to import from. A piece both layers need moves down into `lib/`.
|
||||
files: ["src/lib/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
// Any depth (`lib/api/*` is one level deeper), and `(/|$)` so a bare barrel counts too.
|
||||
regex: String.raw`^(\.\./)+components(/|$)`,
|
||||
message:
|
||||
"lib/ sits below components/: it may not import from components/, not even a type. Move the shared piece down into lib/.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./ui/Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "./ui/tableHeader";
|
||||
import { filterRows } from "../lib/tableColumns";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type ChannelsUrlState,
|
||||
} from "../lib/channelsUrlState";
|
||||
import { usePersistedTab } from "./ui/Tabs";
|
||||
import type { FilterMap } from "./ui/tableHeader";
|
||||
import type { FilterMap } from "../lib/tableColumns";
|
||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import type { ChannelsView } from "./Channels";
|
||||
|
||||
|
||||
@@ -5,13 +5,8 @@ import { useNavigationActions } from "./NavigationProvider";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import {
|
||||
AUTO_MODES,
|
||||
LOOP_MODES,
|
||||
PlayerTransportControls,
|
||||
type AutoMode,
|
||||
type LoopMode,
|
||||
} from "./playerModalControls";
|
||||
import { PlayerTransportControls } from "./playerModalControls";
|
||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
|
||||
@@ -62,14 +62,8 @@ import { usePlaylistActions } from "./usePlaylistActions";
|
||||
import { usePlaylists } from "./PlaylistsProvider";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import {
|
||||
ColumnHead,
|
||||
alignClass,
|
||||
filterRows,
|
||||
isActive,
|
||||
type Column,
|
||||
type FilterMap,
|
||||
} from "./ui/tableHeader";
|
||||
import { ColumnHead, alignClass } from "./ui/tableHeader";
|
||||
import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
|
||||
import { GRIP_KEY, playlistColumns } from "./playlistColumns";
|
||||
|
||||
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react";
|
||||
import type { AutoMode, LoopMode } from "../lib/playerModes";
|
||||
|
||||
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
|
||||
// the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
|
||||
|
||||
// Persistent playback settings (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).
|
||||
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"];
|
||||
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
|
||||
|
||||
/** The queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles.
|
||||
* Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Play, X } from "lucide-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Video } from "../lib/api";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import type { Column } from "./ui/tableHeader";
|
||||
import type { Column } from "../lib/tableColumns";
|
||||
|
||||
// Column definitions for the playlist detail table, in their own file like `channelColumns.tsx`.
|
||||
//
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough.
|
||||
import { useState } from "react";
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import CommandPalette, { type CommandGroup } from "./CommandPalette";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -160,3 +160,105 @@ describe("CommandPalette", () => {
|
||||
expect(ran).toEqual(["closed"]);
|
||||
});
|
||||
});
|
||||
|
||||
// The owner's case (E11 S3): typing "theme" put a channel matched only through its handle above every
|
||||
// "Theme: …" row, and Enter opened the channel.
|
||||
describe("CommandPalette · ranking", () => {
|
||||
const noop = () => {};
|
||||
const ranked = (): CommandGroup[] => [
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
items: [
|
||||
{ id: "ch:melt", label: "The Meltzone Podcast", hint: "@themeltzone", run: noop },
|
||||
{ id: "ch:talk", label: "Talk about themes", run: noop },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: "Actions",
|
||||
items: [
|
||||
{ id: "theme:midnight", label: "Theme: Midnight", run: noop },
|
||||
{ id: "theme:forest", label: "Theme: Forest", run: noop },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("ranks rows inside each group, keeps the group order, and starts on the best match", () => {
|
||||
render(<CommandPalette open onClose={noop} groups={ranked()} />);
|
||||
type("theme");
|
||||
// label elsewhere (talk) > handle only (melt); label start (midnight = forest, group order kept)
|
||||
expect(rows()).toEqual(["ch:talk", "ch:melt", "theme:midnight", "theme:forest"]);
|
||||
// The highlight is what Enter takes (same `activeIndex`); the full close→run cycle cannot run in
|
||||
// this file — see the note on "chooses the selected row on Enter".
|
||||
expect(selected()).toBe("theme:midnight");
|
||||
});
|
||||
|
||||
it("ranks by the worst word first, so every word in the label beats a label start plus a keyword", () => {
|
||||
const groups: CommandGroup[] = [
|
||||
{
|
||||
id: "channels",
|
||||
label: "Channels",
|
||||
items: [
|
||||
{ id: "weekly", label: "Podcast Weekly", keywords: "melt", run: noop },
|
||||
{ id: "melt", label: "The Meltzone Podcast", run: noop },
|
||||
],
|
||||
},
|
||||
];
|
||||
render(<CommandPalette open onClose={noop} groups={groups} />);
|
||||
type("podcast melt");
|
||||
expect(rows()).toEqual(["melt", "weekly"]);
|
||||
});
|
||||
|
||||
// The channel and playlist lists load after the palette opens — often while you are already typing.
|
||||
it("follows the best match as a list lands, but keeps a row the arrows picked", () => {
|
||||
const early: CommandGroup[] = [ranked()[0]!];
|
||||
const r = render(<CommandPalette open onClose={noop} groups={early} />);
|
||||
type("theme");
|
||||
expect(selected()).toBe("ch:talk");
|
||||
r.rerender(<CommandPalette open onClose={noop} groups={ranked()} />);
|
||||
expect(selected()).toBe("theme:midnight");
|
||||
|
||||
press("ArrowDown"); // theme:forest, picked
|
||||
r.rerender(<CommandPalette open onClose={noop} groups={[...ranked()]} />);
|
||||
expect(selected()).toBe("theme:forest");
|
||||
type("them"); // a new query goes back to following the best match
|
||||
expect(selected()).toBe("theme:midnight");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommandPalette · accessibility", () => {
|
||||
it("names each group by its heading", () => {
|
||||
open();
|
||||
const channels = screen.getByRole("group", { name: "Channels" });
|
||||
expect(
|
||||
[...channels.querySelectorAll("[role='option']")].map((o) => o.getAttribute("data-cmd-id")),
|
||||
).toEqual(["ch:1", "ch:2"]);
|
||||
expect(screen.getByRole("group", { name: "Modules" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("is expanded only while there is something to choose, and says 'nothing' outside the listbox", () => {
|
||||
open();
|
||||
expect(field().getAttribute("aria-expanded")).toBe("true");
|
||||
type("zzzz");
|
||||
expect(field().getAttribute("aria-expanded")).toBe("false");
|
||||
const listbox = screen.getByRole("listbox", { hidden: true });
|
||||
expect(listbox.hidden).toBe(true);
|
||||
// aria-controls still names a real element
|
||||
expect(document.getElementById(field().getAttribute("aria-controls")!)).toBe(listbox);
|
||||
expect(listbox.contains(screen.getByText("palette.empty"))).toBe(false);
|
||||
});
|
||||
|
||||
it("announces the result count politely, once the typing pauses", async () => {
|
||||
open();
|
||||
const status = screen.getByRole("status");
|
||||
expect(status.textContent).toBe(""); // nothing to announce on a blank field
|
||||
type("the");
|
||||
expect(status.textContent).toBe(""); // not per keystroke
|
||||
await waitFor(() => expect(status.textContent).toBe("palette.results"));
|
||||
type("zzzz");
|
||||
await waitFor(() => expect(status.textContent).toBe("palette.empty"));
|
||||
type("");
|
||||
expect(status.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,14 +40,37 @@ function fold(s: string): string {
|
||||
.replace(/\p{Diacritic}/gu, "");
|
||||
}
|
||||
|
||||
/** Every query word must appear somewhere in the item's text. Word-wise rather than substring so
|
||||
* "thevr pod" finds "TheVR Podcast" — the way you actually half-remember a name. */
|
||||
function matchesQuery(item: CommandItem, groupLabel: string, words: string[]): boolean {
|
||||
if (words.length === 0) return true;
|
||||
const hay = fold(`${item.label} ${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
|
||||
return words.every((w) => hay.includes(w));
|
||||
/** How well an item answers the query: `[worst word, sum of words]`, lower is better. */
|
||||
type Rank = readonly [number, number];
|
||||
|
||||
const byRank = (a: Rank, b: Rank) => a[0] - b[0] || a[1] - b[1];
|
||||
|
||||
/** Every query word must appear somewhere in the item's text — word-wise rather than substring, so
|
||||
* "thevr pod" finds "TheVR Podcast", the way you actually half-remember a name. `null` = no match.
|
||||
*
|
||||
* Each word scores 0 when the label STARTS with it, 1 when it is elsewhere in the label, 2 when only
|
||||
* the hint, the hidden keywords or the group name carry it. An item ranks by its worst word first, so
|
||||
* a row whose label holds every word beats one that needed its handle for any of them. Before this
|
||||
* the list was pure group order: "theme" put the channel "The Meltzone Podcast" — matched through its
|
||||
* handle `@themeltzone` alone — above every "Theme: …" row, and Enter opened a channel (E11 S3). */
|
||||
function rankItem(item: CommandItem, groupLabel: string, words: string[]): Rank | null {
|
||||
const label = fold(item.label);
|
||||
const rest = fold(`${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
|
||||
let worst = 0;
|
||||
let sum = 0;
|
||||
for (const w of words) {
|
||||
const score = label.startsWith(w) ? 0 : label.includes(w) ? 1 : rest.includes(w) ? 2 : -1;
|
||||
if (score < 0) return null;
|
||||
worst = Math.max(worst, score);
|
||||
sum += score;
|
||||
}
|
||||
return [worst, sum];
|
||||
}
|
||||
|
||||
/** The result count reaches a screen reader only once the typing pauses: a polite region rewritten on
|
||||
* every keystroke queues one announcement per letter. */
|
||||
const ANNOUNCE_DELAY_MS = 400;
|
||||
|
||||
export default function CommandPalette({
|
||||
open,
|
||||
onClose,
|
||||
@@ -59,8 +82,12 @@ export default function CommandPalette({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const listId = useId();
|
||||
const groupId = useId();
|
||||
const [query, setQuery] = useState("");
|
||||
const [active, setActive] = useState(0);
|
||||
// The row the arrows or the pointer picked, by id; `null` follows the best match. An id rather than
|
||||
// an index because the lists arrive asynchronously (channels, playlists load on open), and an index
|
||||
// would quietly point at a different row once they land.
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
// Ref on the highlighted row rather than a selector lookup: item ids come from domain data
|
||||
// (playlist names, channel handles) and would need escaping to be safe in a query string.
|
||||
const activeRef = useRef<HTMLButtonElement | null>(null);
|
||||
@@ -70,26 +97,58 @@ export default function CommandPalette({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
setPicked(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const visible = useMemo(() => {
|
||||
// The GROUP order stays fixed — modules, channels, playlists, views, actions read the same every
|
||||
// time — and the rows are ranked inside each group. The best row overall starts highlighted, so
|
||||
// Enter takes the best match even when it sits in a later group.
|
||||
const { visible, bestId } = useMemo(() => {
|
||||
const words = fold(query.trim()).split(/\s+/).filter(Boolean);
|
||||
return groups
|
||||
.map((g) => ({ ...g, items: g.items.filter((i) => matchesQuery(i, g.label, words)) }))
|
||||
.filter((g) => g.items.length > 0);
|
||||
const out: CommandGroup[] = [];
|
||||
let best: { id: string; rank: Rank } | null = null;
|
||||
for (const g of groups) {
|
||||
const ranked: { item: CommandItem; rank: Rank }[] = [];
|
||||
for (const item of g.items) {
|
||||
const rank = rankItem(item, g.label, words);
|
||||
if (rank) ranked.push({ item, rank });
|
||||
}
|
||||
if (ranked.length === 0) continue;
|
||||
// `sort` is stable, so equal ranks keep the group's own order (modules in nav-rail order).
|
||||
ranked.sort((a, b) => byRank(a.rank, b.rank));
|
||||
const top = ranked[0]!;
|
||||
if (!best || byRank(top.rank, best.rank) < 0) best = { id: top.item.id, rank: top.rank };
|
||||
out.push({ ...g, items: ranked.map((r) => r.item) });
|
||||
}
|
||||
return { visible: out, bestId: best?.id };
|
||||
}, [groups, query]);
|
||||
|
||||
// One flat list behind the grouped rendering — arrow keys walk results, not sections.
|
||||
const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]);
|
||||
const activeId = flat[Math.min(active, flat.length - 1)]?.id;
|
||||
const activeId = picked !== null && flat.some((i) => i.id === picked) ? picked : bestId;
|
||||
const activeIndex = flat.findIndex((i) => i.id === activeId);
|
||||
|
||||
// Keep the highlighted row in view when the arrows walk past the fold.
|
||||
useEffect(() => {
|
||||
activeRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeId]);
|
||||
|
||||
const summary = !query.trim()
|
||||
? ""
|
||||
: flat.length === 0
|
||||
? t("palette.empty")
|
||||
: t("palette.results", { count: flat.length });
|
||||
const [announced, setAnnounced] = useState("");
|
||||
useEffect(() => {
|
||||
if (!summary) {
|
||||
setAnnounced("");
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setAnnounced(summary), ANNOUNCE_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [summary]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands
|
||||
@@ -105,9 +164,10 @@ export default function CommandPalette({
|
||||
e.preventDefault();
|
||||
if (flat.length === 0) return;
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive((i) => (Math.min(i, flat.length - 1) + step + flat.length) % flat.length);
|
||||
const from = Math.max(activeIndex, 0);
|
||||
setPicked(flat[(from + step + flat.length) % flat.length]!.id);
|
||||
} else if (e.key === "Enter") {
|
||||
const item = flat[Math.min(active, flat.length - 1)];
|
||||
const item = flat[activeIndex];
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
choose(item);
|
||||
@@ -124,72 +184,91 @@ export default function CommandPalette({
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setActive(0);
|
||||
setPicked(null);
|
||||
}}
|
||||
onKeyDown={onInputKey}
|
||||
placeholder={t("palette.placeholder")}
|
||||
aria-label={t("palette.placeholder")}
|
||||
// A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in
|
||||
// the field, so a screen reader has to be told which row is current.
|
||||
// the field, so a screen reader has to be told which row is current. `aria-expanded`
|
||||
// follows the results: with nothing to choose from there is no popup to be in.
|
||||
role="combobox"
|
||||
aria-expanded
|
||||
aria-expanded={flat.length > 0}
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
|
||||
className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div id={listId} role="listbox" className="mt-3 max-h-[50vh] overflow-y-auto">
|
||||
{flat.length === 0 ? (
|
||||
<p className="px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
|
||||
) : (
|
||||
visible.map((g) => (
|
||||
<div key={g.id} className="mb-2 last:mb-0">
|
||||
<div
|
||||
role="presentation"
|
||||
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
|
||||
>
|
||||
{g.label}
|
||||
</div>
|
||||
{g.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`${listId}-${item.id}`}
|
||||
ref={item.id === activeId ? activeRef : undefined}
|
||||
data-cmd-id={item.id}
|
||||
role="option"
|
||||
aria-selected={item.id === activeId}
|
||||
type="button"
|
||||
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
|
||||
// these rows. Leaving them tabbable put DOM focus on a row while
|
||||
// aria-activedescendant named a different one — two competing "current" options —
|
||||
// and made Tab walk every result (E10 review round 1).
|
||||
tabIndex={-1}
|
||||
onClick={() => choose(item)}
|
||||
onMouseMove={(e) => {
|
||||
// Only a pointer that actually MOVED may take the selection. After the arrow keys
|
||||
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
|
||||
// cursor position to refresh hover — which handed the highlight straight back to
|
||||
// whatever row slid under a resting cursor (E10 review round 1). The browser
|
||||
// reports zero movement for exactly that synthetic event, so ask it directly:
|
||||
// round 1 remembered the last position instead, which could not judge the FIRST
|
||||
// move of a session and went stale across a close (round 2).
|
||||
if (e.movementX === 0 && e.movementY === 0) return;
|
||||
setActive(flat.indexOf(item));
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
|
||||
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{announced}
|
||||
</div>
|
||||
|
||||
{/* Stays mounted (hidden) while empty, so the input's aria-controls never names a missing
|
||||
element; the "nothing matches" line sits OUTSIDE it, since a listbox may hold only options
|
||||
and groups. */}
|
||||
<div
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={t("palette.title")}
|
||||
hidden={flat.length === 0}
|
||||
className="mt-3 max-h-[50vh] overflow-y-auto"
|
||||
>
|
||||
{visible.map((g) => (
|
||||
<div
|
||||
key={g.id}
|
||||
role="group"
|
||||
aria-labelledby={`${groupId}-${g.id}`}
|
||||
className="mb-2 last:mb-0"
|
||||
>
|
||||
<div
|
||||
id={`${groupId}-${g.id}`}
|
||||
role="presentation"
|
||||
className="px-1 pb-1 text-xs font-semibold tracking-wide text-muted uppercase"
|
||||
>
|
||||
{g.label}
|
||||
</div>
|
||||
{g.items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
id={`${listId}-${item.id}`}
|
||||
ref={item.id === activeId ? activeRef : undefined}
|
||||
data-cmd-id={item.id}
|
||||
role="option"
|
||||
aria-selected={item.id === activeId}
|
||||
type="button"
|
||||
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
|
||||
// these rows. Leaving them tabbable put DOM focus on a row while
|
||||
// aria-activedescendant named a different one — two competing "current" options —
|
||||
// and made Tab walk every result (E10 review round 1).
|
||||
tabIndex={-1}
|
||||
onClick={() => choose(item)}
|
||||
onMouseMove={(e) => {
|
||||
// Only a pointer that actually MOVED may take the selection. After the arrow keys
|
||||
// scroll a row past the fold, Blink re-dispatches a mousemove at the unchanged
|
||||
// cursor position to refresh hover — which handed the highlight straight back to
|
||||
// whatever row slid under a resting cursor (E10 review round 1). The browser
|
||||
// reports zero movement for exactly that synthetic event, so ask it directly:
|
||||
// round 1 remembered the last position instead, which could not judge the FIRST
|
||||
// move of a session and went stale across a close (round 2).
|
||||
if (e.movementX === 0 && e.movementY === 0) return;
|
||||
setPicked(item.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
|
||||
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{flat.length === 0 && (
|
||||
<p className="mt-3 px-1 py-6 text-center text-sm text-muted">{t("palette.empty")}</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
|
||||
import Pager, { hasPagerContent } from "./Pager";
|
||||
import { PageToolbar } from "./PageShell";
|
||||
import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from "./tableHeader";
|
||||
import { ColumnHead, alignClass } from "./tableHeader";
|
||||
import { filterRows, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
|
||||
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
|
||||
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
|
||||
@@ -11,12 +12,12 @@ import { ColumnHead, alignClass, filterRows, type Column, type FilterMap } from
|
||||
// other modules can reuse it. Below `md` it falls back to a card list built from the same column
|
||||
// definitions.
|
||||
//
|
||||
// The header row itself (sort cell + filter funnel/popover + the filter predicate) lives in
|
||||
// `tableHeader.tsx` — the playlist manager renders its own drag-reorderable body under the same
|
||||
// headers without inheriting this component's ordering state.
|
||||
// The header row itself (sort cell + filter funnel/popover) lives in `tableHeader.tsx`, the column
|
||||
// contract and the filter predicate in `lib/tableColumns` — the playlist manager renders its own
|
||||
// drag-reorderable body under the same headers without inheriting this component's ordering state.
|
||||
|
||||
// Re-exported so the many `import { type Column } from "./DataTable"` call sites keep working.
|
||||
export type { Column } from "./tableHeader";
|
||||
export type { Column } from "../../lib/tableColumns";
|
||||
|
||||
interface Persisted {
|
||||
sort: SortState;
|
||||
|
||||
@@ -23,6 +23,10 @@ enforced by `no-restricted-imports` in `eslint.config.js` — not by this README
|
||||
explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
||||
supply it.
|
||||
|
||||
The direction is one-way from below, too: `lib/` may not import from `components/` — this directory
|
||||
included, not even a type (same lint file). A contract both layers need lives in `lib/`: the `Column`
|
||||
/ `FilterMap` shape and `filterRows` are `lib/tableColumns`, which `tableHeader` and `DataTable` import.
|
||||
|
||||
Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage posters), `KeyGate`
|
||||
(secure-messaging key setup), `plexCards`, `downloadRow`, `channelColumns` — all shared, none
|
||||
domain-free.
|
||||
|
||||
@@ -4,10 +4,12 @@ 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";
|
||||
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
@@ -15,58 +17,6 @@ import { type SortState } from "../../lib/columnSort";
|
||||
// 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";
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"title": "Jump to…",
|
||||
"placeholder": "Search modules, channels, playlists…",
|
||||
"empty": "Nothing matches.",
|
||||
"results": "{{count}} result",
|
||||
"results_other": "{{count}} results",
|
||||
"theme": "Theme: {{name}}",
|
||||
"videoCount": "{{count}} video",
|
||||
"videoCount_other": "{{count}} videos",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
"title": "Ugrás…",
|
||||
"placeholder": "Modul, csatorna, lista keresése…",
|
||||
"empty": "Nincs találat.",
|
||||
"results": "{{count}} találat",
|
||||
"results_other": "{{count}} találat",
|
||||
"theme": "Téma: {{name}}",
|
||||
"videoCount": "{{count}} videó",
|
||||
"videoCount_other": "{{count}} videó",
|
||||
|
||||
@@ -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