release: 0.85.0 (feature/e11-power-navigation)
This commit is contained in:
@@ -7,6 +7,31 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.85.0 — 2026-09-13
|
||||
|
||||
Siftlode can now be driven from the keyboard, and Back, dialogs and menus stop losing your place. j/k and the arrow keys walk every list and grid, Enter opens what you are on, and Back puts you on the item you opened a view from instead of at the top of the page.
|
||||
|
||||
### Features
|
||||
|
||||
- j/k and the arrow keys walk the feed, the library, a channel page, the Home rails, every Plex grid and rail, the Channels, Playlists and Downloads tables and the conversation list; Enter opens the item you are on. After you hide or delete an item, the next j goes on from where it was.
|
||||
- Back returns you to the item a view was opened from — the Plex title you opened from a Home rail, the channel you opened from its row — with the keyboard still on it.
|
||||
- Shortcuts to get around: s jumps to the search field, g-chords jump to a module, and the cheat sheet (h) names every key in the language you are reading. PageUp/PageDown move a page at a time in the command palette and in every dropdown list.
|
||||
- The command palette ranks what you typed first: “theme” now puts the Theme rows above a channel whose handle happens to contain it.
|
||||
- A card reached with Tab or j shows one ring around the whole card, and a screen reader calls it by its title.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Back and overlays keep their books straight: Back after navigating over About also closes About, a late clean-up no longer undoes the navigation you just made, and Shift+W on Home closes the player.
|
||||
- Dialogs and menus hand the keyboard back where it came from. Escape from a card’s context menu returns to the card, not its thumbnail link; closing the player under another dialog no longer leaves the page unscrollable; a button that is busy with a request keeps the focus instead of dropping it to the page.
|
||||
- A write that fails says so, with the reason — several saves, share links and admin actions used to fail silently or in English under the Hungarian interface.
|
||||
- A split video export that fails part-way (the job limit) tells you how many clips were added and leaves only the rest selected, so trying again does not queue the first ones twice.
|
||||
- “Feed” is called Feed everywhere, in both languages.
|
||||
|
||||
### Internal
|
||||
|
||||
- Five sprints: history integrity, one dialog/focus contract, palette ranking and accessibility, the keyboard layer, and a Hungarian/English keyboard-only pass. Focus and event-order questions were measured in a real browser before each fix, not argued.
|
||||
- Three epic-end review rounds — 19 → 14, 18 → 12 and a targeted 14 → 14 — each regression test mutation-checked. The last round's findings all sat inside the previous round's own fixes, which is where the loop was closed.
|
||||
|
||||
## 0.84.0 — 2026-09-12
|
||||
|
||||
A bug-fix release, from a list you wrote while using Siftlode. Two of these were destroying your own data: a YouTube video rewound itself when you reopened it, and clearing a resume point on a half-watched Plex item told Plex nothing. Subtitles now appear in seconds instead of minutes, the Plex player's bitrate is a real measurement rather than a guess, and a channel you turn full history on for fills in front of you instead of looking frozen.
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -89,8 +90,11 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
|
||||
"en": (
|
||||
"Hi,\n\n"
|
||||
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube "
|
||||
"subscriptions. Every upload from the channels you follow lands in one feed you can "
|
||||
"subscriptions. Every upload from the channels you follow lands in one Feed you can "
|
||||
"actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
|
||||
"livestreams unless you want them.\n\n"
|
||||
"{next_step}\n\n"
|
||||
@@ -186,7 +186,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
|
||||
"Szia,\n\n"
|
||||
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó "
|
||||
"a YouTube-feliratkozásaidhoz. A követett csatornák minden feltöltése egyetlen "
|
||||
"hírfolyamba érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
|
||||
"Feedbe érkezik, amit tényleg tudsz szűrni és rendezni; nincs algoritmus, ami "
|
||||
"eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
|
||||
"{next_step}\n\n"
|
||||
"Nem sürget semmi — az ajtó nyitva marad.\n\n"
|
||||
|
||||
@@ -46,7 +46,7 @@ WELCOME = {
|
||||
"This is your private message inbox. Conversations with other members are end-to-end "
|
||||
"encrypted — only you and the person you're chatting with can read them, not even an "
|
||||
"admin. To start messaging someone, set up secure messaging with a passphrase first.\n\n"
|
||||
"Enjoy your feed!"
|
||||
"Enjoy your Feed!"
|
||||
),
|
||||
"hu": (
|
||||
"Üdv a Siftlode-on! 👋\n\n"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
// The global Shift+W wiring, pinned at its call site.
|
||||
//
|
||||
// What this is: a source contract, the same idiom as uiBoundary.contract.test.ts — a structural rule
|
||||
// asserted directly rather than left to prose. What it is NOT: a behaviour test. The behaviour lives
|
||||
// in history.dom.test.ts and history.burial.dom.test.ts, which drive `navigateOverOverlays` against
|
||||
// real overlay entries; the end-to-end gesture (Shift+W over an open player, then Back) is a browser
|
||||
// check, because jsdom reproduces history ordering only as bookkeeping.
|
||||
//
|
||||
// It exists because the composition line itself was unpinned: App.tsx is the one module no test
|
||||
// renders (main.tsx lazy-imports it and nothing else does), globalKeymap.dom.test.tsx drives the
|
||||
// hook with its OWN handler, and history.dom.test.ts drives the helper with its own navigate — so
|
||||
// dropping `navigateOverOverlays` from App left all 818 tests green while the user-visible bug came
|
||||
// straight back (round 2, verified by running that revert).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const APP = readFileSync(fileURLToPath(new URL("./App.tsx", import.meta.url)), "utf8");
|
||||
|
||||
describe("App · global keymap wiring", () => {
|
||||
it("routes the global home shortcut through navigateOverOverlays", () => {
|
||||
const call = /useGlobalKeymap\(([\s\S]*?)\);/.exec(APP)?.[1];
|
||||
// A rename or a move is a legitimate change — but then this test is the thing that has to be
|
||||
// updated deliberately, which is the whole point of pinning it.
|
||||
expect(
|
||||
call,
|
||||
"App.tsx no longer calls useGlobalKeymap in a shape this test can read",
|
||||
).toBeTypeOf("string");
|
||||
expect(call).toContain("navigateOverOverlays");
|
||||
expect(call).toContain('setPage("home")');
|
||||
});
|
||||
});
|
||||
+22
-13
@@ -3,6 +3,7 @@ import { qk } from "./lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
|
||||
import { notifyActionError } from "./lib/actionErrors";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import type { Page } from "./lib/urlState";
|
||||
@@ -22,7 +23,7 @@ import { useNavigation, useNavigationActions } from "./components/NavigationProv
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
|
||||
import { useChannelLayout } from "./components/ChannelsProvider";
|
||||
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
|
||||
import { moduleDef } from "./lib/modules";
|
||||
import { moduleDef, moduleOrder } from "./lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
|
||||
// Persistent shell — always mounted, so eagerly imported.
|
||||
import Welcome from "./components/Welcome";
|
||||
@@ -37,7 +38,6 @@ import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import { navigateOverOverlays } from "./lib/history";
|
||||
import { StateMessage } from "./components/ui/QueryState";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
@@ -98,7 +98,9 @@ export default function App() {
|
||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||
saveLayoutLocal(panel, next);
|
||||
api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {});
|
||||
api
|
||||
.savePrefs({ [PREF_KEY[panel]]: next })
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
@@ -142,13 +144,16 @@ export default function App() {
|
||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
|
||||
// above the signed-out branch is worse than not binding it.
|
||||
// Through `navigateOverOverlays`, because a global key can fire while an overlay is open and does
|
||||
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile
|
||||
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the
|
||||
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not
|
||||
// decoration: pressed while ALREADY on Home (the default landing page) it navigates nowhere, and
|
||||
// burying the open player's entry under a push that never happened orphaned it — see `buryUnder`.
|
||||
useGlobalKeymap(!!meQuery.data, () => navigateOverOverlays(() => setPage("home")));
|
||||
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
||||
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||
useGlobalKeymap(
|
||||
!!meQuery.data,
|
||||
setPage,
|
||||
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
||||
);
|
||||
|
||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
||||
// an account (it was riding the session's default) would silently swap identity when ANOTHER
|
||||
@@ -279,8 +284,8 @@ export default function App() {
|
||||
label: t("common.accessRequestsReview"),
|
||||
onClick: () => {
|
||||
focusAccessRequestsTab();
|
||||
// Over whatever overlay the toast was clicked in — as navigateTo does (see lib/nav).
|
||||
navigateOverOverlays(() => setPage("users"));
|
||||
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
||||
setPage("users");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -291,7 +296,11 @@ export default function App() {
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
setLanguage(code);
|
||||
api.savePrefs({ language: code }).catch(() => {});
|
||||
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
|
||||
// the reporter stays quiet there.
|
||||
api
|
||||
.savePrefs({ language: code })
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
||||
}
|
||||
|
||||
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 focus-visible:outline-offset-2"
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
|
||||
@@ -7,8 +7,9 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
@@ -66,25 +67,19 @@ export default function AccountPanel({
|
||||
const issue = useMutation({
|
||||
mutationFn: () => api.createSetPasswordLink(user.id),
|
||||
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }),
|
||||
onError: () => notify({ level: "error", message: t("users.account.linkFailed") }),
|
||||
onError: (err) => notifyActionError(err, t("users.account.linkFailed")),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
mutationFn: () => api.resendUserVerification(user.id),
|
||||
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: () => api.activateUser(user.id),
|
||||
onSuccess: () => invalidateUserAccess(qc, user.id),
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
|
||||
});
|
||||
|
||||
const copy = async () => {
|
||||
@@ -201,7 +196,9 @@ export default function AccountPanel({
|
||||
|
||||
<button
|
||||
onClick={() => resend.mutate()}
|
||||
disabled={!a.email_configured || a.email_verified || resend.isPending}
|
||||
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
||||
// it refuses it a set-password link below (deep-review E11 round 3).
|
||||
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||
>
|
||||
<Send className="w-4 h-4 shrink-0" />
|
||||
|
||||
@@ -86,7 +86,8 @@ describe("a user action that fails", () => {
|
||||
await waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith({
|
||||
level: "error",
|
||||
message: i18n.t("errors.notFound.user"), // the request layer localizes a structured detail
|
||||
title: "users.roles.failed", // what failed…
|
||||
message: i18n.t("errors.notFound.user"), // …and why: the request layer localizes a structured detail
|
||||
}),
|
||||
);
|
||||
await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type PendingAccessRow,
|
||||
} from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
@@ -38,7 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import Tabs, { usePersistedTab } from "./ui/Tabs";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
@@ -319,12 +319,6 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Did the invitation fail because this instance has no working SMTP? */
|
||||
function inviteEmailOff(err: HttpError): boolean {
|
||||
const d = err.detailData as { code?: string; reason?: string } | null | undefined;
|
||||
return d?.code === "invite_send" && d.reason === "email_off";
|
||||
}
|
||||
|
||||
// --- Sign-ups that are still outside (roadmap UX-25) --------------------------------------------
|
||||
//
|
||||
// The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
|
||||
@@ -351,7 +345,7 @@ function PendingAccess() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("users.pendingAccess.activateFailed") }),
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.activateFailed")),
|
||||
});
|
||||
|
||||
const resend = useMutation({
|
||||
@@ -360,15 +354,9 @@ function PendingAccess() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) });
|
||||
},
|
||||
// The public resend is deliberately silent; this one is not, so say WHY it failed — an instance
|
||||
// with no working SMTP would otherwise show a success toast for a mail that was never sent.
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message: isEmailOff(err)
|
||||
? t("users.pendingAccess.resendEmailOff")
|
||||
: t("users.pendingAccess.resendFailed"),
|
||||
}),
|
||||
// The public resend is deliberately silent; this one is not, and the server's reason says WHY
|
||||
// (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
|
||||
onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
|
||||
});
|
||||
|
||||
const list = rows.data ?? [];
|
||||
@@ -563,12 +551,8 @@ function AdminInvites() {
|
||||
// A 409 means the list was stale — someone registered on that address since it loaded. Say
|
||||
// what to do instead of a generic failure, and refetch so the row re-renders as read-only.
|
||||
const conflict = e instanceof HttpError && e.status === 409;
|
||||
notify({
|
||||
level: "error",
|
||||
message: t(
|
||||
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
|
||||
),
|
||||
});
|
||||
if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount"));
|
||||
else notifyActionError(e, t("settings.invites.deleteFailed"));
|
||||
if (conflict) refresh();
|
||||
},
|
||||
});
|
||||
@@ -598,14 +582,7 @@ function AdminInvites() {
|
||||
refresh();
|
||||
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
|
||||
},
|
||||
onError: (err) =>
|
||||
notify({
|
||||
level: "error",
|
||||
message:
|
||||
err instanceof HttpError && inviteEmailOff(err)
|
||||
? t("settings.invites.inviteEmailOff")
|
||||
: t("settings.invites.inviteFailed"),
|
||||
}),
|
||||
onError: (err) => notifyActionError(err, t("settings.invites.inviteFailed")),
|
||||
});
|
||||
// The one recipient the app has no language signal about: the admin typed this address in, so
|
||||
// nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
|
||||
|
||||
@@ -108,9 +108,13 @@ export default function ChannelDiscovery({
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSubscribe(c)}
|
||||
disabled={!canWrite || subscribe.isPending}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
||||
// Busy blocks the click, not the focus: a focused button that turns natively `disabled`
|
||||
// drops the keyboard to <body> (the rule and its measurement: ui/Button).
|
||||
onClick={() => !subscribe.isPending && onSubscribe(c)}
|
||||
disabled={!canWrite}
|
||||
aria-disabled={subscribe.isPending || undefined}
|
||||
aria-busy={subscribe.isPending || undefined}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("channels.discovery.subscribe")}
|
||||
@@ -238,6 +242,7 @@ export default function ChannelDiscovery({
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
emptyText={t("channels.discovery.empty")}
|
||||
keyboardNav
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel manager's card layout as a keyboard list (E11 S4c): each card is an item, and Enter opens
|
||||
// the channel through the name ChannelLink renders. VirtualGrid is replaced by a stand-in that mounts
|
||||
// every item — jsdom has no layout for the virtualizer to measure — and registers with listNav exactly
|
||||
// when the real one would: when it is asked to (`keyboardNav`).
|
||||
import { Fragment, useRef } from "react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import type { VirtualGridProps } from "./ui/VirtualGrid";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./ui/VirtualGrid", async () => {
|
||||
const { useListNav } = await import("../lib/listNav");
|
||||
return {
|
||||
default: function GridStandIn({
|
||||
items,
|
||||
getKey,
|
||||
renderItem,
|
||||
keyboardNav,
|
||||
}: VirtualGridProps<unknown>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useListNav(ref, { enabled: !!keyboardNav });
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={getKey(item)}>{renderItem(item, 0, i)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import { type Column } from "./ui/DataTable";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Ch = { id: string; title: string };
|
||||
const rows: Ch[] = [
|
||||
{ id: "UCa", title: "Alpha" },
|
||||
{ id: "UCb", title: "Beta" },
|
||||
];
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const focusedTitle = () =>
|
||||
(document.activeElement as HTMLElement).querySelector("[data-nav-open]")?.textContent;
|
||||
|
||||
describe("ChannelLayoutGrid · keyboard", () => {
|
||||
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||
const opened: string[] = [];
|
||||
const columns: Column<Ch>[] = [
|
||||
{
|
||||
key: "channel",
|
||||
header: "Channel",
|
||||
cardPrimary: true,
|
||||
render: (c) => (
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
title={c.title}
|
||||
thumbnailUrl={null}
|
||||
onView={() => opened.push(c.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
render(<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} />);
|
||||
press("j");
|
||||
expect(focusedTitle()).toBe("Alpha");
|
||||
press("j");
|
||||
expect(focusedTitle()).toBe("Beta");
|
||||
press("Enter");
|
||||
expect(opened).toEqual(["UCb"]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { type Column } from "./ui/DataTable";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||
@@ -12,6 +13,9 @@ import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
// Named slots place the columns that need bespoke positions; everything else flows in the meta line.
|
||||
// Unlike DataTable's cramped narrow-screen card fallback, the desktop card is roomy, so it shows the
|
||||
// `hideInCard` columns too (About, priority) — the user wants those affordances in every layout.
|
||||
//
|
||||
// Each card is a keyboard item (lib/listNav): `j`/`k` and the arrows walk the cards, and Enter opens
|
||||
// the channel — the name ChannelLink renders is the card's open element.
|
||||
|
||||
interface ChannelLayoutGridProps<T> {
|
||||
rows: T[];
|
||||
@@ -52,9 +56,10 @@ export default function ChannelLayoutGrid<T>({
|
||||
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||
|
||||
const renderCard = (row: T) => (
|
||||
const renderCard = (row: T, index: number) => (
|
||||
<div
|
||||
{...rowProps?.(row)}
|
||||
{...navItemAttrs(index)}
|
||||
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -91,7 +96,8 @@ export default function ChannelLayoutGrid<T>({
|
||||
minCol={spec.minCol}
|
||||
colMaxWidth={null}
|
||||
rowEst={spec.rowEst}
|
||||
renderItem={renderCard}
|
||||
keyboardNav
|
||||
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ export default function ChannelLink({
|
||||
{onView ? (
|
||||
<button
|
||||
onClick={onView}
|
||||
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
||||
// card clicks this.
|
||||
data-nav-open=""
|
||||
onAuxClick={(e) => {
|
||||
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps
|
||||
// opening the in-app channel detail.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { urlWithParams } from "../lib/urlState";
|
||||
// The href is the same URL `openChannel` pushes — `?chan=<id>` on the current page — so a new tab
|
||||
// lands where the in-app navigation would have. A plain left click is still handled in-app, because
|
||||
// the SPA path does more than navigate (the player has to close first and reclaim its own
|
||||
// Back-to-close history entry; see closeThenNavigate).
|
||||
// Back-to-close history entry; see lib/history.closeOverlaysThenNavigate).
|
||||
type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
|
||||
channelId: string;
|
||||
title: string | null | undefined;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel page's Block and Hide toggles (deep-review E11 round 2) and its Subscribe / Unsubscribe
|
||||
// button (round 3: the same row, missed) while their request runs. A
|
||||
// focused button that turns natively `disabled` drops the keyboard to <body> in Chrome (measured for
|
||||
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||
// still pressed only once.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type Me } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const CH = "UCsXVk37bltHxD1rDPwtNM8Q";
|
||||
|
||||
describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||
it.each([
|
||||
["channel.block", "blockChannel", true],
|
||||
["channel.hide", "updateChannel", true],
|
||||
["channel.subscribe", "subscribeChannel", false],
|
||||
["channel.subscribedState", "unsubscribeChannel", true],
|
||||
] as const)(
|
||||
"%s stays focusable and fires once while its request runs",
|
||||
async (label, call, subscribed) => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const pending = vi.spyOn(api, call).mockReturnValue(new Promise(() => {}) as never);
|
||||
render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const button = await screen.findByRole("button", { name: label });
|
||||
act(() => button.focus());
|
||||
fireEvent.click(button);
|
||||
await waitFor(() => expect(button.getAttribute("aria-disabled")).toBe("true"));
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
expect(document.activeElement).toBe(button);
|
||||
fireEvent.click(button);
|
||||
expect(pending).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
@@ -106,18 +107,23 @@ export default function ChannelPage({
|
||||
const runExplore = useCallback(
|
||||
(token?: string | null) => {
|
||||
setExploring(true);
|
||||
return api
|
||||
.exploreChannel(channelId, token ?? undefined)
|
||||
.then((r) => {
|
||||
setNextToken(r.next_page_token);
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
|
||||
// first visit (the GET that drove this render predated the explore that just ran).
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
})
|
||||
.finally(() => setExploring(false));
|
||||
return (
|
||||
api
|
||||
.exploreChannel(channelId, token ?? undefined)
|
||||
.then((r) => {
|
||||
setNextToken(r.next_page_token);
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
|
||||
// first visit (the GET that drove this render predated the explore that just ran).
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
})
|
||||
// Both the automatic first-visit explore and "load more" used to fail in silence: an empty
|
||||
// channel page that looked like a channel with no videos (E11 S2b).
|
||||
.catch((err: unknown) => notifyActionError(err, t("errors.writeFailed.exploreChannel")))
|
||||
.finally(() => setExploring(false))
|
||||
);
|
||||
},
|
||||
[channelId, qc],
|
||||
[channelId, qc, t],
|
||||
);
|
||||
|
||||
// First visit of an un-subscribed channel → ingest its recent uploads. Skipped for the demo
|
||||
@@ -129,7 +135,7 @@ export default function ChannelPage({
|
||||
if (ch.subscribed || ch.blocked) return;
|
||||
if (ch.explored && ch.stored_videos > 0) return;
|
||||
autoTried.current = channelId;
|
||||
runExplore().catch(() => {});
|
||||
void runExplore();
|
||||
}, [ch, channelId, me.is_demo, runExplore]);
|
||||
|
||||
const block = useMutation({
|
||||
@@ -139,15 +145,16 @@ export default function ChannelPage({
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||
},
|
||||
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case),
|
||||
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) —
|
||||
// a caller toast here would just double it.
|
||||
// No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
|
||||
// reporter names the failure the error dialog does not cover (a 404 for a channel gone).
|
||||
meta: { failTitle: "errors.writeFailed.blockChannel" },
|
||||
});
|
||||
|
||||
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}).
|
||||
// Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
|
||||
const toggleHidden = useMutation({
|
||||
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
|
||||
meta: { failTitle: "errors.writeFailed.hideChannel" },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
@@ -472,14 +479,18 @@ export default function ChannelPage({
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<button
|
||||
onClick={() => block.mutate()}
|
||||
disabled={block.isPending}
|
||||
// Busy blocks the click, not the focus: a focused button that turns natively
|
||||
// `disabled` drops the keyboard to <body> (the rule and its measurement: ui/Button).
|
||||
// Nor does a busy toggle light up on hover, as if a click would do something.
|
||||
onClick={() => !block.isPending && block.mutate()}
|
||||
aria-disabled={block.isPending || undefined}
|
||||
aria-busy={block.isPending || undefined}
|
||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
ch?.blocked
|
||||
? "border-danger text-danger"
|
||||
: "border-border text-muted hover:text-danger hover:border-danger"
|
||||
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
|
||||
}`}
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
@@ -490,14 +501,15 @@ export default function ChannelPage({
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<button
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
disabled={toggleHidden.isPending}
|
||||
onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
|
||||
aria-disabled={toggleHidden.isPending || undefined}
|
||||
aria-busy={toggleHidden.isPending || undefined}
|
||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border disabled:opacity-50 transition ${
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
ch.hidden
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-fg/40"
|
||||
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
@@ -509,7 +521,7 @@ export default function ChannelPage({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onUnsubscribe}
|
||||
disabled={unsubscribe.isPending}
|
||||
loading={unsubscribe.isPending}
|
||||
title={t("channel.unsubscribe")}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
>
|
||||
@@ -519,7 +531,7 @@ export default function ChannelPage({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => subscribe.mutate()}
|
||||
disabled={subscribe.isPending}
|
||||
loading={subscribe.isPending}
|
||||
icon={<Plus className="w-4 h-4" />}
|
||||
>
|
||||
{t("channel.subscribe")}
|
||||
@@ -566,9 +578,11 @@ export default function ChannelPage({
|
||||
{!ch?.subscribed && nextToken && (
|
||||
<div className="flex justify-center pb-6">
|
||||
<button
|
||||
onClick={() => runExplore(nextToken)}
|
||||
disabled={exploring}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50"
|
||||
// Busy blocks the click, not the focus (see Block above).
|
||||
onClick={() => !exploring && runExplore(nextToken)}
|
||||
aria-disabled={exploring || undefined}
|
||||
aria-busy={exploring || undefined}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
|
||||
>
|
||||
{exploring ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -32,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";
|
||||
@@ -110,6 +109,7 @@ export default function Channels() {
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
const unblock = useMutation({
|
||||
mutationFn: (id: string) => api.unblockChannel(id),
|
||||
meta: { failTitle: "errors.writeFailed.unblockChannel" },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.blockedChannels() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
@@ -133,6 +133,7 @@ export default function Channels() {
|
||||
id: string;
|
||||
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
|
||||
}) => api.updateChannel(v.id, v.body),
|
||||
meta: { failTitle: "errors.writeFailed.channelSettings" },
|
||||
// Requesting full history may have just pulled in a channel's recent uploads, so
|
||||
// refresh the per-user status and feed too — not only the channel list.
|
||||
onSuccess: () => {
|
||||
@@ -182,11 +183,10 @@ export default function Channels() {
|
||||
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
|
||||
onError: (e) => {
|
||||
// must come back rather than leaving an empty cell (honest states). The hook reports the failure.
|
||||
onError: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
notifyActionError(e, t("channels.tags.updateFailed"));
|
||||
},
|
||||
});
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
@@ -623,6 +623,7 @@ export default function Channels() {
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
keyboardNav
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useSortState } from "../lib/useSortState";
|
||||
import { useFilterState } from "../lib/useFilterState";
|
||||
import { useActiveAccountId } from "../lib/activeAccount";
|
||||
import { useUrlMirror } from "../lib/useUrlMirror";
|
||||
import { rememberNavPosition } from "../lib/history";
|
||||
import {
|
||||
channelsParamsToState,
|
||||
channelsParamUpdates,
|
||||
@@ -23,7 +24,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";
|
||||
|
||||
@@ -243,6 +244,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
|
||||
if (window.history.state?.sfPage === "channels") {
|
||||
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make
|
||||
// Back re-open the channel overlay or a spent search on top of the tab.
|
||||
rememberNavPosition();
|
||||
window.history.pushState({ sfPage: "channels", _chTab: v }, "");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,9 +30,11 @@ const GROUP_ORDER = [
|
||||
"backfill",
|
||||
"shorts",
|
||||
"batch",
|
||||
"maintenance",
|
||||
"downloads",
|
||||
"audit",
|
||||
"plex",
|
||||
"tmdb",
|
||||
];
|
||||
|
||||
export default function ConfigPanel() {
|
||||
@@ -111,9 +113,9 @@ export default function ConfigPanel() {
|
||||
setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
|
||||
setSaveState("saved");
|
||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
setSaveState("error");
|
||||
notify({ level: "error", message: t("config.saveFailed") });
|
||||
notifyActionError(e, t("config.saveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useConfirm } from "./ConfirmProvider";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||
import { inputCls } from "./ui/form";
|
||||
@@ -41,6 +42,7 @@ import { Button } from "./ui/Button";
|
||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||
import { AdminSystem } from "./downloadAdmin";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
|
||||
export default function DownloadCenter() {
|
||||
const me = useMe();
|
||||
@@ -95,14 +97,16 @@ export default function DownloadCenter() {
|
||||
? api.redownloadDownload(id)
|
||||
: api.deleteDownload(id),
|
||||
onSuccess: invalidate,
|
||||
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global
|
||||
// error modal (api.ts req) with the backend's specific message — a caller toast would double it.
|
||||
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
|
||||
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
|
||||
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
|
||||
});
|
||||
|
||||
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
||||
const removeShared = useMutation({
|
||||
mutationFn: (id: number) => api.removeSharedDownload(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
|
||||
meta: { failTitle: "errors.writeFailed.removeShared" },
|
||||
});
|
||||
|
||||
const confirmThen = async (title: string, body: string, fn: () => void) => {
|
||||
@@ -222,9 +226,9 @@ export default function DownloadCenter() {
|
||||
{t("downloads.page.upload")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{queue.map((job) => (
|
||||
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}>
|
||||
<NavGrid className="space-y-2">
|
||||
{queue.map((job, i) => (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
|
||||
{(job.status === "queued" || job.status === "running") && (
|
||||
<IconBtn
|
||||
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
||||
@@ -272,21 +276,25 @@ export default function DownloadCenter() {
|
||||
{t("downloads.empty.queue")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "done" && (
|
||||
<>
|
||||
<UsageBar />
|
||||
<div className="space-y-2">
|
||||
{library.map((job) => {
|
||||
<NavGrid className="space-y-2">
|
||||
{library.map((job, i) => {
|
||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||
const a = libraryRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
@@ -355,19 +363,23 @@ export default function DownloadCenter() {
|
||||
{t("downloads.empty.done")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "shared" && (
|
||||
<div className="space-y-2">
|
||||
{(sharedQ.data ?? []).map((job) => {
|
||||
<NavGrid className="space-y-2">
|
||||
{(sharedQ.data ?? []).map((job, i) => {
|
||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||
const a = sharedRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job}>
|
||||
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||
{a.play && (
|
||||
<IconBtn onClick={() => setPlayJob(job)} title={t("downloads.actions.play")}>
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
@@ -405,7 +417,7 @@ export default function DownloadCenter() {
|
||||
{t("downloads.empty.shared")}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
)}
|
||||
|
||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { invalidateDownloads } from "../lib/downloads";
|
||||
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { inputCls } from "./ui/form";
|
||||
|
||||
@@ -51,8 +52,11 @@ export default function DownloadDialog({
|
||||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
|
||||
} catch (e) {
|
||||
// A quota or validation refusal (422/409) is already in the global error dialog, which the
|
||||
// reporter skips; a 403/404/429 or a lost connection is not, and used to leave the dialog
|
||||
// open with nothing said (E11 S2b).
|
||||
notifyActionError(e, t("errors.writeFailed.enqueueDownload"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The Hidden / Blocked channel cards as a keyboard list (E11 S4c): `j`/`k` walk the cards, Enter opens
|
||||
// the channel — the ChannelLink name is the card's open element — and the card's own action button
|
||||
// keeps its Enter.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const channels = [
|
||||
{ id: "UCa", title: "Alpha", thumbnail_url: null },
|
||||
{ id: "UCb", title: "Beta", thumbnail_url: null },
|
||||
];
|
||||
|
||||
describe("ExcludedChannelList · keyboard", () => {
|
||||
it("walks the cards with j/k and opens the channel on Enter", () => {
|
||||
const onView = vi.fn();
|
||||
render(
|
||||
<ExcludedChannelList
|
||||
channels={channels}
|
||||
icon={null}
|
||||
title="Hidden"
|
||||
hint=""
|
||||
emptyText=""
|
||||
actionLabel="Unhide"
|
||||
actionIcon={null}
|
||||
onAction={() => {}}
|
||||
onView={onView}
|
||||
/>,
|
||||
);
|
||||
press("j");
|
||||
press("j");
|
||||
expect(document.activeElement?.textContent).toContain("Beta");
|
||||
press("k");
|
||||
expect(document.activeElement?.textContent).toContain("Alpha");
|
||||
press("Enter");
|
||||
expect(onView).toHaveBeenCalledWith(channels[0]);
|
||||
expect(screen.getAllByRole("button", { name: "Unhide" })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { type ReactNode } from "react";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
||||
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||
@@ -40,9 +42,13 @@ export default function ExcludedChannelList({
|
||||
{icon}
|
||||
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((c) => (
|
||||
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2">
|
||||
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
{...navItemAttrs(i)}
|
||||
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelLink
|
||||
id={c.id}
|
||||
@@ -63,7 +69,7 @@ export default function ExcludedChannelList({
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
<p className="text-2xs text-muted mt-3">{hint}</p>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
|
||||
import i18n from "../i18n";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify, resolveVideo } from "../lib/notifications";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import {
|
||||
@@ -145,7 +146,7 @@ export default function Feed({
|
||||
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }),
|
||||
[],
|
||||
);
|
||||
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown/scroll-lock effect
|
||||
// Stable identity, like Playlists' `closePlayer`: PlayerModal's keydown effect
|
||||
// depends on `onClose`, so a fresh closure every Feed render re-subscribed those listeners,
|
||||
// re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
|
||||
// debounced volume write on each pass.
|
||||
@@ -308,15 +309,17 @@ export default function Feed({
|
||||
resolveVideo(id);
|
||||
}
|
||||
})
|
||||
.catch(() =>
|
||||
.catch((e: unknown) => {
|
||||
// Server rejected the change — drop the optimistic override so the card
|
||||
// reverts to the real (unchanged) state instead of staying phantom-hidden.
|
||||
// reverts to the real (unchanged) state instead of staying phantom-hidden, and say so:
|
||||
// a card that silently snaps back reads as a misclick.
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.videoState"));
|
||||
setOverrides((o) => {
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
@@ -330,13 +333,14 @@ export default function Feed({
|
||||
.clearState(id)
|
||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
|
||||
.catch(() =>
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
|
||||
setOverrides((o) => {
|
||||
const next = { ...o };
|
||||
delete next[id];
|
||||
return next;
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
@@ -349,7 +353,15 @@ export default function Feed({
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
})
|
||||
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(
|
||||
e,
|
||||
i18n.t(
|
||||
saved ? "errors.writeFailed.watchLaterAdd" : "errors.writeFailed.watchLaterRemove",
|
||||
),
|
||||
);
|
||||
setSavedOverrides((o) => ({ ...o, [id]: !saved }));
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
);
|
||||
@@ -466,7 +478,9 @@ export default function Feed({
|
||||
.then((r) =>
|
||||
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }),
|
||||
)
|
||||
.catch(() => {});
|
||||
.catch((e: unknown) =>
|
||||
notifyActionError(e, i18n.t("errors.writeFailed.clearSearch")),
|
||||
);
|
||||
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
|
||||
onExitYtSearch();
|
||||
dropFeedCaches();
|
||||
|
||||
@@ -372,8 +372,8 @@ describe("Home", () => {
|
||||
it("an unrelated mutation does not put the reroll button into a spinner", async () => {
|
||||
// The other edge of the test above. Because the pick key sits under the `["discover"]` prefix,
|
||||
// every mark-watched, bookmark, pin and shelf move on this page refetches it in the BACKGROUND —
|
||||
// so binding the button's `loading` to raw `isFetching` made it spin and, since `ui/Button` turns
|
||||
// `loading` into `disabled`, swallow clicks after actions the user never associated with a
|
||||
// so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
|
||||
// `ui/Button` swallows its clicks, ignore presses after actions the user never associated with a
|
||||
// reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
|
||||
const rerollBtn = () =>
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
@@ -391,7 +391,6 @@ describe("Home", () => {
|
||||
// The positive control, and it is the half most easily broken while fixing the other: this is the
|
||||
// FIRST press, so there is no previous pick for the placeholder to hold, and a predicate resting
|
||||
// on `isPlaceholderData` alone would drop the spinner from exactly the press that needs it.
|
||||
expect(rerollBtn().disabled).toBe(true);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
@@ -409,7 +408,6 @@ describe("Home", () => {
|
||||
});
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(rerollBtn().disabled).toBe(false);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -434,7 +432,7 @@ describe("Home", () => {
|
||||
// `notifyManager`'s own `setTimeout(0)`, registered AFTER any timer this test schedules, so
|
||||
// "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
|
||||
failPick(new Error("boom"));
|
||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false)); // the failure releases the button
|
||||
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull()); // the failure releases the button
|
||||
|
||||
// Now an unrelated action refetches that errored key in the background.
|
||||
discoverPick.mockReturnValue(new Promise(() => {}));
|
||||
@@ -442,7 +440,6 @@ describe("Home", () => {
|
||||
void qc.invalidateQueries({ queryKey: qk.discover() });
|
||||
});
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
expect(rerollBtn().disabled).toBe(false);
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -479,14 +476,14 @@ describe("Home", () => {
|
||||
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
|
||||
fireEvent.click(screen.getByText("home.hero.reroll"));
|
||||
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
|
||||
expect(rerollBtn().disabled).toBe(true); // the press is outstanding
|
||||
expect(rerollBtn().getAttribute("aria-busy")).toBe("true"); // the press is outstanding
|
||||
|
||||
failPick(new Error("boom"));
|
||||
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies
|
||||
// the observer through its own `setTimeout(0)`, and the empty state is already on screen while
|
||||
// the press is in flight — so asserting it straight away would pass with or without the fix.
|
||||
// The button releasing is that signal (`isFetched` flips once the key settles, error included).
|
||||
await waitFor(() => expect(rerollBtn().disabled).toBe(false));
|
||||
await waitFor(() => expect(rerollBtn().getAttribute("aria-busy")).toBeNull());
|
||||
expect(screen.getByText("home.hero.emptyBudget")).toBeTruthy();
|
||||
expect(screen.queryByText("Hero v1")).toBeNull();
|
||||
});
|
||||
@@ -674,3 +671,47 @@ describe("Home", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S4b, owner decision D4: the rails are one keyboard page — `j` walks a rail's cards and goes on
|
||||
// into the next rail, whichever order the page stacks them in.
|
||||
describe("Home · keyboard", () => {
|
||||
it("walks the rails with j, from one rail on into the next", async () => {
|
||||
discover.mockResolvedValue({
|
||||
...payload("v1"),
|
||||
continue_watching: [
|
||||
ytCard("v1", "Hero v1"),
|
||||
ytCard("v2", "Second thing"),
|
||||
ytCard("v3", "Third thing"),
|
||||
],
|
||||
// A pinned channel has no card of its own: the shelf draws it as a ShelfTile.
|
||||
comfort: [
|
||||
ytCard("v4", "Comfort thing"),
|
||||
{
|
||||
...ytCard("UC9", "Pinned channel"),
|
||||
kind: "channel",
|
||||
video: null,
|
||||
next: null,
|
||||
unwatched_count: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
mount();
|
||||
await screen.findByText("Comfort thing");
|
||||
const titles = ["Comfort thing", "Pinned channel", "Second thing", "Third thing"];
|
||||
const seen: (string | undefined)[] = [];
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
const item = (document.activeElement as HTMLElement).closest("[data-nav-index]");
|
||||
seen.push(titles.find((title) => item?.textContent?.includes(title)));
|
||||
}
|
||||
// Every card once, across both rails: the shelf tile, and on from one rail into the next.
|
||||
expect(new Set(seen)).toEqual(new Set(titles));
|
||||
// Every card of every rail is an item of it — the shelf tile too, not only the native cards.
|
||||
const rails = [...document.querySelectorAll("[data-nav-list]")];
|
||||
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ import VideoCard from "./VideoCard";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import { useNavigationActions } from "./NavigationProvider";
|
||||
import { usePlexActions } from "./PlexProvider";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
// The Home surface (E24) — "Mit nézzek most?".
|
||||
//
|
||||
@@ -172,8 +173,9 @@ function Rail({
|
||||
<Carousel
|
||||
title={<span className="text-sm font-semibold">{title}</span>}
|
||||
ariaLabel={ariaLabel}
|
||||
keyboardNav
|
||||
>
|
||||
{cards.map((c) => (
|
||||
{cards.map((c, i) => (
|
||||
<div
|
||||
key={`${c.source}:${c.kind}:${c.id}`}
|
||||
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
|
||||
@@ -184,6 +186,7 @@ function Rail({
|
||||
video={c.video}
|
||||
view="cardsSmall"
|
||||
width={YT_SLOT}
|
||||
navIndex={i}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||||
@@ -195,13 +198,14 @@ function Rail({
|
||||
) : c.plex ? (
|
||||
<PlexPosterCard
|
||||
card={c.plex}
|
||||
navIndex={i}
|
||||
onOpen={() => onPlay(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
menuProps={() => menu.triggerProps(c)}
|
||||
/>
|
||||
) : (
|
||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} />
|
||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -218,10 +222,12 @@ function ShelfTile({
|
||||
card,
|
||||
onPlay,
|
||||
menuProps,
|
||||
navIndex,
|
||||
}: {
|
||||
card: DiscoverCard;
|
||||
onPlay: (card: DiscoverCard) => void;
|
||||
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||
@@ -234,6 +240,7 @@ function ShelfTile({
|
||||
type="button"
|
||||
onClick={() => card.next && onPlay(card)}
|
||||
{...menuProps?.(card)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
||||
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
|
||||
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The conversation list as a keyboard list (E11 S5): `j`/`k` walk the conversations and Enter opens
|
||||
// the one the row's name button opens — not the pop-out button beside it. The live query, the key
|
||||
// state and the thread are stubbed: the list itself is what is under test.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
const conversation = (id: number, name: string) => ({
|
||||
partner: { id, name, avatar_url: null },
|
||||
last_message: { kind: "system", body: `hello from ${name}`, created_at: "2026-09-13T00:00:00Z" },
|
||||
unread: 0,
|
||||
});
|
||||
const items = [conversation(0, "Siftlode"), conversation(7, "Anna"), conversation(9, "Bence")];
|
||||
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: () => ({ data: { items }, isError: false, isLoading: false, refetch: () => {} }),
|
||||
}));
|
||||
vi.mock("../lib/messaging", () => ({
|
||||
SYSTEM_ID: 0,
|
||||
POLL_MS: 20000,
|
||||
partnerPub: async () => "",
|
||||
useKeyState: () => "ready",
|
||||
}));
|
||||
const openChat = vi.fn();
|
||||
vi.mock("../lib/chatDock", () => ({ openChat: (...a: unknown[]) => openChat(...a) }));
|
||||
vi.mock("./ChatThread", () => ({
|
||||
default: ({ partnerId }: { partnerId: number }) => <div data-testid="thread">{partnerId}</div>,
|
||||
}));
|
||||
|
||||
import Messages from "./Messages";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
history.replaceState(null, "");
|
||||
});
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Messages · the conversation list is a keyboard list", () => {
|
||||
it("walks the conversations with j/k and opens the focused one on Enter", () => {
|
||||
render(<Messages meId={1} />);
|
||||
const focusedRow = () => document.activeElement?.getAttribute("data-nav-index");
|
||||
press("j");
|
||||
press("j");
|
||||
press("j");
|
||||
expect(focusedRow()).toBe("2");
|
||||
expect(document.activeElement?.textContent).toContain("Bence");
|
||||
press("k");
|
||||
expect(focusedRow()).toBe("1");
|
||||
expect(document.activeElement?.textContent).toContain("Anna");
|
||||
press("Enter");
|
||||
expect(screen.getByTestId("thread").textContent).toBe("7");
|
||||
expect(openChat).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import Avatar from "./ui/Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { inputCls } from "./ui/form";
|
||||
import ChatThread from "./ChatThread";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -140,15 +142,19 @@ function ConversationList({
|
||||
) : items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{items.map((c) => {
|
||||
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
|
||||
// opens; the pop-out button beside it keeps its own Enter.
|
||||
<NavGrid className="flex flex-col gap-1.5">
|
||||
{items.map((c, i) => {
|
||||
const isSystem = c.partner.id === SYSTEM_ID;
|
||||
return (
|
||||
<div
|
||||
key={c.partner.id}
|
||||
{...navItemAttrs(i, c.partner.name)}
|
||||
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
||||
>
|
||||
<button
|
||||
data-nav-open=""
|
||||
onClick={() => onOpen(c)}
|
||||
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
|
||||
>
|
||||
@@ -195,7 +201,7 @@ function ConversationList({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered
|
||||
// before this. `setPage` early-returns when the requested page is already current with no channel
|
||||
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to
|
||||
// decide whether the overlay entries beneath a navigation are still reclaimable. Get it wrong and
|
||||
// the round-2 bug is back: Shift+W over the player on Home buries an entry nothing pushed past, and
|
||||
// the user's next browser Back does nothing.
|
||||
// The navigation actions against the overlay history stack: `setPage` and `openChannel` never push
|
||||
// under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
|
||||
// `setPage`'s same-page guard, which decides whether anything is pushed at all.
|
||||
//
|
||||
// The type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one
|
||||
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail
|
||||
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the
|
||||
// early return skips, which is what the boolean actually stands for.
|
||||
// The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
|
||||
// palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
|
||||
// actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
|
||||
// then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as
|
||||
// bookkeeping.
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { renderHook, act, cleanup } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -27,6 +25,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<NavigationProvider>{children}</NavigationProvider>
|
||||
@@ -34,6 +33,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
const render = () =>
|
||||
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper });
|
||||
|
||||
const settle = () =>
|
||||
act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
|
||||
/** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to
|
||||
* land its popstate at once. */
|
||||
async function openOverlay() {
|
||||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||||
});
|
||||
const holder: { unmount?: () => void } = {};
|
||||
const onClose = vi.fn(() => holder.unmount?.());
|
||||
const layer = renderHook(() => useBackToClose(onClose));
|
||||
holder.unmount = layer.unmount;
|
||||
await settle();
|
||||
return { back, onClose };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory
|
||||
@@ -41,46 +59,29 @@ beforeEach(() => {
|
||||
});
|
||||
// Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a
|
||||
// provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does.
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("NavigationProvider · setPage reports whether it navigated", () => {
|
||||
it("returns true and pushes an entry for a real page change", () => {
|
||||
describe("NavigationProvider · setPage's same-page guard", () => {
|
||||
it("pushes an entry for a real page change", () => {
|
||||
const { result } = render();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage("plex");
|
||||
});
|
||||
expect(reported).toBe(true);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.page).toBe("plex");
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
act(() => result.current.setPage("plex"));
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.page).toBe("plex");
|
||||
});
|
||||
|
||||
it("returns false and pushes nothing when it is already that page", () => {
|
||||
it("pushes nothing when it is already that page", () => {
|
||||
const { result } = render();
|
||||
act(() => {
|
||||
result.current.setPage("plex");
|
||||
});
|
||||
act(() => result.current.setPage("plex"));
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage("plex");
|
||||
});
|
||||
// Both halves matter: `false` is what `buryUnder` reads, and "pushed nothing" is what makes
|
||||
// that answer true. A value-only assertion would pass against an inverted pair.
|
||||
expect(reported).toBe(false);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
act(() => result.current.setPage("plex"));
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns true even for the current page when a channel page is over it", () => {
|
||||
it("still navigates to the current page when a channel page is over it", () => {
|
||||
// The nav rail's own module while a channel page is open over it is a real navigation: it closes
|
||||
// the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
|
||||
const { result } = render();
|
||||
@@ -90,22 +91,64 @@ describe("NavigationProvider · setPage reports whether it navigated", () => {
|
||||
// asking for "feed" was just an ordinary page change and deleting the clause left it green.
|
||||
const current = result.current.page;
|
||||
expect(current).toBe("home");
|
||||
act(() => {
|
||||
result.current.openChannel("UC1", "A channel");
|
||||
});
|
||||
act(() => result.current.openChannel("UC1", "A channel"));
|
||||
expect(result.current.channelView).not.toBeNull();
|
||||
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
try {
|
||||
let reported: boolean | undefined;
|
||||
act(() => {
|
||||
reported = result.current.setPage(current);
|
||||
});
|
||||
expect(reported).toBe(true);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.channelView).toBeNull();
|
||||
} finally {
|
||||
push.mockRestore();
|
||||
}
|
||||
act(() => result.current.setPage(current));
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.channelView).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("NavigationProvider · never under an open overlay", () => {
|
||||
it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
|
||||
const { result } = render();
|
||||
const { back, onClose } = await openOverlay();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
|
||||
act(() => result.current.setPage("plex"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current
|
||||
|
||||
await settle();
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.page).toBe("plex");
|
||||
expect(window.history.state).toMatchObject({ sfPage: "plex" });
|
||||
});
|
||||
|
||||
// Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player
|
||||
// closes, and its entry is reclaimed rather than orphaned under a push that never happened.
|
||||
it("setPage to the current page closes the overlay and pushes nothing", async () => {
|
||||
const { result } = render();
|
||||
const { back, onClose } = await openOverlay();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
|
||||
act(() => result.current.setPage(result.current.page));
|
||||
await settle();
|
||||
await settle();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The player's channel link and the palette's channel rows.
|
||||
it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => {
|
||||
const { result } = render();
|
||||
const { back, onClose } = await openOverlay();
|
||||
const push = vi.spyOn(window.history, "pushState");
|
||||
|
||||
act(() => result.current.openChannel("UC1", "A channel"));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
|
||||
await settle();
|
||||
await settle();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" });
|
||||
expect(window.history.state).toMatchObject({ _chan: "UC1" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,12 @@ import { pageTitleKey } from "../lib/pageMeta";
|
||||
import { setAccountRaw } from "../lib/storage";
|
||||
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
|
||||
import { setNavigator } from "../lib/nav";
|
||||
import { nextNavId, notifyNav } from "../lib/history";
|
||||
import {
|
||||
closeOverlaysThenNavigate,
|
||||
nextNavId,
|
||||
notifyNav,
|
||||
rememberNavPosition,
|
||||
} from "../lib/history";
|
||||
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
|
||||
|
||||
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
|
||||
@@ -37,10 +42,11 @@ export interface NavigationState {
|
||||
}
|
||||
|
||||
export interface NavigationActions {
|
||||
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page
|
||||
* is already current with no channel page over it, and a caller that buries overlay history
|
||||
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */
|
||||
setPage: (next: Page) => boolean;
|
||||
/** Switch modules. Closes every open overlay first and pushes only once their history entries are
|
||||
* back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
|
||||
* after the call; with nothing open it is synchronous. The requested page being already current
|
||||
* (with no channel page over it) pushes nothing — but the overlays still close. */
|
||||
setPage: (next: Page) => void;
|
||||
openChannel: (id: string, name?: string) => void;
|
||||
closeChannel: () => void;
|
||||
enterYtSearch: (q: string) => void;
|
||||
@@ -53,7 +59,7 @@ const StateContext = createContext<NavigationState>({
|
||||
ytSearch: null,
|
||||
});
|
||||
const ActionsContext = createContext<NavigationActions>({
|
||||
setPage: () => false,
|
||||
setPage: () => {},
|
||||
openChannel: () => {},
|
||||
closeChannel: () => {},
|
||||
enterYtSearch: () => {},
|
||||
@@ -118,53 +124,66 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
const setPage = useCallback(
|
||||
(next: Page) => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return false;
|
||||
markDirty();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
|
||||
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and
|
||||
// the address bar reflects the page (feed is implicit) while clearing the view-specific params
|
||||
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that
|
||||
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels
|
||||
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page.
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({
|
||||
page: next === "feed" ? null : next,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
return true;
|
||||
},
|
||||
(next: Page) =>
|
||||
// Never under an open overlay — see the interface doc. The refs are read when the push
|
||||
// actually happens, which over an open player is a moment after this call.
|
||||
closeOverlaysThenNavigate(() => {
|
||||
// A channel page overlays the content column, so a nav click must close it even when the
|
||||
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||
if (next === pageRef.current && !channelViewRef.current) return;
|
||||
markDirty();
|
||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||
setPageState(next);
|
||||
setAccountRaw(PAGE_KEY, next);
|
||||
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
|
||||
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
|
||||
// root, and the address bar reflects the page (feed is implicit) while clearing the
|
||||
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
|
||||
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left
|
||||
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to
|
||||
// their page. The item it was opened from is still focused: the state setters above have not
|
||||
// re-rendered anything yet.
|
||||
rememberNavPosition();
|
||||
window.history.pushState(
|
||||
{ sfPage: next, sfScroll: nextNavId() },
|
||||
"",
|
||||
urlWithParams({
|
||||
page: next === "feed" ? null : next,
|
||||
chan: null,
|
||||
v: null,
|
||||
pl: null,
|
||||
pscope: null,
|
||||
psort: null,
|
||||
pshow: null,
|
||||
pf: null,
|
||||
ctab: null,
|
||||
csort: null,
|
||||
cf: null,
|
||||
}),
|
||||
);
|
||||
notifyNav();
|
||||
}),
|
||||
[markDirty],
|
||||
);
|
||||
|
||||
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a
|
||||
// pre-pin account re-hydrate should still restore the stored page underneath — the re-hydrate's
|
||||
// replaceState spreads the _chan/_yt markers, so the overlay survives on top of the restored page.
|
||||
//
|
||||
// Like setPage it never lands under an open overlay: the player's channel link and the palette's
|
||||
// channel rows call it from inside one.
|
||||
const openChannel = useCallback((id: string, name?: string) => {
|
||||
setChannelView({ id, name });
|
||||
const st = window.history.state || {};
|
||||
const url = urlWithParams({ chan: id });
|
||||
if (st._chan)
|
||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
closeOverlaysThenNavigate(() => {
|
||||
const url = urlWithParams({ chan: id });
|
||||
if (window.history.state?._chan) {
|
||||
const st = window.history.state;
|
||||
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
} else {
|
||||
const st = rememberNavPosition();
|
||||
window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
|
||||
}
|
||||
setChannelView({ id, name });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeChannel = useCallback(() => {
|
||||
@@ -185,7 +204,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
|
||||
const { _ytScrape: _drop, ...st } = window.history.state || {};
|
||||
if (st._yt)
|
||||
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
|
||||
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
|
||||
else {
|
||||
// New sub-view entry.
|
||||
const { _ytScrape: _dropToo, ...left } = rememberNavPosition();
|
||||
window.history.pushState({ ...left, sfPage: "feed", _yt: q }, "");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exitYtSearch = useCallback(() => {
|
||||
|
||||
@@ -79,10 +79,26 @@ export default function NotificationsPanel() {
|
||||
qc.invalidateQueries({ queryKey: qk.notifications() });
|
||||
qc.invalidateQueries({ queryKey: qk.notifUnread() });
|
||||
};
|
||||
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh });
|
||||
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh });
|
||||
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh });
|
||||
const clearMut = useMutation({ mutationFn: api.clearNotifications, onSuccess: refresh });
|
||||
const readMut = useMutation({
|
||||
mutationFn: api.markNotificationRead,
|
||||
onSuccess: refresh,
|
||||
meta: { failTitle: "errors.writeFailed.notifRead" },
|
||||
});
|
||||
const readAllMut = useMutation({
|
||||
mutationFn: api.markAllNotificationsRead,
|
||||
onSuccess: refresh,
|
||||
meta: { failTitle: "errors.writeFailed.notifReadAll" },
|
||||
});
|
||||
const dismissMut = useMutation({
|
||||
mutationFn: api.dismissNotification,
|
||||
onSuccess: refresh,
|
||||
meta: { failTitle: "errors.writeFailed.notifDismiss" },
|
||||
});
|
||||
const clearMut = useMutation({
|
||||
mutationFn: api.clearNotifications,
|
||||
onSuccess: refresh,
|
||||
meta: { failTitle: "errors.writeFailed.notifClear" },
|
||||
});
|
||||
|
||||
const serverUnread = serverItems.some((n) => !n.read);
|
||||
const hasAny = serverItems.length > 0 || clientItems.length > 0;
|
||||
|
||||
@@ -82,6 +82,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
},
|
||||
// The step itself says the import failed and offers a retry button — no toast on top.
|
||||
meta: { errorShownInline: true },
|
||||
});
|
||||
|
||||
// First time we see read access with zero imported channels, pull the user's
|
||||
@@ -123,8 +125,9 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
|
||||
useDialogChrome(panelRef, rootRef);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// Only while nothing is above us: an error dialog opened from inside the wizard owns the Tab.
|
||||
if (e.key !== "Tab" || !layer.isTopmost()) return;
|
||||
// Only while nothing that owns the keyboard is above us: an error dialog opened from inside the
|
||||
// wizard owns the Tab, a hover card under the pointer does not.
|
||||
if (e.key !== "Tab" || layer.blockingAbove()) return;
|
||||
const panel = panelRef.current;
|
||||
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had
|
||||
// neither of them (round 6, on the commit that extracted the helper so the two shells could
|
||||
|
||||
@@ -290,3 +290,63 @@ describe("PlayerModal — toasts raised while it is open", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
|
||||
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
|
||||
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
|
||||
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
|
||||
describe("PlayerModal — the dialog contract", () => {
|
||||
const open = (onClose = () => {}) => {
|
||||
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
||||
const A = vid("A", 0);
|
||||
return renderModal(
|
||||
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
||||
);
|
||||
};
|
||||
|
||||
it("is named by the video's title", () => {
|
||||
open();
|
||||
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
|
||||
open();
|
||||
const active = document.activeElement as HTMLElement;
|
||||
expect(active.tagName).not.toBe("BUTTON");
|
||||
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
|
||||
});
|
||||
|
||||
it("gives the focus back to what opened it when it closes", () => {
|
||||
const opener = document.createElement("button");
|
||||
document.body.appendChild(opener);
|
||||
opener.focus();
|
||||
try {
|
||||
const view = open();
|
||||
expect(document.activeElement).not.toBe(opener);
|
||||
view.unmount();
|
||||
expect(document.activeElement).toBe(opener);
|
||||
} finally {
|
||||
opener.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Tab inside the player", () => {
|
||||
open();
|
||||
const dialog = screen.getByTestId("player-modal");
|
||||
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
|
||||
const last = tabbables[tabbables.length - 1]!;
|
||||
last.focus();
|
||||
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
|
||||
// element an offsetParent for the duration of this check.
|
||||
const spy = vi
|
||||
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
||||
.mockReturnValue(document.body);
|
||||
try {
|
||||
fireEvent.keyDown(window, { key: "Tab" });
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
expect(document.activeElement).not.toBe(last);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
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,
|
||||
@@ -32,7 +27,14 @@ import {
|
||||
import Avatar from "./ui/Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import {
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
anyBlockingLayerAbove,
|
||||
anyLayerAbove,
|
||||
escapeHandled,
|
||||
} from "../lib/layerStack";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
@@ -46,7 +48,7 @@ import {
|
||||
import { renderDescription } from "../lib/descriptionLinks";
|
||||
import { fullscreenElement } from "../lib/fullscreen";
|
||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
||||
import { closeThenNavigate, useBackToClose } from "../lib/history";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import ChannelNameLink from "./ChannelNameLink";
|
||||
import Toaster, { useOverlayOrder } from "./ui/Toaster";
|
||||
import { afterRemoval } from "../lib/playerQueue";
|
||||
@@ -166,6 +168,12 @@ export default function PlayerModal({
|
||||
// modal adjusts volume (not just the small player area).
|
||||
const dialogRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusModal = () => cardRef.current?.focus();
|
||||
// The dialog contract every other `aria-modal` shell shares (ui/dialogFocus): the focus goes back
|
||||
// to whatever opened the player, and the page lock is the one counted lock. It opens focused on the
|
||||
// CARD, not its first button, so Space/F/arrows reach the shortcuts. Until E11 this was the one
|
||||
// hand-rolled shell — no accessible name, no Tab trap, no restore (deep-review round 7).
|
||||
useDialogChrome(cardRef, dialogRef, { focusScope: true });
|
||||
const titleId = useId();
|
||||
const togglePlay = () => {
|
||||
const p = playerRef.current;
|
||||
if (!p || typeof p.getPlayerState !== "function") return;
|
||||
@@ -397,7 +405,7 @@ export default function PlayerModal({
|
||||
|
||||
useKeyScope("ytPlayer");
|
||||
|
||||
// Keyboard shortcuts + background scroll lock. The chords live in the shared keymap (`ytPlayer`
|
||||
// Keyboard shortcuts. The chords live in the shared keymap (`ytPlayer`
|
||||
// scope) so the help sheet lists them; only the behaviour behind each id is here.
|
||||
// These fire only while focus is on our page, not inside the cross-origin player iframe — so
|
||||
// we focus the modal card on open and again on player-ready (autoplay can grab focus).
|
||||
@@ -422,6 +430,14 @@ export default function PlayerModal({
|
||||
nudgeSeek(secs);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// The shared dialog Tab trap — only while nothing that owns the keyboard is layered above the
|
||||
// player: a Modal opened over it (the download dialog) traps for itself. The title's hover card
|
||||
// does not, so it must not switch the trap off.
|
||||
if (e.key === "Tab") {
|
||||
if (layerIdRef.current != null && anyBlockingLayerAbove(layerIdRef.current)) return;
|
||||
if (dialogRef.current) trapTab(e, dialogRef.current, cardRef.current);
|
||||
return;
|
||||
}
|
||||
dispatch(e, "ytPlayer", {
|
||||
close: (ev) => {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
@@ -468,11 +484,9 @@ export default function PlayerModal({
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
focusModal();
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
// (The scroll lock is the shared, counted one in useDialogChrome.)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
|
||||
};
|
||||
}, [onClose]);
|
||||
@@ -511,6 +525,7 @@ export default function PlayerModal({
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
|
||||
playlist). Each strip sits just outside the card, spans its full height, and fades out at
|
||||
@@ -735,7 +750,7 @@ export default function PlayerModal({
|
||||
<div className="p-4 sm:p-5">
|
||||
{/* Title row — title (with hover description) on the left, Close on the right. */}
|
||||
<div className="flex items-start gap-3">
|
||||
<h2 className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||
{/* Hover target is the text itself (inline), not the whole row. */}
|
||||
<span
|
||||
ref={descPop.refs.setReference}
|
||||
@@ -822,13 +837,11 @@ export default function PlayerModal({
|
||||
channelId={active.channel_id}
|
||||
title={active.channel_title}
|
||||
onOpenInApp={() => {
|
||||
const cid = active.channel_id;
|
||||
const cname = active.channel_title ?? undefined;
|
||||
// Close first, open the channel only once this player's own Back-to-close
|
||||
// entry has been reclaimed — otherwise that reclaim undoes the push and
|
||||
// clicking the name merely closes the player. The rule lives in history.ts
|
||||
// now; the command palette needs exactly the same one.
|
||||
closeThenNavigate(onClose, () => openChannel(cid, cname));
|
||||
// openChannel closes this player first and pushes only once its
|
||||
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
||||
// and clicking the name merely closes the player
|
||||
// (lib/history.closeOverlaysThenNavigate).
|
||||
openChannel(active.channel_id, active.channel_title ?? undefined);
|
||||
}}
|
||||
className="font-medium hover:text-accent truncate text-left"
|
||||
/>
|
||||
|
||||
@@ -380,7 +380,26 @@ describe("Playlists — a change the server refused is taken back on screen", ()
|
||||
await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
|
||||
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", message: "playlists.removeFailed" }),
|
||||
expect.objectContaining({ level: "error", title: "playlists.removeFailed" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playlists — the rows are a keyboard list", () => {
|
||||
it("walks the rows with j and plays the focused one on Enter", async () => {
|
||||
mount({ 1: [vid("A", 0), vid("B", 0)] });
|
||||
await screen.findByRole("button", { name: "card.play — Video B" });
|
||||
const press = (key: string) =>
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
press("j");
|
||||
press("j");
|
||||
expect(document.activeElement?.tagName).toBe("TR");
|
||||
expect(document.activeElement?.textContent).toContain("Video B");
|
||||
press("Enter");
|
||||
await waitFor(() => expect(h.player?.video.id).toBe("B"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,20 +56,16 @@ import { useComfortPin, pinVideo } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
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 { navItemAttrs } from "../lib/listNav";
|
||||
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
|
||||
@@ -99,12 +95,15 @@ const idsKey = (items: Video[]) =>
|
||||
|
||||
function Row({
|
||||
video,
|
||||
index,
|
||||
columns,
|
||||
dragDisabled,
|
||||
dragHint,
|
||||
menuProps,
|
||||
}: {
|
||||
video: Video;
|
||||
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
|
||||
index: number;
|
||||
columns: Column<Video>[];
|
||||
dragDisabled: boolean;
|
||||
dragHint: string;
|
||||
@@ -131,6 +130,7 @@ function Row({
|
||||
// transition-COLORS, not `transition` — the bare utility animates transform too, which is
|
||||
// the same lag by another route (150ms behind the pointer, on every row).
|
||||
className="border-b border-border/50 hover:bg-card/40 transition-colors"
|
||||
{...navItemAttrs(index)}
|
||||
{...menuProps}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
@@ -676,16 +676,20 @@ export default function Playlists() {
|
||||
}
|
||||
|
||||
function playerState(id: string, status: string) {
|
||||
api.setState(id, status).then(() => {
|
||||
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
|
||||
// list that is no longer the selected one (see `playing`), and that list holds the row whose
|
||||
// status just changed. Only lists actually on screen refetch.
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
});
|
||||
api
|
||||
.setState(id, status)
|
||||
.then(() => {
|
||||
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
|
||||
// list that is no longer the selected one (see `playing`), and that list holds the row whose
|
||||
// status just changed. Only lists actually on screen refetch.
|
||||
qc.invalidateQueries({ queryKey: qk.playlist() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
})
|
||||
// No catch used to mean an unhandled rejection and nothing said (E11 S2b).
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.videoState")));
|
||||
}
|
||||
|
||||
// Stable identity: PlayerModal's keydown/scroll-lock effect depends on it, so a fresh closure
|
||||
// Stable identity: PlayerModal's keydown effect depends on it, so a fresh closure
|
||||
// every render re-subscribed those listeners and re-ran focusModal() under the open player.
|
||||
const closePlayer = useCallback(() => setPlaying(null), []);
|
||||
|
||||
@@ -1089,10 +1093,14 @@ export default function Playlists() {
|
||||
items={shown.map((v) => v.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<tbody>
|
||||
{shown.map((v) => (
|
||||
{/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
|
||||
keeps Space/Enter, and during a keyboard drag the arrows too (listNav leaves a key
|
||||
dnd-kit has handled). */}
|
||||
<NavGrid as="tbody">
|
||||
{shown.map((v, i) => (
|
||||
<Row
|
||||
key={v.id}
|
||||
index={i}
|
||||
video={v}
|
||||
columns={columns}
|
||||
dragDisabled={!canEdit || viewActive}
|
||||
@@ -1100,7 +1108,7 @@ export default function Playlists() {
|
||||
menuProps={rowMenu.triggerProps(v)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</NavGrid>
|
||||
</SortableContext>
|
||||
</table>
|
||||
{shown.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The rail's in-row rename, from the keyboard. Enter and Escape unmount the focused field and render
|
||||
// the row as a NEW button; without a refocus the keyboard user was dropped on <body> and lost their
|
||||
// place (E10 review round 1, measured again in Chrome in E11 S2). Everything around the rail that is
|
||||
// not the rename path is stubbed.
|
||||
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
});
|
||||
vi.mock("./PlaylistsProvider", () => ({
|
||||
usePlaylists: () => ({ search: "", selectedId: 1 }),
|
||||
usePlaylistsActions: () => ({ setSelectedId: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ can_write: true }) }));
|
||||
vi.mock("./comfortPin", () => ({
|
||||
useComfortPin: () => ({ item: () => [] }),
|
||||
pinPlaylist: (id: number) => id,
|
||||
}));
|
||||
const rename = vi.fn(async () => {});
|
||||
vi.mock("./usePlaylistActions", () => ({
|
||||
usePlaylistActions: () => ({ rename, remove: vi.fn() }),
|
||||
}));
|
||||
// The SidePanel/PanelGroups chrome is not what is under test: render the rail's bodies directly.
|
||||
vi.mock("./FilterRail", () => ({
|
||||
default: ({ items }: { items: { id: string; body: ReactNode }[] }) => (
|
||||
<>
|
||||
{items.map((i) => (
|
||||
<div key={i.id}>{i.body}</div>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import PlaylistsRail from "./PlaylistsRail";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
Element.prototype.scrollIntoView ??= () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
rename.mockClear();
|
||||
});
|
||||
|
||||
const alpha = {
|
||||
id: 1,
|
||||
name: "alpha",
|
||||
kind: "custom",
|
||||
source: "local",
|
||||
item_count: 3,
|
||||
total_duration_seconds: 0,
|
||||
cover_thumbnail: null,
|
||||
dirty: false,
|
||||
} as unknown as Playlist;
|
||||
|
||||
async function openRename() {
|
||||
vi.spyOn(api, "playlists").mockResolvedValue([alpha]);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistsRail
|
||||
layout={{} as never}
|
||||
setLayout={() => {}}
|
||||
collapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const row = (await screen.findByText("alpha")).closest("button")!;
|
||||
act(() => row.focus());
|
||||
fireEvent.keyDown(row, { key: "F10", shiftKey: true });
|
||||
fireEvent.click(await screen.findByText("playlists.rename"));
|
||||
// The menu runs its action a task after it closes (see ui/ActionMenu).
|
||||
const field = await screen.findByRole("textbox", { name: "playlists.rename" });
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(field));
|
||||
return field as HTMLInputElement;
|
||||
}
|
||||
|
||||
const rowButton = () => screen.getByText("alpha").closest("button");
|
||||
|
||||
describe("PlaylistsRail · rename from the keyboard", () => {
|
||||
it("Escape cancels and puts the focus back on the row", async () => {
|
||||
const field = await openRename();
|
||||
fireEvent.change(field, { target: { value: "alpha2" } });
|
||||
fireEvent.keyDown(field, { key: "Escape" });
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
|
||||
expect(rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Enter commits and puts the focus back on the row", async () => {
|
||||
const field = await openRename();
|
||||
fireEvent.change(field, { target: { value: "beta" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
expect(rename).toHaveBeenCalledWith(1, "beta");
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(rowButton()));
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,7 @@ export default function PlaylistsRail({
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (name: string) => api.createPlaylist(name),
|
||||
meta: { failTitle: "errors.writeFailed.createPlaylist" },
|
||||
onSuccess: (pl: Playlist) => {
|
||||
setNewName("");
|
||||
qc.invalidateQueries({ queryKey: qk.playlists() });
|
||||
@@ -167,35 +168,20 @@ export default function PlaylistsRail({
|
||||
},
|
||||
});
|
||||
|
||||
// KNOWN GAP: Enter and Escape unmount the focused <input> and re-render the row as a <button> that
|
||||
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10
|
||||
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a
|
||||
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves
|
||||
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable
|
||||
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were
|
||||
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks
|
||||
// this up: instrument the effect in a real browser first, do not reason about the commit order.
|
||||
// Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
|
||||
// this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
|
||||
// The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
|
||||
// S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
|
||||
// "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
|
||||
// blur-commit means the user put the focus somewhere else on purpose.
|
||||
const refocusRowRef = useRef<number | null>(null);
|
||||
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// UNRESOLVED — do not "simplify" this back to `autoFocus` without reading the whole story.
|
||||
//
|
||||
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and
|
||||
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and
|
||||
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its
|
||||
// layout-effect cleanup. If that microtask wins, the field is blurred straight back out and its
|
||||
// own onBlur commits and closes it — "the rename field flashes and vanishes" (E10 review round 1).
|
||||
//
|
||||
// Three answers have been tried and none is VERIFIED. Round 1 turned floating-ui's returnFocus off
|
||||
// platform-wide and broke two other surfaces (a non-focusable <tr> trigger; a scroll-dismiss that
|
||||
// scrolled the list back). Round 2 replaced it with this effect, on the reasoning that a passive
|
||||
// effect is scheduled after the microtask. Round 3 disputed that (React flushes passive effects
|
||||
// synchronously at the end of a SyncLane commit, which would put the microtask LAST) — and a jsdom
|
||||
// harness built to settle it reproduced no failure for ANY of autoFocus / this effect / a
|
||||
// microtask, i.e. jsdom does not exercise the race, because focus never enters the menu there.
|
||||
//
|
||||
// So: this is the shape that is deliberate rather than accidental, and the question is open. It
|
||||
// needs ONE real-browser check (open the rail's context menu, pick Rename, see whether the field
|
||||
// survives) — not a fourth guess. Logged in the E10 plan.
|
||||
// Opening this field from the context menu used to race the menu's own focus return (floating-ui
|
||||
// hands the focus back to the trigger from a microtask queued as the popover unmounts, and the field
|
||||
// mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
|
||||
// return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
|
||||
// focused and survives.
|
||||
useEffect(() => {
|
||||
if (renamingId !== null) renameInputRef.current?.focus();
|
||||
}, [renamingId]);
|
||||
@@ -338,9 +324,15 @@ export default function PlaylistsRail({
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={() => void commitRename(pl)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void commitRename(pl);
|
||||
if (e.key === "Enter") {
|
||||
refocusRowRef.current = pl.id;
|
||||
void commitRename(pl);
|
||||
}
|
||||
// Unmounting the input fires no blur in React, so Escape really does cancel.
|
||||
if (e.key === "Escape") setRenamingId(null);
|
||||
if (e.key === "Escape") {
|
||||
refocusRowRef.current = pl.id;
|
||||
setRenamingId(null);
|
||||
}
|
||||
}}
|
||||
aria-label={t("playlists.rename")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
@@ -349,7 +341,14 @@ export default function PlaylistsRail({
|
||||
) : (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
ref={(el) => {
|
||||
if (pl.id === selectedId) selectedRef.current = el;
|
||||
// Back from the rename field by Enter/Escape — see refocusRowRef.
|
||||
if (el && refocusRowRef.current === pl.id) {
|
||||
refocusRowRef.current = null;
|
||||
el.focus();
|
||||
}
|
||||
}}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
{...railMenu.triggerProps(pl)}
|
||||
className={`${ROW_BASE} text-left ${
|
||||
|
||||
@@ -42,6 +42,7 @@ import { PlexPinMenu } from "./ComfortPinMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
|
||||
import { dispatch, useKeyScope } from "../lib/keymap";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
@@ -607,10 +608,11 @@ export default function PlexBrowse() {
|
||||
{episodes.length > 0 && (
|
||||
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2>
|
||||
)}
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{items.map((c) => (
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{items.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
navIndex={i}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
@@ -618,24 +620,25 @@ export default function PlexBrowse() {
|
||||
menuProps={pinMenu.triggerProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
|
||||
{episodes.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||
{episodes.map((ep) => (
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||
{episodes.map((ep, i) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
navIndex={i}
|
||||
ep={ep}
|
||||
withShowTitle
|
||||
onPlay={() => onCard(ep)}
|
||||
onToggleWatched={() => toggleWatched(ep)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useContextMenu } from "./ui/useContextMenu";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
|
||||
// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
|
||||
// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
|
||||
@@ -398,11 +399,12 @@ export default function PlexCollectionView({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c) => (
|
||||
<NavGrid className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{items.map((c, i) => (
|
||||
<div key={c.id} className="relative">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
@@ -421,7 +423,7 @@ export default function PlexCollectionView({
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve
|
||||
// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -263,3 +263,40 @@ describe("PlexDiscover", () => {
|
||||
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S4b: every rail and the genre grid are lists the keyboard walks (lib/listNav), so `j` goes on
|
||||
// from one to the next in page order (owner decision D4).
|
||||
describe("PlexDiscover · keyboard", () => {
|
||||
it("registers every rail and the genre grid, and j walks on from rail to rail", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] });
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findAllByText("Random Pick");
|
||||
// Five rails (continue, recently watched, recently added, because-you-watched, surprise) + genres.
|
||||
const lists = [...document.querySelectorAll("[data-nav-list]")];
|
||||
expect(lists.length).toBe(6);
|
||||
// …and each holds its cards as items: the genre tiles too, not only the posters.
|
||||
expect(lists.every((list) => list.querySelector("[data-nav-index]"))).toBe(true);
|
||||
const step = () => {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "j", bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
return (document.activeElement as HTMLElement).closest("[data-nav-index]")?.textContent;
|
||||
};
|
||||
const first = step();
|
||||
const second = step();
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).toBeTruthy();
|
||||
expect(second).not.toBe(first); // one card per rail: the second press crossed into the next rail
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import { PlexPosterCard } from "./plexCards";
|
||||
import { PlexPinMenu } from "./ComfortPinMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
|
||||
// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE
|
||||
// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
|
||||
@@ -55,11 +56,18 @@ function CardRail({
|
||||
);
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
|
||||
{cards.map((c) => (
|
||||
<Carousel
|
||||
title={heading}
|
||||
ariaLabel={ariaLabel}
|
||||
onSeeAll={onSeeAll}
|
||||
seeAllLabel={seeAllLabel}
|
||||
keyboardNav
|
||||
>
|
||||
{cards.map((c, i) => (
|
||||
<div key={c.id} className="w-[150px] shrink-0">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
@@ -222,17 +230,18 @@ export default function PlexDiscover({
|
||||
{d.genres.length > 0 && (
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{d.genres.map((g) => (
|
||||
<NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{d.genres.map((g, i) => (
|
||||
<PosterTile
|
||||
key={g.value}
|
||||
navIndex={i}
|
||||
title={g.value}
|
||||
art={g.art}
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="h-24"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Carousel } from "./ui/Carousel";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||
import { PLAYABLE_TINT, playableClass } from "./plexCards";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
@@ -649,7 +650,11 @@ export default function PlexInfo({
|
||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
<Carousel ariaLabel={t("plex.info.cast")} gap="gap-3">
|
||||
<Carousel
|
||||
ariaLabel={t("plex.info.cast")}
|
||||
gap="gap-3"
|
||||
keyboardNav={!!(onOpenPerson || onFilter)}
|
||||
>
|
||||
{cast.map((c, i) => {
|
||||
const clickable = !!(onOpenPerson || onFilter);
|
||||
const inner = (
|
||||
@@ -686,6 +691,7 @@ export default function PlexInfo({
|
||||
return clickable ? (
|
||||
<button
|
||||
key={i}
|
||||
{...navItemAttrs(i)}
|
||||
onClick={() =>
|
||||
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||
}
|
||||
@@ -734,10 +740,11 @@ export default function PlexInfo({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Carousel ariaLabel={col.title} gap="gap-3">
|
||||
{col.items.map((m) => (
|
||||
<Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
|
||||
{col.items.map((m, i) => (
|
||||
<button
|
||||
key={m.id}
|
||||
{...navItemAttrs(i)}
|
||||
onClick={() => onPlayItem?.(m.id)}
|
||||
disabled={!onPlayItem}
|
||||
className="group/col w-28 shrink-0 text-left"
|
||||
|
||||
@@ -915,7 +915,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
info: () => setInfoOpen((v) => !v),
|
||||
stats: () => setStatsOpen((v) => !v),
|
||||
back: (ev) => {
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button (shared cascade).
|
||||
// HTPC-style "stop & go back" to wherever the player was opened from — mirrors the mouse
|
||||
// Back button (shared cascade).
|
||||
// Who owns ⌫ when something is open over the player. Modal stopPropagation()s Escape ONLY
|
||||
// and its focused close button is not a typing target, so the key reaches us regardless.
|
||||
// Two wrong answers so far: deferring to nothing at all (round 1's bug — the cheat sheet
|
||||
|
||||
@@ -180,6 +180,25 @@ describe("PlexSearchBox — typeahead", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// E11 S4: SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the
|
||||
// first Escape belongs to the panel: it must close it and leave the caret in the field; only the
|
||||
// next Escape leaves.
|
||||
it("spends the first Escape on the panel and only the second on leaving the field", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||
renderBox();
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
await screen.findByRole("listbox");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
});
|
||||
|
||||
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function PlexSearchBox() {
|
||||
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
||||
pick(hi);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
|
||||
setDismissed(true);
|
||||
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
@@ -377,9 +378,14 @@ function PlaylistPicker() {
|
||||
];
|
||||
|
||||
async function createPlaylist(title: string) {
|
||||
const pl = await api.plexCreatePlaylist(title);
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onOpenPlaylist(pl.id);
|
||||
try {
|
||||
const pl = await api.plexCreatePlaylist(title);
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
onOpenPlaylist(pl.id);
|
||||
} catch (e) {
|
||||
// Called without an await from the picker, so a refusal was an unhandled rejection (E11 S2b).
|
||||
notifyActionError(e, t("plex.playlistAdd.createFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { hueOf, hueTileGradient } from "../lib/hueTile";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}),
|
||||
// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable
|
||||
@@ -12,6 +13,7 @@ export default function PosterTile({
|
||||
art,
|
||||
onClick,
|
||||
className,
|
||||
navIndex,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m")
|
||||
@@ -19,6 +21,7 @@ export default function PosterTile({
|
||||
art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
|
||||
onClick: () => void;
|
||||
className?: string; // caller sets the height (e.g. "h-20" / "h-28")
|
||||
navIndex?: number; // position in a keyboard-walkable grid (lib/listNav); Enter is the button's own
|
||||
}) {
|
||||
const posters = (art ?? []).slice(0, 2);
|
||||
const band = hueTileGradient(hueOf(title));
|
||||
@@ -27,6 +30,7 @@ export default function PosterTile({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
|
||||
>
|
||||
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
|
||||
|
||||
@@ -77,10 +77,12 @@ export default function ProfileEditor({ onClose }: { onClose: () => void }) {
|
||||
invalidate();
|
||||
setFormOpen(false);
|
||||
},
|
||||
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => api.deleteDownloadProfile(id),
|
||||
onSuccess: invalidate,
|
||||
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
|
||||
});
|
||||
|
||||
const onDelete = async (p: DownloadProfile) => {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// "Manage views…" opens a dialog from a menu row, so it must close the menu FIRST and open the dialog
|
||||
// on a fresh task (`closeThenAct`): the menu's focus return, queued as it unmounts, otherwise takes the
|
||||
// dialog's focus back to the trigger behind its scrim — measured in Chrome on ActionMenu (E11 S2), and
|
||||
// this row was the sibling that fix missed (deep-review E11 round 1). jsdom does not run that focus
|
||||
// race, so the ORDER is what is pinned: the menu is gone before the dialog is asked to open.
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
const openModal = vi.fn();
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./SavedViewsProvider", () => ({
|
||||
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
|
||||
}));
|
||||
|
||||
import SavedViewsPopover from "./SavedViewsPopover";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
describe("SavedViewsPopover · Manage views…", () => {
|
||||
it("closes the menu first, then opens the dialog once", async () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<SavedViewsPopover trigger="views" ariaLabel="Saved views" />
|
||||
</FloatingTree>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Saved views" }));
|
||||
// floating-ui never positions in jsdom, so the surface stays visibility:hidden and the row has no
|
||||
// accessible name to query by: find it by its text.
|
||||
fireEvent.click(screen.getByText("views.manage"));
|
||||
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
||||
expect(openModal).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(openModal).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, Check, Settings2 } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
import {
|
||||
PopoverItem,
|
||||
PopoverSurface,
|
||||
closeThenAct,
|
||||
useAnchoredPopover,
|
||||
} from "./ui/AnchoredPopover";
|
||||
import { useSavedViews } from "./SavedViewsProvider";
|
||||
|
||||
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||
@@ -67,10 +72,8 @@ export default function SavedViewsPopover({
|
||||
<div className="mt-1 border-t border-border pt-1">
|
||||
<PopoverItem
|
||||
role="menuitem"
|
||||
onSelect={() => {
|
||||
openModal();
|
||||
setOpen(false);
|
||||
}}
|
||||
// Opens a dialog: close first, or the menu's focus return steals its focus (closeThenAct).
|
||||
onSelect={() => closeThenAct(() => setOpen(false), openModal)}
|
||||
label={t("views.manage")}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
|
||||
>
|
||||
|
||||
@@ -390,6 +390,7 @@ export default function Scheduler() {
|
||||
// live poll surfaces the "running" state within a tick, so we just nudge a refetch.
|
||||
const runMut = useMutation({
|
||||
mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
|
||||
meta: { failTitle: "errors.writeFailed.schedulerRun" },
|
||||
onSuccess: (_d, jobId) => {
|
||||
notify({
|
||||
level: "success",
|
||||
@@ -400,6 +401,7 @@ export default function Scheduler() {
|
||||
});
|
||||
const runAllMut = useMutation({
|
||||
mutationFn: () => api.runAllSchedulerJobs(),
|
||||
meta: { failTitle: "errors.writeFailed.schedulerRunAll" },
|
||||
onSuccess: (res) => {
|
||||
notify({
|
||||
level: "success",
|
||||
@@ -410,10 +412,12 @@ export default function Scheduler() {
|
||||
});
|
||||
const batchMut = useMutation({
|
||||
mutationFn: (v: number) => api.updateMaintenanceBatch(v),
|
||||
meta: { failTitle: "errors.writeFailed.maintenanceBatch" },
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
|
||||
});
|
||||
const purgeMut = useMutation({
|
||||
mutationFn: () => api.purgeDiscovery(),
|
||||
meta: { failTitle: "errors.writeFailed.purgeDiscovery" },
|
||||
onSuccess: (res) => {
|
||||
const removed =
|
||||
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E11 S4: `s` puts the caret in the module's search box from anywhere on the page, and Escape hands
|
||||
// the keyboard back to the page — the way in needs a way out, or `j`/`k` end up typed into the query.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import KeyboardHelp from "./ui/KeyboardHelp";
|
||||
import { closeKeyboardHelp, toggleKeyboardHelp } from "../lib/keymap";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeKeyboardHelp();
|
||||
});
|
||||
|
||||
function press(init: KeyboardEventInit, on?: Element | null): KeyboardEvent {
|
||||
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
|
||||
act(() => {
|
||||
(on ?? document.activeElement ?? document.body).dispatchEvent(e);
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
const field = () => screen.getByPlaceholderText("Search…") as HTMLInputElement;
|
||||
|
||||
describe("SearchBar keys", () => {
|
||||
it("focuses the box on s, selects the old query, and keeps the s out of it", () => {
|
||||
render(<SearchBar value="old query" onChange={() => {}} placeholder="Search…" />);
|
||||
const e = press({ key: "s" });
|
||||
expect(document.activeElement).toBe(field());
|
||||
expect(e.defaultPrevented).toBe(true); // the `s` is not typed into the box it just focused
|
||||
expect([field().selectionStart, field().selectionEnd]).toEqual([0, "old query".length]);
|
||||
});
|
||||
|
||||
it("leaves an s typed into another field alone", () => {
|
||||
render(
|
||||
<>
|
||||
<input data-testid="other" />
|
||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||
</>,
|
||||
);
|
||||
const other = screen.getByTestId("other");
|
||||
act(() => other.focus());
|
||||
expect(press({ key: "s" }).defaultPrevented).toBe(false);
|
||||
expect(document.activeElement).toBe(other);
|
||||
});
|
||||
|
||||
it("stays quiet under a dialog", () => {
|
||||
render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||
const id = pushLayer();
|
||||
press({ key: "s" });
|
||||
removeLayer(id);
|
||||
expect(document.activeElement).not.toBe(field());
|
||||
});
|
||||
|
||||
it("gives the keyboard back to the page on Escape", () => {
|
||||
render(<SearchBar value="abc" onChange={() => {}} placeholder="Search…" />);
|
||||
press({ key: "s" });
|
||||
press({ key: "Escape" });
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
// Plex's typeahead uses its first Escape to close the result panel; the field keeps focus for that.
|
||||
it("lets a caller keep its own Escape", () => {
|
||||
render(
|
||||
<SearchBar
|
||||
value="abc"
|
||||
onChange={() => {}}
|
||||
placeholder="Search…"
|
||||
onKeyDown={(e) => e.key === "Escape" && e.preventDefault()}
|
||||
/>,
|
||||
);
|
||||
press({ key: "s" });
|
||||
press({ key: "Escape" });
|
||||
expect(document.activeElement).toBe(field());
|
||||
});
|
||||
|
||||
// Through the real hook: the sheet offers `s` only where a search box is mounted.
|
||||
it("is listed in the help sheet while a search box is mounted", () => {
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.getAllByRole("definition").map((d) => d.textContent)).toContain("keys.search");
|
||||
unmount();
|
||||
closeKeyboardHelp();
|
||||
|
||||
render(<KeyboardHelp />);
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.getAllByRole("definition").map((d) => d.textContent)).not.toContain(
|
||||
"keys.search",
|
||||
);
|
||||
});
|
||||
|
||||
// Opened over a player, the page keys are quiet — the sheet must not list them there.
|
||||
it("is left off a sheet opened over a keyboard-owning layer", () => {
|
||||
render(
|
||||
<>
|
||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
const id = pushLayer();
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.queryAllByRole("definition").map((d) => d.textContent)).not.toContain(
|
||||
"keys.search",
|
||||
);
|
||||
removeLayer(id);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useRef,
|
||||
type FocusEventHandler,
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useSearchFocusKey } from "../lib/keymap";
|
||||
|
||||
// The floating search pill in the top header. One component for every module that searches
|
||||
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||
@@ -52,6 +54,9 @@ export default function SearchBar({
|
||||
inputProps?: InputHTMLAttributes<HTMLInputElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// `s` puts the caret here from anywhere on the page (E11 S4) — the header mounts exactly one box.
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useSearchFocusKey(inputRef);
|
||||
const fire = () => {
|
||||
if (onGo && !goDisabled) onGo();
|
||||
};
|
||||
@@ -67,13 +72,19 @@ export default function SearchBar({
|
||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||
<input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === "Enter" && !e.defaultPrevented) fire();
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === "Enter") fire();
|
||||
// Escape hands the keyboard back to the page, where `j`/`k` and the `g` chords live — `s`
|
||||
// got you in, so there has to be a key that gets you out. The query stays as typed. A caller
|
||||
// that uses Escape itself (Plex's typeahead closing its panel) preventDefaults first.
|
||||
else if (e.key === "Escape") e.currentTarget.blur();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, HttpError, type Me } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
@@ -383,8 +384,8 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
try {
|
||||
await api.logoutOthers();
|
||||
notify({ level: "success", message: t("settings.account.sessions.done") });
|
||||
} catch {
|
||||
notify({ level: "error", message: t("settings.account.sessions.failed") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("settings.account.sessions.failed"));
|
||||
} finally {
|
||||
setSessBusy(false);
|
||||
}
|
||||
@@ -553,10 +554,11 @@ function PlexWatchSync() {
|
||||
};
|
||||
|
||||
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
|
||||
// "connect YouTube"), and their 409/500 are already surfaced by the global error modal — a caller
|
||||
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
|
||||
// "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
|
||||
// reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
|
||||
const toggle = useMutation({
|
||||
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
||||
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
|
||||
onSuccess: (res) => {
|
||||
qc.setQueryData(qk.plexWatchLink(), res);
|
||||
announce(res.import);
|
||||
@@ -564,6 +566,7 @@ function PlexWatchSync() {
|
||||
});
|
||||
const reimport = useMutation({
|
||||
mutationFn: () => api.plexWatchImport(),
|
||||
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
|
||||
onSuccess: (res) => {
|
||||
qc.setQueryData(qk.plexWatchLink(), res);
|
||||
announce(res.import);
|
||||
@@ -637,10 +640,13 @@ export function DownloadAuth() {
|
||||
notify({ level: "success", message: t("settings.downloadAuth.saved") });
|
||||
},
|
||||
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
|
||||
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail).
|
||||
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
|
||||
// statuses it does not cover go to the app-wide reporter.
|
||||
meta: { failTitle: "settings.downloadAuth.failed" },
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (host: string) => api.deleteDownloadCredential(host),
|
||||
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
|
||||
onSuccess: (res) => {
|
||||
qc.setQueryData(qk.downloadCredentials(), res);
|
||||
notify({ level: "success", message: t("settings.downloadAuth.removed") });
|
||||
@@ -781,8 +787,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
await api.deleteAccount();
|
||||
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
|
||||
window.location.href = "/?deleted=1";
|
||||
} catch {
|
||||
/* the global error dialog surfaces the reason (e.g. last admin) */
|
||||
} catch (e) {
|
||||
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
|
||||
// the reporter skips; anything else used to end the confirmed delete with nothing said.
|
||||
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
|
||||
}
|
||||
};
|
||||
return (
|
||||
|
||||
@@ -106,15 +106,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||
onSuccess: onChanged,
|
||||
meta: { failTitle: "errors.writeFailed.shareLinkDownload" },
|
||||
});
|
||||
const revoke = useMutation({
|
||||
mutationFn: () => api.revokeDownloadLink(link.id),
|
||||
onSuccess: onChanged,
|
||||
meta: { failTitle: "errors.writeFailed.shareLinkRevoke" },
|
||||
});
|
||||
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's
|
||||
// password_version server-side, which invalidates any watch grant issued under the old password.
|
||||
const savePass = useMutation({
|
||||
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
|
||||
meta: { failTitle: "errors.writeFailed.shareLinkPassword" },
|
||||
onSuccess: () => {
|
||||
setEditPass(false);
|
||||
setNewPass("");
|
||||
@@ -282,6 +285,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
meta: { failTitle: "downloads.share.linkFailed" },
|
||||
});
|
||||
|
||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||
|
||||
@@ -210,6 +210,11 @@ function SystemStats() {
|
||||
const pauseResume = useMutation({
|
||||
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
||||
onError: (e, paused) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||
),
|
||||
});
|
||||
const data = q.data;
|
||||
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
|
||||
import { api, type MyStatus } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { formatViews } from "../lib/format";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
|
||||
|
||||
@@ -53,6 +54,11 @@ export default function SyncStatus({
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
|
||||
onError: (e) =>
|
||||
notifyActionError(
|
||||
e,
|
||||
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
|
||||
),
|
||||
});
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
@@ -129,6 +129,7 @@ export default function TagManager({
|
||||
};
|
||||
const create = useMutation({
|
||||
mutationFn: (name: string) => api.createTag({ name, category: "other" }),
|
||||
meta: { failTitle: "channels.tags.createFailed" },
|
||||
onSuccess: () => {
|
||||
setNewName("");
|
||||
invalidate();
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E11 S4: the video card's half of keyboard navigation (`lib/listNav`). Every layout's ROOT carries the
|
||||
// list index — that is the element `j`/`k` focus and ring — and the title link is the card's `open`
|
||||
// element, so Enter on a selected card runs the very click a mouse would: the in-app player.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||
}));
|
||||
|
||||
import VideoCard from "./VideoCard";
|
||||
import type { Video } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
const video = {
|
||||
id: "abc123",
|
||||
title: "Cicada 3301",
|
||||
channel_id: "UCsXVk37bltHxD1rDPwtNM8Q",
|
||||
channel_title: "LEMMiNO",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
duration_seconds: 1200,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=abc123",
|
||||
thumbnail_url: "",
|
||||
} as unknown as Video;
|
||||
|
||||
beforeEach(cleanup);
|
||||
|
||||
function renderCard(view: FeedView, navIndex?: number, onOpen = vi.fn()) {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
view={view}
|
||||
width={0}
|
||||
navIndex={navIndex}
|
||||
onState={() => {}}
|
||||
onToggleSave={() => {}}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onOpen;
|
||||
}
|
||||
|
||||
// One per layout FAMILY: the stacked card, the thumbnailed row, the one-line row — three roots.
|
||||
const VIEWS: FeedView[] = ["cards", "rows", "rowsCompact"];
|
||||
|
||||
describe("VideoCard — keyboard navigation hooks", () => {
|
||||
for (const view of VIEWS) {
|
||||
it(`puts the list index on the ${view} root and opens in-app from the title`, () => {
|
||||
const onOpen = renderCard(view, 7);
|
||||
const root = screen.getByTestId("video-card");
|
||||
expect(root.getAttribute("data-nav-index")).toBe("7");
|
||||
const open = root.querySelectorAll("[data-nav-open]");
|
||||
expect(open).toHaveLength(1);
|
||||
expect(open[0]!.textContent).toBe("Cicada 3301");
|
||||
(open[0] as HTMLElement).click();
|
||||
expect(onOpen).toHaveBeenCalledWith(video);
|
||||
});
|
||||
}
|
||||
|
||||
it("carries no index outside a navigable list (Home's rails)", () => {
|
||||
renderCard("cards");
|
||||
expect(screen.getByTestId("video-card").hasAttribute("data-nav-index")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import Avatar from "./ui/Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
@@ -388,6 +389,7 @@ function VideoCard({
|
||||
onToggleSave,
|
||||
onOpen,
|
||||
menuProps,
|
||||
navIndex,
|
||||
}: {
|
||||
video: Video;
|
||||
view: FeedView;
|
||||
@@ -403,6 +405,8 @@ function VideoCard({
|
||||
* blow past this component's memo for every card in the virtualized feed on every parent
|
||||
* render. The card calls it with the video it already has. */
|
||||
menuProps?: (v: Video) => Record<string, unknown>;
|
||||
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { openChannel } = useNavigationActions();
|
||||
@@ -465,6 +469,9 @@ function VideoCard({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => openInApp(e, video, onOpen)}
|
||||
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
|
||||
// click: the in-app player, resuming where the reader left off.
|
||||
data-nav-open=""
|
||||
title={titleClamped ? (video.title ?? undefined) : undefined}
|
||||
className={clsx(
|
||||
"font-medium leading-snug hover:text-accent",
|
||||
@@ -534,6 +541,7 @@ function VideoCard({
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
||||
>
|
||||
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
||||
@@ -597,6 +605,7 @@ function VideoCard({
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
|
||||
>
|
||||
<Thumb
|
||||
@@ -631,6 +640,7 @@ function VideoCard({
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(
|
||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
|
||||
"flex flex-col",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A split export that fails part-way (deep-review E11 rounds 2 and 3). Every clip is its own job,
|
||||
// committed as it goes, so a failure at clip 2 of 2 (the job cap) left clip 1 queued while the editor
|
||||
// reported a plain failure and kept both selected — and trying again queued clip 1 a second time. It
|
||||
// now says how many made it (also when `req` has already shown the refusal in its dialog), deselects
|
||||
// them, holds the cut list still while it queues, and a retry goes on with the same "(i/N)" series.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify,
|
||||
}));
|
||||
|
||||
import VideoEditor from "./VideoEditor";
|
||||
import { api, HttpError, type DownloadJob } from "../lib/api";
|
||||
import { markReported } from "../lib/api/core";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
notify.mockReset();
|
||||
});
|
||||
|
||||
const job = {
|
||||
id: 7,
|
||||
display_name: "Talk",
|
||||
asset: { duration_s: 100, width: 1920, height: 1080, title: "Talk" },
|
||||
} as unknown as DownloadJob;
|
||||
|
||||
describe("VideoEditor — a split export that fails part-way", () => {
|
||||
it("says how many clips made it, and a retry queues only the rest", async () => {
|
||||
vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never);
|
||||
let refuse: (e: unknown) => void = () => {};
|
||||
const enqueue = vi
|
||||
.spyOn(api, "enqueueEdit")
|
||||
.mockResolvedValueOnce({} as never)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_, reject) => {
|
||||
refuse = reject;
|
||||
}) as never,
|
||||
)
|
||||
.mockResolvedValue({} as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// The playhead is where the video element says it is.
|
||||
const video = document.querySelector("video")!;
|
||||
const playhead = (s: number) => {
|
||||
Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true });
|
||||
fireEvent.timeUpdate(video);
|
||||
};
|
||||
playhead(50);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
|
||||
fireEvent.click(screen.getByText("editor.createN"));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2));
|
||||
// While the clips are queued the cut list holds still: a split now would be a range never queued.
|
||||
playhead(75);
|
||||
fireEvent.click(screen.getByText("editor.splitHere"));
|
||||
|
||||
// The job cap, as `req` delivers it: already shown in the error dialog, and marked reported.
|
||||
const refused = new HttpError(422, "errors.generic", { code: "quota" });
|
||||
markReported(refused);
|
||||
await act(async () => refuse(refused));
|
||||
await waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "error", title: "editor.partlyCreated" }),
|
||||
),
|
||||
);
|
||||
expect(enqueue.mock.calls[0]![0].display_name).toBe("Talk (1/2)");
|
||||
|
||||
// The queued first clip is deselected: one clip left to create, and only it is sent again — as
|
||||
// the second of the same two.
|
||||
fireEvent.click(screen.getByText("editor.create"));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(3));
|
||||
expect(enqueue.mock.calls[2]![0].edit_spec.trim).toEqual({ start_s: 50, end_s: 100 });
|
||||
expect(enqueue.mock.calls[2]![0].display_name).toBe("Talk (2/2)");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,8 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
|
||||
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
|
||||
import { inputCls, btnCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { failureReason, notifyActionError } from "../lib/actionErrors";
|
||||
import { markReported } from "../lib/api/core";
|
||||
|
||||
// mm:ss.d timecode <-> seconds.
|
||||
function tc(s: number): string {
|
||||
@@ -60,6 +61,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
// The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
|
||||
const [segments, setSegments] = useState<Seg[]>([{ id: 0, start: 0, end: srcDur, keep: true }]);
|
||||
// Set while Create queues its clips. A part-way failure deselects the clips it queued by the ids
|
||||
// Create started with, so the cut list holds still until it is done: a split or a merge in between
|
||||
// would deselect a range that was never queued, or keep one that was (deep-review E11 round 3).
|
||||
const creating = useRef(false);
|
||||
// The "(i/N)" each clip of the last Create was named with, by segment id: a retry after a part-way
|
||||
// failure goes on with that series instead of renumbering what is left ("(1/2)" beside "(1/4)").
|
||||
const series = useRef<Map<number, string> | null>(null);
|
||||
const [selId, setSelId] = useState(0);
|
||||
const [output, setOutput] = useState<"separate" | "join">("separate");
|
||||
const [cropOn, setCropOn] = useState(false);
|
||||
@@ -135,6 +143,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
// --- cut-list mutations ---
|
||||
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
|
||||
const splitAtPlayhead = () => {
|
||||
if (creating.current) return;
|
||||
const time = current;
|
||||
setSegments((segs) => {
|
||||
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
|
||||
@@ -147,7 +156,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
};
|
||||
const moveBoundary = (bi: number, time: number) =>
|
||||
setSegments((segs) => {
|
||||
if (bi < 0 || bi >= segs.length - 1) return segs;
|
||||
if (creating.current || bi < 0 || bi >= segs.length - 1) return segs;
|
||||
// bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
|
||||
const lo = segs[bi]!.start + MIN_SEG;
|
||||
const hi = segs[bi + 1]!.end - MIN_SEG;
|
||||
@@ -159,12 +168,14 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
});
|
||||
const deleteBoundary = (bi: number) =>
|
||||
setSegments((segs) => {
|
||||
if (segs.length < 2) return segs;
|
||||
if (creating.current || segs.length < 2) return segs;
|
||||
const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
|
||||
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
|
||||
});
|
||||
const toggleKeep = (id: number) =>
|
||||
setSegments((segs) => segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)));
|
||||
setSegments((segs) =>
|
||||
creating.current ? segs : segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)),
|
||||
);
|
||||
|
||||
// --- timeline drag (seek / boundary) + hover preview ---
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
@@ -291,6 +302,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
creating.current = true;
|
||||
const cp = cropPx();
|
||||
const base = name.trim() || defaultName;
|
||||
if (willJoin) {
|
||||
@@ -307,13 +319,45 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
return { files: 1 };
|
||||
}
|
||||
const N = kept.length;
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
|
||||
const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } };
|
||||
if (cp) spec.crop = cp;
|
||||
else spec.accurate = accurate;
|
||||
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
|
||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||
const prev = series.current;
|
||||
const suffix =
|
||||
prev && kept.every((s) => prev.has(s.id))
|
||||
? prev
|
||||
: new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
|
||||
series.current = suffix;
|
||||
const done: number[] = [];
|
||||
try {
|
||||
for (let i = 0; i < N; i++) {
|
||||
const s = kept[i]!; // i < N === kept.length, so kept[i] exists
|
||||
const spec: EditSpec = {
|
||||
trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
|
||||
};
|
||||
if (cp) spec.crop = cp;
|
||||
else spec.accurate = accurate;
|
||||
const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
|
||||
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
|
||||
done.push(s.id);
|
||||
}
|
||||
} catch (e) {
|
||||
// Every clip is its own job, committed as it goes: a failure at clip 3 of 3 (the job cap) left
|
||||
// clips 1 and 2 queued while the editor reported a plain failure and kept all three selected,
|
||||
// so trying again queued the first two a second time. Say how many made it, and deselect them
|
||||
// so another try adds only the rest.
|
||||
if (done.length) {
|
||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||
qc.invalidateQueries({ queryKey: qk.downloadUsage() });
|
||||
setSegments((segs) => segs.map((s) => (done.includes(s.id) ? { ...s, keep: false } : s)));
|
||||
// Its own toast, not notifyActionError: a quota or edit refusal has already opened req's
|
||||
// error dialog and marked the error, so that helper would say nothing — and the count is
|
||||
// what the dialog does not tell (deep-review E11 round 3).
|
||||
notify({
|
||||
level: "error",
|
||||
title: t("editor.partlyCreated", { done: done.length, total: N }),
|
||||
message: failureReason(e),
|
||||
});
|
||||
markReported(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return { files: N };
|
||||
},
|
||||
@@ -324,6 +368,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => notifyActionError(e, t("editor.failed")),
|
||||
onSettled: () => {
|
||||
creating.current = false;
|
||||
},
|
||||
});
|
||||
|
||||
const sel = segments.find((s) => s.id === selId) ?? segments[0];
|
||||
|
||||
@@ -64,10 +64,12 @@ export default function VirtualFeed({
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
renderItem={(v, itemWidth) => (
|
||||
keyboardNav
|
||||
renderItem={(v, itemWidth, index) => (
|
||||
<VideoCard
|
||||
video={v}
|
||||
view={view}
|
||||
navIndex={index}
|
||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||
|
||||
@@ -30,7 +30,7 @@ import { setLanguage, type LangCode } from "../i18n";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { Button } from "./ui/Button";
|
||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
||||
import { isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { anyBlockingLayerAbove, isTopmost, pushLayer, removeLayer } from "../lib/layerStack";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { entryHash, entrySearch } from "../lib/entryUrl";
|
||||
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
@@ -275,11 +275,9 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
// and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
|
||||
// membership below.
|
||||
//
|
||||
// It is the THIRD of four to get the contract, not the last: `PlayerModal` is also
|
||||
// `role="dialog" aria-modal="true"` and still hand-rolls its own scroll lock with no accessible
|
||||
// name, no Tab trap and no focus restore. Saying "the fourth shell" here read as though the sweep
|
||||
// were finished, which is the false-completeness that let a demo refusal sit unconverted for a
|
||||
// whole round. It is on ROADMAP.md instead (round 7).
|
||||
// It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth,
|
||||
// joined in E11 S2, so all four (`ui/Modal`, the wizard, this, the player) now share
|
||||
// `useDialogChrome` and its one counted scroll lock.
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialogChrome(panelRef);
|
||||
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal).
|
||||
@@ -295,14 +293,14 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
|
||||
// landing page grows (deep-review round 7).
|
||||
const id = pushLayer();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (!isTopmost(id)) return;
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
e.stopPropagation();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
|
||||
const panel = panelRef.current;
|
||||
if (e.key === "Tab" && panel) trapTab(e, panel, panel);
|
||||
if (e.key === "Tab" && panel && !anyBlockingLayerAbove(id)) trapTab(e, panel, panel);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
@@ -28,6 +28,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import UploadDialog from "./UploadDialog";
|
||||
import DownloadDialog from "./DownloadDialog";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -48,7 +49,9 @@ afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const errors = () => notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.message);
|
||||
// What failed, as the toast names it: the title (E11 S2b — the title says WHAT, the line under it WHY).
|
||||
const errors = () =>
|
||||
notify.mock.calls.filter(([n]) => n.level === "error").map(([n]) => n.title ?? n.message);
|
||||
const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
describe("the Plex collection editor", () => {
|
||||
@@ -144,3 +147,20 @@ describe("the upload dialog", () => {
|
||||
expect(errors()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the download dialog", () => {
|
||||
it("names a queueing the server refused with no dialog of its own", async () => {
|
||||
// Its catch said "the global error dialog already surfaced the reason" — true for a quota 422,
|
||||
// not for a 403/404/429 or a lost connection, where the dialog just stayed open (E11 S2b).
|
||||
vi.spyOn(api, "downloadProfiles").mockResolvedValue([{ id: 1, name: "Best" }] as never);
|
||||
vi.spyOn(api, "enqueueDownload").mockRejectedValue(new HttpError(403, "nope"));
|
||||
render(
|
||||
<QueryClientProvider client={client()}>
|
||||
<DownloadDialog source="https://example.com/v" onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("Best");
|
||||
fireEvent.click(screen.getByText("downloads.dialog.submit"));
|
||||
await waitFor(() => expect(errors()).toEqual(["errors.writeFailed.enqueueDownload"]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,12 +63,12 @@ const user = {
|
||||
is_suspended: false,
|
||||
};
|
||||
|
||||
function mount() {
|
||||
function mount(u: typeof user = user) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
<AccountPanel user={user as any} onClose={() => {}} />
|
||||
<AccountPanel user={u as any} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -87,11 +87,13 @@ beforeEach(() => {
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
// What each error toast names: its title when it has one (a failure reported with its reason under
|
||||
// it — E11 S2b), else the specific sentence (the no-mail case).
|
||||
const errorMessages = () =>
|
||||
notifyMock.mock.calls
|
||||
.map(([a]) => a as { level?: string; message?: string })
|
||||
.map(([a]) => a as { level?: string; title?: string; message?: string })
|
||||
.filter((a) => a.level === "error")
|
||||
.map((a) => a.message);
|
||||
.map((a) => a.title ?? a.message);
|
||||
|
||||
describe("the account panel reports a refused recovery action", () => {
|
||||
it("says the link could not be minted instead of leaving the button inert", async () => {
|
||||
@@ -105,13 +107,21 @@ describe("the account panel reports a refused recovery action", () => {
|
||||
});
|
||||
|
||||
it("names the no-mail case, so a no-SMTP instance does not read as a bug", async () => {
|
||||
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
apiMock.resendUserVerification.mockRejectedValue(
|
||||
new HttpError(409, "email off", { code: "resend", reason: "email_off" }),
|
||||
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
|
||||
await clickAction("users.account.resend");
|
||||
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.resendEmailOff"));
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "users.pendingAccess.resendFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a genuine send failure as a failure, not as a sent mail", async () => {
|
||||
@@ -138,3 +148,15 @@ describe("the account panel reports a refused recovery action", () => {
|
||||
await waitFor(() => expect(errorMessages()).toContain("users.pendingAccess.activateFailed"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the account panel on the demo account", () => {
|
||||
// The demo account's address is never confirmed, so "send the confirmation link again" looked
|
||||
// available — and the server refused it as "nothing left to confirm", beside a row reading
|
||||
// "unconfirmed" (deep-review E11 round 3).
|
||||
it("does not offer it a confirmation mail, which the server refuses", async () => {
|
||||
mount({ ...user, is_demo: true });
|
||||
await screen.findByText("users.account.recovery");
|
||||
const resend = screen.getByText("users.account.resend").closest("button")!;
|
||||
expect(resend.hasAttribute("disabled")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,16 +331,20 @@ describe("inviting someone the admin whitelisted", () => {
|
||||
it("says the mail was never sent when the instance has no SMTP", async () => {
|
||||
listMock.mockResolvedValue([invite({ status: "approved" })]);
|
||||
inviteMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "invite_send", reason: "email_off" }),
|
||||
new HttpError(409, "errors.admin.email_off", { code: "invite_send", reason: "email_off" }),
|
||||
);
|
||||
await open();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com"));
|
||||
|
||||
// A button the admin pressed must not answer with a success toast for a mail nobody received.
|
||||
// `req` has already localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "settings.invites.inviteEmailOff" }),
|
||||
expect.objectContaining({
|
||||
title: "settings.invites.inviteFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,17 +159,21 @@ describe("the stuck sign-ups section", () => {
|
||||
const { HttpError } = await import("../lib/api");
|
||||
pendingMock.mockResolvedValue([row({ email_verified: false })]);
|
||||
resendMock.mockRejectedValue(
|
||||
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }),
|
||||
new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
|
||||
);
|
||||
mount();
|
||||
|
||||
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend"));
|
||||
|
||||
// Mail is fail-soft everywhere else, which is what left the 0.78.5 sign-ups with nothing to fall
|
||||
// back on. A button the admin pressed must not answer with a success toast.
|
||||
// back on. A button the admin pressed must not answer with a success toast. `req` has already
|
||||
// localized the refusal into `detail` (lib/api/core); the toast carries it.
|
||||
await waitFor(() =>
|
||||
expect(notifyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "users.pendingAccess.resendEmailOff" }),
|
||||
expect.objectContaining({
|
||||
title: "users.pendingAccess.resendFailed",
|
||||
message: "errors.admin.email_off",
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,10 +62,12 @@ function QuotaModal({
|
||||
unlimited: state!.unlimited,
|
||||
}),
|
||||
onSuccess: done,
|
||||
meta: { failTitle: "errors.writeFailed.downloadQuota" },
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: () => api.adminResetDownloadQuota(userId),
|
||||
onSuccess: done,
|
||||
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
|
||||
});
|
||||
if (!state) return null;
|
||||
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p });
|
||||
|
||||
@@ -41,6 +41,7 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
|
||||
qc.invalidateQueries({ queryKey: qk.downloads() });
|
||||
onClose();
|
||||
},
|
||||
meta: { failTitle: "errors.writeFailed.downloadDetails" },
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
import { DownloadRow } from "./downloadRow";
|
||||
import { DownloadRow, IconBtn } from "./downloadRow";
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
|
||||
const baseJob: DownloadJob = {
|
||||
@@ -116,3 +116,27 @@ describe("DownloadRow file kind", () => {
|
||||
expect(container.querySelector("img")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DownloadRow · keyboard", () => {
|
||||
it("is a list item only when it is given a position", () => {
|
||||
const { container, rerender } = render(<DownloadRow job={baseJob} navIndex={3} />);
|
||||
expect(container.firstElementChild?.getAttribute("data-nav-index")).toBe("3");
|
||||
rerender(<DownloadRow job={baseJob} />);
|
||||
expect(container.firstElementChild?.hasAttribute("data-nav-index")).toBe(false);
|
||||
});
|
||||
|
||||
it("marks the action that opens the row, and only that one", () => {
|
||||
render(
|
||||
<DownloadRow job={baseJob} navIndex={0}>
|
||||
<IconBtn open onClick={() => {}} title="play">
|
||||
p
|
||||
</IconBtn>
|
||||
<IconBtn onClick={() => {}} title="delete">
|
||||
d
|
||||
</IconBtn>
|
||||
</DownloadRow>,
|
||||
);
|
||||
expect(screen.getByTitle("play").hasAttribute("data-nav-open")).toBe(true);
|
||||
expect(screen.getByTitle("delete").hasAttribute("data-nav-open")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api, type DownloadJob } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
// Download-row presentation + the small shared primitives (status pill, icon button, usage bar),
|
||||
// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
|
||||
@@ -112,16 +113,20 @@ export function IconBtn({
|
||||
onClick,
|
||||
title,
|
||||
danger,
|
||||
open,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
danger?: boolean;
|
||||
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
|
||||
open?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
data-nav-open={open ? "" : undefined}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"p-1.5 rounded-md hover:bg-surface text-muted transition",
|
||||
@@ -201,16 +206,22 @@ function ChannelLine({ job }: { job: DownloadJob }) {
|
||||
export function DownloadRow({
|
||||
job,
|
||||
onShowError,
|
||||
navIndex,
|
||||
children,
|
||||
}: {
|
||||
job: DownloadJob;
|
||||
onShowError?: (job: DownloadJob) => void;
|
||||
/** Its position in a keyboard-walkable list (lib/listNav). */
|
||||
navIndex?: number;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const running = job.status === "running";
|
||||
return (
|
||||
<div className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition">
|
||||
<div
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, jobTitle(job)))}
|
||||
className="flex gap-3 p-2.5 rounded-xl glass-card glass-hover transition"
|
||||
>
|
||||
<Thumb job={job} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5">
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Every rail and every card of a card list is walkable by the keyboard (E11 S4, lib/listNav). Two
|
||||
// opt-in halves, both checked here, because either one missing silently leaves the keyboard out —
|
||||
// and a page where `j` walks two rails and skips the third reads as broken, not as unfinished:
|
||||
// - a `Carousel`, a `VirtualGrid` and a `DataTable` say `keyboardNav`, and a card says `navIndex`;
|
||||
// - a card that says `navIndex` sits in a list that registers with listNav — a `NavGrid`, a
|
||||
// `Carousel`, or a `VirtualGrid` (its `renderItem`). In a plain `<div className="grid">` the card
|
||||
// is marked as an item of a list nobody walks.
|
||||
// Each exception is listed with its reason.
|
||||
//
|
||||
// The assertion pins the SET of offending tags, never a count, and checks the glob matched at all: a
|
||||
// guard whose glob stops matching is green over nothing (E10 review round 4, round 7).
|
||||
const WALKABLE = ["Carousel", "VirtualGrid", "DataTable"];
|
||||
const CARDS = [
|
||||
"VideoCard",
|
||||
"PlexPosterCard",
|
||||
"SeasonCard",
|
||||
"EpisodeCard",
|
||||
"PosterTile",
|
||||
"ShelfTile",
|
||||
"DownloadRow",
|
||||
];
|
||||
const LISTS = ["NavGrid", "Carousel", "VirtualGrid"];
|
||||
|
||||
/** `path#tag#n` → why it is not a keyboard list. An entry that no longer matches a tag fails the test. */
|
||||
const EXEMPT: Record<string, string> = {
|
||||
"components/AuditLog.tsx#DataTable#1":
|
||||
"an admin log read in place: its rows open nothing, so stepping onto them would only move a ring",
|
||||
};
|
||||
|
||||
interface Tag {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const stripComments = (source: string) =>
|
||||
source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
||||
|
||||
/** Each opening `<Name …>` tag, found by brace depth — a JSX attribute holds its own `>`
|
||||
* (`title={<span>…</span>}`, an arrow `=>`), so the first `>` is not the end of the tag. Takes
|
||||
* comment-free code, so the positions line up with `inList`. */
|
||||
export function openingTags(code: string, name: string): Tag[] {
|
||||
const tags: Tag[] = [];
|
||||
const start = new RegExp(`<${name}(?=[\\s/>])`, "g");
|
||||
for (let m = start.exec(code); m; m = start.exec(code)) {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let i = m.index + name.length + 1;
|
||||
for (; i < code.length; i++) {
|
||||
const c = code[i];
|
||||
if (quote) {
|
||||
if (c === quote) quote = null;
|
||||
} else if (depth === 0 && (c === '"' || c === "'")) quote = c;
|
||||
else if (c === "{") depth++;
|
||||
else if (c === "}") depth--;
|
||||
else if (c === ">" && depth === 0) break;
|
||||
}
|
||||
tags.push({ start: m.index, end: i + 1, text: code.slice(m.index, i + 1) });
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Whether `pos` lies inside an open list element: between `<List …>` and `</List>`, or inside a
|
||||
* list's own tag (a `renderItem={…}` attribute). */
|
||||
export function inList(code: string, pos: number): boolean {
|
||||
return LISTS.some((name) => {
|
||||
let open = 0;
|
||||
for (const tag of openingTags(code, name)) {
|
||||
if (tag.start > pos) break;
|
||||
if (tag.end > pos) return true;
|
||||
if (!tag.text.endsWith("/>")) open++;
|
||||
}
|
||||
return open > code.slice(0, pos).split(`</${name}>`).length - 1;
|
||||
});
|
||||
}
|
||||
|
||||
const has = (tag: Tag, prop: string) => new RegExp(`\\s${prop}\\b`).test(tag.text);
|
||||
|
||||
function offenders(): { hits: string[]; tags: number; exempted: Set<string> } {
|
||||
const files = import.meta.glob("./**/*.tsx", { query: "?raw", import: "default", eager: true });
|
||||
const hits: string[] = [];
|
||||
const exempted = new Set<string>();
|
||||
let tags = 0;
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
const path = rel.replace(/^\.\//, "components/");
|
||||
if (/\.test\.tsx$/.test(path)) continue;
|
||||
const code = stripComments(String(source));
|
||||
const report = (id: string, why: string) => {
|
||||
if (id in EXEMPT) exempted.add(id);
|
||||
else hits.push(`${id} ${why}`);
|
||||
};
|
||||
for (const name of WALKABLE) {
|
||||
openingTags(code, name).forEach((tag, n) => {
|
||||
tags++;
|
||||
if (!has(tag, "keyboardNav")) report(`${path}#${name}#${n + 1}`, "no keyboardNav");
|
||||
});
|
||||
}
|
||||
for (const name of CARDS) {
|
||||
openingTags(code, name).forEach((tag, n) => {
|
||||
tags++;
|
||||
const id = `${path}#${name}#${n + 1}`;
|
||||
if (!has(tag, "navIndex")) report(id, "no navIndex");
|
||||
else if (!inList(code, tag.start)) report(id, "not inside a NavGrid/Carousel/VirtualGrid");
|
||||
});
|
||||
}
|
||||
}
|
||||
return { hits: hits.sort(), tags, exempted };
|
||||
}
|
||||
|
||||
describe("the keyboard-list wiring contract", () => {
|
||||
it("has no rail or card list the keyboard cannot walk", () => {
|
||||
const { hits, tags, exempted } = offenders();
|
||||
expect(tags).toBeGreaterThan(15);
|
||||
expect(hits).toEqual([]);
|
||||
expect(Object.keys(EXEMPT).filter((id) => !exempted.has(id))).toEqual([]);
|
||||
});
|
||||
|
||||
it("reads a tag to its real end, past the > inside its attributes", () => {
|
||||
const code = stripComments(`
|
||||
<Carousel
|
||||
title={<span className="x">{title}</span>}
|
||||
onSeeAll={() => go(1 > 0)}
|
||||
keyboardNav
|
||||
>
|
||||
// <Carousel keyboardNav> in a comment is no tag
|
||||
<Carousel ariaLabel="a > b">
|
||||
`);
|
||||
const tags = openingTags(code, "Carousel");
|
||||
expect(tags).toHaveLength(2);
|
||||
expect(has(tags[0]!, "keyboardNav")).toBe(true);
|
||||
expect(has(tags[1]!, "keyboardNav")).toBe(false);
|
||||
});
|
||||
|
||||
it("tells a card inside a list from one in a plain grid", () => {
|
||||
const code = `
|
||||
<NavGrid className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</NavGrid>
|
||||
<div className="grid">{a.map((c, i) => <PosterTile navIndex={i} />)}</div>
|
||||
<VirtualGrid renderItem={(v, w, i) => <VideoCard navIndex={i} />} />
|
||||
`;
|
||||
const at = (n: number) => openingTags(code, "PosterTile")[n]!.start;
|
||||
expect(inList(code, at(0))).toBe(true);
|
||||
expect(inList(code, at(1))).toBe(false);
|
||||
expect(inList(code, openingTags(code, "VideoCard")[0]!.start)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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`.
|
||||
//
|
||||
@@ -68,7 +68,12 @@ export function playlistColumns({
|
||||
<Play className="w-4 h-4 text-white fill-current" />
|
||||
</span>
|
||||
</button>
|
||||
<button onClick={() => onPlay(v)} className="min-w-0 flex-1 text-left">
|
||||
<button
|
||||
onClick={() => onPlay(v)}
|
||||
// The row's "open" element: Enter on a keyboard-walked row (lib/listNav) plays it.
|
||||
data-nav-open=""
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
@@ -49,10 +50,11 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
|
||||
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
|
||||
|
||||
return (
|
||||
<div className={GRID}>
|
||||
{playlists.map((p) => (
|
||||
<NavGrid className={GRID}>
|
||||
{playlists.map((p, i) => (
|
||||
<PosterTile
|
||||
key={p.id}
|
||||
navIndex={i}
|
||||
title={p.title}
|
||||
subtitle={t("plex.browse.items", { count: p.item_count })}
|
||||
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
|
||||
@@ -60,7 +62,7 @@ export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }
|
||||
className="h-28"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,10 +217,11 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
|
||||
) : (
|
||||
<div className={GRID}>
|
||||
{collections.map((c) => (
|
||||
<NavGrid className={GRID}>
|
||||
{collections.map((c, i) => (
|
||||
<PosterTile
|
||||
key={c.id}
|
||||
navIndex={i}
|
||||
title={c.title}
|
||||
art={c.art}
|
||||
subtitle={collectionMeta(c, t)}
|
||||
@@ -234,7 +237,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
|
||||
className="h-28"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is
|
||||
// key-passthrough, so the asserted names are the i18n keys.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -100,3 +100,50 @@ describe("EpisodeCard a11y", () => {
|
||||
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S4b: in a keyboard-walkable grid or rail the WHOLE card is the item (the ring goes round the
|
||||
// poster and its title, D2) and the poster — the card's own role=button — is what Enter opens.
|
||||
describe("Plex cards in a keyboard-walkable list", () => {
|
||||
const cases = [
|
||||
[
|
||||
"PlexPosterCard",
|
||||
(onOpen: () => void, navIndex?: number) => (
|
||||
<PlexPosterCard
|
||||
card={movie()}
|
||||
onOpen={onOpen}
|
||||
onInfo={vi.fn()}
|
||||
onToggleWatched={vi.fn()}
|
||||
navIndex={navIndex}
|
||||
/>
|
||||
),
|
||||
],
|
||||
[
|
||||
"SeasonCard",
|
||||
(onOpen: () => void, navIndex?: number) => (
|
||||
<SeasonCard se={season()} onOpen={onOpen} navIndex={navIndex} />
|
||||
),
|
||||
],
|
||||
[
|
||||
"EpisodeCard",
|
||||
(onOpen: () => void, navIndex?: number) => (
|
||||
<EpisodeCard ep={episode()} onPlay={onOpen} navIndex={navIndex} />
|
||||
),
|
||||
],
|
||||
] as const;
|
||||
|
||||
it.each(cases)("%s: the root is the item and the poster is its open element", (_, card) => {
|
||||
const onOpen = vi.fn();
|
||||
const { container } = render(card(onOpen, 3));
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.getAttribute("data-nav-index")).toBe("3");
|
||||
const open = root.querySelector<HTMLElement>("[data-nav-open]")!;
|
||||
expect(open.getAttribute("role")).toBe("button");
|
||||
fireEvent.click(open);
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(cases)("%s: outside a list it is no item", (_, card) => {
|
||||
const { container } = render(card(vi.fn()));
|
||||
expect(container.querySelector("[data-nav-index]")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
|
||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
||||
// state). Rendered by the browse grid, the person page, and the series show/season views.
|
||||
@@ -52,6 +53,7 @@ export function PlexPosterCard({
|
||||
onInfo,
|
||||
onToggleWatched,
|
||||
menuProps,
|
||||
navIndex,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
@@ -61,6 +63,8 @@ export function PlexPosterCard({
|
||||
* A function of the card rather than a ready-made object, so every card surface in the app takes
|
||||
* `menuProps={menu.triggerProps}` and reads the same at every call site. */
|
||||
menuProps?: (c: PlexCard) => Record<string, unknown>;
|
||||
/** Position in a keyboard-walkable grid or rail (lib/listNav); the poster is what Enter opens. */
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
|
||||
@@ -75,6 +79,7 @@ export function PlexPosterCard({
|
||||
<div
|
||||
className="group text-left"
|
||||
{...menuProps?.(card)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
|
||||
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
|
||||
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
|
||||
>
|
||||
@@ -82,6 +87,7 @@ export function PlexPosterCard({
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -242,11 +248,13 @@ export function SeasonCard({
|
||||
onOpen,
|
||||
onToggleWatched,
|
||||
onAdd,
|
||||
navIndex,
|
||||
}: {
|
||||
se: PlexSeasonDetail;
|
||||
onOpen: () => void;
|
||||
onToggleWatched?: () => void;
|
||||
onAdd?: () => void;
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const watched = se.status === "watched";
|
||||
@@ -254,11 +262,15 @@ export function SeasonCard({
|
||||
? t("plex.series.markSeasonUnwatched")
|
||||
: t("plex.series.markSeasonWatched");
|
||||
return (
|
||||
<div className="group text-left">
|
||||
<div
|
||||
className="group text-left"
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
|
||||
>
|
||||
<div
|
||||
onClick={onOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
@@ -357,7 +369,7 @@ export function CastStrip({
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
|
||||
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")} keyboardNav={clickable}>
|
||||
{cast.map((c, i) => {
|
||||
const inner = (
|
||||
<>
|
||||
@@ -390,6 +402,7 @@ export function CastStrip({
|
||||
return clickable ? (
|
||||
<button
|
||||
key={i}
|
||||
{...navItemAttrs(i)}
|
||||
onClick={() =>
|
||||
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
|
||||
}
|
||||
@@ -427,10 +440,11 @@ export function RelatedStrip({
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
|
||||
{related.map((r) => (
|
||||
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
|
||||
{related.map((r, i) => (
|
||||
<button
|
||||
key={r.id}
|
||||
{...navItemAttrs(i)}
|
||||
onClick={() => onOpen(r.id)}
|
||||
className="group w-[130px] shrink-0 text-left"
|
||||
>
|
||||
@@ -463,12 +477,14 @@ export function EpisodeCard({
|
||||
onAdd,
|
||||
onToggleWatched,
|
||||
withShowTitle,
|
||||
navIndex,
|
||||
}: {
|
||||
ep: PlexCard;
|
||||
onPlay: () => void;
|
||||
onAdd?: () => void;
|
||||
onToggleWatched?: () => void;
|
||||
withShowTitle?: boolean;
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched";
|
||||
@@ -478,11 +494,12 @@ export function EpisodeCard({
|
||||
? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
|
||||
: 0;
|
||||
return (
|
||||
<div className="group">
|
||||
<div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}>
|
||||
<div
|
||||
onClick={onPlay}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-nav-open=""
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Carousel } from "./ui/Carousel";
|
||||
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
|
||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
|
||||
// The Plex sub-router's view branches, split out of PlexBrowse. Each is invoked by PlexBrowse with
|
||||
// shell callbacks as props (onBack/onPlay/onFilter/…) — no view reaches back into the shell's state,
|
||||
@@ -151,10 +152,11 @@ export function PlexPersonView({
|
||||
cards.length > 0 ? (
|
||||
<section className="mb-5">
|
||||
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{cards.map((c) => (
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{cards.map((c, i) => (
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
navIndex={i}
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
@@ -162,7 +164,7 @@ export function PlexPersonView({
|
||||
menuProps={pinMenu.triggerProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</section>
|
||||
) : null;
|
||||
|
||||
@@ -241,11 +243,16 @@ export function PlexPersonView({
|
||||
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
|
||||
{d.known_for.length > 0 && (
|
||||
<section className="mb-5">
|
||||
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
|
||||
{d.known_for.map((c) => (
|
||||
<Carousel
|
||||
title={t("plex.person.knownFor")}
|
||||
ariaLabel={t("plex.person.knownFor")}
|
||||
keyboardNav
|
||||
>
|
||||
{d.known_for.map((c, i) => (
|
||||
<div key={c.id} className="w-[150px] shrink-0">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
navIndex={i}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggle}
|
||||
@@ -501,10 +508,11 @@ export function PlexShowView({
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||
{d.seasons.map((se) => (
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
|
||||
{d.seasons.map((se, i) => (
|
||||
<SeasonCard
|
||||
key={se.id}
|
||||
navIndex={i}
|
||||
se={se}
|
||||
onOpen={() => onOpenSeason(se.id)}
|
||||
onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
|
||||
@@ -520,7 +528,7 @@ export function PlexShowView({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</section>
|
||||
|
||||
{show.cast.length > 0 && showCast && (
|
||||
@@ -684,17 +692,18 @@ export function PlexSeasonView({
|
||||
</div>
|
||||
</DetailHero>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||
{se.episodes.map((ep) => (
|
||||
<NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
|
||||
{se.episodes.map((ep, i) => (
|
||||
<EpisodeCard
|
||||
key={ep.id}
|
||||
navIndex={i}
|
||||
ep={ep}
|
||||
onPlay={() => onPlay(ep.id, allKeys)}
|
||||
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
|
||||
onToggleWatched={() => markEpisode(ep)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</NavGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -54,13 +54,18 @@ describe("ActionMenu", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("selecting an item fires its onSelect and closes the menu", () => {
|
||||
// Close first, act on a later task: floating-ui hands the focus back to the trigger from a microtask
|
||||
// as the menu unmounts, and an action run in that same batch (a confirm dialog) had its focus taken
|
||||
// back to the trigger behind the dialog's scrim — measured in Chrome, E11 S2. jsdom does not run
|
||||
// that focus race, so the ORDER is what is pinned: the menu is gone before the action runs.
|
||||
it("selecting an item closes the menu first, then fires its onSelect once", async () => {
|
||||
const onSelect = vi.fn();
|
||||
renderMenu([{ id: "sync", label: "Sync", onSelect }]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
fireEvent.click(screen.getByText("Sync"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
|
||||
@@ -125,3 +130,67 @@ describe("ActionMenu", () => {
|
||||
expect(screen.getByTestId("custom-trigger")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// PageUp/PageDown in a roving menu (E11, owner request) — the same page as the palette's and the
|
||||
// combobox's (lib/listPaging); here the keyboard's row holds real DOM focus (the `tabindex=0` rover).
|
||||
describe("ActionMenu · paging", () => {
|
||||
it("moves the roving focus a page at a time, over disabled rows, stopping at the ends", () => {
|
||||
renderMenu(
|
||||
Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
label: `Row ${i}`,
|
||||
onSelect: vi.fn(),
|
||||
disabled: i === 20,
|
||||
})),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
const menu = screen.getByRole("menu", { hidden: true });
|
||||
const rover = () =>
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((el) => el.getAttribute("tabindex") === "0")?.textContent;
|
||||
|
||||
expect(fireEvent.keyDown(menu, { key: "PageDown" })).toBe(false);
|
||||
expect(rover()).toBe("Row 10");
|
||||
fireEvent.keyDown(menu, { key: "PageDown" });
|
||||
expect(rover()).toBe("Row 21"); // Row 20 is disabled
|
||||
fireEvent.keyDown(menu, { key: "PageDown" });
|
||||
expect(rover()).toBe("Row 24");
|
||||
fireEvent.keyDown(menu, { key: "PageUp" });
|
||||
expect(rover()).toBe("Row 14");
|
||||
});
|
||||
|
||||
it("pages only the submenu the key was pressed in, not the menu around it", () => {
|
||||
// A submenu's surface sits inside its parent's in the React tree, so the key bubbles through the
|
||||
// parent's handler too; paging both would drag the focus back out to the parent's row.
|
||||
renderMenu([
|
||||
{
|
||||
id: "move",
|
||||
label: "Move to",
|
||||
items: Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
label: `Sub ${i}`,
|
||||
onSelect: vi.fn(),
|
||||
})),
|
||||
},
|
||||
...Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `p${i}`,
|
||||
label: `Top ${i}`,
|
||||
onSelect: vi.fn(),
|
||||
})),
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
|
||||
fireEvent.click(screen.getByText("Move to"));
|
||||
const rovers = () =>
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.filter((el) => el.getAttribute("tabindex") === "0")
|
||||
.map((el) => el.textContent ?? "");
|
||||
const outer = () => rovers().filter((label) => !label.startsWith("Sub"));
|
||||
const before = outer();
|
||||
|
||||
fireEvent.keyDown(screen.getByText("Sub 0").closest("button")!, { key: "PageDown" });
|
||||
expect(rovers()).toContain("Sub 10");
|
||||
expect(outer()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { PopoverItem, PopoverSurface, closeThenAct, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
@@ -82,10 +82,8 @@ export function MenuItemList({
|
||||
disabled={it.disabled}
|
||||
label={it.label}
|
||||
title={it.title}
|
||||
onSelect={() => {
|
||||
it.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
// Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
|
||||
onSelect={() => closeThenAct(onClose, it.onSelect)}
|
||||
className={`${ROW}${it.danger ? " text-danger" : ""}`}
|
||||
>
|
||||
{it.icon && (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Which surfaces give the focus back to a card focused as a whole (deep-review E11 rounds 2 and 3). A
|
||||
// menu does, by hand — floating-ui's own return would land on the card's first link (see
|
||||
// ContextMenu.dom.test). A hover card never returned focus, and one closing after the reader clicked a
|
||||
// link in it must not pull the focus back to the card under it.
|
||||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function Card({ open }: { open: boolean }) {
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: () => {}, mode: "hover" });
|
||||
return (
|
||||
<>
|
||||
<div tabIndex={-1} data-testid="card" ref={pop.refs.setReference}>
|
||||
card
|
||||
</div>
|
||||
<PopoverSurface popover={pop}>
|
||||
<a href="#more" data-testid="link">
|
||||
more
|
||||
</a>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const page = (open: boolean) => (
|
||||
<FloatingTree>
|
||||
<Card open={open} />
|
||||
</FloatingTree>
|
||||
);
|
||||
|
||||
describe("AnchoredPopover · a hover card closing", () => {
|
||||
it("does not hand the focus back to the card it was opened over", async () => {
|
||||
const { rerender } = render(page(false));
|
||||
const card = screen.getByTestId("card");
|
||||
act(() => card.focus());
|
||||
rerender(page(true));
|
||||
act(() => screen.getByTestId("link").focus());
|
||||
rerender(page(false));
|
||||
await act(async () => {}); // the hand-back runs in a microtask
|
||||
expect(document.activeElement).not.toBe(card);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ButtonHTMLAttributes,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
} from "@floating-ui/react";
|
||||
import { useLayer } from "../../lib/layerStack";
|
||||
import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging";
|
||||
|
||||
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9
|
||||
// hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
|
||||
@@ -52,7 +55,8 @@ import { useFullscreenPortalHost } from "../../lib/fullscreen";
|
||||
//
|
||||
// S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's
|
||||
// textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine:
|
||||
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only
|
||||
// - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only;
|
||||
// PageUp/PageDown (a page, no loop) are ours, for every list surface incl. the combobox
|
||||
// - useTypeahead → type-to-focus a matching item by its label
|
||||
// - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close
|
||||
// (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and
|
||||
@@ -272,7 +276,48 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
},
|
||||
});
|
||||
|
||||
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
|
||||
// PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's
|
||||
// list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's
|
||||
// channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a
|
||||
// menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has
|
||||
// already switched to keyboard modality — which is what makes it scroll the new row into view. A
|
||||
// nested menu's surface bubbles through its parent's in the React tree; the inner one handles the
|
||||
// key first and marks it, so the outer one stands down.
|
||||
const onPageKey = (e: React.KeyboardEvent) => {
|
||||
if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return;
|
||||
const rows = elementsRef.current;
|
||||
const live = rows.filter((el): el is HTMLElement => el != null);
|
||||
// No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning.
|
||||
if (!live.length) return;
|
||||
e.preventDefault();
|
||||
const step = pageStep(scrollParent(live[0]!), live);
|
||||
const to = pageTarget(
|
||||
activeIndex ?? 0,
|
||||
e.key === "PageDown" ? step : -step,
|
||||
rows.length,
|
||||
(i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"),
|
||||
);
|
||||
if (to === null) return;
|
||||
setActiveIndex(to);
|
||||
setHoverIndex(null);
|
||||
};
|
||||
const pageKeyRef = useRef(onPageKey);
|
||||
pageKeyRef.current = onPageKey;
|
||||
const paging = useMemo(() => {
|
||||
const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e);
|
||||
return { reference: { onKeyDown }, floating: { onKeyDown } };
|
||||
}, []);
|
||||
|
||||
const interactions = useInteractions([
|
||||
click,
|
||||
hover,
|
||||
focus,
|
||||
dismiss,
|
||||
role,
|
||||
listNav,
|
||||
typeahead,
|
||||
paging,
|
||||
]);
|
||||
|
||||
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
|
||||
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
|
||||
@@ -282,8 +327,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
|
||||
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
|
||||
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
|
||||
// an Escape the user aimed at whatever is actually on screen.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
|
||||
// an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer:
|
||||
// it does not own the keyboard, so the page's own keys keep working under a resting pointer.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover });
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
@@ -348,6 +394,18 @@ const HIGHLIGHT_SELECTED =
|
||||
const HIGHLIGHT_SELECTED_RING_ONLY =
|
||||
"data-active:ring-2 data-active:ring-inset data-active:ring-accent/70";
|
||||
|
||||
/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it
|
||||
* has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as
|
||||
* the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in
|
||||
* Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
|
||||
* 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that
|
||||
* microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element
|
||||
* to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */
|
||||
export function closeThenAct(close: () => void, act: () => void): void {
|
||||
close();
|
||||
setTimeout(act, 0);
|
||||
}
|
||||
|
||||
export function PopoverItem({
|
||||
children,
|
||||
onSelect,
|
||||
@@ -543,12 +601,51 @@ function PopoverSurfaceInner({
|
||||
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
|
||||
if (ariaLabel) delete floatingProps["aria-labelledby"];
|
||||
|
||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
|
||||
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
|
||||
// is focus-managed.
|
||||
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
|
||||
|
||||
// Where focus goes back on close. floating-ui returns it to the element focused before the open —
|
||||
// but when that element is not a Tab stop itself, to its first TABBABLE descendant instead, even
|
||||
// when handed the element as a ref (its return runs through `getFirstTabbableElement` either way).
|
||||
// A card the keyboard is on (lib/listNav gives its root tabindex=-1) is exactly that: measured in
|
||||
// Chrome (deep-review E11 round 2), j, Shift+F10, Escape left focus on the card's thumbnail link, the
|
||||
// ring off the card and the next Enter on a control. So for such an element the library's return is
|
||||
// off and this gives the focus back to it as it is — captured at open, before focus moves into the
|
||||
// panel, and only when the focus was still in a floating layer (or nowhere) as the panel closed:
|
||||
// focus the reader moved on to stays where they put it. A menu row's action runs a task later
|
||||
// (`closeThenAct`), so a dialog it opens still captures the card as the element to return to.
|
||||
//
|
||||
// Only for a focus-managed surface, whose return this replaces: a hover card or a combobox never
|
||||
// returned focus, and a hover card closing after the reader clicked a link in it must not pull the
|
||||
// focus back to the card under it (deep-review E11 round 3).
|
||||
const [returnTo] = useState(() => {
|
||||
const active = document.activeElement;
|
||||
return managed &&
|
||||
active instanceof HTMLElement &&
|
||||
active !== document.body &&
|
||||
active.tabIndex < 0
|
||||
? active
|
||||
: null;
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
if (!returnTo) return;
|
||||
return () => {
|
||||
// "Still in the layer" is read off the portal marker, not the panel: FloatingPortal renders the
|
||||
// panel a commit after this surface mounts, so the panel's ref is still empty when this effect
|
||||
// runs (deep-review E11 round 3).
|
||||
const active = document.activeElement;
|
||||
const stillOurs =
|
||||
!active || active === document.body || !!active.closest("[data-floating-ui-portal]");
|
||||
queueMicrotask(() => {
|
||||
if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true });
|
||||
});
|
||||
};
|
||||
}, [returnTo]);
|
||||
|
||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
@@ -609,7 +706,7 @@ function PopoverSurfaceInner({
|
||||
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
|
||||
// input, so such a menu still has a keyboard entry point instead of stranding focus.
|
||||
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
|
||||
returnFocus
|
||||
returnFocus={!returnTo}
|
||||
>
|
||||
{positioned}
|
||||
</FloatingFocusManager>
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Button primitive (S5)", () => {
|
||||
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("loading disables the button, marks aria-busy, and blocks clicks", () => {
|
||||
it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
|
||||
@@ -47,30 +47,58 @@ describe("Button primitive (S5)", () => {
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.getAttribute("aria-busy")).toBe("true");
|
||||
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
||||
// the leading icon is swapped for a spinner
|
||||
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
||||
fireEvent.click(btn);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loading disables even when an explicit disabled={false} is passed", () => {
|
||||
// E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to
|
||||
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
|
||||
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
|
||||
it("loading keeps the button natively enabled, so it keeps the focus", () => {
|
||||
render(<Button loading>Reroll</Button>);
|
||||
const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("loading blocks clicks even when an explicit disabled={false} is passed", () => {
|
||||
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
|
||||
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
|
||||
// clickable mid-mutation and double-submitted. The contract is: loading always disables.
|
||||
// clickable mid-mutation and double-submitted. The contract is: loading always blocks.
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Button disabled={false} loading onClick={onClick}>
|
||||
Create
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
fireEvent.click(btn);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a busy submit button cancels the form's submission", () => {
|
||||
// Enter in a field submits through a click on the default button; the guard cancels that click.
|
||||
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Button type="submit" loading>
|
||||
Send
|
||||
</Button>
|
||||
</form>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a non-busy button still calls onClick", () => {
|
||||
const onClick = vi.fn();
|
||||
render(<Button onClick={onClick}>Go</Button>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Go" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("a link gets no button padding, and an explicit size still wins", () => {
|
||||
// The size default is variant-dependent (link → "none"). Locking it because the failure is
|
||||
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
|
||||
|
||||
@@ -44,28 +44,32 @@ const VARIANTS: Record<ButtonVariant, string> = {
|
||||
// and the ~100 hand-rolled ones had drifted into two different looks. That split IS the
|
||||
// inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
|
||||
outline:
|
||||
"font-medium border border-border text-muted enabled:hover:text-accent enabled:hover:border-accent",
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent enabled:hover:bg-accent/10",
|
||||
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-accent enabled:not-aria-disabled:hover:border-accent",
|
||||
"outline-accent":
|
||||
"font-medium border border-accent/40 text-accent enabled:not-aria-disabled:hover:bg-accent/10",
|
||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||
// is whether the button should advertise the destruction before the pointer arrives.
|
||||
"outline-danger":
|
||||
"font-medium border border-border text-muted enabled:hover:text-danger enabled:hover:border-danger/40",
|
||||
"outline-warn": "font-medium border border-warn/40 text-warn enabled:hover:bg-warn/10",
|
||||
ghost: "font-medium text-muted enabled:hover:text-fg enabled:hover:bg-card",
|
||||
glass: "font-medium glass-card glass-hover enabled:hover:text-accent",
|
||||
"font-medium border border-border text-muted enabled:not-aria-disabled:hover:text-danger enabled:not-aria-disabled:hover:border-danger/40",
|
||||
"outline-warn":
|
||||
"font-medium border border-warn/40 text-warn enabled:not-aria-disabled:hover:bg-warn/10",
|
||||
ghost:
|
||||
"font-medium text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:bg-card",
|
||||
glass: "font-medium glass-card glass-hover enabled:not-aria-disabled:hover:text-accent",
|
||||
"danger-soft":
|
||||
"font-medium border border-danger/60 text-danger enabled:hover:bg-danger/10 enabled:hover:border-danger",
|
||||
"font-medium border border-danger/60 text-danger enabled:not-aria-disabled:hover:bg-danger/10 enabled:not-aria-disabled:hover:border-danger",
|
||||
// No font-weight here on purpose: BASE already sets font-medium, and a second font-* utility would
|
||||
// be a coin-flip (both are single-class utilities of equal specificity, and without tailwind-merge
|
||||
// the winner is decided by the generated stylesheet order, not by the class list order).
|
||||
link: "font-semibold text-accent enabled:hover:underline",
|
||||
link: "font-semibold text-accent enabled:not-aria-disabled:hover:underline",
|
||||
};
|
||||
|
||||
// Why every hover above is `enabled:hover:` — Tailwind's `:not(:disabled)`. A disabled button that
|
||||
// still lights up on hover advertises a click that does nothing, and the ONE migrated call site that
|
||||
// had guarded its own hover (TagManager's Add) silently lost the guard when it moved onto this
|
||||
// primitive (E10 review round 1). The contract belongs here, not in ~160 call sites: BASE dims a
|
||||
// disabled button, and the variant must not paint over that dimming.
|
||||
// Why every hover above is `enabled:not-aria-disabled:hover:` — neither natively disabled nor busy. A
|
||||
// disabled button that still lights up on hover advertises a click that does nothing, and the ONE
|
||||
// migrated call site that had guarded its own hover (TagManager's Add) silently lost the guard when it
|
||||
// moved onto this primitive (E10 review round 1). `not-aria-disabled` is the `loading` half: a busy
|
||||
// button stays natively enabled (see `loading` below) and must not light up either. The contract
|
||||
// belongs here, not in ~160 call sites: BASE dims both, and the variant must not paint over it.
|
||||
|
||||
// The geometry ladder, and the whole of it — a call site picks a rung, it does not invent one. The
|
||||
// rungs were derived from what the app actually used (counted during E10 S1b): `px-3 py-2 text-sm`
|
||||
@@ -86,7 +90,7 @@ const SIZES: Record<ButtonSize, string> = {
|
||||
// link looks like everywhere in this app) and the action buttons want medium. Two font-* utilities on
|
||||
// one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
"inline-flex items-center justify-center transition disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed";
|
||||
|
||||
interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
@@ -98,7 +102,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
iconOnly?: boolean;
|
||||
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
|
||||
label?: string;
|
||||
/** Disable + swap the icon for a spinner + mark aria-busy. */
|
||||
/** Busy: swap the icon for a spinner, mark aria-busy + aria-disabled, and swallow activation — but
|
||||
* NOT native `disabled`, which would throw the focus out of the page (see the render below). */
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
@@ -131,6 +136,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
children,
|
||||
disabled,
|
||||
type,
|
||||
onClick,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@@ -149,11 +155,23 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
// `loading` must always disable (see the prop's contract above), even when a call-site also
|
||||
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}`
|
||||
// would shadow `loading` and leave the button clickable mid-mutation (double-submit).
|
||||
disabled={disabled || loading}
|
||||
// `loading` blocks activation WITHOUT native `disabled`. A focused button that turns disabled
|
||||
// loses the focus: Chrome moved it to <body> ~60 ms after the reroll click on Home and nothing
|
||||
// handed it back when the button re-enabled (measured, E11 S2) — so a keyboard user was dropped
|
||||
// out of the page on every press. `aria-disabled` keeps the button focusable and announced as
|
||||
// unavailable; the click guard is what actually stops the double-submit, whatever `disabled`
|
||||
// says, and its preventDefault also cancels a form's implicit submission (which is a click on
|
||||
// the default button).
|
||||
disabled={disabled}
|
||||
aria-disabled={loading || undefined}
|
||||
aria-busy={loading || undefined}
|
||||
onClick={(e) => {
|
||||
if (loading) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(e);
|
||||
}}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={clsx(
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
|
||||
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { Carousel } from "./Carousel";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
|
||||
beforeAll(() => {
|
||||
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||
@@ -91,3 +92,38 @@ describe("Carousel", () => {
|
||||
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// E11 S4b: a rail on Home, Discover or an info page is a list the keyboard walks — but only when the
|
||||
// caller says its cards carry `navItemAttrs`. A carousel of plain content must not claim `j`.
|
||||
describe("Carousel · keyboard", () => {
|
||||
const cards = () =>
|
||||
[0, 1, 2].map((i) => (
|
||||
<button key={i} {...navItemAttrs(i)}>
|
||||
{`card ${i}`}
|
||||
</button>
|
||||
));
|
||||
const press = (key: string) =>
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("is walked by j when keyboardNav is on", () => {
|
||||
render(
|
||||
<Carousel ariaLabel="Rail" keyboardNav>
|
||||
{cards()}
|
||||
</Carousel>,
|
||||
);
|
||||
press("j");
|
||||
expect(document.activeElement?.textContent).toBe("card 0");
|
||||
press("j");
|
||||
expect(document.activeElement?.textContent).toBe("card 1");
|
||||
});
|
||||
|
||||
it("leaves j alone without it", () => {
|
||||
render(<Carousel ariaLabel="Rail">{cards()}</Carousel>);
|
||||
press("j");
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { useCallback, useRef, type ReactNode } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useScrollEdges } from "../../lib/useScrollEdges";
|
||||
import { useListNav } from "../../lib/listNav";
|
||||
|
||||
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
|
||||
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
|
||||
@@ -43,6 +44,7 @@ export function Carousel({
|
||||
ariaLabel,
|
||||
gap = "gap-3",
|
||||
className,
|
||||
keyboardNav = false,
|
||||
children,
|
||||
}: {
|
||||
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
|
||||
@@ -55,10 +57,24 @@ export function Carousel({
|
||||
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
|
||||
gap?: string;
|
||||
className?: string;
|
||||
/** The cards spread `navItemAttrs(i)` on their roots: `j`/`k`, ←→ and Enter walk the rail, and ↑↓
|
||||
* leave it for the rail above or below (lib/listNav). */
|
||||
keyboardNav?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { ref, el, left, right } = useHScroll();
|
||||
// The scroller is the list the keyboard walks. useHScroll holds the node through a callback ref,
|
||||
// listNav reads a ref object — one node, both handed it.
|
||||
const navRef = useRef<HTMLDivElement | null>(null);
|
||||
const setScroller = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
ref(node);
|
||||
navRef.current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
useListNav(navRef, { enabled: keyboardNav });
|
||||
|
||||
const page = useCallback(
|
||||
(dir: -1 | 1) => {
|
||||
@@ -112,9 +128,11 @@ export function Carousel({
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
|
||||
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
|
||||
ref={setScroller}
|
||||
className={clsx("no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1", gap)}
|
||||
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
|
||||
@@ -247,3 +247,44 @@ describe("Combobox", () => {
|
||||
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed
|
||||
});
|
||||
});
|
||||
|
||||
// PageUp/PageDown (E11, owner request): the sidebar's channel filter and the Plex pickers run to
|
||||
// hundreds of rows, and floating-ui's list navigation has only the arrows. jsdom has no layout, so a
|
||||
// page is the fixed fallback of 10 rows; the measured page is pinned in lib/listPaging.
|
||||
describe("Combobox · paging", () => {
|
||||
const rows = (disabled: number[] = []) =>
|
||||
Array.from({ length: 25 }, (_, i) => ({ value: `Row ${i}`, disabled: disabled.includes(i) }));
|
||||
const activeValue = (input: HTMLElement) =>
|
||||
document.getElementById(input.getAttribute("aria-activedescendant") ?? "")?.textContent;
|
||||
|
||||
it("moves the active option a page at a time, stopping at both ends", () => {
|
||||
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
expect(fireEvent.keyDown(input, { key: "PageDown" })).toBe(false); // the page does not scroll
|
||||
expect(activeValue(input)).toBe("Row 10");
|
||||
expect(document.activeElement).toBe(input);
|
||||
fireEvent.keyDown(input, { key: "PageDown" });
|
||||
fireEvent.keyDown(input, { key: "PageDown" });
|
||||
expect(activeValue(input)).toBe("Row 24"); // no wrap to the top
|
||||
fireEvent.keyDown(input, { key: "PageUp" });
|
||||
expect(activeValue(input)).toBe("Row 14");
|
||||
});
|
||||
|
||||
it("never lands on a disabled option, and Enter commits where it landed", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Channel" options={rows([10])} selected={[]} onChange={onChange} />);
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
fireEvent.focus(input);
|
||||
fireEvent.keyDown(input, { key: "PageDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["Row 11"]);
|
||||
});
|
||||
|
||||
it("leaves the key alone while the list is closed", () => {
|
||||
wrap(<Combobox label="Channel" options={rows()} selected={[]} onChange={vi.fn()} />);
|
||||
expect(fireEvent.keyDown(screen.getByRole("combobox"), { key: "PageDown" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
@@ -21,7 +21,7 @@ beforeAll(() => {
|
||||
afterEach(cleanup);
|
||||
|
||||
// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before
|
||||
// running the command (see closeThenNavigate), so the command lands a tick later — hence the waits.
|
||||
// running the command (see closeTopOverlayThenRun), so the command lands a tick later — hence the waits.
|
||||
const ran: string[] = [];
|
||||
const groups = (): CommandGroup[] => [
|
||||
{
|
||||
@@ -160,3 +160,176 @@ 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("");
|
||||
});
|
||||
});
|
||||
|
||||
// Owner request (E11): with 461 rows and ~11 in view, the arrows alone were a slog. PageUp/PageDown
|
||||
// move by what fits in the list, and — unlike the arrows — stop at the ends instead of wrapping.
|
||||
describe("CommandPalette · paging", () => {
|
||||
const many = (): CommandGroup[] => [
|
||||
{
|
||||
id: "rows",
|
||||
label: "Rows",
|
||||
items: Array.from({ length: 12 }, (_, i) => ({
|
||||
id: `r${i}`,
|
||||
label: `Row ${i}`,
|
||||
run: () => {},
|
||||
})),
|
||||
},
|
||||
];
|
||||
const openMany = () => render(<CommandPalette open onClose={() => {}} groups={many()} />);
|
||||
const rect = (top: number, height: number) =>
|
||||
({
|
||||
top,
|
||||
bottom: top + height,
|
||||
height,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: top,
|
||||
}) as DOMRect;
|
||||
|
||||
it("pages by the rows in view minus one, and stops at both ends", () => {
|
||||
// The list shows 100px and the rows are 30px tall: 3 fit, so a page is 2 rows.
|
||||
const spy = vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (
|
||||
this: Element,
|
||||
) {
|
||||
if (this.getAttribute("role") === "listbox") return rect(0, 100);
|
||||
const all = [...document.querySelectorAll('[role="option"]')];
|
||||
return rect(all.indexOf(this) * 30, 30);
|
||||
});
|
||||
// Restored in `finally`: a spy left behind by a failing assertion fails every later test too.
|
||||
try {
|
||||
openMany();
|
||||
press("PageDown");
|
||||
expect(selected()).toBe("r2");
|
||||
press("PageDown");
|
||||
expect(selected()).toBe("r4");
|
||||
for (let i = 0; i < 10; i++) press("PageDown");
|
||||
expect(selected()).toBe("r11"); // no wrap to the top
|
||||
press("PageUp");
|
||||
expect(selected()).toBe("r9");
|
||||
for (let i = 0; i < 10; i++) press("PageUp");
|
||||
expect(selected()).toBe("r0");
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("steps a fixed page when there is no layout to measure", () => {
|
||||
openMany();
|
||||
press("PageDown");
|
||||
expect(selected()).toBe("r10");
|
||||
press("PageDown");
|
||||
expect(selected()).toBe("r11");
|
||||
});
|
||||
|
||||
it("leaves Home and End to the text field", () => {
|
||||
openMany();
|
||||
press("PageDown");
|
||||
expect(fireEvent.keyDown(field(), { key: "Home" })).toBe(true); // not preventDefault-ed
|
||||
expect(fireEvent.keyDown(field(), { key: "End" })).toBe(true);
|
||||
expect(selected()).toBe("r10");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette
|
||||
// (see closeThenNavigate), and it ASSERTS on a real useBackToClose entry driven through a real
|
||||
// programmatic history.back() — which is why it cannot share module state with anything. (It is not
|
||||
// the only place in the suite where an unstubbed back() fires; history.burial.dom.test.ts is the
|
||||
// other file that asserts on one, and any test rendering a real Modal can let one go.)
|
||||
// (see closeTopOverlayThenRun), and it ASSERTS on a real useBackToClose entry driven through a real
|
||||
// programmatic history.back() — which is why it cannot share module state with anything. (Any test
|
||||
// rendering a real Modal can let an unstubbed back() go, too.)
|
||||
//
|
||||
// It cannot live in CommandPalette.dom.test.tsx. jsdom gives a test FILE a single session history,
|
||||
// and history.ts's overlay bookkeeping is module-global, so the sibling tests there leave an
|
||||
@@ -50,7 +49,7 @@ function Harness() {
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommandPalette · closeThenNavigate ordering", () => {
|
||||
describe("CommandPalette · close-then-run ordering", () => {
|
||||
it("closes, and runs the command only once the close has landed", async () => {
|
||||
render(<Harness />);
|
||||
// Load-bearing: useBackToClose pushes its entry from a coalesced microtask, so a click in the
|
||||
@@ -62,8 +61,9 @@ describe("CommandPalette · closeThenNavigate ordering", () => {
|
||||
fireEvent.click(screen.getByText("TheVR Podcast"));
|
||||
expect(ran).toEqual(["closed"]);
|
||||
|
||||
// Inside closeThenNavigate's 250ms no-popstate fallback, so a pass means the RECLAIM ran the
|
||||
// command — not the safety net that exists for overlays which never got an entry at all.
|
||||
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 200 });
|
||||
// Well inside closeTopOverlayThenRun's 1500 ms give-up timer, so a pass means the RECLAIM ran the
|
||||
// command — not the late path for a back() that never reports. (The margin used to be 200 ms
|
||||
// under a 250 ms fallback, which made this load-sensitive by construction.)
|
||||
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 1000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "rea
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { closeThenNavigate } from "../../lib/history";
|
||||
import { closeTopOverlayThenRun } from "../../lib/history";
|
||||
import { pageStep, pageTarget } from "../../lib/listPaging";
|
||||
|
||||
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
|
||||
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
|
||||
@@ -40,14 +41,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,42 +83,81 @@ 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);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// A fresh open starts from a blank field: the palette is for "take me somewhere", and a leftover
|
||||
// query from last time hides everything you might want now.
|
||||
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
|
||||
// navigate, and a pushState issued before that reclaim is undone by it — see closeThenNavigate.
|
||||
const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run);
|
||||
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run: most commands
|
||||
// navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
|
||||
// a command that navigates closes whatever is underneath itself, one that does not (a theme) must
|
||||
// leave it standing. See closeTopOverlayThenRun.
|
||||
const choose = (item: CommandItem) => closeTopOverlayThenRun(onClose, item.run);
|
||||
|
||||
// These live on the input rather than in the shared keymap: they are the field's own editing keys
|
||||
// (like a <select>'s arrows), not app shortcuts — and the table only describes what is global.
|
||||
@@ -103,9 +166,22 @@ 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 === "PageDown" || e.key === "PageUp") {
|
||||
// A page at a time, stopping at the ends (lib/listPaging). The list is 50vh and group headings
|
||||
// eat into it, so the page is measured. Home/End stay the field's own (caret to the query's ends).
|
||||
e.preventDefault();
|
||||
const list = listRef.current;
|
||||
const step = pageStep(list, list?.querySelectorAll('[role="option"]') ?? []);
|
||||
const to = pageTarget(
|
||||
Math.max(activeIndex, 0),
|
||||
e.key === "PageDown" ? step : -step,
|
||||
flat.length,
|
||||
);
|
||||
if (to !== null) setPicked(flat[to]!.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);
|
||||
@@ -122,72 +198,92 @@ 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
|
||||
ref={listRef}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,13 +108,13 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
it("selecting an item fires it and closes the menu", () => {
|
||||
it("selecting an item closes the menu, then fires it (see ActionMenu's order test)", async () => {
|
||||
const onSelect = vi.fn();
|
||||
renderList(() => [{ id: "a", label: "Remove", onSelect }]);
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Remove"));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("Shift+F10 opens it from the keyboard", () => {
|
||||
@@ -130,6 +130,42 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
expect(menuitems()).toEqual([]);
|
||||
});
|
||||
|
||||
// A card the keyboard is on is focused as a whole, through a tabindex=-1 root (lib/listNav), and
|
||||
// holds tabbable controls. floating-ui handed the focus back to the first of them instead of the
|
||||
// card — measured in Chrome (deep-review E11 round 2): j, Shift+F10, Escape left it on the thumbnail.
|
||||
// Closed here by picking the row (the other way out; jsdom delivers no Escape to a keyboard-opened
|
||||
// menu — Escape is measured in the browser).
|
||||
it("gives the focus back to a card focused as a whole, not to a control inside it", async () => {
|
||||
function Card() {
|
||||
const menu = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
<div tabIndex={-1} data-testid="card" {...menu.triggerProps("card")}>
|
||||
<a href="#thumb">thumbnail</a>
|
||||
</div>
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel="Card actions"
|
||||
items={menu.target ? [{ id: "a", label: "Pin", onSelect: vi.fn() }] : []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<FloatingTree>
|
||||
<Card />
|
||||
</FloatingTree>,
|
||||
);
|
||||
const card = screen.getByTestId("card");
|
||||
act(() => card.focus());
|
||||
fireEvent.keyDown(card, { key: "F10", shiftKey: true });
|
||||
expect(menuitems()).toEqual(["Pin"]);
|
||||
await vi.waitFor(() => expect(card.contains(document.activeElement)).toBe(false));
|
||||
fireEvent.click(item("Pin"));
|
||||
expect(menuitems()).toEqual([]);
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(card));
|
||||
});
|
||||
|
||||
describe("long press (touch)", () => {
|
||||
const press = (el: HTMLElement, x = 10, y = 10) =>
|
||||
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y });
|
||||
@@ -217,7 +253,7 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
expect(menuitems()).toEqual(["Play", "Move to", "Watch later", "Homelab"]);
|
||||
});
|
||||
|
||||
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", () => {
|
||||
it("choosing inside a submenu closes the WHOLE stack, not just the submenu", async () => {
|
||||
const chosen = vi.fn();
|
||||
renderList(() => [
|
||||
{ id: "play", label: "Play", onSelect: vi.fn() },
|
||||
@@ -226,8 +262,8 @@ describe("useContextMenu / ContextMenu", () => {
|
||||
fireEvent.contextMenu(row("alpha"));
|
||||
fireEvent.click(item("Move to"));
|
||||
fireEvent.click(item("Homelab"));
|
||||
expect(chosen).toHaveBeenCalledTimes(1);
|
||||
expect(menuitems()).toEqual([]);
|
||||
await vi.waitFor(() => expect(chosen).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// DataTable's `keyboardNav` (E11 S4c): the rows are a keyboard list — `j`/`k` walk them and Enter
|
||||
// clicks the element a column marks `data-nav-open`. The table renders its rows twice (a table and,
|
||||
// below `md`, a card list); only the copy on screen may be walked, or `j` past the last row would carry
|
||||
// on into the hidden one.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type Row = { id: string };
|
||||
const rows: Row[] = [{ id: "a" }, { id: "b" }];
|
||||
|
||||
function table(onOpen: (id: string) => void, keyboardNav = true) {
|
||||
const columns: Column<Row>[] = [
|
||||
{
|
||||
key: "name",
|
||||
header: "Name",
|
||||
cardPrimary: true,
|
||||
render: (r) => (
|
||||
<button data-nav-open="" onClick={() => onOpen(r.id)}>
|
||||
{r.id}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ key: "act", header: "", render: (r) => <button>act {r.id}</button> },
|
||||
];
|
||||
return render(
|
||||
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} keyboardNav={keyboardNav} />,
|
||||
);
|
||||
}
|
||||
|
||||
function press(key: string) {
|
||||
act(() => {
|
||||
(document.activeElement ?? document.body).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** What a display:none copy answers in a browser; jsdom lays nothing out, so every copy is "visible". */
|
||||
const offScreen = (el: Element | null) =>
|
||||
Object.defineProperty(el!, "checkVisibility", { value: () => false, configurable: true });
|
||||
|
||||
/** The focused row: which copy (TR / DIV) and which row. */
|
||||
const focused = () => {
|
||||
const el = document.activeElement as HTMLElement;
|
||||
return `${el.tagName} ${el.querySelector("[data-nav-open]")?.textContent ?? ""}`;
|
||||
};
|
||||
|
||||
describe("DataTable · keyboardNav", () => {
|
||||
it("walks the table's rows, stops at the last one, and opens a row on Enter", () => {
|
||||
const opened: string[] = [];
|
||||
const { container } = table((id) => opened.push(id));
|
||||
offScreen(container.querySelector(".md\\:hidden"));
|
||||
press("j");
|
||||
expect(focused()).toBe("TR a");
|
||||
press("j");
|
||||
expect(focused()).toBe("TR b");
|
||||
// The hidden card copy follows the table in the DOM — it must not be the next stop.
|
||||
press("j");
|
||||
expect(focused()).toBe("TR b");
|
||||
press("Enter");
|
||||
expect(opened).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("walks the card copy when that is the one on screen", () => {
|
||||
const { container } = table(() => {});
|
||||
offScreen(container.querySelector("tbody"));
|
||||
press("j");
|
||||
expect(focused()).toBe("DIV a");
|
||||
});
|
||||
|
||||
it("leaves the rows out of the keyboard without keyboardNav", () => {
|
||||
const { container } = table(() => {}, false);
|
||||
expect(container.querySelector("[data-nav-index]")).toBeNull();
|
||||
press("j");
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@ 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";
|
||||
import { navItemAttrs, useListNav } from "../../lib/listNav";
|
||||
|
||||
// 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 +13,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;
|
||||
@@ -62,6 +64,7 @@ export default function DataTable<T>({
|
||||
filters: filtersProp,
|
||||
onFiltersChange,
|
||||
fixedLayout = false,
|
||||
keyboardNav = false,
|
||||
}: {
|
||||
rows: T[];
|
||||
columns: Column<T>[];
|
||||
@@ -110,6 +113,10 @@ export default function DataTable<T>({
|
||||
// the viewport. Off by default so existing content-sized tables (playlists, discovery, Plex) are
|
||||
// untouched; the channel manager turns it on so its row never scrolls horizontally out of view.
|
||||
fixedLayout?: boolean;
|
||||
// Take part in page keyboard navigation (`lib/listNav`): `j`/`k` and the arrows walk the rows, and
|
||||
// Enter clicks the element a column's render marks `data-nav-open` (the channel name). Both row
|
||||
// shapes register; listNav walks only the one on screen, so the hidden copy is never a stop.
|
||||
keyboardNav?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const initial = loadPersist(persistKey);
|
||||
@@ -125,6 +132,10 @@ export default function DataTable<T>({
|
||||
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const cardsRef = useRef<HTMLDivElement>(null);
|
||||
useListNav(bodyRef, { enabled: keyboardNav });
|
||||
useListNav(cardsRef, { enabled: keyboardNav });
|
||||
|
||||
// Commit a whole new filter map through the owner (controlled) or internal state. A ref holds the
|
||||
// latest resolved map so the externalFilter/reset effects can derive from it without taking
|
||||
@@ -256,11 +267,12 @@ export default function DataTable<T>({
|
||||
filters={filters}
|
||||
onFilter={applyFilter}
|
||||
/>
|
||||
<tbody>
|
||||
{paged.map((row) => (
|
||||
<tbody ref={bodyRef}>
|
||||
{paged.map((row, i) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
{...rowProps?.(row)}
|
||||
{...(keyboardNav && navItemAttrs(i))}
|
||||
className={`border-b border-border/50 hover:bg-(--hover) transition ${
|
||||
rowClassName?.(row) ?? ""
|
||||
}`}
|
||||
@@ -282,11 +294,12 @@ export default function DataTable<T>({
|
||||
|
||||
{/* Narrow screens: a compact card per row — the primary column as the heading, the
|
||||
rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
|
||||
<div className="md:hidden flex flex-col gap-2">
|
||||
{paged.map((row) => (
|
||||
<div ref={cardsRef} className="md:hidden flex flex-col gap-2">
|
||||
{paged.map((row, i) => (
|
||||
<div
|
||||
key={rowKey(row)}
|
||||
{...rowProps?.(row)}
|
||||
{...(keyboardNav && navItemAttrs(i))}
|
||||
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
{columns
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useKeyScope,
|
||||
type KeyScope,
|
||||
} from "../../lib/keymap";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -27,9 +28,11 @@ afterEach(() => {
|
||||
closeKeyboardHelp();
|
||||
});
|
||||
|
||||
/** App's job: it binds the `global` scope (h / Mod+K / Shift+W) once for the whole tree. */
|
||||
/** App's job: it binds the `global` scope (h / Mod+K / Shift+W) and the `page` scope (the `g` chords)
|
||||
* once for the whole tree. Home is the one module this stand-in account reaches, so the page block
|
||||
* is the single `G H` chord, sharing Shift+W's line. */
|
||||
function Host({ children }: { children?: React.ReactNode }) {
|
||||
useGlobalKeymap(true, () => {});
|
||||
useGlobalKeymap(true, () => {}, ["home"]);
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
@@ -81,15 +84,16 @@ describe("KeyboardHelp", () => {
|
||||
);
|
||||
pressH();
|
||||
const rows = descriptions();
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants. (Home's
|
||||
// line comes from the page block, which sits between the two; Shift+W joins it there.)
|
||||
expect(rows[0]).toBe("keys.playPause");
|
||||
expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.home"]);
|
||||
expect(rows.slice(-3)).toEqual(["keys.home", "keys.palette", "keys.help"]);
|
||||
});
|
||||
|
||||
it("lists only the app-wide keys when no surface is registered", () => {
|
||||
render(<Host />);
|
||||
pressH();
|
||||
expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.home"]);
|
||||
expect(descriptions()).toEqual(["keys.home", "keys.palette", "keys.help"]);
|
||||
});
|
||||
|
||||
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
|
||||
@@ -120,6 +124,24 @@ describe("KeyboardHelp", () => {
|
||||
expect(descriptions()).not.toContain("keys.contextMenu");
|
||||
});
|
||||
|
||||
// Opened over a player, Shift+F10 has nothing to reach — focus is inside the player, not on a row.
|
||||
it("drops the context-menu key from a sheet opened over a keyboard-owning layer", () => {
|
||||
function WithMenu() {
|
||||
useContextMenu<string>();
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<Host>
|
||||
<WithMenu />
|
||||
</Host>,
|
||||
);
|
||||
const id = pushLayer();
|
||||
pressH();
|
||||
removeLayer(id);
|
||||
expect(descriptions()).not.toContain("keys.contextMenu");
|
||||
expect(descriptions()).toContain("keys.help");
|
||||
});
|
||||
|
||||
// Two live context menus (the Playlists page mounts one, its rail another) must not double the row.
|
||||
it("lists the context-menu chords once however many surfaces register", () => {
|
||||
function WithMenu() {
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
closeKeyboardHelp,
|
||||
formatChord,
|
||||
helpLines,
|
||||
isPageLevelScope,
|
||||
useActiveScopes,
|
||||
useHelpConditionValues,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyboardHelpOverLayer,
|
||||
useNavigablePages,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in
|
||||
@@ -29,11 +32,22 @@ export default function KeyboardHelp() {
|
||||
const open = useKeyboardHelpOpen();
|
||||
const scopes = useActiveScopes();
|
||||
const conditions = useHelpConditionValues();
|
||||
const overLayer = useKeyboardHelpOverLayer();
|
||||
const pages = useNavigablePages();
|
||||
|
||||
if (!open) return null;
|
||||
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
|
||||
// a promise the player does not keep.
|
||||
const lines = helpLines(scopes).filter((l) => !l.when || !!conditions[l.when]);
|
||||
// Opened over a player or a dialog, the page-level keys (`s`, `g …`, `j`/`k`) are quiet — listing
|
||||
// them there would promise keys that do nothing until the overlay closes. And a `g` chord is listed
|
||||
// only for a module this account can actually reach.
|
||||
// The context-menu key goes too: every `useContextMenu` is a page list, and with an overlay up the
|
||||
// focus Shift+F10 needs is inside the overlay (pre-existing since E10, found in S4 UAT).
|
||||
const live = overLayer
|
||||
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
|
||||
: scopes;
|
||||
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
|
||||
const words = { Space: t("keys.cap.space"), ContextMenu: t("keys.cap.menu") };
|
||||
return (
|
||||
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
@@ -45,7 +59,7 @@ export default function KeyboardHelp() {
|
||||
key={chord}
|
||||
className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap"
|
||||
>
|
||||
{formatChord(chord)}
|
||||
{formatChord(chord, words)}
|
||||
</kbd>
|
||||
))}
|
||||
</dt>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user