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:
@@ -49,7 +49,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent/60 focus-visible:outline-offset-2"
|
||||
className="block max-w-88 truncate text-muted cursor-help rounded-sm outline-hidden focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
|
||||
@@ -180,6 +180,25 @@ describe("PlexSearchBox — typeahead", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// E11 S4: SearchBar's Escape hands the keyboard back to the page (blur). With the panel open, the
|
||||
// first Escape belongs to the panel: it must close it and leave the caret in the field; only the
|
||||
// next Escape leaves.
|
||||
it("spends the first Escape on the panel and only the second on leaving the field", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||
renderBox();
|
||||
const input = screen.getByRole("combobox");
|
||||
input.focus();
|
||||
await screen.findByRole("listbox");
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
});
|
||||
|
||||
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
|
||||
plexState.q = "matrix";
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
||||
|
||||
@@ -96,6 +96,7 @@ export default function PlexSearchBox() {
|
||||
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
|
||||
pick(hi);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault(); // close the panel only; SearchBar's Escape-to-blur waits for the next press
|
||||
setDismissed(true);
|
||||
setHi(-1); // don't leave a stale highlight to flash on the next reopen
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E11 S4: `s` puts the caret in the module's search box from anywhere on the page, and Escape hands
|
||||
// the keyboard back to the page — the way in needs a way out, or `j`/`k` end up typed into the query.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import SearchBar from "./SearchBar";
|
||||
import KeyboardHelp from "./ui/KeyboardHelp";
|
||||
import { closeKeyboardHelp, toggleKeyboardHelp } from "../lib/keymap";
|
||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
closeKeyboardHelp();
|
||||
});
|
||||
|
||||
function press(init: KeyboardEventInit, on?: Element | null): KeyboardEvent {
|
||||
const e = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
|
||||
act(() => {
|
||||
(on ?? document.activeElement ?? document.body).dispatchEvent(e);
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
const field = () => screen.getByPlaceholderText("Search…") as HTMLInputElement;
|
||||
|
||||
describe("SearchBar keys", () => {
|
||||
it("focuses the box on s, selects the old query, and keeps the s out of it", () => {
|
||||
render(<SearchBar value="old query" onChange={() => {}} placeholder="Search…" />);
|
||||
const e = press({ key: "s" });
|
||||
expect(document.activeElement).toBe(field());
|
||||
expect(e.defaultPrevented).toBe(true); // the `s` is not typed into the box it just focused
|
||||
expect([field().selectionStart, field().selectionEnd]).toEqual([0, "old query".length]);
|
||||
});
|
||||
|
||||
it("leaves an s typed into another field alone", () => {
|
||||
render(
|
||||
<>
|
||||
<input data-testid="other" />
|
||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||
</>,
|
||||
);
|
||||
const other = screen.getByTestId("other");
|
||||
act(() => other.focus());
|
||||
expect(press({ key: "s" }).defaultPrevented).toBe(false);
|
||||
expect(document.activeElement).toBe(other);
|
||||
});
|
||||
|
||||
it("stays quiet under a dialog", () => {
|
||||
render(<SearchBar value="" onChange={() => {}} placeholder="Search…" />);
|
||||
const id = pushLayer();
|
||||
press({ key: "s" });
|
||||
removeLayer(id);
|
||||
expect(document.activeElement).not.toBe(field());
|
||||
});
|
||||
|
||||
it("gives the keyboard back to the page on Escape", () => {
|
||||
render(<SearchBar value="abc" onChange={() => {}} placeholder="Search…" />);
|
||||
press({ key: "s" });
|
||||
press({ key: "Escape" });
|
||||
expect(document.activeElement).toBe(document.body);
|
||||
});
|
||||
|
||||
// Plex's typeahead uses its first Escape to close the result panel; the field keeps focus for that.
|
||||
it("lets a caller keep its own Escape", () => {
|
||||
render(
|
||||
<SearchBar
|
||||
value="abc"
|
||||
onChange={() => {}}
|
||||
placeholder="Search…"
|
||||
onKeyDown={(e) => e.key === "Escape" && e.preventDefault()}
|
||||
/>,
|
||||
);
|
||||
press({ key: "s" });
|
||||
press({ key: "Escape" });
|
||||
expect(document.activeElement).toBe(field());
|
||||
});
|
||||
|
||||
// Through the real hook: the sheet offers `s` only where a search box is mounted.
|
||||
it("is listed in the help sheet while a search box is mounted", () => {
|
||||
const { unmount } = render(
|
||||
<>
|
||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.getAllByRole("definition").map((d) => d.textContent)).toContain("keys.search");
|
||||
unmount();
|
||||
closeKeyboardHelp();
|
||||
|
||||
render(<KeyboardHelp />);
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.getAllByRole("definition").map((d) => d.textContent)).not.toContain(
|
||||
"keys.search",
|
||||
);
|
||||
});
|
||||
|
||||
// Opened over a player, the page keys are quiet — the sheet must not list them there.
|
||||
it("is left off a sheet opened over a keyboard-owning layer", () => {
|
||||
render(
|
||||
<>
|
||||
<SearchBar value="" onChange={() => {}} placeholder="Search…" />
|
||||
<KeyboardHelp />
|
||||
</>,
|
||||
);
|
||||
const id = pushLayer();
|
||||
act(() => toggleKeyboardHelp());
|
||||
expect(screen.queryAllByRole("definition").map((d) => d.textContent)).not.toContain(
|
||||
"keys.search",
|
||||
);
|
||||
removeLayer(id);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
useRef,
|
||||
type FocusEventHandler,
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEventHandler,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { useSearchFocusKey } from "../lib/keymap";
|
||||
|
||||
// The floating search pill in the top header. One component for every module that searches
|
||||
// (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a
|
||||
@@ -52,6 +54,9 @@ export default function SearchBar({
|
||||
inputProps?: InputHTMLAttributes<HTMLInputElement>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// `s` puts the caret here from anywhere on the page (E11 S4) — the header mounts exactly one box.
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useSearchFocusKey(inputRef);
|
||||
const fire = () => {
|
||||
if (onGo && !goDisabled) onGo();
|
||||
};
|
||||
@@ -67,13 +72,19 @@ export default function SearchBar({
|
||||
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
|
||||
<input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown?.(e);
|
||||
if (e.key === "Enter" && !e.defaultPrevented) fire();
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === "Enter") fire();
|
||||
// Escape hands the keyboard back to the page, where `j`/`k` and the `g` chords live — `s`
|
||||
// got you in, so there has to be a key that gets you out. The query stays as typed. A caller
|
||||
// that uses Escape itself (Plex's typeahead closing its panel) preventDefaults first.
|
||||
else if (e.key === "Escape") e.currentTarget.blur();
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted"
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E11 S4: the video card's half of keyboard navigation (`lib/listNav`). Every layout's ROOT carries the
|
||||
// list index — that is the element `j`/`k` focus and ring — and the title link is the card's `open`
|
||||
// element, so Enter on a selected card runs the very click a mouse would: the in-app player.
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./NavigationProvider", () => ({
|
||||
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||
}));
|
||||
|
||||
import VideoCard from "./VideoCard";
|
||||
import type { Video } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
const video = {
|
||||
id: "abc123",
|
||||
title: "Cicada 3301",
|
||||
channel_id: "UCsXVk37bltHxD1rDPwtNM8Q",
|
||||
channel_title: "LEMMiNO",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
duration_seconds: 1200,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=abc123",
|
||||
thumbnail_url: "",
|
||||
} as unknown as Video;
|
||||
|
||||
beforeEach(cleanup);
|
||||
|
||||
function renderCard(view: FeedView, navIndex?: number, onOpen = vi.fn()) {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoCard
|
||||
video={video}
|
||||
view={view}
|
||||
width={0}
|
||||
navIndex={navIndex}
|
||||
onState={() => {}}
|
||||
onToggleSave={() => {}}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onOpen;
|
||||
}
|
||||
|
||||
// One per layout FAMILY: the stacked card, the thumbnailed row, the one-line row — three roots.
|
||||
const VIEWS: FeedView[] = ["cards", "rows", "rowsCompact"];
|
||||
|
||||
describe("VideoCard — keyboard navigation hooks", () => {
|
||||
for (const view of VIEWS) {
|
||||
it(`puts the list index on the ${view} root and opens in-app from the title`, () => {
|
||||
const onOpen = renderCard(view, 7);
|
||||
const root = screen.getByTestId("video-card");
|
||||
expect(root.getAttribute("data-nav-index")).toBe("7");
|
||||
const open = root.querySelectorAll("[data-nav-open]");
|
||||
expect(open).toHaveLength(1);
|
||||
expect(open[0]!.textContent).toBe("Cicada 3301");
|
||||
(open[0] as HTMLElement).click();
|
||||
expect(onOpen).toHaveBeenCalledWith(video);
|
||||
});
|
||||
}
|
||||
|
||||
it("carries no index outside a navigable list (Home's rails)", () => {
|
||||
renderCard("cards");
|
||||
expect(screen.getByTestId("video-card").hasAttribute("data-nav-index")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import Avatar from "./ui/Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
@@ -388,6 +389,7 @@ function VideoCard({
|
||||
onToggleSave,
|
||||
onOpen,
|
||||
menuProps,
|
||||
navIndex,
|
||||
}: {
|
||||
video: Video;
|
||||
view: FeedView;
|
||||
@@ -403,6 +405,8 @@ function VideoCard({
|
||||
* blow past this component's memo for every card in the virtualized feed on every parent
|
||||
* render. The card calls it with the video it already has. */
|
||||
menuProps?: (v: Video) => Record<string, unknown>;
|
||||
/** Its position in a keyboard-navigable list (`lib/listNav`) — omit outside one (Home's rails). */
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { openChannel } = useNavigationActions();
|
||||
@@ -465,6 +469,9 @@ function VideoCard({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => openInApp(e, video, onOpen)}
|
||||
// Enter on a keyboard-selected card clicks THIS link (lib/listNav), so it opens exactly like a
|
||||
// click: the in-app player, resuming where the reader left off.
|
||||
data-nav-open=""
|
||||
title={titleClamped ? (video.title ?? undefined) : undefined}
|
||||
className={clsx(
|
||||
"font-medium leading-snug hover:text-accent",
|
||||
@@ -534,6 +541,7 @@ function VideoCard({
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}
|
||||
>
|
||||
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
|
||||
@@ -597,6 +605,7 @@ function VideoCard({
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}
|
||||
>
|
||||
<Thumb
|
||||
@@ -631,6 +640,7 @@ function VideoCard({
|
||||
data-testid="video-card"
|
||||
data-video-id={video.id}
|
||||
{...menuProps?.(video)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
className={clsx(
|
||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150",
|
||||
"flex flex-col",
|
||||
|
||||
@@ -64,10 +64,12 @@ export default function VirtualFeed({
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
renderItem={(v, itemWidth) => (
|
||||
keyboardNav
|
||||
renderItem={(v, itemWidth, index) => (
|
||||
<VideoCard
|
||||
video={v}
|
||||
view={view}
|
||||
navIndex={index}
|
||||
// Only the one-line row (thumb:false) reads its own width — it stands columns down as it
|
||||
// narrows. Everything else takes a stable 0, so a resize (which changes itemWidth every
|
||||
// frame) doesn't blow past VideoCard's memo for views that wouldn't do anything with it.
|
||||
|
||||
@@ -282,8 +282,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
|
||||
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
|
||||
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
|
||||
// an Escape the user aimed at whatever is actually on screen.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
|
||||
// an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer:
|
||||
// it does not own the keyboard, so the page's own keys keep working under a resting pointer.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover });
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useKeyScope,
|
||||
type KeyScope,
|
||||
} from "../../lib/keymap";
|
||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -27,9 +28,11 @@ afterEach(() => {
|
||||
closeKeyboardHelp();
|
||||
});
|
||||
|
||||
/** App's job: it binds the `global` scope (h / Mod+K / Shift+W) once for the whole tree. */
|
||||
/** App's job: it binds the `global` scope (h / Mod+K / Shift+W) and the `page` scope (the `g` chords)
|
||||
* once for the whole tree. Home is the one module this stand-in account reaches, so the page block
|
||||
* is the single `G H` chord, sharing Shift+W's line. */
|
||||
function Host({ children }: { children?: React.ReactNode }) {
|
||||
useGlobalKeymap(true, () => {});
|
||||
useGlobalKeymap(true, () => {}, ["home"]);
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
@@ -81,15 +84,16 @@ describe("KeyboardHelp", () => {
|
||||
);
|
||||
pressH();
|
||||
const rows = descriptions();
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants.
|
||||
// A plexPlayer row first, the app-wide rows last — the reading order the reader wants. (Home's
|
||||
// line comes from the page block, which sits between the two; Shift+W joins it there.)
|
||||
expect(rows[0]).toBe("keys.playPause");
|
||||
expect(rows.slice(-3)).toEqual(["keys.palette", "keys.help", "keys.home"]);
|
||||
expect(rows.slice(-3)).toEqual(["keys.home", "keys.palette", "keys.help"]);
|
||||
});
|
||||
|
||||
it("lists only the app-wide keys when no surface is registered", () => {
|
||||
render(<Host />);
|
||||
pressH();
|
||||
expect(descriptions()).toEqual(["keys.palette", "keys.help", "keys.home"]);
|
||||
expect(descriptions()).toEqual(["keys.home", "keys.palette", "keys.help"]);
|
||||
});
|
||||
|
||||
// The context menu is NOT app-wide: it has its own scope, registered by `useContextMenu`, so the
|
||||
@@ -120,6 +124,24 @@ describe("KeyboardHelp", () => {
|
||||
expect(descriptions()).not.toContain("keys.contextMenu");
|
||||
});
|
||||
|
||||
// Opened over a player, Shift+F10 has nothing to reach — focus is inside the player, not on a row.
|
||||
it("drops the context-menu key from a sheet opened over a keyboard-owning layer", () => {
|
||||
function WithMenu() {
|
||||
useContextMenu<string>();
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<Host>
|
||||
<WithMenu />
|
||||
</Host>,
|
||||
);
|
||||
const id = pushLayer();
|
||||
pressH();
|
||||
removeLayer(id);
|
||||
expect(descriptions()).not.toContain("keys.contextMenu");
|
||||
expect(descriptions()).toContain("keys.help");
|
||||
});
|
||||
|
||||
// Two live context menus (the Playlists page mounts one, its rail another) must not double the row.
|
||||
it("lists the context-menu chords once however many surfaces register", () => {
|
||||
function WithMenu() {
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
closeKeyboardHelp,
|
||||
formatChord,
|
||||
helpLines,
|
||||
isPageLevelScope,
|
||||
useActiveScopes,
|
||||
useHelpConditionValues,
|
||||
useKeyboardHelpOpen,
|
||||
useKeyboardHelpOverLayer,
|
||||
useNavigablePages,
|
||||
} from "../../lib/keymap";
|
||||
|
||||
// The app's one keyboard cheat sheet. Mounted once by App; the global `h` that opens it lives in
|
||||
@@ -29,11 +32,21 @@ export default function KeyboardHelp() {
|
||||
const open = useKeyboardHelpOpen();
|
||||
const scopes = useActiveScopes();
|
||||
const conditions = useHelpConditionValues();
|
||||
const overLayer = useKeyboardHelpOverLayer();
|
||||
const pages = useNavigablePages();
|
||||
|
||||
if (!open) return null;
|
||||
// A `when` row is listed only where it applies — "Previous / next episode" inside a movie would be
|
||||
// a promise the player does not keep.
|
||||
const lines = helpLines(scopes).filter((l) => !l.when || !!conditions[l.when]);
|
||||
// Opened over a player or a dialog, the page-level keys (`s`, `g …`, `j`/`k`) are quiet — listing
|
||||
// them there would promise keys that do nothing until the overlay closes. And a `g` chord is listed
|
||||
// only for a module this account can actually reach.
|
||||
// The context-menu key goes too: every `useContextMenu` is a page list, and with an overlay up the
|
||||
// focus Shift+F10 needs is inside the overlay (pre-existing since E10, found in S4 UAT).
|
||||
const live = overLayer
|
||||
? scopes.filter((s) => !isPageLevelScope(s) && s !== "contextMenu")
|
||||
: scopes;
|
||||
const lines = helpLines(live, pages).filter((l) => !l.when || !!conditions[l.when]);
|
||||
return (
|
||||
<Modal title={t("keys.title")} onClose={closeKeyboardHelp} maxWidth="max-w-md">
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-2.5 text-sm">
|
||||
|
||||
@@ -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,
|
||||
} from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useListNav } from "../../lib/listNav";
|
||||
import { GRID_GAP, columnsFor, itemWidthFor } from "./gridColumns";
|
||||
import { shouldPrefetchNext } from "./readAhead";
|
||||
|
||||
@@ -66,8 +67,13 @@ export interface VirtualGridProps<T> {
|
||||
rowEst: number;
|
||||
/** 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
|
||||
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down. */
|
||||
renderItem: (item: T, itemWidth: number) => ReactNode;
|
||||
* protect a memo'd item from re-rendering on every resize frame, pass a stable value down.
|
||||
* `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. */
|
||||
hasNextPage?: boolean;
|
||||
isFetchingNextPage?: boolean;
|
||||
@@ -85,6 +91,7 @@ export default function VirtualGrid<T>({
|
||||
hasNextPage = false,
|
||||
isFetchingNextPage = false,
|
||||
fetchNextPage,
|
||||
keyboardNav = false,
|
||||
}: VirtualGridProps<T>) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
|
||||
@@ -139,6 +146,15 @@ export default function VirtualGrid<T>({
|
||||
|
||||
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
|
||||
// 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
|
||||
@@ -159,6 +175,7 @@ export default function VirtualGrid<T>({
|
||||
{virtualRows.map((vr) => {
|
||||
const row = rows[vr.index];
|
||||
if (!row) return null;
|
||||
const first = vr.index * colCount;
|
||||
return (
|
||||
<div
|
||||
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
|
||||
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. */}
|
||||
{row.map((item) => (
|
||||
<Fragment key={getKey(item)}>{renderItem(item, itemWidth)}</Fragment>
|
||||
{row.map((item, i) => (
|
||||
<Fragment key={getKey(item)}>{renderItem(item, itemWidth, first + i)}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user