From 9f13638eab32c6d7f1b5876390a307fb70c9a113 Mon Sep 17 00:00:00 2001 From: Syed Ali Abbas Zaidi <88369802+Syed-Ali-Abbas-Zaidi@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:54:23 +0500 Subject: [PATCH] fix(web): drop focus when panning the builder canvas (#3303) 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 wherever it was before the pan -- typically the sidebar button that opened the last dialog, since closing a dialog restores focus to its trigger. A focused button activates on Space keyup, so holding space to pan and then releasing it reopened the most recent dialog. Blur the focused element from onPanningStart, which reinstates exactly the focus change the browser would have made on its own. onPanningStart only fires when the mousedown target is inside the transform wrapper, so sidebar and dialog clicks are unaffected, and keyboard-only users never trigger a pointer pan. Closes #3300 Co-authored-by: Amruth Pillai --- .../$resumeId/-components/pan-focus.test.ts | 61 +++++++++++++++++++ .../$resumeId/-components/pan-focus.ts | 20 ++++++ .../$resumeId/-components/preview-page.tsx | 2 + 3 files changed, 83 insertions(+) create mode 100644 apps/web/src/routes/builder/$resumeId/-components/pan-focus.test.ts create mode 100644 apps/web/src/routes/builder/$resumeId/-components/pan-focus.ts 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} >