{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "kanban-board",
  "title": "Kanban Board",
  "description": "A drag-and-drop kanban board with collapsible columns and animated card transitions.",
  "dependencies": [
    "@dnd-kit/core",
    "@dnd-kit/sortable",
    "@dnd-kit/utilities",
    "motion",
    "clsx",
    "tailwind-merge"
  ],
  "files": [
    {
      "path": "registry/wise-ui/components/kanban-board.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  DndContext,\n  closestCorners,\n  KeyboardSensor,\n  PointerSensor,\n  useSensor,\n  useSensors,\n  DragOverlay,\n  type DragStartEvent,\n  type DragEndEvent,\n  type DragOverEvent,\n} from \"@dnd-kit/core\"\nimport {\n  arrayMove,\n  SortableContext,\n  sortableKeyboardCoordinates,\n  useSortable,\n  verticalListSortingStrategy,\n} from \"@dnd-kit/sortable\"\nimport { CSS } from \"@dnd-kit/utilities\"\nimport { motion, AnimatePresence } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nconst EASE_OUT_QUART: [number, number, number, number] = [0.25, 1, 0.5, 1]\n\n// ── Types ────────────────────────────────────────────────────────────\n\ninterface KanbanTag {\n  id: string\n  label: string\n  color?: string\n}\n\ninterface KanbanColumn<T extends { id: string | number }> {\n  id: string\n  title: string\n  items: T[]\n}\n\ninterface KanbanBoardProps<T extends { id: string | number }> {\n  columns: KanbanColumn<T>[]\n  onColumnsChange: (columns: KanbanColumn<T>[]) => void\n  renderItem: (item: T, columnId: string) => React.ReactNode\n  onAddItem?: (columnId: string, title: string, tagId?: string) => void\n  tags?: KanbanTag[]\n  /** Extract the tag id from an item - used for tag filtering */\n  getItemTagId?: (item: T) => string | undefined\n  collapsible?: boolean\n  className?: string\n  columnClassName?: string\n  minColumnWidth?: number\n  collapsedColumnWidth?: number\n}\n\n// ── Sortable Item ────────────────────────────────────────────────────\n\nfunction KanbanSortableItem({\n  id,\n  children,\n  isDragOverlay,\n}: {\n  id: string | number\n  children: React.ReactNode\n  isDragOverlay?: boolean\n}) {\n  const {\n    attributes,\n    listeners,\n    setNodeRef,\n    transform,\n    isDragging,\n  } = useSortable({ id })\n\n  if (isDragOverlay) {\n    return (\n      <motion.div\n        className=\"touch-none cursor-grabbing\"\n        initial={{ scale: 1, boxShadow: \"0 1px 3px rgba(0,0,0,0.08)\" }}\n        animate={{\n          scale: 1.03,\n          boxShadow: \"0 12px 40px rgba(0,0,0,0.15), 0 4px 12px rgba(0,0,0,0.1)\",\n        }}\n        transition={{ duration: 0.2, ease: EASE_OUT_QUART }}\n      >\n        {children}\n      </motion.div>\n    )\n  }\n\n  const style: React.CSSProperties = {\n    transform: CSS.Translate.toString(transform),\n    transition: isDragging\n      ? \"opacity 200ms ease\"\n      : \"transform 250ms cubic-bezier(0.25, 1, 0.5, 1), opacity 200ms ease\",\n  }\n\n  return (\n    <div\n      ref={setNodeRef}\n      style={style}\n      className={cn(\n        \"touch-none cursor-grab active:cursor-grabbing\",\n        isDragging && \"opacity-40\"\n      )}\n      {...attributes}\n      {...listeners}\n    >\n      {children}\n    </div>\n  )\n}\n\n// ── Add Item Form ────────────────────────────────────────────────────\n\nfunction AddItemForm({\n  tags,\n  onSubmit,\n  onCancel,\n}: {\n  tags?: KanbanTag[]\n  onSubmit: (title: string, tagId?: string) => void\n  onCancel: () => void\n}) {\n  const [title, setTitle] = React.useState(\"\")\n  const [selectedTag, setSelectedTag] = React.useState<string | undefined>()\n  const inputRef = React.useRef<HTMLInputElement>(null)\n\n  React.useEffect(() => {\n    inputRef.current?.focus()\n  }, [])\n\n  function handleSubmit(e: React.FormEvent) {\n    e.preventDefault()\n    const trimmed = title.trim()\n    if (!trimmed) return\n    onSubmit(trimmed, selectedTag)\n    setTitle(\"\")\n    setSelectedTag(undefined)\n    inputRef.current?.focus()\n  }\n\n  return (\n    <motion.form\n      initial={{ opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={{ opacity: 0, height: 0 }}\n      transition={{ duration: 0.3, ease: EASE_OUT_QUART }}\n      className=\"overflow-hidden px-3 pb-3\"\n      onSubmit={handleSubmit}\n    >\n      <div className=\"rounded-lg border border-border bg-card p-2.5\">\n        <input\n          ref={inputRef}\n          type=\"text\"\n          value={title}\n          onChange={(e) => setTitle(e.target.value)}\n          placeholder=\"Task title…\"\n          className=\"w-full bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none\"\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") onCancel()\n          }}\n        />\n\n        {tags && tags.length > 0 && (\n          <div className=\"mt-2 flex flex-wrap gap-1\">\n            {tags.map((tag) => (\n              <button\n                key={tag.id}\n                type=\"button\"\n                onClick={() =>\n                  setSelectedTag((s) => (s === tag.id ? undefined : tag.id))\n                }\n                className={cn(\n                  \"rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider transition-all duration-200 ease-out\",\n                  tag.color,\n                  selectedTag === tag.id\n                    ? \"ring-1 ring-foreground/30 scale-110 opacity-100\"\n                    : \"opacity-40 hover:opacity-70 hover:scale-105\"\n                )}\n              >\n                {tag.label}\n              </button>\n            ))}\n          </div>\n        )}\n\n        <div className=\"mt-2 flex items-center gap-1.5\">\n          <button\n            type=\"submit\"\n            disabled={!title.trim()}\n            className=\"rounded-md bg-foreground/10 px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-foreground/20 disabled:opacity-30 disabled:cursor-not-allowed\"\n          >\n            Add\n          </button>\n          <button\n            type=\"button\"\n            onClick={onCancel}\n            className=\"rounded-md px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground\"\n          >\n            Cancel\n          </button>\n        </div>\n      </div>\n    </motion.form>\n  )\n}\n\n// ── Droppable Column ─────────────────────────────────────────────────\n\nfunction KanbanColumnContainer<T extends { id: string | number }>({\n  column,\n  renderItem,\n  onAddItem,\n  tags,\n  collapsible,\n  columnClassName,\n  minColumnWidth,\n  collapsedColumnWidth,\n}: {\n  column: KanbanColumn<T>\n  renderItem: (item: T, columnId: string) => React.ReactNode\n  onAddItem?: (columnId: string, title: string, tagId?: string) => void\n  tags?: KanbanTag[]\n  collapsible: boolean\n  columnClassName?: string\n  minColumnWidth: number\n  collapsedColumnWidth: number\n}) {\n  const [collapsed, setCollapsed] = React.useState(false)\n  const [addingItem, setAddingItem] = React.useState(false)\n\n  const { setNodeRef } = useSortable({\n    id: column.id,\n    data: { type: \"column\" },\n    disabled: true,\n  })\n\n  return (\n    <div\n      ref={setNodeRef}\n      className={cn(\n        \"flex flex-col rounded-xl border border-border bg-muted/30 overflow-hidden\",\n        collapsed ? \"shrink-0\" : \"flex-1 basis-0\",\n        columnClassName\n      )}\n      style={{\n        width: collapsed ? collapsedColumnWidth : undefined,\n        minWidth: collapsed ? collapsedColumnWidth : minColumnWidth,\n        transition: \"width 400ms cubic-bezier(0.25, 1, 0.5, 1), min-width 400ms cubic-bezier(0.25, 1, 0.5, 1), flex 400ms cubic-bezier(0.25, 1, 0.5, 1)\",\n      }}\n    >\n      {collapsed ? (\n        /* ── Collapsed state: rotated title + count + eye icon ── */\n        <button\n          onClick={() => setCollapsed(false)}\n          className=\"flex h-full w-full flex-col items-center gap-3 py-4 text-muted-foreground transition-colors hover:text-foreground\"\n        >\n          {/* Eye icon */}\n          <svg\n            width=\"16\"\n            height=\"16\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            className=\"shrink-0\"\n          >\n            <path d=\"M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z\" />\n            <circle cx=\"12\" cy=\"12\" r=\"3\" />\n          </svg>\n\n          {/* Count badge */}\n          <span className=\"inline-flex size-5 items-center justify-center rounded-full bg-muted text-[11px] font-medium\">\n            {column.items.length}\n          </span>\n\n          {/* Rotated title */}\n          <span\n            className=\"text-sm font-semibold whitespace-nowrap\"\n            style={{\n              writingMode: \"vertical-lr\",\n              transform: \"rotate(180deg)\",\n            }}\n          >\n            {column.title}\n          </span>\n        </button>\n      ) : (\n        /* ── Expanded state ── */\n        <>\n          {/* Column header */}\n          <div className=\"flex items-center gap-2 px-3 py-3\">\n            <h3 className=\"text-sm font-semibold text-foreground\">\n              {column.title}\n            </h3>\n            <span className=\"inline-flex size-5 items-center justify-center rounded-full bg-muted text-[11px] font-medium text-muted-foreground\">\n              {column.items.length}\n            </span>\n            {collapsible && (\n              <button\n                onClick={() => setCollapsed(true)}\n                className=\"ml-auto flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:text-foreground\"\n                aria-label={`Collapse ${column.title}`}\n              >\n                {/* Eye-off icon */}\n                <svg\n                  width=\"14\"\n                  height=\"14\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"2\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                >\n                  <path d=\"M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94\" />\n                  <path d=\"M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19\" />\n                  <line x1=\"1\" y1=\"1\" x2=\"23\" y2=\"23\" />\n                </svg>\n              </button>\n            )}\n          </div>\n\n          {/* Column items */}\n          <SortableContext\n            items={column.items.map((i) => i.id)}\n            strategy={verticalListSortingStrategy}\n          >\n            <div className=\"flex min-h-[40px] flex-1 flex-col gap-2 px-3 pb-2\">\n              {column.items.map((item) => (\n                <KanbanSortableItem key={item.id} id={item.id}>\n                  {renderItem(item, column.id)}\n                </KanbanSortableItem>\n              ))}\n            </div>\n          </SortableContext>\n\n          {/* Add item(s) */}\n          {onAddItem && (\n            <AnimatePresence initial={false} mode=\"wait\">\n              {addingItem ? (\n                <AddItemForm\n                  key=\"form\"\n                  tags={tags}\n                  onSubmit={(title, tagId) =>\n                    onAddItem(column.id, title, tagId)\n                  }\n                  onCancel={() => setAddingItem(false)}\n                />\n              ) : (\n                <motion.div\n                  key=\"button\"\n                  className=\"px-3 pb-3\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 0.2, ease: EASE_OUT_QUART }}\n                >\n                  <button\n                    onClick={() => setAddingItem(true)}\n                    className=\"flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-2 text-sm text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground\"\n                  >\n                    <svg\n                      width=\"14\"\n                      height=\"14\"\n                      viewBox=\"0 0 14 14\"\n                      fill=\"none\"\n                      stroke=\"currentColor\"\n                      strokeWidth=\"2\"\n                      strokeLinecap=\"round\"\n                    >\n                      <line x1=\"7\" y1=\"2\" x2=\"7\" y2=\"12\" />\n                      <line x1=\"2\" y1=\"7\" x2=\"12\" y2=\"7\" />\n                    </svg>\n                    Add item(s)\n                  </button>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          )}\n        </>\n      )}\n    </div>\n  )\n}\n\n// ── Main KanbanBoard ─────────────────────────────────────────────────\n\nfunction KanbanBoard<T extends { id: string | number }>({\n  columns,\n  onColumnsChange,\n  renderItem,\n  onAddItem,\n  tags,\n  getItemTagId,\n  collapsible = true,\n  className,\n  columnClassName,\n  minColumnWidth = 200,\n  collapsedColumnWidth = 44,\n}: KanbanBoardProps<T>) {\n  const [activeId, setActiveId] = React.useState<string | number | null>(null)\n  const [activeTagFilter, setActiveTagFilter] = React.useState<string | null>(null)\n\n  const sensors = useSensors(\n    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),\n    useSensor(KeyboardSensor, {\n      coordinateGetter: sortableKeyboardCoordinates,\n    })\n  )\n\n  // Filter columns by active tag\n  const displayColumns = React.useMemo(() => {\n    if (!activeTagFilter || !getItemTagId) return columns\n    return columns.map((col) => ({\n      ...col,\n      items: col.items.filter(\n        (item) => getItemTagId(item) === activeTagFilter\n      ),\n    }))\n  }, [columns, activeTagFilter, getItemTagId])\n\n  function findColumnOfItem(itemId: string | number): string | undefined {\n    // Always search in the unfiltered columns for DnD\n    return columns.find((col) => col.items.some((i) => i.id === itemId))?.id\n  }\n\n  const activeItem = React.useMemo(() => {\n    if (!activeId) return null\n    for (const col of columns) {\n      const item = col.items.find((i) => i.id === activeId)\n      if (item) return { item, columnId: col.id }\n    }\n    return null\n  }, [columns, activeId])\n\n  function handleDragStart(event: DragStartEvent) {\n    setActiveId(event.active.id as string | number)\n  }\n\n  function handleDragOver(event: DragOverEvent) {\n    const { active, over } = event\n    if (!over) return\n\n    const activeColId = findColumnOfItem(active.id as string | number)\n    let overColId = findColumnOfItem(over.id as string | number)\n    if (!overColId && columns.some((c) => c.id === over.id)) {\n      overColId = over.id as string\n    }\n    if (!activeColId || !overColId || activeColId === overColId) return\n\n    const newColumns = columns.map((col) => ({\n      ...col,\n      items: [...col.items],\n    }))\n\n    const sourceCol = newColumns.find((c) => c.id === activeColId)!\n    const destCol = newColumns.find((c) => c.id === overColId)!\n\n    const activeIndex = sourceCol.items.findIndex((i) => i.id === active.id)\n    const [movedItem] = sourceCol.items.splice(activeIndex, 1)\n\n    const overIndex = destCol.items.findIndex((i) => i.id === over.id)\n    if (overIndex >= 0) {\n      destCol.items.splice(overIndex, 0, movedItem)\n    } else {\n      destCol.items.push(movedItem)\n    }\n\n    onColumnsChange(newColumns)\n  }\n\n  function handleDragEnd(event: DragEndEvent) {\n    const { active, over } = event\n    setActiveId(null)\n\n    if (!over || active.id === over.id) return\n\n    const activeColId = findColumnOfItem(active.id as string | number)\n    const overColId = findColumnOfItem(over.id as string | number)\n\n    if (activeColId && overColId && activeColId === overColId) {\n      const newColumns = columns.map((col) => {\n        if (col.id !== activeColId) return col\n        const oldIndex = col.items.findIndex((i) => i.id === active.id)\n        const newIndex = col.items.findIndex((i) => i.id === over.id)\n        return { ...col, items: arrayMove(col.items, oldIndex, newIndex) }\n      })\n      onColumnsChange(newColumns)\n    }\n  }\n\n  const allItemIds = displayColumns.flatMap((col) => col.items.map((i) => i.id))\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      {/* Tag filter bar */}\n      {tags && tags.length > 0 && getItemTagId && (\n        <div className=\"flex items-center gap-1.5 px-1 pb-2\">\n          <button\n            onClick={() => setActiveTagFilter(null)}\n            className={cn(\n              \"rounded-full px-2.5 py-1 text-[11px] font-semibold transition-all duration-200 ease-out\",\n              activeTagFilter === null\n                ? \"bg-foreground/10 text-foreground\"\n                : \"text-muted-foreground/60 hover:text-muted-foreground\"\n            )}\n          >\n            All\n          </button>\n          {tags.map((tag) => (\n            <button\n              key={tag.id}\n              onClick={() =>\n                setActiveTagFilter((f) => (f === tag.id ? null : tag.id))\n              }\n              className={cn(\n                \"rounded-full px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider transition-all duration-200 ease-out\",\n                tag.color,\n                activeTagFilter === tag.id\n                  ? \"opacity-100 ring-1 ring-foreground/20\"\n                  : \"opacity-40 hover:opacity-70\"\n              )}\n            >\n              {tag.label}\n            </button>\n          ))}\n        </div>\n      )}\n\n      <DndContext\n        sensors={sensors}\n        collisionDetection={closestCorners}\n        onDragStart={handleDragStart}\n        onDragOver={handleDragOver}\n        onDragEnd={handleDragEnd}\n      >\n        <SortableContext items={[...allItemIds, ...displayColumns.map((c) => c.id)]}>\n          <div className={cn(\"flex flex-1 gap-3 p-1\", className)}>\n            {displayColumns.map((column) => (\n              <KanbanColumnContainer\n                key={column.id}\n                column={column}\n                renderItem={renderItem}\n                onAddItem={activeTagFilter ? undefined : onAddItem}\n                tags={tags}\n                collapsible={collapsible}\n                columnClassName={columnClassName}\n                minColumnWidth={minColumnWidth}\n                collapsedColumnWidth={collapsedColumnWidth}\n              />\n            ))}\n          </div>\n        </SortableContext>\n\n        <DragOverlay>\n          {activeItem ? (\n            <KanbanSortableItem id={activeItem.item.id} isDragOverlay>\n              {renderItem(activeItem.item, activeItem.columnId)}\n            </KanbanSortableItem>\n          ) : null}\n        </DragOverlay>\n      </DndContext>\n    </div>\n  )\n}\n\nexport { KanbanBoard }\nexport type { KanbanBoardProps, KanbanColumn, KanbanTag }\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}