Fix inbox swipe-to-archive click-through
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
6a72faf83b
commit
94d6ae4049
2 changed files with 134 additions and 0 deletions
125
ui/src/components/SwipeToArchive.test.tsx
Normal file
125
ui/src/components/SwipeToArchive.test.tsx
Normal file
|
|
@ -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(
|
||||||
|
<SwipeToArchive onArchive={onArchive}>
|
||||||
|
<button type="button" onClick={onClick}>
|
||||||
|
Open issue
|
||||||
|
</button>
|
||||||
|
</SwipeToArchive>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
<SwipeToArchive onArchive={onArchive}>
|
||||||
|
<button type="button" onClick={onClick}>
|
||||||
|
Open issue
|
||||||
|
</button>
|
||||||
|
</SwipeToArchive>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -23,6 +23,7 @@ export function SwipeToArchive({
|
||||||
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
const startPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const widthRef = useRef(0);
|
const widthRef = useRef(0);
|
||||||
const timeoutRef = useRef<number | null>(null);
|
const timeoutRef = useRef<number | null>(null);
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
const [offsetX, setOffsetX] = useState(0);
|
const [offsetX, setOffsetX] = useState(0);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isCollapsing, setIsCollapsing] = useState(false);
|
const [isCollapsing, setIsCollapsing] = useState(false);
|
||||||
|
|
@ -68,6 +69,7 @@ export function SwipeToArchive({
|
||||||
widthRef.current = node?.offsetWidth ?? 0;
|
widthRef.current = node?.offsetWidth ?? 0;
|
||||||
setLockedHeight(node?.offsetHeight ?? null);
|
setLockedHeight(node?.offsetHeight ?? null);
|
||||||
setIsCollapsing(false);
|
setIsCollapsing(false);
|
||||||
|
suppressClickRef.current = false;
|
||||||
startPointRef.current = { x: touch.clientX, y: touch.clientY };
|
startPointRef.current = { x: touch.clientX, y: touch.clientY };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -86,6 +88,7 @@ export function SwipeToArchive({
|
||||||
startPointRef.current = null;
|
startPointRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
suppressClickRef.current = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deltaX >= 0) {
|
if (deltaX >= 0) {
|
||||||
|
|
@ -127,6 +130,12 @@ export function SwipeToArchive({
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
onTouchCancel={handleTouchEnd}
|
onTouchCancel={handleTouchEnd}
|
||||||
|
onClickCapture={(event) => {
|
||||||
|
if (!suppressClickRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue