Fix Kanban drag jitter: use DragOverlay + ghost placeholder

Root causes of the jitter:
1. No DragOverlay — card transformed in-place via translate(), causing
   parent column layout reflow as siblings shifted around the gap.
2. transition-all on cards fought with drag transforms on slow moves.
3. closestCorners collision bounced rapidly between column boundaries.

Fixes:
- DragOverlay renders a floating copy of the card above everything,
  with a subtle 2deg rotation and shadow for visual feedback.
- Original card becomes a ghost placeholder (accent border, 40% opacity)
  so the column layout stays stable during drag.
- Switched to closestCenter collision detection (less boundary bounce).
- Increased PointerSensor distance from 5px to 8px to reduce accidental
  drag activation.
- Removed transition-all from card styles (no more CSS vs drag fight).
- dropAnimation: null for instant snap on release.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-17 04:37:56 +08:00
parent 7eac213c20
commit e0a5f4855f

View File

@ -1,226 +1,262 @@
import { import { useState, useCallback } from 'react';
DndContext, import {
closestCorners, DndContext,
PointerSensor, closestCenter,
TouchSensor, PointerSensor,
useSensor, TouchSensor,
useSensors, useSensor,
type DragEndEvent, useSensors,
useDroppable, type DragStartEvent,
useDraggable, type DragEndEvent,
} from '@dnd-kit/core'; useDroppable,
import { format, parseISO } from 'date-fns'; useDraggable,
import type { ProjectTask } from '@/types'; DragOverlay,
import { Badge } from '@/components/ui/badge'; } from '@dnd-kit/core';
import { AssigneeAvatars } from './AssignmentPicker'; import { format, parseISO } from 'date-fns';
import type { ProjectTask } from '@/types';
const COLUMNS: { id: string; label: string; color: string }[] = [ import { Badge } from '@/components/ui/badge';
{ id: 'pending', label: 'Pending', color: 'text-gray-400' }, import { AssigneeAvatars } from './AssignmentPicker';
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' }, const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' }, { id: 'pending', label: 'Pending', color: 'text-gray-400' },
{ id: 'review', label: 'Review', color: 'text-yellow-400' }, { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
{ id: 'completed', label: 'Completed', color: 'text-green-400' }, { id: 'blocked', label: 'Blocked', color: 'text-red-400' },
]; { id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
{ id: 'review', label: 'Review', color: 'text-yellow-400' },
const priorityColors: Record<string, string> = { { id: 'completed', label: 'Completed', color: 'text-green-400' },
none: 'bg-gray-500/20 text-gray-400', ];
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400', const priorityColors: Record<string, string> = {
high: 'bg-red-500/20 text-red-400', none: 'bg-gray-500/20 text-gray-400',
}; low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
interface KanbanBoardProps { high: 'bg-red-500/20 text-red-400',
tasks: ProjectTask[]; };
selectedTaskId: number | null;
kanbanParentTask?: ProjectTask | null; interface KanbanBoardProps {
onSelectTask: (taskId: number) => void; tasks: ProjectTask[];
onStatusChange: (taskId: number, status: string) => void; selectedTaskId: number | null;
onBackToAllTasks?: () => void; kanbanParentTask?: ProjectTask | null;
} onSelectTask: (taskId: number) => void;
onStatusChange: (taskId: number, status: string) => void;
function KanbanColumn({ onBackToAllTasks?: () => void;
column, }
tasks,
selectedTaskId, function KanbanColumn({
onSelectTask, column,
}: { tasks,
column: (typeof COLUMNS)[0]; selectedTaskId,
tasks: ProjectTask[]; draggingId,
selectedTaskId: number | null; onSelectTask,
onSelectTask: (taskId: number) => void; }: {
}) { column: (typeof COLUMNS)[0];
const { setNodeRef, isOver } = useDroppable({ id: column.id }); tasks: ProjectTask[];
selectedTaskId: number | null;
return ( draggingId: number | null;
<div onSelectTask: (taskId: number) => void;
ref={setNodeRef} }) {
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${ const { setNodeRef, isOver } = useDroppable({ id: column.id });
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
}`} return (
> <div
{/* Column header */} ref={setNodeRef}
<div className="px-3 py-2.5 border-b border-border"> className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
<div className="flex items-center justify-between"> isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
<span className={`text-sm font-semibold font-heading ${column.color}`}> }`}
{column.label} >
</span> {/* Column header */}
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5"> <div className="px-3 py-2.5 border-b border-border">
{tasks.length} <div className="flex items-center justify-between">
</span> <span className={`text-sm font-semibold font-heading ${column.color}`}>
</div> {column.label}
</div> </span>
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
{/* Cards */} {tasks.length}
<div className="p-2 space-y-2 min-h-[100px]"> </span>
{tasks.map((task) => ( </div>
<KanbanCard </div>
key={task.id}
task={task} {/* Cards */}
isSelected={selectedTaskId === task.id} <div className="p-2 space-y-2 min-h-[100px]">
onSelect={() => onSelectTask(task.id)} {tasks.map((task) => (
/> <KanbanCard
))} key={task.id}
</div> task={task}
</div> isSelected={selectedTaskId === task.id}
); isDragSource={draggingId === task.id}
} onSelect={() => onSelectTask(task.id)}
/>
function KanbanCard({ ))}
task, </div>
isSelected, </div>
onSelect, );
}: { }
task: ProjectTask;
isSelected: boolean; // Card content — shared between in-place card and drag overlay
onSelect: () => void; function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) {
}) { const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ const totalSubtasks = task.subtasks?.length ?? 0;
id: task.id,
data: { task }, return (
}); <div
className={`rounded-md border p-3 ${
const style = transform ghost
? { ? 'border-accent/20 bg-accent/5 opacity-40'
transform: `translate(${transform.x}px, ${transform.y}px)`, : isSelected
opacity: isDragging ? 0.5 : 1, ? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
} : 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
: undefined; }`}
>
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; <p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
const totalSubtasks = task.subtasks?.length ?? 0; <div className="flex items-center gap-1.5 flex-wrap">
<Badge
return ( className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
<div >
ref={setNodeRef} {task.priority}
style={style} </Badge>
{...listeners} {task.due_date && (
{...attributes} <span className="text-[11px] text-muted-foreground tabular-nums">
onClick={onSelect} {format(parseISO(task.due_date), 'MMM d')}
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${ </span>
isSelected )}
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10' {totalSubtasks > 0 && (
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20' <span className="text-[11px] text-muted-foreground tabular-nums">
}`} {completedSubtasks}/{totalSubtasks}
> </span>
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p> )}
<div className="flex items-center gap-1.5 flex-wrap"> </div>
<Badge {task.assignments && task.assignments.length > 0 && (
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`} <div className="flex justify-end mt-2">
> <AssigneeAvatars assignments={task.assignments} max={2} />
{task.priority} </div>
</Badge> )}
{task.due_date && ( </div>
<span className="text-[11px] text-muted-foreground tabular-nums"> );
{format(parseISO(task.due_date), 'MMM d')} }
</span>
)} function KanbanCard({
{totalSubtasks > 0 && ( task,
<span className="text-[11px] text-muted-foreground tabular-nums"> isSelected,
{completedSubtasks}/{totalSubtasks} isDragSource,
</span> onSelect,
)} }: {
</div> task: ProjectTask;
{/* Assignee avatars */} isSelected: boolean;
{task.assignments && task.assignments.length > 0 && ( isDragSource: boolean;
<div className="flex justify-end mt-2"> onSelect: () => void;
<AssigneeAvatars assignments={task.assignments} max={2} /> }) {
</div> const { attributes, listeners, setNodeRef } = useDraggable({
)} id: task.id,
</div> data: { task },
); });
}
return (
export default function KanbanBoard({ <div
tasks, ref={setNodeRef}
selectedTaskId, {...listeners}
kanbanParentTask, {...attributes}
onSelectTask, onClick={onSelect}
onStatusChange, className="cursor-pointer"
onBackToAllTasks, >
}: KanbanBoardProps) { <CardContent task={task} isSelected={isSelected} ghost={isDragSource} />
const sensors = useSensors( </div>
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , );
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) }
);
export default function KanbanBoard({
// Subtask view is driven by kanbanParentTask (decoupled from selected task) tasks,
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; selectedTaskId,
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; kanbanParentTask,
onSelectTask,
const handleDragEnd = (event: DragEndEvent) => { onStatusChange,
const { active, over } = event; onBackToAllTasks,
if (!over) return; }: KanbanBoardProps) {
const [draggingId, setDraggingId] = useState<number | null>(null);
const taskId = active.id as number;
const newStatus = over.id as string; const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
const task = activeTasks.find((t) => t.id === taskId); useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } })
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { );
onStatusChange(taskId, newStatus);
} const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
}; const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
const tasksByStatus = COLUMNS.map((col) => ({ const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null;
column: col,
tasks: activeTasks.filter((t) => t.status === col.id), const handleDragStart = useCallback((event: DragStartEvent) => {
})); setDraggingId(event.active.id as number);
}, []);
return (
<div className="flex flex-col gap-3"> const handleDragEnd = useCallback((event: DragEndEvent) => {
{/* Subtask view header */} setDraggingId(null);
{isSubtaskView && kanbanParentTask && ( const { active, over } = event;
<div className="flex items-center gap-3 px-1"> if (!over) return;
<button
onClick={onBackToAllTasks} const taskId = active.id as number;
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" const newStatus = over.id as string;
>
Back to all tasks const task = activeTasks.find((t) => t.id === taskId);
</button> if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
<span className="text-muted-foreground text-xs">/</span> onStatusChange(taskId, newStatus);
<span className="text-xs text-foreground font-medium"> }
Subtasks of: {kanbanParentTask.title} }, [activeTasks, onStatusChange]);
</span>
</div> const handleDragCancel = useCallback(() => {
)} setDraggingId(null);
}, []);
<DndContext
sensors={sensors} const tasksByStatus = COLUMNS.map((col) => ({
collisionDetection={closestCorners} column: col,
onDragEnd={handleDragEnd} tasks: activeTasks.filter((t) => t.status === col.id),
> }));
<div className="flex gap-3 overflow-x-auto pb-2">
{tasksByStatus.map(({ column, tasks: colTasks }) => ( return (
<KanbanColumn <div className="flex flex-col gap-3">
key={column.id} {/* Subtask view header */}
column={column} {isSubtaskView && kanbanParentTask && (
tasks={colTasks} <div className="flex items-center gap-3 px-1">
selectedTaskId={selectedTaskId} <button
onSelectTask={onSelectTask} onClick={onBackToAllTasks}
/> className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
))} >
</div> Back to all tasks
</DndContext> </button>
</div> <span className="text-muted-foreground text-xs">/</span>
); <span className="text-xs text-foreground font-medium">
} Subtasks of: {kanbanParentTask.title}
</span>
</div>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="flex gap-3 overflow-x-auto pb-2">
{tasksByStatus.map(({ column, tasks: colTasks }) => (
<KanbanColumn
key={column.id}
column={column}
tasks={colTasks}
selectedTaskId={selectedTaskId}
draggingId={draggingId}
onSelectTask={onSelectTask}
/>
))}
</div>
{/* Floating overlay — renders above everything, no layout impact */}
<DragOverlay dropAnimation={null}>
{draggingTask ? (
<div className="w-[200px] opacity-95 shadow-lg shadow-black/30 rotate-[2deg]">
<CardContent task={draggingTask} isSelected={false} />
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
);
}