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 }) => (
+
+
settings
+
+
+ );
+
+ 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);
};