"use client"; import { useCallback, useMemo } from "react"; import { DndContext, PointerSensor, closestCenter, type DragEndEvent, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; import type { TaskSwitcherItem } from "./task-switcher"; export const DRAG_ACTIVATION_DISTANCE = 8; /** Sortable implementation — isolated so `useSortable` is never called conditionally. */ function DraggableSortableTaskNode({ taskId, depth, handle, nested, }: { taskId: string; depth: number; handle: React.ReactNode; nested?: React.ReactNode; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: taskId, }); const sortableAttributes = { ...attributes, role: undefined, "aria-roledescription": undefined, }; const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : undefined, }; return (
{handle}
{nested}
); } /** * A single sortable node in the (arbitrarily deep) task tree. * * `setNodeRef` and the CSS transform live on the outer wrapper so the row and * its nested subtree move together while dragging. Drag listeners attach ONLY * to the `handle` (the task row) — `nested` (the child subtree) renders as a * sibling outside the handle so a pointer-down on a descendant row drags that * descendant, not this node. This is the same handle/children split the root * level has always used; generalizing it is what makes nesting safe at depth. * * When `isDraggable` is false, renders a plain wrapper with no DnD wiring. */ export function SortableTaskNode({ taskId, depth, handle, nested, isDraggable = true, }: { taskId: string; depth: number; handle: React.ReactNode; nested?: React.ReactNode; isDraggable?: boolean; }) { if (!isDraggable) { return (
{handle}
{nested}
); } return ( ); } /** * DnD wiring for one level of sibling tasks. Reordering is scoped to the * siblings sharing a parent (cross-parent drag is intentionally unsupported). */ function useLevelDnd(tasks: TaskSwitcherItem[], onReorder?: (orderedTaskIds: string[]) => void) { const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }), ); const handleDragEnd = useCallback( (event: DragEndEvent) => { if (!onReorder) return; const { active, over } = event; if (!over || active.id === over.id) return; const ids = tasks.map((t) => t.id); const oldIndex = ids.indexOf(String(active.id)); const newIndex = ids.indexOf(String(over.id)); if (oldIndex < 0 || newIndex < 0) return; onReorder(arrayMove(ids, oldIndex, newIndex)); }, [tasks, onReorder], ); const sortableIds = useMemo(() => tasks.map((t) => t.id), [tasks]); return { sensors, handleDragEnd, sortableIds }; } /** * Renders one level of sibling task nodes. When `onReorder` is omitted the DnD * scaffolding (and the misleading grab cursor) is skipped, so non-reorderable * callers get a plain list with no wasted setup. */ export function SortableTaskLevel({ tasks, onReorder, renderNode, }: { tasks: TaskSwitcherItem[]; onReorder?: (orderedTaskIds: string[]) => void; renderNode: (task: TaskSwitcherItem, isDraggable: boolean) => React.ReactNode; }) { const { sensors, handleDragEnd, sortableIds } = useLevelDnd(tasks, onReorder); const isDraggable = !!onReorder; const body = tasks.map((task) => renderNode(task, isDraggable)); if (!onReorder) return <>{body}; return ( {body} ); }