FN-9121: prevent deferred autofocus from stealing picker input

Keep Create Room member searches focused when deferred modal autofocus runs.

- Preserve focus when it is already inside the Create Room modal.
- Cover deferred autofocus with real member-search keyboard input.
- Exercise picker search state transitions with user-level typing.

Files changed:
 .../dashboard/app/components/CreateRoomModal.tsx   | 14 +++++++--
 .../components/__tests__/CreateRoomModal.test.tsx  | 33 ++++++++++++++++++++--
 2 files changed, 42 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-9121

Fusion-Task-Lineage: 088ddc4f-0e2f-4ba0-ba12-ffe396654e1f

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-16 02:34:13 -07:00
parent 111c6c96cc
commit e7e33dae87
2 changed files with 42 additions and 5 deletions

View File

@@ -49,6 +49,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const agentLoadEpochRef = useRef(0);
/*
@@ -104,7 +105,16 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
setIsSubmitting(false);
return;
}
const frame = window.requestAnimationFrame(() => nameInputRef.current?.focus());
/*
FNXC:CreateRoomModal 2026-08-16-09:20:
A late open-time animation frame must not yank focus from a field the user already selected
inside this dialog. Otherwise member-search keystrokes can be swallowed by the room-name field.
*/
const frame = window.requestAnimationFrame(() => {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement && modalRef.current?.contains(activeElement)) return;
nameInputRef.current?.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [isOpen]);
@@ -198,7 +208,7 @@ export function CreateRoomModal({ isOpen, onClose, onCreate, projectId, existing
closeOnOutsidePointerDown
layer="utility"
>
<div className="modal create-room-modal">
<div ref={modalRef} className="modal create-room-modal">
<div className="modal-header">
<h3>{t("createRoom.title", "Create room")}</h3>
<button type="button" className="modal-close" aria-label={t("actions.close", "Close")} onClick={onClose}>×</button>

View File

@@ -197,6 +197,32 @@ describe("CreateRoomModal", () => {
await waitFor(() => expect(screen.getByRole("button", { name: "Room launcher" })).toHaveFocus());
});
it("does not let deferred autofocus steal member-search keystrokes", async () => {
let capturedFrameCallback: FrameRequestCallback | undefined;
const requestAnimationFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
capturedFrameCallback = callback;
return 1;
});
try {
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
await screen.findByRole("button", { name: /Alpha/i });
await userEvent.click(screen.getByLabelText("Members"));
const searchInput = screen.getByLabelText("Members");
expect(searchInput).toHaveFocus();
expect(capturedFrameCallback).toBeDefined();
act(() => { capturedFrameCallback?.(0); });
await userEvent.keyboard("zzz");
expect(searchInput).toHaveValue("zzz");
expect(screen.getByLabelText("Room name")).toHaveValue("");
expect(screen.getByText("No agents match your search.")).toBeInTheDocument();
} finally {
requestAnimationFrame.mockRestore();
}
});
it.each(["desktop", "mobile"])("shows loading, empty, no-match, populated, and selected-member picker states on %s", async (viewport) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: viewport === "mobile" ? 375 : 1280 });
const loading = createDeferred<any[]>();
@@ -225,10 +251,11 @@ describe("CreateRoomModal", () => {
await waitFor(() => expect(mockFetchAgents).toHaveBeenCalledTimes(3));
await act(async () => { populated.resolve(agents); });
expect(await screen.findByRole("button", { name: /Alpha/i })).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Members"), { target: { value: "zzz" } });
const memberSearch = screen.getByLabelText("Members");
await userEvent.type(memberSearch, "zzz");
expect(screen.getByText("No agents match your search.")).toBeInTheDocument();
expect(screen.queryByText("No agents in this project yet.")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Members"), { target: { value: "" } });
await userEvent.clear(memberSearch);
await userEvent.click(screen.getByRole("button", { name: /Alpha/i }));
expect(screen.getByTestId("create-room-selected-chips")).toHaveTextContent("Alpha");
expect(mockFetchAgents).toHaveBeenCalledTimes(3);
@@ -289,7 +316,7 @@ describe("CreateRoomModal", () => {
render(<CreateRoomModal isOpen onClose={vi.fn()} onCreate={vi.fn()} />);
await screen.findByRole("button", { name: /Alpha/i });
fireEvent.change(screen.getByLabelText("Members"), { target: { value: "zzz" } });
await userEvent.type(screen.getByLabelText("Members"), "zzz");
expect(screen.getByText("No agents match your search.")).toBeInTheDocument();
});