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;
+ }}
>