void; children?: ReactNode }) {
+ const closeOnBackdrop = useBackdropClose(onClose);
+ return (
+
+
+
+
+
+ );
+}
+
+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(
);
+ 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(
);
+ 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(
);
+ 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(
);
+ 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(
+
+
+ ,
+ );
+ fireEvent.pointerDown(backdrop("Inner"));
+ fireEvent.click(backdrop("Inner"));
+ expect(inner).toHaveBeenCalledTimes(1);
+ expect(outer).not.toHaveBeenCalled();
+ });
+});
diff --git a/web/src/lib/hooks/useBackdropClose.ts b/web/src/lib/hooks/useBackdropClose.ts
new file mode 100644
index 000000000..9a5ffa385
--- /dev/null
+++ b/web/src/lib/hooks/useBackdropClose.ts
@@ -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
) => {
+ pressedHere.current = event.target === event.currentTarget;
+ },
+ onClick: (event: MouseEvent) => {
+ const fromBackdrop = pressedHere.current;
+ pressedHere.current = false;
+ if (fromBackdrop && event.target === event.currentTarget) onClose();
+ },
+ };
+}