From 94d6ae4049ab459a2e39cb60a0d23b920b87cf1c Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 26 Mar 2026 16:45:01 -0500 Subject: [PATCH] Fix inbox swipe-to-archive click-through Co-Authored-By: Paperclip --- ui/src/components/SwipeToArchive.test.tsx | 125 ++++++++++++++++++++++ ui/src/components/SwipeToArchive.tsx | 9 ++ 2 files changed, 134 insertions(+) create mode 100644 ui/src/components/SwipeToArchive.test.tsx diff --git a/ui/src/components/SwipeToArchive.test.tsx b/ui/src/components/SwipeToArchive.test.tsx new file mode 100644 index 00000000..06867336 --- /dev/null +++ b/ui/src/components/SwipeToArchive.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SwipeToArchive } from "./SwipeToArchive"; + +// Tell React this environment uses act() for event flushing. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function dispatchTouchEvent( + node: Element, + type: "touchstart" | "touchmove" | "touchend", + coords: { x: number; y: number }, +) { + const event = new Event(type, { bubbles: true, cancelable: true }); + const touchPoint = { clientX: coords.x, clientY: coords.y }; + + Object.defineProperty(event, "touches", { + configurable: true, + value: type === "touchend" ? [] : [touchPoint], + }); + Object.defineProperty(event, "changedTouches", { + configurable: true, + value: [touchPoint], + }); + + node.dispatchEvent(event); +} + +describe("SwipeToArchive", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + container.remove(); + }); + + it("suppresses descendant clicks after a horizontal swipe and archives the row", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const wrapper = container.firstElementChild as HTMLDivElement; + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + Object.defineProperty(wrapper, "offsetWidth", { configurable: true, value: 200 }); + Object.defineProperty(wrapper, "offsetHeight", { configurable: true, value: 48 }); + + act(() => { + dispatchTouchEvent(wrapper, "touchstart", { x: 180, y: 20 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchmove", { x: 80, y: 22 }); + }); + act(() => { + dispatchTouchEvent(wrapper, "touchend", { x: 80, y: 22 }); + }); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(210); + }); + + expect(onArchive).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it("does not suppress a normal tap click", () => { + const onArchive = vi.fn(); + const onClick = vi.fn(); + const root = createRoot(container); + + act(() => { + root.render( + + + , + ); + }); + + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + act(() => { + button!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onArchive).not.toHaveBeenCalled(); + + act(() => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/SwipeToArchive.tsx b/ui/src/components/SwipeToArchive.tsx index 17ea3707..141d439c 100644 --- a/ui/src/components/SwipeToArchive.tsx +++ b/ui/src/components/SwipeToArchive.tsx @@ -23,6 +23,7 @@ export function SwipeToArchive({ const startPointRef = useRef<{ x: number; y: number } | null>(null); const widthRef = useRef(0); const timeoutRef = useRef(null); + const suppressClickRef = useRef(false); const [offsetX, setOffsetX] = useState(0); const [isDragging, setIsDragging] = useState(false); const [isCollapsing, setIsCollapsing] = useState(false); @@ -68,6 +69,7 @@ export function SwipeToArchive({ widthRef.current = node?.offsetWidth ?? 0; setLockedHeight(node?.offsetHeight ?? null); setIsCollapsing(false); + suppressClickRef.current = false; startPointRef.current = { x: touch.clientX, y: touch.clientY }; }; @@ -86,6 +88,7 @@ export function SwipeToArchive({ startPointRef.current = null; return; } + suppressClickRef.current = true; } if (deltaX >= 0) { @@ -127,6 +130,12 @@ export function SwipeToArchive({ onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onTouchCancel={handleTouchEnd} + onClickCapture={(event) => { + if (!suppressClickRef.current) return; + event.preventDefault(); + event.stopPropagation(); + suppressClickRef.current = false; + }} >