From e5bcd3c2fe9d980c4eef4a72ecb6a2e83b2e3e66 Mon Sep 17 00:00:00 2001 From: DavidLeuter Date: Sat, 26 Sep 2026 15:00:55 +0200 Subject: [PATCH] fix(admin): keep keyboard focus in the wizard after adding a token inline The desktop token companion is portalled next to the wizard, outside the wizard's Tab trap. After saving, the focused submit button unmounted and focus dropped to , so the next Tab landed on the page behind the wizard. The phone inline form had the same drop. The companion now uses useDialogFocus (focus in, Tab trap, restore), and the credential slot hands focus back to its trigger whenever the form closes. Applies to the GitHub token and Atlassian credential forms. Co-Authored-By: Claude Opus 5.5 --- .../wizard/sources/AddSourceFlow.tsx | 44 ++++++++++- .../components/CreateProjectWizard.test.tsx | 77 ++++++++++++++++++- 2 files changed, 116 insertions(+), 5 deletions(-) diff --git a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx index 5532a1c59..44394294e 100644 --- a/src/features/admin/components/wizard/sources/AddSourceFlow.tsx +++ b/src/features/admin/components/wizard/sources/AddSourceFlow.tsx @@ -6,6 +6,7 @@ import { useState, type CSSProperties, type ReactNode, + type Ref, type RefObject, } from "react"; import { createPortal } from "react-dom"; @@ -22,6 +23,7 @@ import { JiraConnectStep } from "../../../../data-ingestion/components/JiraConne import { ConfluenceConnectStep } from "../../../../data-ingestion/components/ConfluenceConnectStep"; import { SourceTypeStep } from "../../../../data-ingestion/components/SourceTypeStep"; import { FileUploadZone } from "../../../../knowledge-base/components/FileUploadZone"; +import { useDialogFocus } from "../../../../../components/ui/useDialogFocus"; import { TokenAddForm } from "../../../../settings/components/TokenAddForm"; import { AtlassianCredentialAddForm } from "../../../../settings/components/atlassian/AtlassianCredentialAddForm"; import type { SourceSystem } from "../../../../data-ingestion/types"; @@ -137,9 +139,23 @@ type AddSourceFlowProps = { * and closes itself once the token/credential is saved. The form owns save + * cancel; this only decides where it is shown. */ -function TriggerButton({ label, onClick }: { label: string; onClick: () => void }) { +function TriggerButton({ + label, + onClick, + ref, +}: { + label: string; + onClick: () => void; + ref?: Ref; +}) { return ( - ); @@ -171,6 +187,12 @@ const VIEWPORT_MARGIN = 12; * and it slides in/out. Escape closes only the companion — a capture-phase * handler stops the event before the wizard modal's own Escape-to-close fires. * + * Being portalled beside the wizard, it sits outside the wizard's Tab trap, so + * it runs its own: focus moves in on open, Tab stays inside while open, and + * focus returns to the trigger on close. Without that, a keyboard user who saved + * a token was left with focus on ``, and the next Tab landed on the page + * underneath the wizard. + * * The position is measured from the wizard dialog (found via `anchorRef`) and * re-measured on scroll/resize; it clamps into the viewport if the modal sits * too far right for the full gap. @@ -189,6 +211,7 @@ function CompanionModal({ children: ReactNode; }) { const prefersReducedMotion = useReducedMotion(); + const dialogRef = useDialogFocus(isOpen); const [style, setStyle] = useState({ position: "fixed", top: -9999, left: -9999 }); // Remembers the last applied top/left so the per-frame tracker only triggers a // re-render when the position actually moves. @@ -258,6 +281,8 @@ function CompanionModal({ {isOpen && ( (null); + const triggerRef = useRef(null); + const wasOpenRef = useRef(false); const close = () => setIsOpen(false); + // Hand focus back to the trigger once the form closes (saved or cancelled). + // The focused control was inside the form, so without this focus drops to + // `` and the next Tab escapes the wizard onto the page behind it. On + // phone the trigger remounts in place of the inline form, hence the effect. + useEffect(() => { + if (wasOpenRef.current && !isOpen) triggerRef.current?.focus(); + wasOpenRef.current = isOpen; + }, [isOpen]); + const companionOpen = isDesktop && isOpen; useEffect(() => { onCompanionOpenChange?.(companionOpen); @@ -337,7 +373,7 @@ function CredentialSlot({ <>{renderForm(close, false)} ) : (
- setIsOpen(true)} /> + setIsOpen(true)} /> {missingLabel && }
); @@ -345,7 +381,7 @@ function CredentialSlot({ return (
- setIsOpen(true)} /> + setIsOpen(true)} /> {missingLabel && } {renderForm(close, true)} diff --git a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx index b61287eb0..db39b15d1 100644 --- a/tests/unit/features/admin/components/CreateProjectWizard.test.tsx +++ b/tests/unit/features/admin/components/CreateProjectWizard.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { CreateProjectWizard } from "../../../../../src/features/admin/components/CreateProjectWizard"; @@ -767,6 +767,81 @@ describe("CreateProjectWizard", () => { ); }); + it("returns focus to the trigger after a token is added inline by keyboard", async () => { + vi.mocked(getGithubPatNames).mockResolvedValueOnce([]).mockResolvedValue(["fresh-pat"]); + const user = userEvent.setup(); + renderWizard({ tokenNames: [] }); + + await goToSources(user); + await openGithubDetail(user); + + screen.getByRole("button", { name: /add github token/i }).focus(); + await user.keyboard("{Enter}"); + await user.type(screen.getByTestId("settings-add-token-name"), "fresh-pat"); + await user.type(screen.getByTestId("settings-add-token-value"), "ghp_secret123{Enter}"); + + // Focus lands back on the trigger instead of dropping to , where the + // next Tab would escape the wizard onto the page behind it. + await waitFor(() => + expect(screen.getByRole("button", { name: /add github token/i })).toHaveFocus(), + ); + }); + + describe("on desktop, where the token form opens in a companion beside the wizard", () => { + beforeEach(() => { + vi.spyOn(window, "matchMedia").mockImplementation( + (query: string) => + ({ + matches: query === "(min-width: 1280px)", + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }) as MediaQueryList, + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("keeps Tab inside the companion and returns focus to the trigger after saving", async () => { + vi.mocked(getGithubPatNames).mockResolvedValueOnce([]).mockResolvedValue(["fresh-pat"]); + const user = userEvent.setup(); + renderWizard({ tokenNames: [] }); + + await goToSources(user); + await openGithubDetail(user); + + const trigger = screen.getByRole("button", { name: /add github token/i }); + trigger.focus(); + await user.keyboard("{Enter}"); + + const companion = await screen.findByRole("dialog", { name: "New GitHub token" }); + await settleModalFocus(); + expect(screen.getByTestId("settings-add-token-name")).toHaveFocus(); + + // Tabbing past the last control wraps within the companion. + const submit = screen.getByTestId("settings-add-token-submit"); + await user.type(screen.getByTestId("settings-add-token-name"), "fresh-pat"); + await user.type(screen.getByTestId("settings-add-token-value"), "ghp_secret123"); + submit.focus(); + await user.tab(); + expect(companion).toContainElement(document.activeElement as HTMLElement); + + submit.focus(); + await user.keyboard("{Enter}"); + + await waitFor(() => + expect(vi.mocked(addGithubPat)).toHaveBeenCalledWith("fresh-pat", "ghp_secret123"), + ); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + }); + it("adds a Jira credential inline and selects the new one", async () => { vi.mocked(getMyAtlassianCredentials) .mockResolvedValueOnce([]) // initial load: none stored