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:
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user