"use client"; import * as React from "react"; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "framer-motion"; import { cn } from "../lib/utils"; import { transitions } from "../lib/motion"; /** * A board with drag and drop between and within columns. * * Pointer: drag a card; a slot opens where it will land. Keyboard: focus * a card, press Space to pick it up, arrows to move it (←/→ across * columns, ↑/↓ within one), Space or Enter to drop, Escape to cancel. * Moves are announced to screen readers. * * The board holds no item state: it calls `onMove` and the parent * updates its items (with `useLiveCollection`, optimistically). */ export interface KanbanColumn { id: string; title: string; /** Dot color in the column header. */ tone?: "default" | "info" | "positive" | "attention" | "risk"; } export interface KanbanMove { itemId: string; fromColumnId: string; toColumnId: string; /** Index within the destination column once the card has moved. */ toIndex: number; } const TONE_DOT = { default: "bg-muted-foreground/40", info: "bg-action", positive: "bg-positive", attention: "bg-attention", risk: "bg-risk", } as const; interface Position { columnId: string; index: number; } export function KanbanBoard({ columns, items, getItemId, getColumnId, renderCard, onMove, onOpen, columnFooter, label = "Board", className, }: { columns: KanbanColumn[]; items: readonly T[]; getItemId: (item: T) => string; getColumnId: (item: T) => string; /** * The card's contents only. The board draws the card itself (surface, * border, radius, padding, drag and focus states); don't wrap the content * in another bordered box. */ renderCard: (item: T, state: { moving: boolean }) => React.ReactNode; onMove: (move: KanbanMove) => void; onOpen?: (item: T) => void; columnFooter?: (column: KanbanColumn) => React.ReactNode; label?: string; className?: string; }) { const reduceMotion = Boolean(useReducedMotion()); const columnRefs = React.useRef(new Map()); const cardRefs = React.useRef(new Map()); const [dragging, setDragging] = React.useState<{ id: string; target: Position | null } | null>(null); const [lifted, setLifted] = React.useState<{ id: string; target: Position } | null>(null); const [announcement, setAnnouncement] = React.useState(""); const byColumn = React.useMemo(() => { const map = new Map(columns.map((column) => [column.id, []])); for (const item of items) map.get(getColumnId(item))?.push(item); return map; }, [columns, items, getColumnId]); const titleOf = (columnId: string) => columns.find((column) => column.id === columnId)?.title ?? columnId; const othersIn = (columnId: string, movingId: string) => (byColumn.get(columnId) ?? []).filter((item) => getItemId(item) !== movingId); const originOf = (itemId: string): Position | null => { for (const [columnId, cards] of byColumn) { const index = cards.findIndex((item) => getItemId(item) === itemId); if (index >= 0) return { columnId, index }; } return null; }; const isOrigin = (itemId: string, position: Position) => { const origin = originOf(itemId); return origin?.columnId === position.columnId && origin.index === position.index; }; const positionAt = (x: number, y: number, movingId: string): Position | null => { for (const column of columns) { const rect = columnRefs.current.get(column.id)?.getBoundingClientRect(); if (!rect || x < rect.left || x > rect.right) continue; const others = othersIn(column.id, movingId); let index = others.length; for (let position = 0; position < others.length; position += 1) { const box = cardRefs.current.get(getItemId(others[position]!))?.getBoundingClientRect(); if (box && y < box.top + box.height / 2) { index = position; break; } } return { columnId: column.id, index }; } return null; }; const commit = (itemId: string, destination: Position) => { const origin = originOf(itemId); if (!origin || isOrigin(itemId, destination)) return; onMove({ itemId, fromColumnId: origin.columnId, toColumnId: destination.columnId, toIndex: destination.index }); setAnnouncement(`Moved to ${titleOf(destination.columnId)}, position ${destination.index + 1}.`); }; const nudge = (deltaColumn: number, deltaIndex: number) => { if (!lifted) return; const columnIndex = columns.findIndex((column) => column.id === lifted.target.columnId); const column = columns[Math.max(0, Math.min(columns.length - 1, columnIndex + deltaColumn))]!; const size = othersIn(column.id, lifted.id).length; const index = Math.max(0, Math.min(size, lifted.target.index + deltaIndex)); setLifted({ id: lifted.id, target: { columnId: column.id, index } }); setAnnouncement(`${titleOf(column.id)}, position ${index + 1} of ${size + 1}.`); }; const handleKey = (item: T, event: React.KeyboardEvent) => { const id = getItemId(item); const isLifted = lifted?.id === id; if (event.key === " " || (event.key === "Enter" && isLifted)) { event.preventDefault(); if (isLifted) { commit(id, lifted.target); setLifted(null); } else { const origin = originOf(id); if (!origin) return; setLifted({ id, target: origin }); setAnnouncement( `Picked up. ${titleOf(origin.columnId)}, position ${origin.index + 1}. Arrow keys move, Space drops, Escape cancels.`, ); } return; } if (event.key === "Enter" && onOpen) { event.preventDefault(); onOpen(item); return; } if (!isLifted) return; const moves: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1], }; const move = moves[event.key]; if (move) { event.preventDefault(); nudge(move[0], move[1]); } else if (event.key === "Escape") { event.preventDefault(); setLifted(null); setAnnouncement("Move cancelled."); } }; const card = (item: T) => { const id = getItemId(item); return ( onOpen(item) : undefined} onKeyDown={(event) => handleKey(item, event)} onDragStart={() => setDragging({ id, target: null })} onDrag={(x, y) => { const target = positionAt(x, y, id); setDragging((current) => current?.target?.columnId === target?.columnId && current?.target?.index === target?.index ? current : { id, target }, ); }} onDragEnd={(x, y) => { const destination = positionAt(x, y, id); setDragging(null); if (destination) commit(id, destination); }} > {renderCard(item, { moving: dragging?.id === id || lifted?.id === id })} ); }; return (

{announcement}

{columns.map((column) => { const cards = byColumn.get(column.id) ?? []; const nodes: React.ReactNode[] = []; if (lifted) { // The lifted card sits wherever it is headed. for (const item of othersIn(column.id, lifted.id)) nodes.push(card(item)); if (lifted.target.columnId === column.id) { const liftedItem = items.find((item) => getItemId(item) === lifted.id); if (liftedItem) nodes.splice(lifted.target.index, 0, card(liftedItem)); } } else { // A dragged card stays under the pointer; a slot opens where it will land. for (const item of cards) nodes.push(card(item)); const target = dragging?.target; if (dragging && target?.columnId === column.id && !isOrigin(dragging.id, target)) { const before = othersIn(column.id, dragging.id)[target.index]; nodes.splice(before ? cards.indexOf(before) : cards.length, 0, ); } } const receiving = (dragging?.target?.columnId ?? lifted?.target.columnId) === column.id; return (

{column.title}

{cards.length}
{ if (element) columnRefs.current.set(column.id, element); else columnRefs.current.delete(column.id); }} className="flex min-h-24 flex-1 flex-col gap-2 px-2 pb-2" > {nodes} {cards.length === 0 && !receiving ? (

Nothing here

) : null} {columnFooter ?
{columnFooter(column)}
: null}
); })}
); } function DropSlot() { return ( ); } function KanbanCard({ id, children, cardRefs, lifted, reduceMotion, onOpen, onKeyDown, onDragStart, onDrag, onDragEnd, }: { id: string; children: React.ReactNode; cardRefs: React.RefObject>; lifted: boolean; reduceMotion: boolean; onOpen: (() => void) | undefined; onKeyDown: (event: React.KeyboardEvent) => void; onDragStart: () => void; onDrag: (x: number, y: number) => void; onDragEnd: (x: number, y: number) => void; }) { const dragged = React.useRef(false); const element = React.useRef(null); React.useEffect(() => { if (lifted) element.current?.focus(); }, [lifted]); return ( { element.current = node; if (node) cardRefs.current.set(id, node); else cardRefs.current.delete(id); }} layout={reduceMotion ? false : "position"} {...(reduceMotion ? {} : { layoutId: `kanban-${id}` })} initial={reduceMotion ? false : { opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: lifted ? 1.02 : 1 }} exit={{ opacity: 0, scale: 0.98 }} drag dragSnapToOrigin dragElastic={0.1} dragMomentum={false} whileDrag={{ scale: 1.03, rotate: 1.5, zIndex: 50 }} transition={transitions.spring} onPointerDown={() => { dragged.current = false; }} onDragStart={() => { dragged.current = true; onDragStart(); }} onDrag={(_event, info) => onDrag(info.point.x - window.scrollX, info.point.y - window.scrollY)} onDragEnd={(_event, info) => onDragEnd(info.point.x - window.scrollX, info.point.y - window.scrollY)} onClick={() => { if (!dragged.current) onOpen?.(); }} onKeyDown={onKeyDown} tabIndex={0} role="button" aria-roledescription="Draggable card" aria-pressed={lifted} className={cn( "relative cursor-grab touch-none rounded-xl border border-border/60 bg-card p-3 text-sm shadow-xs outline-none transition-[border-color,box-shadow] duration-150 select-none hover:border-border focus-visible:ring-2 focus-visible:ring-ring/60 active:cursor-grabbing", lifted && "shadow-lg ring-2 ring-action/60", )} > {children} ); }