"use client" import * as React from "react" import { move } from "@dnd-kit/helpers" import { DragDropProvider, DragOverlay, useDroppable, type DragEndEvent, type DragOverEvent, type DragStartEvent, } from "@dnd-kit/react" import { isSortable, useSortable } from "@dnd-kit/react/sortable" import { CalendarDaysIcon, CheckIcon, GripVerticalIcon, PlusIcon } from "lucide-react" import { Avatar } from "@/components/display/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { cn } from "@/lib/utils" const KANBAN_CARD_PREFIX = "tembro-kanban-card:" const KANBAN_COLUMN_PREFIX = "tembro-kanban-column:" function getCardId(cardKey: string) { return `${KANBAN_CARD_PREFIX}${cardKey}` } function getColumnId(columnKey: string) { return `${KANBAN_COLUMN_PREFIX}${columnKey}` } function getCardKey(id: string) { return id.startsWith(KANBAN_CARD_PREFIX) ? id.slice(KANBAN_CARD_PREFIX.length) : id } function getColumnKey(id: string | number | symbol | undefined) { const value = id === undefined ? "" : String(id) return value.startsWith(KANBAN_COLUMN_PREFIX) ? value.slice(KANBAN_COLUMN_PREFIX.length) : value } export type KanbanCard = { key: string title: React.ReactNode description?: React.ReactNode meta?: React.ReactNode extra?: React.ReactNode actions?: React.ReactNode labels?: Array priority?: "low" | "medium" | "high" | "urgent" assignee?: { name: string; src?: string; fallback?: React.ReactNode } dueDate?: React.ReactNode selected?: boolean disabled?: boolean } export type KanbanColumn = { key: string title: React.ReactNode description?: React.ReactNode cards: KanbanCard[] count?: React.ReactNode limit?: number color?: string actions?: React.ReactNode footer?: React.ReactNode disabled?: boolean } export type KanbanCardRenderContext = { index: number isDragging: boolean isDropTarget: boolean selected: boolean selectControl: React.ReactNode handle: React.ReactNode } export type KanbanCardMove = { card: KanbanCard fromColumn: KanbanColumn toColumn: KanbanColumn fromIndex: number toIndex: number } export type KanbanBoardProps = Omit, "defaultValue"> & { columns?: KanbanColumn[] defaultColumns?: KanbanColumn[] onColumnsChange?: (columns: KanbanColumn[], change: KanbanCardMove) => void onCardMove?: (change: KanbanCardMove) => void canMoveCard?: (change: KanbanCardMove) => boolean renderCard?: ( card: KanbanCard, column: KanbanColumn, context: KanbanCardRenderContext ) => React.ReactNode onCardClick?: (card: KanbanCard, column: KanbanColumn) => void onAddCard?: (column: KanbanColumn) => void onAddColumn?: () => void renderColumnHeader?: (column: KanbanColumn) => React.ReactNode renderColumnFooter?: (column: KanbanColumn) => React.ReactNode selectionMode?: "none" | "single" | "multiple" selectedCardKeys?: string[] defaultSelectedCardKeys?: string[] onSelectionChange?: (keys: string[], cards: KanbanCard[]) => void allowCrossColumn?: boolean allowReorder?: boolean density?: "compact" | "comfortable" columnWidth?: number | string disabled?: boolean emptyColumn?: React.ReactNode dragHandleLabel?: (card: KanbanCard, column: KanbanColumn) => string columnClassName?: string cardClassName?: string overlayClassName?: string } type KanbanCardViewProps = { card: KanbanCard column: KanbanColumn index: number boardDisabled: boolean renderCard?: KanbanBoardProps["renderCard"] onCardClick?: KanbanBoardProps["onCardClick"] selected: boolean selectionMode: NonNullable onSelect: (card: KanbanCard) => void dragHandleLabel?: KanbanBoardProps["dragHandleLabel"] cardClassName?: string onNativeDragStart?: (source: NativeDragSource) => void onNativeDragEnd?: () => void onNativeDrop?: (target: NativeDropTarget) => void } type NativeDragSource = { cardKey: string columnKey: string index: number } type NativeDropTarget = { columnKey: string index?: number } function createCardMap(columns: KanbanColumn[]) { return new Map( columns.flatMap((column) => column.cards.map((card) => [getCardId(card.key), card] as const) ) ) } function moveKanbanCards( columns: KanbanColumn[], event: DragOverEvent | DragEndEvent ) { const cardMap = createCardMap(columns) const cardIdsByColumn = Object.fromEntries( columns.map((column) => [ getColumnId(column.key), column.cards.map((card) => getCardId(card.key)), ]) ) const movedIds = move(cardIdsByColumn, event) return columns.map((column) => ({ ...column, cards: (movedIds[getColumnId(column.key)] ?? []) .map((id) => cardMap.get(String(id))) .filter((card): card is KanbanCard => Boolean(card)), })) } function moveKanbanCardToTarget( columns: KanbanColumn[], source: NativeDragSource, target: NativeDropTarget ): { columns: KanbanColumn[]; change: KanbanCardMove } | null { const sourceColumn = columns.find((column) => column.key === source.columnKey) const targetColumn = columns.find((column) => column.key === target.columnKey) if (!sourceColumn || !targetColumn) return null const card = sourceColumn.cards.find((item) => item.key === source.cardKey) if (!card) return null const sourceIndex = sourceColumn.cards.findIndex((item) => item.key === source.cardKey) if (sourceIndex < 0) return null const targetCards = targetColumn.cards let targetIndex = target.index ?? targetCards.length if (sourceColumn.key === targetColumn.key && targetIndex > sourceIndex) { targetIndex -= 1 } targetIndex = Math.max(0, Math.min(targetIndex, targetCards.length)) if (sourceColumn.key === targetColumn.key && sourceIndex === targetIndex) return null const nextColumns = columns.map((column) => { if (column.key === sourceColumn.key) { return { ...column, cards: column.cards.filter((item) => item.key !== source.cardKey) } } return column }).map((column) => { if (column.key !== targetColumn.key) return column const nextCards = [...column.cards] nextCards.splice(targetIndex, 0, card) return { ...column, cards: nextCards } }) const resolvedTargetColumn = nextColumns.find((column) => column.key === targetColumn.key) ?? targetColumn return { columns: nextColumns, change: { card, fromColumn: sourceColumn, toColumn: resolvedTargetColumn, fromIndex: sourceIndex, toIndex: targetIndex, }, } } function getKanbanChange(before: KanbanColumn[], after: KanbanColumn[], cardKey: string): KanbanCardMove | null { const fromColumn = before.find((column) => column.cards.some((card) => card.key === cardKey)) const toColumn = after.find((column) => column.cards.some((card) => card.key === cardKey)) const card = fromColumn?.cards.find((item) => item.key === cardKey) if (!fromColumn || !toColumn || !card) return null return { card, fromColumn, toColumn, fromIndex: fromColumn.cards.findIndex((item) => item.key === cardKey), toIndex: toColumn.cards.findIndex((item) => item.key === cardKey), } } function isKanbanMoveAllowed( before: KanbanColumn[], after: KanbanColumn[], cardKey: string, allowCrossColumn: boolean, allowReorder: boolean, canMoveCard?: KanbanBoardProps["canMoveCard"] ) { const change = getKanbanChange(before, after, cardKey) if (!change) return false const crossColumn = change.fromColumn.key !== change.toColumn.key if (crossColumn && !allowCrossColumn) return false if (!crossColumn && !allowReorder) return false if (crossColumn && change.toColumn.limit !== undefined && change.toColumn.cards.length > change.toColumn.limit) return false return canMoveCard?.(change) ?? true } const KanbanDragHandle = React.forwardRef(function KanbanDragHandle({ label, disabled }, ref) { return ( ) }) function DefaultKanbanCard({ card, handle, selectControl }: { card: KanbanCard; handle: React.ReactNode; selectControl?: React.ReactNode }) { const priorityTone = card.priority === "urgent" ? "danger" : card.priority === "high" ? "warning" : card.priority === "medium" ? "info" : "muted" return (
{selectControl} {card.title}
{card.extra} {handle}
{(card.description || card.meta || card.labels?.length || card.priority || card.assignee || card.dueDate || card.actions) ? ( {card.description ?
{card.description}
: null} {card.labels?.length || card.priority ?
{card.priority ? : null} {card.labels?.map((label, index) => React.isValidElement(label) ? React.cloneElement(label, { key: label.key ?? index }) : typeof label === "object" && label !== null && "key" in label ? : )}
: null} {card.meta ?
{card.meta}
: null} {(card.assignee || card.dueDate) ?
{card.assignee ?
{card.assignee.name}
: } {card.dueDate ? {card.dueDate} : null}
: null} {card.actions ?
{card.actions}
: null}
) : null}
) } function KanbanCardView({ card, column, index, boardDisabled, renderCard, onCardClick, selected, selectionMode, onSelect, dragHandleLabel, cardClassName, onNativeDragStart, onNativeDragEnd, onNativeDrop, }: KanbanCardViewProps) { const disabled = boardDisabled || column.disabled === true || card.disabled === true const { ref, handleRef, isDragging, isDropTarget } = useSortable({ id: getCardId(card.key), index, group: getColumnId(column.key), type: "tembro-kanban-card", accept: "tembro-kanban-card", disabled, data: { cardKey: card.key, columnKey: column.key }, transition: { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)", idle: true }, }) const label = dragHandleLabel?.(card, column) ?? `Move ${String(card.title)}` const handle = ( } label={label} disabled={disabled} /> ) const selectControl = selectionMode !== "none" ? ( ) : null const context: KanbanCardRenderContext = { index, isDragging, isDropTarget, selected, selectControl, handle, } return (
} data-slot="kanban-card" data-dragging={isDragging || undefined} data-drop-target={isDropTarget || undefined} data-disabled={disabled || undefined} role="group" aria-label={typeof card.title === "string" ? card.title : undefined} draggable={!disabled} className={cn( "rounded-lg outline-none transition-[opacity,box-shadow]", onCardClick && !disabled && "cursor-pointer focus-visible:ring-2 focus-visible:ring-ring", isDropTarget && "ring-2 ring-primary/30", isDragging && "opacity-30", selected && "ring-2 ring-primary/35", disabled && "opacity-55", cardClassName )} onClick={() => { if (!disabled) onCardClick?.(card, column) }} onDragStart={(event) => { if (disabled) return const source = { cardKey: card.key, columnKey: column.key, index } event.dataTransfer.effectAllowed = "move" event.dataTransfer.setData("text/plain", JSON.stringify(source)) onNativeDragStart?.(source) }} onDragEnd={onNativeDragEnd} onDragOver={(event) => { if (!disabled) event.preventDefault() }} onDrop={(event) => { if (disabled) return event.preventDefault() event.stopPropagation() onNativeDrop?.({ columnKey: column.key, index }) }} > {renderCard?.(card, column, context) ?? }
) } function KanbanColumnView({ column, boardDisabled, renderCard, onCardClick, selectedKeys, selectionMode, onSelect, onAddCard, renderColumnHeader, renderColumnFooter, dragHandleLabel, emptyColumn, columnClassName, cardClassName, onNativeDragStart, onNativeDragEnd, onNativeDrop, density, }: { column: KanbanColumn boardDisabled: boolean renderCard?: KanbanBoardProps["renderCard"] onCardClick?: KanbanBoardProps["onCardClick"] selectedKeys: Set selectionMode: NonNullable onSelect: (card: KanbanCard) => void onAddCard?: KanbanBoardProps["onAddCard"] renderColumnHeader?: KanbanBoardProps["renderColumnHeader"] renderColumnFooter?: KanbanBoardProps["renderColumnFooter"] dragHandleLabel?: KanbanBoardProps["dragHandleLabel"] emptyColumn: React.ReactNode columnClassName?: string cardClassName?: string onNativeDragStart?: (source: NativeDragSource) => void onNativeDragEnd?: () => void onNativeDrop?: (target: NativeDropTarget) => void density: NonNullable }) { const { ref, isDropTarget } = useDroppable({ id: getColumnId(column.key), type: "tembro-kanban-column", accept: "tembro-kanban-card", disabled: boardDisabled || column.disabled === true, collisionPriority: 1, data: { columnKey: column.key }, }) return (
} data-slot="kanban-column" data-drop-target={isDropTarget || undefined} data-disabled={column.disabled || undefined} data-density={density} aria-label={typeof column.title === "string" ? column.title : undefined} className={cn( "grid min-h-40 content-start gap-3 rounded-lg border bg-muted/25 p-3 transition-[border-color,background-color,box-shadow] data-[density=compact]:gap-2 data-[density=compact]:p-2.5", isDropTarget && "border-primary/60 bg-primary/5 ring-2 ring-primary/15", column.disabled && "opacity-60", columnClassName )} onDragOver={(event) => { if (!(boardDisabled || column.disabled === true)) event.preventDefault() }} onDrop={(event) => { if (boardDisabled || column.disabled === true) return event.preventDefault() onNativeDrop?.({ columnKey: column.key, index: column.cards.length }) }} > {renderColumnHeader?.(column) ??
{column.color ? : null}

{column.title}

{column.description ?

{column.description}

: null}
{column.actions} {onAddCard && !column.disabled ? : null} = column.limit ? "soft" : "outline"} tone={column.limit !== undefined && column.cards.length >= column.limit ? "warning" : "muted"} label={column.count ?? (column.limit === undefined ? column.cards.length : `${column.cards.length}/${column.limit}`)} />
}
{column.cards.length === 0 ? (
{emptyColumn} No cards yet.
) : ( column.cards.map((card, index) => ( )) )}
{renderColumnFooter?.(column) ?? column.footer ?? (onAddCard && !column.disabled ? : null)}
) } function KanbanBoard({ columns: columnsProp, defaultColumns = [], onColumnsChange, onCardMove, canMoveCard, renderCard, onCardClick, onAddCard, onAddColumn, renderColumnHeader, renderColumnFooter, selectionMode = "none", selectedCardKeys, defaultSelectedCardKeys = [], onSelectionChange, allowCrossColumn = true, allowReorder = true, density = "comfortable", columnWidth = 320, disabled = false, emptyColumn = "Drop cards here.", dragHandleLabel, columnClassName, cardClassName, overlayClassName, className, "aria-label": ariaLabel = "Kanban board", ...props }: KanbanBoardProps) { const [internalColumns, setInternalColumns] = React.useState(() => defaultColumns) const [draftColumns, setDraftColumns] = React.useState(null) const [activeCardId, setActiveCardId] = React.useState(null) const draftColumnsRef = React.useRef(null) const nativeDragRef = React.useRef(null) const [internalSelectedKeys, setInternalSelectedKeys] = React.useState(defaultSelectedCardKeys) const columns = columnsProp ?? internalColumns const renderedColumns = draftColumns ?? columns const isControlled = columnsProp !== undefined const resolvedSelectedKeys = selectedCardKeys ?? internalSelectedKeys const selectedKeySet = React.useMemo(() => new Set(resolvedSelectedKeys), [resolvedSelectedKeys]) function updateSelection(card: KanbanCard) { if (selectionMode === "none" || card.disabled) return const next = selectionMode === "single" ? (selectedKeySet.has(card.key) ? [] : [card.key]) : (selectedKeySet.has(card.key) ? resolvedSelectedKeys.filter((key) => key !== card.key) : [...resolvedSelectedKeys, card.key]) if (selectedCardKeys === undefined) setInternalSelectedKeys(next) const allCards = columns.flatMap((column) => column.cards) onSelectionChange?.(next, allCards.filter((item) => next.includes(item.key))) } function handleDragStart(event: DragStartEvent) { const source = event.operation.source if (!source) return const id = String(source.id) draftColumnsRef.current = columns setDraftColumns(columns) setActiveCardId(id) } function handleDragOver(event: DragOverEvent) { const source = event.operation.source if (!source || !isSortable(source)) return const currentColumns = draftColumnsRef.current ?? columns const nextColumns = moveKanbanCards(currentColumns, event) const cardKey = getCardKey(String(source.id)) if (!isKanbanMoveAllowed(columns, nextColumns, cardKey, allowCrossColumn, allowReorder, canMoveCard)) return draftColumnsRef.current = nextColumns setDraftColumns(nextColumns) } function handleDragEnd(event: DragEndEvent) { const source = event.operation.source setActiveCardId(null) if (event.canceled || !source || !isSortable(source)) { draftColumnsRef.current = null setDraftColumns(null) return } const finalColumns = draftColumnsRef.current ?? moveKanbanCards(columns, event) const cardKey = getCardKey(String(source.id)) const fromColumnKey = getColumnKey(source.initialGroup) const toColumnKey = getColumnKey(source.group) const fromColumn = columns.find((column) => column.key === fromColumnKey) const toColumn = finalColumns.find((column) => column.key === toColumnKey) const card = fromColumn?.cards.find((item) => item.key === cardKey) draftColumnsRef.current = null setDraftColumns(null) if (!card || !fromColumn || !toColumn) return const change: KanbanCardMove = { card, fromColumn, toColumn, fromIndex: source.initialIndex, toIndex: source.index, } if (!isKanbanMoveAllowed(columns, finalColumns, card.key, allowCrossColumn, allowReorder, canMoveCard)) return if (!isControlled) setInternalColumns(finalColumns) onColumnsChange?.(finalColumns, change) onCardMove?.(change) } function handleNativeDrop(target: NativeDropTarget) { const source = nativeDragRef.current nativeDragRef.current = null if (!source) return const result = moveKanbanCardToTarget(columns, source, target) if (!result) return if (!isKanbanMoveAllowed(columns, result.columns, result.change.card.key, allowCrossColumn, allowReorder, canMoveCard)) return if (!isControlled) setInternalColumns(result.columns) onColumnsChange?.(result.columns, result.change) onCardMove?.(result.change) } const activeEntry = activeCardId ? renderedColumns .flatMap((column) => column.cards.map((card) => ({ card, column }))) .find(({ card }) => getCardId(card.key) === activeCardId) : undefined return (
{renderedColumns.map((column) => ( { nativeDragRef.current = source }} onNativeDragEnd={() => { nativeDragRef.current = null }} onNativeDrop={handleNativeDrop} density={density} /> ))} {onAddColumn ? : null}
{activeEntry ? (
{renderCard?.(activeEntry.card, activeEntry.column, { index: activeEntry.column.cards.findIndex((card) => card.key === activeEntry.card.key), isDragging: true, isDropTarget: false, handle: null, selected: selectedKeySet.has(activeEntry.card.key), selectControl: null, }) ?? }
) : null}
) } export { KanbanBoard }