feat(keys): j/k walk the feed, s finds the search box, g jumps to a module
A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass. First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c. - `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k` step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next item takes it, so the player's focus restore lands back on the card. The first `j` takes the first card you can see, not the top of the list. - `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in); `VirtualFeed` turns it on, so the feed, the library and the channel page all have it. - Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open, enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the Hungarian layout. - `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends its first Escape on its panel). - Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences the page keys. - Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/ link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome). - Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable module; opened over a player or dialog it leaves out the page-level keys and the context-menu key, which nothing can reach there (the latter pre-existing since E10). - CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` + `data-nav-open`, not a private arrow-key handler).
This commit is contained in:
@@ -90,6 +90,7 @@ 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 | `useListNav` + `navItemAttrs(i)` + one `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
| 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 |
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||||
|
|||||||
@@ -23,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";
|
||||||
@@ -147,7 +147,13 @@ export default function App() {
|
|||||||
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
||||||
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
||||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||||
useGlobalKeymap(!!meQuery.data, () => setPage("home"));
|
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||||
|
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||||
|
useGlobalKeymap(
|
||||||
|
!!meQuery.data,
|
||||||
|
setPage,
|
||||||
|
meQuery.data ? [...moduleOrder(meQuery.data), "settings"] : [],
|
||||||
|
);
|
||||||
|
|
||||||
// Pin this tab to whatever account it first loaded as. Without this, a tab that never picked
|
// 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -282,8 +282,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,
|
||||||
|
|||||||
@@ -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,21 @@ 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]);
|
||||||
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">
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// E11 S4: a hover surface (tooltip, hover card) is a PASSIVE layer. It still sits on the layer stack —
|
||||||
|
// Escape closes it before the surface beneath — but it does not own the keyboard, so the page-level
|
||||||
|
// keys (`j`/`k`, `s`, the `g` chords) keep working while one happens to be open under a resting pointer.
|
||||||
|
// Without the flag, hovering any hinted icon silently disabled keyboard navigation.
|
||||||
|
//
|
||||||
|
// jsdom lays nothing out, and floating-ui's `hide` middleware then reports the reference as scrolled
|
||||||
|
// out of view — and an invisible surface is deliberately NOT a layer (AnchoredPopover). A test on that
|
||||||
|
// would pass without the flag, so the reference and the viewport get real boxes first, and the test
|
||||||
|
// proves the surface is a layer (Escape closes it) before trusting the "not blocking" half.
|
||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import Tooltip from "./Tooltip";
|
||||||
|
import { anyBlockingLayer } from "../../lib/layerStack";
|
||||||
|
|
||||||
|
const box = { x: 200, y: 200, top: 200, left: 200, width: 60, height: 24, right: 260, bottom: 224 };
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
class Stub {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
|
vi.spyOn(Element.prototype, "getBoundingClientRect").mockReturnValue({
|
||||||
|
...box,
|
||||||
|
toJSON: () => box,
|
||||||
|
} as DOMRect);
|
||||||
|
for (const [prop, value] of [
|
||||||
|
["clientWidth", 1280],
|
||||||
|
["clientHeight", 800],
|
||||||
|
] as const) {
|
||||||
|
Object.defineProperty(document.documentElement, prop, { configurable: true, value });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
afterAll(() => vi.restoreAllMocks());
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe("an open tooltip", () => {
|
||||||
|
it("is a layer Escape reaches, but does not block the page keys", async () => {
|
||||||
|
render(
|
||||||
|
<Tooltip hint="A hint" always>
|
||||||
|
<button>icon</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByText("icon").parentElement as HTMLElement;
|
||||||
|
act(() => {
|
||||||
|
fireEvent.pointerEnter(trigger);
|
||||||
|
fireEvent.mouseEnter(trigger);
|
||||||
|
fireEvent.mouseMove(trigger);
|
||||||
|
});
|
||||||
|
const surface = await screen.findByRole("tooltip");
|
||||||
|
await waitFor(() => expect(surface.style.visibility).not.toBe("hidden"));
|
||||||
|
|
||||||
|
expect(anyBlockingLayer()).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { useListNav } from "../../lib/listNav";
|
||||||
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
||||||
import { shouldPrefetchNext } from "./readAhead";
|
import { shouldPrefetchNext } from "./readAhead";
|
||||||
|
|
||||||
@@ -66,8 +67,13 @@ export interface VirtualGridProps<T> {
|
|||||||
rowEst: number;
|
rowEst: number;
|
||||||
/** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived
|
/** Renders one item. `itemWidth` is the measured px width of one cell — for width-derived
|
||||||
* layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to
|
* layouts (e.g. a one-line row that drops columns as it narrows); ignore it otherwise, and to
|
||||||
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */
|
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down.
|
||||||
renderItem: (item: T, itemWidth: number) => ReactNode;
|
* `index` is the item's position in `items` — what `navItemAttrs` wants on the item's root. */
|
||||||
|
renderItem: (item: T, itemWidth: number, index: number) => ReactNode;
|
||||||
|
/** Take part in page keyboard navigation (`lib/listNav`): `j`/`k`/arrows/Enter over the items.
|
||||||
|
* The grid supplies the column count and scrolls an unmounted item's row in; the item must spread
|
||||||
|
* `navItemAttrs(index)` on its root and mark its "open" element `data-nav-open`. */
|
||||||
|
keyboardNav?: boolean;
|
||||||
/** Infinite scroll — omit all three for a finite client-side list. */
|
/** Infinite scroll — omit all three for a finite client-side list. */
|
||||||
hasNextPage?: boolean;
|
hasNextPage?: boolean;
|
||||||
isFetchingNextPage?: boolean;
|
isFetchingNextPage?: boolean;
|
||||||
@@ -85,6 +91,7 @@ export default function VirtualGrid<T>({
|
|||||||
hasNextPage = false,
|
hasNextPage = false,
|
||||||
isFetchingNextPage = false,
|
isFetchingNextPage = false,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
|
keyboardNav = false,
|
||||||
}: VirtualGridProps<T>) {
|
}: VirtualGridProps<T>) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||||
@@ -139,6 +146,15 @@ export default function VirtualGrid<T>({
|
|||||||
|
|
||||||
const virtualRows = virtualizer.getVirtualItems();
|
const virtualRows = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// Only ~a screen of rows (plus overscan) is mounted, so a key that moves further — or past the
|
||||||
|
// overscan at the edge — has to scroll the row in before the item exists to be focused.
|
||||||
|
useListNav(listRef, {
|
||||||
|
enabled: keyboardNav,
|
||||||
|
count: items.length,
|
||||||
|
cols: colCount,
|
||||||
|
reveal: (index) => virtualizer.scrollToIndex(Math.floor(index / colCount), { align: "auto" }),
|
||||||
|
});
|
||||||
|
|
||||||
// Infinite-scroll read-ahead: pull the next page once the last rendered row is within the
|
// Infinite-scroll read-ahead: pull the next page once the last rendered row is within the
|
||||||
// read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row
|
// read-ahead window of the loaded end (see readAhead.ts — the window scales with the rendered row
|
||||||
// count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no
|
// count, clamped so a tall window doesn't prefetch unboundedly). A no-op for finite lists (no
|
||||||
@@ -159,6 +175,7 @@ export default function VirtualGrid<T>({
|
|||||||
{virtualRows.map((vr) => {
|
{virtualRows.map((vr) => {
|
||||||
const row = rows[vr.index];
|
const row = rows[vr.index];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
const first = vr.index * colCount;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={vr.key}
|
key={vr.key}
|
||||||
@@ -185,8 +202,8 @@ export default function VirtualGrid<T>({
|
|||||||
{/* A transparent Fragment carries React's key so the rendered item stays the DIRECT
|
{/* A transparent Fragment carries React's key so the rendered item stays the DIRECT
|
||||||
grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned
|
grid/column child — some cards rely on that (e.g. `h-full` stretch, actions pinned
|
||||||
to the bottom edge), which a wrapper element would silently break. */}
|
to the bottom edge), which a wrapper element would silently break. */}
|
||||||
{row.map((item) => (
|
{row.map((item, i) => (
|
||||||
<Fragment key={getKey(item)}>{renderItem(item, itemWidth)}</Fragment>
|
<Fragment key={getKey(item)}>{renderItem(item, itemWidth, first + i)}</Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,5 +21,18 @@
|
|||||||
"back": "Stop & back to feed",
|
"back": "Stop & back to feed",
|
||||||
"browseBack": "Back one level",
|
"browseBack": "Back one level",
|
||||||
"closePlayer": "Close the player",
|
"closePlayer": "Close the player",
|
||||||
"palette": "Open the jump-to palette"
|
"palette": "Open the jump-to palette",
|
||||||
|
"goFeed": "Go to Feed",
|
||||||
|
"goChannels": "Go to Channels",
|
||||||
|
"goPlaylists": "Go to Playlists",
|
||||||
|
"goPlex": "Go to Plex",
|
||||||
|
"goNotifications": "Go to Notifications",
|
||||||
|
"goMessages": "Go to Messages",
|
||||||
|
"goDownloads": "Go to Downloads",
|
||||||
|
"goStats": "Go to Stats",
|
||||||
|
"goSettings": "Go to Settings",
|
||||||
|
"search": "Jump to the search box",
|
||||||
|
"listStep": "Next / previous item",
|
||||||
|
"gridMove": "Move around the list",
|
||||||
|
"open": "Open the selected item"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,18 @@
|
|||||||
"back": "Leállítás és vissza a feedre",
|
"back": "Leállítás és vissza a feedre",
|
||||||
"browseBack": "Vissza egy szintet",
|
"browseBack": "Vissza egy szintet",
|
||||||
"closePlayer": "Lejátszó bezárása",
|
"closePlayer": "Lejátszó bezárása",
|
||||||
"palette": "Ugrás-paletta megnyitása"
|
"palette": "Ugrás-paletta megnyitása",
|
||||||
|
"goFeed": "Ugrás: Hírfolyam",
|
||||||
|
"goChannels": "Ugrás: Csatornák",
|
||||||
|
"goPlaylists": "Ugrás: Lejátszási listák",
|
||||||
|
"goPlex": "Ugrás: Plex",
|
||||||
|
"goNotifications": "Ugrás: Értesítések",
|
||||||
|
"goMessages": "Ugrás: Üzenetek",
|
||||||
|
"goDownloads": "Ugrás: Letöltések",
|
||||||
|
"goStats": "Ugrás: Statisztika",
|
||||||
|
"goSettings": "Ugrás: Beállítások",
|
||||||
|
"search": "Ugrás a keresőbe",
|
||||||
|
"listStep": "Következő / előző elem",
|
||||||
|
"gridMove": "Mozgás a listában",
|
||||||
|
"open": "A kijelölt elem megnyitása"
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-2
@@ -84,7 +84,8 @@
|
|||||||
[role="button"]:focus-visible,
|
[role="button"]:focus-visible,
|
||||||
[role="tab"]:focus-visible,
|
[role="tab"]:focus-visible,
|
||||||
summary:focus-visible {
|
summary:focus-visible {
|
||||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
/* Solid accent (E11 D2 = B): the 60% mix of R9 all but vanished on the dark glass. */
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,10 +436,29 @@ body {
|
|||||||
.selectable:focus-visible {
|
.selectable:focus-visible {
|
||||||
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
|
/* outline (not a box-shadow ring) so it never fights a glass surface's box-shadow and never gets
|
||||||
clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */
|
clipped by an overflow-hidden ancestor; inset offset keeps it inside tightly-packed rows. */
|
||||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* E11 S4 · a list/grid item the keyboard landed on (`lib/listNav`): the SAME solid ring, around the
|
||||||
|
whole card rather than one control inside it (D2 = B). It is focus-visible only, so a mouse click
|
||||||
|
never paints it, and it is a ring rather than a fill, so it never reads like the hover tint. The
|
||||||
|
scroll margins are what `scrollIntoView({block:"nearest"})` honours: they keep a card stepped to
|
||||||
|
from sliding under the floating header (`--hdr-h`) or flush against the bottom edge. */
|
||||||
|
[data-nav-index] {
|
||||||
|
scroll-margin-top: calc(var(--hdr-h, 0px) + 0.75rem);
|
||||||
|
scroll-margin-bottom: 0.75rem;
|
||||||
|
/* A transparent resting ring of the SAME geometry: the video card transitions `all`, so without
|
||||||
|
it the ring animated in from the initial outline (3px, currentColor) — a white flash on every
|
||||||
|
`j`, measured in Chrome. Now only the colour fades in. */
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
[data-nav-index]:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== R10 S4 · Selection-paint recipes =====
|
/* ===== R10 S4 · Selection-paint recipes =====
|
||||||
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
|
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
|
||||||
bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
|
bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function Host({
|
|||||||
onState: (s: string) => void;
|
onState: (s: string) => void;
|
||||||
onHome?: () => void;
|
onHome?: () => void;
|
||||||
}) {
|
}) {
|
||||||
useGlobalKeymap(enabled, onHome);
|
useGlobalKeymap(enabled, (page) => page === "home" && onHome(), ["home"]);
|
||||||
const help = useKeyboardHelpOpen();
|
const help = useKeyboardHelpOpen();
|
||||||
const palette = usePaletteOpen();
|
const palette = usePaletteOpen();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// Two-step sequences (`g h`) and the page-level quiet rule, in their own file: the pending first step
|
||||||
|
// is module state, and a test that left one pending would turn the NEXT test's first key into its
|
||||||
|
// second step. Every test here starts a fake clock far past the previous test's, so nothing carries.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SHORTCUTS, dispatch, formatChord, helpLines, type ShortcutId } from "./keymap";
|
||||||
|
import { pushLayer, removeLayer } from "./layerStack";
|
||||||
|
|
||||||
|
let clock = Date.UTC(2030, 0, 1);
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
clock += 3_600_000;
|
||||||
|
vi.setSystemTime(clock);
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
function press(init: KeyboardEventInit, on?: HTMLElement): KeyboardEvent {
|
||||||
|
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
|
||||||
|
(on ?? document.body).dispatchEvent(e);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every page row's handler, recording which one ran. */
|
||||||
|
function pageHandlers(calls: string[]) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
SHORTCUTS.filter((s) => s.scope === "page").map((s) => [s.id, () => void calls.push(s.id)]),
|
||||||
|
) as { readonly [K in ShortcutId<"page">]: () => void };
|
||||||
|
}
|
||||||
|
const listHandlers = (calls: string[]) =>
|
||||||
|
({
|
||||||
|
next: () => calls.push("next"),
|
||||||
|
prev: () => calls.push("prev"),
|
||||||
|
left: () => {},
|
||||||
|
right: () => {},
|
||||||
|
up: () => {},
|
||||||
|
down: () => {},
|
||||||
|
open: () => {},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
describe("two-step sequences", () => {
|
||||||
|
it("runs the row once both steps arrive, and not on the first alone", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
expect(dispatch(press({ key: "g" }), "page", pageHandlers(calls))).toBe(true);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
expect(dispatch(press({ key: "f" }), "page", pageHandlers(calls))).toBe(true);
|
||||||
|
expect(calls).toEqual(["goFeed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every scope has its own window listener, so the `h` completing `g h` reaches the global one too.
|
||||||
|
it("keeps the completing key from every other scope, whichever listener runs first", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const global = {
|
||||||
|
help: () => calls.push("help"),
|
||||||
|
palette: () => {},
|
||||||
|
home: () => {},
|
||||||
|
};
|
||||||
|
// another scope BEFORE the owner: the pending step belongs to `page`, so it stands aside
|
||||||
|
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
||||||
|
const h = press({ key: "h" });
|
||||||
|
expect(dispatch(h, "global", global)).toBe(false);
|
||||||
|
expect(dispatch(h, "page", pageHandlers(calls))).toBe(true);
|
||||||
|
expect(calls).toEqual(["goHome"]);
|
||||||
|
|
||||||
|
// another scope AFTER the owner: the owner stamped the event
|
||||||
|
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
||||||
|
const j = press({ key: "j" }); // breaks the sequence: no `g j`
|
||||||
|
expect(dispatch(j, "page", pageHandlers(calls))).toBe(false);
|
||||||
|
expect(dispatch(j, "list", listHandlers(calls))).toBe(false);
|
||||||
|
expect(calls).toEqual(["goHome"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up after the window, so a late key is an ordinary key again", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
||||||
|
vi.setSystemTime(clock + 1_600);
|
||||||
|
expect(dispatch(press({ key: "j" }), "list", listHandlers(calls))).toBe(true);
|
||||||
|
expect(dispatch(press({ key: "f" }), "page", pageHandlers(calls))).toBe(false);
|
||||||
|
expect(calls).toEqual(["next"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not broken by reaching for a modifier between the steps", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
||||||
|
dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls));
|
||||||
|
dispatch(press({ key: "d" }), "page", pageHandlers(calls));
|
||||||
|
expect(calls).toEqual(["goDownloads"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start or finish inside a text field", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const input = document.createElement("input");
|
||||||
|
document.body.append(input);
|
||||||
|
expect(dispatch(press({ key: "g" }, input), "page", pageHandlers(calls))).toBe(false);
|
||||||
|
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
||||||
|
expect(dispatch(press({ key: "f" }, input), "page", pageHandlers(calls))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
input.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("page-level scopes under a layer", () => {
|
||||||
|
it("are quiet while a keyboard-owning layer is open", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const id = pushLayer();
|
||||||
|
expect(dispatch(press({ key: "j" }), "list", listHandlers(calls))).toBe(false);
|
||||||
|
expect(dispatch(press({ key: "s" }), "search", { focus: () => calls.push("s") })).toBe(false);
|
||||||
|
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
|
||||||
|
dispatch(press({ key: "f" }), "page", pageHandlers(calls));
|
||||||
|
removeLayer(id);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The layer's OWN keys must keep working — that is the player the page keys are standing aside for.
|
||||||
|
it("leave the layer's own scope alone", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const id = pushLayer();
|
||||||
|
expect(
|
||||||
|
dispatch(press({ key: "k" }), "plexPlayer", { playPause: () => calls.push("pp") } as never),
|
||||||
|
).toBe(true);
|
||||||
|
removeLayer(id);
|
||||||
|
expect(calls).toEqual(["pp"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keep working under a passive layer (an open tooltip)", () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
const id = pushLayer({ passive: true });
|
||||||
|
expect(dispatch(press({ key: "j" }), "list", listHandlers(calls))).toBe(true);
|
||||||
|
removeLayer(id);
|
||||||
|
expect(calls).toEqual(["next"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the page-level rows", () => {
|
||||||
|
// Owner rule D1, enforced rather than commented: on the Hungarian layout the owner types on, a
|
||||||
|
// chord with a modifier (or `/`, `?`, which ARE Shift chords there) is not a quick key.
|
||||||
|
it("need no modifier and no Shift-only character", () => {
|
||||||
|
for (const s of SHORTCUTS) {
|
||||||
|
if (!["page", "search", "list"].includes(s.scope)) continue;
|
||||||
|
for (const chord of s.keys) {
|
||||||
|
for (const step of chord.split(" ")) {
|
||||||
|
expect(step.includes("+"), `${s.scope}:${s.id} ${chord}`).toBe(false);
|
||||||
|
expect(/^[a-z0-9,.-]$|^(Enter|Escape|Arrow(Up|Down|Left|Right))$/.test(step), chord).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("render as the keys to press, step by step", () => {
|
||||||
|
expect(formatChord("g h")).toBe("G H");
|
||||||
|
expect(formatChord("g ,")).toBe("G ,");
|
||||||
|
});
|
||||||
|
|
||||||
|
// `g h` shares Home's line with Shift+W; leaving out an unreachable chord must not take that with it.
|
||||||
|
it("drop an unreachable module's chord but keep the line it shares", () => {
|
||||||
|
const lines = helpLines(["page", "global"], ["home", "feed"]);
|
||||||
|
expect(lines.find((l) => l.labelKey === "keys.home")?.chords).toEqual(["g h", "Shift+w"]);
|
||||||
|
expect(lines.some((l) => l.labelKey === "keys.goFeed")).toBe(true);
|
||||||
|
expect(lines.some((l) => l.labelKey === "keys.goPlex")).toBe(false);
|
||||||
|
expect(
|
||||||
|
helpLines(["page", "global"], []).find((l) => l.labelKey === "keys.home")?.chords,
|
||||||
|
).toEqual(["Shift+w"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+239
-25
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
import { anyBlockingLayer } from "./layerStack";
|
||||||
import { createStore } from "./store";
|
import { createStore } from "./store";
|
||||||
|
import type { Page } from "./urlState";
|
||||||
|
|
||||||
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
|
// ── The app's ONE keyboard map ────────────────────────────────────────────────────────────────
|
||||||
// Before E10 S3 six surfaces each owned a private `keydown` listener with its own key literals and
|
// Before E10 S3 six surfaces each owned a private `keydown` listener with its own key literals and
|
||||||
@@ -18,8 +20,26 @@ import { createStore } from "./store";
|
|||||||
// one place, and the sheet renders this table rather than a hand-copied array beside it.
|
// one place, and the sheet renders this table rather than a hand-copied array beside it.
|
||||||
|
|
||||||
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
|
/** Which surface a shortcut belongs to. `global` is live app-wide; the rest only while that surface
|
||||||
* is mounted. The help sheet shows `global` plus the topmost registered scope. */
|
* is mounted. The help sheet shows `global` plus the registered scopes.
|
||||||
export type KeyScope = "global" | "undo" | "contextMenu" | "ytPlayer" | "plexPlayer" | "plexBrowse";
|
*
|
||||||
|
* `page`, `search` and `list` are the PAGE-LEVEL scopes (E11 S4): they act on the page underneath
|
||||||
|
* every overlay, so `dispatch` keeps them quiet while a layer that owns the keyboard is open. That is
|
||||||
|
* also what keeps them clear of the players' bare letters — the Plex player's `s` and `k` only ever
|
||||||
|
* run with the player's own layer up. */
|
||||||
|
export type KeyScope =
|
||||||
|
| "global"
|
||||||
|
| "page"
|
||||||
|
| "search"
|
||||||
|
| "list"
|
||||||
|
| "undo"
|
||||||
|
| "contextMenu"
|
||||||
|
| "ytPlayer"
|
||||||
|
| "plexPlayer"
|
||||||
|
| "plexBrowse";
|
||||||
|
|
||||||
|
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>(["page", "search", "list"]);
|
||||||
|
/** True for a scope that acts on the page beneath the overlays (see `KeyScope`). */
|
||||||
|
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
|
||||||
|
|
||||||
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
/** Extra render condition for the help sheet (the shortcut itself is always live). */
|
||||||
type ShortcutWhen = "episode";
|
type ShortcutWhen = "episode";
|
||||||
@@ -42,11 +62,19 @@ export interface Shortcut {
|
|||||||
* and a player whose Escape died inside its own subtitle-offset input is a trap. Every other
|
* and a player whose Escape died inside its own subtitle-offset input is a trap. Every other
|
||||||
* shortcut stays out of the way of typing. */
|
* shortcut stays out of the way of typing. */
|
||||||
readonly allowInInput?: boolean;
|
readonly allowInInput?: boolean;
|
||||||
|
/** The module a `g` chord jumps to. The sheet lists the row only while that module is available
|
||||||
|
* to this account, and the handler refuses it otherwise — a gated module has no page to land on. */
|
||||||
|
readonly page?: Page;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chord syntax: `Mod` = Ctrl on Windows/Linux, ⌘ on macOS. `Shift`/`Alt` are literal. The final
|
// Chord syntax: `Mod` = Ctrl on Windows/Linux, ⌘ on macOS. `Shift`/`Alt` are literal. The final
|
||||||
// segment is a `KeyboardEvent.key` name, except `Space` (which also accepts `code`, because a
|
// segment is a `KeyboardEvent.key` name, except `Space` (which also accepts `code`, because a
|
||||||
// focused button reports `key === " "` inconsistently across engines).
|
// focused button reports `key === " "` inconsistently across engines). A SPACE separates the two
|
||||||
|
// steps of a sequence: `g h` is `g`, then `h` within SEQUENCE_MS.
|
||||||
|
//
|
||||||
|
// Owner rule (E11 D1): no new binding may need a modifier on the Hungarian QWERTZ layout — so no `/`
|
||||||
|
// or `?` (both Shift chords there). Letters, digits, `-`, `,`, `.`, Esc/Enter/arrows only. `keyMatches`
|
||||||
|
// compares `e.key`, so a letter follows the printed legend (the HU Z/Y swap included).
|
||||||
export const SHORTCUTS = [
|
export const SHORTCUTS = [
|
||||||
// --- global ---------------------------------------------------------------------------------
|
// --- global ---------------------------------------------------------------------------------
|
||||||
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
|
// `h`, not `?`: on a Hungarian layout `?` is a Shift chord, and the one help key people already
|
||||||
@@ -60,6 +88,50 @@ export const SHORTCUTS = [
|
|||||||
// sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
|
// sits directly above `s`, so a blind reach for the subtitle key would yank you out of the film
|
||||||
// and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
|
// and onto the landing page. A chord makes it the deliberate "I'm stuck — answer the question".
|
||||||
{ id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
|
{ id: "home", scope: "global", keys: ["Shift+w"], labelKey: "keys.home" },
|
||||||
|
|
||||||
|
// --- page-level: jump to a module (quiet under an overlay) -----------------------------------
|
||||||
|
// English initials, because the Hungarian ones collide (Letöltések / Lejátszási listák). `g h` is
|
||||||
|
// Shift+W's modifier-free twin and shares its help line. Admin pages stay on the palette.
|
||||||
|
{ id: "goHome", scope: "page", keys: ["g h"], labelKey: "keys.home", page: "home" },
|
||||||
|
{ id: "goFeed", scope: "page", keys: ["g f"], labelKey: "keys.goFeed", page: "feed" },
|
||||||
|
{ id: "goChannels", scope: "page", keys: ["g c"], labelKey: "keys.goChannels", page: "channels" },
|
||||||
|
{
|
||||||
|
id: "goPlaylists",
|
||||||
|
scope: "page",
|
||||||
|
keys: ["g p"],
|
||||||
|
labelKey: "keys.goPlaylists",
|
||||||
|
page: "playlists",
|
||||||
|
},
|
||||||
|
{ id: "goPlex", scope: "page", keys: ["g x"], labelKey: "keys.goPlex", page: "plex" },
|
||||||
|
{
|
||||||
|
id: "goNotifications",
|
||||||
|
scope: "page",
|
||||||
|
keys: ["g n"],
|
||||||
|
labelKey: "keys.goNotifications",
|
||||||
|
page: "notifications",
|
||||||
|
},
|
||||||
|
{ id: "goMessages", scope: "page", keys: ["g m"], labelKey: "keys.goMessages", page: "messages" },
|
||||||
|
{
|
||||||
|
id: "goDownloads",
|
||||||
|
scope: "page",
|
||||||
|
keys: ["g d"],
|
||||||
|
labelKey: "keys.goDownloads",
|
||||||
|
page: "downloads",
|
||||||
|
},
|
||||||
|
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
|
||||||
|
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
|
||||||
|
|
||||||
|
// --- the module's search box (wherever a SearchBar is mounted) -------------------------------
|
||||||
|
{ id: "focus", scope: "search", keys: ["s"], labelKey: "keys.search" },
|
||||||
|
|
||||||
|
// --- list / grid navigation (wherever a list registers with `useListNav`) --------------------
|
||||||
|
{ id: "next", scope: "list", keys: ["j"], labelKey: "keys.listStep" },
|
||||||
|
{ id: "prev", scope: "list", keys: ["k"], labelKey: "keys.listStep" },
|
||||||
|
{ id: "left", scope: "list", keys: ["ArrowLeft"], labelKey: "keys.gridMove" },
|
||||||
|
{ id: "right", scope: "list", keys: ["ArrowRight"], labelKey: "keys.gridMove" },
|
||||||
|
{ id: "up", scope: "list", keys: ["ArrowUp"], labelKey: "keys.gridMove" },
|
||||||
|
{ id: "down", scope: "list", keys: ["ArrowDown"], labelKey: "keys.gridMove" },
|
||||||
|
{ id: "open", scope: "list", keys: ["Enter"], labelKey: "keys.open" },
|
||||||
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
// --- the row context menu (wherever useContextMenu is wired) ---------------------------------
|
||||||
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
// Its OWN scope, not `global`: only the surfaces that actually call `useContextMenu` register it,
|
||||||
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
|
// so the sheet stops advertising "Open the context menu" on the feed, Plex, Downloads and Settings
|
||||||
@@ -206,23 +278,87 @@ export function matchesShortcut<S extends KeyScope>(
|
|||||||
return !!row?.keys.some((k) => matches(e, k));
|
return !!row?.keys.some((k) => matches(e, k));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Two-step sequences (`g h`) ─────────────────────────────────────────────────────────────────
|
||||||
|
// Each scope keeps its own window listener, so a sequence cannot live in one listener's closure: the
|
||||||
|
// `h` that completes `g h` also reaches the global listener (where `h` opens the sheet) and every
|
||||||
|
// other live scope. So the pending first step is module state, and it belongs to the scope that
|
||||||
|
// started it — every OTHER scope ignores the next key while it is pending, and the owner stamps the
|
||||||
|
// event once it has resolved it, so a scope whose listener runs after the owner's ignores it too.
|
||||||
|
// The stamp is the layerStack Escape trick: every listener in one dispatch gets the same object.
|
||||||
|
const SEQUENCE_MS = 1500;
|
||||||
|
let pending: { scope: KeyScope; key: string; at: number } | null = null;
|
||||||
|
const sequenceEvents = new WeakSet<KeyboardEvent>();
|
||||||
|
// A modifier's own keydown (reaching for Shift) neither completes nor breaks a sequence.
|
||||||
|
const MODIFIER_KEYS = new Set(["Shift", "Control", "Alt", "AltGraph", "Meta", "CapsLock"]);
|
||||||
|
|
||||||
|
function liveSequence(e: KeyboardEvent): typeof pending {
|
||||||
|
if (!pending) return null;
|
||||||
|
if (Date.now() - pending.at >= SEQUENCE_MS) {
|
||||||
|
pending = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return MODIFIER_KEYS.has(e.key) ? null : pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepsOf = (chord: string): string[] => chord.split(" ");
|
||||||
|
|
||||||
/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides
|
/** Run `scope`'s map against an event. Returns true when a shortcut matched (the handler decides
|
||||||
* whether to `preventDefault`, because only it knows if it actually acted). `handlers` is a mapped
|
* whether to `preventDefault`, because only it knows if it actually acted) or a sequence's first
|
||||||
* type over the scope's ids, so the table and the implementation cannot drift without tsc saying
|
* step was taken. `handlers` is a mapped type over the scope's ids, so the table and the
|
||||||
* so. Skipped while typing unless the row opts in with `allowInInput` (Escape, and only Escape). */
|
* implementation cannot drift without tsc saying so. Skipped while typing unless the row opts in
|
||||||
|
* with `allowInInput` (Escape, and only Escape), and — for a page-level scope — while a layer that
|
||||||
|
* owns the keyboard is open. The rule lives here rather than in each listener so a new page-level
|
||||||
|
* surface cannot forget it. */
|
||||||
export function dispatch<S extends KeyScope>(
|
export function dispatch<S extends KeyScope>(
|
||||||
e: KeyboardEvent,
|
e: KeyboardEvent,
|
||||||
scope: S,
|
scope: S,
|
||||||
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
|
handlers: { readonly [K in ShortcutId<S>]: (e: KeyboardEvent) => void },
|
||||||
): boolean {
|
): boolean {
|
||||||
|
if (sequenceEvents.has(e)) return false;
|
||||||
|
const live = liveSequence(e);
|
||||||
|
if (live && live.scope !== scope) return false;
|
||||||
|
const quiet = isPageLevelScope(scope) && anyBlockingLayer();
|
||||||
const typing = isTypingTarget(e);
|
const typing = isTypingTarget(e);
|
||||||
|
const run = (id: string) => (handlers as Record<string, (ev: KeyboardEvent) => void>)[id]?.(e);
|
||||||
|
|
||||||
|
if (live) {
|
||||||
|
// This key ends the sequence either way: it completes a row, or it breaks the attempt.
|
||||||
|
pending = null;
|
||||||
|
sequenceEvents.add(e);
|
||||||
|
if (quiet || typing) return false;
|
||||||
|
for (const s of ROWS) {
|
||||||
|
if (s.scope !== scope || s.handledLocally) continue;
|
||||||
|
const hit = s.keys.some((k) => {
|
||||||
|
const [first, second] = stepsOf(k);
|
||||||
|
return second !== undefined && first === live.key && matches(e, second);
|
||||||
|
});
|
||||||
|
if (hit) {
|
||||||
|
run(s.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quiet) return false;
|
||||||
for (const s of ROWS) {
|
for (const s of ROWS) {
|
||||||
if (s.scope !== scope || s.handledLocally) continue;
|
if (s.scope !== scope || s.handledLocally) continue;
|
||||||
if (typing && !s.allowInInput) continue;
|
if (typing && !s.allowInInput) continue;
|
||||||
if (!s.keys.some((k) => matches(e, k))) continue;
|
if (!s.keys.some((k) => stepsOf(k).length === 1 && matches(e, k))) continue;
|
||||||
(handlers as Record<string, (ev: KeyboardEvent) => void>)[s.id]?.(e);
|
run(s.id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (typing) return false;
|
||||||
|
// Nothing matched outright — does this key open one of the scope's sequences?
|
||||||
|
for (const s of ROWS) {
|
||||||
|
if (s.scope !== scope || s.handledLocally) continue;
|
||||||
|
const first = s.keys.map(stepsOf).find((st) => st.length === 2 && matches(e, st[0]!))?.[0];
|
||||||
|
if (first !== undefined) {
|
||||||
|
pending = { scope, key: first, at: Date.now() };
|
||||||
|
sequenceEvents.add(e);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,18 +397,40 @@ export function useActiveScopes(): KeyScope[] {
|
|||||||
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
|
// rail each mount a `useContextMenu`), and `helpLines` appends a row's chords once per occurrence —
|
||||||
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key
|
// so without this the sheet rendered "⇧ F10 Menu ⇧ F10 Menu" under a duplicated React key
|
||||||
// (E10 review round 2). Innermost registration wins the ordering.
|
// (E10 review round 2). Innermost registration wins the ordering.
|
||||||
return [...new Set([...stack].reverse())].concat("global");
|
const live = [...new Set([...stack].reverse())];
|
||||||
|
// The page-level scopes go in a FIXED order after the surfaces, not in registration order: App
|
||||||
|
// registers `page` in its own effect, which React runs after every child's — so by mount order
|
||||||
|
// the module jumps would head the sheet, above the list keys of the page you are looking at.
|
||||||
|
const surfaces = live.filter((s) => !isPageLevelScope(s));
|
||||||
|
const pageLevel = (["list", "search", "page"] as const).filter((s) => live.includes(s));
|
||||||
|
return [...surfaces, ...pageLevel, "global"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The keyboard help sheet ───────────────────────────────────────────────────────────────────
|
// ── The keyboard help sheet ───────────────────────────────────────────────────────────────────
|
||||||
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
|
// One sheet for the whole app, opened by the global `h` (or a surface's own keyboard button), so
|
||||||
// there is exactly one answer to "what can I press here" instead of one per player.
|
// there is exactly one answer to "what can I press here" instead of one per player.
|
||||||
const helpOpen = createStore(false);
|
const helpOpen = createStore(false);
|
||||||
|
// Whether the sheet was opened OVER a layer that owns the keyboard (a player, a dialog). The page-level
|
||||||
|
// keys are quiet there, so the sheet must not list them — and it cannot ask the layer stack itself
|
||||||
|
// once it is showing, because its own Modal is a layer too. So the answer is taken at the moment of
|
||||||
|
// opening, before that Modal mounts. (The palette's "Help" row runs only after the palette has closed.)
|
||||||
|
const helpOverLayer = createStore(false);
|
||||||
|
|
||||||
export const closeKeyboardHelp = (): void => helpOpen.set(false);
|
export const closeKeyboardHelp = (): void => helpOpen.set(false);
|
||||||
export const toggleKeyboardHelp = (): void => helpOpen.set((v) => !v);
|
export const toggleKeyboardHelp = (): void => {
|
||||||
|
const next = !helpOpen.get();
|
||||||
|
if (next) helpOverLayer.set(anyBlockingLayer());
|
||||||
|
helpOpen.set(next);
|
||||||
|
};
|
||||||
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
/** Subscribe to whether the sheet is showing (the Plex player suppresses wheel-volume under it). */
|
||||||
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
export const useKeyboardHelpOpen = (): boolean => helpOpen.use();
|
||||||
|
/** Whether the sheet was opened over a keyboard-owning layer (see `helpOverLayer`). */
|
||||||
|
export const useKeyboardHelpOverLayer = (): boolean => helpOverLayer.use();
|
||||||
|
|
||||||
|
// The modules this account can reach — published by `useGlobalKeymap`, read by the `g` chords and
|
||||||
|
// by the sheet (which drops the chord of a gated module rather than promise a page that isn't there).
|
||||||
|
const navigablePages = createStore<readonly Page[]>([]);
|
||||||
|
export const useNavigablePages = (): readonly Page[] => navigablePages.use();
|
||||||
|
|
||||||
// ── The command palette ───────────────────────────────────────────────────────────────────────
|
// ── The command palette ───────────────────────────────────────────────────────────────────────
|
||||||
const paletteOpen = createStore(false);
|
const paletteOpen = createStore(false);
|
||||||
@@ -280,21 +438,32 @@ const paletteOpen = createStore(false);
|
|||||||
export const closePalette = (): void => paletteOpen.set(false);
|
export const closePalette = (): void => paletteOpen.set(false);
|
||||||
export const usePaletteOpen = (): boolean => paletteOpen.use();
|
export const usePaletteOpen = (): boolean => paletteOpen.use();
|
||||||
|
|
||||||
/** Bind the `global` scope, once, for the whole app. It lives here rather than inside either
|
/** Bind the `global` and `page` scopes, once, for the whole app. It lives here rather than inside
|
||||||
* overlay because both are opened by it: a listener that sat in one of them would have to route a
|
* either overlay because both are opened by it: a listener that sat in one of them would have to
|
||||||
* key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything. App
|
* route a key belonging to the other, and `dispatch`'s exhaustiveness would stop meaning anything.
|
||||||
* calls this; the overlays are pure renderers of their store.
|
* App calls this; the overlays are pure renderers of their store.
|
||||||
|
*
|
||||||
|
* `pages` is the set of modules this account can reach (a `g` chord to anything else is a no-op).
|
||||||
*
|
*
|
||||||
* `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
|
* `enabled` must track where those overlays (and the pages Shift+W jumps to) are actually MOUNTED.
|
||||||
* Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
|
* Bound unconditionally it swallowed `h` and Mod+K on the loading/Welcome/setup screens — the
|
||||||
* browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
|
* browser shortcut was preventDefault'ed, nothing appeared, and the flipped module-level store then
|
||||||
* popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
|
* popped the overlay open by itself the moment the signed-in shell mounted (E10 review round 1). */
|
||||||
export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
export function useGlobalKeymap(
|
||||||
// The Home jump is the one global key whose action lives OUTSIDE this file — navigation is a
|
enabled: boolean,
|
||||||
|
onNavigate: (page: Page) => void,
|
||||||
|
pages: readonly Page[],
|
||||||
|
): void {
|
||||||
|
// The page jumps are the global keys whose action lives OUTSIDE this file — navigation is a
|
||||||
// provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
|
// provider and `lib/` may not import a component — so App hands it in. Kept in a ref so a fresh
|
||||||
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
|
// closure each render doesn't rebind the window listener (and can't leave a stale `setPage`).
|
||||||
const homeRef = useRef(onHome);
|
const navRef = useRef(onNavigate);
|
||||||
homeRef.current = onHome;
|
navRef.current = onNavigate;
|
||||||
|
useKeyScope("page", enabled);
|
||||||
|
const pagesKey = pages.join(",");
|
||||||
|
useEffect(() => {
|
||||||
|
navigablePages.set(pagesKey ? (pagesKey.split(",") as Page[]) : []);
|
||||||
|
}, [pagesKey]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
// Leaving a stale `true` behind would spring the overlay open on the next mount.
|
// Leaving a stale `true` behind would spring the overlay open on the next mount.
|
||||||
@@ -302,6 +471,17 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
|||||||
paletteOpen.set(false);
|
paletteOpen.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Built from the rows, so a new `g` row needs no second edit here — the row's `page` IS the action.
|
||||||
|
const pageHandlers = Object.fromEntries(
|
||||||
|
ROWS.filter((s) => s.scope === "page").map((s) => [
|
||||||
|
s.id,
|
||||||
|
(ev: KeyboardEvent) => {
|
||||||
|
if (!s.page || !navigablePages.get().includes(s.page)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
navRef.current(s.page);
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
) as { readonly [K in ShortcutId<"page">]: (e: KeyboardEvent) => void };
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
// Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
|
// Two of the three global keys TOGGLE an overlay, and each overlay mount/unmount costs a real
|
||||||
// history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
|
// history entry (useBackToClose); the third (Shift+W) pushes a page entry of its own. Either
|
||||||
@@ -309,10 +489,10 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
|||||||
// our own depth bookkeeping keeps counting them — a desynced stack whose next browser Back
|
// our own depth bookkeeping keeps counting them — a desynced stack whose next browser Back
|
||||||
// walks out of the app. Holding a key is not a second press.
|
// walks out of the app. Holding a key is not a second press.
|
||||||
if (e.repeat) return;
|
if (e.repeat) return;
|
||||||
dispatch(e, "global", {
|
const handled = dispatch(e, "global", {
|
||||||
help: (ev) => {
|
help: (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
helpOpen.set((v) => !v);
|
toggleKeyboardHelp();
|
||||||
},
|
},
|
||||||
palette: (ev) => {
|
palette: (ev) => {
|
||||||
// Mod+K is the browser's "focus the search bar" in a few builds; we want it.
|
// Mod+K is the browser's "focus the search bar" in a few builds; we want it.
|
||||||
@@ -321,9 +501,10 @@ export function useGlobalKeymap(enabled: boolean, onHome: () => void): void {
|
|||||||
},
|
},
|
||||||
home: (ev) => {
|
home: (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
homeRef.current();
|
navRef.current("home");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (!handled) dispatch(e, "page", pageHandlers);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
@@ -344,6 +525,30 @@ export function useHelpConditions(conditions: HelpConditions): void {
|
|||||||
}, [episode]);
|
}, [episode]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The module's search box ───────────────────────────────────────────────────────────────────
|
||||||
|
/** Bind `s` to "put the caret in this search box" while the box is mounted. The one SearchBar in the
|
||||||
|
* header calls this; the text is selected so the next keystrokes replace the old query (an arrow key
|
||||||
|
* keeps it). `preventDefault` keeps the `s` itself out of the field it just focused. */
|
||||||
|
export function useSearchFocusKey(input: RefObject<HTMLInputElement | null>): void {
|
||||||
|
useKeyScope("search");
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.repeat) return;
|
||||||
|
dispatch(e, "search", {
|
||||||
|
focus: (ev) => {
|
||||||
|
const el = input.current;
|
||||||
|
if (!el) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [input]);
|
||||||
|
}
|
||||||
|
|
||||||
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
|
export const useHelpConditionValues = (): HelpConditions => helpConditions.use();
|
||||||
|
|
||||||
// ── Display ───────────────────────────────────────────────────────────────────────────────────
|
// ── Display ───────────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -361,8 +566,13 @@ const GLYPHS: Record<string, string> = {
|
|||||||
const isMac = (): boolean =>
|
const isMac = (): boolean =>
|
||||||
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent);
|
typeof navigator !== "undefined" && /mac|iphone|ipad/i.test(navigator.userAgent);
|
||||||
|
|
||||||
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac). */
|
/** A chord as the help sheet shows it: `Mod+Shift+z` → `Ctrl ⇧ Z` (`⌘ ⇧ Z` on a Mac), and a
|
||||||
|
* sequence step by step: `g h` → `G H`. */
|
||||||
export function formatChord(chord: string): string {
|
export function formatChord(chord: string): string {
|
||||||
|
return stepsOf(chord).map(formatStep).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStep(chord: string): string {
|
||||||
return chord
|
return chord
|
||||||
.split("+")
|
.split("+")
|
||||||
.map((part, i, all) => {
|
.map((part, i, all) => {
|
||||||
@@ -380,15 +590,19 @@ export interface HelpLine {
|
|||||||
when?: ShortcutWhen;
|
when?: ShortcutWhen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The shortcuts of `scopes`, grouped into help lines by description. Scopes come out in the order
|
/** The shortcuts of `scopes`, grouped into help lines by description (with `pages`, the `g` chords
|
||||||
|
* are limited to those modules). Scopes come out in the order
|
||||||
* ASKED FOR, rows within a scope in table order — so a surface passing its own scope first heads its
|
* ASKED FOR, rows within a scope in table order — so a surface passing its own scope first heads its
|
||||||
* sheet with its own keys and leaves the app-wide ones underneath, where they read as context. */
|
* sheet with its own keys and leaves the app-wide ones underneath, where they read as context. */
|
||||||
export function helpLines(scopes: readonly KeyScope[]): HelpLine[] {
|
export function helpLines(scopes: readonly KeyScope[], pages?: readonly Page[]): HelpLine[] {
|
||||||
const out: HelpLine[] = [];
|
const out: HelpLine[] = [];
|
||||||
const byLabel = new Map<string, HelpLine>();
|
const byLabel = new Map<string, HelpLine>();
|
||||||
for (const scope of scopes) {
|
for (const scope of scopes) {
|
||||||
for (const s of ROWS) {
|
for (const s of ROWS) {
|
||||||
if (s.scope !== scope) continue;
|
if (s.scope !== scope) continue;
|
||||||
|
// A `g` chord to a module this account cannot reach is left out ROW by row, not line by line:
|
||||||
|
// `g h` shares its line with Shift+W, which must stay.
|
||||||
|
if (s.page && pages && !pages.includes(s.page)) continue;
|
||||||
const existing = byLabel.get(s.labelKey);
|
const existing = byLabel.get(s.labelKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.chords.push(...s.keys);
|
existing.chords.push(...s.keys);
|
||||||
|
|||||||
@@ -16,15 +16,28 @@ import { useEffect, useRef } from "react";
|
|||||||
|
|
||||||
let stack: number[] = [];
|
let stack: number[] = [];
|
||||||
let nextId = 1;
|
let nextId = 1;
|
||||||
|
// Layers that take Escape but do not own the keyboard: an open tooltip or hover card. They sit on the
|
||||||
|
// stack for Escape ordering like any other layer, but `anyBlockingLayer` looks past them — otherwise
|
||||||
|
// a tooltip that happens to be open under a resting mouse pointer would silence the page's `j`/`k`.
|
||||||
|
const passive = new Set<number>();
|
||||||
|
|
||||||
export function pushLayer(): number {
|
export function pushLayer(opts?: { passive?: boolean }): number {
|
||||||
const id = nextId++;
|
const id = nextId++;
|
||||||
stack.push(id);
|
stack.push(id);
|
||||||
|
if (opts?.passive) passive.add(id);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeLayer(id: number): void {
|
export function removeLayer(id: number): void {
|
||||||
stack = stack.filter((x) => x !== id);
|
stack = stack.filter((x) => x !== id);
|
||||||
|
passive.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while a layer that owns the keyboard is open — a modal, a player, a click-opened menu. The
|
||||||
|
* page-level keys (`s`, the `g` chords, list navigation) stay quiet then: they act on the page
|
||||||
|
* underneath, which the user cannot see or reach while such a layer is up. */
|
||||||
|
export function anyBlockingLayer(): boolean {
|
||||||
|
return stack.some((id) => !passive.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTopmost(id: number): boolean {
|
export function isTopmost(id: number): boolean {
|
||||||
@@ -62,14 +75,17 @@ export function escapeHandled(e: KeyboardEvent): boolean {
|
|||||||
export function useLayer(
|
export function useLayer(
|
||||||
active: boolean,
|
active: boolean,
|
||||||
onEscape?: () => void,
|
onEscape?: () => void,
|
||||||
|
opts?: { passive?: boolean },
|
||||||
): { isTopmost: () => boolean; anyAbove: () => boolean } {
|
): { isTopmost: () => boolean; anyAbove: () => boolean } {
|
||||||
const idRef = useRef<number | null>(null);
|
const idRef = useRef<number | null>(null);
|
||||||
const escRef = useRef(onEscape);
|
const escRef = useRef(onEscape);
|
||||||
escRef.current = onEscape;
|
escRef.current = onEscape;
|
||||||
|
const passiveRef = useRef(!!opts?.passive);
|
||||||
|
passiveRef.current = !!opts?.passive;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
const id = pushLayer();
|
const id = pushLayer({ passive: passiveRef.current });
|
||||||
idRef.current = id;
|
idRef.current = id;
|
||||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||||
if (escRef.current) {
|
if (escRef.current) {
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// `lib/listNav` against real DOM: `j`/`k` from item to item and across lists in reading order, the
|
||||||
|
// arrows around a grid, Enter as a click on the item's own "open" element — and the places where the
|
||||||
|
// keys must stay out of the way (a field, an overlay, a control inside the card).
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { navItemAttrs, useListNav } from "./listNav";
|
||||||
|
import { pushLayer, removeLayer } from "./layerStack";
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
function List({
|
||||||
|
id,
|
||||||
|
count,
|
||||||
|
cols = 1,
|
||||||
|
mounted,
|
||||||
|
reveal,
|
||||||
|
onOpen = () => {},
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
count: number;
|
||||||
|
cols?: number;
|
||||||
|
/** Mount only the first N items, the way a virtualized grid leaves the rest out of the DOM. */
|
||||||
|
mounted?: number;
|
||||||
|
reveal?: (index: number) => void;
|
||||||
|
onOpen?: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useListNav(ref, { enabled: true, count, cols, reveal });
|
||||||
|
return (
|
||||||
|
<div ref={ref}>
|
||||||
|
{Array.from({ length: mounted ?? count }, (_, i) => (
|
||||||
|
<div key={i} {...navItemAttrs(i)} data-testid={`${id}${i}`}>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
data-nav-open=""
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpen(`${id}${i}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
title
|
||||||
|
</a>
|
||||||
|
<button data-testid={`${id}${i}-action`}>action</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A keydown where a real one starts: on whatever holds focus, bubbling to the window listener. */
|
||||||
|
function press(key: string): KeyboardEvent {
|
||||||
|
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||||
|
act(() => {
|
||||||
|
(document.activeElement ?? document.body).dispatchEvent(e);
|
||||||
|
});
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
const focused = () => (document.activeElement as HTMLElement | null)?.dataset.testid;
|
||||||
|
|
||||||
|
describe("useListNav", () => {
|
||||||
|
it("lands the first j on an item, then steps one item at a time", () => {
|
||||||
|
render(<List id="a" count={3} />);
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("a0");
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("a1");
|
||||||
|
press("k");
|
||||||
|
expect(focused()).toBe("a0");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The Tab order is the reader's, not ours: only the item the keyboard is on is focusable, and the
|
||||||
|
// previous one gives its tabindex back.
|
||||||
|
it("roves a single tabindex=-1 and never adds a Tab stop", () => {
|
||||||
|
render(<List id="a" count={3} />);
|
||||||
|
expect(screen.getByTestId("a0").hasAttribute("tabindex")).toBe(false);
|
||||||
|
press("j");
|
||||||
|
expect(screen.getByTestId("a0").getAttribute("tabindex")).toBe("-1");
|
||||||
|
press("j");
|
||||||
|
expect(screen.getByTestId("a0").hasAttribute("tabindex")).toBe(false);
|
||||||
|
expect(screen.getByTestId("a1").getAttribute("tabindex")).toBe("-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// The player (E11 S2) restores focus to whatever held it when it opened. If the item gave its
|
||||||
|
// tabindex back on blur, that restore would silently fail and focus would fall to <body>.
|
||||||
|
it("keeps the item focusable after focus leaves it, so a dialog can restore onto it", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<List id="a" count={2} />
|
||||||
|
<button data-testid="outside">elsewhere</button>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
press("j");
|
||||||
|
act(() => screen.getByTestId("outside").focus());
|
||||||
|
act(() => screen.getByTestId("a0").focus());
|
||||||
|
expect(focused()).toBe("a0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at both ends of the page instead of wrapping", () => {
|
||||||
|
render(<List id="a" count={2} />);
|
||||||
|
press("j");
|
||||||
|
press("k");
|
||||||
|
expect(focused()).toBe("a0");
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("a1");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Owner decision D4: j/k walk every collection on the page in reading order.
|
||||||
|
it("continues into the next list, and back, in document order", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<List id="a" count={2} />
|
||||||
|
<List id="b" count={2} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("b0");
|
||||||
|
press("k");
|
||||||
|
expect(focused()).toBe("a1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves around a grid with the arrows, row by row", () => {
|
||||||
|
// 3 columns, 7 items: rows [0 1 2] [3 4 5] [6]
|
||||||
|
render(<List id="a" count={7} cols={3} />);
|
||||||
|
press("j");
|
||||||
|
press("ArrowRight");
|
||||||
|
expect(focused()).toBe("a1");
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(focused()).toBe("a4");
|
||||||
|
press("ArrowDown"); // the row below is short: land on its last item
|
||||||
|
expect(focused()).toBe("a6");
|
||||||
|
press("ArrowDown"); // already on the last row
|
||||||
|
expect(focused()).toBe("a6");
|
||||||
|
press("ArrowUp");
|
||||||
|
expect(focused()).toBe("a3");
|
||||||
|
press("ArrowLeft"); // left wraps to the end of the row above — reading order
|
||||||
|
expect(focused()).toBe("a2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("works the arrows from a control inside the card too", () => {
|
||||||
|
render(<List id="a" count={4} cols={2} />);
|
||||||
|
act(() => screen.getByTestId("a0-action").focus());
|
||||||
|
press("ArrowDown");
|
||||||
|
expect(focused()).toBe("a2");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Outside a list an arrow scrolls the page; that native meaning must survive.
|
||||||
|
it("leaves the arrows alone when focus is not in a list", () => {
|
||||||
|
render(<List id="a" count={4} cols={2} />);
|
||||||
|
expect(press("ArrowDown").defaultPrevented).toBe(false);
|
||||||
|
expect(focused()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the item on Enter by clicking its own open element", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
render(<List id="a" count={2} onOpen={onOpen} />);
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
expect(press("Enter").defaultPrevented).toBe(true);
|
||||||
|
expect(onOpen).toHaveBeenCalledWith("a1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves Enter to a control inside the card", () => {
|
||||||
|
const onOpen = vi.fn();
|
||||||
|
render(<List id="a" count={2} onOpen={onOpen} />);
|
||||||
|
act(() => screen.getByTestId("a0-action").focus());
|
||||||
|
expect(press("Enter").defaultPrevented).toBe(false);
|
||||||
|
expect(onOpen).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays out of a text field", () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<input data-testid="field" />
|
||||||
|
<List id="a" count={2} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
act(() => screen.getByTestId("field").focus());
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("field");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is quiet under a layer that owns the keyboard, but not under a tooltip", () => {
|
||||||
|
render(<List id="a" count={2} />);
|
||||||
|
const modal = pushLayer();
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBeUndefined();
|
||||||
|
removeLayer(modal);
|
||||||
|
|
||||||
|
const tooltip = pushLayer({ passive: true });
|
||||||
|
press("j");
|
||||||
|
expect(focused()).toBe("a0");
|
||||||
|
removeLayer(tooltip);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A virtualized grid only mounts a screen of rows: the next item may not exist yet.
|
||||||
|
it("asks the list to reveal an unmounted item, then focuses it once it renders", async () => {
|
||||||
|
const reveal = vi.fn();
|
||||||
|
const { rerender } = render(<List id="a" count={5} mounted={2} reveal={reveal} />);
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
press("j");
|
||||||
|
expect(reveal).toHaveBeenCalledWith(2);
|
||||||
|
expect(focused()).toBe("a1");
|
||||||
|
rerender(<List id="a" count={5} reveal={reveal} />);
|
||||||
|
await waitFor(() => expect(focused()).toBe("a2"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unbinds when the last list unmounts", () => {
|
||||||
|
const { unmount } = render(<List id="a" count={2} />);
|
||||||
|
unmount();
|
||||||
|
expect(press("j").defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { useEffect, useRef, type RefObject } from "react";
|
||||||
|
import { dispatch, useKeyScope } from "./keymap";
|
||||||
|
|
||||||
|
// ── Keyboard navigation over lists and grids (E11 S4, UX-6) ──────────────────────────────────────
|
||||||
|
// Before this, a feed card cost ~10 Tab stops (thumb, play, info, title, channel, five actions), so
|
||||||
|
// the tenth card sat ~135 Tabs from the top of the page. `j`/`k` step from ITEM to item instead, the
|
||||||
|
// arrows move around a grid, and Enter opens — the same thing a click on the item does.
|
||||||
|
//
|
||||||
|
// How an item takes part, without this file knowing what an item is:
|
||||||
|
// - its root element carries `data-nav-index` (its index in the list — `navItemAttrs`),
|
||||||
|
// - the one element whose click means "open" carries `data-nav-open` (the title link on a video card),
|
||||||
|
// - the list's container registers with `useListNav`, saying how many items there are, how many
|
||||||
|
// columns a row has, and how to bring an unmounted item into the DOM (a virtualized grid).
|
||||||
|
//
|
||||||
|
// The Tab order is untouched: an item root is NOT a Tab stop. It gets `tabindex="-1"` at the moment
|
||||||
|
// the keyboard lands on it — one item at a time, the previous one gives it back — so Tab still walks
|
||||||
|
// the controls inside the cards, a mouse click on a card's blank area still focuses nothing, and the
|
||||||
|
// player's focus restore (E11 S2) can land back on the card it was opened from.
|
||||||
|
//
|
||||||
|
// Every page-level list shares ONE window listener, and the lists are walked in DOCUMENT order: `j`
|
||||||
|
// at the end of one list continues at the start of the next (owner decision D4, "reading order").
|
||||||
|
// The arrows stay inside the list they are in.
|
||||||
|
|
||||||
|
interface NavList {
|
||||||
|
readonly el: HTMLElement;
|
||||||
|
readonly count: () => number;
|
||||||
|
readonly cols: () => number;
|
||||||
|
readonly reveal: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lists = new Map<HTMLElement, NavList>();
|
||||||
|
// The item we gave a tabindex to, so the next move can take it back.
|
||||||
|
let roved: HTMLElement | null = null;
|
||||||
|
|
||||||
|
const INDEX = "data-nav-index";
|
||||||
|
const LIST = "data-nav-list";
|
||||||
|
// How long to wait for a virtualized item to mount after asking the list to reveal it.
|
||||||
|
const REVEAL_FRAMES = 10;
|
||||||
|
|
||||||
|
/** Spread onto an item's ROOT element. */
|
||||||
|
export const navItemAttrs = (index: number): { "data-nav-index": number } => ({
|
||||||
|
[INDEX]: index,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexOf = (item: Element): number => Number(item.getAttribute(INDEX));
|
||||||
|
|
||||||
|
function itemAt(list: NavList, index: number): HTMLElement | null {
|
||||||
|
return list.el.querySelector<HTMLElement>(`[${INDEX}="${index}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ordered(): NavList[] {
|
||||||
|
return [...lists.values()]
|
||||||
|
.filter((l) => l.el.isConnected)
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The item holding focus (on its root or on a control inside it), and the list it belongs to. */
|
||||||
|
function current(): { list: NavList; index: number; item: HTMLElement } | null {
|
||||||
|
const item = (document.activeElement as HTMLElement | null)?.closest<HTMLElement>(`[${INDEX}]`);
|
||||||
|
const list =
|
||||||
|
item && lists.get(item.parentElement?.closest<HTMLElement>(`[${LIST}]`) as HTMLElement);
|
||||||
|
return item && list ? { list, index: indexOf(item), item } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollParent(node: HTMLElement): HTMLElement | null {
|
||||||
|
for (let el = node.parentElement; el; el = el.parentElement) {
|
||||||
|
const oy = getComputedStyle(el).overflowY;
|
||||||
|
if (oy === "auto" || oy === "scroll") return el;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where `j` lands when nothing in a list has focus yet: the first item the reader can see, not the
|
||||||
|
* top of the list — after scrolling down a feed, the first press must not throw them back up. "Can
|
||||||
|
* see" = its bottom edge is below the top of the scroller's visible band, which starts under the
|
||||||
|
* floating header (the item's own `scroll-margin-top` is exactly that header clearance). */
|
||||||
|
function firstVisible(): { list: NavList; index: number } | null {
|
||||||
|
let fallback: { list: NavList; index: number } | null = null;
|
||||||
|
for (const list of ordered()) {
|
||||||
|
const items = [...list.el.querySelectorAll<HTMLElement>(`[${INDEX}]`)]
|
||||||
|
.filter((el) => el.parentElement?.closest(`[${LIST}]`) === list.el)
|
||||||
|
.sort((a, b) => indexOf(a) - indexOf(b));
|
||||||
|
if (!items.length) continue;
|
||||||
|
fallback ??= { list, index: indexOf(items[0]!) };
|
||||||
|
const top = scrollParent(list.el)?.getBoundingClientRect().top ?? 0;
|
||||||
|
for (const el of items) {
|
||||||
|
const band = top + (parseFloat(getComputedStyle(el).scrollMarginTop) || 0);
|
||||||
|
if (el.getBoundingClientRect().bottom > band) return { list, index: indexOf(el) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusItem(el: HTMLElement): void {
|
||||||
|
if (roved && roved !== el) roved.removeAttribute("tabindex");
|
||||||
|
roved = null;
|
||||||
|
// An item that is focusable in its own right (a Plex card is a role=button) keeps its tabindex.
|
||||||
|
if (!el.hasAttribute("tabindex")) {
|
||||||
|
el.setAttribute("tabindex", "-1");
|
||||||
|
roved = el;
|
||||||
|
}
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
// `nearest` + the item's scroll margins: a visible item does not move, an item under the floating
|
||||||
|
// header or past the bottom edge scrolls just far enough to clear it.
|
||||||
|
el.scrollIntoView?.({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusIndex(list: NavList, index: number): void {
|
||||||
|
const el = itemAt(list, index);
|
||||||
|
if (el) return focusItem(el);
|
||||||
|
// Virtualized and not mounted: have the list scroll it in, then take it as soon as it renders.
|
||||||
|
list.reveal(index);
|
||||||
|
let frames = 0;
|
||||||
|
const wait = () => {
|
||||||
|
const got = itemAt(list, index);
|
||||||
|
if (got) focusItem(got);
|
||||||
|
else if (++frames < REVEAL_FRAMES) requestAnimationFrame(wait);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
function step(delta: 1 | -1): void {
|
||||||
|
const cur = current();
|
||||||
|
if (!cur) {
|
||||||
|
const start = firstVisible();
|
||||||
|
if (start) focusIndex(start.list, start.index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = cur.index + delta;
|
||||||
|
if (next >= 0 && next < cur.list.count()) return focusIndex(cur.list, next);
|
||||||
|
// Past this list's end: on to the neighbouring list in reading order, skipping empty ones. At the
|
||||||
|
// first or last item of the page it stops — no wrap to the other end, no loading the next page.
|
||||||
|
const all = ordered();
|
||||||
|
for (let at = all.indexOf(cur.list) + delta; at >= 0 && at < all.length; at += delta) {
|
||||||
|
const n = all[at]!.count();
|
||||||
|
if (n > 0) return focusIndex(all[at]!, delta > 0 ? 0 : n - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move inside the focused item's list. ←→ step one item (wrapping rows, the reading order); ↑↓ one
|
||||||
|
* row, landing on the last item when the row below is a short last row. Returns false when focus is
|
||||||
|
* not in a list, so the arrow keeps its native meaning (scrolling) there. */
|
||||||
|
function move(dx: -1 | 0 | 1, dy: -1 | 0 | 1): boolean {
|
||||||
|
const cur = current();
|
||||||
|
if (!cur) return false;
|
||||||
|
const count = cur.list.count();
|
||||||
|
const cols = Math.max(1, cur.list.cols());
|
||||||
|
const row = Math.floor(cur.index / cols);
|
||||||
|
let target = cur.index;
|
||||||
|
if (dx) target = cur.index + dx;
|
||||||
|
else if (dy > 0 && row < Math.floor((count - 1) / cols))
|
||||||
|
target = Math.min(cur.index + cols, count - 1);
|
||||||
|
else if (dy < 0 && row > 0) target = cur.index - cols;
|
||||||
|
if (target !== cur.index && target >= 0 && target < count) focusIndex(cur.list, target);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent): void {
|
||||||
|
const arrow = (dx: -1 | 0 | 1, dy: -1 | 0 | 1) => (ev: KeyboardEvent) => {
|
||||||
|
if (move(dx, dy)) ev.preventDefault();
|
||||||
|
};
|
||||||
|
dispatch(e, "list", {
|
||||||
|
next: (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
step(1);
|
||||||
|
},
|
||||||
|
prev: (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
step(-1);
|
||||||
|
},
|
||||||
|
left: arrow(-1, 0),
|
||||||
|
right: arrow(1, 0),
|
||||||
|
up: arrow(0, -1),
|
||||||
|
down: arrow(0, 1),
|
||||||
|
// Only on the item ROOT: Enter on a button inside the card must stay that button's Enter.
|
||||||
|
open: (ev) => {
|
||||||
|
const cur = current();
|
||||||
|
if (!cur || document.activeElement !== cur.item) return;
|
||||||
|
const target = cur.item.querySelector<HTMLElement>("[data-nav-open]");
|
||||||
|
if (!target) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
// A real click on the item's own "open" element, so Enter runs exactly the click path — not a
|
||||||
|
// second copy of what opening means that could drift from it.
|
||||||
|
target.click();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register `ref` as a keyboard-navigable list while `enabled`. `count` is the number of items (the
|
||||||
|
* loaded ones, for an infinite list), `cols` the items per row (1 for a column), and `reveal` scrolls
|
||||||
|
* an item that is not mounted into view — omit it for a list that mounts everything. */
|
||||||
|
export function useListNav(
|
||||||
|
ref: RefObject<HTMLElement | null>,
|
||||||
|
opts: { enabled: boolean; count: number; cols: number; reveal?: (index: number) => void },
|
||||||
|
): void {
|
||||||
|
const optsRef = useRef(opts);
|
||||||
|
optsRef.current = opts;
|
||||||
|
useKeyScope("list", opts.enabled);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!opts.enabled || !el) return;
|
||||||
|
lists.set(el, {
|
||||||
|
el,
|
||||||
|
count: () => optsRef.current.count,
|
||||||
|
cols: () => optsRef.current.cols,
|
||||||
|
reveal: (i) => optsRef.current.reveal?.(i),
|
||||||
|
});
|
||||||
|
el.setAttribute(LIST, "");
|
||||||
|
if (lists.size === 1) window.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
lists.delete(el);
|
||||||
|
el.removeAttribute(LIST);
|
||||||
|
if (lists.size === 0) window.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [ref, opts.enabled]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The `page` scope as App binds it through `useGlobalKeymap`: the `g` chords jump to a module this
|
||||||
|
// account can reach, stay out of an overlay's way, and leave the global keys (Shift+W, h) intact.
|
||||||
|
// Its own file for the same reason as keymap.sequence.test.ts: a pending `g` is module state.
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { act, cleanup, render } from "@testing-library/react";
|
||||||
|
import { closeKeyboardHelp, useGlobalKeymap, useKeyboardHelpOpen } from "./keymap";
|
||||||
|
import { pushLayer, removeLayer } from "./layerStack";
|
||||||
|
import type { Page } from "./urlState";
|
||||||
|
|
||||||
|
let clock = Date.UTC(2031, 0, 1);
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
clock += 3_600_000;
|
||||||
|
vi.setSystemTime(clock);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
closeKeyboardHelp();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "downloads", "settings"];
|
||||||
|
|
||||||
|
function Host({
|
||||||
|
onNavigate,
|
||||||
|
pages = ALL,
|
||||||
|
onHelp = () => {},
|
||||||
|
}: {
|
||||||
|
onNavigate: (p: Page) => void;
|
||||||
|
pages?: Page[];
|
||||||
|
onHelp?: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
useGlobalKeymap(true, onNavigate, pages);
|
||||||
|
const help = useKeyboardHelpOpen();
|
||||||
|
useEffect(() => {
|
||||||
|
onHelp(help);
|
||||||
|
}, [help, onHelp]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = (init: KeyboardEventInit) =>
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the g chords", () => {
|
||||||
|
it("jump to the module named by the second key", () => {
|
||||||
|
const nav = vi.fn();
|
||||||
|
render(<Host onNavigate={nav} />);
|
||||||
|
key({ key: "f" });
|
||||||
|
expect(nav).not.toHaveBeenCalled();
|
||||||
|
key({ key: "g" });
|
||||||
|
key({ key: "f" });
|
||||||
|
key({ key: "g" });
|
||||||
|
key({ key: "," });
|
||||||
|
expect(nav.mock.calls).toEqual([["feed"], ["settings"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// `h` alone opens the sheet; the `h` of `g h` must not ALSO do that.
|
||||||
|
it("take the h of g h away from the help sheet", () => {
|
||||||
|
const nav = vi.fn();
|
||||||
|
let help = false;
|
||||||
|
render(<Host onNavigate={nav} onHelp={(v) => (help = v)} />);
|
||||||
|
key({ key: "g" });
|
||||||
|
key({ key: "h" });
|
||||||
|
expect(nav).toHaveBeenCalledWith("home");
|
||||||
|
expect(help).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuse a module this account cannot reach", () => {
|
||||||
|
const nav = vi.fn();
|
||||||
|
render(<Host onNavigate={nav} pages={["home", "feed"]} />);
|
||||||
|
key({ key: "g" });
|
||||||
|
key({ key: "x" });
|
||||||
|
expect(nav).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stand aside for an overlay, while Shift+W still answers there", () => {
|
||||||
|
const nav = vi.fn();
|
||||||
|
render(<Host onNavigate={nav} />);
|
||||||
|
const id = pushLayer();
|
||||||
|
key({ key: "g" });
|
||||||
|
key({ key: "f" });
|
||||||
|
expect(nav).not.toHaveBeenCalled();
|
||||||
|
key({ key: "w", shiftKey: true });
|
||||||
|
expect(nav).toHaveBeenCalledWith("home");
|
||||||
|
removeLayer(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user