From 7f4eb24a7427da9f190d96027fe29618bf29e89e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 03:51:54 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20=EB=B0=94?= =?UTF-8?q?=ED=80=B4=2078?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KrwPAQrXtjp1YkhF57Z6rc From 88e128fe58117b52abe713f939b9e0c0ece03e38 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 03:56:21 +0000 Subject: [PATCH 2/5] =?UTF-8?q?web:=20Pattern=20Lab=20=EC=B0=BD=EC=9D=B4?= =?UTF-8?q?=20=EB=B0=94=EA=B9=A5=20=ED=8C=90=EC=97=90=EC=84=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=9E=91=ED=95=9C=20=EB=88=84=EB=A6=84=EC=97=90=EB=A7=8C=20?= =?UTF-8?q?=EB=8B=AB=ED=9E=8C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KrwPAQrXtjp1YkhF57Z6rc --- .../app/pattern-lab/HardwareModal.test.tsx | 26 +++++- web/src/app/pattern-lab/HardwareModal.tsx | 12 +-- .../app/pattern-lab/panels/CodeGuideModal.tsx | 4 +- .../panels/ImportImageModal.test.tsx | 21 ++++- .../pattern-lab/panels/ImportImageModal.tsx | 4 +- web/src/lib/hooks/useBackdropClose.test.tsx | 91 +++++++++++++++++++ web/src/lib/hooks/useBackdropClose.ts | 31 +++++++ 7 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 web/src/lib/hooks/useBackdropClose.test.tsx create mode 100644 web/src/lib/hooks/useBackdropClose.ts diff --git a/web/src/app/pattern-lab/HardwareModal.test.tsx b/web/src/app/pattern-lab/HardwareModal.test.tsx index 9bc38be00..94a9feeb4 100644 --- a/web/src/app/pattern-lab/HardwareModal.test.tsx +++ b/web/src/app/pattern-lab/HardwareModal.test.tsx @@ -52,6 +52,12 @@ function pasteHeader() { fireEvent.change(headerBox(), { target: { value: HEADER } }); } +/** A press that goes down and comes up on the same element. */ +function press(element: Element) { + fireEvent.pointerDown(element); + fireEvent.click(element); +} + /** Paste a header, press Next, and land on the "On to hardware" stage. */ function pasteHeaderAndContinue() { pasteHeader(); @@ -88,7 +94,7 @@ describe("HardwareModal", () => { fireEvent.click(screen.getByText("↗ Apply to my Patternflow")); const send = screen.getByText("Send to my Patternflow").closest('[role="dialog"]')!; - fireEvent.click(send); + press(send); expect(screen.queryByText("Send to my Patternflow")).toBeNull(); expectHeaderKept(); @@ -101,7 +107,7 @@ describe("HardwareModal", () => { const title = await screen.findByText("Share to Community"); const publish = title.closest('[role="dialog"]')!; - fireEvent.click(publish); + press(publish); await waitFor(() => expect(screen.queryByText("Share to Community")).toBeNull()); expectHeaderKept(); @@ -115,11 +121,23 @@ describe("HardwareModal", () => { it("a press on its own backdrop still closes it", () => { render(); - fireEvent.click(hardware()); + press(hardware()); expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.getByText("closed")).toBeTruthy(); }); + // Select the pasted header by dragging across it, let go past the edge of + // the card, and Chromium sends the click to the backdrop, the one element + // the press and the release share. That click used to close the window. + it("stays open when a drag that began in the header box ends on the backdrop", () => { + render(); + pasteHeader(); + fireEvent.pointerDown(headerBox()); + fireEvent.click(hardware()); + expect(hardware()).toBeTruthy(); + expect(headerBox()).toHaveValue(HEADER); + }); + it("opens with the focus inside it", () => { render(); expect(hardware().contains(document.activeElement)).toBe(true); @@ -399,7 +417,7 @@ describe("HardwareModal", () => { // the way back was to ask the model again. it.each([ ["Esc", () => fireEvent.keyDown(document.activeElement!, { key: "Escape" })], - ["a press outside", () => fireEvent.click(hardware())], + ["a press outside", () => press(hardware())], ["×", () => fireEvent.click(screen.getByRole("button", { name: "Close" }))], ])("opens again with the header it held before %s", (_, close) => { render(); diff --git a/web/src/app/pattern-lab/HardwareModal.tsx b/web/src/app/pattern-lab/HardwareModal.tsx index ebbd53dc5..919dd26ae 100644 --- a/web/src/app/pattern-lab/HardwareModal.tsx +++ b/web/src/app/pattern-lab/HardwareModal.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useMemo, useState, type MouseEvent } from "react"; +import { useEffect, useMemo, useState } from "react"; import { PublishModal, SendModuleModal, buildsConfigured, communityConfigured } from "./community"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import { useDialog } from "@/lib/hooks/useDialog"; import { useResettingState } from "@/lib/hooks/useResettingState"; import { @@ -212,10 +213,9 @@ export default function HardwareModal({ // Only a press on this backdrop itself closes. Send and Publish open inside // it, so a press on their backdrop bubbles up here too, and closing one of - // them would close this window under it. - const closeOnBackdrop = (event: MouseEvent) => { - if (event.target === event.currentTarget) onClose(); - }; + // them would close this window under it. A drag out of the header box ends + // here as well, and that one began in the card. + const closeOnBackdrop = useBackdropClose(onClose); // Esc closes the window on top. Send and Publish open over this one and // hear no keys of their own, so the first Esc is theirs and the header @@ -240,7 +240,7 @@ export default function HardwareModal({ role="dialog" aria-modal="true" aria-label="Hardware" - onClick={closeOnBackdrop} + {...closeOnBackdrop} >
void }) { const cardRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); return (
{}) { useLabStore.getState().discardProject(); useLabStore.getState().addPixelLayer(); const layer = useLabStore.getState().layers.find((entry) => entry.type === "pixel")!; render( - {}} onClose={() => {}} />, + {}} onClose={onClose} />, ); return screen.getByLabelText(/^Import preview/); } @@ -103,6 +103,23 @@ describe("ImportImageModal", () => { expect(screen.getByText(/108%/)).toBeTruthy(); }); + // Dragging the image into place and letting go past the card's edge ends + // in a click on the backdrop. It used to close the window and drop the + // placement. + it("stays open when a drag on the preview ends on the backdrop", async () => { + const onClose = vi.fn(); + const preview = mount(onClose); + await loadImage(); + const backdrop = screen.getByRole("dialog", { name: "Import image" }); + fireEvent.pointerDown(preview, { button: 0, clientX: 64, clientY: 32 }); + fireEvent.click(backdrop); + expect(onClose).not.toHaveBeenCalled(); + + fireEvent.pointerDown(backdrop); + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalledTimes(1); + }); + it("the wheel keeps the point of the image under the cursor where it was", async () => { const preview = mount(); await loadImage(); diff --git a/web/src/app/pattern-lab/panels/ImportImageModal.tsx b/web/src/app/pattern-lab/panels/ImportImageModal.tsx index db4773516..d96d3fdfd 100644 --- a/web/src/app/pattern-lab/panels/ImportImageModal.tsx +++ b/web/src/app/pattern-lab/panels/ImportImageModal.tsx @@ -6,6 +6,7 @@ // Lived at the bottom of PixelPanel.tsx until 2026-09; same code, own file. import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import { useDialog } from "@/lib/hooks/useDialog"; import { rasterizeImage, type ImportFit, type RGBA } from "@/lib/lab/pixelTools"; import { labEngine } from "@/lib/lab/engine"; @@ -45,6 +46,7 @@ export default function ImportImageModal({ }, [scale, offset]); const [showLayers, setShowLayers] = useState(true); const cardRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); const previewRef = useRef(null); const backdropRef = useRef(null); const resultRef = useRef(null); @@ -216,7 +218,7 @@ export default function ImportImageModal({ role="dialog" aria-modal="true" aria-label="Import image" - onClick={onClose} + {...closeOnBackdrop} >
void; children?: ReactNode }) { + const closeOnBackdrop = useBackdropClose(onClose); + return ( +
+
+