feat(FN-4225): add mobile double-tap to select graph task nodes
Adds touch double-tap detection to the node drag hook, enabling mobile-friendly interactions on the graph canvas, with coverage in the node drag and GraphTaskNode test suites. Fusion-Task-Id: FN-4225
This commit is contained in:
@@ -42,6 +42,7 @@ function createProps(task: Task) {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
@@ -230,6 +231,72 @@ describe("GraphTaskNode", () => {
|
||||
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
|
||||
});
|
||||
|
||||
it("touch double-tap opens task detail exactly once", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const props = createProps(createTask());
|
||||
render(<GraphTaskNode {...props} />);
|
||||
|
||||
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
vi.advanceTimersByTime(120);
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 24, clientY: 32 });
|
||||
|
||||
expect(props.onOpenDetail).toHaveBeenCalledTimes(1);
|
||||
expect(props.onOpenDetail).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-TEST" }));
|
||||
});
|
||||
|
||||
it("touch taps outside the double-tap window do not open task detail", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const props = createProps(createTask());
|
||||
render(<GraphTaskNode {...props} />);
|
||||
|
||||
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
vi.advanceTimersByTime(320);
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
|
||||
expect(props.onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("touch drag gestures do not open task detail on pointer up", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const props = createProps(createTask());
|
||||
render(<GraphTaskNode {...props} isSelected={true} />);
|
||||
|
||||
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
vi.advanceTimersByTime(120);
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 20, clientY: 30 });
|
||||
fireEvent.pointerMove(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "touch", clientX: 28, clientY: 30 });
|
||||
|
||||
expect(props.onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("mouse pointer taps do not trigger the touch double-tap path", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const props = createProps(createTask());
|
||||
render(<GraphTaskNode {...props} />);
|
||||
|
||||
const node = screen.getByTestId("graph-task-node-FN-TEST");
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 1, pointerType: "mouse", clientX: 20, clientY: 30 });
|
||||
vi.advanceTimersByTime(120);
|
||||
fireEvent.pointerDown(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 });
|
||||
fireEvent.pointerUp(node, { isPrimary: true, pointerId: 2, pointerType: "mouse", clientX: 22, clientY: 30 });
|
||||
|
||||
expect(props.onOpenDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("single click on active indicator surface does not open task detail", () => {
|
||||
const props = createProps(createTask({ column: "in-progress", status: "executing" }));
|
||||
const { container } = render(<GraphTaskNode {...props} />);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __internal, useNodeDrag } from "../hooks/useNodeDrag";
|
||||
|
||||
function pointerEvent(overrides: Partial<PointerEvent> = {}) {
|
||||
@@ -12,15 +12,23 @@ function pointerEvent(overrides: Partial<PointerEvent> = {}) {
|
||||
return {
|
||||
isPrimary: true,
|
||||
pointerId: 1,
|
||||
pointerType: "mouse",
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
timeStamp: 0,
|
||||
defaultPrevented: false,
|
||||
stopPropagation: vi.fn(),
|
||||
preventDefault: vi.fn(),
|
||||
currentTarget: target,
|
||||
...overrides,
|
||||
} as unknown as React.PointerEvent<HTMLElement>;
|
||||
}
|
||||
|
||||
describe("useNodeDrag", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("transitions pending to dragging and back on pointer up", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const onDragStateChange = vi.fn();
|
||||
@@ -79,16 +87,99 @@ describe("useNodeDrag", () => {
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores pointer interactions when dragging is disabled", () => {
|
||||
it("ignores position updates when dragging is disabled", () => {
|
||||
const onPositionChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent()));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ clientX: 8, clientY: 0 })));
|
||||
act(() => result.current.onPointerDown(pointerEvent({ pointerType: "touch", timeStamp: 100 })));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ pointerType: "touch", clientX: 8, clientY: 0, timeStamp: 120 })));
|
||||
|
||||
expect(result.current.isDragging).toBe(false);
|
||||
expect(onPositionChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires onDoubleTap for qualifying touch taps and suppresses the follow-up click", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const onDoubleTap = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||
);
|
||||
|
||||
const firstDown = pointerEvent({ pointerId: 1, pointerType: "touch", clientX: 12, clientY: 16 });
|
||||
const firstUp = pointerEvent({ pointerId: 1, pointerType: "touch", clientX: 12, clientY: 16, currentTarget: firstDown.currentTarget });
|
||||
const secondDown = pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 18, clientY: 20 });
|
||||
const secondUp = pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 18, clientY: 20, currentTarget: secondDown.currentTarget });
|
||||
|
||||
act(() => result.current.onPointerDown(firstDown));
|
||||
act(() => result.current.onPointerUp(firstUp));
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
act(() => result.current.onPointerDown(secondDown));
|
||||
act(() => result.current.onPointerUp(secondUp));
|
||||
|
||||
expect(onDoubleTap).toHaveBeenCalledTimes(1);
|
||||
expect(secondUp.preventDefault).toHaveBeenCalledTimes(1);
|
||||
|
||||
const clickEvent = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
} as unknown as React.MouseEvent<HTMLElement>;
|
||||
act(() => result.current.onClickCapture(clickEvent));
|
||||
expect(clickEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(clickEvent.stopPropagation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fire onDoubleTap when the second tap is too late", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const onDoubleTap = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 1, pointerType: "touch" })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 1, pointerType: "touch" })));
|
||||
act(() => vi.advanceTimersByTime(320));
|
||||
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 2, pointerType: "touch" })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 2, pointerType: "touch" })));
|
||||
|
||||
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire onDoubleTap for mouse pointer events", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const onDoubleTap = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 1, pointerType: "mouse" })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 1, pointerType: "mouse" })));
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 2, pointerType: "mouse" })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 2, pointerType: "mouse" })));
|
||||
|
||||
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fire onDoubleTap after movement reaches the drag threshold", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const onDoubleTap = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useNodeDrag({ taskId: "A", position: { x: 0, y: 0 }, scale: 1, canDrag: false, onPositionChange: vi.fn(), onDoubleTap }),
|
||||
);
|
||||
|
||||
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 1, pointerType: "touch", clientX: 0, clientY: 0 })));
|
||||
act(() => result.current.onPointerMove(pointerEvent({ pointerId: 1, pointerType: "touch", clientX: __internal.DRAG_THRESHOLD_PX, clientY: 0 })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 1, pointerType: "touch", clientX: __internal.DRAG_THRESHOLD_PX, clientY: 0 })));
|
||||
act(() => vi.advanceTimersByTime(120));
|
||||
act(() => result.current.onPointerDown(pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 0, clientY: 0 })));
|
||||
act(() => result.current.onPointerUp(pointerEvent({ pointerId: 2, pointerType: "touch", clientX: 0, clientY: 0 })));
|
||||
|
||||
expect(onDoubleTap).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user