feat(KB-149): add mobile touch gesture support to TaskCard

- Implement touch gesture detection for swipe and tap interactions in TaskCard
- Add comprehensive tests for touch event handling (209 lines of test coverage)
- Include changeset for mobile touch sensitivity fix
- Enable smooth card interactions on mobile devices
This commit is contained in:
gsxdsm
2026-03-30 11:40:09 -07:00
parent 7b18926a7b
commit ba69864707
3 changed files with 265 additions and 2 deletions

View File

@@ -135,6 +135,10 @@ function TaskCardComponent({
const cardRef = useRef<HTMLDivElement>(null);
const [isInViewport, setIsInViewport] = useState(false);
// Touch gesture detection refs
const touchStartPosRef = useRef<{ x: number; y: number; time: number } | null>(null);
const hasTouchMovedRef = useRef(false);
const isInteractiveTarget = useCallback((target: EventTarget | null): boolean => {
if (!(target instanceof HTMLElement)) return false;
return !!target.closest("button, a, input, textarea, select, label, [role='button']");
@@ -239,10 +243,48 @@ function TaskCardComponent({
void handleClick();
}, [handleClick, isInteractiveTarget]);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
touchStartPosRef.current = { x: touch.clientX, y: touch.clientY, time: Date.now() };
hasTouchMovedRef.current = false;
}, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!touchStartPosRef.current) return;
const touch = e.touches[0];
if (!touch) return;
const dx = Math.abs(touch.clientX - touchStartPosRef.current.x);
const dy = Math.abs(touch.clientY - touchStartPosRef.current.y);
// If moved beyond threshold, mark as moved (scrolling/dragging)
if (dx > TOUCH_MOVE_THRESHOLD || dy > TOUCH_MOVE_THRESHOLD) {
hasTouchMovedRef.current = true;
}
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (isInteractiveTarget(e.target)) return;
touchOpenHandledRef.current = true;
void handleClick();
// Check if this was a valid tap (not a scroll)
if (!touchStartPosRef.current) return;
const touchDuration = Date.now() - touchStartPosRef.current.time;
const isQuickTap = touchDuration < TOUCH_TAP_MAX_DURATION;
const isStationary = !hasTouchMovedRef.current;
// Only open modal for quick taps that didn't move significantly
if (isQuickTap && isStationary) {
touchOpenHandledRef.current = true;
void handleClick();
}
// Reset touch tracking
touchStartPosRef.current = null;
hasTouchMovedRef.current = false;
}, [handleClick, isInteractiveTarget]);
const handleDepClick = useCallback(async (e: React.MouseEvent, depId: string) => {
@@ -450,6 +492,8 @@ function TaskCardComponent({
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
onClick={handleCardClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onDoubleClick={handleDoubleClick}
>
@@ -604,5 +648,8 @@ function TaskCardComponent({
);
}
const TOUCH_MOVE_THRESHOLD = 10; // pixels
const TOUCH_TAP_MAX_DURATION = 300; // milliseconds
export const TaskCard = memo(TaskCardComponent, areTaskCardPropsEqual);
TaskCard.displayName = "TaskCard";

View File

@@ -2008,3 +2008,212 @@ describe("TaskCard GitHub badges", () => {
}
});
});
/**
* Tests for touch gesture handling in TaskCard.
* Ensures that scrolling/dragging does not accidentally open the modal,
* while intentional taps still work correctly.
*/
describe("TaskCard touch gesture handling", () => {
const noopToast = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("opens modal on quick tap without movement", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "KB-099" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
const onOpenDetail = vi.fn();
const task = makeTask();
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const card = document.querySelector('[data-id="KB-099"]');
expect(card).toBeDefined();
// Simulate a quick tap: touchStart, then touchEnd without touchMove
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
// Small delay but still within tap duration
await new Promise((resolve) => setTimeout(resolve, 50));
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 100, clientY: 100 }],
target: card,
});
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("KB-099");
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
});
});
it("does NOT open modal when touch moves beyond threshold (scrolling)", async () => {
const onOpenDetail = vi.fn();
const task = makeTask();
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const card = document.querySelector('[data-id="KB-099"]');
expect(card).toBeDefined();
// Simulate scrolling: touchStart, touchMove with significant movement, then touchEnd
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 120, clientY: 120 }], // 20px movement (> 10px threshold)
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 120, clientY: 120 }],
target: card,
});
// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
// Modal should NOT have opened
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("does NOT open modal on long press (slow touch)", async () => {
const onOpenDetail = vi.fn();
const task = makeTask();
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const card = document.querySelector('[data-id="KB-099"]');
expect(card).toBeDefined();
// Simulate long press: touchStart, wait > 300ms, then touchEnd
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
// Wait longer than tap threshold (300ms)
await new Promise((resolve) => setTimeout(resolve, 350));
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 100, clientY: 100 }],
target: card,
});
// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
// Modal should NOT have opened
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("does NOT open modal when touch starts on interactive element (button)", async () => {
const onOpenDetail = vi.fn();
const onArchiveTask = vi.fn().mockResolvedValue(makeTask());
const task = makeTask({ column: "done" });
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
onArchiveTask={onArchiveTask}
/>
);
// Find the archive button (an interactive element)
const archiveButton = screen.getByRole("button", { name: /Archive task/i });
expect(archiveButton).toBeDefined();
// Simulate touch on the button
fireEvent.touchStart(archiveButton, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchEnd(archiveButton, {
changedTouches: [{ clientX: 100, clientY: 100 }],
target: archiveButton,
});
// Wait for any async operations
await new Promise((resolve) => setTimeout(resolve, 50));
// Modal should NOT have opened (button click should be handled separately)
expect(onOpenDetail).not.toHaveBeenCalled();
});
it("opens modal for tap with minimal movement within threshold", async () => {
const { fetchTaskDetail } = await import("../../api");
const mockFetch = vi.mocked(fetchTaskDetail);
const mockDetail: TaskDetail = {
...makeTask({ id: "KB-099" }),
prompt: "",
attachments: [],
};
mockFetch.mockResolvedValueOnce(mockDetail);
const onOpenDetail = vi.fn();
const task = makeTask();
render(
<TaskCard
task={task}
onOpenDetail={onOpenDetail}
addToast={noopToast}
/>
);
const card = document.querySelector('[data-id="KB-099"]');
expect(card).toBeDefined();
// Simulate tap with small movement (5px, under 10px threshold)
fireEvent.touchStart(card!, {
touches: [{ clientX: 100, clientY: 100 }],
});
fireEvent.touchMove(card!, {
touches: [{ clientX: 105, clientY: 105 }], // 5px movement (< 10px threshold)
});
fireEvent.touchEnd(card!, {
changedTouches: [{ clientX: 105, clientY: 105 }],
target: card,
});
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith("KB-099");
expect(onOpenDetail).toHaveBeenCalledWith(mockDetail);
});
});
});