From 120e33844f04c829f7a59b1fefd37050ccad0d8d Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 10 Oct 2026 07:20:10 +0200 Subject: [PATCH] fix(editor): a modal above a held source session no longer swallows every click --- .../Output/ZPLOutput.sourceEdit.test.tsx | 53 ++++++++++++++++++- src/components/Output/ZplSourceEditor.tsx | 8 +-- src/hooks/useSessionExit.ts | 13 ++--- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/components/Output/ZPLOutput.sourceEdit.test.tsx b/src/components/Output/ZPLOutput.sourceEdit.test.tsx index 14bc292f..0faae399 100644 --- a/src/components/Output/ZPLOutput.sourceEdit.test.tsx +++ b/src/components/Output/ZPLOutput.sourceEdit.test.tsx @@ -48,6 +48,7 @@ vi.mock("./ZplCodeMirror", () => { return { default: MockCodeMirror }; }); import { ZPLOutput } from "./ZPLOutput"; +import { DialogShell } from "../ui/DialogShell"; import { useLabelStore } from "../../store/labelStore"; import type { LabelObject, Page } from "@zplab/core/types/Group"; @@ -234,7 +235,7 @@ describe("leaving the editor", () => { const confirm = () => within(screen.getByRole("alertdialog")); expect(confirm().getByText(t().output.editSourceDiscardBody)).toBeTruthy(); expect(review().getByRole("button", { name: t().output.editSourceConfirmApply })).toBeTruthy(); - // The deferred blur apply stays suspended across the handover. No apply, no second review. + // Focus crossing from the review into the question must not apply, or a second review opens. await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); @@ -266,7 +267,7 @@ describe("leaving the editor", () => { expect(copiedTexts).toEqual(["^XA^FDedited^FS^XZ"]); }); - it("asks before discarding a modified buffer, and the question suspends the blur apply", async () => { + it("asks before discarding a modified buffer, and the question holds the blur apply", async () => { const { container } = render(); typeDraft("^XA^JZY^FO10,10^A0N,30,30^FDX^FS^XZ"); fireEvent.click(screen.getByRole("button", { name: t().app.cancel })); @@ -593,6 +594,54 @@ describe("outside pointerdown", () => { }); }); +describe("a modal above the panel", () => { + const Modal = ({ onAction }: { onAction: () => void }) => ( + + + + + ); + + it("takes clicks while an unappliable buffer holds the session", () => { + const action = vi.fn(); + render( + <> + + + , + ); + typeDraft("^XA^FO10,10^A0N,30,30^FDbroken^FS"); + const setting = screen.getByRole("button", { name: "setting" }); + fireEvent.pointerDown(setting); + fireEvent.click(setting); + expect(action).toHaveBeenCalledTimes(1); + expect(useLabelStore.getState().sourceEdit.status).toBe("editing"); + expect(useLabelStore.getState().sourceShadow?.refusal ?? null).toBeNull(); + }); + + it("keeps the focus it took instead of handing it back to the editor", async () => { + const { container, rerender } = render(); + typeDraft("^XA^FO10,10^A0N,30,30^FDbroken^FS"); + editor().focus(); + rerender( + <> + + + , + ); + vi.spyOn(document, "hasFocus").mockReturnValue(true); + const setting = screen.getByRole("button", { name: "setting" }); + setting.focus(); + fireEvent.focusOut(panelRoot(container)); + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + expect(useLabelStore.getState().sourceEdit.status).toBe("editing"); + expect(useLabelStore.getState().sourceShadow?.refusal ?? null).toBeNull(); + expect(document.activeElement).toBe(setting); + }); +}); + describe("keyboard focus after button exits", () => { it("returns to the editor on a clean footer apply", () => { render(); diff --git a/src/components/Output/ZplSourceEditor.tsx b/src/components/Output/ZplSourceEditor.tsx index a395ce3f..1f1487c1 100644 --- a/src/components/Output/ZplSourceEditor.tsx +++ b/src/components/Output/ZplSourceEditor.tsx @@ -199,7 +199,7 @@ function SessionChrome({ focusEditor(); }; - /** Returns whether the session ended (a held session swallows the click). */ + /** Returns whether the session ended. */ const handleApply = (explicit = false): boolean => { // An untouched buffer must not commit: the reparse would still rename // variables and replace ids, pure loss for a no-op edit. @@ -237,11 +237,7 @@ function SessionChrome({ return false; }; - useSessionExit(panelRef, { - onExit: handleApply, - onEscape: requestCancel, - suspended: pendingPlan !== null || confirmDiscard || confirmNew, - }); + useSessionExit(panelRef, { onExit: handleApply, onEscape: requestCancel }); return ( <> diff --git a/src/hooks/useSessionExit.ts b/src/hooks/useSessionExit.ts index d32b8f8b..8385efe3 100644 --- a/src/hooks/useSessionExit.ts +++ b/src/hooks/useSessionExit.ts @@ -1,11 +1,11 @@ import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'; /** The source-edit session's exit surface on a panel root. Not useDismiss: - * capture phase, focusout fallback, and a held exit (onExit false: refusal - * or confirm dialog) swallows the click so nothing fires under it. */ + * capture phase, a focusout fallback, and a held exit that swallows the + * click so nothing fires under it. */ export function useSessionExit( panelRef: RefObject, - handlers: { onExit: () => boolean; onEscape: () => void; suspended: boolean }, + handlers: { onExit: () => boolean; onEscape: () => void }, ): void { // Latest closures for the raw DOM listeners (mounted once per session). // Layout effect: synced before paint, so no user event sees a stale closure. @@ -18,14 +18,15 @@ export function useSessionExit( const root = panelRef.current; if (!root) return; let timer: ReturnType | undefined; + // Under a modal's backdrop a held session would swallow every click, so a modal is no exit. + const modalOpen = () => document.querySelector('[aria-modal="true"]') !== null; // Capture-phase, so the exit lands BEFORE the target's own handlers and // chrome/stage clicks act on the unfrozen store in the same click. (A // click on a canvas OBJECT still needs a second one: the apply re-ids // the Konva node under the pointer mid-gesture.) const onPointerDown = (e: PointerEvent) => { - if (handlersRef.current.suspended) return; const target = e.target as Element | null; - if (!target || root.contains(target)) return; + if (!target || root.contains(target) || modalOpen()) return; if (!handlersRef.current.onExit()) { e.preventDefault(); e.stopPropagation(); @@ -51,10 +52,10 @@ export function useSessionExit( // window blur (alt-tab) keeps the session; only focus moving elsewhere // in the app is "the one person now works the canvas". timer = setTimeout(() => { - if (handlersRef.current.suspended) return; if (!document.hasFocus()) return; const active = document.activeElement; if (active && root.contains(active)) return; + if (modalOpen()) return; handlersRef.current.onExit(); }, 0); };