From 78a9324381abd4378e9566029e13d7172de2339f Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 13 Sep 2026 02:14:07 +0200 Subject: [PATCH] fix(ui): the palette ranks what you typed, and lib stops leaning on components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- CLAUDE.md | 5 +- frontend/eslint.config.js | 22 ++ frontend/src/components/Channels.tsx | 2 +- frontend/src/components/ChannelsProvider.tsx | 2 +- frontend/src/components/PlayerModal.tsx | 9 +- frontend/src/components/Playlists.tsx | 10 +- .../src/components/playerModalControls.tsx | 10 +- frontend/src/components/playlistColumns.tsx | 2 +- .../components/ui/CommandPalette.dom.test.tsx | 104 ++++++++- frontend/src/components/ui/CommandPalette.tsx | 215 ++++++++++++------ frontend/src/components/ui/DataTable.tsx | 11 +- frontend/src/components/ui/README.md | 4 + frontend/src/components/ui/tableHeader.tsx | 60 +---- frontend/src/i18n/locales/en/palette.json | 2 + frontend/src/i18n/locales/hu/palette.json | 2 + frontend/src/lib/channelsUrlState.dom.test.ts | 2 +- frontend/src/lib/channelsUrlState.ts | 2 +- frontend/src/lib/keymap.ts | 8 +- frontend/src/lib/playerModalHooks.ts | 2 +- frontend/src/lib/playerModes.ts | 11 + frontend/src/lib/playlistView.test.ts | 2 +- frontend/src/lib/sortOptions.ts | 2 +- frontend/src/lib/tableColumns.ts | 61 +++++ frontend/src/lib/uiBoundary.contract.test.ts | 33 +++ frontend/src/lib/useFilterState.ts | 2 +- 25 files changed, 418 insertions(+), 167 deletions(-) create mode 100644 frontend/src/lib/playerModes.ts create mode 100644 frontend/src/lib/tableColumns.ts diff --git a/CLAUDE.md b/CLAUDE.md index af4f1665..d580fc7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 7a85a064..49b74a83 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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, ); diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index f35a436d..b965ad0c 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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"; diff --git a/frontend/src/components/ChannelsProvider.tsx b/frontend/src/components/ChannelsProvider.tsx index 4111b803..efa31cb2 100644 --- a/frontend/src/components/ChannelsProvider.tsx +++ b/frontend/src/components/ChannelsProvider.tsx @@ -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"; diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 2159391a..5de0b221 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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, diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 252cd2cc..5962d177 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -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 diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/components/playerModalControls.tsx index ddc89092..75704a85 100644 --- a/frontend/src/components/playerModalControls.tsx +++ b/frontend/src/components/playerModalControls.tsx @@ -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 diff --git a/frontend/src/components/playlistColumns.tsx b/frontend/src/components/playlistColumns.tsx index 5d01b2e3..03269c38 100644 --- a/frontend/src/components/playlistColumns.tsx +++ b/frontend/src/components/playlistColumns.tsx @@ -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`. // diff --git a/frontend/src/components/ui/CommandPalette.dom.test.tsx b/frontend/src/components/ui/CommandPalette.dom.test.tsx index 70bd113b..3a3abaca 100644 --- a/frontend/src/components/ui/CommandPalette.dom.test.tsx +++ b/frontend/src/components/ui/CommandPalette.dom.test.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(); + 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(); + 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(); + type("theme"); + expect(selected()).toBe("ch:talk"); + r.rerender(); + expect(selected()).toBe("theme:midnight"); + + press("ArrowDown"); // theme:forest, picked + r.rerender(); + 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(""); + }); +}); diff --git a/frontend/src/components/ui/CommandPalette.tsx b/frontend/src/components/ui/CommandPalette.tsx index aff1833a..0102f038 100644 --- a/frontend/src/components/ui/CommandPalette.tsx +++ b/frontend/src/components/ui/CommandPalette.tsx @@ -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(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(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" /> -
- {flat.length === 0 ? ( -

{t("palette.empty")}

- ) : ( - visible.map((g) => ( -
-
- {g.label} -
- {g.items.map((item) => ( - - ))} -
- )) - )} +
+ {announced}
+ + {/* 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. */} + + {flat.length === 0 && ( +

{t("palette.empty")}

+ )} ); } diff --git a/frontend/src/components/ui/DataTable.tsx b/frontend/src/components/ui/DataTable.tsx index 81d1efa5..36658622 100644 --- a/frontend/src/components/ui/DataTable.tsx +++ b/frontend/src/components/ui/DataTable.tsx @@ -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; diff --git a/frontend/src/components/ui/README.md b/frontend/src/components/ui/README.md index eae01500..d60901ac 100644 --- a/frontend/src/components/ui/README.md +++ b/frontend/src/components/ui/README.md @@ -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. diff --git a/frontend/src/components/ui/tableHeader.tsx b/frontend/src/components/ui/tableHeader.tsx index 1cd87b43..87aa8f9d 100644 --- a/frontend/src/components/ui/tableHeader.tsx +++ b/frontend/src/components/ui/tableHeader.tsx @@ -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` render closures. -type ColumnFilter = - | { 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 { - key: string; - header: string; - render: (row: T) => ReactNode; - align?: "left" | "right" | "center"; - width?: string; - sortable?: boolean; - sortValue?: (row: T) => string | number; - filter?: ColumnFilter; - // 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; - -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(rows: T[], columns: Column[], 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"; diff --git a/frontend/src/i18n/locales/en/palette.json b/frontend/src/i18n/locales/en/palette.json index 16963d00..1cf52bad 100644 --- a/frontend/src/i18n/locales/en/palette.json +++ b/frontend/src/i18n/locales/en/palette.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/palette.json b/frontend/src/i18n/locales/hu/palette.json index 3e00a1a6..21cca8b9 100644 --- a/frontend/src/i18n/locales/hu/palette.json +++ b/frontend/src/i18n/locales/hu/palette.json @@ -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ó", diff --git a/frontend/src/lib/channelsUrlState.dom.test.ts b/frontend/src/lib/channelsUrlState.dom.test.ts index 5ed18cdb..a91490ea 100644 --- a/frontend/src/lib/channelsUrlState.dom.test.ts +++ b/frontend/src/lib/channelsUrlState.dom.test.ts @@ -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). diff --git a/frontend/src/lib/channelsUrlState.ts b/frontend/src/lib/channelsUrlState.ts index 14cb611a..2a930f9d 100644 --- a/frontend/src/lib/channelsUrlState.ts +++ b/frontend/src/lib/channelsUrlState.ts @@ -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 diff --git a/frontend/src/lib/keymap.ts b/frontend/src/lib/keymap.ts index ee47c000..83a236a2 100644 --- a/frontend/src/lib/keymap.ts +++ b/frontend/src/lib/keymap.ts @@ -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( 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( e: KeyboardEvent, scope: S, diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index ab0a47f8..5cd77a2d 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -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; diff --git a/frontend/src/lib/playerModes.ts b/frontend/src/lib/playerModes.ts new file mode 100644 index 00000000..78b78cfc --- /dev/null +++ b/frontend/src/lib/playerModes.ts @@ -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"]; diff --git a/frontend/src/lib/playlistView.test.ts b/frontend/src/lib/playlistView.test.ts index 358a09d5..6367aedf 100644 --- a/frontend/src/lib/playlistView.test.ts +++ b/frontend/src/lib/playlistView.test.ts @@ -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 diff --git a/frontend/src/lib/sortOptions.ts b/frontend/src/lib/sortOptions.ts index 34038d2a..fefcb89c 100644 --- a/frontend/src/lib/sortOptions.ts +++ b/frontend/src/lib/sortOptions.ts @@ -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 diff --git a/frontend/src/lib/tableColumns.ts b/frontend/src/lib/tableColumns.ts new file mode 100644 index 00000000..fd8c2843 --- /dev/null +++ b/frontend/src/lib/tableColumns.ts @@ -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 = + | { 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 { + key: string; + header: string; + render: (row: T) => ReactNode; + align?: "left" | "right" | "center"; + width?: string; + sortable?: boolean; + sortValue?: (row: T) => string | number; + filter?: ColumnFilter; + // 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; + +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(rows: T[], columns: Column[], 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[]); + }), + ); +} diff --git a/frontend/src/lib/uiBoundary.contract.test.ts b/frontend/src/lib/uiBoundary.contract.test.ts index 6132915f..67673424 100644 --- a/frontend/src/lib/uiBoundary.contract.test.ts +++ b/frontend/src/lib/uiBoundary.contract.test.ts @@ -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([]); + }); +}); diff --git a/frontend/src/lib/useFilterState.ts b/frontend/src/lib/useFilterState.ts index 5685a632..29ffd1d6 100644 --- a/frontend/src/lib/useFilterState.ts +++ b/frontend/src/lib/useFilterState.ts @@ -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