fix: cross-column drag-and-drop + suppress tooltip during drag

This commit is contained in:
Brad Groux 2026-01-29 06:31:07 -06:00
parent 0c0f5b344d
commit 3cbcb20ecd
4 changed files with 101 additions and 20 deletions

View file

@ -5,7 +5,7 @@ import { BoardLoadingSkeleton } from './BoardLoadingSkeleton';
import { TaskDetailPanel } from '@/components/task/TaskDetailPanel';
import type { TaskStatus, Task } from '@veritas-kanban/shared';
import { useFeatureSettings } from '@/hooks/useFeatureSettings';
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import { DndContext, DragOverlay } from '@dnd-kit/core';
import { useState, useEffect, useCallback, useMemo, lazy, Suspense } from 'react';
import { TaskCard } from '@/components/task/TaskCard';
import { useKeyboard } from '@/hooks/useKeyboard';
@ -99,7 +99,15 @@ export function KanbanBoard() {
setOnMoveTask(handleMoveTask);
// Drag and drop logic
const { activeTask, sensors, handleDragStart, handleDragOver, handleDragEnd } = useBoardDragDrop({
const {
activeTask,
isDragActive,
sensors,
collisionDetection,
handleDragStart,
handleDragOver,
handleDragEnd,
} = useBoardDragDrop({
tasks: filteredTasks,
tasksByStatus,
columns: COLUMNS,
@ -152,7 +160,7 @@ export function KanbanBoard() {
{featureSettings.board.enableDragAndDrop ? (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
@ -167,6 +175,7 @@ export function KanbanBoard() {
allTasks={filteredTasks}
onTaskClick={handleTaskClick}
selectedTaskId={selectedTaskId}
isDragActive={isDragActive}
/>
))}
</div>

View file

@ -16,6 +16,7 @@ interface KanbanColumnProps {
allTasks: Task[];
onTaskClick?: (task: Task) => void;
selectedTaskId?: string | null;
isDragActive?: boolean;
}
const columnColors: Record<TaskStatus, string> = {
@ -32,6 +33,7 @@ export function KanbanColumn({
allTasks,
onTaskClick,
selectedTaskId,
isDragActive,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id });
const { settings: featureSettings } = useFeatureSettings();
@ -97,6 +99,7 @@ export function KanbanColumn({
isBlocked={blocked}
blockerTitles={blockers.map((b) => b.title)}
cardMetrics={taskMetrics}
isDragActive={isDragActive}
/>
</ErrorBoundary>
);

View file

@ -56,6 +56,7 @@ const blockedCategoryInfo: Record<
interface TaskCardProps {
task: Task;
isDragging?: boolean;
isDragActive?: boolean;
onClick?: () => void;
isSelected?: boolean;
isBlocked?: boolean;
@ -72,6 +73,7 @@ interface TaskCardProps {
function areTaskCardPropsEqual(prev: TaskCardProps, next: TaskCardProps): boolean {
// Simple scalar/boolean props
if (prev.isDragging !== next.isDragging) return false;
if (prev.isDragActive !== next.isDragActive) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isBlocked !== next.isBlocked) return false;
// onClick is intentionally skipped — always a new closure but functionally equivalent
@ -140,6 +142,7 @@ const priorityColors: Record<TaskPriority, string> = {
export const TaskCard = memo(function TaskCard({
task,
isDragging,
isDragActive,
onClick,
isSelected,
isBlocked,
@ -223,9 +226,12 @@ export const TaskCard = memo(function TaskCard({
};
}, [task.subtasks]);
// Suppress the outer card tooltip entirely during any drag operation
const suppressCardTooltip = isDragActive || isDragging || isCurrentlyDragging;
return (
<TooltipProvider>
<Tooltip delayDuration={500}>
<Tooltip delayDuration={500} open={suppressCardTooltip ? false : undefined}>
<TooltipTrigger asChild>
<div
ref={setNodeRef}

View file

@ -1,9 +1,12 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import {
CollisionDetection,
DragEndEvent,
DragOverEvent,
DragStartEvent,
PointerSensor,
pointerWithin,
rectIntersection,
useSensor,
useSensors,
} from '@dnd-kit/core';
@ -20,7 +23,9 @@ interface UseBoardDragDropOptions {
interface UseBoardDragDropReturn {
activeTask: Task | null;
isDragActive: boolean;
sensors: ReturnType<typeof useSensors>;
collisionDetection: CollisionDetection;
handleDragStart: (event: DragStartEvent) => void;
handleDragOver: (event: DragOverEvent) => void;
handleDragEnd: (event: DragEndEvent) => void;
@ -34,6 +39,7 @@ export function useBoardDragDrop({
onReorder,
}: UseBoardDragDropOptions): UseBoardDragDropReturn {
const [activeTask, setActiveTask] = useState<Task | null>(null);
const lastOverColumnRef = useRef<TaskStatus | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
@ -43,6 +49,41 @@ export function useBoardDragDrop({
})
);
const columnIds = columns.map((c) => c.id);
// Custom collision detection for kanban cross-column support.
// pointerWithin alone misses when the pointer is between cards inside a column,
// so we fall back to rectIntersection which catches overlapping rects.
// We always prefer column droppables over task droppables for cross-container moves.
const collisionDetection: CollisionDetection = useCallback(
(args) => {
// First try pointerWithin — most accurate when pointer is directly over a droppable
const pointerCollisions = pointerWithin(args);
if (pointerCollisions.length > 0) {
// If we hit a column droppable, prefer it for cross-column detection
const columnCollision = pointerCollisions.find((c) =>
columnIds.includes(c.id as TaskStatus)
);
// Also check for task collisions within the column
const taskCollision = pointerCollisions.find(
(c) => !columnIds.includes(c.id as TaskStatus)
);
// If we found a task inside the target column, prefer it (for precise positioning)
if (taskCollision) return [taskCollision];
// Otherwise use the column (for drops into empty areas or between tasks)
if (columnCollision) return [columnCollision];
return pointerCollisions;
}
// Fallback to rect intersection when pointer isn't directly within any droppable
return rectIntersection(args);
},
[columnIds]
);
// Find which column a task belongs to
const findColumnForTask = useCallback(
(taskId: string): TaskStatus | null => {
@ -61,32 +102,52 @@ export function useBoardDragDrop({
const task = tasks?.find((t) => t.id === event.active.id);
if (task) {
setActiveTask(task);
lastOverColumnRef.current = null;
}
},
[tasks]
);
const handleDragOver = useCallback((_event: DragOverEvent) => {
// We don't need real-time container switching since our columns
// are droppable targets and tasks are sortable within them.
// The visual reordering within a column is handled by SortableContext.
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveTask(null);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// Determine which column the active task is currently in
const activeColumn = findColumnForTask(activeId);
if (!activeColumn) return;
// Determine the target column — either the column directly, or the column a task belongs to
const isOverColumn = columnIds.includes(overId as TaskStatus);
const overColumn = isOverColumn ? (overId as TaskStatus) : findColumnForTask(overId);
if (!overColumn || activeColumn === overColumn) return;
// Track which column we're over for handleDragEnd
lastOverColumnRef.current = overColumn;
},
[columnIds, findColumnForTask]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveTask(null);
lastOverColumnRef.current = null;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
// Check if dropped on a column (status) directly
const isOverColumn = columns.some((c) => c.id === overId);
const isOverColumn = columnIds.includes(overId as TaskStatus);
if (isOverColumn) {
// Dropped on empty column area — change status only
// Dropped on column area — change status
const newStatus = overId as TaskStatus;
const task = tasks?.find((t) => t.id === activeId);
if (task && task.status !== newStatus) {
@ -116,21 +177,23 @@ export function useBoardDragDrop({
const destTasks = [...tasksByStatus[overColumn]];
const overIndex = destTasks.findIndex((t: Task) => t.id === overId);
// First update the task's status
// Update the task's status to the destination column
onStatusChange(activeId, overColumn);
// Build the new order for the destination column including the moved task
const newOrder = destTasks.map((t: Task) => t.id);
newOrder.splice(overIndex, 0, activeId);
onReorder(newOrder);
}
},
[columns, findColumnForTask, onReorder, onStatusChange, tasks, tasksByStatus]
[columnIds, findColumnForTask, onReorder, onStatusChange, tasks, tasksByStatus]
);
return {
activeTask,
isDragActive: activeTask !== null,
sensors,
collisionDetection,
handleDragStart,
handleDragOver,
handleDragEnd,