}
+ renderAction={(c) => {
+ const inColl = existing.has(c.id) || added.has(c.id);
+ return (
+
+ );
+ }}
+ />
);
}
diff --git a/frontend/src/components/PlexLibraryPicker.dom.test.tsx b/frontend/src/components/PlexLibraryPicker.dom.test.tsx
new file mode 100644
index 00000000..4d6887b3
--- /dev/null
+++ b/frontend/src/components/PlexLibraryPicker.dom.test.tsx
@@ -0,0 +1,101 @@
+// @vitest-environment jsdom
+//
+// Contract of the shared library search-and-pick list (F6): it owns the debounced query and the
+// full loading/error/empty/no-results/list state machine so the two callers (AddItemPanel in
+// PlexCollectionView, NewCollectionPanel in plexBrowseLists) can't drift. The two things a caller
+// steers — the `enabled` gate (NewCollectionPanel holds it off until the collection name is valid)
+// and the `emptyHint` shown before a term is typed — are the pieces with no test before this file, so
+// they're covered here. i18n is key-passthrough and api/debounce are mocked so the test drives state
+// directly.
+import { type ComponentProps } from "react";
+import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
+import { render, screen, cleanup, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+vi.mock("react-i18next", async (importOriginal) => {
+ const actual = await importOriginal();
+ return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
+});
+// Identity debounce so a passed term is reflected immediately.
+vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
+
+// api.plexLibrary stub — returns whatever the current test queued.
+const plexLibrary = vi.fn();
+vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
+
+import PlexLibraryPicker from "./PlexLibraryPicker";
+
+const card = (id: string, title: string, year = 2000) => ({
+ id,
+ type: "movie" as const,
+ title,
+ year,
+ thumb: `/api/plex/image/${id}`,
+});
+const result = (items: ReturnType[]) => ({
+ scope: "both",
+ total: items.length,
+ offset: 0,
+ limit: 12,
+ items,
+ episodes: [],
+});
+
+beforeEach(() => {
+ plexLibrary.mockReset();
+ plexLibrary.mockResolvedValue(result([]));
+});
+afterEach(cleanup);
+
+const renderPicker = (props: Partial> = {}) => {
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return render(
+
+ }
+ {...props}
+ />
+ ,
+ );
+};
+
+describe("PlexLibraryPicker", () => {
+ it("renders nothing and fires no query while disabled, even with a term", () => {
+ const { container } = renderPicker({ enabled: false, term: "matrix" });
+ expect(container.firstChild).toBeNull();
+ expect(plexLibrary).not.toHaveBeenCalled();
+ });
+
+ it("shows the emptyHint (and fires no query) before a term is typed", () => {
+ renderPicker({ term: "", emptyHint:
EMPTY_HINT
});
+ expect(screen.getByText("EMPTY_HINT")).toBeTruthy();
+ expect(plexLibrary).not.toHaveBeenCalled();
+ });
+
+ it("queries the library once enabled + a term are both present and renders the rows via renderAction", async () => {
+ plexLibrary.mockResolvedValue(result([card("m1", "The Matrix"), card("m2", "Reloaded")]));
+ renderPicker({ term: "matrix" });
+
+ await waitFor(() => expect(screen.getByText("act:m1")).toBeTruthy());
+ expect(screen.getByText("act:m2")).toBeTruthy();
+ expect(screen.getByText("The Matrix")).toBeTruthy();
+ expect(plexLibrary).toHaveBeenCalledWith({ scope: "both", q: "matrix", limit: 12 });
+ });
+
+ it("renders the honest error state (not an empty/no-results state) when the query fails", async () => {
+ plexLibrary.mockRejectedValue(new Error("boom"));
+ renderPicker({ term: "matrix" });
+
+ await waitFor(() => expect(screen.getByText("common.loadError")).toBeTruthy());
+ expect(screen.queryByText("plex.collection.addNoResults")).toBeNull();
+ });
+
+ it("shows the no-results message when the query returns an empty list", async () => {
+ plexLibrary.mockResolvedValue(result([]));
+ renderPicker({ term: "zzzz" });
+
+ await waitFor(() => expect(screen.getByText("plex.collection.addNoResults")).toBeTruthy());
+ });
+});
diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx
new file mode 100644
index 00000000..25c0ba51
--- /dev/null
+++ b/frontend/src/components/PlexLibraryPicker.tsx
@@ -0,0 +1,60 @@
+import { type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { useQuery } from "@tanstack/react-query";
+import { qk } from "../lib/queryKeys";
+import { api, type PlexCard } from "../lib/api";
+import { useDebounced } from "../lib/useDebounced";
+import { LoadingState } from "./QueryState";
+
+// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
+// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
+// render the same result rows, differing only in the per-row action. Callers keep their own chrome
+// (search input, name field, close button) and supply `term` + `renderAction`; this owns the query,
+// the loading/error/empty states, and the row markup so those can't drift between the two surfaces.
+export default function PlexLibraryPicker({
+ scope,
+ term,
+ enabled = true,
+ emptyHint = null,
+ renderAction,
+}: {
+ scope: string;
+ /** The raw search term; debounced internally. */
+ term: string;
+ /** Gate the query off (e.g. until a collection name is valid). When false, renders nothing. */
+ enabled?: boolean;
+ /** Shown while the (debounced) term is empty — a hint, or nothing. */
+ emptyHint?: ReactNode;
+ /** The trailing control for each result row (Add / Create-with / …). */
+ renderAction: (card: PlexCard) => ReactNode;
+}) {
+ const { t } = useTranslation();
+ const debounced = useDebounced(term.trim(), 300);
+ const q = useQuery({
+ queryKey: qk.plexLibrary("collection-pick", scope, debounced),
+ queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
+ enabled: enabled && debounced.length > 0,
+ });
+ const results = q.data?.items ?? [];
+
+ if (!enabled) return null;
+ if (debounced.length === 0) return <>{emptyHint}>;
+ if (q.isLoading) return ;
+ if (q.isError) return