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 <im.amruth@gmail.com>
This commit is contained in:
Syed Ali Abbas Zaidi
2026-08-13 22:54:23 +02:00
committed by GitHub
co-authored by Amruth Pillai
parent 13e584d522
commit 9f13638eab
3 changed files with 83 additions and 0 deletions
@@ -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();
});
});
@@ -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();
}
@@ -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}
>
<TransformComponent wrapperClass="h-full! w-full!">
<ResumePreview showPageNumbers pageLayout={pageLayout} />