Kanban Task Board
blockproductivityFull 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
$pnpm dlx shadcn@latest add https://uipkge.dev/r/lit/kanban-task-board.json$npx shadcn@latest add https://uipkge.dev/r/lit/kanban-task-board.json$yarn dlx shadcn@latest add https://uipkge.dev/r/lit/kanban-task-board.json$bunx shadcn@latest add https://uipkge.dev/r/lit/kanban-task-board.jsonhttps://uipkge.dev/r/lit/kanban-task-board.jsonInstalls to:src/uip/blocks/kanban-task-board/Variants
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
heading | string | Kanban Board | optional |
description | string | Drag 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.
BoardTaskCommentinterface BoardTaskComment {
id: string
author: string
text: string
time: string
}BoardTaskinterface 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[]
}BoardColumninterface BoardColumn {
id: string
title: string
dotColor: string
tasks: BoardTask[]
}HTMLElementTagNameMapinterface HTMLElementTagNameMap {
'uip-kanban-task-board': UipKanbanTaskBoard
}
}SimpleKanbanIteminterface 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
}
}SimpleKanbanColumninterface 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
import { LitElement, css, html } from 'lit' import { Calendar, Check, GripVertical, Plus } 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/button/button' import '../../components/input/input' export 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 } } export interface SimpleKanbanColumn { id: string title: string color?: string dotColor?: string items: SimpleKanbanItem[] } const DEFAULT_DOT_COLORS: Record<string, string> = { backlog: 'bg-muted-foreground/60', todo: 'bg-info', 'in-progress': 'bg-warning', review: 'bg-chart-1', done: 'bg-success', } function priorityLabel(p: string): string { return p.charAt(0).toUpperCase() + p.slice(1) } function priorityClass(p: string): string { switch (p) { case 'low': return 'bg-muted text-muted-foreground' case 'medium': return 'bg-info/10 text-info' case 'high': return 'bg-warning/10 text-warning' case 'urgent': return 'bg-destructive/10 text-destructive font-medium' default: return 'bg-muted text-muted-foreground' } } /** * <uip-simple-kanban> — the registry SimpleKanban block as a web component. * * Ships inside the kanban-task-board block dir (one block = one install * unit). Class strings are React's SimpleKanban verbatim. `columns` is a * settable property (defaults to the demo seed); `columns-change` * (columns), `card-click` ({ item, columnId }), `card-move` ({ item, * fromColumnId, toColumnId, newIndex }) and `add-item` ({ columnId, title }) * CustomEvents replace the callbacks (bubbles, composed). Native HTML5 * drag-and-drop is kept, including the drop indicator and the inline * quick-add row. `allow-add` hides the per-column add button. */ export class UipSimpleKanban extends LitElement { static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true } // :host display can't be a utility class (the host has no template of its own). static styles = [tailwind, css`:host { display: block; }`] static properties = { columns: { attribute: false }, allowAdd: { type: Boolean, attribute: 'allow-add' }, draggedId: { state: true }, dragOverColumnId: { state: true }, dropTargetIndex: { state: true }, addingColumnId: { state: true }, newTitle: { state: true }, } columns: SimpleKanbanColumn[] = [] allowAdd = true private draggedId: string | null = null private dragOverColumnId: string | null = null private dropTargetIndex = -1 private addingColumnId: string | null = null private newTitle = '' private lastDragTime = 0 constructor() { super() new ThemeController(this) } connectedCallback() { super.connectedCallback() this.setAttribute('data-uipkge', '') this.setAttribute('data-slot', 'simple-kanban') if (this.columns.length === 0) this.columns = seedSimpleColumns() } private commit(mutator: (cols: SimpleKanbanColumn[]) => void) { const next = this.columns.map((col) => ({ ...col, items: [...col.items] })) mutator(next) this.columns = next this.dispatchEvent(new CustomEvent('columns-change', { detail: next, bubbles: true, composed: true })) } private handleDragStart(e: DragEvent, id: string) { this.draggedId = id if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', id) } } private handleDragEnd() { this.draggedId = null this.dragOverColumnId = null this.dropTargetIndex = -1 this.lastDragTime = Date.now() } private handleDragOverColumn(e: DragEvent, columnId: string, itemsCount: number) { e.preventDefault() if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' this.dragOverColumnId = columnId this.dropTargetIndex = itemsCount } private handleCardDragOver(e: DragEvent, columnId: string, index: number) { e.preventDefault() e.stopPropagation() if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' this.dragOverColumnId = columnId const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() this.dropTargetIndex = e.clientY < rect.top + rect.height / 2 ? index : index + 1 } private locate(id: string): { sourceColIdx: number; itemIdx: number; targetColIdx?: number } { let sourceColIdx = -1 let itemIdx = -1 for (let c = 0; c < this.columns.length; c++) { const idx = this.columns[c]!.items.findIndex((item) => item.id === id) if (idx !== -1) { sourceColIdx = c itemIdx = idx break } } return { sourceColIdx, itemIdx } } private handleDrop(e: DragEvent, targetColumnId: string) { e.preventDefault() if (!this.draggedId) { this.handleDragEnd() return } const { sourceColIdx, itemIdx } = this.locate(this.draggedId) const targetColIdx = this.columns.findIndex((c) => c.id === targetColumnId) if (sourceColIdx === -1 || targetColIdx === -1) { this.handleDragEnd() return } const sourceCol = this.columns[sourceColIdx]! const item = sourceCol.items[itemIdx] if (!item) { this.handleDragEnd() return } let at = this.dropTargetIndex const removed = item this.commit((cols) => { const src = cols[sourceColIdx]! const tgt = cols[targetColIdx]! src.items.splice(itemIdx, 1) if (at < 0) at = tgt.items.length if (sourceColIdx === targetColIdx && itemIdx < at) at-- tgt.items.splice(at, 0, removed) }) this.dispatchEvent( new CustomEvent('card-move', { detail: { item: removed, fromColumnId: sourceCol.id, toColumnId: targetColumnId, newIndex: at }, bubbles: true, composed: true, }), ) this.handleDragEnd() } private handleInlineAdd(columnId: string) { if (!this.newTitle.trim()) { this.addingColumnId = null return } const title = this.newTitle.trim() this.dispatchEvent(new CustomEvent('add-item', { detail: { columnId, title }, bubbles: true, composed: true })) const newItem: SimpleKanbanItem = { id: `item-${Date.now()}`, title } this.commit((cols) => { cols.find((c) => c.id === columnId)?.items.push(newItem) }) this.newTitle = '' this.addingColumnId = null } render() { return html` <div data-slot="simple-kanban" class=${cn('flex min-h-[380px] w-full [scrollbar-width:thin] items-start gap-4 overflow-x-auto pb-4')} > ${this.columns.map((column) => { const isColumnOver = this.dragOverColumnId === column.id const dot = column.dotColor || column.color || DEFAULT_DOT_COLORS[column.id] || 'bg-primary' return html` <div data-slot="kanban-column" class=${cn( 'bg-muted/40 border-border/80 flex w-72 shrink-0 flex-col rounded-xl border p-3 transition-colors', isColumnOver && 'border-primary/50 bg-muted/60 ring-primary/10 ring-2', )} @dragover=${(e: DragEvent) => this.handleDragOverColumn(e, column.id, column.items.length)} @drop=${(e: DragEvent) => this.handleDrop(e, column.id)} > <div class="mb-3 flex items-center justify-between gap-2 px-1"> <div class="flex items-center gap-2"> <span class=${cn('size-2 rounded-full', dot)}></span> <h3 class="text-foreground 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.items.length}</span > </div> ${this.allowAdd ? html`<uip-button variant="ghost" size="icon" class="text-muted-foreground hover:text-foreground size-7 rounded-lg" aria-label=${`Add item to ${column.title}`} @click=${() => { this.addingColumnId = column.id this.newTitle = '' }} > ${icon(Plus, 'plus', 'size-3.5')} </uip-button>` : ''} </div> <div class="flex min-h-[120px] flex-1 flex-col gap-2.5"> ${this.addingColumnId === column.id ? html` <div class="bg-card border-border animate-in fade-in zoom-in-95 rounded-lg border p-2.5 shadow-xs duration-150" > <input type="text" autofocus placeholder="Item title..." .value=${this.newTitle} @input=${(e: Event) => { this.newTitle = (e.target as HTMLInputElement).value }} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') this.handleInlineAdd(column.id) if (e.key === 'Escape') this.addingColumnId = null }} class="placeholder:text-muted-foreground/60 w-full bg-transparent text-sm font-medium outline-none" /> <div class="mt-2.5 flex items-center justify-end gap-1.5"> <uip-button size="sm" variant="ghost" class="h-7 px-2 text-xs" @click=${() => { this.addingColumnId = null }} >Cancel</uip-button > <uip-button size="sm" class="h-7 gap-1 px-2.5 text-xs" @click=${() => this.handleInlineAdd(column.id)}> ${icon(Check, 'check', 'size-3')} Add </uip-button> </div> </div> ` : ''} ${column.items.map((item, index) => { const isDragging = this.draggedId === item.id const isDropTarget = isColumnOver && this.dropTargetIndex === index return html` ${isDropTarget && !isDragging ? html`<div class="bg-primary/20 h-1.5 w-full rounded-full"></div>` : ''} <div role="button" tabindex="0" draggable="true" data-slot="kanban-card" @dragstart=${(e: DragEvent) => this.handleDragStart(e, item.id)} @dragend=${this.handleDragEnd} @dragover=${(e: DragEvent) => this.handleCardDragOver(e, column.id, index)} @click=${() => { if (Date.now() - this.lastDragTime < 150) return this.dispatchEvent( new CustomEvent('card-click', { detail: { item, columnId: column.id }, bubbles: true, composed: true }), ) }} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() this.dispatchEvent( new CustomEvent('card-click', { detail: { item, columnId: column.id }, bubbles: true, composed: true }), ) } }} class=${cn( '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', isDragging && 'ring-primary/40 opacity-40 shadow-md ring-2', )} > <div class="flex items-start justify-between gap-2"> <p class="text-foreground line-clamp-2 text-sm leading-snug font-medium">${item.title}</p> ${icon(GripVertical, 'grip-vertical', 'text-muted-foreground/30 size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100')} </div> ${item.description ? html`<p class="text-muted-foreground line-clamp-2 text-xs leading-relaxed"> ${item.description} </p>` : ''} ${item.date || item.tag || item.priority || item.assignee ? html` <div class="border-border/40 mt-1 flex flex-wrap items-center justify-between gap-1.5 border-t pt-1 text-xs" > <div class="flex flex-wrap items-center gap-1.5"> ${item.date ? html`<div class="text-muted-foreground flex items-center gap-1 text-xs"> ${icon(Calendar, 'calendar', 'text-muted-foreground/70 size-3')} <span>${item.date}</span> </div>` : ''} ${item.tag ? html`<span class=${cn( 'rounded-md px-1.5 py-0.5 text-xs font-medium', item.tagColor || 'bg-secondary text-secondary-foreground', )} >${item.tag}</span >` : ''} ${item.priority ? html`<span class=${cn('rounded-md px-1.5 py-0.5 text-xs', priorityClass(item.priority))} >${priorityLabel(item.priority)}</span >` : ''} </div> ${item.assignee ? html`<uip-avatar class="border-background size-5 shrink-0 border"> <uip-avatar-fallback class="text-xs font-medium"> ${item.assignee.initials || item.assignee.name.slice(0, 2).toUpperCase()} </uip-avatar-fallback> </uip-avatar>` : ''} </div> ` : ''} </div> ` })} ${isColumnOver && this.dropTargetIndex >= column.items.length && this.draggedId ? html`<div class="bg-primary/20 h-1.5 w-full rounded-full"></div>` : ''} ${column.items.length === 0 && this.addingColumnId !== column.id ? 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 items here </div>` : ''} </div> </div> ` })} </div> ` } } function seedSimpleColumns(): SimpleKanbanColumn[] { return [ { id: 'backlog', title: 'Backlog', dotColor: 'bg-slate-400 dark:bg-slate-500', items: [ { id: 'sk-1', title: 'Cross-platform auth synchronization', description: 'Implement token refresh rotation across mobile and web clients.', date: 'May 15 - Jun 2, 2026', priority: 'high', tag: 'Auth', assignee: { name: 'Alice Chen', initials: 'AC' } }, { id: 'sk-2', title: 'Scale multi-region edge caching', description: 'Benchmark Cloudflare worker cache hit ratios for static registry files.', date: 'Jun 22 - Jul 10, 2026', priority: 'medium', tag: 'Infra', assignee: { name: 'Dan Ruiz', initials: 'DR' } }, { id: 'sk-3', title: 'Extensible drag & drop plugin architecture', date: 'Jul 28 - Aug 14, 2026', priority: 'low', tag: 'Core' }, ], }, { id: 'in-progress', title: 'In Progress', dotColor: 'bg-amber-500', items: [ { id: 'sk-4', title: 'Real-time telemetry event streaming', description: 'Wire SSE endpoints for instant live preview updates.', date: 'May 16 - Jun 9, 2026', priority: 'urgent', tag: 'Realtime', assignee: { name: 'Sarah Miller', initials: 'SM' } }, { id: 'sk-5', title: 'OKLCH theme token harmonizer', description: 'Calibrate contrast ratios across light and dark modes.', date: 'Apr 26 - May 20, 2026', priority: 'high', tag: 'Design', assignee: { name: 'Leo Vance', initials: 'LV' } }, ], }, { id: 'review', title: 'In Review', dotColor: 'bg-purple-500', items: [ { id: 'sk-6', title: 'Astro island SSR hydration benchmark', description: 'Verify sub-50ms TTFB across dual-framework demo islands.', date: 'Aug 3 - Aug 18, 2026', priority: 'medium', tag: 'Performance', assignee: { name: 'Alice Chen', initials: 'AC' } }, ], }, { id: 'done', title: 'Done', dotColor: 'bg-emerald-500', items: [ { id: 'sk-7', title: 'Sub-pixel border and shadow layering', description: 'Refined neutral borders matching Linear & Raycast standards.', date: 'Mar 10 - Apr 12, 2026', priority: 'low', tag: 'Craft', assignee: { name: 'Dan Ruiz', initials: 'DR' } }, { id: 'sk-8', title: 'Zero-config JSON registry endpoint schema', date: 'Feb 14 - Mar 2, 2026', priority: 'high', tag: 'Registry' }, ], }, ] } customElements.get('uip-simple-kanban') || customElements.define('uip-simple-kanban', UipSimpleKanban) declare global { interface HTMLElementTagNameMap { 'uip-simple-kanban': UipSimpleKanban } }
Raw manifest:https://uipkge.dev/r/lit/kanban-task-board.json