diff --git a/client/__tests__/CollectionGamesDialog.test.tsx b/client/__tests__/CollectionGamesDialog.test.tsx
new file mode 100644
index 000000000..c03e22a8e
--- /dev/null
+++ b/client/__tests__/CollectionGamesDialog.test.tsx
@@ -0,0 +1,92 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import CollectionGamesDialog from "@/components/CollectionGamesDialog";
+
+const apiRequest = vi.fn();
+vi.mock("@/lib/queryClient", () => ({ apiRequest: (...args: unknown[]) => apiRequest(...args) }));
+vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
+vi.mock("@/hooks/use-fresh-games", () => ({
+ useFreshGames: () => ({
+ games: [
+ { id: "g1", title: "Alpha", coverUrl: null },
+ { id: "g2", title: "Beta", coverUrl: null },
+ ],
+ isLoading: false,
+ isError: false,
+ retry: vi.fn(),
+ }),
+}));
+
+const client = new QueryClient();
+
+function dialog(memberIds: string[]) {
+ return (
+
+
+
+ );
+}
+
+function renderDialog() {
+ return render(dialog(["g1"]));
+}
+
+describe("CollectionGamesDialog", () => {
+ beforeEach(() => apiRequest.mockReset().mockResolvedValue({}));
+
+ it("adds a game that is not yet a member", async () => {
+ renderDialog();
+ fireEvent.click(screen.getByRole("button", { name: "Add Beta" }));
+ await waitFor(() =>
+ expect(apiRequest).toHaveBeenCalledWith("POST", "/api/collections/c1/games", {
+ gameId: "g2",
+ })
+ );
+ });
+
+ it("shows members as Added without an add button", () => {
+ renderDialog();
+ expect(screen.queryByRole("button", { name: "Add Alpha" })).toBeNull();
+ expect(screen.getByRole("button", { name: "Remove Alpha" })).toBeTruthy();
+ expect(screen.getByText("Added")).toBeTruthy();
+ });
+
+ it("removes a member", async () => {
+ renderDialog();
+ fireEvent.click(screen.getByRole("button", { name: "Remove Alpha" }));
+ await waitFor(() =>
+ expect(apiRequest).toHaveBeenCalledWith("DELETE", "/api/collections/c1/games/g1")
+ );
+ });
+
+ it("moves focus to the replacement control once the game becomes a member", async () => {
+ const { rerender } = renderDialog();
+ const add = screen.getByRole("button", { name: "Add Beta" });
+ add.focus();
+ fireEvent.click(add);
+ await waitFor(() => expect(apiRequest).toHaveBeenCalled());
+ rerender(dialog(["g1", "g2"]));
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByRole("button", { name: "Remove Beta" }))
+ );
+ });
+
+ it("leaves focus alone when the user moved to the search field", async () => {
+ const { rerender } = renderDialog();
+ fireEvent.click(screen.getByRole("button", { name: "Add Beta" }));
+ const search = screen.getByLabelText("Search your library");
+ search.focus();
+ await waitFor(() => expect(apiRequest).toHaveBeenCalled());
+ rerender(dialog(["g1", "g2"]));
+ const remove = await screen.findByRole("button", { name: "Remove Beta" });
+ await waitFor(() => expect((remove as HTMLButtonElement).disabled).toBe(false));
+ expect(document.activeElement).toBe(search);
+ });
+});
diff --git a/client/src/components/CollectionGamesDialog.tsx b/client/src/components/CollectionGamesDialog.tsx
index e8f09ce4d..30904b469 100644
--- a/client/src/components/CollectionGamesDialog.tsx
+++ b/client/src/components/CollectionGamesDialog.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useFreshGames } from "@/hooks/use-fresh-games";
@@ -8,11 +8,12 @@ import {
Dialog,
DialogContent,
DialogDescription,
+ DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
-import { Check, Plus } from "lucide-react";
+import { Check, Plus, X } from "lucide-react";
interface CollectionGamesDialogProps {
open: boolean;
@@ -23,7 +24,7 @@ interface CollectionGamesDialogProps {
memberIds: ReadonlySet;
}
-/** Add games to, or remove them from, a manual collection by picking from the library. */
+/** Add games to a manual collection by picking from the library; members can also be removed. */
export default function CollectionGamesDialog({
open,
onOpenChange,
@@ -34,6 +35,9 @@ export default function CollectionGamesDialog({
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
+ const listRef = useRef(null);
+ /** Row whose control just swapped (Add <-> Remove); focus moves to its replacement. */
+ const focusGameId = useRef(null);
const { games, isLoading, isError, retry } = useFreshGames(open);
@@ -53,7 +57,10 @@ export default function CollectionGamesDialog({
}
},
// Returned so the mutation stays pending until membership is refetched (no duplicate click).
- onSuccess: () => queryClient.invalidateQueries({ queryKey: ["/api/collections"] }),
+ onSuccess: (_data, { gameId }) => {
+ focusGameId.current = gameId;
+ return queryClient.invalidateQueries({ queryKey: ["/api/collections"] });
+ },
onError: (error: Error) => {
toast({
title: "Could not update the collection",
@@ -63,12 +70,33 @@ export default function CollectionGamesDialog({
},
});
+ useEffect(() => {
+ const gameId = focusGameId.current;
+ if (!gameId || toggleMutation.isPending) return;
+ focusGameId.current = null;
+ // Leave focus alone if the user moved on (e.g. to the search input) while the update ran.
+ // Radix parks focus on the dialog container when the swapped button unmounts: not a user move.
+ const active = document.activeElement;
+ const dialog = listRef.current?.closest("[role='dialog']");
+ if (
+ active &&
+ active !== document.body &&
+ active !== dialog &&
+ !listRef.current?.contains(active)
+ )
+ return;
+ listRef.current?.querySelector(`[data-game-id="${gameId}"] button`)?.focus();
+ }, [memberIds, toggleMutation.isPending]);
+
return (
);
diff --git a/client/src/pages/collections.tsx b/client/src/pages/collections.tsx
index 2315ce403..bbf583869 100644
--- a/client/src/pages/collections.tsx
+++ b/client/src/pages/collections.tsx
@@ -248,12 +248,8 @@ function CollectionView({ id }: Readonly<{ id: string }>) {
actions={
<>
{!isSmart && (
-