feat: add drag-and-drop for cards and columns with keyboard support
This commit is contained in:
@@ -1,17 +1,56 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCorners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragStartEvent,
|
||||
type DragOverEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBoardStore } from "@/stores/board-store";
|
||||
import { KanbanColumn } from "@/components/board/KanbanColumn";
|
||||
import {
|
||||
CardOverlay,
|
||||
ColumnOverlay,
|
||||
} from "@/components/board/DragOverlayContent";
|
||||
import type { Board } from "@/types/board";
|
||||
|
||||
function findColumnByCardId(board: Board, cardId: string) {
|
||||
return board.columns.find((col) => col.cardIds.includes(cardId));
|
||||
}
|
||||
|
||||
export function BoardView() {
|
||||
const board = useBoardStore((s) => s.board);
|
||||
const addColumn = useBoardStore((s) => s.addColumn);
|
||||
const moveCard = useBoardStore((s) => s.moveCard);
|
||||
const moveColumn = useBoardStore((s) => s.moveColumn);
|
||||
|
||||
const [addingColumn, setAddingColumn] = useState(false);
|
||||
const [newColumnTitle, setNewColumnTitle] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Drag state
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [activeType, setActiveType] = useState<"card" | "column" | null>(null);
|
||||
|
||||
// Sensors: PointerSensor with distance of 5px prevents accidental drags on click
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
}),
|
||||
useSensor(KeyboardSensor)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (addingColumn && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
@@ -23,7 +62,6 @@ export function BoardView() {
|
||||
if (trimmed) {
|
||||
addColumn(trimmed);
|
||||
setNewColumnTitle("");
|
||||
// Keep the input open for quick successive adds
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
@@ -37,6 +75,145 @@ export function BoardView() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drag handlers ---
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
const type = active.data.current?.type as "card" | "column" | undefined;
|
||||
setActiveId(active.id as string);
|
||||
setActiveType(type ?? null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || !board) return;
|
||||
|
||||
const activeType = active.data.current?.type;
|
||||
if (activeType !== "card") return; // Only handle card cross-column moves here
|
||||
|
||||
const activeCardId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
// Determine the source column
|
||||
const activeColumn = findColumnByCardId(board, activeCardId);
|
||||
if (!activeColumn) return;
|
||||
|
||||
// Determine the target column
|
||||
let overColumn: ReturnType<typeof findColumnByCardId>;
|
||||
let overIndex: number;
|
||||
|
||||
// Check if we're hovering over a card
|
||||
const overType = over.data.current?.type;
|
||||
if (overType === "card") {
|
||||
overColumn = findColumnByCardId(board, overId);
|
||||
if (!overColumn) return;
|
||||
overIndex = overColumn.cardIds.indexOf(overId);
|
||||
} else if (overType === "column") {
|
||||
// Hovering over the droppable area of a column
|
||||
const columnId = over.data.current?.columnId as string | undefined;
|
||||
if (columnId) {
|
||||
overColumn = board.columns.find((c) => c.id === columnId);
|
||||
} else {
|
||||
overColumn = board.columns.find((c) => c.id === overId);
|
||||
}
|
||||
if (!overColumn) return;
|
||||
overIndex = overColumn.cardIds.length; // Append to end
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only move if we're going to a different column or different position
|
||||
if (activeColumn.id === overColumn.id) return;
|
||||
|
||||
moveCard(activeCardId, activeColumn.id, overColumn.id, overIndex);
|
||||
},
|
||||
[board, moveCard]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || !board) {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const type = active.data.current?.type;
|
||||
|
||||
if (type === "column") {
|
||||
// Column reordering
|
||||
const activeColumnId = active.id as string;
|
||||
const overColumnId = over.id as string;
|
||||
|
||||
if (activeColumnId !== overColumnId) {
|
||||
const fromIndex = board.columns.findIndex(
|
||||
(c) => c.id === activeColumnId
|
||||
);
|
||||
const toIndex = board.columns.findIndex(
|
||||
(c) => c.id === overColumnId
|
||||
);
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
moveColumn(fromIndex, toIndex);
|
||||
}
|
||||
}
|
||||
} else if (type === "card") {
|
||||
// Card reordering within same column (cross-column already handled in onDragOver)
|
||||
const activeCardId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
const activeColumn = findColumnByCardId(board, activeCardId);
|
||||
if (!activeColumn) {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const overType = over.data.current?.type;
|
||||
|
||||
if (overType === "card") {
|
||||
const overColumn = findColumnByCardId(board, overId);
|
||||
if (!overColumn) {
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeColumn.id === overColumn.id) {
|
||||
// Within same column, reorder
|
||||
const oldIndex = activeColumn.cardIds.indexOf(activeCardId);
|
||||
const newIndex = activeColumn.cardIds.indexOf(overId);
|
||||
if (oldIndex !== newIndex) {
|
||||
moveCard(activeCardId, activeColumn.id, activeColumn.id, newIndex);
|
||||
}
|
||||
}
|
||||
} else if (overType === "column") {
|
||||
// Dropped on an empty column droppable
|
||||
const columnId = over.data.current?.columnId as string | undefined;
|
||||
const targetColumnId = columnId ?? (over.id as string);
|
||||
const targetColumn = board.columns.find(
|
||||
(c) => c.id === targetColumnId
|
||||
);
|
||||
|
||||
if (targetColumn && activeColumn.id !== targetColumn.id) {
|
||||
moveCard(
|
||||
activeCardId,
|
||||
activeColumn.id,
|
||||
targetColumn.id,
|
||||
targetColumn.cardIds.length
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
setActiveType(null);
|
||||
},
|
||||
[board, moveCard, moveColumn]
|
||||
);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-pylon-text-secondary">
|
||||
@@ -45,58 +222,90 @@ export function BoardView() {
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-6 overflow-x-auto p-6">
|
||||
{board.columns.map((column) => (
|
||||
<KanbanColumn key={column.id} column={column} />
|
||||
))}
|
||||
// Get the active item for the drag overlay
|
||||
const activeCard =
|
||||
activeType === "card" && activeId ? board.cards[activeId] : null;
|
||||
const activeColumn =
|
||||
activeType === "column" && activeId
|
||||
? board.columns.find((c) => c.id === activeId)
|
||||
: null;
|
||||
|
||||
{/* Add column button / inline input */}
|
||||
<div className="shrink-0">
|
||||
{addingColumn ? (
|
||||
<div className="flex w-[280px] flex-col gap-2 rounded-lg bg-pylon-column p-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={newColumnTitle}
|
||||
onChange={(e) => setNewColumnTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
if (!newColumnTitle.trim()) {
|
||||
setAddingColumn(false);
|
||||
}
|
||||
}}
|
||||
placeholder="Column title..."
|
||||
className="h-8 rounded-md bg-pylon-surface px-3 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary focus:ring-1 focus:ring-pylon-accent"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAddColumn}>
|
||||
Add
|
||||
</Button>
|
||||
const columnIds = board.columns.map((c) => c.id);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={columnIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<div className="flex h-full gap-6 overflow-x-auto p-6">
|
||||
{board.columns.map((column) => (
|
||||
<KanbanColumn key={column.id} column={column} />
|
||||
))}
|
||||
|
||||
{/* Add column button / inline input */}
|
||||
<div className="shrink-0">
|
||||
{addingColumn ? (
|
||||
<div className="flex w-[280px] flex-col gap-2 rounded-lg bg-pylon-column p-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={newColumnTitle}
|
||||
onChange={(e) => setNewColumnTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => {
|
||||
if (!newColumnTitle.trim()) {
|
||||
setAddingColumn(false);
|
||||
}
|
||||
}}
|
||||
placeholder="Column title..."
|
||||
className="h-8 rounded-md bg-pylon-surface px-3 text-sm text-pylon-text outline-none placeholder:text-pylon-text-secondary focus:ring-1 focus:ring-pylon-accent"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAddColumn}>
|
||||
Add
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddingColumn(false);
|
||||
setNewColumnTitle("");
|
||||
}}
|
||||
className="text-pylon-text-secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddingColumn(false);
|
||||
setNewColumnTitle("");
|
||||
}}
|
||||
className="text-pylon-text-secondary"
|
||||
onClick={() => setAddingColumn(true)}
|
||||
className="h-10 w-[280px] justify-start border border-dashed border-pylon-text-secondary/30 text-pylon-text-secondary hover:border-pylon-text-secondary/60 hover:text-pylon-text"
|
||||
>
|
||||
Cancel
|
||||
<Plus className="size-4" />
|
||||
Add column
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setAddingColumn(true)}
|
||||
className="h-10 w-[280px] justify-start border border-dashed border-pylon-text-secondary/30 text-pylon-text-secondary hover:border-pylon-text-secondary/60 hover:text-pylon-text"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add column
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
|
||||
{/* Drag overlay - renders a styled copy of the dragged item */}
|
||||
<DragOverlay>
|
||||
{activeCard ? (
|
||||
<CardOverlay card={activeCard} boardLabels={board.labels} />
|
||||
) : activeColumn ? (
|
||||
<ColumnOverlay column={activeColumn} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user