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
26 changes: 22 additions & 4 deletions web/src/app/pattern-lab/HardwareModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -115,11 +121,23 @@ describe("HardwareModal", () => {

it("a press on its own backdrop still closes it", () => {
render(<Lab />);
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(<Lab />);
pasteHeader();
fireEvent.pointerDown(headerBox());
fireEvent.click(hardware());
expect(hardware()).toBeTruthy();
expect(headerBox()).toHaveValue(HEADER);
});

it("opens with the focus inside it", () => {
render(<Lab />);
expect(hardware().contains(document.activeElement)).toBe(true);
Expand Down Expand Up @@ -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(<Lab />);
Expand Down
12 changes: 6 additions & 6 deletions web/src/app/pattern-lab/HardwareModal.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<HTMLDivElement>) => {
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
Expand All @@ -240,7 +240,7 @@ export default function HardwareModal({
role="dialog"
aria-modal="true"
aria-label="Hardware"
onClick={closeOnBackdrop}
{...closeOnBackdrop}
>
<div
ref={cardRef}
Expand Down
4 changes: 3 additions & 1 deletion web/src/app/pattern-lab/panels/CodeGuideModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

// The pattern-code reference modal, unchanged from the single-pattern lab.

import { useBackdropClose } from "@/lib/hooks/useBackdropClose";
import { useDialog } from "@/lib/hooks/useDialog";
import styles from "../PatternLab.module.css";

export default function CodeGuideModal({ onClose }: { onClose: () => void }) {
const cardRef = useDialog<HTMLDivElement>(onClose);
const closeOnBackdrop = useBackdropClose(onClose);
return (
<div
className={styles.modalOverlay}
role="dialog"
aria-modal="true"
aria-label="Code guide"
onClick={onClose}
{...closeOnBackdrop}
>
<div
ref={cardRef}
Expand Down
21 changes: 19 additions & 2 deletions web/src/app/pattern-lab/panels/ImportImageModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ class LoadedImage {
}
}

function mount() {
function mount(onClose = () => {}) {
useLabStore.getState().discardProject();
useLabStore.getState().addPixelLayer();
const layer = useLabStore.getState().layers.find((entry) => entry.type === "pixel")!;
render(
<ImportImageModal width={128} height={64} layerId={layer.id} onApply={() => {}} onClose={() => {}} />,
<ImportImageModal width={128} height={64} layerId={layer.id} onApply={() => {}} onClose={onClose} />,
);
return screen.getByLabelText(/^Import preview/);
}
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion web/src/app/pattern-lab/panels/ImportImageModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,7 @@ export default function ImportImageModal({
}, [scale, offset]);
const [showLayers, setShowLayers] = useState(true);
const cardRef = useDialog<HTMLDivElement>(onClose);
const closeOnBackdrop = useBackdropClose(onClose);
const previewRef = useRef<HTMLCanvasElement | null>(null);
const backdropRef = useRef<HTMLCanvasElement | null>(null);
const resultRef = useRef<Uint8ClampedArray | null>(null);
Expand Down Expand Up @@ -216,7 +218,7 @@ export default function ImportImageModal({
role="dialog"
aria-modal="true"
aria-label="Import image"
onClick={onClose}
{...closeOnBackdrop}
>
<div
ref={cardRef}
Expand Down
83 changes: 83 additions & 0 deletions web/src/lib/hooks/useBackdropClose.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// useBackdropClose: which presses on a modal's backdrop close it. A press is
// a pointerdown and then a click. Where the two land apart, the click is
// where Chromium sends it: the element both ends share, which for a drag out
// of the card is the backdrop.

import type { ReactNode } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useBackdropClose } from "./useBackdropClose";

function Modal({ label, onClose, children }: { label: string; onClose: () => void; children?: ReactNode }) {
const closeOnBackdrop = useBackdropClose(onClose);
return (
<div role="dialog" aria-modal="true" aria-label={label} {...closeOnBackdrop}>
<div>
<textarea aria-label={`${label} text`} defaultValue="#pragma once" />
<canvas
aria-label={`${label} canvas`}
onPointerDown={(event) => event.stopPropagation()}
/>
{children}
</div>
</div>
);
}

const backdrop = (label = "Outer") => screen.getByRole("dialog", { name: label });

afterEach(cleanup);

describe("useBackdropClose", () => {
it("closes on a press that goes down and comes up on the backdrop", () => {
const onClose = vi.fn();
render(<Modal label="Outer" onClose={onClose} />);
fireEvent.pointerDown(backdrop());
fireEvent.click(backdrop());
expect(onClose).toHaveBeenCalledTimes(1);
});

it("stays open when a drag across the text lets go on the backdrop", () => {
const onClose = vi.fn();
render(<Modal label="Outer" onClose={onClose} />);
fireEvent.pointerDown(screen.getByLabelText("Outer text"));
fireEvent.click(backdrop());
expect(onClose).not.toHaveBeenCalled();
});

// A press on the backdrop that a scroll took over makes no click. The next
// press began on a canvas that keeps its pointerdown to itself, and its
// drag ended outside.
it("hears a press that something inside keeps to itself", () => {
const onClose = vi.fn();
render(<Modal label="Outer" onClose={onClose} />);
fireEvent.pointerDown(backdrop());
fireEvent.pointerDown(screen.getByLabelText("Outer canvas"));
fireEvent.click(backdrop());
expect(onClose).not.toHaveBeenCalled();
});

it("reads each click by its own press", () => {
const onClose = vi.fn();
render(<Modal label="Outer" onClose={onClose} />);
fireEvent.pointerDown(backdrop());
fireEvent.click(backdrop());
fireEvent.click(backdrop());
expect(onClose).toHaveBeenCalledTimes(1);
});

// To hardware opens Send and Publish inside its own backdrop.
it("closes the window on top alone", () => {
const outer = vi.fn();
const inner = vi.fn();
render(
<Modal label="Outer" onClose={outer}>
<Modal label="Inner" onClose={inner} />
</Modal>,
);
fireEvent.pointerDown(backdrop("Inner"));
fireEvent.click(backdrop("Inner"));
expect(inner).toHaveBeenCalledTimes(1);
expect(outer).not.toHaveBeenCalled();
});
});
31 changes: 31 additions & 0 deletions web/src/lib/hooks/useBackdropClose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import { useRef, type MouseEvent, type PointerEvent } from "react";

/**
* A press on a modal's backdrop closes it, and only a press that began there.
*
* Drag across the text in the card to select it and let go outside, and the
* browser still makes a click. Chromium sends it to the element the press and
* the release share, which is the backdrop. A backdrop that looked only at
* the click's target closed the window, and a header pasted into To hardware
* went with it.
*
* Spread the result on the backdrop. Where the press began is noted in the
* capture phase, so a canvas inside that keeps its pointerdown to itself
* cannot leave an old answer behind. The click has to land on the backdrop
* itself as well, so a window opened inside another one closes alone.
*/
export function useBackdropClose(onClose: () => void) {
const pressedHere = useRef(false);
return {
onPointerDownCapture: (event: PointerEvent<HTMLElement>) => {
pressedHere.current = event.target === event.currentTarget;
},
onClick: (event: MouseEvent<HTMLElement>) => {
const fromBackdrop = pressedHere.current;
pressedHere.current = false;
if (fromBackdrop && event.target === event.currentTarget) onClose();
},
};
}
Loading