Menu

Change language

GitHub repo

Kanban Task Board

blockproductivity

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 React files (KanbanBoard + 6 kanban-* views + 7 small badges/lists). Bind with columns/onColumnsChange; 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).

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/kanban-task-board.json
Manifest:https://uipkge.dev/r/lit/kanban-task-board.jsonInstalls to:src/uip/blocks/kanban-task-board/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
headingstringKanban Boardoptional
descriptionstringDrag tasks across columns to update their status.optional

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

BoardTaskComment
interface BoardTaskComment {
  id: string
  author: string
  text: string
  time: string
}
BoardTask
interface BoardTask {
  id: string
  title: string
  description?: string
  priority: 'low' | 'medium' | 'high' | 'urgent'
  tags: Array<{ label: string; color: string }>
  dueDate?: string
  assignee: { name: string; color: string }
  comments: BoardTaskComment[]
}
BoardColumn
interface BoardColumn {
  id: string
  title: string
  dotColor: string
  tasks: BoardTask[]
}
HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-kanban-task-board': UipKanbanTaskBoard
  }
}
SimpleKanbanItem
interface SimpleKanbanItem {
  id: string
  title: string
  description?: string
  date?: string
  priority?: 'low' | 'medium' | 'high' | 'urgent'
  tag?: string
  tagColor?: string
  assignee?: {
    name: string
    initials?: string
    avatar?: string
  }
}
SimpleKanbanColumn
interface SimpleKanbanColumn {
  id: string
  title: string
  color?: string
  dotColor?: string
  items: SimpleKanbanItem[]
}

Files installed (2)

  • src/uip/blocks/kanban-task-board/kanban-task-board.ts34.9 kB
    import { LitElement, css, html, nothing } from 'lit'
    import {
      Calendar,
      ChevronsLeft,
      ChevronsRight,
      LayoutGrid,
      List,
      MessageSquare,
      Plus,
      Search,
      Trash2,
    } from 'lucide'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    import { icon } from '../../lib/icon'
    // Side-effect imports: register the composed elements for standalone use
    // (consumers who install only this block still get working primitives).
    import '../../components/avatar/avatar'
    import '../../components/badge/badge'
    import '../../components/button/button'
    import '../../components/dialog/dialog'
    import '../../components/input/input'
    import '../../components/label/label'
    import '../../components/select/select'
    import '../../components/sheet/sheet'
    import '../../components/textarea/textarea'
    import type { UipInput } from '../../components/input/input'
    import type { UipSelect } from '../../components/select/select'
    
    export interface BoardTaskComment {
      id: string
      author: string
      text: string
      time: string
    }
    
    export interface BoardTask {
      id: string
      title: string
      description?: string
      priority: 'low' | 'medium' | 'high' | 'urgent'
      tags: Array<{ label: string; color: string }>
      dueDate?: string
      assignee: { name: string; color: string }
      comments: BoardTaskComment[]
    }
    
    export interface BoardColumn {
      id: string
      title: string
      dotColor: string
      tasks: BoardTask[]
    }
    
    const PRIORITY_LABEL: Record<BoardTask['priority'], string> = {
      low: 'Low',
      medium: 'Medium',
      high: 'High',
      urgent: 'Urgent',
    }
    
    function priorityAccent(p: BoardTask['priority']): string {
      switch (p) {
        case 'low':
          return 'bg-muted-foreground'
        case 'medium':
          return 'bg-info'
        case 'high':
          return 'bg-warning'
        default:
          return 'bg-destructive'
      }
    }
    
    function initials(name: string): string {
      return name
        .split(' ')
        .map((p) => p[0])
        .join('')
        .slice(0, 2)
        .toUpperCase()
    }
    
    function seedBoardColumns(): BoardColumn[] {
      return [
        {
          id: 'backlog',
          title: 'Backlog',
          dotColor: 'bg-muted-foreground/60',
          tasks: [
            {
              id: 'KAN-101',
              title: 'Cross-platform auth synchronization',
              description: 'Implement token refresh rotation across mobile and web clients.',
              priority: 'high',
              tags: [{ label: 'Auth', color: 'bg-chart-1/10 text-chart-1 ring-chart-1/20' }],
              dueDate: 'Jun 2, 2026',
              assignee: { name: 'Alice Chen', color: 'bg-chart-1/15 text-chart-1' },
              comments: [{ id: 'c1', author: 'Dan Ruiz', text: 'Needs a spike on refresh races first.', time: '2d ago' }],
            },
            {
              id: 'KAN-102',
              title: 'Scale multi-region edge caching',
              priority: 'medium',
              tags: [{ label: 'Infra', color: 'bg-info/10 text-info ring-info/20' }],
              dueDate: 'Jul 10, 2026',
              assignee: { name: 'Dan Ruiz', color: 'bg-info/10 text-info' },
              comments: [],
            },
          ],
        },
        {
          id: 'todo',
          title: 'To Do',
          dotColor: 'bg-info',
          tasks: [
            {
              id: 'KAN-103',
              title: 'OKLCH theme token harmonizer',
              description: 'Calibrate contrast ratios across light and dark modes.',
              priority: 'high',
              tags: [{ label: 'Design', color: 'bg-warning/10 text-warning ring-warning/20' }],
              dueDate: 'May 20, 2026',
              assignee: { name: 'Leo Vance', color: 'bg-warning/10 text-warning' },
              comments: [],
            },
          ],
        },
        {
          id: 'in-progress',
          title: 'In Progress',
          dotColor: 'bg-warning',
          tasks: [
            {
              id: 'KAN-104',
              title: 'Real-time telemetry event streaming',
              description: 'Wire SSE endpoints for instant live preview updates.',
              priority: 'urgent',
              tags: [
                { label: 'Realtime', color: 'bg-destructive/10 text-destructive ring-destructive/20' },
                { label: 'Backend', color: 'bg-chart-2/10 text-chart-2 ring-chart-2/20' },
              ],
              dueDate: 'Jun 9, 2026',
              assignee: { name: 'Sarah Miller', color: 'bg-success/10 text-success' },
              comments: [
                { id: 'c1', author: 'Sarah Miller', text: 'SSE endpoint live on staging.', time: '3h ago' },
                { id: 'c2', author: 'Leo Vance', text: 'Client reconnect looks solid.', time: '1h ago' },
              ],
            },
          ],
        },
        {
          id: 'done',
          title: 'Done',
          dotColor: 'bg-success',
          tasks: [
            {
              id: 'KAN-105',
              title: 'Sub-pixel border and shadow layering',
              priority: 'low',
              tags: [{ label: 'Craft', color: 'bg-muted text-muted-foreground ring-muted-foreground/20' }],
              assignee: { name: 'Dan Ruiz', color: 'bg-info/10 text-info' },
              comments: [],
            },
          ],
        },
      ]
    }
    
    /**
     * <uip-kanban-task-board> — the registry KanbanBoard block as a web component.
     *
     * Ports the full enterprise surface (header, toolbar with search + priority
     * + assignee filters, board/list toggle, collapsible drag-and-drop columns,
     * task detail sheet with comments, add-task dialog) with class strings kept
     * close to React's KanbanBoard / KanbanCard / KanbanToolbar.
     *
     * Deviations from React: the 14-file decomposition (KanbanCard, KanbanColumn,
     * KanbanListView, KanbanTaskSheet, KanbanAddTaskDialog, badges…) is folded
     * into this single file; subtask checklists and file attachments are
     * dropped (cards keep tags, due date, comment counts, assignee); the
     * assignee filter is a select instead of the avatar stack; card dropdown
     * menus and tooltips are dropped (click opens the sheet); `columns` is a
     * settable property with a `columns-change` event instead of the
     * use-kanban/kanban-data store. React's injected card-in keyframes are
     * copied into `static styles` (allowed exception).
     */
    export class UipKanbanTaskBoard extends LitElement {
      static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }
      // :host display can't be a utility class (the host has no template of its own).
      // card-in is copied from React's injected <style> (keyframes exception).
      static styles = [
        tailwind,
        css`:host { display: block; }`,
        css`
          .kanban-card { animation: card-in 0.25s ease-out both; }
          @keyframes card-in {
            from { opacity: 0; transform: translateY(6px); }
          }
        `,
      ]
    
      static properties = {
        columns: { attribute: false },
        heading: {},
        description: {},
        searchQuery: { state: true },
        selectedPriority: { state: true },
        selectedAssignee: { state: true },
        viewMode: { state: true },
        collapsed: { state: true },
        detailOpen: { state: true },
        detailTaskId: { state: true },
        addOpen: { state: true },
        addColumnId: { state: true },
        draggedId: { state: true },
        dragOverColumn: { state: true },
        dropIndex: { state: true },
        addTitle: { state: true },
        addDescription: { state: true },
        addPriority: { state: true },
        addAssignee: { state: true },
        addDueDate: { state: true },
        commentDraft: { state: true },
      }
    
      columns: BoardColumn[] = []
      /** React's `title` (`title` is taken by HTMLElement). */
      heading = 'Kanban Board'
      description = 'Drag tasks across columns to update their status.'
    
      private searchQuery = ''
      private selectedPriority: '' | BoardTask['priority'] = ''
      private selectedAssignee = ''
      private viewMode: 'board' | 'list' = 'board'
      private collapsed: string[] = []
      private detailOpen = false
      private detailTaskId: string | null = null
      private addOpen = false
      private addColumnId = 'backlog'
      private draggedId: string | null = null
      private dragOverColumn: string | null = null
      private dropIndex = -1
      private addTitle = ''
      private addDescription = ''
      private addPriority: BoardTask['priority'] = 'medium'
      private addAssignee = 'Alice Chen'
      private addDueDate = ''
      private commentDraft = ''
      private lastDragEnd = 0
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'kanban-task-board')
        if (this.columns.length === 0) this.columns = seedBoardColumns()
      }
    
      private commit(mutator: (cols: BoardColumn[]) => void) {
        const next = this.columns.map((c) => ({ ...c, tasks: [...c.tasks] }))
        mutator(next)
        this.columns = next
        this.dispatchEvent(new CustomEvent('columns-change', { detail: next, bubbles: true, composed: true }))
      }
    
      private get assigneeNames(): string[] {
        const set = new Set<string>()
        for (const c of this.columns) for (const t of c.tasks) set.add(t.assignee.name)
        return [...set]
      }
    
      private get filteredColumns(): BoardColumn[] {
        const q = this.searchQuery.toLowerCase()
        return this.columns.map((col) => ({
          ...col,
          tasks: col.tasks.filter((t) => {
            const matchesSearch = !q || t.title.toLowerCase().includes(q) || t.id.toLowerCase().includes(q)
            const matchesPriority = !this.selectedPriority || t.priority === this.selectedPriority
            const matchesAssignee = !this.selectedAssignee || t.assignee.name === this.selectedAssignee
            return matchesSearch && matchesPriority && matchesAssignee
          }),
        }))
      }
    
      private get totalTasks(): number {
        return this.columns.reduce((sum, col) => sum + col.tasks.length, 0)
      }
    
      private findTask(id: string): { col: BoardColumn; task: BoardTask } | null {
        for (const col of this.columns) {
          const task = col.tasks.find((t) => t.id === id)
          if (task) return { col, task }
        }
        return null
      }
    
      private openDetail(task: BoardTask) {
        this.detailTaskId = task.id
        this.commentDraft = ''
        this.detailOpen = true
      }
    
      private moveTask(taskId: string, targetColumnId: string, at?: number) {
        this.commit((cols) => {
          let task: BoardTask | undefined
          for (const c of cols) {
            const i = c.tasks.findIndex((t) => t.id === taskId)
            if (i !== -1) {
              task = c.tasks.splice(i, 1)[0]
              break
            }
          }
          const target = cols.find((c) => c.id === targetColumnId)
          if (!task || !target) return
          target.tasks.splice(at ?? target.tasks.length, 0, task)
        })
      }
    
      private onDragStart(e: DragEvent, taskId: string) {
        this.draggedId = taskId
        if (e.dataTransfer) {
          e.dataTransfer.effectAllowed = 'move'
          e.dataTransfer.setData('text/plain', taskId)
        }
      }
    
      private resetDrag() {
        this.draggedId = null
        this.dragOverColumn = null
        this.dropIndex = -1
        this.lastDragEnd = Date.now()
      }
    
      private onCardDragOver(e: DragEvent, columnId: string, taskIndex: number) {
        e.preventDefault()
        e.stopPropagation()
        this.dragOverColumn = columnId
        const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
        this.dropIndex = e.clientY < rect.top + rect.height / 2 ? taskIndex : taskIndex + 1
      }
    
      private onLaneDragOver(e: DragEvent, columnId: string, taskCount: number) {
        e.preventDefault()
        this.dragOverColumn = columnId
        this.dropIndex = taskCount
      }
    
      private onDrop(columnId: string) {
        if (!this.draggedId) {
          this.resetDrag()
          return
        }
        this.moveTask(this.draggedId, columnId, this.dropIndex < 0 ? undefined : this.dropIndex)
        this.resetDrag()
      }
    
      private addComment() {
        const text = this.commentDraft.trim()
        if (!text || !this.detailTaskId) return
        const id = this.detailTaskId
        this.commit((cols) => {
          for (const c of cols) {
            const t = c.tasks.find((t) => t.id === id)
            if (t) t.comments = [...t.comments, { id: `c${Date.now()}`, author: 'Admin User', text, time: 'Just now' }]
          }
        })
        this.commentDraft = ''
      }
    
      private createTask(e: Event) {
        e.preventDefault()
        if (!this.addTitle.trim()) return
        const task: BoardTask = {
          id: `KAN-${Math.floor(100 + Math.random() * 900)}`,
          title: this.addTitle.trim(),
          description: this.addDescription.trim() || undefined,
          priority: this.addPriority,
          tags: [],
          dueDate: this.addDueDate || undefined,
          assignee: { name: this.addAssignee, color: 'bg-chart-1/15 text-chart-1' },
          comments: [],
        }
        const columnId = this.addColumnId
        this.commit((cols) => {
          cols.find((c) => c.id === columnId)?.tasks.push(task)
        })
        this.addTitle = ''
        this.addDescription = ''
        this.addOpen = false
      }
    
      private renderCard(task: BoardTask, isDone: boolean) {
        return html`
          <div
            data-slot="kanban-task-board"
            role="button"
            tabindex="0"
            draggable="true"
            class=${cn(
              'kanban-card group/card bg-card relative cursor-grab rounded-lg border p-3 transition-colors duration-150',
              'hover:border-border hover:shadow-md active:scale-[0.97] active:cursor-grabbing',
              'focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]',
              isDone ? 'opacity-75 hover:opacity-100 focus-visible:opacity-100' : '',
            )}
            @click=${() => {
              if (Date.now() - this.lastDragEnd < 200) return
              this.openDetail(task)
            }}
            @keydown=${(e: KeyboardEvent) => {
              if (e.key !== 'Enter' && e.key !== ' ') return
              e.preventDefault()
              this.openDetail(task)
            }}
            @dragstart=${(e: DragEvent) => this.onDragStart(e, task.id)}
            @dragend=${this.resetDrag}
          >
            <div
              class=${cn(
                'kanban-accent absolute top-3 bottom-3 left-0 w-[1.5px] rounded-full transition-colors duration-150',
                priorityAccent(task.priority),
                task.priority === 'low' ? 'opacity-40' : task.priority === 'medium' ? 'opacity-60' : 'opacity-90',
              )}
            ></div>
            <div class="mb-1 flex items-center justify-between pl-2">
              <span class="text-muted-foreground/70 font-mono text-xs">${task.id}</span>
              <span class=${cn('text-xs font-semibold', priorityAccent(task.priority).replace('bg-', 'text-'))}>
                ${PRIORITY_LABEL[task.priority]}
              </span>
            </div>
            <p class=${cn('mb-2 pl-2 text-sm leading-snug font-medium', isDone ? 'decoration-muted-foreground/40 line-through' : '')}>
              ${task.title}
            </p>
            ${task.tags.length > 0
              ? html`<div class="mb-2 flex flex-wrap gap-1 pl-2">
                  ${task.tags.map(
                    (tag) => html`<span
                      data-slot="kanban-task-board"
                      class=${cn('rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset', tag.color)}
                      >${tag.label}</span
                    >`,
                  )}
                </div>`
              : nothing}
            <div class="flex items-center gap-2 pl-2">
              ${task.dueDate
                ? html`<span class="text-muted-foreground bg-muted inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs">
                    ${icon(Calendar, 'calendar', 'size-3')} ${task.dueDate}
                  </span>`
                : nothing}
              ${task.comments.length > 0
                ? html`<span class="text-muted-foreground/70 flex items-center gap-1 text-xs">
                    ${icon(MessageSquare, 'message-square', 'size-3')} ${task.comments.length}
                  </span>`
                : nothing}
              <div class="ml-auto">
                <uip-avatar class="size-6 shrink-0">
                  <uip-avatar-fallback class=${cn('text-xs font-bold', task.assignee.color)}>
                    ${initials(task.assignee.name)}
                  </uip-avatar-fallback>
                </uip-avatar>
              </div>
            </div>
          </div>
        `
      }
    
      render() {
        const filtered = this.filteredColumns
        const detail = this.detailTaskId ? this.findTask(this.detailTaskId) : null
        return html`
          <div data-slot="kanban-task-board" class="flex flex-col gap-3">
            <div class="mb-3 shrink-0">
              <div class="flex items-start justify-between gap-4">
                <div>
                  <h2 class="text-foreground text-xl font-bold tracking-tight">${this.heading}</h2>
                  <p class="text-muted-foreground mt-0.5 text-sm">${this.description}</p>
                </div>
                <div class="flex shrink-0 items-center gap-2">
                  <uip-badge variant="secondary" class="font-mono text-xs tabular-nums">${this.totalTasks} tasks</uip-badge>
                  <uip-button
                    size="sm"
                    @click=${() => {
                      this.addColumnId = 'backlog'
                      this.addOpen = true
                    }}
                  >
                    ${icon(Plus, 'plus', 'size-4')} Add Task
                  </uip-button>
                </div>
              </div>
            </div>
    
            <div data-slot="kanban-task-board" class="mb-3 flex shrink-0 flex-wrap items-center gap-2">
              <uip-input
                .value=${this.searchQuery}
                placeholder="Search tasks..."
                class="h-8 w-56 text-sm"
                @input=${(e: Event) => {
                  this.searchQuery = (e.target as UipInput).value
                }}
              >
                <span slot="prefix">${icon(Search, 'search', 'size-3.5')}</span>
              </uip-input>
              <uip-select
                value=${this.selectedPriority}
                class="h-8 w-32 text-xs"
                @change=${(e: Event) => {
                  this.selectedPriority = (e.target as UipSelect).value as '' | BoardTask['priority']
                }}
              >
                <option value="">All priorities</option>
                <option value="low">Low</option>
                <option value="medium">Medium</option>
                <option value="high">High</option>
                <option value="urgent">Urgent</option>
              </uip-select>
              <uip-select
                value=${this.selectedAssignee}
                class="h-8 w-36 text-xs"
                @change=${(e: Event) => {
                  this.selectedAssignee = (e.target as UipSelect).value
                }}
              >
                <option value="">All assignees</option>
                ${this.assigneeNames.map((n) => html`<option value=${n}>${n}</option>`)}
              </uip-select>
              <div class="bg-muted ml-auto flex items-center gap-0.5 rounded-md p-0.5">
                <uip-button
                  variant="ghost"
                  size="icon"
                  class=${cn('size-7 rounded-sm p-0', this.viewMode === 'board' ? 'bg-background shadow-sm' : '')}
                  title="Board view"
                  aria-label="Board view"
                  @click=${() => {
                    this.viewMode = 'board'
                  }}
                >
                  ${icon(LayoutGrid, 'layout-grid', 'size-3.5')}
                </uip-button>
                <uip-button
                  variant="ghost"
                  size="icon"
                  class=${cn('size-7 rounded-sm p-0', this.viewMode === 'list' ? 'bg-background shadow-sm' : '')}
                  title="List view"
                  aria-label="List view"
                  @click=${() => {
                    this.viewMode = 'list'
                  }}
                >
                  ${icon(List, 'list', 'size-3.5')}
                </uip-button>
              </div>
            </div>
    
            ${this.viewMode === 'board'
              ? html`
                  <div class="kanban-board relative flex min-h-0 flex-1 items-start gap-3 overflow-auto pb-3">
                    ${filtered.map((column) => {
                      const isCollapsed = this.collapsed.includes(column.id)
                      const isOver = this.dragOverColumn === column.id
                      return html`
                        <div
                          class=${cn(
                            'bg-muted/40 border-border/80 flex shrink-0 flex-col rounded-xl border p-3 transition-colors',
                            isCollapsed ? 'w-14' : 'w-72',
                            isOver && 'border-primary/50',
                          )}
                          @dragover=${(e: DragEvent) => this.onLaneDragOver(e, column.id, column.tasks.length)}
                          @drop=${() => this.onDrop(column.id)}
                        >
                          <div class="mb-3 flex items-center justify-between gap-2 px-1">
                            <div class="flex min-w-0 items-center gap-2">
                              <span class=${cn('size-2 shrink-0 rounded-full', column.dotColor)}></span>
                              ${!isCollapsed
                                ? html`<h3 class="text-foreground truncate text-sm font-semibold tracking-tight">
                                      ${column.title}
                                    </h3>
                                    <span
                                      class="bg-muted text-muted-foreground rounded-full px-2 py-0.5 text-xs font-semibold tabular-nums"
                                      >${column.tasks.length}</span
                                    >`
                                : nothing}
                            </div>
                            <div class="flex items-center gap-1">
                              ${!isCollapsed
                                ? html`<uip-button
                                    variant="ghost"
                                    size="icon"
                                    class="text-muted-foreground hover:text-foreground size-7 rounded-lg"
                                    aria-label=${`Add task to ${column.title}`}
                                    @click=${() => {
                                      this.addColumnId = column.id
                                      this.addOpen = true
                                    }}
                                  >
                                    ${icon(Plus, 'plus', 'size-3.5')}
                                  </uip-button>`
                                : nothing}
                              <uip-button
                                variant="ghost"
                                size="icon"
                                class="text-muted-foreground hover:text-foreground size-7 rounded-lg"
                                aria-label=${isCollapsed ? `Expand ${column.title}` : `Collapse ${column.title}`}
                                @click=${() => {
                                  this.collapsed = isCollapsed
                                    ? this.collapsed.filter((c) => c !== column.id)
                                    : [...this.collapsed, column.id]
                                }}
                              >
                                ${isCollapsed ? icon(ChevronsRight, 'chevrons-right', 'size-3.5') : icon(ChevronsLeft, 'chevrons-left', 'size-3.5')}
                              </uip-button>
                            </div>
                          </div>
                          ${!isCollapsed
                            ? html`<div class="kanban-lane flex min-h-[60px] flex-1 flex-col gap-2.5">
                                ${column.tasks.map((task, ti) => {
                                  const isTarget = isOver && this.dropIndex === ti
                                  return html`
                                    ${isTarget && this.draggedId !== task.id
                                      ? html`<div class="bg-primary/20 h-1.5 w-full rounded-full"></div>`
                                      : nothing}
                                    <div
                                      @dragover=${(e: DragEvent) => this.onCardDragOver(e, column.id, ti)}
                                    >
                                      ${this.renderCard(task, column.id === 'done')}
                                    </div>
                                  `
                                })}
                                ${isOver && this.dropIndex >= column.tasks.length && this.draggedId
                                  ? html`<div class="bg-primary/20 h-1.5 w-full rounded-full"></div>`
                                  : nothing}
                                ${column.tasks.length === 0
                                  ? html`<div
                                      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"
                                    >
                                      Drop tasks here
                                    </div>`
                                  : nothing}
                              </div>`
                            : nothing}
                        </div>
                      `
                    })}
                  </div>
                `
              : html`
                  <div class="kanban-list flex min-h-0 flex-1 flex-col gap-2 overflow-auto pb-3">
                    ${filtered.map(
                      (column) => html`
                        <div>
                          <p class="text-muted-foreground px-1 py-1 text-xs font-semibold tracking-widest uppercase">
                            ${column.title} · ${column.tasks.length}
                          </p>
                          ${column.tasks.map(
                            (task) => html`
                              <button
                                class="bg-card hover:bg-muted/40 flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors"
                                @click=${() => this.openDetail(task)}
                              >
                                <span class=${cn('size-2 shrink-0 rounded-full', priorityAccent(task.priority))}></span>
                                <span class="text-muted-foreground/70 shrink-0 font-mono text-xs">${task.id}</span>
                                <span class="min-w-0 flex-1 truncate text-sm font-medium">${task.title}</span>
                                ${task.dueDate
                                  ? html`<span class="text-muted-foreground hidden shrink-0 items-center gap-1 text-xs sm:inline-flex">
                                      ${icon(Calendar, 'calendar', 'size-3')} ${task.dueDate}
                                    </span>`
                                  : nothing}
                                <uip-avatar class="size-6 shrink-0">
                                  <uip-avatar-fallback class=${cn('text-xs font-bold', task.assignee.color)}>
                                    ${initials(task.assignee.name)}
                                  </uip-avatar-fallback>
                                </uip-avatar>
                                <uip-select
                                  value=${column.id}
                                  class="h-7 w-28 text-xs"
                                  @click=${(e: Event) => e.stopPropagation()}
                                  @change=${(e: Event) => {
                                    this.moveTask(task.id, (e.target as UipSelect).value)
                                  }}
                                >
                                  ${this.columns.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
                                </uip-select>
                              </button>
                            `,
                          )}
                        </div>
                      `,
                    )}
                  </div>
                `}
    
            <uip-sheet
              ?open=${this.detailOpen}
              heading=${detail?.task.title ?? ''}
              description=${detail ? `Task ${detail.task.id} · ${detail.col.title}` : ''}
              @open-change=${(e: CustomEvent<{ open: boolean }>) => {
                this.detailOpen = e.detail.open
              }}
            >
              ${detail
                ? html`
                    <div class="space-y-5 py-5 text-sm">
                      ${detail.task.description
                        ? html`<p class="text-muted-foreground text-sm">${detail.task.description}</p>`
                        : nothing}
                      <div class="flex flex-wrap items-center gap-2">
                        <uip-badge variant="secondary">${PRIORITY_LABEL[detail.task.priority]} priority</uip-badge>
                        ${detail.task.dueDate
                          ? html`<uip-badge variant="outline">
                              ${icon(Calendar, 'calendar', 'size-3')} Due ${detail.task.dueDate}
                            </uip-badge>`
                          : nothing}
                        ${detail.task.tags.map(
                          (tag) => html`<span
                            class=${cn('rounded-md px-1.5 py-0.5 text-xs font-medium ring-1 ring-inset', tag.color)}
                            >${tag.label}</span
                          >`,
                        )}
                      </div>
                      <div class="flex items-center gap-2.5">
                        <uip-avatar class="size-7">
                          <uip-avatar-fallback class=${cn('text-xs font-bold', detail.task.assignee.color)}>
                            ${initials(detail.task.assignee.name)}
                          </uip-avatar-fallback>
                        </uip-avatar>
                        <span class="text-sm font-medium">${detail.task.assignee.name}</span>
                      </div>
                      <div class="space-y-2">
                        <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
                          Comments (${detail.task.comments.length})
                        </p>
                        ${detail.task.comments.map(
                          (c) => html`
                            <div class="bg-muted/40 rounded-lg border px-3 py-2">
                              <p class="text-xs font-semibold">${c.author} <span class="text-muted-foreground font-normal">· ${c.time}</span></p>
                              <p class="mt-0.5 text-sm">${c.text}</p>
                            </div>
                          `,
                        )}
                        <form
                          class="flex gap-2"
                          @submit=${(e: Event) => {
                            e.preventDefault()
                            this.addComment()
                          }}
                        >
                          <uip-input
                            .value=${this.commentDraft}
                            placeholder="Add a comment..."
                            class="flex-1 text-sm"
                            @input=${(e: Event) => {
                              this.commentDraft = (e.target as UipInput).value
                            }}
                          ></uip-input>
                          <uip-button type="submit" size="sm" ?disabled=${!this.commentDraft.trim()}>Post</uip-button>
                        </form>
                      </div>
                      <div class="flex items-center gap-2">
                        <uip-label class="text-xs">Move to</uip-label>
                        <uip-select
                          value=${detail.col.id}
                          class="h-8 w-36 text-xs"
                          @change=${(e: Event) => {
                            this.moveTask(detail.task.id, (e.target as UipSelect).value)
                          }}
                        >
                          ${this.columns.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
                        </uip-select>
                        <uip-button
                          variant="ghost"
                          size="sm"
                          class="text-destructive ml-auto gap-1 text-xs"
                          @click=${() => {
                            const id = detail.task.id
                            this.commit((cols) => {
                              for (const c of cols) c.tasks = c.tasks.filter((t) => t.id !== id)
                            })
                            this.detailOpen = false
                          }}
                        >
                          ${icon(Trash2, 'trash-2', 'size-3.5')} Delete
                        </uip-button>
                      </div>
                    </div>
                  `
                : nothing}
            </uip-sheet>
    
            <uip-dialog
              ?open=${this.addOpen}
              heading="Add task"
              description="Create a new card in the selected column."
              @open-change=${(e: CustomEvent<{ open: boolean }>) => {
                this.addOpen = e.detail.open
              }}
            >
              <form class="space-y-4 py-2" @submit=${this.createTask}>
                <div class="space-y-1.5">
                  <uip-label for="kanban-task-title">Title</uip-label>
                  <uip-input
                    id="kanban-task-title"
                    .value=${this.addTitle}
                    placeholder="e.g. Draft migration runbook"
                    required
                    @input=${(e: Event) => {
                      this.addTitle = (e.target as UipInput).value
                    }}
                  ></uip-input>
                </div>
                <div class="space-y-1.5">
                  <uip-label for="kanban-task-desc">Description</uip-label>
                  <uip-textarea
                    id="kanban-task-desc"
                    .value=${this.addDescription}
                    placeholder="Optional context..."
                    rows="2"
                    @value-change=${(e: CustomEvent<{ value: string }>) => {
                      this.addDescription = e.detail.value
                    }}
                  ></uip-textarea>
                </div>
                <div class="grid grid-cols-2 gap-3">
                  <div class="space-y-1.5">
                    <uip-label>Priority</uip-label>
                    <uip-select
                      value=${this.addPriority}
                      @change=${(e: Event) => {
                        this.addPriority = (e.target as UipSelect).value as BoardTask['priority']
                      }}
                    >
                      <option value="low">Low</option>
                      <option value="medium">Medium</option>
                      <option value="high">High</option>
                      <option value="urgent">Urgent</option>
                    </uip-select>
                  </div>
                  <div class="space-y-1.5">
                    <uip-label>Column</uip-label>
                    <uip-select
                      value=${this.addColumnId}
                      @change=${(e: Event) => {
                        this.addColumnId = (e.target as UipSelect).value
                      }}
                    >
                      ${this.columns.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
                    </uip-select>
                  </div>
                </div>
                <div class="grid grid-cols-2 gap-3">
                  <div class="space-y-1.5">
                    <uip-label for="kanban-task-assignee">Assignee</uip-label>
                    <uip-input
                      id="kanban-task-assignee"
                      .value=${this.addAssignee}
                      placeholder="Full name"
                      @input=${(e: Event) => {
                        this.addAssignee = (e.target as UipInput).value
                      }}
                    ></uip-input>
                  </div>
                  <div class="space-y-1.5">
                    <uip-label for="kanban-task-due">Due date</uip-label>
                    <uip-input
                      id="kanban-task-due"
                      .value=${this.addDueDate}
                      type="date"
                      @input=${(e: Event) => {
                        this.addDueDate = (e.target as UipInput).value
                      }}
                    ></uip-input>
                  </div>
                </div>
              </form>
              <div slot="footer">
                <uip-button
                  variant="outline"
                  @click=${() => {
                    this.addOpen = false
                  }}
                  >Cancel</uip-button
                >
                <uip-button
                  @click=${this.createTask}
                  ?disabled=${!this.addTitle.trim()}
                >
                  ${icon(Plus, 'plus', 'size-3.5')} Create task
                </uip-button>
              </div>
            </uip-dialog>
          </div>
        `
      }
    }
    
    customElements.get('uip-kanban-task-board') || customElements.define('uip-kanban-task-board', UipKanbanTaskBoard)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-kanban-task-board': UipKanbanTaskBoard
      }
    }
    
  • src/uip/blocks/kanban-task-board/simple-kanban.ts18.6 kB

Raw manifest:https://uipkge.dev/r/lit/kanban-task-board.json