{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "kanban-task-board",
  "title": "Kanban Task Board",
  "type": "registry:page",
  "files": [
    {
      "path": "packages/registry-angular/blocks/kanban-task-board/kanban-task-board.component.ts",
      "content": "// Full kanban task board surface (SimpleKanban port): status columns with item counts, HTML5\n// drag-and-drop card movement with an insertion indicator, inline quick-add, priority badges,\n// tags, dates and assignee avatars. Port of the Vue/React SimpleKanban 1:1.\nimport { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'\nimport { Calendar, Check, GripVertical, LucideAngularModule, Plus } from 'lucide-angular'\nimport { cn } from '@/lib/utils'\nimport { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'\nimport { UiButtonComponent } from '@/ui/button/button.component'\n\nexport interface SimpleKanbanItem {\n  id: string\n  title: string\n  description?: string\n  date?: string\n  priority?: 'low' | 'medium' | 'high' | 'urgent'\n  tag?: string\n  tagColor?: string\n  assignee?: {\n    name: string\n    initials?: string\n    avatar?: string\n  }\n}\n\nexport interface SimpleKanbanColumn {\n  id: string\n  title: string\n  color?: string\n  dotColor?: string\n  items: SimpleKanbanItem[]\n}\n\nconst DEFAULT_DOT_COLORS: Record<string, string> = {\n  backlog: 'bg-muted-foreground/60',\n  todo: 'bg-info',\n  'in-progress': 'bg-warning',\n  review: 'bg-chart-1',\n  done: 'bg-success',\n}\n\nconst PRIORITY_STYLES: Record<string, { label: string; class: string }> = {\n  low: { label: 'Low', class: 'bg-muted text-muted-foreground' },\n  medium: { label: 'Medium', class: 'bg-info/10 text-info' },\n  high: { label: 'High', class: 'bg-warning/10 text-warning' },\n  urgent: { label: 'Urgent', class: 'bg-destructive/10 text-destructive font-medium' },\n}\n\nconst defaultColumns: SimpleKanbanColumn[] = [\n  {\n    id: 'backlog',\n    title: 'Backlog',\n    dotColor: 'bg-slate-400 dark:bg-slate-500',\n    items: [\n      {\n        id: 'sk-1',\n        title: 'Cross-platform auth synchronization',\n        description: 'Implement token refresh rotation across mobile and web clients.',\n        date: 'May 15 - Jun 2, 2026',\n        priority: 'high',\n        tag: 'Auth',\n        assignee: { name: 'Alice Chen', initials: 'AC' },\n      },\n      {\n        id: 'sk-2',\n        title: 'Scale multi-region edge caching',\n        description: 'Benchmark Cloudflare worker cache hit ratios for static registry files.',\n        date: 'Jun 22 - Jul 10, 2026',\n        priority: 'medium',\n        tag: 'Infra',\n        assignee: { name: 'Dan Ruiz', initials: 'DR' },\n      },\n      {\n        id: 'sk-3',\n        title: 'Extensible drag & drop plugin architecture',\n        date: 'Jul 28 - Aug 14, 2026',\n        priority: 'low',\n        tag: 'Core',\n      },\n    ],\n  },\n  {\n    id: 'in-progress',\n    title: 'In Progress',\n    dotColor: 'bg-amber-500',\n    items: [\n      {\n        id: 'sk-4',\n        title: 'Real-time telemetry event streaming',\n        description: 'Wire SSE endpoints for instant live preview updates.',\n        date: 'May 16 - Jun 9, 2026',\n        priority: 'urgent',\n        tag: 'Realtime',\n        assignee: { name: 'Sarah Miller', initials: 'SM' },\n      },\n      {\n        id: 'sk-5',\n        title: 'OKLCH theme token harmonizer',\n        description: 'Calibrate contrast ratios across light and dark modes.',\n        date: 'Apr 26 - May 20, 2026',\n        priority: 'high',\n        tag: 'Design',\n        assignee: { name: 'Leo Vance', initials: 'LV' },\n      },\n    ],\n  },\n  {\n    id: 'review',\n    title: 'In Review',\n    dotColor: 'bg-purple-500',\n    items: [\n      {\n        id: 'sk-6',\n        title: 'Astro island SSR hydration benchmark',\n        description: 'Verify sub-50ms TTFB across dual-framework demo islands.',\n        date: 'Aug 3 - Aug 18, 2026',\n        priority: 'medium',\n        tag: 'Performance',\n        assignee: { name: 'Alice Chen', initials: 'AC' },\n      },\n    ],\n  },\n  {\n    id: 'done',\n    title: 'Done',\n    dotColor: 'bg-emerald-500',\n    items: [\n      {\n        id: 'sk-7',\n        title: 'Sub-pixel border and shadow layering',\n        description: 'Refined neutral borders matching Linear & Raycast standards.',\n        date: 'Mar 10 - Apr 12, 2026',\n        priority: 'low',\n        tag: 'Craft',\n        assignee: { name: 'Dan Ruiz', initials: 'DR' },\n      },\n      {\n        id: 'sk-8',\n        title: 'Zero-config JSON registry endpoint schema',\n        date: 'Feb 14 - Mar 2, 2026',\n        priority: 'high',\n        tag: 'Registry',\n      },\n    ],\n  },\n]\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-kanban-task-board, [ui-kanban-task-board]',\n  standalone: true,\n  // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.\n  host: { '[attr.class]': '\"contents\"' },\n  imports: [LucideAngularModule, UiAvatarComponent, UiAvatarFallbackComponent, UiButtonComponent],\n  template: `\n    <div data-slot=\"kanban-task-board\" [class]=\"rootClass\">\n      @for (column of columns(); track column.id) {\n        <div\n          data-slot=\"kanban-column\"\n          [class]=\"columnClass(column.id)\"\n          (dragover)=\"handleDragOverColumn($event, column.id, column.items.length)\"\n          (drop)=\"handleDrop($event, column.id)\"\n        >\n          <!-- Column Header -->\n          <div class=\"mb-3 flex items-center justify-between gap-2 px-1\">\n            <div class=\"flex items-center gap-2\">\n              <span [class]=\"dotClass(column)\"></span>\n              <h3 class=\"text-foreground text-sm font-semibold tracking-tight\">{{ column.title }}</h3>\n              <span class=\"bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums\">\n                {{ column.items.length }}\n              </span>\n            </div>\n            @if (allowAdd()) {\n              <button\n                ui-button\n                variant=\"ghost\"\n                size=\"icon\"\n                class=\"text-muted-foreground hover:text-foreground size-7 rounded-lg\"\n                [attr.aria-label]=\"'Add item to ' + column.title\"\n                (click)=\"startAdd(column.id)\"\n              >\n                <lucide-icon [img]=\"Plus\" class=\"size-3.5\" />\n              </button>\n            }\n          </div>\n\n          <!-- Column Cards Container -->\n          <div class=\"flex min-h-[120px] flex-1 flex-col gap-2.5\">\n            @if (addingColumnId() === column.id) {\n              <div\n                class=\"bg-card border-border animate-in fade-in zoom-in-95 rounded-lg border p-2.5 shadow-xs duration-150\"\n              >\n                <input\n                  [value]=\"newTitle()\"\n                  (input)=\"newTitle.set($any($event.target).value)\"\n                  type=\"text\"\n                  placeholder=\"Item title...\"\n                  class=\"placeholder:text-muted-foreground/60 w-full bg-transparent text-sm font-medium outline-none\"\n                  (keydown.enter)=\"handleInlineAdd(column.id)\"\n                  (keydown.escape)=\"addingColumnId.set(null)\"\n                />\n                <div class=\"mt-2.5 flex items-center justify-end gap-1.5\">\n                  <button\n                    ui-button\n                    size=\"sm\"\n                    variant=\"ghost\"\n                    class=\"h-7 px-2 text-xs\"\n                    (click)=\"addingColumnId.set(null)\"\n                  >\n                    Cancel\n                  </button>\n                  <button ui-button size=\"sm\" class=\"h-7 gap-1 px-2.5 text-xs\" (click)=\"handleInlineAdd(column.id)\">\n                    <lucide-icon [img]=\"Check\" class=\"size-3\" />\n                    Add\n                  </button>\n                </div>\n              </div>\n            }\n\n            @for (item of column.items; track item.id; let i = $index) {\n              @if (showGap(column.id, i, item.id)) {\n                <div class=\"bg-primary/20 h-1.5 w-full rounded-full\"></div>\n              }\n              <div\n                role=\"button\"\n                tabindex=\"0\"\n                draggable=\"true\"\n                data-slot=\"kanban-card\"\n                [attr.aria-label]=\"cardAriaLabel(item, column)\"\n                [class]=\"cardClasses(item.id)\"\n                (dragstart)=\"handleDragStart($event, item.id)\"\n                (dragend)=\"handleDragEnd()\"\n                (dragover)=\"handleCardDragOver($event, column.id, i)\"\n                (click)=\"handleCardClick(item, column.id)\"\n                (keydown.enter)=\"handleCardKey($event, item, column.id)\"\n                (keydown.space)=\"handleCardKey($event, item, column.id)\"\n              >\n                <div class=\"flex items-start justify-between gap-2\">\n                  <p class=\"text-foreground line-clamp-2 text-sm leading-snug font-medium\">{{ item.title }}</p>\n                  <lucide-icon\n                    [img]=\"GripVertical\"\n                    class=\"text-muted-foreground/30 size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100\"\n                  />\n                </div>\n                @if (item.description) {\n                  <p class=\"text-muted-foreground line-clamp-2 text-xs leading-relaxed\">{{ item.description }}</p>\n                }\n                @if (item.date || item.tag || item.priority || item.assignee) {\n                  <div\n                    class=\"border-border/40 mt-1 flex flex-wrap items-center justify-between gap-1.5 border-t pt-1 text-xs\"\n                  >\n                    <div class=\"flex flex-wrap items-center gap-1.5\">\n                      @if (item.date) {\n                        <div class=\"text-muted-foreground flex items-center gap-1 text-xs\">\n                          <lucide-icon [img]=\"Calendar\" class=\"text-muted-foreground/70 size-3\" />\n                          <span>{{ item.date }}</span>\n                        </div>\n                      }\n                      @if (item.tag) {\n                        <span [class]=\"tagClass(item)\">{{ item.tag }}</span>\n                      }\n                      @if (item.priority) {\n                        <span [class]=\"priorityClassFor(item)\">{{ priorityLabel(item) }}</span>\n                      }\n                    </div>\n                    @if (item.assignee) {\n                      <ui-avatar class=\"border-background size-5 shrink-0 border\">\n                        <ui-avatar-fallback class=\"text-xs font-medium\">\n                          {{ assigneeInitials(item) }}\n                        </ui-avatar-fallback>\n                      </ui-avatar>\n                    }\n                  </div>\n                }\n              </div>\n            }\n\n            @if (showTrailingGap(column.id, column.items.length)) {\n              <div class=\"bg-primary/20 h-1.5 w-full rounded-full\"></div>\n            }\n\n            @if (column.items.length === 0 && addingColumnId() !== column.id) {\n              <div\n                class=\"border-border/60 text-muted-foreground/60 flex flex-1 items-center justify-center rounded-lg border border-dashed py-8 text-center text-xs\"\n              >\n                Drop items here\n              </div>\n            }\n          </div>\n        </div>\n      }\n    </div>\n  `,\n})\nexport class UiKanbanTaskBoardComponent {\n  protected readonly Calendar = Calendar\n  protected readonly Check = Check\n  protected readonly GripVertical = GripVertical\n  protected readonly Plus = Plus\n\n  @Input() set initialColumns(v: SimpleKanbanColumn[] | undefined) {\n    if (v) this.columns.set(v.map((c) => ({ ...c, items: [...c.items] })))\n  }\n  @Input() set allowInlineAdd(v: boolean) {\n    this.allowAdd.set(v ?? true)\n  }\n  @Input('class') className?: string\n  @Input() cardClass?: string\n\n  @Output() readonly columnsChange = new EventEmitter<SimpleKanbanColumn[]>()\n  @Output() readonly cardClick = new EventEmitter<{ item: SimpleKanbanItem; columnId: string }>()\n  @Output() readonly cardMove = new EventEmitter<{\n    item: SimpleKanbanItem\n    fromColumnId: string\n    toColumnId: string\n    newIndex: number\n  }>()\n  @Output() readonly addItem = new EventEmitter<{ columnId: string; title: string }>()\n\n  readonly columns = signal<SimpleKanbanColumn[]>(defaultColumns.map((c) => ({ ...c, items: [...c.items] })))\n  readonly allowAdd = signal(true)\n  readonly draggedId = signal<string | null>(null)\n  readonly dragOverColumnId = signal<string | null>(null)\n  readonly dropTargetIndex = signal(-1)\n  readonly addingColumnId = signal<string | null>(null)\n  readonly newTitle = signal('')\n  readonly selectedCard = signal<string | null>(null)\n  private lastDragTime = 0\n\n  readonly totalCards = computed(() => this.columns().reduce((n, c) => n + c.items.length, 0))\n\n  get rootClass(): string {\n    return cn('flex min-h-[380px] w-full [scrollbar-width:thin] items-start gap-4 overflow-x-auto pb-4', this.className)\n  }\n\n  dotClass(column: SimpleKanbanColumn): string {\n    return cn('size-2 rounded-full', column.dotColor || column.color || DEFAULT_DOT_COLORS[column.id] || 'bg-primary')\n  }\n\n  columnClass(columnId: string): string {\n    return cn(\n      'bg-muted/40 border-border/80 flex w-72 shrink-0 flex-col rounded-xl border p-3 transition-colors',\n      this.dragOverColumnId() === columnId && 'border-primary/50 bg-muted/60 ring-primary/10 ring-2',\n    )\n  }\n\n  cardClasses(id: string): string {\n    return cn(\n      'group bg-card text-card-foreground border-border/80 hover:border-border relative flex cursor-grab flex-col gap-2 rounded-lg border p-3 shadow-xs transition-colors hover:shadow-sm active:cursor-grabbing',\n      'focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]',\n      this.draggedId() === id && 'ring-primary/40 opacity-40 shadow-md ring-2',\n      this.cardClass,\n    )\n  }\n\n  // Screen-reader name: title first, then column + priority for context.\n  cardAriaLabel(item: SimpleKanbanItem, column: SimpleKanbanColumn): string {\n    const priority = item.priority ? `${this.priorityLabel(item)} priority` : ''\n    return [item.title, column.title, priority].filter(Boolean).join(', ')\n  }\n\n  tagClass(item: SimpleKanbanItem): string {\n    return cn('rounded-md px-1.5 py-0.5 text-xs font-medium', item.tagColor || 'bg-secondary text-secondary-foreground')\n  }\n\n  priorityLabel(item: SimpleKanbanItem): string {\n    if (!item.priority) return ''\n    return PRIORITY_STYLES[item.priority]?.label ?? item.priority\n  }\n\n  priorityClassFor(item: SimpleKanbanItem): string {\n    if (!item.priority) return ''\n    return cn('rounded-md px-1.5 py-0.5 text-xs', PRIORITY_STYLES[item.priority]?.class ?? '')\n  }\n\n  assigneeInitials(item: SimpleKanbanItem): string {\n    const a = item.assignee\n    if (!a) return ''\n    return a.initials || a.name.slice(0, 2).toUpperCase()\n  }\n\n  showGap(columnId: string, index: number, itemId: string): boolean {\n    return this.dragOverColumnId() === columnId && this.dropTargetIndex() === index && this.draggedId() !== itemId\n  }\n\n  showTrailingGap(columnId: string, itemsCount: number): boolean {\n    return this.dragOverColumnId() === columnId && (this.dropTargetIndex() ?? -1) >= itemsCount && !!this.draggedId()\n  }\n\n  handleDragStart(e: DragEvent, id: string): void {\n    this.draggedId.set(id)\n    if (e.dataTransfer) {\n      e.dataTransfer.effectAllowed = 'move'\n      e.dataTransfer.setData('text/plain', id)\n    }\n  }\n\n  handleDragEnd(): void {\n    this.draggedId.set(null)\n    this.dragOverColumnId.set(null)\n    this.dropTargetIndex.set(-1)\n    this.lastDragTime = Date.now()\n  }\n\n  handleDragOverColumn(e: DragEvent, columnId: string, itemsCount: number): void {\n    e.preventDefault()\n    if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'\n    this.dragOverColumnId.set(columnId)\n    this.dropTargetIndex.set(itemsCount)\n  }\n\n  handleCardDragOver(e: DragEvent, columnId: string, index: number): void {\n    e.preventDefault()\n    e.stopPropagation()\n    if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'\n    this.dragOverColumnId.set(columnId)\n    const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()\n    const midY = rect.top + rect.height / 2\n    this.dropTargetIndex.set(e.clientY < midY ? index : index + 1)\n  }\n\n  handleDrop(e: DragEvent, targetColumnId: string): void {\n    e.preventDefault()\n    const dragged = this.draggedId()\n    if (!dragged || !targetColumnId) {\n      this.handleDragEnd()\n      return\n    }\n    const next = this.columns().map((col) => ({ ...col, items: [...col.items] }))\n    let sourceColIdx = -1\n    let itemIdx = -1\n    for (let c = 0; c < next.length; c++) {\n      const idx = next[c]!.items.findIndex((item) => item.id === dragged)\n      if (idx !== -1) {\n        sourceColIdx = c\n        itemIdx = idx\n        break\n      }\n    }\n    const targetColIdx = next.findIndex((c) => c.id === targetColumnId)\n    if (sourceColIdx === -1 || targetColIdx === -1) {\n      this.handleDragEnd()\n      return\n    }\n    const sourceCol = next[sourceColIdx]!\n    const targetCol = next[targetColIdx]!\n    const [removed] = sourceCol.items.splice(itemIdx, 1)\n    if (!removed) {\n      this.handleDragEnd()\n      return\n    }\n    let at = this.dropTargetIndex()\n    if (at < 0) at = targetCol.items.length\n    if (sourceColIdx === targetColIdx && itemIdx < at) at--\n    targetCol.items.splice(at, 0, removed)\n    this.columns.set(next)\n    this.cardMove.emit({ item: removed, fromColumnId: sourceCol.id, toColumnId: targetColumnId, newIndex: at })\n    this.columnsChange.emit(next)\n    this.handleDragEnd()\n  }\n\n  handleCardClick(item: SimpleKanbanItem, columnId: string): void {\n    if (Date.now() - this.lastDragTime < 150) return\n    this.selectedCard.set(item.id)\n    this.cardClick.emit({ item, columnId })\n  }\n\n  // `Event`, not KeyboardEvent: strictTemplates types `(keydown.enter)`'s $event as Event.\n  // Only the card itself: Enter/Space inside a nested control must not open the card.\n  handleCardKey(e: Event, item: SimpleKanbanItem, columnId: string): void {\n    if (e.target !== e.currentTarget) return\n    e.preventDefault()\n    this.handleCardClick(item, columnId)\n  }\n\n  startAdd(columnId: string): void {\n    this.addingColumnId.set(columnId)\n    this.newTitle.set('')\n  }\n\n  handleInlineAdd(columnId: string): void {\n    const text = this.newTitle().trim()\n    if (!text) {\n      this.addingColumnId.set(null)\n      return\n    }\n    if (this.addItem.observed) {\n      this.addItem.emit({ columnId, title: text })\n    } else {\n      const next = this.columns().map((col) =>\n        col.id === columnId ? { ...col, items: [...col.items, { id: `item-${Date.now()}`, title: text }] } : col,\n      )\n      this.columns.set(next)\n      this.columnsChange.emit(next)\n    }\n    this.newTitle.set('')\n    this.addingColumnId.set(null)\n  }\n\n  /** Programmatic move (used by specs + consumers without DOM drag events). */\n  moveCard(itemId: string, toColumnId: string, toIndex?: number): boolean {\n    const next = this.columns().map((col) => ({ ...col, items: [...col.items] }))\n    let removed: SimpleKanbanItem | undefined\n    let fromColumnId = ''\n    for (const col of next) {\n      const idx = col.items.findIndex((i) => i.id === itemId)\n      if (idx !== -1) {\n        fromColumnId = col.id\n        removed = col.items.splice(idx, 1)[0]\n        break\n      }\n    }\n    const target = next.find((c) => c.id === toColumnId)\n    if (!removed || !target) return false\n    const at = toIndex === undefined ? target.items.length : Math.max(0, Math.min(toIndex, target.items.length))\n    target.items.splice(at, 0, removed)\n    this.columns.set(next)\n    this.cardMove.emit({ item: removed, fromColumnId, toColumnId, newIndex: at })\n    this.columnsChange.emit(next)\n    return true\n  }\n}\n",
      "type": "registry:page",
      "target": "~/src/app/components/blocks/kanban-task-board/kanban-task-board.component.ts"
    },
    {
      "path": "packages/registry-angular/blocks/kanban-task-board/index.ts",
      "content": "export {\n  UiKanbanTaskBoardComponent,\n  type SimpleKanbanItem,\n  type SimpleKanbanColumn,\n} from './kanban-task-board.component'\n",
      "type": "registry:page",
      "target": "~/src/app/components/blocks/kanban-task-board/index.ts"
    }
  ],
  "dependencies": [
    "lucide-angular"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/button.json",
    "https://uipkge.dev/r/angular/avatar.json"
  ],
  "description": "Full kanban task board surface: 4 status columns, drag-and-drop card movement with insertion indicator, board/list view toggle, search + priority + assignee filters, collapsible columns, click-to-open task detail Sheet (comments, subtasks, files), full-form Add Task Dialog with priority/assignee/due-date/tags/subtasks. Mega-block: ships 14 Vue files (KanbanBoard + 6 kanban-* views + 7 small badges/lists). Bind with v-model:columns; consumer owns the columns array so swapping the in-memory store for Drizzle/Postgres is a one-line change. Auto-pulls the use-kanban hook (types + configs + helpers) and kanban-data lib (seed columns).",
  "categories": [
    "productivity",
    "dashboard",
    "data"
  ]
}