Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions src/components/Output/ZPLOutput.sourceEdit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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));
});
Expand Down Expand Up @@ -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(<ZPLOutput onResizeMouseDown={vi.fn()} />);
typeDraft("^XA^JZY^FO10,10^A0N,30,30^FDX^FS^XZ");
fireEvent.click(screen.getByRole("button", { name: t().app.cancel }));
Expand Down Expand Up @@ -593,6 +594,54 @@ describe("outside pointerdown", () => {
});
});

describe("a modal above the panel", () => {
const Modal = ({ onAction }: { onAction: () => void }) => (
<DialogShell portal onClose={vi.fn()} describedBy="modal-body" boxClassName="">
<p id="modal-body">settings</p>
<button onClick={onAction}>setting</button>
</DialogShell>
);

it("takes clicks while an unappliable buffer holds the session", () => {
const action = vi.fn();
render(
<>
<Modal onAction={action} />
<ZPLOutput onResizeMouseDown={vi.fn()} />
</>,
);
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(<ZPLOutput onResizeMouseDown={vi.fn()} />);
typeDraft("^XA^FO10,10^A0N,30,30^FDbroken^FS");
editor().focus();
rerender(
<>
<Modal onAction={vi.fn()} />
<ZPLOutput onResizeMouseDown={vi.fn()} />
</>,
);
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(<ZPLOutput onResizeMouseDown={vi.fn()} />);
Expand Down
8 changes: 2 additions & 6 deletions src/components/Output/ZplSourceEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<>
Expand Down
13 changes: 7 additions & 6 deletions src/hooks/useSessionExit.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>,
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.
Expand All @@ -18,14 +18,15 @@ export function useSessionExit(
const root = panelRef.current;
if (!root) return;
let timer: ReturnType<typeof setTimeout> | 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();
Expand All @@ -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);
};
Expand Down
Loading