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).
223 lines
8.3 KiB
TypeScript
223 lines
8.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// Contract of the Plex header search box (S2): the field scope-pill and the instant typeahead.
|
|
// Covers the pieces the /code-review flagged: the pill's per-scope options (Episode only in
|
|
// show/both), the suggestion list rendering, keyboard pick → jumpTo hand-off, and the regression
|
|
// that closing the panel (Escape) must NOT wedge it shut while the input keeps focus — typing again
|
|
// re-opens it. i18n is key-passthrough; the provider, api, and debounce are mocked so the test drives
|
|
// state directly. ActionMenu is floating-ui based, so we stub the observers and wrap in a FloatingTree.
|
|
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
|
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
|
import { FloatingTree } from "@floating-ui/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
|
});
|
|
// Identity debounce so a typed value is reflected immediately.
|
|
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
|
|
|
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
|
|
const plexState = { scope: "both", field: "all", q: "" } as {
|
|
scope: string;
|
|
field: string;
|
|
q: string;
|
|
};
|
|
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
|
|
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
|
|
// the panel re-appears from cache.
|
|
const setQ = vi.fn((v: string) => {
|
|
plexState.q = v;
|
|
});
|
|
const setField = vi.fn();
|
|
const setJumpTo = vi.fn();
|
|
vi.mock("./PlexProvider", () => ({
|
|
usePlex: () => plexState,
|
|
usePlexActions: () => ({ setQ, setField, setJumpTo }),
|
|
}));
|
|
|
|
// api.plexLibrary stub — returns whatever the current test queued.
|
|
const plexLibrary = vi.fn();
|
|
vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
|
|
|
|
import PlexSearchBox from "./PlexSearchBox";
|
|
|
|
const card = (id: string, title: string, type: "movie" | "show" = "movie", year = 2000) => ({
|
|
id,
|
|
type,
|
|
title,
|
|
year,
|
|
thumb: `/api/plex/image/${id}`,
|
|
});
|
|
const result = (items: ReturnType<typeof card>[]) => ({
|
|
scope: "both",
|
|
total: items.length,
|
|
offset: 0,
|
|
limit: 10,
|
|
items,
|
|
episodes: [],
|
|
});
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
// jsdom has no layout, so scrollIntoView (used to keep the active option in view) is absent.
|
|
Element.prototype.scrollIntoView ??= () => {};
|
|
});
|
|
|
|
beforeEach(() => {
|
|
plexState.scope = "both";
|
|
plexState.field = "all";
|
|
plexState.q = "";
|
|
setQ.mockClear(); // clear calls but KEEP the write-back implementation
|
|
setField.mockReset();
|
|
setJumpTo.mockReset();
|
|
plexLibrary.mockReset();
|
|
plexLibrary.mockResolvedValue(result([]));
|
|
});
|
|
afterEach(cleanup);
|
|
|
|
const renderBox = () => {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(
|
|
<QueryClientProvider client={qc}>
|
|
<FloatingTree>
|
|
<PlexSearchBox />
|
|
</FloatingTree>
|
|
</QueryClientProvider>,
|
|
);
|
|
};
|
|
|
|
const openPill = () =>
|
|
fireEvent.click(screen.getByRole("button", { name: "plex.searchField.aria" }));
|
|
|
|
describe("PlexSearchBox — scope-pill", () => {
|
|
it("offers all five field scopes when shows are in scope", () => {
|
|
plexState.scope = "both";
|
|
renderBox();
|
|
openPill();
|
|
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
|
expect(labels).toEqual([
|
|
"plex.searchField.all",
|
|
"plex.searchField.title",
|
|
"plex.searchField.people",
|
|
"plex.searchField.summary",
|
|
"plex.searchField.episode",
|
|
]);
|
|
});
|
|
|
|
it("hides the Episode scope in a movie-only scope", () => {
|
|
plexState.scope = "movie";
|
|
renderBox();
|
|
openPill();
|
|
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
|
expect(labels).toEqual([
|
|
"plex.searchField.all",
|
|
"plex.searchField.title",
|
|
"plex.searchField.people",
|
|
"plex.searchField.summary",
|
|
]);
|
|
expect(labels).not.toContain("plex.searchField.episode");
|
|
});
|
|
});
|
|
|
|
describe("PlexSearchBox — typeahead", () => {
|
|
it("renders the top matches on focus and picks one via keyboard → jumpTo", async () => {
|
|
plexState.q = "matrix";
|
|
plexLibrary.mockResolvedValue(
|
|
result([card("m1", "The Matrix"), card("m2", "The Matrix Reloaded")]),
|
|
);
|
|
renderBox();
|
|
fireEvent.focus(screen.getByRole("combobox"));
|
|
|
|
// Both matches show up as options.
|
|
await waitFor(() =>
|
|
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
|
|
expect.stringContaining("The Matrix"),
|
|
expect.stringContaining("The Matrix Reloaded"),
|
|
]),
|
|
);
|
|
// The request carried the active field scope + the limit.
|
|
expect(plexLibrary).toHaveBeenCalledWith(
|
|
expect.objectContaining({ scope: "both", q: "matrix", field: "all", limit: 10 }),
|
|
);
|
|
|
|
// ArrowDown highlights the first, Enter opens it via the jumpTo hand-off (not a Go search).
|
|
const input = screen.getByRole("combobox");
|
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
|
fireEvent.keyDown(input, { key: "Enter" });
|
|
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie" });
|
|
});
|
|
|
|
it("reopens after Escape when the user types again (focus/dismiss are decoupled)", async () => {
|
|
plexState.q = "matrix";
|
|
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
|
|
renderBox();
|
|
const input = screen.getByRole("combobox");
|
|
fireEvent.focus(input);
|
|
await screen.findByRole("listbox");
|
|
|
|
// Escape dismisses the panel (input stays focused — Escape does not blur).
|
|
fireEvent.keyDown(input, { key: "Escape" });
|
|
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
|
|
|
|
// Typing again must re-open it without a blur+refocus round-trip AND fire a fresh suggest query
|
|
// for the new term (not just re-show the cached list).
|
|
plexLibrary.mockClear();
|
|
fireEvent.change(input, { target: { value: "matrixx" } });
|
|
await screen.findByRole("listbox");
|
|
await waitFor(() =>
|
|
expect(plexLibrary).toHaveBeenCalledWith(expect.objectContaining({ q: "matrixx" })),
|
|
);
|
|
});
|
|
|
|
// 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")]));
|
|
renderBox();
|
|
const input = screen.getByRole("combobox");
|
|
// Collapsed until focused.
|
|
expect(input.getAttribute("aria-expanded")).toBe("false");
|
|
expect(input.getAttribute("aria-controls")).toBeNull();
|
|
|
|
fireEvent.focus(input);
|
|
const list = await screen.findByRole("listbox");
|
|
// Expanded, and the input points at the listbox it controls.
|
|
expect(input.getAttribute("aria-expanded")).toBe("true");
|
|
expect(input.getAttribute("aria-controls")).toBe(list.id);
|
|
|
|
// Escape collapses it again and drops the association.
|
|
fireEvent.keyDown(input, { key: "Escape" });
|
|
await waitFor(() => expect(input.getAttribute("aria-expanded")).toBe("false"));
|
|
expect(input.getAttribute("aria-controls")).toBeNull();
|
|
});
|
|
});
|