fix: reliable mobile board horizontal swipe over task cards
Swiping across the board to scroll horizontally was intermittent when the gesture started on a task card. Cards render with native HTML5 `draggable` for desktop drag-to-move, but native DnD does not function via touch — the attribute only arms the browser's touch-drag heuristic, which non- deterministically hijacks horizontal swipes meant to pan the board. Disable native drag on touch-primary devices (`(hover: none) and (pointer: coarse)`) via a new `useCoarsePointer` hook, folded into the card's `isDraggable`. Mouse/desktop and hybrid laptops keep drag-to-move; touch loses nothing (DnD never worked there) and panning is now reliable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-mobile-card-swipe-scroll.md
Normal file
5
.changeset/fix-mobile-card-swipe-scroll.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fixed unreliable horizontal scrolling when swiping across task cards on the mobile board. Native HTML5 drag is now disabled on touch-primary devices (where it never worked anyway), so the browser no longer hijacks swipe-to-scroll gestures that start on a card.
|
||||
@@ -18,6 +18,7 @@ import { PrCreateModal } from "./PrCreateModal";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { PluginSlot } from "./PluginSlot";
|
||||
import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket";
|
||||
import { useCoarsePointer } from "../hooks/useCoarsePointer";
|
||||
import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
|
||||
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
|
||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||
@@ -983,7 +984,12 @@ function TaskCardComponent({
|
||||
const isAwaitingInput = task.status === "awaiting-user-input";
|
||||
const isArchived = task.column === "archived";
|
||||
const isAgentActive = !globalPaused && !queued && !isFailed && !isPaused && !isStuck && !isAwaitingApproval && !isAwaitingInput && (task.column === "in-progress" || ACTIVE_STATUSES.has(visualStatus as string));
|
||||
const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived; // Disable drag during edit/archived or host embedding
|
||||
// Native HTML5 drag is desktop-mouse only — it doesn't move cards via touch.
|
||||
// On touch-primary devices the `draggable` attribute still arms the browser's
|
||||
// touch-drag heuristic, which intermittently hijacks horizontal swipes meant
|
||||
// to scroll the board. Drop drag on coarse pointers so panning stays reliable.
|
||||
const isCoarsePointer = useCoarsePointer();
|
||||
const isDraggable = !disableDrag && !queued && !isPaused && !isEditing && !isArchived && !isCoarsePointer; // Disable drag during edit/archived, host embedding, or touch
|
||||
|
||||
// Check if this card can be edited inline
|
||||
const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask;
|
||||
|
||||
@@ -607,6 +607,33 @@ describe("TaskCard", () => {
|
||||
expect(card.getAttribute("draggable")).toBe("false");
|
||||
});
|
||||
|
||||
// FN-6389 follow-up: native HTML5 drag is desktop-mouse only and doesn't move
|
||||
// cards via touch, but a `draggable` element still arms the browser's touch-drag
|
||||
// heuristic, which intermittently hijacks horizontal swipes meant to scroll the
|
||||
// mobile board. On touch-primary (coarse pointer) devices we drop `draggable`.
|
||||
it("disables native card dragging on touch-primary (coarse pointer) devices", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(hover: none) and (pointer: coarse)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
try {
|
||||
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
|
||||
const card = container.querySelector(".card") as HTMLElement;
|
||||
expect(card.getAttribute("draggable")).toBe("false");
|
||||
// No drag-start handler should be wired on touch (would arm the heuristic).
|
||||
const dragStart = new Event("dragstart", { bubbles: true, cancelable: true });
|
||||
const prevented = !card.dispatchEvent(dragStart);
|
||||
expect(prevented).toBe(false);
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("renders Nx PR badge label when multiple PRs are linked", () => {
|
||||
render(
|
||||
<TaskCard
|
||||
|
||||
32
packages/dashboard/app/hooks/useCoarsePointer.ts
Normal file
32
packages/dashboard/app/hooks/useCoarsePointer.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Touch-primary devices (phones/tablets, Android WebViews). A hybrid laptop
|
||||
// with a trackpad/mouse reports `(hover: hover)` and is intentionally excluded
|
||||
// so mouse drag-to-move keeps working there.
|
||||
export const COARSE_POINTER_MEDIA_QUERY = "(hover: none) and (pointer: coarse)";
|
||||
|
||||
export function isCoarsePointer(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia(COARSE_POINTER_MEDIA_QUERY).matches;
|
||||
}
|
||||
|
||||
// Whether the primary pointer is coarse (touch). Native HTML5 drag-and-drop is
|
||||
// non-functional via touch, yet a `draggable` element still arms the browser's
|
||||
// touch-drag heuristic and can hijack a horizontal swipe meant to scroll the
|
||||
// board. Components use this to drop `draggable` on touch so panning is reliable.
|
||||
export function useCoarsePointer(): boolean {
|
||||
const [coarse, setCoarse] = useState<boolean>(isCoarsePointer);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
|
||||
const query = window.matchMedia(COARSE_POINTER_MEDIA_QUERY);
|
||||
const update = () => setCoarse(query.matches);
|
||||
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return coarse;
|
||||
}
|
||||
Reference in New Issue
Block a user