diff --git a/web/src/app/community/d/[id]/DeckDetailClient.tsx b/web/src/app/community/d/[id]/DeckDetailClient.tsx index 84e7a8bb7..3450a70f9 100644 --- a/web/src/app/community/d/[id]/DeckDetailClient.tsx +++ b/web/src/app/community/d/[id]/DeckDetailClient.tsx @@ -24,6 +24,7 @@ import { } from "@/lib/community/visibility"; import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate"; import { useDeviceHost } from "@/lib/community/deviceHost"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import { useZipDownload, zipStatusUrl } from "@/lib/community/zipDownload"; import type { DeckPageItem } from "@/lib/community/server/serialize"; import { captureEvent } from "@/lib/posthogEvents"; @@ -607,6 +608,7 @@ function PerformanceModal({ const [json, setJson] = useState(initialJson ?? ""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const closeOnBackdrop = useBackdropClose(onClose); const send = async (value: string | null) => { setBusy(true); @@ -643,7 +645,7 @@ function PerformanceModal({ }; return ( -
+
event.stopPropagation()}>
Deck performance @@ -715,6 +717,7 @@ function EditDeckModal({ const [description, setDescription] = useState(initialDescription ?? ""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const closeOnBackdrop = useBackdropClose(onClose); const save = async () => { const trimmed = title.trim(); @@ -745,7 +748,7 @@ function EditDeckModal({ }; return ( -
+
event.stopPropagation()}>
Edit deck details diff --git a/web/src/components/community/AddHeaderModal.tsx b/web/src/components/community/AddHeaderModal.tsx index 602d8f0b6..b1ca7d643 100644 --- a/web/src/components/community/AddHeaderModal.tsx +++ b/web/src/components/community/AddHeaderModal.tsx @@ -8,6 +8,7 @@ import { authClient } from "@/lib/community/auth-client"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; import { useDialog } from "@/lib/hooks/useDialog"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import AuthModal from "./AuthModal"; import styles from "./Community.module.css"; @@ -56,6 +57,7 @@ export default function AddHeaderModal({ // One request per press, decided in the click's own tick (#455). const latch = useSubmitLatch(); const cardRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); const moderating = mode === "moderate" || mode === "moderate-port"; @@ -126,7 +128,7 @@ export default function AddHeaderModal({ : "Add firmware header (.h)"; return ( -
+
(onClose); + const closeOnBackdrop = useBackdropClose(onClose); // The Director saves .pfs and the panel plays it, so that is what people // have. It is decoded here into the canonical form the site stores, which @@ -79,7 +81,7 @@ export default function AddPerformanceModal({ }; return ( -
+
void; children: ReactNode }) { const cardRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); return ( -
+
{open && ( -
+
event.stopPropagation()} diff --git a/web/src/components/community/EditDetailsModal.tsx b/web/src/components/community/EditDetailsModal.tsx index 58203b2e8..6c5cf78d9 100644 --- a/web/src/components/community/EditDetailsModal.tsx +++ b/web/src/components/community/EditDetailsModal.tsx @@ -11,6 +11,7 @@ import { } from "@/lib/community/validate"; import { LICENSE_OPTIONS, forkLicenseOptions, licenseBySpdx } from "@/lib/pattern/share"; import { useDialog } from "@/lib/hooks/useDialog"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import { captureEvent } from "@/lib/posthogEvents"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { @@ -68,6 +69,7 @@ export default function EditDetailsModal({ // before the hook could see which button opened the window, so the focus // had nowhere to go back to. const titleRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); // What this pattern may be relicensed to. The pattern's CURRENT licence is // always included even when it is no longer offered (MIT, CC0): a retired @@ -113,7 +115,7 @@ export default function EditDetailsModal({ }; return ( -
+
event.stopPropagation()}>
Edit pattern details diff --git a/web/src/components/community/PublishModal.tsx b/web/src/components/community/PublishModal.tsx index 30dea52f4..573b5bdb8 100644 --- a/web/src/components/community/PublishModal.tsx +++ b/web/src/components/community/PublishModal.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useDialog } from "@/lib/hooks/useDialog"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import { authClient } from "@/lib/community/auth-client"; import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; @@ -127,6 +128,7 @@ export default function PublishModal({ // field's autoFocus used to move it before the hook could look, and the // field is not there yet while the session is being checked. const cardRef = useDialog(onClose); + const closeOnBackdrop = useBackdropClose(onClose); const titleRef = useRef(null); const formShown = !isPending && Boolean(session); useEffect(() => { @@ -263,7 +265,7 @@ export default function PublishModal({ }; return ( -
+
(onClose); + const closeOnBackdrop = useBackdropClose(onClose); const submit = async () => { if (!latch.take()) return; @@ -69,7 +71,7 @@ export default function ReportModal({ }; return ( -
+
+
event.stopPropagation()} diff --git a/web/src/components/community/ShareDeckModal.tsx b/web/src/components/community/ShareDeckModal.tsx index 7cb6ba4f4..7370dc4c2 100644 --- a/web/src/components/community/ShareDeckModal.tsx +++ b/web/src/components/community/ShareDeckModal.tsx @@ -7,6 +7,7 @@ import { COMMUNITY_FETCH_INIT, communityApiUrl } from "@/lib/community/apiBase"; import { useSubmitLatch } from "@/lib/community/submitLatch"; import { PUBLIC_DECKS_MAX, type CollectedPattern } from "@/lib/community/deck"; import { DESCRIPTION_MAX, TITLE_MAX } from "@/lib/community/validate"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import { VISIBILITY_HINTS, VISIBILITY_LABELS, @@ -40,6 +41,7 @@ export default function ShareDeckModal({ const [busy, setBusy] = useState(false); // One request per press, decided in the click's own tick (#455). const latch = useSubmitLatch(); + const closeOnBackdrop = useBackdropClose(onClose); const share = async () => { const trimmed = title.trim(); @@ -83,7 +85,7 @@ export default function ShareDeckModal({ }; return ( -
+
event.stopPropagation()}>
Share deck diff --git a/web/src/components/community/ShareDeckPackModal.tsx b/web/src/components/community/ShareDeckPackModal.tsx index 08e5c8f4e..3bad92ae3 100644 --- a/web/src/components/community/ShareDeckPackModal.tsx +++ b/web/src/components/community/ShareDeckPackModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useBackdropClose } from "@/lib/hooks/useBackdropClose"; import styles from "./Community.module.css"; // Handing a deck to somebody. @@ -39,6 +40,7 @@ export default function ShareDeckPackModal({ }) { const [copied, setCopied] = useState(false); const inputRef = useRef(null); + const closeOnBackdrop = useBackdropClose(onClose); // Escape closes it. This one is a thing you glance at and leave, so it had // the key before the form windows took it from lib/hooks/useDialog. @@ -62,7 +64,7 @@ export default function ShareDeckPackModal({ }; return ( -
+
event.stopPropagation()}>
Share diff --git a/web/src/components/community/backdropClose.test.tsx b/web/src/components/community/backdropClose.test.tsx new file mode 100644 index 000000000..c5d00fa17 --- /dev/null +++ b/web/src/components/community/backdropClose.test.tsx @@ -0,0 +1,202 @@ +// The community windows closed on any click that reached their backdrop. Drag +// across the text in a card and let go outside, and Chromium sends the click +// to the backdrop, the element the press and the release share. Edit details +// closed that way under a half-written description, and opened again empty. +// Pattern Lab's windows have read where the press began since +// useBackdropClose, and these twelve now do too. +// +// Each case opens its window the way its page does, presses in the card's +// first text field (or on the card, where it has none) and lets go on the +// backdrop. + +import { useState, type ReactNode } from "react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import DeckDetailClient, { type DeckView } from "@/app/community/d/[id]/DeckDetailClient"; +import AddHeaderModal from "./AddHeaderModal"; +import AddPerformanceModal from "./AddPerformanceModal"; +import AuthModal from "./AuthModal"; +import DeckPanel from "./DeckPanel"; +import EditDetailsModal from "./EditDetailsModal"; +import PublishModal from "./PublishModal"; +import ReportModal from "./ReportModal"; +import SendModuleModal from "./SendModuleModal"; +import ShareDeckModal from "./ShareDeckModal"; +import ShareDeckPackModal from "./ShareDeckPackModal"; + +vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock("@/lib/community/auth-client", () => ({ + authClient: { + useSession: () => ({ data: { user: { id: "u1", name: "Tester" } }, isPending: false }), + }, + dummyEmailFor: (name: string) => `${name}@example.invalid`, +})); +vi.mock("@/lib/posthogEvents", () => ({ captureEvent: vi.fn() })); + +beforeEach(() => { + // The deck chip only shows where builds are on, as on the Pi. + vi.stubEnv("NEXT_PUBLIC_BUILD_ENABLED", "1"); + // Send starts its build as it opens. Nothing here waits for the answer. + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => {})), + ); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + window.localStorage.clear(); +}); + +function Page({ dialog }: { dialog: (close: () => void) => ReactNode }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open && dialog(() => setOpen(false))} + + ); +} + +const opened = (dialog: (close: () => void) => ReactNode) => () => { + render(); + fireEvent.click(screen.getByText("Open")); +}; + +const DECK: DeckView = { + id: "d1", + title: "Night set", + description: null, + visibility: "public", + hiddenAt: null, + performanceJson: null, + createdAt: "2026-10-01T00:00:00.000Z", + username: "tester", + displayUsername: "Tester", +}; + +const onDeckPage = (button: string) => () => { + render(); + fireEvent.click(screen.getByRole("button", { name: button })); +}; + +const WINDOWS: [string, () => void][] = [ + [ + "Share to Community", + opened((close) => ( + + )), + ], + [ + "Edit details", + opened((close) => ( + + )), + ], + [ + "report", + opened((close) => ( + + )), + ], + [ + "Add firmware header", + opened((close) => ), + ], + [ + "Publish a performance", + opened((close) => ), + ], + [ + "Share deck", + opened((close) => ( + + )), + ], + [ + "Share this deck", + opened((close) => ( + true} + onDownload={() => {}} + onClose={close} + /> + )), + ], + ["sign-in", opened((close) => )], + [ + "Send to my Patternflow", + opened((close) => ), + ], + [ + "Deck", + () => { + render(); + fireEvent.click(screen.getByTitle(/^Your deck \(/)); + }, + ], + ["Edit deck details", onDeckPage("Edit details")], + ["Deck performance", onDeckPage("Attach performance")], +]; + +// Where a drag that ends outside begins: the first field, or the card. +function insideCard(backdrop: HTMLElement): Element { + const field = within(backdrop).queryAllByRole("textbox")[0]; + const card = backdrop.firstElementChild; + if (!card) throw new Error("the window has no card"); + return field ?? card; +} + +describe.each(WINDOWS)("the %s window", (_name, mount) => { + it("closes on a press that goes down and comes up on the backdrop", () => { + mount(); + const backdrop = screen.getByRole("dialog"); + fireEvent.pointerDown(backdrop); + fireEvent.click(backdrop); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("stays open when a drag from inside the card lets go on the backdrop", () => { + mount(); + const backdrop = screen.getByRole("dialog"); + fireEvent.pointerDown(insideCard(backdrop)); + fireEvent.click(backdrop); + expect(screen.getByRole("dialog")).toBe(backdrop); + }); +}); + +describe("community windows", () => { + it("Edit details keeps a description that a drag out of it let go outside", () => { + WINDOWS[1][1](); + const description = screen.getByRole("textbox", { name: "Description" }); + fireEvent.change(description, { target: { value: "Two waves that cross" } }); + fireEvent.pointerDown(description); + fireEvent.click(screen.getByRole("dialog")); + expect(screen.getByRole("textbox", { name: "Description" })).toHaveValue( + "Two waves that cross", + ); + }); +});