release: 0.85.0 (feature/e11-power-navigation)
This commit is contained in:
@@ -7,6 +7,31 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
// The global Shift+W wiring, pinned at its call site.
|
|
||||||
//
|
|
||||||
// What this is: a source contract, the same idiom as uiBoundary.contract.test.ts — a structural rule
|
|
||||||
// asserted directly rather than left to prose. What it is NOT: a behaviour test. The behaviour lives
|
|
||||||
// in history.dom.test.ts and history.burial.dom.test.ts, which drive `navigateOverOverlays` against
|
|
||||||
// real overlay entries; the end-to-end gesture (Shift+W over an open player, then Back) is a browser
|
|
||||||
// check, because jsdom reproduces history ordering only as bookkeeping.
|
|
||||||
//
|
|
||||||
// It exists because the composition line itself was unpinned: App.tsx is the one module no test
|
|
||||||
// renders (main.tsx lazy-imports it and nothing else does), globalKeymap.dom.test.tsx drives the
|
|
||||||
// hook with its OWN handler, and history.dom.test.ts drives the helper with its own navigate — so
|
|
||||||
// dropping `navigateOverOverlays` from App left all 818 tests green while the user-visible bug came
|
|
||||||
// straight back (round 2, verified by running that revert).
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
const APP = readFileSync(fileURLToPath(new URL("./App.tsx", import.meta.url)), "utf8");
|
|
||||||
|
|
||||||
describe("App · global keymap wiring", () => {
|
|
||||||
it("routes the global home shortcut through navigateOverOverlays", () => {
|
|
||||||
const call = /useGlobalKeymap\(([\s\S]*?)\);/.exec(APP)?.[1];
|
|
||||||
// A rename or a move is a legitimate change — but then this test is the thing that has to be
|
|
||||||
// updated deliberately, which is the whole point of pinning it.
|
|
||||||
expect(
|
|
||||||
call,
|
|
||||||
"App.tsx no longer calls useGlobalKeymap in a shape this test can read",
|
|
||||||
).toBeTypeOf("string");
|
|
||||||
expect(call).toContain("navigateOverOverlays");
|
|
||||||
expect(call).toContain('setPage("home")');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+22
-13
@@ -3,6 +3,7 @@ import { qk } from "./lib/queryKeys";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { 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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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 }, "");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 ${
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user