release: 0.85.0 (feature/e11-power-navigation)

This commit is contained in:
2026-09-13 21:02:22 +02:00
201 changed files with 6879 additions and 1241 deletions
+25
View File
@@ -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). 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 ## 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. 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.
+6 -2
View File
@@ -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 components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That 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 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 feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
test). A new generic component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md). 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 Data via react-query (`qk` factory), per-account `localStorage`, and server prefs; API through
`lib/api/*` slices over `core.req<T>`. `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 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 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 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 control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals | | 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) ## House rules that bite (learned from real bugs)
+1 -1
View File
@@ -1 +1 @@
0.84.0 0.85.0
+2 -2
View File
@@ -175,7 +175,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
"en": ( "en": (
"Hi,\n\n" "Hi,\n\n"
"Someone has opened up Siftlode for you — a quiet, self-hosted reader for your YouTube " "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 " "actually filter and sort, with no algorithm deciding what you see, and no Shorts or "
"livestreams unless you want them.\n\n" "livestreams unless you want them.\n\n"
"{next_step}\n\n" "{next_step}\n\n"
@@ -186,7 +186,7 @@ MAIL_TEXT: dict[str, dict[str, str]] = {
"Szia,\n\n" "Szia,\n\n"
"Valaki megnyitotta előtted a Siftlode-ot — ez egy csendes, saját üzemeltetésű olvasó " "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 " "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" "eldönti, mit látsz, és nincs Shorts vagy élő adás, hacsak nem kéred.\n\n"
"{next_step}\n\n" "{next_step}\n\n"
"Nem sürget semmi — az ajtó nyitva marad.\n\n" "Nem sürget semmi — az ajtó nyitva marad.\n\n"
+1 -1
View File
@@ -46,7 +46,7 @@ WELCOME = {
"This is your private message inbox. Conversations with other members are end-to-end " "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 " "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" "admin. To start messaging someone, set up secure messaging with a passphrase first.\n\n"
"Enjoy your feed!" "Enjoy your Feed!"
), ),
"hu": ( "hu": (
"Üdv a Siftlode-on! 👋\n\n" "Üdv a Siftlode-on! 👋\n\n"
+22
View File
@@ -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, prettier,
); );
-33
View File
@@ -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
View File
@@ -3,6 +3,7 @@ import { qk } from "./lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api"; import { api, getActiveAccount, setActiveAccount, setUnauthorizedHandler } from "./lib/api";
import { notifyActionError } from "./lib/actionErrors";
import { setLanguage, isSupported, type LangCode } from "./i18n"; import { setLanguage, isSupported, type LangCode } from "./i18n";
import { clearParams } from "./lib/urlState"; import { clearParams } from "./lib/urlState";
import type { Page } 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 { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useChannelLayout } from "./components/ChannelsProvider"; import { useChannelLayout } from "./components/ChannelsProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider"; 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"; import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
// Persistent shell — always mounted, so eagerly imported. // Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome"; import Welcome from "./components/Welcome";
@@ -37,7 +38,6 @@ import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp"; import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette"; import AppCommandPalette from "./components/AppCommandPalette";
import { useGlobalKeymap } from "./lib/keymap"; import { useGlobalKeymap } from "./lib/keymap";
import { navigateOverOverlays } from "./lib/history";
import { StateMessage } from "./components/ui/QueryState"; import { StateMessage } from "./components/ui/QueryState";
import ErrorDialog from "./components/ErrorDialog"; import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner"; import VersionBanner from "./components/VersionBanner";
@@ -98,7 +98,9 @@ export default function App() {
function setPanelLayout(panel: PanelId, next: PanelLayout) { function setPanelLayout(panel: PanelId, next: PanelLayout) {
setPanelLayouts((prev) => ({ ...prev, [panel]: next })); setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(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 // 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 // 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 // (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. // 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 // A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
// not know it: the page change unmounts the player (it lives inside the module), whose reconcile // so Shift+W over the player lands on Home with the player closed — also when Home is already the
// then reclaimed the entry this push had just made — Shift+W over a playing video closed the // page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
// player and snapped straight back to the feed. `setPage`'s boolean is load-bearing here, not // The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
// decoration: pressed while ALREADY on Home (the default landing page) it navigates nowhere, and // account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
// burying the open player's entry under a push that never happened orphaned it — see `buryUnder`. useGlobalKeymap(
useGlobalKeymap(!!meQuery.data, () => navigateOverOverlays(() => setPage("home"))); !!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 // 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 // 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"), label: t("common.accessRequestsReview"),
onClick: () => { onClick: () => {
focusAccessRequestsTab(); focusAccessRequestsTab();
// Over whatever overlay the toast was clicked in — as navigateTo does (see lib/nav). // Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
navigateOverOverlays(() => setPage("users")); setPage("users");
}, },
}, },
}); });
@@ -291,7 +296,11 @@ export default function App() {
// Language is edited outside the Settings page (sidebar/login), so it stays instant. // Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) { function changeLanguage(code: LangCode) {
setLanguage(code); 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. // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
+1 -1
View File
@@ -49,7 +49,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
ref={pop.refs.setReference} ref={pop.refs.setReference}
{...pop.getReferenceProps()} {...pop.getReferenceProps()}
tabIndex={0} 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} {preview}
</span> </span>
+8 -11
View File
@@ -7,8 +7,9 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
import { invalidateUserAccess, isEmailOff } from "../lib/adminUsers"; import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin"; 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 // 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({ const issue = useMutation({
mutationFn: () => api.createSetPasswordLink(user.id), mutationFn: () => api.createSetPasswordLink(user.id),
onSuccess: (r) => setLink({ url: r.url, expires_at: r.expires_at }), 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({ const resend = useMutation({
mutationFn: () => api.resendUserVerification(user.id), mutationFn: () => api.resendUserVerification(user.id),
onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }), onSuccess: () => notify({ level: "success", message: t("users.account.resendSent") }),
onError: (err) => onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
}); });
const activate = useMutation({ const activate = useMutation({
mutationFn: () => api.activateUser(user.id), mutationFn: () => api.activateUser(user.id),
onSuccess: () => invalidateUserAccess(qc, 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 () => { const copy = async () => {
@@ -201,7 +196,9 @@ export default function AccountPanel({
<button <button
onClick={() => resend.mutate()} 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" 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" /> <Send className="w-4 h-4 shrink-0" />
@@ -86,7 +86,8 @@ describe("a user action that fails", () => {
await waitFor(() => await waitFor(() =>
expect(notify).toHaveBeenCalledWith({ expect(notify).toHaveBeenCalledWith({
level: "error", 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)); await waitFor(() => expect(users).toHaveBeenCalledTimes(2));
+9 -32
View File
@@ -24,7 +24,7 @@ import {
type PendingAccessRow, type PendingAccessRow,
} from "../lib/api"; } from "../lib/api";
import { notify } from "../lib/notifications"; 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 { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip"; import Tooltip from "./ui/Tooltip";
@@ -38,7 +38,7 @@ import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./ui/Tabs"; import Tabs, { usePersistedTab } from "./ui/Tabs";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe"; 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 // 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 // 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) -------------------------------------------- // --- 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 // The gap this closes: approve an address BEFORE the person registers and their sign-up produces no
@@ -351,7 +345,7 @@ function PendingAccess() {
refresh(); refresh();
notify({ level: "success", message: t("users.pendingAccess.activated", { email: u.email }) }); 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({ const resend = useMutation({
@@ -360,15 +354,9 @@ function PendingAccess() {
refresh(); refresh();
notify({ level: "success", message: t("users.pendingAccess.resent", { email: u.email }) }); 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 // The public resend is deliberately silent; this one is not, and the server's reason says WHY
// with no working SMTP would otherwise show a success toast for a mail that was never sent. // (no working SMTP, a refused send) — a success toast for a mail never sent would be the lie.
onError: (err) => onError: (err) => notifyActionError(err, t("users.pendingAccess.resendFailed")),
notify({
level: "error",
message: isEmailOff(err)
? t("users.pendingAccess.resendEmailOff")
: t("users.pendingAccess.resendFailed"),
}),
}); });
const list = rows.data ?? []; 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 // 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. // 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; const conflict = e instanceof HttpError && e.status === 409;
notify({ if (conflict) notifyKnownFailure(e, t("settings.invites.deleteHasAccount"));
level: "error", else notifyActionError(e, t("settings.invites.deleteFailed"));
message: t(
conflict ? "settings.invites.deleteHasAccount" : "settings.invites.deleteFailed",
),
});
if (conflict) refresh(); if (conflict) refresh();
}, },
}); });
@@ -598,14 +582,7 @@ function AdminInvites() {
refresh(); refresh();
notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) }); notify({ level: "success", message: t("settings.invites.invited", { email: v.inv.email }) });
}, },
onError: (err) => onError: (err) => notifyActionError(err, t("settings.invites.inviteFailed")),
notify({
level: "error",
message:
err instanceof HttpError && inviteEmailOff(err)
? t("settings.invites.inviteEmailOff")
: t("settings.invites.inviteFailed"),
}),
}); });
// The one recipient the app has no language signal about: the admin typed this address in, so // 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 // nobody ever told us how to write to its owner. The dialog is where that gets decided — and it
+8 -3
View File
@@ -108,9 +108,13 @@ export default function ChannelDiscovery({
} }
> >
<button <button
onClick={() => onSubscribe(c)} // Busy blocks the click, not the focus: a focused button that turns natively `disabled`
disabled={!canWrite || subscribe.isPending} // drops the keyboard to <body> (the rule and its measurement: ui/Button).
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" 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" /> <UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")} {t("channels.discovery.subscribe")}
@@ -238,6 +242,7 @@ export default function ChannelDiscovery({
sort={sort} sort={sort}
onSortChange={setSort} onSortChange={setSort}
emptyText={t("channels.discovery.empty")} emptyText={t("channels.discovery.empty")}
keyboardNav
/> />
) : ( ) : (
<ChannelLayoutGrid <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 { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid"; import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout"; 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 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 // 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. // 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 // 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. // `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> { interface ChannelLayoutGridProps<T> {
rows: 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. // 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 meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
const renderCard = (row: T) => ( const renderCard = (row: T, index: number) => (
<div <div
{...rowProps?.(row)} {...rowProps?.(row)}
{...navItemAttrs(index)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`} 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"> <div className="flex items-start justify-between gap-2">
@@ -91,7 +96,8 @@ export default function ChannelLayoutGrid<T>({
minCol={spec.minCol} minCol={spec.minCol}
colMaxWidth={null} colMaxWidth={null}
rowEst={spec.rowEst} rowEst={spec.rowEst}
renderItem={renderCard} keyboardNav
renderItem={(row, _itemWidth, index) => renderCard(row, index)}
/> />
); );
} }
+3
View File
@@ -30,6 +30,9 @@ export default function ChannelLink({
{onView ? ( {onView ? (
<button <button
onClick={onView} 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) => { onAuxClick={(e) => {
// Middle-click opens the channel's YouTube page in a new tab; left-click keeps // Middle-click opens the channel's YouTube page in a new tab; left-click keeps
// opening the in-app channel detail. // opening the in-app channel detail.
+1 -1
View File
@@ -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 // 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 // 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 // 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"> & { type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick" | "title"> & {
channelId: string; channelId: string;
title: string | null | undefined; 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);
},
);
});
+42 -28
View File
@@ -29,6 +29,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; 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 { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView"; import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format"; import { channelYouTubeUrl, formatViews } from "../lib/format";
@@ -106,18 +107,23 @@ export default function ChannelPage({
const runExplore = useCallback( const runExplore = useCallback(
(token?: string | null) => { (token?: string | null) => {
setExploring(true); setExploring(true);
return api return (
.exploreChannel(channelId, token ?? undefined) api
.then((r) => { .exploreChannel(channelId, token ?? undefined)
setNextToken(r.next_page_token); .then((r) => {
qc.invalidateQueries({ queryKey: qk.feed() }); setNextToken(r.next_page_token);
// Refetch the detail so `explored` flips true → the "Exploring" badge shows on the qc.invalidateQueries({ queryKey: qk.feed() });
// first visit (the GET that drove this render predated the explore that just ran). // Refetch the detail so `explored` flips true → the "Exploring" badge shows on the
qc.invalidateQueries({ queryKey: qk.channel(channelId) }); // first visit (the GET that drove this render predated the explore that just ran).
}) qc.invalidateQueries({ queryKey: qk.channel(channelId) });
.finally(() => setExploring(false)); })
// 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 // 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.subscribed || ch.blocked) return;
if (ch.explored && ch.stored_videos > 0) return; if (ch.explored && ch.stored_videos > 0) return;
autoTried.current = channelId; autoTried.current = channelId;
runExplore().catch(() => {}); void runExplore();
}, [ch, channelId, me.is_demo, runExplore]); }, [ch, channelId, me.is_demo, runExplore]);
const block = useMutation({ const block = useMutation({
@@ -139,15 +145,16 @@ export default function ChannelPage({
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.blockedChannels() }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
}, },
// No onError: block/unblock is a local DB op (not a YouTube write, so no 403 "connect" case), // No onError: a local DB op (not a YouTube write, so no 403 "connect" case). The app-wide
// and its 400/409/422/500 are already surfaced by the global error modal (see api.ts req) — // reporter names the failure the error dialog does not cover (a 404 for a channel gone).
// a caller toast here would just double it. meta: { failTitle: "errors.writeFailed.blockChannel" },
}); });
// "Hide from feed": the same per-subscription flag the manager toggles (updateChannel {hidden}). // "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. // Local DB op — keeps the subscription, only drops the channel's videos from the feed/explore.
const toggleHidden = useMutation({ const toggleHidden = useMutation({
mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }), mutationFn: () => api.updateChannel(channelId, { hidden: !ch?.hidden }),
meta: { failTitle: "errors.writeFailed.hideChannel" },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) }); qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
@@ -472,14 +479,18 @@ export default function ChannelPage({
)} )}
{!me.is_demo && ( {!me.is_demo && (
<button <button
onClick={() => block.mutate()} // Busy blocks the click, not the focus: a focused button that turns natively
disabled={block.isPending} // `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")} title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={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 ch?.blocked
? "border-danger text-danger" ? "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" /> <Ban className="w-4 h-4" />
@@ -490,14 +501,15 @@ export default function ChannelPage({
from the feed. EyeOff = currently hidden (click to unhide). */} from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && ( {ch?.subscribed && !me.is_demo && (
<button <button
onClick={() => toggleHidden.mutate()} onClick={() => !toggleHidden.isPending && toggleHidden.mutate()}
disabled={toggleHidden.isPending} aria-disabled={toggleHidden.isPending || undefined}
aria-busy={toggleHidden.isPending || undefined}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")} title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")} 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 ch.hidden
? "border-accent text-accent" ? "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" />} {ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
@@ -509,7 +521,7 @@ export default function ChannelPage({
<Button <Button
variant="outline" variant="outline"
onClick={onUnsubscribe} onClick={onUnsubscribe}
disabled={unsubscribe.isPending} loading={unsubscribe.isPending}
title={t("channel.unsubscribe")} title={t("channel.unsubscribe")}
icon={<Check className="w-4 h-4" />} icon={<Check className="w-4 h-4" />}
> >
@@ -519,7 +531,7 @@ export default function ChannelPage({
<Button <Button
variant="outline" variant="outline"
onClick={() => subscribe.mutate()} onClick={() => subscribe.mutate()}
disabled={subscribe.isPending} loading={subscribe.isPending}
icon={<Plus className="w-4 h-4" />} icon={<Plus className="w-4 h-4" />}
> >
{t("channel.subscribe")} {t("channel.subscribe")}
@@ -566,9 +578,11 @@ export default function ChannelPage({
{!ch?.subscribed && nextToken && ( {!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6"> <div className="flex justify-center pb-6">
<button <button
onClick={() => runExplore(nextToken)} // Busy blocks the click, not the focus (see Block above).
disabled={exploring} onClick={() => !exploring && runExplore(nextToken)}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border hover:bg-surface disabled:opacity-50" 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 ? ( {exploring ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
+6 -5
View File
@@ -6,7 +6,6 @@ import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } fr
import ExcludedChannelList from "./ExcludedChannelList"; import ExcludedChannelList from "./ExcludedChannelList";
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api"; import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
import { Sparkles } from "lucide-react"; import { Sparkles } from "lucide-react";
import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage"; import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns"; import { buildChannelColumns, applySyncFilter } from "./channelColumns";
@@ -32,7 +31,7 @@ import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./ui/Pager"; import Pager from "./ui/Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout"; import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort"; import { sortRows } from "../lib/columnSort";
import { filterRows } from "./ui/tableHeader"; import { filterRows } from "../lib/tableColumns";
import { useCardPager } from "../lib/useCardPager"; import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import ChannelDiscovery from "./ChannelDiscovery"; import ChannelDiscovery from "./ChannelDiscovery";
@@ -110,6 +109,7 @@ export default function Channels() {
const [tagManagerOpen, setTagManagerOpen] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false);
const unblock = useMutation({ const unblock = useMutation({
mutationFn: (id: string) => api.unblockChannel(id), mutationFn: (id: string) => api.unblockChannel(id),
meta: { failTitle: "errors.writeFailed.unblockChannel" },
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.blockedChannels() }); qc.invalidateQueries({ queryKey: qk.blockedChannels() });
qc.invalidateQueries({ queryKey: qk.feed() }); qc.invalidateQueries({ queryKey: qk.feed() });
@@ -133,6 +133,7 @@ export default function Channels() {
id: string; id: string;
body: { priority?: number; hidden?: boolean; deep_requested?: boolean }; body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
}) => api.updateChannel(v.id, v.body), }) => 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 // 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. // refresh the per-user status and feed too — not only the channel list.
onSuccess: () => { onSuccess: () => {
@@ -182,11 +183,10 @@ export default function Channels() {
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch // 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 // 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 // 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. // must come back rather than leaving an empty cell (honest states). The hook reports the failure.
onError: (e) => { onError: () => {
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.tagSuggestions() }); 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 // --- 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 fixedLayout
emptyText={t("channels.empty")} emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps} rowProps={pinMenu.triggerProps}
keyboardNav
/> />
) : ( ) : (
<ChannelLayoutGrid <ChannelLayoutGrid
+3 -1
View File
@@ -16,6 +16,7 @@ import { useSortState } from "../lib/useSortState";
import { useFilterState } from "../lib/useFilterState"; import { useFilterState } from "../lib/useFilterState";
import { useActiveAccountId } from "../lib/activeAccount"; import { useActiveAccountId } from "../lib/activeAccount";
import { useUrlMirror } from "../lib/useUrlMirror"; import { useUrlMirror } from "../lib/useUrlMirror";
import { rememberNavPosition } from "../lib/history";
import { import {
channelsParamsToState, channelsParamsToState,
channelsParamUpdates, channelsParamUpdates,
@@ -23,7 +24,7 @@ import {
type ChannelsUrlState, type ChannelsUrlState,
} from "../lib/channelsUrlState"; } from "../lib/channelsUrlState";
import { usePersistedTab } from "./ui/Tabs"; import { usePersistedTab } from "./ui/Tabs";
import type { FilterMap } from "./ui/tableHeader"; import type { FilterMap } from "../lib/tableColumns";
import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelsView } from "./Channels"; import type { ChannelsView } from "./Channels";
@@ -243,6 +244,7 @@ export function ChannelsProvider({ children }: { children: ReactNode }) {
if (window.history.state?.sfPage === "channels") { if (window.history.state?.sfPage === "channels") {
// A FRESH state, like setPage's: carrying the old entry's `_chan`/`_yt` markers over would make // 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. // Back re-open the channel overlay or a spent search on top of the tab.
rememberNavPosition();
window.history.pushState({ sfPage: "channels", _chTab: v }, ""); window.history.pushState({ sfPage: "channels", _chTab: v }, "");
} }
}, },
+4 -2
View File
@@ -30,9 +30,11 @@ const GROUP_ORDER = [
"backfill", "backfill",
"shorts", "shorts",
"batch", "batch",
"maintenance",
"downloads", "downloads",
"audit", "audit",
"plex", "plex",
"tmdb",
]; ];
export default function ConfigPanel() { 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 setReseedToken((n) => n + 1); // re-seed the draft from the saved server state
setSaveState("saved"); setSaveState("saved");
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500); setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
} catch { } catch (e) {
setSaveState("error"); setSaveState("error");
notify({ level: "error", message: t("config.saveFailed") }); notifyActionError(e, t("config.saveFailed"));
} }
} }
+28 -16
View File
@@ -34,6 +34,7 @@ import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery"; import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api"; import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads"; import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
@@ -41,6 +42,7 @@ import { Button } from "./ui/Button";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow"; import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals"; import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin"; import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
export default function DownloadCenter() { export default function DownloadCenter() {
const me = useMe(); const me = useMe();
@@ -95,14 +97,16 @@ export default function DownloadCenter() {
? api.redownloadDownload(id) ? api.redownloadDownload(id)
: api.deleteDownload(id), : api.deleteDownload(id),
onSuccess: invalidate, onSuccess: invalidate,
// No onError: a failed job action returns 400/409/422/500, already surfaced by the global // A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// error modal (api.ts req) with the backend's specific message — a caller toast would double it. // 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). // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({ const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id), mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
}); });
const confirmThen = async (title: string, body: string, fn: () => void) => { const confirmThen = async (title: string, body: string, fn: () => void) => {
@@ -222,9 +226,9 @@ export default function DownloadCenter() {
{t("downloads.page.upload")} {t("downloads.page.upload")}
</Button> </Button>
</div> </div>
<div className="space-y-2"> <NavGrid className="space-y-2">
{queue.map((job) => ( {queue.map((job, i) => (
<DownloadRow key={job.id} job={job} onShowError={setErrorJob}> <DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && ( {(job.status === "queued" || job.status === "running") && (
<IconBtn <IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })} onClick={() => act.mutate({ id: job.id, op: "pause" })}
@@ -272,21 +276,25 @@ export default function DownloadCenter() {
{t("downloads.empty.queue")} {t("downloads.empty.queue")}
</div> </div>
))} ))}
</div> </NavGrid>
</> </>
)} )}
{tab === "done" && ( {tab === "done" && (
<> <>
<UsageBar /> <UsageBar />
<div className="space-y-2"> <NavGrid className="space-y-2">
{library.map((job) => { {library.map((job, i) => {
// Row actions gated on the stored media_kind + job_kind (see downloadActions). // Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job); const a = libraryRowActions(job);
return ( return (
<DownloadRow key={job.id} job={job}> <DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && ( {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" /> <PlayCircle className="w-4 h-4" />
</IconBtn> </IconBtn>
)} )}
@@ -355,19 +363,23 @@ export default function DownloadCenter() {
{t("downloads.empty.done")} {t("downloads.empty.done")}
</div> </div>
))} ))}
</div> </NavGrid>
</> </>
)} )}
{tab === "shared" && ( {tab === "shared" && (
<div className="space-y-2"> <NavGrid className="space-y-2">
{(sharedQ.data ?? []).map((job) => { {(sharedQ.data ?? []).map((job, i) => {
// Same media_kind + can_download gate as the library, via the shared helper (S5b). // Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job); const a = sharedRowActions(job);
return ( return (
<DownloadRow key={job.id} job={job}> <DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && ( {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" /> <PlayCircle className="w-4 h-4" />
</IconBtn> </IconBtn>
)} )}
@@ -405,7 +417,7 @@ export default function DownloadCenter() {
{t("downloads.empty.shared")} {t("downloads.empty.shared")}
</div> </div>
))} ))}
</div> </NavGrid>
)} )}
{tab === "system" && me.role === "admin" && <AdminSystem />} {tab === "system" && me.role === "admin" && <AdminSystem />}
+6 -2
View File
@@ -10,6 +10,7 @@ import { invalidateDownloads } from "../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance. // Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor")); const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { navigateTo } from "../lib/nav"; import { navigateTo } from "../lib/nav";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
@@ -51,8 +52,11 @@ export default function DownloadDialog({
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") }, action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
}); });
onClose(); onClose();
} catch { } catch (e) {
// Non-quiet request: the global error dialog already surfaced the reason (e.g. quota). // 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); 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 { type ReactNode } from "react";
import ChannelLink from "./ChannelLink"; 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. // 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 // A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
@@ -40,9 +42,13 @@ export default function ExcludedChannelList({
{icon} {icon}
<span className="text-xs uppercase tracking-wide text-muted">{title}</span> <span className="text-xs uppercase tracking-wide text-muted">{title}</span>
</div> </div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> <NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{channels.map((c) => ( {channels.map((c, i) => (
<div key={c.id} className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"> <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"> <div className="min-w-0 flex-1">
<ChannelLink <ChannelLink
id={c.id} id={c.id}
@@ -63,7 +69,7 @@ export default function ExcludedChannelList({
</button> </button>
</div> </div>
))} ))}
</div> </NavGrid>
<p className="text-2xs text-muted mt-3">{hint}</p> <p className="text-2xs text-muted mt-3">{hint}</p>
</> </>
); );
+24 -10
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api"; import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notifyActionError } from "../lib/actionErrors";
import { notify, resolveVideo } from "../lib/notifications"; import { notify, resolveVideo } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
import { import {
@@ -145,7 +146,7 @@ export default function Feed({
(video: Video, startAt: number | null = null) => setActiveVideo({ video, startAt }), (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, // 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 // re-ran focusModal() under the open player, dropped its in-flight timers and flushed the
// debounced volume write on each pass. // debounced volume write on each pass.
@@ -308,15 +309,17 @@ export default function Feed({
resolveVideo(id); resolveVideo(id);
} }
}) })
.catch(() => .catch((e: unknown) => {
// Server rejected the change — drop the optimistic override so the card // 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) => { setOverrides((o) => {
const next = { ...o }; const next = { ...o };
delete next[id]; delete next[id];
return next; return next;
}), });
); });
}, },
[qc], [qc],
); );
@@ -330,13 +333,14 @@ export default function Feed({
.clearState(id) .clearState(id)
// Stale-mark only (see setState above): the optimistic override already reset the card. // Stale-mark only (see setState above): the optimistic override already reset the card.
.then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" })) .then(() => qc.invalidateQueries({ queryKey: qk.feed(), refetchType: "none" }))
.catch(() => .catch((e: unknown) => {
notifyActionError(e, i18n.t("errors.writeFailed.videoReset"));
setOverrides((o) => { setOverrides((o) => {
const next = { ...o }; const next = { ...o };
delete next[id]; delete next[id];
return next; return next;
}), });
); });
}, },
[qc], [qc],
); );
@@ -349,7 +353,15 @@ export default function Feed({
qc.invalidateQueries({ queryKey: qk.playlists() }); qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() }); 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], [qc],
); );
@@ -466,7 +478,9 @@ export default function Feed({
.then((r) => .then((r) =>
notify({ message: i18n.t("feed.yt.cleared", { count: r.removed }) }), 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" }); setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch(); onExitYtSearch();
dropFeedCaches(); dropFeedCaches();
+49 -8
View File
@@ -372,8 +372,8 @@ describe("Home", () => {
it("an unrelated mutation does not put the reroll button into a spinner", async () => { 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, // 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 — // 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 // so binding the button's `loading` to raw `isFetching` made it spin and, since a `loading`
// `loading` into `disabled`, swallow clicks after actions the user never associated with a // `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. // reroll (round 2). A press is outstanding only while THIS key has no answer of its own.
const rerollBtn = () => const rerollBtn = () =>
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement; 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 // 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 // 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. // 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"); expect(rerollBtn().getAttribute("aria-busy")).toBe("true");
await act(async () => { await act(async () => {
@@ -409,7 +408,6 @@ describe("Home", () => {
}); });
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2)); await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull(); 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 // `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. // "await one macrotask then assert" races it. Round 4 measured that race as an intermittent red.
failPick(new Error("boom")); 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. // Now an unrelated action refetches that errored key in the background.
discoverPick.mockReturnValue(new Promise(() => {})); discoverPick.mockReturnValue(new Promise(() => {}));
@@ -442,7 +440,6 @@ describe("Home", () => {
void qc.invalidateQueries({ queryKey: qk.discover() }); void qc.invalidateQueries({ queryKey: qk.discover() });
}); });
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2)); await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2));
expect(rerollBtn().disabled).toBe(false);
expect(rerollBtn().getAttribute("aria-busy")).toBeNull(); expect(rerollBtn().getAttribute("aria-busy")).toBeNull();
}); });
@@ -479,14 +476,14 @@ describe("Home", () => {
screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement; screen.getByText("home.hero.reroll").closest("button") as HTMLButtonElement;
fireEvent.click(screen.getByText("home.hero.reroll")); fireEvent.click(screen.getByText("home.hero.reroll"));
await waitFor(() => expect(discoverPick).toHaveBeenCalledTimes(2)); 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")); failPick(new Error("boom"));
// Wait for a signal that the REJECTION has landed, not for a fixed tick: react-query notifies // 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 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 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). // 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.getByText("home.hero.emptyBudget")).toBeTruthy();
expect(screen.queryByText("Hero v1")).toBeNull(); 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]);
});
});
+9 -2
View File
@@ -40,6 +40,7 @@ import VideoCard from "./VideoCard";
import { PlexPosterCard } from "./plexCards"; import { PlexPosterCard } from "./plexCards";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { usePlexActions } from "./PlexProvider"; import { usePlexActions } from "./PlexProvider";
import { navItemAttrs } from "../lib/listNav";
// The Home surface (E24) — "Mit nézzek most?". // The Home surface (E24) — "Mit nézzek most?".
// //
@@ -172,8 +173,9 @@ function Rail({
<Carousel <Carousel
title={<span className="text-sm font-semibold">{title}</span>} title={<span className="text-sm font-semibold">{title}</span>}
ariaLabel={ariaLabel} ariaLabel={ariaLabel}
keyboardNav
> >
{cards.map((c) => ( {cards.map((c, i) => (
<div <div
key={`${c.source}:${c.kind}:${c.id}`} key={`${c.source}:${c.kind}:${c.id}`}
className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"} className={c.aspect === "poster" ? "w-[150px] shrink-0" : "shrink-0"}
@@ -184,6 +186,7 @@ function Rail({
video={c.video} video={c.video}
view="cardsSmall" view="cardsSmall"
width={YT_SLOT} width={YT_SLOT}
navIndex={i}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)} onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
@@ -195,13 +198,14 @@ function Rail({
) : c.plex ? ( ) : c.plex ? (
<PlexPosterCard <PlexPosterCard
card={c.plex} card={c.plex}
navIndex={i}
onOpen={() => onPlay(c)} onOpen={() => onPlay(c)}
onInfo={() => onInfo(c)} onInfo={() => onInfo(c)}
onToggleWatched={onToggleWatched} onToggleWatched={onToggleWatched}
menuProps={() => menu.triggerProps(c)} menuProps={() => menu.triggerProps(c)}
/> />
) : ( ) : (
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} /> <ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
)} )}
</div> </div>
))} ))}
@@ -218,10 +222,12 @@ function ShelfTile({
card, card,
onPlay, onPlay,
menuProps, menuProps,
navIndex,
}: { }: {
card: DiscoverCard; card: DiscoverCard;
onPlay: (card: DiscoverCard) => void; onPlay: (card: DiscoverCard) => void;
menuProps?: (c: DiscoverCard) => Record<string, unknown>; menuProps?: (c: DiscoverCard) => Record<string, unknown>;
navIndex?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const count = card.kind === "channel" ? card.unwatched_count : card.item_count; const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
@@ -234,6 +240,7 @@ function ShelfTile({
type="button" type="button"
onClick={() => card.next && onPlay(card)} onClick={() => card.next && onPlay(card)}
{...menuProps?.(card)} {...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not // `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 // 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 // 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();
});
});
+9 -3
View File
@@ -16,6 +16,8 @@ import Avatar from "./ui/Avatar";
import KeyGate from "./KeyGate"; import KeyGate from "./KeyGate";
import { inputCls } from "./ui/form"; import { inputCls } from "./ui/form";
import ChatThread from "./ChatThread"; import ChatThread from "./ChatThread";
import NavGrid from "./ui/NavGrid";
import { navItemAttrs } from "../lib/listNav";
type View = type View =
| "list" | "list"
@@ -140,15 +142,19 @@ function ConversationList({
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div> <div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
) : ( ) : (
<div className="flex flex-col gap-1.5"> // A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
{items.map((c) => { // 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; const isSystem = c.partner.id === SYSTEM_ID;
return ( return (
<div <div
key={c.partner.id} key={c.partner.id}
{...navItemAttrs(i, c.partner.name)}
className="group flex items-center gap-1 rounded-xl hover:bg-card transition" className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
> >
<button <button
data-nav-open=""
onClick={() => onOpen(c)} onClick={() => onOpen(c)}
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1" className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
> >
@@ -195,7 +201,7 @@ function ConversationList({
</div> </div>
); );
})} })}
</div> </NavGrid>
)} )}
</div> </div>
); );
@@ -1,16 +1,14 @@
// @vitest-environment jsdom // @vitest-environment jsdom
// //
// `setPage`'s no-op contract — the PRODUCER side of the history-burial fix, which nothing rendered // The navigation actions against the overlay history stack: `setPage` and `openChannel` never push
// before this. `setPage` early-returns when the requested page is already current with no channel // under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and
// page over it, and reports that by returning `false`; `lib/history.buryUnder` reads the boolean to // `setPage`'s same-page guard, which decides whether anything is pushed at all.
// 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 type does NOT fence this. `NavigationActions` pins the SHAPE (`=> boolean`), so deleting one // The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the
// `return` fails tsc — but round 3 measured that swapping the two values (early return `true`, tail // palette, the player's channel link) and could be forgotten at any of them; it now lives in the two
// `false`) passes typecheck and all 822 tests. Hence assertions on the value AND on the effects the // actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player,
// early return skips, which is what the boolean actually stands for. // 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 { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { renderHook, act, cleanup } from "@testing-library/react"; import { renderHook, act, cleanup } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -27,6 +25,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider"; import { NavigationProvider, useNavigation, useNavigationActions } from "./NavigationProvider";
import { useBackToClose } from "../lib/history";
const wrapper = ({ children }: { children: ReactNode }) => ( const wrapper = ({ children }: { children: ReactNode }) => (
<NavigationProvider>{children}</NavigationProvider> <NavigationProvider>{children}</NavigationProvider>
@@ -34,6 +33,25 @@ const wrapper = ({ children }: { children: ReactNode }) => (
const render = () => const render = () =>
renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); 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(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory 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 // 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. // 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", () => { describe("NavigationProvider · setPage's same-page guard", () => {
it("returns true and pushes an entry for a real page change", () => { it("pushes an entry for a real page change", () => {
const { result } = render(); const { result } = render();
const push = vi.spyOn(window.history, "pushState"); const push = vi.spyOn(window.history, "pushState");
try { act(() => result.current.setPage("plex"));
let reported: boolean | undefined; expect(push).toHaveBeenCalledTimes(1);
act(() => { expect(result.current.page).toBe("plex");
reported = result.current.setPage("plex");
});
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1);
expect(result.current.page).toBe("plex");
} finally {
push.mockRestore();
}
}); });
it("returns false and pushes nothing when it is already that page", () => { it("pushes nothing when it is already that page", () => {
const { result } = render(); const { result } = render();
act(() => { act(() => result.current.setPage("plex"));
result.current.setPage("plex");
});
const push = vi.spyOn(window.history, "pushState"); const push = vi.spyOn(window.history, "pushState");
try { act(() => result.current.setPage("plex"));
let reported: boolean | undefined; expect(push).not.toHaveBeenCalled();
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();
}
}); });
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 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. // the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one.
const { result } = render(); 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. // asking for "feed" was just an ordinary page change and deleting the clause left it green.
const current = result.current.page; const current = result.current.page;
expect(current).toBe("home"); expect(current).toBe("home");
act(() => { act(() => result.current.openChannel("UC1", "A channel"));
result.current.openChannel("UC1", "A channel");
});
expect(result.current.channelView).not.toBeNull(); expect(result.current.channelView).not.toBeNull();
const push = vi.spyOn(window.history, "pushState"); const push = vi.spyOn(window.history, "pushState");
try { act(() => result.current.setPage(current));
let reported: boolean | undefined; expect(push).toHaveBeenCalledTimes(1);
act(() => { expect(result.current.channelView).toBeNull();
reported = result.current.setPage(current); });
}); });
expect(reported).toBe(true);
expect(push).toHaveBeenCalledTimes(1); describe("NavigationProvider · never under an open overlay", () => {
expect(result.current.channelView).toBeNull(); it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => {
} finally { const { result } = render();
push.mockRestore(); 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" });
}); });
}); });
+70 -47
View File
@@ -14,7 +14,12 @@ import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage"; import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate"; import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav"; 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"; 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 // 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 { export interface NavigationActions {
/** Switch modules. Returns whether it actually navigated — it is a no-op when the requested page /** Switch modules. Closes every open overlay first and pushes only once their history entries are
* is already current with no channel page over it, and a caller that buries overlay history * back (`lib/history.closeOverlaysThenNavigate`), so over an open player the push lands a moment
* entries under the push has to know that (see `lib/history.navigateOverOverlays`). */ * after the call; with nothing open it is synchronous. The requested page being already current
setPage: (next: Page) => boolean; * (with no channel page over it) pushes nothing — but the overlays still close. */
setPage: (next: Page) => void;
openChannel: (id: string, name?: string) => void; openChannel: (id: string, name?: string) => void;
closeChannel: () => void; closeChannel: () => void;
enterYtSearch: (q: string) => void; enterYtSearch: (q: string) => void;
@@ -53,7 +59,7 @@ const StateContext = createContext<NavigationState>({
ytSearch: null, ytSearch: null,
}); });
const ActionsContext = createContext<NavigationActions>({ const ActionsContext = createContext<NavigationActions>({
setPage: () => false, setPage: () => {},
openChannel: () => {}, openChannel: () => {},
closeChannel: () => {}, closeChannel: () => {},
enterYtSearch: () => {}, enterYtSearch: () => {},
@@ -118,53 +124,66 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
}); });
const setPage = useCallback( const setPage = useCallback(
(next: Page) => { (next: Page) =>
// A channel page overlays the content column, so a nav click must close it even when the // Never under an open overlay — see the interface doc. The refs are read when the push
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed). // actually happens, which over an open player is a moment after this call.
if (next === pageRef.current && !channelViewRef.current) return false; closeOverlaysThenNavigate(() => {
markDirty(); // A channel page overlays the content column, so a nav click must close it even when the
setChannelView(null); // leave any open channel page when navigating via the rail // underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
setPageState(next); if (next === pageRef.current && !channelViewRef.current) return;
setAccountRaw(PAGE_KEY, next); markDirty();
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the setChannelView(null); // leave any open channel page when navigating via the rail
// app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its root, and setPageState(next);
// the address bar reflects the page (feed is implicit) while clearing the view-specific params setAccountRaw(PAGE_KEY, next);
// (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page switch resets to that // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// module's root. Feed filters (owned by FeedFiltersProvider) are left intact; the plex/channels // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// keys re-mirror from PlexProvider / ChannelsProvider on return to their page. // root, and the address bar reflects the page (feed is implicit) while clearing the
window.history.pushState( // view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page
{ sfPage: next, sfScroll: nextNavId() }, // 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
urlWithParams({ // their page. The item it was opened from is still focused: the state setters above have not
page: next === "feed" ? null : next, // re-rendered anything yet.
chan: null, rememberNavPosition();
v: null, window.history.pushState(
pl: null, { sfPage: next, sfScroll: nextNavId() },
pscope: null, "",
psort: null, urlWithParams({
pshow: null, page: next === "feed" ? null : next,
pf: null, chan: null,
ctab: null, v: null,
csort: null, pl: null,
cf: null, pscope: null,
}), psort: null,
); pshow: null,
notifyNav(); pf: null,
return true; ctab: null,
}, csort: null,
cf: null,
}),
);
notifyNav();
}),
[markDirty], [markDirty],
); );
// A sub-view (channel / YT search) is an OVERLAY, not a page change, so it does NOT markDirty: a // 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 // 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. // 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) => { const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name }); closeOverlaysThenNavigate(() => {
const st = window.history.state || {}; const url = urlWithParams({ chan: id });
const url = urlWithParams({ chan: id }); if (window.history.state?._chan) {
if (st._chan) const st = window.history.state;
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url); window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "", url);
else window.history.pushState({ ...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(() => { const closeChannel = useCallback(() => {
@@ -185,7 +204,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
const { _ytScrape: _drop, ...st } = window.history.state || {}; const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt) if (st._yt)
window.history.replaceState({ ...st, _yt: q }, ""); // refine current search 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(() => { const exitYtSearch = useCallback(() => {
+20 -4
View File
@@ -79,10 +79,26 @@ export default function NotificationsPanel() {
qc.invalidateQueries({ queryKey: qk.notifications() }); qc.invalidateQueries({ queryKey: qk.notifications() });
qc.invalidateQueries({ queryKey: qk.notifUnread() }); qc.invalidateQueries({ queryKey: qk.notifUnread() });
}; };
const readMut = useMutation({ mutationFn: api.markNotificationRead, onSuccess: refresh }); const readMut = useMutation({
const readAllMut = useMutation({ mutationFn: api.markAllNotificationsRead, onSuccess: refresh }); mutationFn: api.markNotificationRead,
const dismissMut = useMutation({ mutationFn: api.dismissNotification, onSuccess: refresh }); onSuccess: refresh,
const clearMut = useMutation({ mutationFn: api.clearNotifications, 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 serverUnread = serverItems.some((n) => !n.read);
const hasAny = serverItems.length > 0 || clientItems.length > 0; const hasAny = serverItems.length > 0 || clientItems.length > 0;
+5 -2
View File
@@ -82,6 +82,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
qc.invalidateQueries({ queryKey: qk.channels() }); qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() }); 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 // 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); useDialogChrome(panelRef, rootRef);
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
// Only while nothing is above us: an error dialog opened from inside the wizard owns the Tab. // Only while nothing that owns the keyboard is above us: an error dialog opened from inside the
if (e.key !== "Tab" || !layer.isTopmost()) return; // wizard owns the Tab, a hover card under the pointer does not.
if (e.key !== "Tab" || layer.blockingAbove()) return;
const panel = panelRef.current; const panel = panelRef.current;
// The SAME three arguments `ui/Modal` passes, for the same two reasons — and this call had // 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 // 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();
}
});
});
+35 -22
View File
@@ -1,17 +1,12 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider"; import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { usePref } from "../lib/usePref"; import { usePref } from "../lib/usePref";
import { import { PlayerTransportControls } from "./playerModalControls";
AUTO_MODES, import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
LOOP_MODES,
PlayerTransportControls,
type AutoMode,
type LoopMode,
} from "./playerModalControls";
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
@@ -32,7 +27,14 @@ import {
import Avatar from "./ui/Avatar"; import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; 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 DownloadButton from "./DownloadButton";
import { api, type Video, type VideoStatus } from "../lib/api"; import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube"; import type { YTPlayer } from "../lib/youtube";
@@ -46,7 +48,7 @@ import {
import { renderDescription } from "../lib/descriptionLinks"; import { renderDescription } from "../lib/descriptionLinks";
import { fullscreenElement } from "../lib/fullscreen"; import { fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap"; import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { closeThenNavigate, useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink"; import ChannelNameLink from "./ChannelNameLink";
import Toaster, { useOverlayOrder } from "./ui/Toaster"; import Toaster, { useOverlayOrder } from "./ui/Toaster";
import { afterRemoval } from "../lib/playerQueue"; import { afterRemoval } from "../lib/playerQueue";
@@ -166,6 +168,12 @@ export default function PlayerModal({
// modal adjusts volume (not just the small player area). // modal adjusts volume (not just the small player area).
const dialogRef = useRef<HTMLDivElement | null>(null); const dialogRef = useRef<HTMLDivElement | null>(null);
const focusModal = () => cardRef.current?.focus(); 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 togglePlay = () => {
const p = playerRef.current; const p = playerRef.current;
if (!p || typeof p.getPlayerState !== "function") return; if (!p || typeof p.getPlayerState !== "function") return;
@@ -397,7 +405,7 @@ export default function PlayerModal({
useKeyScope("ytPlayer"); 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. // 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 // 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). // 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); nudgeSeek(secs);
}; };
const onKey = (e: KeyboardEvent) => { 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", { dispatch(e, "ytPlayer", {
close: (ev) => { close: (ev) => {
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an // 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); window.addEventListener("keydown", onKey);
focusModal(); focusModal();
const prevOverflow = document.body.style.overflow; // (The scroll lock is the shared, counted one in useDialogChrome.)
document.body.style.overflow = "hidden";
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
// (The debounced volume write is flushed by useYtVolume's own unmount cleanup.) // (The debounced volume write is flushed by useYtVolume's own unmount cleanup.)
}; };
}, [onClose]); }, [onClose]);
@@ -511,6 +525,7 @@ export default function PlayerModal({
onClick={onClose} onClick={onClose}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby={titleId}
> >
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a {/* 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 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"> <div className="p-4 sm:p-5">
{/* Title row — title (with hover description) on the left, Close on the right. */} {/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3"> <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. */} {/* Hover target is the text itself (inline), not the whole row. */}
<span <span
ref={descPop.refs.setReference} ref={descPop.refs.setReference}
@@ -822,13 +837,11 @@ export default function PlayerModal({
channelId={active.channel_id} channelId={active.channel_id}
title={active.channel_title} title={active.channel_title}
onOpenInApp={() => { onOpenInApp={() => {
const cid = active.channel_id; // openChannel closes this player first and pushes only once its
const cname = active.channel_title ?? undefined; // Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// Close first, open the channel only once this player's own Back-to-close // and clicking the name merely closes the player
// entry has been reclaimed — otherwise that reclaim undoes the push and // (lib/history.closeOverlaysThenNavigate).
// clicking the name merely closes the player. The rule lives in history.ts openChannel(active.channel_id, active.channel_title ?? undefined);
// now; the command palette needs exactly the same one.
closeThenNavigate(onClose, () => openChannel(cid, cname));
}} }}
className="font-medium hover:text-accent truncate text-left" className="font-medium hover:text-accent truncate text-left"
/> />
+20 -1
View File
@@ -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)))); await act(async () => void (await new Promise((r) => setTimeout(r, 20))));
expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); expect(rowsOnScreen()).toEqual(["Video V", "Video W"]);
expect(h.notify).toHaveBeenCalledWith( 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"));
});
});
+27 -19
View File
@@ -56,20 +56,16 @@ import { useComfortPin, pinVideo } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu"; import { ContextMenu } from "./ui/ContextMenu";
import { useContextMenu } from "./ui/useContextMenu"; import { useContextMenu } from "./ui/useContextMenu";
import UndoToolbar from "./ui/UndoToolbar"; import UndoToolbar from "./ui/UndoToolbar";
import NavGrid from "./ui/NavGrid";
import { PageToolbar } from "./ui/PageShell"; import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { usePlaylistActions } from "./usePlaylistActions"; import { usePlaylistActions } from "./usePlaylistActions";
import { usePlaylists } from "./PlaylistsProvider"; import { usePlaylists } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe"; import { useMe } from "../lib/useMe";
import { usePref } from "../lib/usePref"; import { usePref } from "../lib/usePref";
import { import { navItemAttrs } from "../lib/listNav";
ColumnHead, import { ColumnHead, alignClass } from "./ui/tableHeader";
alignClass, import { filterRows, isActive, type Column, type FilterMap } from "../lib/tableColumns";
filterRows,
isActive,
type Column,
type FilterMap,
} from "./ui/tableHeader";
import { GRIP_KEY, playlistColumns } from "./playlistColumns"; import { GRIP_KEY, playlistColumns } from "./playlistColumns";
// The playlist detail is a HYBRID table (E4 S4): real sortable/filterable column headers over a // 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({ function Row({
video, video,
index,
columns, columns,
dragDisabled, dragDisabled,
dragHint, dragHint,
menuProps, menuProps,
}: { }: {
video: Video; video: Video;
/** Its position among the rows shown — the row is a keyboard item (lib/listNav). */
index: number;
columns: Column<Video>[]; columns: Column<Video>[];
dragDisabled: boolean; dragDisabled: boolean;
dragHint: string; dragHint: string;
@@ -131,6 +130,7 @@ function Row({
// transition-COLORS, not `transition` — the bare utility animates transform too, which is // 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). // 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" className="border-b border-border/50 hover:bg-card/40 transition-colors"
{...navItemAttrs(index)}
{...menuProps} {...menuProps}
> >
{columns.map((col) => ( {columns.map((col) => (
@@ -676,16 +676,20 @@ export default function Playlists() {
} }
function playerState(id: string, status: string) { function playerState(id: string, status: string) {
api.setState(id, status).then(() => { api
// Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a .setState(id, status)
// list that is no longer the selected one (see `playing`), and that list holds the row whose .then(() => {
// status just changed. Only lists actually on screen refetch. // Every cached list, not `qk.playlist(selectedId)`: the player may have been launched from a
qc.invalidateQueries({ queryKey: qk.playlist() }); // list that is no longer the selected one (see `playing`), and that list holds the row whose
qc.invalidateQueries({ queryKey: qk.feed() }); // 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. // every render re-subscribed those listeners and re-ran focusModal() under the open player.
const closePlayer = useCallback(() => setPlaying(null), []); const closePlayer = useCallback(() => setPlaying(null), []);
@@ -1089,10 +1093,14 @@ export default function Playlists() {
items={shown.map((v) => v.id)} items={shown.map((v) => v.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<tbody> {/* The rows are a keyboard list: `j`/`k` and the arrows walk them, Enter plays. The grip
{shown.map((v) => ( 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 <Row
key={v.id} key={v.id}
index={i}
video={v} video={v}
columns={columns} columns={columns}
dragDisabled={!canEdit || viewActive} dragDisabled={!canEdit || viewActive}
@@ -1100,7 +1108,7 @@ export default function Playlists() {
menuProps={rowMenu.triggerProps(v)} menuProps={rowMenu.triggerProps(v)}
/> />
))} ))}
</tbody> </NavGrid>
</SortableContext> </SortableContext>
</table> </table>
{shown.length === 0 && ( {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()));
});
});
+29 -30
View File
@@ -160,6 +160,7 @@ export default function PlaylistsRail({
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (name: string) => api.createPlaylist(name), mutationFn: (name: string) => api.createPlaylist(name),
meta: { failTitle: "errors.writeFailed.createPlaylist" },
onSuccess: (pl: Playlist) => { onSuccess: (pl: Playlist) => {
setNewName(""); setNewName("");
qc.invalidateQueries({ queryKey: qk.playlists() }); 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 // Enter and Escape unmount the focused <input> and re-render the row as a NEW <button>, so without
// nothing focuses, so a keyboard user is dropped on <body> and loses their place in the rail (E10 // this a keyboard user was dropped on <body> and lost their place in the rail (E10 review round 1).
// review round 1). A refocus-the-row fix was written in round 1 and REMOVED in round 3 because a // The row is focused from its ref callback, i.e. as that button attaches: measured in Chrome (E11
// real-browser check showed it never worked: Escape's handler does run (typing then Escape leaves // S2), a focus issued after the commit sticks and nothing takes it back — the round-1 attempt that
// the name unchanged, i.e. it cancels rather than blur-commits) and the row button is focusable // "never worked" focused before the replacement button existed. Set by Enter/Escape only: a
// when focused from outside — yet activeElement is still BODY afterwards. Three explanations were // blur-commit means the user put the focus somewhere else on purpose.
// tried and none survived contact, so the machinery went rather than a fourth guess. Whoever picks const refocusRowRef = useRef<number | null>(null);
// this up: instrument the effect in a real browser first, do not reason about the commit order.
const renameInputRef = useRef<HTMLInputElement | 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 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
// Opening this field from the context menu is a focus race: `MenuItemList` runs `onSelect()` and // mounted in that same commit). `MenuItemList` now runs the item's action on a fresh task AFTER that
// `onClose()` in one batch, so the field mounts in the same commit that unmounts the popover, and // return, so the field simply mounts and is focused here; measured in Chrome (E11 S2), the field is
// floating-ui's FloatingFocusManager hands focus back from a `queueMicrotask` queued in its // focused and survives.
// 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.
useEffect(() => { useEffect(() => {
if (renamingId !== null) renameInputRef.current?.focus(); if (renamingId !== null) renameInputRef.current?.focus();
}, [renamingId]); }, [renamingId]);
@@ -338,9 +324,15 @@ export default function PlaylistsRail({
onChange={(e) => setRenameValue(e.target.value)} onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => void commitRename(pl)} onBlur={() => void commitRename(pl)}
onKeyDown={(e) => { 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. // 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")} 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" 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 <button
key={pl.id} 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)} onClick={() => setSelectedId(pl.id)}
{...railMenu.triggerProps(pl)} {...railMenu.triggerProps(pl)}
className={`${ROW_BASE} text-left ${ className={`${ROW_BASE} text-left ${
+9 -6
View File
@@ -42,6 +42,7 @@ import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu"; import { useContextMenu } from "./ui/useContextMenu";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews"; import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
import { dispatch, useKeyScope } from "../lib/keymap"; 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. // Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer")); const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -607,10 +608,11 @@ export default function PlexBrowse() {
{episodes.length > 0 && ( {episodes.length > 0 && (
<h2 className="text-sm font-semibold mb-2">{t("plex.unified.titles")}</h2> <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"> <NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{items.map((c) => ( {items.map((c, i) => (
<PlexPosterCard <PlexPosterCard
key={c.id} key={c.id}
navIndex={i}
card={c} card={c}
onOpen={() => onCard(c)} onOpen={() => onCard(c)}
onInfo={() => onInfo(c)} onInfo={() => onInfo(c)}
@@ -618,24 +620,25 @@ export default function PlexBrowse() {
menuProps={pinMenu.triggerProps} menuProps={pinMenu.triggerProps}
/> />
))} ))}
</div> </NavGrid>
</> </>
)} )}
{/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */} {/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */}
{episodes.length > 0 && ( {episodes.length > 0 && (
<section className="mt-8"> <section className="mt-8">
<h2 className="text-sm font-semibold mb-3">{t("plex.unified.episodes")}</h2> <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"> <NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{episodes.map((ep) => ( {episodes.map((ep, i) => (
<EpisodeCard <EpisodeCard
key={ep.id} key={ep.id}
navIndex={i}
ep={ep} ep={ep}
withShowTitle withShowTitle
onPlay={() => onCard(ep)} onPlay={() => onCard(ep)}
onToggleWatched={() => toggleWatched(ep)} onToggleWatched={() => toggleWatched(ep)}
/> />
))} ))}
</div> </NavGrid>
</section> </section>
)} )}
</> </>
@@ -20,6 +20,7 @@ import { useContextMenu } from "./ui/useContextMenu";
import PlexLibraryPicker from "./PlexLibraryPicker"; import PlexLibraryPicker from "./PlexLibraryPicker";
import { toggleWatchState } from "../lib/plexWatch"; import { toggleWatchState } from "../lib/plexWatch";
import { LoadingState, StateMessage } from "./ui/QueryState"; 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 // 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, // 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> </div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6"> <NavGrid className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c) => ( {items.map((c, i) => (
<div key={c.id} className="relative"> <div key={c.id} className="relative">
<PlexPosterCard <PlexPosterCard
card={c} card={c}
navIndex={i}
onOpen={() => onOpenCard(c)} onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)} onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched} onToggleWatched={toggleWatched}
@@ -421,7 +423,7 @@ export default function PlexCollectionView({
)} )}
</div> </div>
))} ))}
</div> </NavGrid>
</> </>
)} )}
</> </>
@@ -6,7 +6,7 @@
// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve // 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. // synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; 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"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => { beforeAll(() => {
@@ -263,3 +263,40 @@ describe("PlexDiscover", () => {
expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull(); 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
});
});
+14 -5
View File
@@ -13,6 +13,7 @@ import { PlexPosterCard } from "./plexCards";
import { PlexPinMenu } from "./ComfortPinMenu"; import { PlexPinMenu } from "./ComfortPinMenu";
import { useContextMenu } from "./ui/useContextMenu"; import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState"; 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 // 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 // /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you
@@ -55,11 +56,18 @@ function CardRail({
); );
return ( return (
<section className="mb-5"> <section className="mb-5">
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}> <Carousel
{cards.map((c) => ( title={heading}
ariaLabel={ariaLabel}
onSeeAll={onSeeAll}
seeAllLabel={seeAllLabel}
keyboardNav
>
{cards.map((c, i) => (
<div key={c.id} className="w-[150px] shrink-0"> <div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard <PlexPosterCard
card={c} card={c}
navIndex={i}
onOpen={() => onOpenCard(c)} onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)} onInfo={() => onInfoCard(c)}
onToggleWatched={onToggleWatched} onToggleWatched={onToggleWatched}
@@ -222,17 +230,18 @@ export default function PlexDiscover({
{d.genres.length > 0 && ( {d.genres.length > 0 && (
<section className="mb-5"> <section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2> <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"> <NavGrid className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => ( {d.genres.map((g, i) => (
<PosterTile <PosterTile
key={g.value} key={g.value}
navIndex={i}
title={g.value} title={g.value}
art={g.art} art={g.art}
onClick={() => onGenre(g.value)} onClick={() => onGenre(g.value)}
className="h-24" className="h-24"
/> />
))} ))}
</div> </NavGrid>
</section> </section>
)} )}
+10 -3
View File
@@ -20,6 +20,7 @@ import { Carousel } from "./ui/Carousel";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; import PlexPlaylistAdd from "./PlexPlaylistAdd";
import { PLAYABLE_TINT, playableClass } from "./plexCards"; 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, // 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 // 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" : ""}`}> <h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
{t("plex.info.cast")} {t("plex.info.cast")}
</h2> </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) => { {cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter); const clickable = !!(onOpenPerson || onFilter);
const inner = ( const inner = (
@@ -686,6 +691,7 @@ export default function PlexInfo({
return clickable ? ( return clickable ? (
<button <button
key={i} key={i}
{...navItemAttrs(i)}
onClick={() => onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] }) onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
} }
@@ -734,10 +740,11 @@ export default function PlexInfo({
</button> </button>
)} )}
</div> </div>
<Carousel ariaLabel={col.title} gap="gap-3"> <Carousel ariaLabel={col.title} gap="gap-3" keyboardNav={!!onPlayItem}>
{col.items.map((m) => ( {col.items.map((m, i) => (
<button <button
key={m.id} key={m.id}
{...navItemAttrs(i)}
onClick={() => onPlayItem?.(m.id)} onClick={() => onPlayItem?.(m.id)}
disabled={!onPlayItem} disabled={!onPlayItem}
className="group/col w-28 shrink-0 text-left" className="group/col w-28 shrink-0 text-left"
+2 -1
View File
@@ -915,7 +915,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
info: () => setInfoOpen((v) => !v), info: () => setInfoOpen((v) => !v),
stats: () => setStatsOpen((v) => !v), stats: () => setStatsOpen((v) => !v),
back: (ev) => { 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 // 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. // 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 // 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 () => { it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
plexState.q = "matrix"; plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The 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 e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
pick(hi); pick(hi);
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
setDismissed(true); setDismissed(true);
setHi(-1); // don't leave a stale highlight to flash on the next reopen setHi(-1); // don't leave a stale highlight to flash on the next reopen
} }
+9 -3
View File
@@ -6,6 +6,7 @@ import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../lib/api"; import { api, plexFilterCount, type PlexFilters } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { type PanelLayout } from "../lib/panelLayout"; import { type PanelLayout } from "../lib/panelLayout";
import { type MediaSourceKind } from "../lib/mediaSource"; import { type MediaSourceKind } from "../lib/mediaSource";
import { useDebounced } from "../lib/useDebounced"; import { useDebounced } from "../lib/useDebounced";
@@ -377,9 +378,14 @@ function PlaylistPicker() {
]; ];
async function createPlaylist(title: string) { async function createPlaylist(title: string) {
const pl = await api.plexCreatePlaylist(title); try {
qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); const pl = await api.plexCreatePlaylist(title);
onOpenPlaylist(pl.id); 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 ( return (
+4
View File
@@ -1,5 +1,6 @@
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { hueOf, hueTileGradient } from "../lib/hueTile"; 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}), // 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 // 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, art,
onClick, onClick,
className, className,
navIndex,
}: { }: {
title: string; title: string;
subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m") 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 art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash
onClick: () => void; onClick: () => void;
className?: string; // caller sets the height (e.g. "h-20" / "h-28") 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 posters = (art ?? []).slice(0, 2);
const band = hueTileGradient(hueOf(title)); const band = hueTileGradient(hueOf(title));
@@ -27,6 +30,7 @@ export default function PosterTile({
type="button" type="button"
onClick={onClick} onClick={onClick}
title={title} title={title}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`} 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. */} {/* 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(); invalidate();
setFormOpen(false); setFormOpen(false);
}, },
meta: { failTitle: "errors.writeFailed.downloadProfileSave" },
}); });
const del = useMutation({ const del = useMutation({
mutationFn: (id: number) => api.deleteDownloadProfile(id), mutationFn: (id: number) => api.deleteDownloadProfile(id),
onSuccess: invalidate, onSuccess: invalidate,
meta: { failTitle: "errors.writeFailed.downloadProfileDelete" },
}); });
const onDelete = async (p: DownloadProfile) => { 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 { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Bookmark, Check, Settings2 } from "lucide-react"; 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"; import { useSavedViews } from "./SavedViewsProvider";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as // 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"> <div className="mt-1 border-t border-border pt-1">
<PopoverItem <PopoverItem
role="menuitem" role="menuitem"
onSelect={() => { // Opens a dialog: close first, or the menu's focus return steals its focus (closeThenAct).
openModal(); onSelect={() => closeThenAct(() => setOpen(false), openModal)}
setOpen(false);
}}
label={t("views.manage")} 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" 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"
> >
+4
View File
@@ -390,6 +390,7 @@ export default function Scheduler() {
// live poll surfaces the "running" state within a tick, so we just nudge a refetch. // live poll surfaces the "running" state within a tick, so we just nudge a refetch.
const runMut = useMutation({ const runMut = useMutation({
mutationFn: (jobId: string) => api.runSchedulerJob(jobId), mutationFn: (jobId: string) => api.runSchedulerJob(jobId),
meta: { failTitle: "errors.writeFailed.schedulerRun" },
onSuccess: (_d, jobId) => { onSuccess: (_d, jobId) => {
notify({ notify({
level: "success", level: "success",
@@ -400,6 +401,7 @@ export default function Scheduler() {
}); });
const runAllMut = useMutation({ const runAllMut = useMutation({
mutationFn: () => api.runAllSchedulerJobs(), mutationFn: () => api.runAllSchedulerJobs(),
meta: { failTitle: "errors.writeFailed.schedulerRunAll" },
onSuccess: (res) => { onSuccess: (res) => {
notify({ notify({
level: "success", level: "success",
@@ -410,10 +412,12 @@ export default function Scheduler() {
}); });
const batchMut = useMutation({ const batchMut = useMutation({
mutationFn: (v: number) => api.updateMaintenanceBatch(v), mutationFn: (v: number) => api.updateMaintenanceBatch(v),
meta: { failTitle: "errors.writeFailed.maintenanceBatch" },
onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.scheduler() }),
}); });
const purgeMut = useMutation({ const purgeMut = useMutation({
mutationFn: () => api.purgeDiscovery(), mutationFn: () => api.purgeDiscovery(),
meta: { failTitle: "errors.writeFailed.purgeDiscovery" },
onSuccess: (res) => { onSuccess: (res) => {
const removed = const removed =
(res.search_videos_deleted ?? 0) + (res.videos_deleted ?? 0) + (res.channels_deleted ?? 0); (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);
});
});
+12 -1
View File
@@ -1,4 +1,5 @@
import { import {
useRef,
type FocusEventHandler, type FocusEventHandler,
type InputHTMLAttributes, type InputHTMLAttributes,
type KeyboardEventHandler, type KeyboardEventHandler,
@@ -6,6 +7,7 @@ import {
} from "react"; } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react"; 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 // 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 // (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>; inputProps?: InputHTMLAttributes<HTMLInputElement>;
}) { }) {
const { t } = useTranslation(); 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 = () => { const fire = () => {
if (onGo && !goDisabled) onGo(); if (onGo && !goDisabled) onGo();
}; };
@@ -67,13 +72,19 @@ export default function SearchBar({
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />} {leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
<input <input
{...inputProps} {...inputProps}
ref={inputRef}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
onKeyDown={(e) => { onKeyDown={(e) => {
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} placeholder={placeholder}
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted" className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
+15 -7
View File
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react"; import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme"; import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, HttpError, type Me } from "../lib/api"; import { api, HttpError, type Me } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { LS, useAccountPersistedState } from "../lib/storage"; import { LS, useAccountPersistedState } from "../lib/storage";
import Avatar from "./ui/Avatar"; import Avatar from "./ui/Avatar";
import { notify, type NotifSettings } from "../lib/notifications"; import { notify, type NotifSettings } from "../lib/notifications";
@@ -383,8 +384,8 @@ function SignInMethods({ me }: { me: Me }) {
try { try {
await api.logoutOthers(); await api.logoutOthers();
notify({ level: "success", message: t("settings.account.sessions.done") }); notify({ level: "success", message: t("settings.account.sessions.done") });
} catch { } catch (e) {
notify({ level: "error", message: t("settings.account.sessions.failed") }); notifyActionError(e, t("settings.account.sessions.failed"));
} finally { } finally {
setSessBusy(false); setSessBusy(false);
} }
@@ -553,10 +554,11 @@ function PlexWatchSync() {
}; };
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say // 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 // "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
// toast would double it. Actual import failures come back as 200 {error}, handled by announce. // reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
const toggle = useMutation({ const toggle = useMutation({
mutationFn: (next: boolean) => api.plexWatchSetLink(next), mutationFn: (next: boolean) => api.plexWatchSetLink(next),
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
onSuccess: (res) => { onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res); qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import); announce(res.import);
@@ -564,6 +566,7 @@ function PlexWatchSync() {
}); });
const reimport = useMutation({ const reimport = useMutation({
mutationFn: () => api.plexWatchImport(), mutationFn: () => api.plexWatchImport(),
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
onSuccess: (res) => { onSuccess: (res) => {
qc.setQueryData(qk.plexWatchLink(), res); qc.setQueryData(qk.plexWatchLink(), res);
announce(res.import); announce(res.import);
@@ -637,10 +640,13 @@ export function DownloadAuth() {
notify({ level: "success", message: t("settings.downloadAuth.saved") }); notify({ level: "success", message: t("settings.downloadAuth.saved") });
}, },
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which // 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({ const remove = useMutation({
mutationFn: (host: string) => api.deleteDownloadCredential(host), mutationFn: (host: string) => api.deleteDownloadCredential(host),
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
onSuccess: (res) => { onSuccess: (res) => {
qc.setQueryData(qk.downloadCredentials(), res); qc.setQueryData(qk.downloadCredentials(), res);
notify({ level: "success", message: t("settings.downloadAuth.removed") }); notify({ level: "success", message: t("settings.downloadAuth.removed") });
@@ -781,8 +787,10 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
await api.deleteAccount(); await api.deleteAccount();
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner. // Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
window.location.href = "/?deleted=1"; window.location.href = "/?deleted=1";
} catch { } catch (e) {
/* the global error dialog surfaces the reason (e.g. last admin) */ // 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 ( return (
+4
View File
@@ -106,15 +106,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
const toggle = useMutation({ const toggle = useMutation({
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }), mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
onSuccess: onChanged, onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkDownload" },
}); });
const revoke = useMutation({ const revoke = useMutation({
mutationFn: () => api.revokeDownloadLink(link.id), mutationFn: () => api.revokeDownloadLink(link.id),
onSuccess: onChanged, onSuccess: onChanged,
meta: { failTitle: "errors.writeFailed.shareLinkRevoke" },
}); });
// Set / change / clear the link password. Rotating (or clearing) it bumps the link's // 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. // password_version server-side, which invalidates any watch grant issued under the old password.
const savePass = useMutation({ const savePass = useMutation({
mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }), mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }),
meta: { failTitle: "errors.writeFailed.shareLinkPassword" },
onSuccess: () => { onSuccess: () => {
setEditPass(false); setEditPass(false);
setNewPass(""); setNewPass("");
@@ -282,6 +285,7 @@ function LinkShare({ job }: { job: DownloadJob }) {
setExpiryDays(null); setExpiryDays(null);
setPassword(""); setPassword("");
}, },
meta: { failTitle: "downloads.share.linkFailed" },
}); });
// Open with a link already generated: sharing is almost always the goal, so mint a default // Open with a link already generated: sharing is almost always the goal, so mint a default
+5
View File
@@ -210,6 +210,11 @@ function SystemStats() {
const pauseResume = useMutation({ const pauseResume = useMutation({
mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()), mutationFn: (paused: boolean) => (paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e, paused) =>
notifyActionError(
e,
t(paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
}); });
const data = q.data; const data = q.data;
const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total)); const maxDaily = Math.max(1, ...(data?.daily ?? []).map((d) => d.total));
+6
View File
@@ -4,6 +4,7 @@ import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react"; import { CheckCircle2, Clock, Database, History, Loader2, Pause, Play } from "lucide-react";
import { api, type MyStatus } from "../lib/api"; import { api, type MyStatus } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { formatViews } from "../lib/format"; import { formatViews } from "../lib/format";
import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover"; import { PopoverSurface, useAnchoredPopover } from "./ui/AnchoredPopover";
@@ -53,6 +54,11 @@ export default function SyncStatus({
const toggle = useMutation({ const toggle = useMutation({
mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()), mutationFn: () => (data?.paused ? api.resumeSync() : api.pauseSync()),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.myStatus() }),
onError: (e) =>
notifyActionError(
e,
t(data?.paused ? "errors.writeFailed.syncResume" : "errors.writeFailed.syncPause"),
),
}); });
if (!data) return null; if (!data) return null;
+1
View File
@@ -129,6 +129,7 @@ export default function TagManager({
}; };
const create = useMutation({ const create = useMutation({
mutationFn: (name: string) => api.createTag({ name, category: "other" }), mutationFn: (name: string) => api.createTag({ name, category: "other" }),
meta: { failTitle: "channels.tags.createFailed" },
onSuccess: () => { onSuccess: () => {
setNewName(""); setNewName("");
invalidate(); 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);
});
});
+10
View File
@@ -18,6 +18,7 @@ import Avatar from "./ui/Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import VideoInfoCard from "./VideoInfoCard"; import VideoInfoCard from "./VideoInfoCard";
import { navItemAttrs } from "../lib/listNav";
import clsx from "clsx"; import clsx from "clsx";
import type { Video, VideoStatus } from "../lib/api"; import type { Video, VideoStatus } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
@@ -388,6 +389,7 @@ function VideoCard({
onToggleSave, onToggleSave,
onOpen, onOpen,
menuProps, menuProps,
navIndex,
}: { }: {
video: Video; video: Video;
view: FeedView; view: FeedView;
@@ -403,6 +405,8 @@ function VideoCard({
* blow past this component's memo for every card in the virtualized feed on every parent * 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. */ * render. The card calls it with the video it already has. */
menuProps?: (v: Video) => Record<string, unknown>; 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 { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions(); const { openChannel } = useNavigationActions();
@@ -465,6 +469,9 @@ function VideoCard({
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)} 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} title={titleClamped ? (video.title ?? undefined) : undefined}
className={clsx( className={clsx(
"font-medium leading-snug hover:text-accent", "font-medium leading-snug hover:text-accent",
@@ -534,6 +541,7 @@ function VideoCard({
data-testid="video-card" data-testid="video-card"
data-video-id={video.id} data-video-id={video.id}
{...menuProps?.(video)} {...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")} 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 {/* 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-testid="video-card"
data-video-id={video.id} data-video-id={video.id}
{...menuProps?.(video)} {...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")} className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
> >
<Thumb <Thumb
@@ -631,6 +640,7 @@ function VideoCard({
data-testid="video-card" data-testid="video-card"
data-video-id={video.id} data-video-id={video.id}
{...menuProps?.(video)} {...menuProps?.(video)}
{...(navIndex !== undefined && navItemAttrs(navIndex))}
className={clsx( className={clsx(
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150", "cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
"flex flex-col", "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)");
});
});
+58 -11
View File
@@ -10,7 +10,8 @@ import { api, type DownloadJob, type EditSpec } from "../lib/api";
import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry"; import { contentRect, cropToSourcePx, type Frac } from "../lib/cropGeometry";
import { inputCls, btnCls } from "./ui/form"; import { inputCls, btnCls } from "./ui/form";
import { Button } from "./ui/Button"; 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. // mm:ss.d timecode <-> seconds.
function tc(s: number): string { 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. // 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 }]); 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 [selId, setSelId] = useState(0);
const [output, setOutput] = useState<"separate" | "join">("separate"); const [output, setOutput] = useState<"separate" | "join">("separate");
const [cropOn, setCropOn] = useState(false); const [cropOn, setCropOn] = useState(false);
@@ -135,6 +143,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
// --- cut-list mutations --- // --- cut-list mutations ---
const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]); const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
const splitAtPlayhead = () => { const splitAtPlayhead = () => {
if (creating.current) return;
const time = current; const time = current;
setSegments((segs) => { setSegments((segs) => {
const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG); 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) => const moveBoundary = (bi: number, time: number) =>
setSegments((segs) => { 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. // bi is in [0, length-2] here, so bi and bi+1 both index existing segments.
const lo = segs[bi]!.start + MIN_SEG; const lo = segs[bi]!.start + MIN_SEG;
const hi = segs[bi + 1]!.end - 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) => const deleteBoundary = (bi: number) =>
setSegments((segs) => { 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 }; const merged = { ...segs[bi]!, end: segs[bi + 1]!.end };
return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)]; return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
}); });
const toggleKeep = (id: number) => 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 --- // --- timeline drag (seek / boundary) + hover preview ---
const [drag, setDrag] = useState<Drag | null>(null); const [drag, setDrag] = useState<Drag | null>(null);
@@ -291,6 +302,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const create = useMutation({ const create = useMutation({
mutationFn: async () => { mutationFn: async () => {
creating.current = true;
const cp = cropPx(); const cp = cropPx();
const base = name.trim() || defaultName; const base = name.trim() || defaultName;
if (willJoin) { if (willJoin) {
@@ -307,13 +319,45 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
return { files: 1 }; return { files: 1 };
} }
const N = kept.length; const N = kept.length;
for (let i = 0; i < N; i++) { const prev = series.current;
const s = kept[i]!; // i < N === kept.length, so kept[i] exists const suffix =
const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } }; prev && kept.every((s) => prev.has(s.id))
if (cp) spec.crop = cp; ? prev
else spec.accurate = accurate; : new Map(kept.map((s, i) => [s.id, N > 1 ? ` (${i + 1}/${N})` : ""]));
const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined; series.current = suffix;
await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn }); 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 }; return { files: N };
}, },
@@ -324,6 +368,9 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onClose(); onClose();
}, },
onError: (e) => notifyActionError(e, t("editor.failed")), onError: (e) => notifyActionError(e, t("editor.failed")),
onSettled: () => {
creating.current = false;
},
}); });
const sel = segments.find((s) => s.id === selId) ?? segments[0]; const sel = segments.find((s) => s.id === selId) ?? segments[0];
+3 -1
View File
@@ -64,10 +64,12 @@ export default function VirtualFeed({
hasNextPage={hasNextPage} hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage} fetchNextPage={fetchNextPage}
renderItem={(v, itemWidth) => ( keyboardNav
renderItem={(v, itemWidth, index) => (
<VideoCard <VideoCard
video={v} video={v}
view={view} view={view}
navIndex={index}
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it // 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 // 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. // frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
+7 -9
View File
@@ -30,7 +30,7 @@ import { setLanguage, type LangCode } from "../i18n";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { trapTab, useDialogChrome } from "./ui/dialogFocus"; 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 { inputCls } from "./ui/form";
import { entryHash, entrySearch } from "../lib/entryUrl"; import { entryHash, entrySearch } from "../lib/entryUrl";
import type { VerifyEntry } from "../lib/useVerifyEntry"; 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 // and the wizard get theirs; the body-scroll lock moved with it, and round 7 added the layer
// membership below. // membership below.
// //
// It is the THIRD of four to get the contract, not the last: `PlayerModal` is also // It was the third of four `aria-modal` shells to get the contract; `PlayerModal`, the fourth,
// `role="dialog" aria-modal="true"` and still hand-rolls its own scroll lock with no accessible // joined in E11 S2, so all four (`ui/Modal`, the wizard, this, the player) now share
// name, no Tab trap and no focus restore. Saying "the fourth shell" here read as though the sweep // `useDialogChrome` and its one counted scroll lock.
// 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).
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
useDialogChrome(panelRef); useDialogChrome(panelRef);
// Keep a stable handler across renders so the stack id is assigned once per mount (as in ui/Modal). // 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). // landing page grows (deep-review round 7).
const id = pushLayer(); const id = pushLayer();
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (!isTopmost(id)) return; if (e.key === "Escape" && isTopmost(id)) {
if (e.key === "Escape") {
e.stopPropagation(); e.stopPropagation();
onCloseRef.current(); onCloseRef.current();
return; return;
} }
// The trap defers only to a layer that owns the keyboard, not to a hover card under the pointer.
const panel = panelRef.current; 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); window.addEventListener("keydown", onKey);
return () => { return () => {
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true })); vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
const { notify } = vi.hoisted(() => ({ 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) => ({ vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()), ...(await importOriginal<typeof import("../lib/notifications")>()),
@@ -28,6 +28,7 @@ vi.mock("../lib/notifications", async (importOriginal) => ({
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import UploadDialog from "./UploadDialog"; import UploadDialog from "./UploadDialog";
import DownloadDialog from "./DownloadDialog";
import { api, HttpError } from "../lib/api"; import { api, HttpError } from "../lib/api";
beforeAll(() => { beforeAll(() => {
@@ -48,7 +49,9 @@ afterEach(() => {
vi.restoreAllMocks(); 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 } } }); const client = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
describe("the Plex collection editor", () => { describe("the Plex collection editor", () => {
@@ -144,3 +147,20 @@ describe("the upload dialog", () => {
expect(errors()).toEqual([]); 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, is_suspended: false,
}; };
function mount() { function mount(u: typeof user = user) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<AccountPanel user={user as any} onClose={() => {}} /> <AccountPanel user={u as any} onClose={() => {}} />
</QueryClientProvider>, </QueryClientProvider>,
); );
} }
@@ -87,11 +87,13 @@ beforeEach(() => {
}); });
afterEach(cleanup); 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 = () => const errorMessages = () =>
notifyMock.mock.calls 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") .filter((a) => a.level === "error")
.map((a) => a.message); .map((a) => a.title ?? a.message);
describe("the account panel reports a refused recovery action", () => { describe("the account panel reports a refused recovery action", () => {
it("says the link could not be minted instead of leaving the button inert", async () => { 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 () => { 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( 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 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 () => { 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")); 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 () => { it("says the mail was never sent when the instance has no SMTP", async () => {
listMock.mockResolvedValue([invite({ status: "approved" })]); listMock.mockResolvedValue([invite({ status: "approved" })]);
inviteMock.mockRejectedValue( 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(); await open();
fireEvent.click(screen.getByLabelText("settings.invites.inviteLabel:asker@example.com")); 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. // 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(() => await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith( 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"); const { HttpError } = await import("../lib/api");
pendingMock.mockResolvedValue([row({ email_verified: false })]); pendingMock.mockResolvedValue([row({ email_verified: false })]);
resendMock.mockRejectedValue( resendMock.mockRejectedValue(
new HttpError(409, "ignored", { code: "resend", reason: "email_off" }), new HttpError(409, "errors.admin.email_off", { code: "resend", reason: "email_off" }),
); );
mount(); mount();
fireEvent.click(await screen.findByLabelText("users.pendingAccess.resend")); 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 // 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(() => await waitFor(() =>
expect(notifyMock).toHaveBeenCalledWith( 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, unlimited: state!.unlimited,
}), }),
onSuccess: done, onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuota" },
}); });
const reset = useMutation({ const reset = useMutation({
mutationFn: () => api.adminResetDownloadQuota(userId), mutationFn: () => api.adminResetDownloadQuota(userId),
onSuccess: done, onSuccess: done,
meta: { failTitle: "errors.writeFailed.downloadQuotaReset" },
}); });
if (!state) return null; if (!state) return null;
const upd = (p: Partial<typeof state>) => setForm({ ...state, ...p }); 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() }); qc.invalidateQueries({ queryKey: qk.downloads() });
onClose(); onClose();
}, },
meta: { failTitle: "errors.writeFailed.downloadDetails" },
}); });
return ( return (
@@ -12,7 +12,7 @@ vi.mock("react-i18next", async (importOriginal) => {
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
}); });
import { DownloadRow } from "./downloadRow"; import { DownloadRow, IconBtn } from "./downloadRow";
import type { DownloadJob } from "../lib/api"; import type { DownloadJob } from "../lib/api";
const baseJob: DownloadJob = { const baseJob: DownloadJob = {
@@ -116,3 +116,27 @@ describe("DownloadRow file kind", () => {
expect(container.querySelector("img")).not.toBeNull(); 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);
});
});
+12 -1
View File
@@ -8,6 +8,7 @@ import { api, type DownloadJob } from "../lib/api";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; 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), // 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, // split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue,
@@ -112,16 +113,20 @@ export function IconBtn({
onClick, onClick,
title, title,
danger, danger,
open,
children, children,
}: { }: {
onClick: () => void; onClick: () => void;
title: string; title: string;
danger?: boolean; danger?: boolean;
/** The row's "open" action (play): Enter on a keyboard-walked row (lib/listNav) clicks it. */
open?: boolean;
children: ReactNode; children: ReactNode;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
data-nav-open={open ? "" : undefined}
title={title} title={title}
className={clsx( className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted transition", "p-1.5 rounded-md hover:bg-surface text-muted transition",
@@ -201,16 +206,22 @@ function ChannelLine({ job }: { job: DownloadJob }) {
export function DownloadRow({ export function DownloadRow({
job, job,
onShowError, onShowError,
navIndex,
children, children,
}: { }: {
job: DownloadJob; job: DownloadJob;
onShowError?: (job: DownloadJob) => void; onShowError?: (job: DownloadJob) => void;
/** Its position in a keyboard-walkable list (lib/listNav). */
navIndex?: number;
children?: ReactNode; children?: ReactNode;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const running = job.status === "running"; const running = job.status === "running";
return ( 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} /> <Thumb job={job} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium leading-snug line-clamp-1 flex items-center gap-1.5"> <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 { useTranslation } from "react-i18next";
import { Repeat, Repeat1, Shuffle, SkipBack, SkipForward } from "lucide-react"; 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 — // 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. // the sibling of plexPlayerControls.tsx. No state or effects here; all behaviour comes in via props.
// The auto-advance / loop modes it renders are defined in `lib/playerModes`.
// 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 queue transport cluster — prev / counter / next, then the auto-advance and loop mode toggles. /** 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 * Rendered in two places with identical logic: the modal's queue bar (`variant:"modal"`, normal glass
+7 -2
View File
@@ -2,7 +2,7 @@ import { Play, X } from "lucide-react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration } from "../lib/format"; 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`. // 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" /> <Play className="w-4 h-4 text-white fill-current" />
</span> </span>
</button> </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> <span className="block text-[0.8125rem] text-fg truncate">{v.title}</span>
</button> </button>
</div> </div>
+9 -6
View File
@@ -11,6 +11,7 @@ import { useDebounced } from "../lib/useDebounced";
import { LoadingState, StateMessage } from "./ui/QueryState"; import { LoadingState, StateMessage } from "./ui/QueryState";
import PosterTile from "./PosterTile"; import PosterTile from "./PosterTile";
import PlexLibraryPicker from "./PlexLibraryPicker"; 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 // A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
// collections have no movie runtime). // 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 <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
return ( return (
<div className={GRID}> <NavGrid className={GRID}>
{playlists.map((p) => ( {playlists.map((p, i) => (
<PosterTile <PosterTile
key={p.id} key={p.id}
navIndex={i}
title={p.title} title={p.title}
subtitle={t("plex.browse.items", { count: p.item_count })} subtitle={t("plex.browse.items", { count: p.item_count })}
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />} 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" className="h-28"
/> />
))} ))}
</div> </NavGrid>
); );
} }
@@ -215,10 +217,11 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
) : collections.length === 0 ? ( ) : collections.length === 0 ? (
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p> <p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
) : ( ) : (
<div className={GRID}> <NavGrid className={GRID}>
{collections.map((c) => ( {collections.map((c, i) => (
<PosterTile <PosterTile
key={c.id} key={c.id}
navIndex={i}
title={c.title} title={c.title}
art={c.art} art={c.art}
subtitle={collectionMeta(c, t)} subtitle={collectionMeta(c, t)}
@@ -234,7 +237,7 @@ export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title:
className="h-28" className="h-28"
/> />
))} ))}
</div> </NavGrid>
)} )}
</div> </div>
); );
+48 -1
View File
@@ -5,7 +5,7 @@
// aria-label — a dropped label silently reintroduces an unnamed button for screen-reader users. i18n is // 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. // key-passthrough, so the asserted names are the i18n keys.
import { describe, it, expect, afterEach, vi } from "vitest"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -100,3 +100,50 @@ describe("EpisodeCard a11y", () => {
expect(screen.getByRole("button", { name: "plex.playlist.addEpisode" })).toBeTruthy(); 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();
});
});
+22 -5
View File
@@ -4,6 +4,7 @@ import { type CSSProperties } from "react";
import { formatRuntime } from "../lib/format"; import { formatRuntime } from "../lib/format";
import { Carousel } from "./ui/Carousel"; import { Carousel } from "./ui/Carousel";
import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; 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 // 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. // state). Rendered by the browse grid, the person page, and the series show/season views.
@@ -52,6 +53,7 @@ export function PlexPosterCard({
onInfo, onInfo,
onToggleWatched, onToggleWatched,
menuProps, menuProps,
navIndex,
}: { }: {
card: PlexCard; card: PlexCard;
onOpen: () => void; 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 * 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={menu.triggerProps}` and reads the same at every call site. */
menuProps?: (c: PlexCard) => Record<string, unknown>; 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 { t } = useTranslation();
const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched";
@@ -75,6 +79,7 @@ export function PlexPosterCard({
<div <div
className="group text-left" className="group text-left"
{...menuProps?.(card)} {...menuProps?.(card)}
{...(navIndex !== undefined && navItemAttrs(navIndex, card.title))}
// content-visibility keeps off-screen posters cheap to render (smoother long-list scroll). // content-visibility keeps off-screen posters cheap to render (smoother long-list scroll).
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties} style={{ contentVisibility: "auto", containIntrinsicSize: "auto 300px" } as CSSProperties}
> >
@@ -82,6 +87,7 @@ export function PlexPosterCard({
onClick={onOpen} onClick={onOpen}
role="button" role="button"
tabIndex={0} tabIndex={0}
data-nav-open=""
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
@@ -242,11 +248,13 @@ export function SeasonCard({
onOpen, onOpen,
onToggleWatched, onToggleWatched,
onAdd, onAdd,
navIndex,
}: { }: {
se: PlexSeasonDetail; se: PlexSeasonDetail;
onOpen: () => void; onOpen: () => void;
onToggleWatched?: () => void; onToggleWatched?: () => void;
onAdd?: () => void; onAdd?: () => void;
navIndex?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const watched = se.status === "watched"; const watched = se.status === "watched";
@@ -254,11 +262,15 @@ export function SeasonCard({
? t("plex.series.markSeasonUnwatched") ? t("plex.series.markSeasonUnwatched")
: t("plex.series.markSeasonWatched"); : t("plex.series.markSeasonWatched");
return ( return (
<div className="group text-left"> <div
className="group text-left"
{...(navIndex !== undefined && navItemAttrs(navIndex, se.title))}
>
<div <div
onClick={onOpen} onClick={onOpen}
role="button" role="button"
tabIndex={0} tabIndex={0}
data-nav-open=""
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
@@ -357,7 +369,7 @@ export function CastStrip({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }} style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
> >
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2> <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) => { {cast.map((c, i) => {
const inner = ( const inner = (
<> <>
@@ -390,6 +402,7 @@ export function CastStrip({
return clickable ? ( return clickable ? (
<button <button
key={i} key={i}
{...navItemAttrs(i)}
onClick={() => onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] }) 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)" }} style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
> >
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2> <h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}> <Carousel gap="gap-3" ariaLabel={t("plex.series.related")} keyboardNav>
{related.map((r) => ( {related.map((r, i) => (
<button <button
key={r.id} key={r.id}
{...navItemAttrs(i)}
onClick={() => onOpen(r.id)} onClick={() => onOpen(r.id)}
className="group w-[130px] shrink-0 text-left" className="group w-[130px] shrink-0 text-left"
> >
@@ -463,12 +477,14 @@ export function EpisodeCard({
onAdd, onAdd,
onToggleWatched, onToggleWatched,
withShowTitle, withShowTitle,
navIndex,
}: { }: {
ep: PlexCard; ep: PlexCard;
onPlay: () => void; onPlay: () => void;
onAdd?: () => void; onAdd?: () => void;
onToggleWatched?: () => void; onToggleWatched?: () => void;
withShowTitle?: boolean; withShowTitle?: boolean;
navIndex?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; 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)) ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100))
: 0; : 0;
return ( return (
<div className="group"> <div className="group" {...(navIndex !== undefined && navItemAttrs(navIndex, ep.title))}>
<div <div
onClick={onPlay} onClick={onPlay}
role="button" role="button"
tabIndex={0} tabIndex={0}
data-nav-open=""
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
+20 -11
View File
@@ -29,6 +29,7 @@ import { Carousel } from "./ui/Carousel";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import { LoadingState, StateMessage } from "./ui/QueryState"; 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 // 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, // 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 ? ( cards.length > 0 ? (
<section className="mb-5"> <section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2> <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"> <NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => ( {cards.map((c, i) => (
<PlexPosterCard <PlexPosterCard
key={c.id} key={c.id}
navIndex={i}
card={c} card={c}
onOpen={() => onOpenCard(c)} onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)} onInfo={() => onInfoCard(c)}
@@ -162,7 +164,7 @@ export function PlexPersonView({
menuProps={pinMenu.triggerProps} menuProps={pinMenu.triggerProps}
/> />
))} ))}
</div> </NavGrid>
</section> </section>
) : null; ) : null;
@@ -241,11 +243,16 @@ export function PlexPersonView({
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && ( {d.known_for.length > 0 && (
<section className="mb-5"> <section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}> <Carousel
{d.known_for.map((c) => ( 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"> <div key={c.id} className="w-[150px] shrink-0">
<PlexPosterCard <PlexPosterCard
card={c} card={c}
navIndex={i}
onOpen={() => onOpenCard(c)} onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)} onInfo={() => onInfoCard(c)}
onToggleWatched={toggle} onToggleWatched={toggle}
@@ -501,10 +508,11 @@ export function PlexShowView({
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }} style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
> >
<h2 className="text-sm font-semibold mb-3">{t("plex.series.seasons")}</h2> <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"> <NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3">
{d.seasons.map((se) => ( {d.seasons.map((se, i) => (
<SeasonCard <SeasonCard
key={se.id} key={se.id}
navIndex={i}
se={se} se={se}
onOpen={() => onOpenSeason(se.id)} onOpen={() => onOpenSeason(se.id)}
onToggleWatched={() => markSeason(se.id, se.status !== "watched")} onToggleWatched={() => markSeason(se.id, se.status !== "watched")}
@@ -520,7 +528,7 @@ export function PlexShowView({
} }
/> />
))} ))}
</div> </NavGrid>
</section> </section>
{show.cast.length > 0 && showCast && ( {show.cast.length > 0 && showCast && (
@@ -684,17 +692,18 @@ export function PlexSeasonView({
</div> </div>
</DetailHero> </DetailHero>
<div className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4"> <NavGrid className="grid grid-cols-[repeat(auto-fill,minmax(230px,1fr))] gap-x-3 gap-y-4">
{se.episodes.map((ep) => ( {se.episodes.map((ep, i) => (
<EpisodeCard <EpisodeCard
key={ep.id} key={ep.id}
navIndex={i}
ep={ep} ep={ep}
onPlay={() => onPlay(ep.id, allKeys)} onPlay={() => onPlay(ep.id, allKeys)}
onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })}
onToggleWatched={() => markEpisode(ep)} 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(); const onSelect = vi.fn();
renderMenu([{ id: "sync", label: "Sync", onSelect }]); renderMenu([{ id: "sync", label: "Sync", onSelect }]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" })); fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
fireEvent.click(screen.getByText("Sync")); fireEvent.click(screen.getByText("Sync"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("menuitem", { hidden: true })).toBeNull(); 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", () => { it("a disabled item is inert — rendered disabled and never fires onSelect", () => {
@@ -125,3 +130,67 @@ describe("ActionMenu", () => {
expect(screen.getByTestId("custom-trigger")).toBeTruthy(); 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);
});
});
+3 -5
View File
@@ -1,6 +1,6 @@
import { Fragment, useState, type ReactNode } from "react"; import { Fragment, useState, type ReactNode } from "react";
import { ChevronRight, MoreHorizontal } from "lucide-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. // 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 // Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
@@ -82,10 +82,8 @@ export function MenuItemList({
disabled={it.disabled} disabled={it.disabled}
label={it.label} label={it.label}
title={it.title} title={it.title}
onSelect={() => { // Any row may open a dialog (a confirm), so every row closes first — see closeThenAct.
it.onSelect(); onSelect={() => closeThenAct(onClose, it.onSelect)}
onClose();
}}
className={`${ROW}${it.danger ? " text-danger" : ""}`} className={`${ROW}${it.danger ? " text-danger" : ""}`}
> >
{it.icon && ( {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);
});
});
+103 -6
View File
@@ -3,6 +3,8 @@ import {
useContext, useContext,
useEffect, useEffect,
useId, useId,
useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
type ButtonHTMLAttributes, type ButtonHTMLAttributes,
@@ -37,6 +39,7 @@ import {
} from "@floating-ui/react"; } from "@floating-ui/react";
import { useLayer } from "../../lib/layerStack"; import { useLayer } from "../../lib/layerStack";
import { useFullscreenPortalHost } from "../../lib/fullscreen"; 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 // 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 // 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 // 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: // 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 // - 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 // - 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 // (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 // `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. // 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 // 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 // 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 // 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. // an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer:
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false)); // 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 { return {
nodeId, nodeId,
@@ -348,6 +394,18 @@ const HIGHLIGHT_SELECTED =
const HIGHLIGHT_SELECTED_RING_ONLY = const HIGHLIGHT_SELECTED_RING_ONLY =
"data-active:ring-2 data-active:ring-inset data-active:ring-accent/70"; "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({ export function PopoverItem({
children, children,
onSelect, onSelect,
@@ -543,12 +601,51 @@ function PopoverSurfaceInner({
// in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref. // in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref.
if (ariaLabel) delete floatingProps["aria-labelledby"]; 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 // 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 // passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
// is focus-managed. // is focus-managed.
const managed = focusManaged ?? (mode !== "hover" && !isCombobox); 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 = ( const surface = (
<div <div
ref={refs.setFloating} 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 // — 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. // input, so such a menu still has a keyboard entry point instead of stranding focus.
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)} initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
returnFocus returnFocus={!returnTo}
> >
{positioned} {positioned}
</FloatingFocusManager> </FloatingFocusManager>
+35 -7
View File
@@ -39,7 +39,7 @@ describe("Button primitive (S5)", () => {
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull(); 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(); const onClick = vi.fn();
render( render(
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}> <Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
@@ -47,30 +47,58 @@ describe("Button primitive (S5)", () => {
</Button>, </Button>,
); );
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement; 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-busy")).toBe("true");
expect(btn.getAttribute("aria-disabled")).toBe("true");
// the leading icon is swapped for a spinner // the leading icon is swapped for a spinner
expect(btn.querySelector("[data-testid=ic]")).toBeNull(); expect(btn.querySelector("[data-testid=ic]")).toBeNull();
fireEvent.click(btn); fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled(); 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 // Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed // 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(); const onClick = vi.fn();
render( render(
<Button disabled={false} loading onClick={onClick}> <Button disabled={false} loading onClick={onClick}>
Create Create
</Button>, </Button>,
); );
const btn = screen.getByRole("button", { name: "Create" }) as HTMLButtonElement; fireEvent.click(screen.getByRole("button", { name: "Create" }));
expect(btn.disabled).toBe(true);
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled(); 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", () => { 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 // 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. // silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
+37 -19
View File
@@ -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 // 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. // inconsistency this epic exists to remove; the primitive follows the majority, not the reverse.
outline: outline:
"font-medium border border-border text-muted enabled:hover:text-accent enabled:hover:border-accent", "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:hover:bg-accent/10", "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 // 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. // is whether the button should advertise the destruction before the pointer arrives.
"outline-danger": "outline-danger":
"font-medium border border-border text-muted enabled:hover:text-danger enabled:hover:border-danger/40", "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:hover:bg-warn/10", "outline-warn":
ghost: "font-medium text-muted enabled:hover:text-fg enabled:hover:bg-card", "font-medium border border-warn/40 text-warn enabled:not-aria-disabled:hover:bg-warn/10",
glass: "font-medium glass-card glass-hover enabled:hover:text-accent", 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": "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 // 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 // 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). // 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 // Why every hover above is `enabled:not-aria-disabled:hover:` — neither natively disabled nor busy. A
// still lights up on hover advertises a click that does nothing, and the ONE migrated call site that // disabled button that still lights up on hover advertises a click that does nothing, and the ONE
// had guarded its own hover (TagManager's Add) silently lost the guard when it moved onto this // migrated call site that had guarded its own hover (TagManager's Add) silently lost the guard when it
// primitive (E10 review round 1). The contract belongs here, not in ~160 call sites: BASE dims a // moved onto this primitive (E10 review round 1). `not-aria-disabled` is the `loading` half: a busy
// disabled button, and the variant must not paint over that dimming. // 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 // 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` // 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 // 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. // one element is a coin flip without tailwind-merge, so exactly one has to emit it — see the test.
const BASE = 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> { interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant; variant?: ButtonVariant;
@@ -98,7 +102,8 @@ interface ButtonOwnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
iconOnly?: boolean; iconOnly?: boolean;
/** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */ /** Accessible name + tooltip — required for `iconOnly`, optional otherwise. */
label?: string; 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; loading?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
} }
@@ -131,6 +136,7 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
children, children,
disabled, disabled,
type, type,
onClick,
...rest ...rest
}, },
ref, ref,
@@ -149,11 +155,23 @@ const ButtonImpl = forwardRef<HTMLButtonElement, ButtonOwnProps>(function Button
<button <button
ref={ref} ref={ref}
type={type ?? "button"} type={type ?? "button"}
// `loading` must always disable (see the prop's contract above), even when a call-site also // `loading` blocks activation WITHOUT native `disabled`. A focused button that turns disabled
// passes an explicit boolean `disabled` — so `||`, not `??`. With `??`, `disabled={false}` // loses the focus: Chrome moved it to <body> ~60 ms after the reroll click on Home and nothing
// would shadow `loading` and leave the button clickable mid-mutation (double-submit). // handed it back when the button re-enabled (measured, E11 S2) — so a keyboard user was dropped
disabled={disabled || loading} // 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} aria-busy={loading || undefined}
onClick={(e) => {
if (loading) {
e.preventDefault();
return;
}
onClick?.(e);
}}
aria-label={label} aria-label={label}
title={label} title={label}
className={clsx( className={clsx(
@@ -5,7 +5,7 @@
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and // 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. // 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 { 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -13,6 +13,7 @@ vi.mock("react-i18next", async (importOriginal) => {
}); });
import { Carousel } from "./Carousel"; import { Carousel } from "./Carousel";
import { navItemAttrs } from "../../lib/listNav";
beforeAll(() => { beforeAll(() => {
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none. // useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
@@ -91,3 +92,38 @@ describe("Carousel", () => {
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy(); 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);
});
});
+21 -3
View File
@@ -1,8 +1,9 @@
import { useCallback, type ReactNode } from "react"; import { useCallback, useRef, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import clsx from "clsx"; import clsx from "clsx";
import { useScrollEdges } from "../../lib/useScrollEdges"; 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 // 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 // the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
@@ -43,6 +44,7 @@ export function Carousel({
ariaLabel, ariaLabel,
gap = "gap-3", gap = "gap-3",
className, className,
keyboardNav = false,
children, children,
}: { }: {
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */ /** 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"). */ /** Tailwind gap class for the scroller (e.g. "gap-4"). */
gap?: string; gap?: string;
className?: 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; children: ReactNode;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { ref, el, left, right } = useHScroll(); 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( const page = useCallback(
(dir: -1 | 1) => { (dir: -1 | 1) => {
@@ -112,9 +128,11 @@ export function Carousel({
<ChevronLeft className="h-5 w-5" /> <ChevronLeft className="h-5 w-5" />
</button> </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 <div
ref={ref} ref={setScroller}
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)} className={clsx("no-scrollbar -mx-1 -mt-1 flex overflow-x-auto px-1 pt-1 pb-1", gap)}
style={{ maskImage: mask, WebkitMaskImage: mask }} style={{ maskImage: mask, WebkitMaskImage: mask }}
role="group" role="group"
aria-label={label} aria-label={label}
@@ -247,3 +247,44 @@ describe("Combobox", () => {
expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed 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. // (no i18n, no domain), so this asserts the shell only. i18n is key-passthrough.
import { useState } from "react"; import { useState } from "react";
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; 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"; import CommandPalette, { type CommandGroup } from "./CommandPalette";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
@@ -21,7 +21,7 @@ beforeAll(() => {
afterEach(cleanup); afterEach(cleanup);
// `choose` closes and waits for the dialog's own Back-to-close history entry to be reclaimed before // `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 ran: string[] = [];
const groups = (): CommandGroup[] => [ const groups = (): CommandGroup[] => [
{ {
@@ -160,3 +160,176 @@ describe("CommandPalette", () => {
expect(ran).toEqual(["closed"]); 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 // @vitest-environment jsdom
// //
// ONE test, ONE file, on purpose. This is the close→reclaim→run ordering contract of the palette // 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 // (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. (It is not // programmatic history.back() — which is why it cannot share module state with anything. (Any test
// the only place in the suite where an unstubbed back() fires; history.burial.dom.test.ts is the // rendering a real Modal can let an unstubbed back() go, too.)
// other file that asserts on one, and any test rendering a real Modal can let one go.)
// //
// It cannot live in CommandPalette.dom.test.tsx. jsdom gives a test FILE a single session history, // 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 // 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 () => { it("closes, and runs the command only once the close has landed", async () => {
render(<Harness />); render(<Harness />);
// Load-bearing: useBackToClose pushes its entry from a coalesced microtask, so a click in the // 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")); fireEvent.click(screen.getByText("TheVR Podcast"));
expect(ran).toEqual(["closed"]); expect(ran).toEqual(["closed"]);
// Inside closeThenNavigate's 250ms no-popstate fallback, so a pass means the RECLAIM ran the // Well inside closeTopOverlayThenRun's 1500 ms give-up timer, so a pass means the RECLAIM ran the
// command — not the safety net that exists for overlays which never got an entry at all. // command — not the late path for a back() that never reports. (The margin used to be 200 ms
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 200 }); // under a 250 ms fallback, which made this load-sensitive by construction.)
await waitFor(() => expect(ran).toEqual(["closed", "ch:1"]), { timeout: 1000 });
}); });
}); });
+168 -72
View File
@@ -2,7 +2,8 @@ import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "rea
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import Modal from "./Modal"; 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 // 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 // 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, ""); .replace(/\p{Diacritic}/gu, "");
} }
/** Every query word must appear somewhere in the item's text. Word-wise rather than substring so /** How well an item answers the query: `[worst word, sum of words]`, lower is better. */
* "thevr pod" finds "TheVR Podcast" — the way you actually half-remember a name. */ type Rank = readonly [number, number];
function matchesQuery(item: CommandItem, groupLabel: string, words: string[]): boolean {
if (words.length === 0) return true; const byRank = (a: Rank, b: Rank) => a[0] - b[0] || a[1] - b[1];
const hay = fold(`${item.label} ${item.hint ?? ""} ${item.keywords ?? ""} ${groupLabel}`);
return words.every((w) => hay.includes(w)); /** 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({ export default function CommandPalette({
open, open,
onClose, onClose,
@@ -59,42 +83,81 @@ export default function CommandPalette({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const listId = useId(); const listId = useId();
const groupId = useId();
const [query, setQuery] = useState(""); 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 // 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. // (playlist names, channel handles) and would need escaping to be safe in a query string.
const activeRef = useRef<HTMLButtonElement | null>(null); 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 // 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. // query from last time hides everything you might want now.
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setQuery(""); setQuery("");
setActive(0); setPicked(null);
} }
}, [open]); }, [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); const words = fold(query.trim()).split(/\s+/).filter(Boolean);
return groups const out: CommandGroup[] = [];
.map((g) => ({ ...g, items: g.items.filter((i) => matchesQuery(i, g.label, words)) })) let best: { id: string; rank: Rank } | null = null;
.filter((g) => g.items.length > 0); 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]); }, [groups, query]);
// One flat list behind the grouped rendering — arrow keys walk results, not sections. // One flat list behind the grouped rendering — arrow keys walk results, not sections.
const flat = useMemo(() => visible.flatMap((g) => g.items), [visible]); 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. // Keep the highlighted row in view when the arrows walk past the fold.
useEffect(() => { useEffect(() => {
activeRef.current?.scrollIntoView({ block: "nearest" }); activeRef.current?.scrollIntoView({ block: "nearest" });
}, [activeId]); }, [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; if (!open) return null;
// Close, wait for this dialog's own Back-to-close entry to be reclaimed, THEN run. Most commands // 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. // navigate, and a pushState issued before that reclaim is undone by it. Only THIS dialog closes —
const choose = (item: CommandItem) => closeThenNavigate(onClose, item.run); // 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 // 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. // (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(); e.preventDefault();
if (flat.length === 0) return; if (flat.length === 0) return;
const step = e.key === "ArrowDown" ? 1 : -1; 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") { } else if (e.key === "Enter") {
const item = flat[Math.min(active, flat.length - 1)]; const item = flat[activeIndex];
if (item) { if (item) {
e.preventDefault(); e.preventDefault();
choose(item); choose(item);
@@ -122,72 +198,92 @@ export default function CommandPalette({
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setActive(0); setPicked(null);
}} }}
onKeyDown={onInputKey} onKeyDown={onInputKey}
placeholder={t("palette.placeholder")} placeholder={t("palette.placeholder")}
aria-label={t("palette.placeholder")} aria-label={t("palette.placeholder")}
// A minimal combobox tree: the arrows move a VIRTUAL selection while the caret stays in // 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" role="combobox"
aria-expanded aria-expanded={flat.length > 0}
aria-controls={listId} aria-controls={listId}
aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined} aria-activedescendant={activeId ? `${listId}-${activeId}` : undefined}
className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted" className="w-full bg-transparent text-sm outline-hidden placeholder:text-muted"
/> />
</div> </div>
<div id={listId} role="listbox" className="mt-3 max-h-[50vh] overflow-y-auto"> <div role="status" aria-live="polite" className="sr-only">
{flat.length === 0 ? ( {announced}
<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> </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> </Modal>
); );
} }
@@ -108,13 +108,13 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual([]); 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(); const onSelect = vi.fn();
renderList(() => [{ id: "a", label: "Remove", onSelect }]); renderList(() => [{ id: "a", label: "Remove", onSelect }]);
fireEvent.contextMenu(row("alpha")); fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Remove")); fireEvent.click(item("Remove"));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]); expect(menuitems()).toEqual([]);
await vi.waitFor(() => expect(onSelect).toHaveBeenCalledTimes(1));
}); });
it("Shift+F10 opens it from the keyboard", () => { it("Shift+F10 opens it from the keyboard", () => {
@@ -130,6 +130,42 @@ describe("useContextMenu / ContextMenu", () => {
expect(menuitems()).toEqual([]); 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)", () => { describe("long press (touch)", () => {
const press = (el: HTMLElement, x = 10, y = 10) => const press = (el: HTMLElement, x = 10, y = 10) =>
fireEvent.pointerDown(el, { pointerType: "touch", clientX: x, clientY: y }); 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"]); 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(); const chosen = vi.fn();
renderList(() => [ renderList(() => [
{ id: "play", label: "Play", onSelect: vi.fn() }, { id: "play", label: "Play", onSelect: vi.fn() },
@@ -226,8 +262,8 @@ describe("useContextMenu / ContextMenu", () => {
fireEvent.contextMenu(row("alpha")); fireEvent.contextMenu(row("alpha"));
fireEvent.click(item("Move to")); fireEvent.click(item("Move to"));
fireEvent.click(item("Homelab")); fireEvent.click(item("Homelab"));
expect(chosen).toHaveBeenCalledTimes(1);
expect(menuitems()).toEqual([]); 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);
});
});
+22 -9
View File
@@ -3,7 +3,9 @@ import { useTranslation } from "react-i18next";
import { sortRows, cycleSort, type SortState } from "../../lib/columnSort"; import { sortRows, cycleSort, type SortState } from "../../lib/columnSort";
import Pager, { hasPagerContent } from "./Pager"; import Pager, { hasPagerContent } from "./Pager";
import { PageToolbar } from "./PageShell"; 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, // 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 // 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 // other modules can reuse it. Below `md` it falls back to a card list built from the same column
// definitions. // definitions.
// //
// The header row itself (sort cell + filter funnel/popover + the filter predicate) lives in // The header row itself (sort cell + filter funnel/popover) lives in `tableHeader.tsx`, the column
// `tableHeader.tsx` — the playlist manager renders its own drag-reorderable body under the same // contract and the filter predicate in `lib/tableColumns` — the playlist manager renders its own
// headers without inheriting this component's ordering state. // 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. // 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 { interface Persisted {
sort: SortState; sort: SortState;
@@ -62,6 +64,7 @@ export default function DataTable<T>({
filters: filtersProp, filters: filtersProp,
onFiltersChange, onFiltersChange,
fixedLayout = false, fixedLayout = false,
keyboardNav = false,
}: { }: {
rows: T[]; rows: T[];
columns: Column<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 // 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. // untouched; the channel manager turns it on so its row never scrolls horizontally out of view.
fixedLayout?: boolean; 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 { t } = useTranslation();
const initial = loadPersist(persistKey); const initial = loadPersist(persistKey);
@@ -125,6 +132,10 @@ export default function DataTable<T>({
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters; const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
const [page, setPage] = useState(initial.page); const [page, setPage] = useState(initial.page);
const [size, setSize] = useState(initial.size ?? pageSize); 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 // 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 // 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} filters={filters}
onFilter={applyFilter} onFilter={applyFilter}
/> />
<tbody> <tbody ref={bodyRef}>
{paged.map((row) => ( {paged.map((row, i) => (
<tr <tr
key={rowKey(row)} key={rowKey(row)}
{...rowProps?.(row)} {...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`border-b border-border/50 hover:bg-(--hover) transition ${ className={`border-b border-border/50 hover:bg-(--hover) transition ${
rowClassName?.(row) ?? "" 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 {/* 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). */} rest flowing in a single wrapping meta line (no full-width label/value gaps). */}
<div className="md:hidden flex flex-col gap-2"> <div ref={cardsRef} className="md:hidden flex flex-col gap-2">
{paged.map((row) => ( {paged.map((row, i) => (
<div <div
key={rowKey(row)} key={rowKey(row)}
{...rowProps?.(row)} {...rowProps?.(row)}
{...(keyboardNav && navItemAttrs(i))}
className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`} className={`glass-card rounded-xl p-3 ${rowClassName?.(row) ?? ""}`}
> >
{columns {columns
@@ -14,6 +14,7 @@ import {
useKeyScope, useKeyScope,
type KeyScope, type KeyScope,
} from "../../lib/keymap"; } from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
@@ -27,9 +28,11 @@ afterEach(() => {
closeKeyboardHelp(); 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 }) { function Host({ children }: { children?: React.ReactNode }) {
useGlobalKeymap(true, () => {}); useGlobalKeymap(true, () => {}, ["home"]);
return ( return (
<> <>
{children} {children}
@@ -81,15 +84,16 @@ describe("KeyboardHelp", () => {
); );
pressH(); pressH();
const rows = descriptions(); 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[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", () => { it("lists only the app-wide keys when no surface is registered", () => {
render(<Host />); render(<Host />);
pressH(); 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 // 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"); 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. // 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", () => { it("lists the context-menu chords once however many surfaces register", () => {
function WithMenu() { function WithMenu() {
+16 -2
View File
@@ -5,9 +5,12 @@ import {
closeKeyboardHelp, closeKeyboardHelp,
formatChord, formatChord,
helpLines, helpLines,
isPageLevelScope,
useActiveScopes, useActiveScopes,
useHelpConditionValues, useHelpConditionValues,
useKeyboardHelpOpen, useKeyboardHelpOpen,
useKeyboardHelpOverLayer,
useNavigablePages,
} from "../../lib/keymap"; } from "../../lib/keymap";
// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in // 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 open = useKeyboardHelpOpen();
const scopes = useActiveScopes(); const scopes = useActiveScopes();
const conditions = useHelpConditionValues(); const conditions = useHelpConditionValues();
const overLayer = useKeyboardHelpOverLayer();
const pages = useNavigablePages();
if (!open) return null; if (!open) return null;
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be // A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
// a promise the player does not keep. // 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 ( return (
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md"> <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"> <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} key={chord}
className="rounded-sm border border-border bg-surface px-1.5 py-0.5 text-xs font-mono whitespace-nowrap" 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> </kbd>
))} ))}
</dt> </dt>

Some files were not shown because too many files have changed in this diff Show More