Organize/components/board/category-lane.tsx

115 lines
4.0 KiB
TypeScript

"use client";
import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useDndContext, useDroppable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical, MoreVertical, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { GroupCard } from "@/components/board/group-card";
import { AddGroupPopover } from "@/components/board/add-group-popover";
import { useBoard } from "@/components/board/board-context";
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,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: category.id,
data: { type: "category" },
disabled: { draggable: false, droppable: isDraggingGroup },
});
const { setNodeRef: setDropzoneRef } = useDroppable({
id: `category-dropzone-${category.id}`,
data: { type: "category-dropzone", categoryId: category.id },
});
const groupIds = category.groups.map((g) => g.id);
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex h-full w-72 shrink-0 flex-col rounded-xl bg-muted/40",
isDragging && "opacity-50"
)}
>
<div className="flex items-center gap-1 p-3">
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
aria-label={`Drag to reorder ${category.name}`}
>
<GripVertical className="size-4" />
</button>
<h2 className="min-w-0 flex-1 truncate font-semibold">{category.name}</h2>
<DropdownMenu>
<DropdownMenuTrigger
render={
<button
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
aria-label={`More options for ${category.name}`}
>
<MoreVertical className="size-4" />
</button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
onClick={() => removeCategory(category.id)}
>
<Trash2 className="size-4" />
Delete category
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div ref={setDropzoneRef} className="flex-1 space-y-2 overflow-y-auto px-3 pb-2">
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
{category.groups.map((group) => (
<GroupCard key={group.id} group={group} />
))}
</SortableContext>
</div>
<div className="p-2">
<AddGroupPopover categoryId={category.id} />
</div>
</div>
);
}