diff --git a/apps/web/src/routes/builder/$resumeId/-components/pan-focus.test.ts b/apps/web/src/routes/builder/$resumeId/-components/pan-focus.test.ts new file mode 100644 index 000000000..61ac9df23 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-components/pan-focus.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { blurFocusedElementOnPan } from "./pan-focus"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +describe("blurFocusedElementOnPan", () => { + it("blurs the focused element", () => { + const button = document.createElement("button"); + document.body.append(button); + button.focus(); + expect(document.activeElement).toBe(button); + + blurFocusedElementOnPan(); + + expect(document.activeElement).not.toBe(button); + }); + + it("blurs a focused text field so panning does not keep it active", () => { + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + + blurFocusedElementOnPan(); + + expect(document.activeElement).not.toBe(input); + }); + + it("does nothing when focus already rests on the body", () => { + const blur = vi.spyOn(document.body, "blur"); + + blurFocusedElementOnPan(); + + expect(blur).not.toHaveBeenCalled(); + blur.mockRestore(); + }); + + // The issue: closing a dialog restores focus to its trigger, and because react-zoom-pan-pinch + // preventDefault()s the canvas mousedown the browser never moves focus away. Space then + // activates the still-focused trigger on keyup and reopens the dialog. + // https://github.com/amruthpillai/reactive-resume/issues/3300 + it("stops a Space keypress after panning from re-activating the last dialog trigger", () => { + const trigger = document.createElement("button"); + const openDialog = vi.fn(); + trigger.addEventListener("click", openDialog); + document.body.append(trigger); + + // Focus restored to the trigger when the dialog closed. + trigger.focus(); + + blurFocusedElementOnPan(); + + // happy-dom does not synthesise the activation click that a browser fires on Space keyup, + // so assert the precondition for it instead: the trigger no longer holds focus. + expect(document.activeElement).not.toBe(trigger); + expect(openDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routes/builder/$resumeId/-components/pan-focus.ts b/apps/web/src/routes/builder/$resumeId/-components/pan-focus.ts new file mode 100644 index 000000000..8e83e9850 --- /dev/null +++ b/apps/web/src/routes/builder/$resumeId/-components/pan-focus.ts @@ -0,0 +1,20 @@ +/** + * Drops focus from whatever element currently holds it, mirroring what the browser would have + * done natively when the user presses the mouse down on the (non-focusable) preview canvas. + * + * `react-zoom-pan-pinch` calls `preventDefault()` on its window-level `mousedown` listener so + * that dragging the canvas does not select text. That also cancels the browser's native focus + * shift, so focus stays on whatever was focused before the pan — typically the sidebar button + * that opened the last dialog, since closing a dialog restores focus to its trigger. A Space + * keypress then activates that still-focused button on keyup and the dialog reopens. + * + * @see https://github.com/amruthpillai/reactive-resume/issues/3300 + */ +export function blurFocusedElementOnPan(): void { + if (typeof document === "undefined") return; + + const element = document.activeElement; + if (!(element instanceof HTMLElement) || element === document.body) return; + + element.blur(); +} diff --git a/apps/web/src/routes/builder/$resumeId/-components/preview-page.tsx b/apps/web/src/routes/builder/$resumeId/-components/preview-page.tsx index 0303d40e7..35244aaf0 100644 --- a/apps/web/src/routes/builder/$resumeId/-components/preview-page.tsx +++ b/apps/web/src/routes/builder/$resumeId/-components/preview-page.tsx @@ -8,6 +8,7 @@ import { LoadingScreen } from "@/components/layout/loading-screen"; import { ResumePreview } from "@/features/resume/preview/preview"; import { BuilderDock } from "./dock"; import { DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT, getNextBuilderPreviewPageLayout } from "./page-layout"; +import { blurFocusedElementOnPan } from "./pan-focus"; export function PreviewPage() { const [pageLayout, setPageLayout] = useState(DEFAULT_BUILDER_PREVIEW_PAGE_LAYOUT); @@ -26,6 +27,7 @@ export function PreviewPage() { initialScale={0.75} limitToBounds={false} wheel={{ step: 0.001 }} + onPanningStart={blurFocusedElementOnPan} >