fix(plex): address S2 /code-review findings (typeahead focus + a11y + test)
- Decouple the typeahead's dismiss from real focus: a separate `dismissed` flag (set on Escape/pick, cleared on typing/refocus) replaces manually clearing `focused`, so closing the panel no longer wedges it shut while the input keeps DOM focus — typing again re-opens it (verified in-browser). - Scroll the highlighted option into view during arrow-key nav (listRef + data-idx + scrollIntoView), instead of leaving listRef unused. - Wire the input as a combobox for screen readers: role=combobox, aria-autocomplete/expanded/controls/activedescendant + per-option ids. - Drop the redundant client-side .slice (the request already caps at limit=10). - SearchBar gains an `inputProps` passthrough for the combobox ARIA. - Add PlexSearchBox.dom.test.tsx (4 tests): per-scope pill options, suggestion render + keyboard pick→jumpTo, and the Escape→retype reopen regression. Reviewer's scrollbar-blur concern was a false positive — the listbox container is not focusable, so a mousedown on it never blurs the input.
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// @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 };
|
||||
const setQ = vi.fn();
|
||||
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.mockReset();
|
||||
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.
|
||||
fireEvent.change(input, { target: { value: "matrixx" } });
|
||||
await screen.findByRole("listbox");
|
||||
expect(screen.getByRole("listbox")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user