{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"kanban","type":"registry:ui","title":"Kanban","description":"","dependencies":["@dnd-kit/core","@dnd-kit/sortable","@dnd-kit/utilities","radix-ui"],"registryDependencies":[],"files":[{"path":"kanban.tsx","type":"registry:ui","content":"/* eslint-disable @typescript-eslint/no-explicit-any */\n\"use client\"\n\nimport * as React from \"react\"\nimport type { CSSProperties, HTMLAttributes, ReactNode } from \"react\"\nimport {\n createContext,\n useCallback,\n useContext,\n useLayoutEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\"\nimport type {\n DragCancelEvent,\n DragEndEvent,\n DragOverEvent,\n DragStartEvent,\n DropAnimation,\n Modifiers,\n UniqueIdentifier,\n} from \"@dnd-kit/core\"\nimport {\n defaultDropAnimationSideEffects,\n DndContext,\n DragOverlay,\n KeyboardSensor,\n MeasuringStrategy,\n MouseSensor,\n TouchSensor,\n useSensor,\n useSensors,\n type DraggableAttributes,\n type DraggableSyntheticListeners,\n} from \"@dnd-kit/core\"\nimport {\n arrayMove,\n defaultAnimateLayoutChanges,\n rectSortingStrategy,\n SortableContext,\n sortableKeyboardCoordinates,\n useSortable,\n verticalListSortingStrategy,\n type AnimateLayoutChanges,\n} from \"@dnd-kit/sortable\"\nimport { CSS } from \"@dnd-kit/utilities\"\nimport { Slot } from \"radix-ui\"\nimport { createPortal } from \"react-dom\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface KanbanContextProps {\n columns: Record\n setColumns: (columns: Record) => void\n getItemId: (item: T) => string\n columnIds: string[]\n activeId: UniqueIdentifier | null\n setActiveId: (id: UniqueIdentifier | null) => void\n findContainer: (id: UniqueIdentifier) => string | undefined\n isColumn: (id: UniqueIdentifier) => boolean\n modifiers?: Modifiers\n}\n\nconst KanbanContext = createContext>({\n columns: {},\n setColumns: () => {},\n getItemId: () => \"\",\n columnIds: [],\n activeId: null,\n setActiveId: () => {},\n findContainer: () => undefined,\n isColumn: () => false,\n modifiers: undefined,\n})\n\nconst ColumnContext = createContext<{\n attributes: DraggableAttributes\n listeners: DraggableSyntheticListeners | undefined\n isDragging?: boolean\n disabled?: boolean\n}>({\n attributes: {} as DraggableAttributes,\n listeners: undefined,\n isDragging: false,\n disabled: false,\n})\n\nconst ItemContext = createContext<{\n listeners: DraggableSyntheticListeners | undefined\n isDragging?: boolean\n disabled?: boolean\n}>({\n listeners: undefined,\n isDragging: false,\n disabled: false,\n})\n\nconst IsOverlayContext = createContext(false)\n\nconst animateLayoutChanges: AnimateLayoutChanges = (args) =>\n defaultAnimateLayoutChanges({ ...args, wasDragging: true })\n\nconst dropAnimationConfig: DropAnimation = {\n sideEffects: defaultDropAnimationSideEffects({\n styles: {\n active: {\n opacity: \"0.4\",\n },\n },\n }),\n}\n\n/**\n * Client-mount gate for the `createPortal` call in KanbanOverlay, which needs\n * `document.body` and so must not run on the server or during hydration.\n *\n * A never-notifying subscription makes `useSyncExternalStore` return the server\n * snapshot (`false`) while rendering on the server and while hydrating, then the\n * client snapshot (`true`) once mounted - the same gate the previous\n * `useLayoutEffect(() => setMounted(true), [])` provided, minus the extra render\n * pass that `react-hooks/set-state-in-effect` flags. All three functions are\n * module-scoped so their identities stay stable; an inline `getSnapshot` is the\n * classic cause of an infinite re-subscribe loop.\n */\nconst subscribeToNothing = () => () => {}\nconst getIsMounted = () => true\nconst getIsMountedOnServer = () => false\n\nconst MOUSE_SENSOR_OPTIONS = { activationConstraint: { distance: 10 } }\nconst TOUCH_SENSOR_OPTIONS = {\n activationConstraint: { delay: 250, tolerance: 5 },\n}\nconst KEYBOARD_SENSOR_OPTIONS = {\n coordinateGetter: sortableKeyboardCoordinates,\n}\nconst MEASURING_CONFIG = {\n droppable: { strategy: MeasuringStrategy.Always },\n}\n\nexport interface KanbanMoveEvent {\n event: DragEndEvent\n activeContainer: string\n activeIndex: number\n overContainer: string\n overIndex: number\n}\n\nexport interface KanbanCommitMeta {\n kind: \"item\" | \"column\"\n event: DragEndEvent\n activeContainer: string\n activeIndex: number\n overContainer: string\n overIndex: number\n previousValue: Record\n}\n\nexport interface KanbanRootProps extends Omit<\n HTMLAttributes,\n \"onDragStart\" | \"onDragEnd\"\n> {\n value: Record\n onValueChange: (value: Record) => void\n getItemValue: (item: T) => string\n children: ReactNode\n onMove?: (event: KanbanMoveEvent) => void\n onValueCommit?: (\n value: Record,\n meta: KanbanCommitMeta\n ) => void\n restoreOnCancel?: boolean\n onDragStart?: (event: DragStartEvent) => void\n onDragEnd?: (event: DragEndEvent) => void\n onDragCancel?: (event: DragCancelEvent) => void\n accessibility?: React.ComponentProps[\"accessibility\"]\n asChild?: boolean\n modifiers?: Modifiers\n}\n\nfunction Kanban({\n value,\n onValueChange,\n getItemValue,\n children,\n className,\n asChild = false,\n onMove,\n onValueCommit,\n restoreOnCancel = false,\n onDragStart,\n onDragEnd,\n onDragCancel,\n accessibility,\n modifiers,\n ...props\n}: KanbanRootProps) {\n const columns = value\n const setColumns = onValueChange\n const [activeId, setActiveId] = useState(null)\n\n // Always-current mirrors so the drag handlers can read fresh values without\n // widening their dependency arrays (keeps handler identity stable). The\n // handlers only fire after commit, so syncing the mirrors in an effect is\n // safe — assigning to a ref during render breaks under concurrent rendering.\n const valueRef = useRef(value)\n const getItemValueRef = useRef(getItemValue)\n useLayoutEffect(() => {\n valueRef.current = value\n getItemValueRef.current = getItemValue\n })\n const dragOriginRef = useRef<{\n value: Record\n container: string | undefined\n index: number\n } | null>(null)\n\n const sensors = useSensors(\n useSensor(MouseSensor, MOUSE_SENSOR_OPTIONS),\n useSensor(TouchSensor, TOUCH_SENSOR_OPTIONS),\n useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS)\n )\n\n const columnIds = useMemo(() => {\n const keys = Object.keys(columns)\n if (process.env.NODE_ENV !== \"production\") {\n const seen = new Set()\n for (const key of keys) {\n for (const item of columns[key]) {\n const itemId = getItemValue(item)\n if (seen.has(itemId)) {\n console.warn(\n `[Kanban] Duplicate item id \"${itemId}\". Item ids must be unique across all columns, or drag and drop will misbehave.`\n )\n break\n }\n seen.add(itemId)\n }\n }\n }\n return keys\n }, [columns, getItemValue])\n\n const isColumn = useCallback(\n (id: UniqueIdentifier) => columnIds.includes(id as string),\n [columnIds]\n )\n\n const findContainer = useCallback(\n (id: UniqueIdentifier) => {\n if (isColumn(id)) return id as string\n return columnIds.find((key) =>\n columns[key].some((item) => getItemValue(item) === id)\n )\n },\n [columns, columnIds, getItemValue, isColumn]\n )\n\n const commitChange = useCallback(\n (\n finalValue: Record,\n event: DragEndEvent,\n kind: \"item\" | \"column\"\n ) => {\n if (!onValueCommit) return\n const origin = dragOriginRef.current\n if (!origin) return\n\n const id = event.active.id\n\n if (kind === \"column\") {\n const keys = Object.keys(finalValue)\n const overIndex = keys.indexOf(id as string)\n if (overIndex === -1 || overIndex === origin.index) return\n onValueCommit(finalValue, {\n kind: \"column\",\n event,\n activeContainer: id as string,\n activeIndex: origin.index,\n overContainer: String(event.over?.id ?? id),\n overIndex,\n previousValue: origin.value,\n })\n return\n }\n\n const getId = getItemValueRef.current\n let overContainer: string | undefined\n let overIndex = -1\n for (const key of Object.keys(finalValue)) {\n const found = finalValue[key].findIndex((item) => getId(item) === id)\n if (found !== -1) {\n overContainer = key\n overIndex = found\n break\n }\n }\n if (overContainer === undefined) return\n if (overContainer === origin.container && overIndex === origin.index) {\n return\n }\n onValueCommit(finalValue, {\n kind: \"item\",\n event,\n activeContainer: origin.container ?? overContainer,\n activeIndex: origin.index,\n overContainer,\n overIndex,\n previousValue: origin.value,\n })\n },\n [onValueCommit]\n )\n\n const handleDragStart = useCallback(\n (event: DragStartEvent) => {\n setActiveId(event.active.id)\n onDragStart?.(event)\n\n if (onValueCommit || restoreOnCancel) {\n const snapshot = valueRef.current\n const id = event.active.id\n const keys = Object.keys(snapshot)\n if (keys.includes(id as string)) {\n dragOriginRef.current = {\n value: snapshot,\n container: id as string,\n index: keys.indexOf(id as string),\n }\n } else {\n const getId = getItemValueRef.current\n let container: string | undefined\n let index = -1\n for (const key of keys) {\n const found = snapshot[key].findIndex((item) => getId(item) === id)\n if (found !== -1) {\n container = key\n index = found\n break\n }\n }\n dragOriginRef.current = { value: snapshot, container, index }\n }\n }\n },\n [onDragStart, onValueCommit, restoreOnCancel]\n )\n\n const handleDragOver = useCallback(\n (event: DragOverEvent) => {\n if (onMove) {\n return\n }\n\n const { active, over } = event\n if (!over) return\n\n if (isColumn(active.id)) return\n\n const activeContainer = findContainer(active.id)\n const overContainer = findContainer(over.id)\n\n if (!activeContainer || !overContainer) {\n return\n }\n\n if (activeContainer !== overContainer) {\n const activeItems = columns[activeContainer]\n const overItems = columns[overContainer]\n\n const activeIndex = activeItems.findIndex(\n (item: T) => getItemValue(item) === active.id\n )\n let overIndex = overItems.findIndex(\n (item: T) => getItemValue(item) === over.id\n )\n\n // If dropping on the column itself, not an item\n if (isColumn(over.id)) {\n overIndex = overItems.length\n }\n\n const newActiveItems = [...activeItems]\n const newOverItems = [...overItems]\n const [movedItem] = newActiveItems.splice(activeIndex, 1)\n newOverItems.splice(overIndex, 0, movedItem)\n\n setColumns({\n ...columns,\n [activeContainer]: newActiveItems,\n [overContainer]: newOverItems,\n })\n } else {\n const container = activeContainer\n const activeIndex = columns[container].findIndex(\n (item: T) => getItemValue(item) === active.id\n )\n const overIndex = columns[container].findIndex(\n (item: T) => getItemValue(item) === over.id\n )\n\n if (activeIndex !== overIndex) {\n setColumns({\n ...columns,\n [container]: arrayMove(columns[container], activeIndex, overIndex),\n })\n }\n }\n },\n [findContainer, getItemValue, isColumn, setColumns, columns, onMove]\n )\n\n const handleDragCancel = useCallback(\n (event: DragCancelEvent) => {\n const origin = dragOriginRef.current\n\n if (restoreOnCancel && origin && !onMove) {\n // Escape/cancel: undo the live-preview reshuffle applied during dragOver.\n setColumns(origin.value)\n } else if (onValueCommit && origin && !onMove) {\n // No restore requested: the live preview stays visible, so commit it.\n commitChange(valueRef.current, event, \"item\")\n }\n\n dragOriginRef.current = null\n setActiveId(null)\n onDragCancel?.(event)\n },\n [\n restoreOnCancel,\n onMove,\n onValueCommit,\n setColumns,\n onDragCancel,\n commitChange,\n ]\n )\n\n const handleDragEnd = useCallback(\n (event: DragEndEvent) => {\n const { active, over } = event\n setActiveId(null)\n onDragEnd?.(event)\n\n if (!over) {\n // Released over nothing. In default mode the live preview during\n // dragOver may have already moved the item, so commit the current value.\n commitChange(valueRef.current, event, \"item\")\n dragOriginRef.current = null\n return\n }\n\n // Handle item move callback\n if (onMove && !isColumn(active.id)) {\n const activeContainer = findContainer(active.id)\n const overContainer = findContainer(over.id)\n\n if (activeContainer && overContainer) {\n const activeIndex = columns[activeContainer].findIndex(\n (item: T) => getItemValue(item) === active.id\n )\n const overIndex = isColumn(over.id)\n ? columns[overContainer].length\n : columns[overContainer].findIndex(\n (item: T) => getItemValue(item) === over.id\n )\n\n onMove({\n event,\n activeContainer,\n activeIndex,\n overContainer,\n overIndex,\n })\n }\n // In onMove mode the consumer owns applying the item move, so do not\n // fire onValueCommit for item moves; column reorders still commit below.\n dragOriginRef.current = null\n return\n }\n\n // Handle column reordering\n if (isColumn(active.id) && isColumn(over.id)) {\n const activeIndex = columnIds.indexOf(active.id as string)\n const overIndex = columnIds.indexOf(over.id as string)\n if (activeIndex !== overIndex) {\n const newOrder = arrayMove(\n Object.keys(columns),\n activeIndex,\n overIndex\n )\n const newColumns: Record = {}\n newOrder.forEach((key) => {\n newColumns[key] = columns[key]\n })\n setColumns(newColumns)\n commitChange(newColumns, event, \"column\")\n }\n dragOriginRef.current = null\n return\n }\n\n // A column drag that ends over a non-column droppable is not an item move.\n if (isColumn(active.id)) {\n dragOriginRef.current = null\n return\n }\n\n const activeContainer = findContainer(active.id)\n const overContainer = findContainer(over.id)\n\n // Handle item reordering within the same column\n if (\n activeContainer &&\n overContainer &&\n activeContainer === overContainer\n ) {\n const container = activeContainer\n const activeIndex = columns[container].findIndex(\n (item: T) => getItemValue(item) === active.id\n )\n const overIndex = columns[container].findIndex(\n (item: T) => getItemValue(item) === over.id\n )\n\n if (activeIndex !== overIndex) {\n const newColumns = {\n ...columns,\n [container]: arrayMove(columns[container], activeIndex, overIndex),\n }\n setColumns(newColumns)\n commitChange(newColumns, event, \"item\")\n } else {\n // Cross-column moves are applied during dragOver, so the current\n // value is already final.\n commitChange(columns, event, \"item\")\n }\n } else {\n commitChange(columns, event, \"item\")\n }\n dragOriginRef.current = null\n },\n [\n columnIds,\n columns,\n findContainer,\n getItemValue,\n isColumn,\n setColumns,\n onMove,\n onDragEnd,\n commitChange,\n ]\n )\n\n const contextValue = useMemo(\n () => ({\n columns,\n setColumns,\n getItemId: getItemValue,\n columnIds,\n activeId,\n setActiveId,\n findContainer,\n isColumn,\n modifiers,\n }),\n [\n columns,\n setColumns,\n getItemValue,\n columnIds,\n activeId,\n findContainer,\n isColumn,\n modifiers,\n ]\n )\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n \n \n {children}\n \n \n \n )\n}\n\nexport interface KanbanBoardProps extends HTMLAttributes {\n asChild?: boolean\n}\n\nfunction KanbanBoard({\n className,\n asChild = false,\n children,\n ...props\n}: KanbanBoardProps) {\n const { columnIds } = useContext(KanbanContext)\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport interface KanbanColumnProps extends HTMLAttributes {\n value: string\n disabled?: boolean\n asChild?: boolean\n}\n\nfunction KanbanColumn({\n value,\n className,\n asChild = false,\n disabled,\n children,\n ...props\n}: KanbanColumnProps) {\n const isOverlay = useContext(IsOverlayContext)\n\n const {\n setNodeRef,\n transform,\n transition,\n attributes,\n listeners,\n isDragging: isSortableDragging,\n } = useSortable({\n id: value,\n disabled: disabled || isOverlay,\n animateLayoutChanges,\n })\n\n const { activeId, isColumn } = useContext(KanbanContext)\n const isColumnDragging = activeId ? isColumn(activeId) : false\n\n const style = {\n transition,\n transform: CSS.Transform.toString(transform),\n } as CSSProperties\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n if (isOverlay) {\n return (\n \n \n {children}\n \n \n )\n }\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport interface KanbanColumnHandleProps extends HTMLAttributes {\n cursor?: boolean\n asChild?: boolean\n}\n\nfunction KanbanColumnHandle({\n className,\n asChild = false,\n cursor = true,\n children,\n ...props\n}: KanbanColumnHandleProps) {\n const { attributes, listeners, isDragging, disabled } =\n useContext(ColumnContext)\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n {children}\n \n )\n}\n\nexport interface KanbanItemProps extends HTMLAttributes {\n value: string\n disabled?: boolean\n asChild?: boolean\n}\n\nfunction KanbanItem({\n value,\n className,\n asChild = false,\n disabled,\n children,\n ...props\n}: KanbanItemProps) {\n const isOverlay = useContext(IsOverlayContext)\n\n const {\n setNodeRef,\n transform,\n transition,\n attributes,\n listeners,\n isDragging: isSortableDragging,\n } = useSortable({\n id: value,\n disabled: disabled || isOverlay,\n animateLayoutChanges,\n })\n\n const { activeId, isColumn } = useContext(KanbanContext)\n const isItemDragging = activeId ? !isColumn(activeId) : false\n\n const style = {\n transition,\n transform: CSS.Transform.toString(transform),\n } as CSSProperties\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n if (isOverlay) {\n return (\n \n \n {children}\n \n \n )\n }\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport interface KanbanItemHandleProps extends HTMLAttributes {\n cursor?: boolean\n asChild?: boolean\n}\n\nfunction KanbanItemHandle({\n className,\n asChild = false,\n cursor = true,\n children,\n ...props\n}: KanbanItemHandleProps) {\n const { listeners, isDragging, disabled } = useContext(ItemContext)\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n {children}\n \n )\n}\n\nexport interface KanbanColumnContentProps extends HTMLAttributes {\n value: string\n asChild?: boolean\n}\n\nfunction KanbanColumnContent({\n value,\n className,\n asChild = false,\n children,\n ...props\n}: KanbanColumnContentProps) {\n const { columns, getItemId } = useContext(KanbanContext)\n\n const itemIds = useMemo(() => {\n const items = columns[value]\n if (!items) {\n throw new Error(\n `KanbanColumnContent: column \"${value}\" was not found in the Kanban value. ` +\n `Available columns: ${Object.keys(columns).join(\", \") || \"(none)\"}.`\n )\n }\n return items.map(getItemId)\n }, [columns, getItemId, value])\n\n const Comp = asChild ? Slot.Root : \"div\"\n\n return (\n \n \n {children}\n \n \n )\n}\n\nexport interface KanbanOverlayProps extends Omit<\n React.ComponentProps,\n \"children\"\n> {\n children?:\n | ReactNode\n | ((params: {\n value: UniqueIdentifier\n variant: \"column\" | \"item\"\n }) => ReactNode)\n}\n\nfunction KanbanOverlay({ children, className, ...props }: KanbanOverlayProps) {\n const { activeId, isColumn, modifiers } = useContext(KanbanContext)\n const mounted = useSyncExternalStore(\n subscribeToNothing,\n getIsMounted,\n getIsMountedOnServer\n )\n\n const variant = activeId ? (isColumn(activeId) ? \"column\" : \"item\") : \"item\"\n\n const content =\n activeId && children\n ? typeof children === \"function\"\n ? children({ value: activeId, variant })\n : children\n : null\n\n if (!mounted) return null\n\n return createPortal(\n \n \n {content}\n \n ,\n document.body\n )\n}\n\nexport {\n Kanban,\n KanbanBoard,\n KanbanColumn,\n KanbanColumnHandle,\n KanbanItem,\n KanbanItemHandle,\n KanbanColumnContent,\n KanbanOverlay,\n}","target":"components/neui/kanban.tsx"}]}