diff --git a/src/components/clinical-dashboard/master-search-header.tsx b/src/components/clinical-dashboard/master-search-header.tsx index 099cb89b..45995577 100644 --- a/src/components/clinical-dashboard/master-search-header.tsx +++ b/src/components/clinical-dashboard/master-search-header.tsx @@ -33,7 +33,7 @@ import { import { DocumentTagCloud } from "@/components/DocumentTagCloud"; import { PrivacyInputNotice } from "@/components/privacy-input-notice"; -import { useDismissableLayer } from "@/components/use-dismissable-layer"; +import { restoreFocusUnlessMoved, useDismissableLayer } from "@/components/use-dismissable-layer"; import { useHideOnScroll } from "@/components/clinical-dashboard/use-hide-on-scroll"; import { AnswerFollowUpSuggestions } from "@/components/clinical-dashboard/answer-follow-up-suggestions"; import { @@ -747,13 +747,15 @@ export function MasterSearchHeader({ if (scopeOpen || !restoreActionMenuFocusRef.current) return; restoreActionMenuFocusRef.current = false; window.requestAnimationFrame(() => { - actionMenuTriggerRef.current?.focus({ preventScroll: true }); + restoreFocusUnlessMoved(actionMenuTriggerRef.current); }); }, [scopeOpen]); const closeScopeSheet = useCallback(() => { setScopeSheetOpen(false); - window.requestAnimationFrame(() => actionMenuTriggerRef.current?.focus()); + window.requestAnimationFrame(() => { + restoreFocusUnlessMoved(actionMenuTriggerRef.current); + }); }, []); useEffect(() => { diff --git a/src/components/use-dismissable-layer.ts b/src/components/use-dismissable-layer.ts index 78329408..40dd7d74 100644 --- a/src/components/use-dismissable-layer.ts +++ b/src/components/use-dismissable-layer.ts @@ -15,6 +15,24 @@ function eventHitsRef(event: Event, ref: DismissableLayerRef) { return target instanceof Node && element.contains(target); } +/** + * Restore focus after a dismissable surface closes, but do not steal focus if the + * user/test already moved it (e.g. opened the app-mode menu in the same frame + * window as Escape's deferred restore). + */ +export function restoreFocusUnlessMoved(target: HTMLElement | null | undefined) { + if (!target) return false; + // Matching global-search-shell's focus=1 guard: an open mode menu means focus + // was intentionally moved off the prior surface. + if (document.getElementById("app-mode-menu")) return false; + const active = document.activeElement; + if (active instanceof HTMLElement && active !== document.body && active !== target) { + return false; + } + target.focus({ preventScroll: true }); + return true; +} + export function useDismissableLayer({ enabled, refs, @@ -40,7 +58,7 @@ export function useDismissableLayer({ onDismiss("escape"); window.requestAnimationFrame(() => { window.requestAnimationFrame(() => { - restoreFocusRef?.current?.focus({ preventScroll: true }); + restoreFocusUnlessMoved(restoreFocusRef?.current); }); }); } diff --git a/tests/restore-focus-unless-moved.dom.test.tsx b/tests/restore-focus-unless-moved.dom.test.tsx new file mode 100644 index 00000000..f2d227d3 --- /dev/null +++ b/tests/restore-focus-unless-moved.dom.test.tsx @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { restoreFocusUnlessMoved } from "../src/components/use-dismissable-layer"; + +describe("restoreFocusUnlessMoved (jsdom)", () => { + afterEach(() => { + document.getElementById("app-mode-menu")?.remove(); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("restores focus when nothing else holds it", () => { + const target = document.createElement("button"); + target.focus = vi.fn(); + document.body.append(target); + + expect(restoreFocusUnlessMoved(target)).toBe(true); + expect(target.focus).toHaveBeenCalledWith({ preventScroll: true }); + }); + + it("skips restore while the app-mode menu is open", () => { + const menu = document.createElement("div"); + menu.id = "app-mode-menu"; + document.body.append(menu); + + const target = document.createElement("button"); + target.focus = vi.fn(); + document.body.append(target); + + expect(restoreFocusUnlessMoved(target)).toBe(false); + expect(target.focus).not.toHaveBeenCalled(); + }); + + it("skips restore when focus already moved to another control", () => { + const target = document.createElement("button"); + target.focus = vi.fn(); + const other = document.createElement("button"); + other.setAttribute("aria-label", "Mode Answer"); + document.body.append(target, other); + other.focus(); + + expect(restoreFocusUnlessMoved(target)).toBe(false); + expect(target.focus).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 0ae320ca..6924ff2f 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -163,6 +163,12 @@ async function switchToDocumentSearchMode(page: Page) { } await expect(appModeMenu).toBeEnabled(); await waitForReactEventHandler(appModeMenu, "onClick"); + // Scope/Escape deferred focus restore can race a mode-menu open; if the scope + // surface is open, wait for it to dismiss before opening the mode menu. + const scopePopover = page.getByTestId("scope-command-popover"); + if (await isVisibleWithoutThrow(scopePopover)) { + await scopePopover.waitFor({ state: "hidden", timeout: uiAssertionTimeoutMs }); + } await appModeMenu.click({ force: true }); const appModeGroup = page.getByRole("menu", { name: "Choose app mode" }); await expect(appModeGroup).toBeVisible({ timeout: uiAssertionTimeoutMs });