diff --git a/components/board/category-lane.tsx b/components/board/category-lane.tsx index 6c20f7a..be7e59d 100644 --- a/components/board/category-lane.tsx +++ b/components/board/category-lane.tsx @@ -2,7 +2,7 @@ import { useSortable } from "@dnd-kit/sortable"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { useDroppable } from "@dnd-kit/core"; +import { useDndContext, useDroppable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; import { GripVertical, MoreVertical, Trash2 } from "lucide-react"; @@ -21,6 +21,19 @@ import type { CategoryDTO } from "@/types/board"; export function CategoryLane({ category }: { category: CategoryDTO }) { const { removeCategory } = useBoard(); + // The lane itself is sortable (so lanes can be reordered), which + // registers its *entire* rect -- header, cards, empty space, all of it -- + // as a droppable. Dragging a group card over empty lane space then had + // two overlapping droppables to resolve against: this lane's own + // "category" droppable and the narrower "category-dropzone" below. With + // `closestCenter`, the much larger lane rect often won, so drop handling + // never saw a "group" or "category-dropzone" target and silently did + // nothing. Disabling this lane's droppable side while a *group* (not a + // lane) is being dragged removes it from collision detection entirely, + // leaving only the group cards and the dropzone as valid targets. + const { active } = useDndContext(); + const isDraggingGroup = active?.data.current?.type === "group"; + const { setNodeRef, setActivatorNodeRef, @@ -32,6 +45,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) { } = useSortable({ id: category.id, data: { type: "category" }, + disabled: { draggable: false, droppable: isDraggingGroup }, }); const { setNodeRef: setDropzoneRef } = useDroppable({ @@ -75,7 +89,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) { removeCategory(category.id)} + onClick={() => removeCategory(category.id)} > Delete category diff --git a/components/board/group-card.tsx b/components/board/group-card.tsx index 201fa8d..0442236 100644 --- a/components/board/group-card.tsx +++ b/components/board/group-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useTheme } from "next-themes"; @@ -47,10 +47,18 @@ export function GroupCard({ group }: { group: GroupDTO }) { // portaled Popover/Dialog content nested inside the card (the notes // editor, the "add to-do" popover). Wiring the same node as the // activator restores the target check. - function setCardRef(node: HTMLDivElement | null) { - setNodeRef(node); - setActivatorNodeRef(node); - } + // + // This must be memoized: a fresh function identity on every render makes + // React detach+reattach the ref each time, which during a drag (many + // re-renders as transform/isDragging change) resets dnd-kit's node + // registration and broke drop detection entirely. + const setCardRef = useCallback( + (node: HTMLDivElement | null) => { + setNodeRef(node); + setActivatorNodeRef(node); + }, + [setNodeRef, setActivatorNodeRef] + ); const color = getGroupColor(group.color); const borderColor = resolvedTheme === "dark" ? color.dark : color.light; @@ -90,7 +98,7 @@ export function GroupCard({ group }: { group: GroupDTO }) { removeGroup(group.id, group.categoryId)} + onClick={() => removeGroup(group.id, group.categoryId)} > Delete group diff --git a/components/theme/theme-toggle.tsx b/components/theme/theme-toggle.tsx index 746708b..48c4f0b 100644 --- a/components/theme/theme-toggle.tsx +++ b/components/theme/theme-toggle.tsx @@ -38,7 +38,7 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) { /> {OPTIONS.map((option) => ( - setTheme(option.value)}> + setTheme(option.value)}> {option.label}