Initial commit: kanban-elements-ui library

Angular 19 component library for kanban boards:
- kb-board: main board with toolbar, swimlanes, and pipeline view
- kb-column: columns with WIP limits, collapse, and drag reorder
- kb-card: draggable cards with labels, assignees, and priorities
- kb-swimlane: horizontal grouping with collapsible rows
- kb-toolbar: search, sort, filter, and view toggle
- kb-quick-add: inline card creation
- kb-wip-indicator: WIP limit status display
- kb-card-def / kb-column-header-def: custom template directives

Includes signal-based services (board, drag, filter), SCSS design
tokens with dark mode, and full TypeScript type definitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-02-13 21:54:33 +10:00
commit c7fe04e8e3
55 changed files with 7059 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist/
node_modules/
.angular/
*.tgz

12
ng-package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": ["src/styles"]
},
"dest": "dist",
"deleteDestPath": true,
"assets": [
"src/styles/**/*.scss"
]
}

3767
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@sda/kanban-elements-ui",
"version": "0.1.0",
"description": "Angular components for kanban boards with drag-and-drop, swimlanes, WIP limits, and pipeline views powered by @sda/base-ui",
"keywords": [
"angular",
"kanban",
"board",
"drag-and-drop",
"swimlanes",
"pipeline",
"components",
"ui"
],
"repository": {
"type": "git",
"url": "https://git.sky-ai.com/ui-core-design/kanban-elements-ui.git"
},
"license": "MIT",
"sideEffects": false,
"scripts": {
"build": "ng-packagr -p ng-package.json",
"build:dev": "./build-for-dev.sh"
},
"peerDependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
"@sda/base-ui": "*"
},
"devDependencies": {
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/compiler-cli": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.2.18",
"ng-packagr": "^19.1.0",
"typescript": "~5.7.2"
}
}

9
src/components/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export * from './kb-board';
export * from './kb-column';
export * from './kb-card';
export * from './kb-swimlane';
export * from './kb-toolbar';
export * from './kb-quick-add';
export * from './kb-wip-indicator';
export * from './kb-card-def';
export * from './kb-column-header-def';

View File

@@ -0,0 +1 @@
export * from './kb-board.component';

View File

@@ -0,0 +1,97 @@
<div class="kb-board" [class.kb-board--pipeline]="viewMode() === 'pipeline'">
<!-- Toolbar -->
@if (showToolbar()) {
<kb-toolbar
[viewMode]="viewMode()"
[searchable]="searchable()"
[showViewToggle]="showViewToggle()"
[filterCount]="filterService.activeFilterCount()"
[currentSort]="boardService.currentSort()"
(searchChange)="onSearchChange($event)"
(viewChange)="onViewChange($event)"
(sortChange)="onSortChange($event)"
/>
}
<!-- Loading overlay -->
@if (loading()) {
<div class="kb-board__loading">
<span class="kb-board__spinner"></span>
</div>
}
<!-- Board content -->
<div class="kb-board__content">
@if (hasSwimlanes()) {
<!-- Swimlane layout -->
@for (swimlane of sortedSwimlanes(); track swimlane.id) {
<kb-swimlane
[swimlane]="swimlane"
[collapsed]="isSwimlaneCollapsed(swimlane.id)"
[cardCount]="getSwimlaneCardCount(swimlane.id)"
(swimlaneToggle)="onSwimlaneToggle($event)"
>
<div class="kb-board__columns">
@for (column of sortedColumns(); track column.id) {
<kb-column
[column]="column"
[cards]="getColumnCards(column.id, swimlane.id)"
[collapsed]="isColumnCollapsed(column.id)"
[allowReorder]="allowColumnReorder()"
[allowQuickAdd]="allowQuickAdd()"
[showWipLimits]="true"
[showCardCount]="true"
[swimlaneId]="swimlane.id"
[cardTemplate]="cardTemplate()"
[headerTemplate]="getHeaderTemplate(column.id)"
(cardMove)="onCardMove($event)"
(cardClick)="onCardClick($event)"
(cardDblClick)="onCardDblClick($event)"
(cardContextMenu)="onCardContextMenu($event)"
(cardAdd)="onCardAdd($event)"
(columnClick)="onColumnClick($event)"
(columnToggle)="onColumnToggle($event)"
(dragover)="onColumnDragOver($event)"
(drop)="onColumnDrop($event, column.id)"
/>
}
</div>
</kb-swimlane>
}
} @else {
<!-- Flat column layout -->
<div class="kb-board__columns">
@for (column of sortedColumns(); track column.id) {
@if (viewMode() === 'pipeline' && !$first) {
<div class="kb-board__pipeline-connector">
<div class="kb-board__pipeline-line"></div>
<span class="kb-board__pipeline-arrow">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M6 3.5L10.5 8 6 12.5V3.5z"/></svg>
</span>
</div>
}
<kb-column
[column]="column"
[cards]="getColumnCards(column.id)"
[collapsed]="isColumnCollapsed(column.id)"
[allowReorder]="allowColumnReorder()"
[allowQuickAdd]="allowQuickAdd()"
[showWipLimits]="true"
[showCardCount]="true"
[cardTemplate]="cardTemplate()"
[headerTemplate]="getHeaderTemplate(column.id)"
(cardMove)="onCardMove($event)"
(cardClick)="onCardClick($event)"
(cardDblClick)="onCardDblClick($event)"
(cardContextMenu)="onCardContextMenu($event)"
(cardAdd)="onCardAdd($event)"
(columnClick)="onColumnClick($event)"
(columnToggle)="onColumnToggle($event)"
(dragover)="onColumnDragOver($event)"
(drop)="onColumnDrop($event, column.id)"
/>
}
</div>
}
</div>
</div>

View File

@@ -0,0 +1,115 @@
:host {
display: block;
}
.kb-board {
background: var(--kb-bg);
min-height: 200px;
position: relative;
}
.kb-board__loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--kb-glass-bg);
backdrop-filter: blur(var(--kb-glass-blur));
-webkit-backdrop-filter: blur(var(--kb-glass-blur));
z-index: 20;
}
.kb-board__spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--kb-column-border);
border-top-color: var(--color-primary-500, #3b82f6);
border-radius: 50%;
animation: kb-spin 0.6s linear infinite;
}
@keyframes kb-spin {
to { transform: rotate(360deg); }
}
.kb-board__content {
overflow-x: auto;
padding: 1.25rem;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--kb-card-meta-color);
border-radius: 4px;
&:hover {
background: var(--kb-card-desc-color);
}
}
scrollbar-width: thin;
scrollbar-color: var(--kb-card-meta-color) transparent;
}
.kb-board__columns {
display: flex;
gap: var(--kb-column-gap);
align-items: flex-start;
min-height: 200px;
}
// Pipeline mode
.kb-board--pipeline {
.kb-board__columns {
align-items: stretch;
}
}
.kb-board__pipeline-connector {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
position: relative;
}
.kb-board__pipeline-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.kb-board__pipeline-line {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: var(--kb-pipeline-connector-width);
background: var(--kb-pipeline-connector-color);
}
.kb-board__pipeline-arrow {
position: relative;
z-index: 1;
color: var(--kb-pipeline-connector-color);
display: inline-flex;
svg {
width: 0.875rem;
height: 0.875rem;
}
}

View File

@@ -0,0 +1,220 @@
import {
Component, ChangeDetectionStrategy, input, output, computed, inject,
contentChildren, effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import type {
KbCard, KbColumn, KbSwimlane, KbViewMode, KbCardSort,
} from '../../types/kanban.types';
import type {
KbCardMoveEvent, KbCardClickEvent, KbCardDblClickEvent,
KbCardContextMenuEvent, KbCardAddEvent, KbCardDeleteEvent,
KbColumnClickEvent, KbColumnToggleEvent, KbColumnReorderEvent,
KbSwimlaneToggleEvent, KbViewChangeEvent, KbSortChangeEvent,
KbFilterChangeEvent, KbWipExceededEvent, KbDragStartEvent, KbDragEndEvent,
} from '../../types/event.types';
import { KANBAN_CONFIG } from '../../providers/kanban-config.provider';
import { KanbanBoardService } from '../../services/kanban-board.service';
import { KanbanDragService } from '../../services/kanban-drag.service';
import { KanbanFilterService } from '../../services/kanban-filter.service';
import { isWipExceeded } from '../../utils/wip.utils';
import { KbColumnComponent } from '../kb-column/kb-column.component';
import { KbSwimlaneComponent } from '../kb-swimlane/kb-swimlane.component';
import { KbToolbarComponent } from '../kb-toolbar/kb-toolbar.component';
import { KbCardDefDirective } from '../kb-card-def/kb-card-def.directive';
import { KbColumnHeaderDefDirective } from '../kb-column-header-def/kb-column-header-def.directive';
@Component({
selector: 'kb-board',
standalone: true,
imports: [CommonModule, KbColumnComponent, KbSwimlaneComponent, KbToolbarComponent],
templateUrl: './kb-board.component.html',
styleUrl: './kb-board.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [KanbanBoardService, KanbanDragService, KanbanFilterService],
})
export class KbBoardComponent {
private config = inject(KANBAN_CONFIG);
protected boardService = inject(KanbanBoardService);
private dragService = inject(KanbanDragService);
protected filterService = inject(KanbanFilterService);
// Content children
private cardDefs = contentChildren(KbCardDefDirective);
private headerDefs = contentChildren(KbColumnHeaderDefDirective);
// --- Inputs ---
readonly cards = input.required<KbCard[]>();
readonly columns = input.required<KbColumn[]>();
readonly swimlanes = input<KbSwimlane[]>([]);
readonly viewMode = input<KbViewMode>(this.config.defaultView);
readonly loading = input(false);
readonly showToolbar = input(false);
readonly searchable = input(true);
readonly showViewToggle = input(true);
readonly allowColumnReorder = input(this.config.allowColumnReorder);
readonly allowQuickAdd = input(this.config.allowQuickAdd);
// --- Outputs ---
readonly cardMove = output<KbCardMoveEvent>();
readonly cardClick = output<KbCardClickEvent>();
readonly cardDblClick = output<KbCardDblClickEvent>();
readonly cardContextMenu = output<KbCardContextMenuEvent>();
readonly cardAdd = output<KbCardAddEvent>();
readonly cardDelete = output<KbCardDeleteEvent>();
readonly columnClick = output<KbColumnClickEvent>();
readonly columnToggle = output<KbColumnToggleEvent>();
readonly columnReorder = output<KbColumnReorderEvent>();
readonly swimlaneToggle = output<KbSwimlaneToggleEvent>();
readonly viewChange = output<KbViewChangeEvent>();
readonly sortChange = output<KbSortChangeEvent>();
readonly filterChange = output<KbFilterChangeEvent>();
readonly wipExceeded = output<KbWipExceededEvent>();
readonly dragStart = output<KbDragStartEvent>();
readonly dragEnd = output<KbDragEndEvent>();
// --- Computed ---
readonly sortedColumns = computed(() =>
[...this.columns()].sort((a, b) => a.order - b.order)
);
readonly sortedSwimlanes = computed(() =>
[...this.swimlanes()].sort((a, b) => a.order - b.order)
);
readonly hasSwimlanes = computed(() => this.swimlanes().length > 0);
readonly filteredCards = computed(() =>
this.filterService.applyFilters(this.cards())
);
protected cardTemplate = computed(() => {
const defs = this.cardDefs();
return defs.length > 0 ? defs[0].templateRef : null;
});
constructor() {
// Watch for drag start/end to emit events
effect(() => {
const card = this.dragService.draggedCard();
const isDragging = this.dragService.isDragging();
if (isDragging && card) {
this.dragStart.emit({
card,
columnId: this.dragService.sourceColumnId() ?? '',
swimlaneId: this.dragService.sourceSwimlaneId() ?? undefined,
});
}
});
}
/** Get the header template for a specific column */
protected getHeaderTemplate(columnId: string) {
const defs = this.headerDefs();
// First check for column-specific template
const specific = defs.find(d => d.kbColumnHeaderDef() === columnId);
if (specific) return specific.templateRef;
// Then check for generic template (empty string)
const generic = defs.find(d => !d.kbColumnHeaderDef());
return generic?.templateRef ?? null;
}
/** Get cards for a specific column (optionally within a swimlane) */
protected getColumnCards(columnId: string, swimlaneId?: string): KbCard[] {
return this.boardService.getCardsForColumn(this.filteredCards(), columnId, swimlaneId);
}
/** Get total card count for a swimlane */
protected getSwimlaneCardCount(swimlaneId: string): number {
return this.filteredCards().filter(c => c.swimlaneId === swimlaneId).length;
}
/** Check if a column is collapsed */
protected isColumnCollapsed(columnId: string): boolean {
return this.boardService.isColumnCollapsed(columnId);
}
/** Check if a swimlane is collapsed */
protected isSwimlaneCollapsed(swimlaneId: string): boolean {
return this.boardService.isSwimlaneCollapsed(swimlaneId);
}
// --- Event handlers ---
protected onCardMove(event: KbCardMoveEvent): void {
// Check WIP limits on target column
const targetCol = this.columns().find(c => c.id === event.toColumnId);
if (targetCol && event.fromColumnId !== event.toColumnId) {
const currentCount = this.getColumnCards(event.toColumnId).length;
if (isWipExceeded(currentCount + 1, targetCol.wipLimit)) {
this.wipExceeded.emit({
column: targetCol,
cardCount: currentCount + 1,
wipLimit: targetCol.wipLimit!,
});
}
}
this.cardMove.emit(event);
this.dragEnd.emit({ card: event.card, dropped: true });
}
protected onCardClick(event: KbCardClickEvent): void {
this.cardClick.emit(event);
}
protected onCardDblClick(event: KbCardDblClickEvent): void {
this.cardDblClick.emit(event);
}
protected onCardContextMenu(event: KbCardContextMenuEvent): void {
this.cardContextMenu.emit(event);
}
protected onCardAdd(event: KbCardAddEvent): void {
this.cardAdd.emit(event);
}
protected onColumnClick(event: KbColumnClickEvent): void {
this.columnClick.emit(event);
}
protected onColumnToggle(event: KbColumnToggleEvent): void {
this.boardService.toggleColumn(event.column.id);
this.columnToggle.emit(event);
}
protected onSwimlaneToggle(event: KbSwimlaneToggleEvent): void {
this.boardService.toggleSwimlane(event.swimlane.id);
this.swimlaneToggle.emit(event);
}
protected onViewChange(event: KbViewChangeEvent): void {
this.viewChange.emit(event);
}
protected onSortChange(event: KbSortChangeEvent): void {
this.boardService.setSort(event.sort);
this.sortChange.emit(event);
}
protected onSearchChange(term: string): void {
this.filterService.setSearch(term);
this.filterChange.emit({ filter: this.filterService.currentFilter() });
}
// --- Column reorder DnD ---
protected onColumnDragOver(event: DragEvent): void {
if (!this.dragService.isDraggingColumn()) return;
event.preventDefault();
}
protected onColumnDrop(event: DragEvent, targetColumnId: string): void {
if (!this.dragService.isDraggingColumn()) return;
event.preventDefault();
const reorderEvent = this.dragService.dropColumn(this.columns(), targetColumnId);
if (reorderEvent) {
this.columnReorder.emit(reorderEvent);
}
}
}

View File

@@ -0,0 +1 @@
export * from './kb-card-def.directive';

View File

@@ -0,0 +1,27 @@
import { Directive, TemplateRef, inject } from '@angular/core';
import type { KbCard, KbPriorityMeta } from '../../types/kanban.types';
/** Template context for custom card rendering */
export interface KbCardDefContext {
$implicit: KbCard;
card: KbCard;
index: number;
isDragging: boolean;
isOverdue: boolean;
priorityMeta: KbPriorityMeta | undefined;
}
@Directive({
selector: 'ng-template[kbCardDef]',
standalone: true,
})
export class KbCardDefDirective {
readonly templateRef = inject(TemplateRef<KbCardDefContext>);
static ngTemplateContextGuard(
_dir: KbCardDefDirective,
ctx: unknown,
): ctx is KbCardDefContext {
return true;
}
}

View File

@@ -0,0 +1 @@
export * from './kb-card.component';

View File

@@ -0,0 +1,137 @@
@if (customTemplate()) {
<ng-container *ngTemplateOutlet="customTemplate()!; context: templateContext()" />
} @else {
<div
class="kb-card"
[class.kb-card--dragging]="isDragging()"
[class.kb-card--blocked]="card().blocked"
[class.kb-card--overdue]="isCardOverdue()"
draggable="true"
(dragstart)="onDragStart($event)"
(dragend)="onDragEnd()"
(click)="onClick($event)"
(dblclick)="onDblClick($event)"
(contextmenu)="onContextMenu($event)"
>
<!-- Cover image -->
@if (card().coverImage) {
<div class="kb-card__cover">
<img [src]="card().coverImage" [alt]="card().title" />
</div>
}
<!-- Labels -->
@if (card().labels?.length) {
<div class="kb-card__labels">
@for (label of card().labels; track label.id) {
<span
class="kb-card__label"
[style.backgroundColor]="label.color + '20'"
[style.color]="label.color"
>
{{ label.text }}
</span>
}
</div>
}
<!-- Title -->
<div class="kb-card__title">{{ card().title }}</div>
<!-- Description -->
@if (card().description) {
<div
class="kb-card__description"
[style.-webkit-line-clamp]="descriptionLines"
>
{{ card().description }}
</div>
}
<!-- Footer -->
<div class="kb-card__footer">
<div class="kb-card__meta">
<!-- Priority -->
@if (priorityMeta(); as pm) {
<span
class="kb-card__priority"
[style.color]="pm.color"
[title]="pm.label"
>
@if (pm.icon === 'arrow-up') {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 3.5l4.5 5H9V13H7V8.5H3.5L8 3.5z"/></svg>
} @else if (pm.icon === 'arrow-down') {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 12.5L3.5 7.5H7V3h2v4.5h3.5L8 12.5z"/></svg>
} @else if (pm.icon === 'alert-circle') {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 10.5a.75.75 0 110-1.5.75.75 0 010 1.5zM8.75 4v4.5h-1.5V4h1.5z"/></svg>
} @else {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 8h10v1.5H3z"/></svg>
}
</span>
}
<!-- Due date -->
@if (card().dueDate) {
<span
class="kb-card__due"
[class.kb-card__due--overdue]="isCardOverdue()"
>
{{ formattedDueDate() }}
</span>
}
<!-- Story points -->
@if (card().storyPoints !== undefined) {
<span class="kb-card__points" [title]="card().storyPoints + ' story points'">
{{ card().storyPoints }}pt
</span>
}
<!-- Subtasks -->
@if (card().subtasks) {
<span class="kb-card__subtasks">
<span class="kb-card__subtasks-bar">
<span
class="kb-card__subtasks-fill"
[style.width.%]="(card().subtasks!.completed / card().subtasks!.total) * 100"
></span>
</span>
<span class="kb-card__subtasks-text">{{ card().subtasks!.completed }}/{{ card().subtasks!.total }}</span>
</span>
}
<!-- Blocked -->
@if (card().blocked) {
<span class="kb-card__blocked" [title]="card().blockedReason || 'Blocked'">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 1a7 7 0 100 14A7 7 0 008 1zm0 1.5a5.5 5.5 0 014.33 8.89L4.61 3.67A5.47 5.47 0 018 2.5zm0 11a5.5 5.5 0 01-4.33-8.89l7.72 7.72A5.47 5.47 0 018 13.5z"/></svg>
</span>
}
</div>
<!-- Assignees -->
@if (card().assignees?.length) {
<div class="kb-card__assignees">
@for (assignee of card().assignees; track assignee.id; let i = $index) {
@if (i < 3) {
<span
class="kb-card__avatar"
[title]="assignee.name"
>
@if (assignee.avatar) {
<img [src]="assignee.avatar" [alt]="assignee.name" />
} @else {
{{ assignee.name.charAt(0).toUpperCase() }}
}
</span>
}
}
@if ((card().assignees?.length ?? 0) > 3) {
<span class="kb-card__avatar kb-card__avatar--more">
+{{ (card().assignees?.length ?? 0) - 3 }}
</span>
}
</div>
}
</div>
</div>
}

View File

@@ -0,0 +1,221 @@
:host {
display: block;
min-width: 0;
max-width: 100%;
}
.kb-card {
background: var(--kb-card-bg);
border: 1px solid var(--kb-card-border);
border-radius: var(--kb-card-radius);
padding: var(--kb-card-padding);
box-shadow: var(--kb-card-shadow);
cursor: grab;
transition:
box-shadow 200ms var(--kb-ease-smooth),
transform 250ms var(--kb-ease-spring),
opacity 200ms var(--kb-ease-smooth),
border-color var(--kb-transition);
display: flex;
flex-direction: column;
gap: var(--kb-card-gap);
min-width: 0;
max-width: 100%;
overflow: hidden;
overflow-wrap: break-word;
word-break: break-word;
&:hover {
transform: translateY(-1px);
box-shadow: var(--kb-card-shadow-hover);
}
&--dragging {
transform: scale(var(--kb-card-drag-scale)) rotate(var(--kb-card-drag-rotate));
box-shadow: var(--kb-card-shadow-dragging);
opacity: var(--kb-card-dragging-opacity);
cursor: grabbing;
z-index: 100;
}
&--blocked {
border-left: 3px solid var(--kb-card-blocked-border);
}
&--overdue {
// Overdue styling handled via due date element
}
}
.kb-card__cover {
margin: calc(var(--kb-card-padding) * -1) calc(var(--kb-card-padding) * -1) 0;
border-radius: var(--kb-card-radius) var(--kb-card-radius) 0 0;
overflow: hidden;
max-height: 120px;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.kb-card__labels {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.kb-card__label {
display: inline-block;
padding: var(--kb-label-padding);
border-radius: var(--kb-label-radius);
font-size: var(--kb-label-font-size);
font-weight: 600;
line-height: 1.4;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.kb-card__title {
font-size: var(--kb-card-title-font-size);
font-weight: var(--kb-card-title-font-weight);
color: var(--kb-card-title-color);
line-height: 1.4;
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.kb-card__description {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: var(--kb-card-desc-font-size);
color: var(--kb-card-desc-color);
line-height: 1.5;
overflow-wrap: break-word;
word-break: break-word;
min-width: 0;
}
.kb-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.25rem;
padding-top: 0.375rem;
}
.kb-card__meta {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: var(--kb-card-meta-font-size);
color: var(--kb-card-meta-color);
}
.kb-card__priority {
display: inline-flex;
align-items: center;
line-height: 1;
svg {
width: 0.875rem;
height: 0.875rem;
}
}
.kb-card__due {
&--overdue {
color: var(--kb-card-overdue-color);
font-weight: 500;
}
}
.kb-card__points {
padding: 0.0625rem 0.375rem;
border-radius: 999px;
background: var(--kb-bg);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.kb-card__subtasks {
display: flex;
align-items: center;
gap: 0.375rem;
}
.kb-card__subtasks-bar {
width: 3rem;
height: 3px;
border-radius: 2px;
background: var(--kb-bg);
overflow: hidden;
}
.kb-card__subtasks-fill {
height: 100%;
border-radius: 2px;
background: var(--color-primary-500, #3b82f6);
transition: width 300ms var(--kb-ease-smooth);
}
.kb-card__subtasks-text {
font-variant-numeric: tabular-nums;
}
.kb-card__blocked {
display: inline-flex;
align-items: center;
svg {
width: 0.875rem;
height: 0.875rem;
}
}
.kb-card__assignees {
display: flex;
align-items: center;
flex-shrink: 0;
&:hover .kb-card__avatar + .kb-card__avatar {
margin-left: 0.125rem;
}
}
.kb-card__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--color-primary-100, #dbeafe);
color: var(--color-primary-700, #1d4ed8);
font-size: 0.625rem;
font-weight: 600;
border: 2px solid var(--kb-card-bg);
overflow: hidden;
transition: margin 200ms var(--kb-ease-spring);
& + & {
margin-left: -0.375rem;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&--more {
background: var(--kb-card-meta-color);
color: #fff;
font-size: 0.5rem;
}
}

View File

@@ -0,0 +1,102 @@
import {
Component, ChangeDetectionStrategy, input, output, computed, inject,
TemplateRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import type { KbCard, KbPriorityMeta } from '../../types/kanban.types';
import type { KbCardClickEvent, KbCardDblClickEvent, KbCardContextMenuEvent } from '../../types/event.types';
import { KANBAN_CONFIG } from '../../providers/kanban-config.provider';
import { KanbanDragService } from '../../services/kanban-drag.service';
import { isOverdue, formatCardDate } from '../../utils/date.utils';
import type { KbCardDefContext } from '../kb-card-def/kb-card-def.directive';
@Component({
selector: 'kb-card',
standalone: true,
imports: [CommonModule],
templateUrl: './kb-card.component.html',
styleUrl: './kb-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KbCardComponent {
private config = inject(KANBAN_CONFIG);
private dragService = inject(KanbanDragService);
readonly card = input.required<KbCard>();
readonly columnId = input.required<string>();
readonly index = input(0);
readonly swimlaneId = input<string>();
readonly customTemplate = input<TemplateRef<KbCardDefContext> | null>(null);
readonly cardClick = output<KbCardClickEvent>();
readonly cardDblClick = output<KbCardDblClickEvent>();
readonly cardContextMenu = output<KbCardContextMenuEvent>();
readonly priorityMeta = computed<KbPriorityMeta | undefined>(() => {
const p = this.card().priority;
if (!p) return undefined;
return this.config.priorities.find(pm => pm.level === p);
});
readonly isCardOverdue = computed(() => isOverdue(this.card().dueDate));
readonly formattedDueDate = computed(() =>
formatCardDate(this.card().dueDate, this.config.locale)
);
readonly isDragging = computed(() =>
this.dragService.draggedCard()?.id === this.card().id
);
readonly descriptionLines = this.config.cardDescriptionLines;
readonly templateContext = computed<KbCardDefContext>(() => ({
$implicit: this.card(),
card: this.card(),
index: this.index(),
isDragging: this.isDragging(),
isOverdue: this.isCardOverdue(),
priorityMeta: this.priorityMeta(),
}));
protected onDragStart(event: DragEvent): void {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', this.card().id);
// Custom ghost image
const el = event.target as HTMLElement;
const ghost = el.cloneNode(true) as HTMLElement;
ghost.style.width = `${el.offsetWidth}px`;
ghost.style.opacity = '0.8';
ghost.style.position = 'absolute';
ghost.style.top = '-9999px';
document.body.appendChild(ghost);
event.dataTransfer.setDragImage(ghost, event.offsetX, event.offsetY);
setTimeout(() => document.body.removeChild(ghost));
this.dragService.startDrag(
this.card(),
this.columnId(),
this.index(),
this.swimlaneId(),
);
}
protected onDragEnd(): void {
this.dragService.endDrag();
}
protected onClick(event: MouseEvent): void {
this.cardClick.emit({ card: this.card(), columnId: this.columnId(), event });
}
protected onDblClick(event: MouseEvent): void {
this.cardDblClick.emit({ card: this.card(), columnId: this.columnId(), event });
}
protected onContextMenu(event: MouseEvent): void {
event.preventDefault();
this.cardContextMenu.emit({ card: this.card(), columnId: this.columnId(), event });
}
}

View File

@@ -0,0 +1 @@
export * from './kb-column-header-def.directive';

View File

@@ -0,0 +1,27 @@
import { Directive, TemplateRef, inject, input } from '@angular/core';
import type { KbColumn, KbWipStatus } from '../../types/kanban.types';
/** Template context for custom column header rendering */
export interface KbColumnHeaderDefContext {
$implicit: KbColumn;
column: KbColumn;
cardCount: number;
wipStatus: KbWipStatus;
}
@Directive({
selector: 'ng-template[kbColumnHeaderDef]',
standalone: true,
})
export class KbColumnHeaderDefDirective {
/** Optionally bind to a specific column ID */
readonly kbColumnHeaderDef = input<string>('');
readonly templateRef = inject(TemplateRef<KbColumnHeaderDefContext>);
static ngTemplateContextGuard(
_dir: KbColumnHeaderDefDirective,
ctx: unknown,
): ctx is KbColumnHeaderDefContext {
return true;
}
}

View File

@@ -0,0 +1 @@
export * from './kb-column.component';

View File

@@ -0,0 +1,99 @@
<div
class="kb-column"
[class.kb-column--collapsed]="collapsed()"
[class.kb-column--over]="isOver()"
[class.kb-column--wip-exceeded]="wipStatus() === 'exceeded'"
[draggable]="allowReorder()"
(dragstart)="onColumnDragStart($event)"
(dragend)="onColumnDragEnd()"
role="region"
[attr.aria-label]="column().title + ' column'"
>
<!-- Accent bar -->
@if (column().color) {
<div class="kb-column__accent-bar" [style.backgroundColor]="column().color"></div>
}
<!-- Header -->
@if (headerTemplate()) {
<ng-container *ngTemplateOutlet="headerTemplate()!; context: headerContext()" />
} @else {
<div class="kb-column__header" (click)="onHeaderClick($event)">
<div class="kb-column__header-left">
<span class="kb-column__title">{{ column().title }}</span>
@if (showCardCount()) {
<span class="kb-column__count">{{ cards().length }}</span>
}
@if (showWipLimits() && column().wipLimit) {
<kb-wip-indicator
[current]="cards().length"
[limit]="column().wipLimit!"
[status]="wipStatus()"
/>
}
</div>
<div class="kb-column__header-right">
<button
class="kb-column__collapse-btn"
type="button"
(click)="onToggleCollapse(); $event.stopPropagation()"
[title]="collapsed() ? 'Expand' : 'Collapse'"
[attr.aria-expanded]="!collapsed()"
>
@if (collapsed()) {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M6 3.5L10.5 8 6 12.5V3.5z"/></svg>
} @else {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3.5 6L8 10.5 12.5 6H3.5z"/></svg>
}
</button>
</div>
</div>
}
<!-- Card list -->
@if (!collapsed()) {
<div
class="kb-column__body"
#cardList
(dragover)="onDragOver($event)"
(dragenter)="onDragEnter($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
@for (card of cards(); track card.id; let i = $index) {
<kb-card
[card]="card"
[columnId]="column().id"
[index]="i"
[swimlaneId]="swimlaneId()"
[customTemplate]="cardTemplate()"
(cardClick)="onCardClick($event)"
(cardDblClick)="onCardDblClick($event)"
(cardContextMenu)="onCardContextMenu($event)"
/>
} @empty {
<div class="kb-column__empty">
@if (isOver()) {
<div class="kb-column__drop-placeholder">Drop here</div>
} @else {
<span class="kb-column__empty-text">No cards</span>
}
</div>
}
<!-- Drop indicator when hovering with cards -->
@if (isOver() && cards().length > 0) {
<div class="kb-column__drop-indicator"></div>
}
</div>
<!-- Quick add -->
@if (allowQuickAdd() && column().allowAdd !== false) {
<kb-quick-add
[columnId]="column().id"
[swimlaneId]="swimlaneId()"
(cardAdd)="onCardAdd($event)"
/>
}
}
</div>

View File

@@ -0,0 +1,185 @@
:host {
display: block;
flex-shrink: 0;
width: fit-content;
}
.kb-column {
background: transparent;
border: none;
border-radius: var(--kb-column-radius);
width: var(--kb-column-width, 380px);
min-width: var(--kb-column-min-width, 340px);
max-width: var(--kb-column-max-width, 480px);
display: flex;
flex-direction: column;
max-height: 100%;
overflow: hidden;
transition:
width var(--kb-transition-slow),
min-width var(--kb-transition-slow),
border-color 200ms var(--kb-ease-smooth),
box-shadow 200ms var(--kb-ease-smooth);
&--collapsed {
width: var(--kb-column-collapsed-width);
min-width: var(--kb-column-collapsed-width);
max-width: var(--kb-column-collapsed-width);
overflow: hidden;
}
&--over {
border-color: var(--kb-drop-zone-border);
background: var(--kb-drop-zone-bg);
box-shadow:
0 0 0 1px var(--kb-drop-zone-border),
0 0 var(--kb-glow-spread) rgba(59, 130, 246, var(--kb-glow-opacity));
}
&--wip-exceeded {
border-color: var(--kb-wip-exceeded-color);
}
}
.kb-column__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--kb-column-header-padding);
cursor: pointer;
user-select: none;
flex-shrink: 0;
}
.kb-column__header-left {
display: flex;
align-items: center;
gap: 0.625rem;
min-width: 0;
}
.kb-column__header-right {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.kb-column__accent-bar {
height: var(--kb-column-accent-height);
border-radius: var(--kb-column-radius) var(--kb-column-radius) 0 0;
flex-shrink: 0;
}
.kb-column__title {
font-size: var(--kb-column-header-font-size);
font-weight: var(--kb-column-header-font-weight);
color: var(--kb-column-header-color);
text-transform: uppercase;
letter-spacing: 0.05em;
font-size: 0.75rem;
overflow-wrap: break-word;
word-break: break-word;
}
.kb-column__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 999px;
background: var(--kb-bg);
color: var(--kb-card-meta-color);
font-size: var(--font-size-xs, 0.75rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.kb-column__collapse-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border: none;
background: transparent;
color: var(--kb-card-meta-color);
cursor: pointer;
border-radius: 999px;
transition: background var(--kb-transition), transform 200ms var(--kb-ease-spring);
&:hover {
background: var(--kb-quick-add-hover-bg);
}
svg {
width: 0.75rem;
height: 0.75rem;
transition: transform 200ms var(--kb-ease-spring);
}
}
.kb-column__body {
flex: 1;
overflow-x: hidden;
overflow-y: auto;
padding: 0.25rem var(--kb-column-padding) var(--kb-column-padding);
display: flex;
flex-direction: column;
gap: 0.875rem;
min-height: 2rem;
min-width: 0;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--kb-card-meta-color);
border-radius: 4px;
&:hover {
background: var(--kb-card-desc-color);
}
}
scrollbar-width: thin;
scrollbar-color: var(--kb-card-meta-color) transparent;
}
.kb-column__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 4rem;
}
.kb-column__empty-text {
font-size: var(--font-size-sm, 0.875rem);
color: var(--kb-card-meta-color);
}
.kb-column__drop-placeholder {
padding: 1rem;
border: 2px dashed var(--kb-drop-zone-border);
border-radius: var(--kb-card-radius);
background: var(--kb-drop-zone-bg);
color: var(--color-primary-500, #3b82f6);
font-size: var(--font-size-sm, 0.875rem);
text-align: center;
width: 100%;
}
.kb-column__drop-indicator {
height: 2px;
background: var(--kb-drop-indicator-color);
border-radius: 1px;
margin: -0.25rem 0;
}

View File

@@ -0,0 +1,147 @@
import {
Component, ChangeDetectionStrategy, input, output, computed, inject,
ElementRef, viewChild, TemplateRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import type { KbCard, KbColumn, KbWipStatus } from '../../types/kanban.types';
import type {
KbCardMoveEvent, KbCardClickEvent, KbCardDblClickEvent,
KbCardContextMenuEvent, KbCardAddEvent, KbColumnClickEvent,
KbColumnToggleEvent,
} from '../../types/event.types';
import { KANBAN_CONFIG } from '../../providers/kanban-config.provider';
import { KanbanDragService } from '../../services/kanban-drag.service';
import { KanbanBoardService } from '../../services/kanban-board.service';
import { KbCardComponent } from '../kb-card/kb-card.component';
import { KbQuickAddComponent } from '../kb-quick-add/kb-quick-add.component';
import { KbWipIndicatorComponent } from '../kb-wip-indicator/kb-wip-indicator.component';
import type { KbCardDefContext } from '../kb-card-def/kb-card-def.directive';
import type { KbColumnHeaderDefContext } from '../kb-column-header-def/kb-column-header-def.directive';
@Component({
selector: 'kb-column',
standalone: true,
imports: [CommonModule, KbCardComponent, KbQuickAddComponent, KbWipIndicatorComponent],
templateUrl: './kb-column.component.html',
styleUrl: './kb-column.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KbColumnComponent {
private config = inject(KANBAN_CONFIG);
private dragService = inject(KanbanDragService);
private boardService = inject(KanbanBoardService);
readonly column = input.required<KbColumn>();
readonly cards = input<KbCard[]>([]);
readonly collapsed = input(false);
readonly allowReorder = input(false);
readonly allowQuickAdd = input(true);
readonly showWipLimits = input(true);
readonly showCardCount = input(true);
readonly swimlaneId = input<string>();
readonly cardTemplate = input<TemplateRef<KbCardDefContext> | null>(null);
readonly headerTemplate = input<TemplateRef<KbColumnHeaderDefContext> | null>(null);
readonly cardMove = output<KbCardMoveEvent>();
readonly cardClick = output<KbCardClickEvent>();
readonly cardDblClick = output<KbCardDblClickEvent>();
readonly cardContextMenu = output<KbCardContextMenuEvent>();
readonly cardAdd = output<KbCardAddEvent>();
readonly columnClick = output<KbColumnClickEvent>();
readonly columnToggle = output<KbColumnToggleEvent>();
private cardListRef = viewChild<ElementRef<HTMLElement>>('cardList');
readonly isOver = computed(() => this.dragService.isOverColumn(this.column().id));
readonly wipStatus = computed<KbWipStatus>(() =>
this.boardService.getWipStatus(this.cards().length, this.column().wipLimit)
);
readonly headerContext = computed<KbColumnHeaderDefContext>(() => ({
$implicit: this.column(),
column: this.column(),
cardCount: this.cards().length,
wipStatus: this.wipStatus(),
}));
protected onHeaderClick(event: MouseEvent): void {
this.columnClick.emit({ column: this.column(), event });
}
protected onToggleCollapse(): void {
this.columnToggle.emit({ column: this.column(), collapsed: !this.collapsed() });
}
// --- Card DnD handlers ---
protected onDragOver(event: DragEvent): void {
event.preventDefault();
if (!event.dataTransfer) return;
event.dataTransfer.dropEffect = 'move';
const listEl = this.cardListRef()?.nativeElement;
if (!listEl) return;
const cardElements = Array.from(listEl.querySelectorAll<HTMLElement>('.kb-card'));
const dropIdx = this.dragService.calculateDropIndex(event, cardElements);
this.dragService.dragOverColumn(this.column().id, dropIdx);
}
protected onDragEnter(event: DragEvent): void {
event.preventDefault();
}
protected onDragLeave(event: DragEvent): void {
// Only handle if actually leaving the column
const el = event.currentTarget as HTMLElement;
const related = event.relatedTarget as Node | null;
if (related && el.contains(related)) return;
this.dragService.dragLeaveColumn(this.column().id);
}
protected onDrop(event: DragEvent): void {
event.preventDefault();
const dropIdx = this.dragService.dropIndex();
const targetIdx = dropIdx >= 0 ? dropIdx : this.cards().length;
const moveEvent = this.dragService.drop(
this.column().id,
targetIdx,
this.swimlaneId(),
);
if (moveEvent) {
this.cardMove.emit(moveEvent);
}
}
// --- Column DnD (for reorder) ---
protected onColumnDragStart(event: DragEvent): void {
if (!this.allowReorder()) return;
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', `column:${this.column().id}`);
this.dragService.startColumnDrag(this.column().id);
}
protected onColumnDragEnd(): void {
this.dragService.endDrag();
}
protected onCardClick(event: KbCardClickEvent): void {
this.cardClick.emit(event);
}
protected onCardDblClick(event: KbCardDblClickEvent): void {
this.cardDblClick.emit(event);
}
protected onCardContextMenu(event: KbCardContextMenuEvent): void {
this.cardContextMenu.emit(event);
}
protected onCardAdd(event: KbCardAddEvent): void {
this.cardAdd.emit(event);
}
}

View File

@@ -0,0 +1 @@
export * from './kb-quick-add.component';

View File

@@ -0,0 +1,25 @@
@if (isEditing()) {
<div class="kb-quick-add__form">
<input
#addInput
class="kb-quick-add__input"
type="text"
placeholder="Enter card title..."
[value]="inputValue()"
(input)="onInput($event)"
(keydown)="onKeydown($event)"
(blur)="cancel()"
/>
</div>
} @else {
<button
class="kb-quick-add__trigger"
type="button"
(click)="startEditing()"
>
<span class="kb-quick-add__icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="8" cy="8" r="6.5"/><path d="M8 5v6M5 8h6"/></svg>
</span>
<span>Add card</span>
</button>
}

View File

@@ -0,0 +1,58 @@
:host {
display: block;
}
.kb-quick-add__trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem var(--kb-column-padding, 1rem);
border: none;
background: var(--kb-quick-add-bg);
color: var(--kb-quick-add-text);
font-size: var(--font-size-sm, 0.875rem);
cursor: pointer;
border-radius: 0 0 var(--kb-column-radius, 0.75rem) var(--kb-column-radius, 0.75rem);
transition: background 200ms var(--kb-ease-smooth);
&:hover {
background: var(--kb-quick-add-hover-bg);
}
}
.kb-quick-add__icon {
display: inline-flex;
align-items: center;
justify-content: center;
svg {
width: 1rem;
height: 1rem;
}
}
.kb-quick-add__form {
padding: 0.625rem var(--kb-column-padding, 1rem) 0.75rem;
}
.kb-quick-add__input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--kb-toolbar-border);
border-radius: 0.5rem;
background: var(--kb-card-bg);
color: var(--kb-card-title-color);
font-size: var(--font-size-sm, 0.875rem);
outline: none;
transition: border-color 200ms var(--kb-ease-smooth), box-shadow 200ms var(--kb-ease-smooth);
&:focus {
border-color: var(--kb-drop-zone-border);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&::placeholder {
color: var(--kb-quick-add-text);
}
}

View File

@@ -0,0 +1,61 @@
import {
Component, ChangeDetectionStrategy, input, output, signal,
ElementRef, viewChild,
} from '@angular/core';
import type { KbCardAddEvent } from '../../types/event.types';
@Component({
selector: 'kb-quick-add',
standalone: true,
templateUrl: './kb-quick-add.component.html',
styleUrl: './kb-quick-add.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KbQuickAddComponent {
readonly columnId = input.required<string>();
readonly swimlaneId = input<string>();
readonly cardAdd = output<KbCardAddEvent>();
readonly isEditing = signal(false);
readonly inputValue = signal('');
private inputRef = viewChild<ElementRef<HTMLInputElement>>('addInput');
protected startEditing(): void {
this.isEditing.set(true);
this.inputValue.set('');
// Focus after view update
setTimeout(() => this.inputRef()?.nativeElement.focus());
}
protected onKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter') {
event.preventDefault();
this.submit();
} else if (event.key === 'Escape') {
this.cancel();
}
}
protected onInput(event: Event): void {
this.inputValue.set((event.target as HTMLInputElement).value);
}
protected submit(): void {
const title = this.inputValue().trim();
if (title) {
this.cardAdd.emit({
title,
columnId: this.columnId(),
swimlaneId: this.swimlaneId(),
});
}
this.cancel();
}
protected cancel(): void {
this.isEditing.set(false);
this.inputValue.set('');
}
}

View File

@@ -0,0 +1 @@
export * from './kb-swimlane.component';

View File

@@ -0,0 +1,27 @@
<div class="kb-swimlane" [class.kb-swimlane--collapsed]="collapsed()">
<button
class="kb-swimlane__header"
type="button"
(click)="toggle()"
[disabled]="swimlane().collapsible === false"
role="button"
[attr.aria-expanded]="!collapsed()"
>
@if (swimlane().collapsible !== false) {
<span class="kb-swimlane__toggle">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3.5 6L8 10.5 12.5 6H3.5z"/></svg>
</span>
}
@if (swimlane().color) {
<span class="kb-swimlane__color-bar" [style.backgroundColor]="swimlane().color"></span>
}
<span class="kb-swimlane__title">{{ swimlane().title }}</span>
<span class="kb-swimlane__count">{{ cardCount() }}</span>
</button>
@if (!collapsed()) {
<div class="kb-swimlane__body">
<ng-content />
</div>
}
</div>

View File

@@ -0,0 +1,109 @@
:host {
display: block;
}
.kb-swimlane {
border-bottom: 1px solid var(--kb-swimlane-border);
&:last-child {
border-bottom: none;
}
}
.kb-swimlane__header {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: var(--kb-swimlane-header-padding);
border: none;
background: var(--kb-glass-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
cursor: pointer;
text-align: left;
border-radius: 0.5rem;
transition: background 200ms var(--kb-ease-smooth);
&:hover {
background: var(--kb-quick-add-hover-bg);
}
&:disabled {
cursor: default;
&:hover {
background: var(--kb-glass-bg);
}
}
}
.kb-swimlane__toggle {
display: inline-flex;
align-items: center;
color: var(--kb-card-meta-color);
transition: transform 200ms var(--kb-ease-spring);
svg {
width: 0.75rem;
height: 0.75rem;
}
}
.kb-swimlane--collapsed .kb-swimlane__toggle {
transform: rotate(-90deg);
}
.kb-swimlane__color-bar {
width: 3px;
height: 1rem;
border-radius: 2px;
flex-shrink: 0;
}
.kb-swimlane__title {
font-size: var(--kb-swimlane-header-font-size);
font-weight: var(--kb-swimlane-header-font-weight);
color: var(--kb-swimlane-header-color);
}
.kb-swimlane__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.375rem;
border-radius: 999px;
background: var(--kb-bg);
color: var(--kb-card-meta-color);
font-size: var(--font-size-xs, 0.75rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.kb-swimlane__body {
display: flex;
gap: var(--kb-column-gap);
overflow-x: auto;
padding: 0.75rem 0.25rem 1rem;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--kb-card-meta-color);
border-radius: 4px;
&:hover {
background: var(--kb-card-desc-color);
}
}
scrollbar-width: thin;
scrollbar-color: var(--kb-card-meta-color) transparent;
}

View File

@@ -0,0 +1,24 @@
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import type { KbSwimlane } from '../../types/kanban.types';
import type { KbSwimlaneToggleEvent } from '../../types/event.types';
@Component({
selector: 'kb-swimlane',
standalone: true,
templateUrl: './kb-swimlane.component.html',
styleUrl: './kb-swimlane.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KbSwimlaneComponent {
readonly swimlane = input.required<KbSwimlane>();
readonly collapsed = input(false);
readonly cardCount = input(0);
readonly swimlaneToggle = output<KbSwimlaneToggleEvent>();
protected toggle(): void {
const sl = this.swimlane();
if (sl.collapsible === false) return;
this.swimlaneToggle.emit({ swimlane: sl, collapsed: !this.collapsed() });
}
}

View File

@@ -0,0 +1 @@
export * from './kb-toolbar.component';

View File

@@ -0,0 +1,87 @@
<div class="kb-toolbar">
@if (searchable()) {
<div class="kb-toolbar__search">
<span class="kb-toolbar__search-icon">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></svg>
</span>
<input
class="kb-toolbar__search-input"
type="text"
placeholder="Search cards..."
[value]="searchTerm()"
(input)="onSearchInput($event)"
/>
</div>
}
<div class="kb-toolbar__actions">
<!-- Sort dropdown -->
<div class="kb-toolbar__sort">
<button
class="kb-toolbar__btn"
type="button"
(click)="toggleSortMenu()"
[title]="currentSort() ? 'Sort: ' + currentSort()!.field + ' ' + currentSort()!.direction : 'Sort cards'"
>
<span class="kb-toolbar__btn-icon">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M3 4h10v1.5H3V4zm0 3.25h7v1.5H3v-1.5zm0 3.25h4v1.5H3v-1.5z"/></svg>
Sort
</span>
</button>
@if (showSortMenu()) {
<div class="kb-toolbar__sort-menu">
@for (opt of sortOptions; track opt.field) {
<button
class="kb-toolbar__sort-option"
[class.kb-toolbar__sort-option--active]="currentSort()?.field === opt.field"
type="button"
(click)="onSort(opt.field)"
>
{{ opt.label }}
@if (currentSort()?.field === opt.field) {
<span class="kb-toolbar__sort-dir">
@if (currentSort()!.direction === 'asc') {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 3.5l4.5 5H3.5L8 3.5z"/></svg>
} @else {
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 12.5L3.5 7.5h9L8 12.5z"/></svg>
}
</span>
}
</button>
}
</div>
}
</div>
@if (filterCount() > 0) {
<span class="kb-toolbar__filter-badge">{{ filterCount() }}</span>
}
@if (showViewToggle()) {
<button
class="kb-toolbar__btn"
[class.kb-toolbar__btn--active]="viewMode() === 'board'"
type="button"
title="Board view"
(click)="viewMode() !== 'board' && toggleView()"
>
<span class="kb-toolbar__btn-icon">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h3v10H2V3zm4.5 0h3v10h-3V3zm4.5 0h3v10h-3V3z"/></svg>
Board
</span>
</button>
<button
class="kb-toolbar__btn"
[class.kb-toolbar__btn--active]="viewMode() === 'pipeline'"
type="button"
title="Pipeline view"
(click)="viewMode() !== 'pipeline' && toggleView()"
>
<span class="kb-toolbar__btn-icon">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M1 7.25h3.5v1.5H1v-1.5zm5 0h3.5v1.5H6v-1.5zm5 0h3.5v1.5H11v-1.5zM4.5 7.5l1.5 .5-1.5.5v-1zm5 0l1.5.5-1.5.5v-1z"/></svg>
Pipeline
</span>
</button>
}
</div>
</div>

View File

@@ -0,0 +1,175 @@
:host {
display: block;
}
.kb-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: var(--kb-toolbar-padding);
background: var(--kb-glass-bg);
backdrop-filter: blur(var(--kb-glass-blur));
-webkit-backdrop-filter: blur(var(--kb-glass-blur));
border: 1px solid var(--kb-glass-border);
border-bottom: 1px solid var(--kb-toolbar-border);
border-radius: var(--kb-column-radius) var(--kb-column-radius) 0 0;
}
.kb-toolbar__search {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
max-width: 320px;
padding: 0.375rem 0.75rem;
background: var(--kb-bg);
border: 1px solid var(--kb-toolbar-border);
border-radius: 999px;
transition: border-color 200ms var(--kb-ease-smooth), box-shadow 200ms var(--kb-ease-smooth);
&:focus-within {
border-color: var(--kb-drop-zone-border);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.kb-toolbar__search-icon {
display: inline-flex;
color: var(--kb-card-meta-color);
flex-shrink: 0;
svg {
width: 0.875rem;
height: 0.875rem;
}
}
.kb-toolbar__search-input {
flex: 1;
border: none;
background: transparent;
color: var(--kb-card-title-color);
font-size: var(--font-size-sm, 0.875rem);
outline: none;
&::placeholder {
color: var(--kb-quick-add-text);
}
}
.kb-toolbar__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kb-toolbar__btn {
padding: 0.375rem 0.875rem;
border: 1px solid var(--kb-toolbar-border);
border-radius: 999px;
background: transparent;
color: var(--kb-card-desc-color);
font-size: var(--font-size-sm, 0.875rem);
cursor: pointer;
transition: all 200ms var(--kb-ease-smooth);
&:hover {
background: var(--kb-quick-add-hover-bg);
color: var(--kb-card-title-color);
}
&--active {
background: var(--color-primary-50, #eff6ff);
color: var(--color-primary-500, #3b82f6);
border-color: var(--color-primary-500, #3b82f6);
}
}
.kb-toolbar__btn-icon {
display: inline-flex;
align-items: center;
gap: 0.375rem;
svg {
width: 0.875rem;
height: 0.875rem;
}
}
.kb-toolbar__sort {
position: relative;
}
.kb-toolbar__sort-menu {
position: absolute;
top: calc(100% + 0.375rem);
right: 0;
z-index: 10;
min-width: 180px;
background: var(--kb-card-bg);
border: 1px solid var(--kb-card-border);
border-radius: var(--kb-card-radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
animation: kb-slide-down 150ms var(--kb-ease-spring);
}
@keyframes kb-slide-down {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.kb-toolbar__sort-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--kb-card-title-color);
font-size: var(--font-size-sm, 0.875rem);
cursor: pointer;
text-align: left;
transition: background var(--kb-transition);
&:hover {
background: var(--kb-quick-add-hover-bg);
}
&--active {
color: var(--color-primary-500, #3b82f6);
font-weight: 500;
}
}
.kb-toolbar__sort-dir {
display: inline-flex;
svg {
width: 0.625rem;
height: 0.625rem;
}
}
.kb-toolbar__filter-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.25rem;
height: 1.25rem;
padding: 0 0.25rem;
border-radius: 999px;
background: var(--color-primary-500, #3b82f6);
color: #fff;
font-size: 0.625rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}

View File

@@ -0,0 +1,60 @@
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
import type { KbViewMode, KbCardSort, KbCardSortField } from '../../types/kanban.types';
import type { KbViewChangeEvent, KbSortChangeEvent } from '../../types/event.types';
@Component({
selector: 'kb-toolbar',
standalone: true,
templateUrl: './kb-toolbar.component.html',
styleUrl: './kb-toolbar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KbToolbarComponent {
readonly viewMode = input<KbViewMode>('board');
readonly searchable = input(true);
readonly showViewToggle = input(true);
readonly filterCount = input(0);
readonly currentSort = input<KbCardSort | null>(null);
readonly searchChange = output<string>();
readonly viewChange = output<KbViewChangeEvent>();
readonly sortChange = output<KbSortChangeEvent>();
readonly searchTerm = signal('');
readonly showSortMenu = signal(false);
readonly sortOptions: { field: KbCardSortField; label: string }[] = [
{ field: 'order', label: 'Manual order' },
{ field: 'priority', label: 'Priority' },
{ field: 'dueDate', label: 'Due date' },
{ field: 'title', label: 'Title' },
{ field: 'createdAt', label: 'Created date' },
];
protected onSearchInput(event: Event): void {
const value = (event.target as HTMLInputElement).value;
this.searchTerm.set(value);
this.searchChange.emit(value);
}
protected toggleView(): void {
const next: KbViewMode = this.viewMode() === 'board' ? 'pipeline' : 'board';
this.viewChange.emit({ view: next });
}
protected onSort(field: KbCardSortField): void {
const current = this.currentSort();
let direction: 'asc' | 'desc' = 'asc';
if (current?.field === field) {
direction = current.direction === 'asc' ? 'desc' : 'asc';
}
this.sortChange.emit({ sort: { field, direction } });
this.showSortMenu.set(false);
}
protected toggleSortMenu(): void {
this.showSortMenu.update(v => !v);
}
}

View File

@@ -0,0 +1 @@
export * from './kb-wip-indicator.component';

View File

@@ -0,0 +1,71 @@
import { Component, ChangeDetectionStrategy, input, computed } from '@angular/core';
import type { KbWipStatus } from '../../types/kanban.types';
@Component({
selector: 'kb-wip-indicator',
standalone: true,
template: `
<span
class="kb-wip"
[class.kb-wip--normal]="status() === 'normal'"
[class.kb-wip--warning]="status() === 'warning'"
[class.kb-wip--exceeded]="status() === 'exceeded'"
[title]="tooltipText()"
>
{{ current() }} / {{ limit() }}
</span>
`,
styles: [`
:host { display: inline-flex; }
.kb-wip {
display: inline-flex;
align-items: center;
padding: 0.1875rem 0.625rem;
border-radius: 999px;
font-size: var(--font-size-xs, 0.75rem);
font-weight: 500;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
transition: box-shadow 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.kb-wip--normal {
color: var(--kb-wip-normal-color);
background: var(--kb-wip-normal-bg);
}
.kb-wip--warning {
color: var(--kb-wip-warning-color);
background: var(--kb-wip-warning-bg);
box-shadow: 0 0 0 1px rgba(217, 119, 6, 0.2), 0 0 6px rgba(217, 119, 6, 0.15);
}
.kb-wip--exceeded {
color: var(--kb-wip-exceeded-color);
background: var(--kb-wip-exceeded-bg);
font-weight: 600;
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.3), 0 0 8px rgba(239, 68, 68, 0.2);
animation: kb-wip-pulse 2s ease-in-out infinite;
}
@keyframes kb-wip-pulse {
0%, 100% { box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.3), 0 0 8px rgba(239, 68, 68, 0.2); }
50% { box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.4), 0 0 12px rgba(239, 68, 68, 0.3); }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class KbWipIndicatorComponent {
readonly current = input.required<number>();
readonly limit = input.required<number>();
readonly status = input<KbWipStatus>('normal');
readonly tooltipText = computed(() => {
const s = this.status();
if (s === 'exceeded') return `WIP limit exceeded (${this.current()}/${this.limit()})`;
if (s === 'warning') return `Approaching WIP limit (${this.current()}/${this.limit()})`;
return `${this.current()} of ${this.limit()} WIP limit`;
});
}

48
src/index.ts Normal file
View File

@@ -0,0 +1,48 @@
/**
* Kanban Elements UI
* Main library entry point
*
* Angular components for kanban boards with drag-and-drop, swimlanes,
* WIP limits, pipeline views, and custom card templates powered by @sda/base-ui
*
* @example
* ```typescript
* import { Component, signal } from '@angular/core';
* import { KbBoardComponent, KbCardDefDirective, type KbCard, type KbColumn } from '@sda/kanban-elements-ui';
*
* @Component({
* standalone: true,
* imports: [KbBoardComponent, KbCardDefDirective],
* template: `
* <kb-board [cards]="cards()" [columns]="columns" (cardMove)="onCardMove($event)">
* <ng-template kbCardDef let-card>
* <div class="custom-card">{{ card.title }}</div>
* </ng-template>
* </kb-board>
* `
* })
* export class AppComponent {
* cards = signal<KbCard[]>([...]);
* columns: KbColumn[] = [
* { id: 'todo', title: 'To Do', order: 0 },
* { id: 'doing', title: 'In Progress', order: 1, wipLimit: 3 },
* { id: 'done', title: 'Done', order: 2 },
* ];
* }
* ```
*/
// Types
export * from './types';
// Utils
export * from './utils';
// Providers
export * from './providers';
// Services
export * from './services';
// Components
export * from './components';

1
src/providers/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './kanban-config.provider';

View File

@@ -0,0 +1,35 @@
import { InjectionToken, makeEnvironmentProviders, EnvironmentProviders } from '@angular/core';
import type { KbConfig } from '../types/config.types';
import type { KbPriorityMeta } from '../types/kanban.types';
const DEFAULT_PRIORITIES: KbPriorityMeta[] = [
{ level: 'critical', label: 'Critical', icon: 'alert-circle', color: '#dc2626' },
{ level: 'high', label: 'High', icon: 'arrow-up', color: '#f97316' },
{ level: 'medium', label: 'Medium', icon: 'minus', color: '#eab308' },
{ level: 'low', label: 'Low', icon: 'arrow-down', color: '#22c55e' },
{ level: 'none', label: 'None', icon: 'minus', color: '#9ca3af' },
];
export const DEFAULT_KANBAN_CONFIG: KbConfig = {
defaultView: 'board',
collapsibleColumns: true,
showCardCount: true,
showWipLimits: true,
wipWarningThreshold: 0.8,
allowColumnReorder: true,
allowQuickAdd: true,
cardDescriptionLines: 2,
priorities: DEFAULT_PRIORITIES,
locale: 'en-US',
};
export const KANBAN_CONFIG = new InjectionToken<KbConfig>('KANBAN_CONFIG', {
providedIn: 'root',
factory: () => DEFAULT_KANBAN_CONFIG,
});
export function provideKanbanConfig(config: Partial<KbConfig> = {}): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: KANBAN_CONFIG, useValue: { ...DEFAULT_KANBAN_CONFIG, ...config } },
]);
}

3
src/services/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './kanban-board.service';
export * from './kanban-drag.service';
export * from './kanban-filter.service';

View File

@@ -0,0 +1,88 @@
import { Injectable, inject, signal, computed } from '@angular/core';
import type { KbCard, KbCardSort, KbColumn, KbWipStatus } from '../types/kanban.types';
import { KANBAN_CONFIG } from '../providers/kanban-config.provider';
import { getCardsByColumn, sortCards } from '../utils/card.utils';
import { getWipStatus } from '../utils/wip.utils';
@Injectable()
export class KanbanBoardService {
private config = inject(KANBAN_CONFIG);
readonly collapsedColumns = signal<Set<string>>(new Set());
readonly collapsedSwimlanes = signal<Set<string>>(new Set());
readonly currentSort = signal<KbCardSort | null>(null);
/** Toggle a column's collapsed state */
toggleColumn(columnId: string): boolean {
const set = new Set(this.collapsedColumns());
const collapsed = !set.has(columnId);
if (collapsed) {
set.add(columnId);
} else {
set.delete(columnId);
}
this.collapsedColumns.set(set);
return collapsed;
}
/** Toggle a swimlane's collapsed state */
toggleSwimlane(swimlaneId: string): boolean {
const set = new Set(this.collapsedSwimlanes());
const collapsed = !set.has(swimlaneId);
if (collapsed) {
set.add(swimlaneId);
} else {
set.delete(swimlaneId);
}
this.collapsedSwimlanes.set(set);
return collapsed;
}
/** Set current sort */
setSort(sort: KbCardSort | null): void {
this.currentSort.set(sort);
}
/** Apply the current sort to cards within each column */
applySortWithinColumns(cards: KbCard[], columns: KbColumn[]): KbCard[] {
const sort = this.currentSort();
if (!sort) return cards;
const result: KbCard[] = [];
for (const col of columns) {
const columnCards = getCardsByColumn(cards, col.id);
const sorted = sortCards(columnCards, sort);
result.push(...sorted);
}
// Add any remaining cards not in known columns
const knownIds = new Set(columns.map(c => c.id));
for (const card of cards) {
if (!knownIds.has(card.columnId)) {
result.push(card);
}
}
return result;
}
/** Get cards for a specific column */
getCardsForColumn(cards: KbCard[], columnId: string, swimlaneId?: string): KbCard[] {
return getCardsByColumn(cards, columnId, swimlaneId).sort((a, b) => a.order - b.order);
}
/** Get WIP status for a column */
getWipStatus(cardCount: number, wipLimit?: number): KbWipStatus {
return getWipStatus(cardCount, wipLimit, this.config.wipWarningThreshold);
}
/** Check if a column is collapsed */
isColumnCollapsed(columnId: string): boolean {
return this.collapsedColumns().has(columnId);
}
/** Check if a swimlane is collapsed */
isSwimlaneCollapsed(swimlaneId: string): boolean {
return this.collapsedSwimlanes().has(swimlaneId);
}
}

View File

@@ -0,0 +1,128 @@
import { Injectable, signal } from '@angular/core';
import type { KbCard, KbColumn } from '../types/kanban.types';
import type { KbCardMoveEvent, KbColumnReorderEvent } from '../types/event.types';
@Injectable()
export class KanbanDragService {
// Card drag state
readonly isDragging = signal(false);
readonly draggedCard = signal<KbCard | null>(null);
readonly sourceColumnId = signal<string | null>(null);
readonly sourceSwimlaneId = signal<string | null>(null);
readonly sourceIndex = signal<number>(-1);
readonly overColumnId = signal<string | null>(null);
readonly dropIndex = signal<number>(-1);
// Column drag state
readonly isDraggingColumn = signal(false);
readonly draggedColumnId = signal<string | null>(null);
/** Start dragging a card */
startDrag(card: KbCard, columnId: string, index: number, swimlaneId?: string): void {
this.isDragging.set(true);
this.draggedCard.set(card);
this.sourceColumnId.set(columnId);
this.sourceSwimlaneId.set(swimlaneId ?? null);
this.sourceIndex.set(index);
}
/** Update when dragging over a column */
dragOverColumn(columnId: string, dropIdx: number): void {
this.overColumnId.set(columnId);
this.dropIndex.set(dropIdx);
}
/** Clear the over-column state when leaving */
dragLeaveColumn(columnId: string): void {
if (this.overColumnId() === columnId) {
this.overColumnId.set(null);
this.dropIndex.set(-1);
}
}
/** Complete a card drop and return the move event */
drop(targetColumnId: string, targetIndex: number, targetSwimlaneId?: string): KbCardMoveEvent | null {
const card = this.draggedCard();
const fromCol = this.sourceColumnId();
const fromIdx = this.sourceIndex();
if (!card || !fromCol) return null;
const event: KbCardMoveEvent = {
card,
fromColumnId: fromCol,
toColumnId: targetColumnId,
fromSwimlaneId: this.sourceSwimlaneId() ?? undefined,
toSwimlaneId: targetSwimlaneId,
fromIndex: fromIdx,
toIndex: targetIndex,
};
this.endDrag();
return event;
}
/** Clean up drag state */
endDrag(): void {
this.isDragging.set(false);
this.draggedCard.set(null);
this.sourceColumnId.set(null);
this.sourceSwimlaneId.set(null);
this.sourceIndex.set(-1);
this.overColumnId.set(null);
this.dropIndex.set(-1);
this.isDraggingColumn.set(false);
this.draggedColumnId.set(null);
}
/** Start dragging a column for reorder */
startColumnDrag(columnId: string): void {
this.isDraggingColumn.set(true);
this.draggedColumnId.set(columnId);
}
/** Complete a column drop and return the reorder event */
dropColumn(
columns: KbColumn[],
targetColumnId: string,
): KbColumnReorderEvent | null {
const draggedId = this.draggedColumnId();
if (!draggedId || draggedId === targetColumnId) {
this.endDrag();
return null;
}
const sorted = [...columns].sort((a, b) => a.order - b.order);
const fromIndex = sorted.findIndex(c => c.id === draggedId);
const toIndex = sorted.findIndex(c => c.id === targetColumnId);
const column = sorted[fromIndex];
if (fromIndex === -1 || toIndex === -1 || !column) {
this.endDrag();
return null;
}
const event: KbColumnReorderEvent = { column, fromIndex, toIndex };
this.endDrag();
return event;
}
/** Check if currently over a specific column */
isOverColumn(columnId: string): boolean {
return this.isDragging() && this.overColumnId() === columnId;
}
/** Calculate the drop index based on mouse Y position within a card list */
calculateDropIndex(event: DragEvent, cardElements: HTMLElement[]): number {
if (cardElements.length === 0) return 0;
const mouseY = event.clientY;
for (let i = 0; i < cardElements.length; i++) {
const rect = cardElements[i].getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (mouseY < midY) return i;
}
return cardElements.length;
}
}

View File

@@ -0,0 +1,66 @@
import { Injectable, signal, computed } from '@angular/core';
import type { KbCard, KbCardFilter, KbPriority } from '../types/kanban.types';
import { applyCardFilters, countActiveFilters } from '../utils/filter.utils';
@Injectable()
export class KanbanFilterService {
readonly searchTerm = signal('');
readonly priorityFilter = signal<KbPriority[]>([]);
readonly labelFilter = signal<string[]>([]);
readonly assigneeFilter = signal<string[]>([]);
readonly blockedFilter = signal<boolean | undefined>(undefined);
readonly activeFilterCount = computed(() =>
countActiveFilters(this.currentFilter())
);
readonly hasFilters = computed(() => this.activeFilterCount() > 0);
readonly currentFilter = computed<KbCardFilter>(() => ({
searchTerm: this.searchTerm() || undefined,
priorities: this.priorityFilter().length > 0 ? this.priorityFilter() : undefined,
labelIds: this.labelFilter().length > 0 ? this.labelFilter() : undefined,
assigneeIds: this.assigneeFilter().length > 0 ? this.assigneeFilter() : undefined,
blocked: this.blockedFilter(),
}));
/** Set search term */
setSearch(term: string): void {
this.searchTerm.set(term);
}
/** Set priority filter */
setPriorities(priorities: KbPriority[]): void {
this.priorityFilter.set(priorities);
}
/** Set label filter */
setLabels(labelIds: string[]): void {
this.labelFilter.set(labelIds);
}
/** Set assignee filter */
setAssignees(assigneeIds: string[]): void {
this.assigneeFilter.set(assigneeIds);
}
/** Set blocked filter */
setBlocked(blocked: boolean | undefined): void {
this.blockedFilter.set(blocked);
}
/** Apply all current filters to a list of cards */
applyFilters(cards: KbCard[]): KbCard[] {
if (!this.hasFilters()) return cards;
return applyCardFilters(cards, this.currentFilter());
}
/** Clear all filters */
clearAll(): void {
this.searchTerm.set('');
this.priorityFilter.set([]);
this.labelFilter.set([]);
this.assigneeFilter.set([]);
this.blockedFilter.set(undefined);
}
}

2
src/styles/_index.scss Normal file
View File

@@ -0,0 +1,2 @@
@forward 'tokens';
@forward 'mixins';

141
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,141 @@
@mixin kb-container {
display: block;
position: relative;
width: 100%;
font-family: var(--kb-font-family, inherit);
}
@mixin kb-flex-center {
display: flex;
align-items: center;
}
@mixin kb-flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
@mixin kb-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin kb-line-clamp($lines: 2) {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
}
@mixin kb-card-base {
background: var(--kb-card-bg);
border: 1px solid var(--kb-card-border);
border-radius: var(--kb-card-radius);
padding: var(--kb-card-padding);
box-shadow: var(--kb-card-shadow);
transition:
box-shadow 200ms var(--kb-ease-smooth),
transform 200ms var(--kb-ease-spring),
opacity 200ms var(--kb-ease-smooth),
border-color var(--kb-transition);
cursor: grab;
&:hover {
box-shadow: var(--kb-card-shadow-hover);
}
}
@mixin kb-column-base {
background: var(--kb-column-bg);
border: 1px solid var(--kb-column-border);
border-radius: var(--kb-column-radius);
width: var(--kb-column-width);
min-width: var(--kb-column-min-width);
max-width: var(--kb-column-max-width);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
@mixin kb-drop-zone {
border: 2px dashed var(--kb-drop-zone-border);
background: var(--kb-drop-zone-bg);
border-radius: var(--kb-card-radius);
}
@mixin kb-wip-state($state) {
@if $state == 'normal' {
color: var(--kb-wip-normal-color);
background: var(--kb-wip-normal-bg);
} @else if $state == 'warning' {
color: var(--kb-wip-warning-color);
background: var(--kb-wip-warning-bg);
} @else if $state == 'exceeded' {
color: var(--kb-wip-exceeded-color);
background: var(--kb-wip-exceeded-bg);
}
}
// Premium mixins
@mixin kb-glass {
background: var(--kb-glass-bg);
backdrop-filter: blur(var(--kb-glass-blur));
-webkit-backdrop-filter: blur(var(--kb-glass-blur));
border-color: var(--kb-glass-border);
}
@mixin kb-hover-lift {
transition:
box-shadow 200ms var(--kb-ease-smooth),
transform 250ms var(--kb-ease-spring);
&:hover {
transform: translateY(-1px);
box-shadow: var(--kb-card-shadow-hover);
}
}
@mixin kb-glow($color: var(--kb-glow-color)) {
box-shadow:
0 0 0 1px $color,
0 0 var(--kb-glow-spread) rgba(59, 130, 246, var(--kb-glow-opacity));
}
@mixin kb-drag-state {
transform: scale(var(--kb-card-drag-scale)) rotate(var(--kb-card-drag-rotate));
box-shadow: var(--kb-card-shadow-dragging);
opacity: var(--kb-card-dragging-opacity);
cursor: grabbing;
z-index: 100;
}
@mixin kb-scrollbar($width: 4px) {
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--kb-card-meta-color);
border-radius: $width;
opacity: 0.4;
&:hover {
background: var(--kb-card-desc-color);
}
}
scrollbar-width: thin;
scrollbar-color: var(--kb-card-meta-color) transparent;
}
@mixin kb-accent-bar($color: var(--kb-drop-indicator-color)) {
border-top: var(--kb-column-accent-height) solid $color;
}

167
src/styles/_tokens.scss Normal file
View File

@@ -0,0 +1,167 @@
:root {
// Board
--kb-bg: var(--color-bg-primary, #f5f5f7);
--kb-border: var(--color-border-primary, #e5e7eb);
// Column
--kb-column-bg: var(--color-bg-secondary, #ffffff);
--kb-column-border: var(--color-border-primary, #e5e7eb);
--kb-column-radius: 0.875rem;
--kb-column-width: 380px;
--kb-column-min-width: 340px;
--kb-column-max-width: 480px;
--kb-column-gap: 16px;
--kb-column-padding: 1rem;
--kb-column-header-bg: transparent;
--kb-column-header-padding: 1rem 1rem 0.625rem;
--kb-column-header-font-size: var(--font-size-sm, 0.875rem);
--kb-column-header-font-weight: 600;
--kb-column-header-color: var(--color-text-primary, #111827);
--kb-column-collapsed-width: 48px;
--kb-column-accent-height: 3px;
// Card
--kb-card-bg: var(--color-bg-secondary, #ffffff);
--kb-card-border: var(--color-border-primary, #e5e7eb);
--kb-card-radius: 0.75rem;
--kb-card-padding: 1rem;
--kb-card-gap: 0.625rem;
--kb-card-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 4px rgba(0, 0, 0, 0.03);
--kb-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
--kb-card-shadow-dragging: 0 16px 48px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.08);
--kb-card-title-font-size: var(--font-size-sm, 0.875rem);
--kb-card-title-font-weight: 500;
--kb-card-title-color: var(--color-text-primary, #111827);
--kb-card-desc-font-size: var(--font-size-xs, 0.75rem);
--kb-card-desc-color: var(--color-text-secondary, #6b7280);
--kb-card-meta-font-size: var(--font-size-xs, 0.75rem);
--kb-card-meta-color: var(--color-text-muted, #9ca3af);
--kb-card-blocked-border: var(--color-error-500, #ef4444);
--kb-card-overdue-color: var(--color-error-500, #ef4444);
--kb-card-dragging-opacity: 0.85;
--kb-card-drag-scale: 1.03;
--kb-card-drag-rotate: 1.5deg;
// Label
--kb-label-radius: 999px;
--kb-label-padding: 0.125rem 0.625rem;
--kb-label-font-size: 0.625rem;
// Swimlane
--kb-swimlane-bg: transparent;
--kb-swimlane-border: var(--color-border-primary, #e5e7eb);
--kb-swimlane-header-padding: 0.75rem 1rem;
--kb-swimlane-header-font-size: var(--font-size-sm, 0.875rem);
--kb-swimlane-header-font-weight: 600;
--kb-swimlane-header-color: var(--color-text-primary, #111827);
--kb-swimlane-gap: 0.75rem;
// WIP
--kb-wip-normal-color: var(--color-text-muted, #9ca3af);
--kb-wip-normal-bg: transparent;
--kb-wip-warning-color: #d97706;
--kb-wip-warning-bg: rgba(217, 119, 6, 0.1);
--kb-wip-exceeded-color: var(--color-error-500, #ef4444);
--kb-wip-exceeded-bg: rgba(239, 68, 68, 0.1);
// Drag
--kb-drop-indicator-color: var(--color-primary-500, #3b82f6);
--kb-drop-zone-bg: rgba(59, 130, 246, 0.05);
--kb-drop-zone-border: var(--color-primary-500, #3b82f6);
// Toolbar
--kb-toolbar-bg: var(--color-bg-secondary, #ffffff);
--kb-toolbar-border: var(--color-border-primary, #e5e7eb);
--kb-toolbar-padding: 0.875rem 1.25rem;
// Quick-add
--kb-quick-add-bg: transparent;
--kb-quick-add-hover-bg: var(--color-bg-hover, #f3f4f6);
--kb-quick-add-text: var(--color-text-muted, #9ca3af);
// Pipeline
--kb-pipeline-connector-color: var(--color-border-primary, #e5e7eb);
--kb-pipeline-connector-width: 2px;
// Transitions
--kb-transition: 150ms ease-in-out;
--kb-transition-slow: 300ms ease-in-out;
// Spring & smooth easing
--kb-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--kb-ease-smooth: cubic-bezier(0.25, 0.46, 0.45, 0.94);
// Glass
--kb-glass-bg: rgba(255, 255, 255, 0.72);
--kb-glass-blur: 12px;
--kb-glass-border: rgba(255, 255, 255, 0.2);
// Glow
--kb-glow-color: var(--color-primary-500, #3b82f6);
--kb-glow-spread: 8px;
--kb-glow-opacity: 0.25;
}
@media (prefers-color-scheme: dark) {
:root {
--kb-bg: var(--color-bg-primary, #0c0f17);
--kb-column-bg: var(--color-bg-secondary, #161b26);
--kb-column-border: var(--color-border-primary, #1e2536);
--kb-column-header-color: var(--color-text-primary, #f9fafb);
--kb-card-bg: var(--color-bg-secondary, #161b26);
--kb-card-border: var(--color-border-primary, #1e2536);
--kb-card-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2);
--kb-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
--kb-card-shadow-dragging: 0 16px 48px rgba(0, 0, 0, 0.5), 0 8px 16px rgba(0, 0, 0, 0.35);
--kb-card-title-color: var(--color-text-primary, #f9fafb);
--kb-card-desc-color: var(--color-text-secondary, #9ca3af);
--kb-card-meta-color: var(--color-text-muted, #6b7280);
--kb-swimlane-border: var(--color-border-primary, #1e2536);
--kb-swimlane-header-color: var(--color-text-primary, #f9fafb);
--kb-toolbar-bg: var(--color-bg-secondary, #161b26);
--kb-toolbar-border: var(--color-border-primary, #1e2536);
--kb-quick-add-hover-bg: var(--color-bg-hover, #1e2536);
--kb-quick-add-text: var(--color-text-muted, #6b7280);
--kb-pipeline-connector-color: var(--color-border-primary, #1e2536);
--kb-glass-bg: rgba(12, 15, 23, 0.75);
--kb-glass-border: rgba(255, 255, 255, 0.06);
}
}
[data-mode="dark"] {
--kb-bg: var(--color-bg-primary, #0c0f17);
--kb-column-bg: var(--color-bg-secondary, #161b26);
--kb-column-border: var(--color-border-primary, #1e2536);
--kb-column-header-color: var(--color-text-primary, #f9fafb);
--kb-card-bg: var(--color-bg-secondary, #161b26);
--kb-card-border: var(--color-border-primary, #1e2536);
--kb-card-shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2);
--kb-card-shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
--kb-card-shadow-dragging: 0 16px 48px rgba(0, 0, 0, 0.5), 0 8px 16px rgba(0, 0, 0, 0.35);
--kb-card-title-color: var(--color-text-primary, #f9fafb);
--kb-card-desc-color: var(--color-text-secondary, #9ca3af);
--kb-card-meta-color: var(--color-text-muted, #6b7280);
--kb-swimlane-border: var(--color-border-primary, #1e2536);
--kb-swimlane-header-color: var(--color-text-primary, #f9fafb);
--kb-toolbar-bg: var(--color-bg-secondary, #161b26);
--kb-toolbar-border: var(--color-border-primary, #1e2536);
--kb-quick-add-hover-bg: var(--color-bg-hover, #1e2536);
--kb-quick-add-text: var(--color-text-muted, #6b7280);
--kb-pipeline-connector-color: var(--color-border-primary, #1e2536);
--kb-glass-bg: rgba(12, 15, 23, 0.75);
--kb-glass-border: rgba(255, 255, 255, 0.06);
}

25
src/types/config.types.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { KbPriorityMeta, KbViewMode } from './kanban.types';
/** Global kanban board configuration */
export interface KbConfig {
/** Default view mode */
defaultView: KbViewMode;
/** Whether columns can be collapsed */
collapsibleColumns: boolean;
/** Show card count badge on column headers */
showCardCount: boolean;
/** Show WIP limit indicators on columns */
showWipLimits: boolean;
/** Threshold (0-1) at which WIP warning state triggers */
wipWarningThreshold: number;
/** Whether columns can be reordered via drag */
allowColumnReorder: boolean;
/** Whether quick-add is enabled on columns */
allowQuickAdd: boolean;
/** Max lines for card description before clamping */
cardDescriptionLines: number;
/** Priority metadata for display */
priorities: KbPriorityMeta[];
/** Locale for date formatting */
locale: string;
}

105
src/types/event.types.ts Normal file
View File

@@ -0,0 +1,105 @@
import type { KbCard, KbColumn, KbSwimlane, KbViewMode, KbCardSort, KbCardFilter } from './kanban.types';
/** Emitted when a card is moved between columns or reordered */
export interface KbCardMoveEvent {
card: KbCard;
fromColumnId: string;
toColumnId: string;
fromSwimlaneId?: string;
toSwimlaneId?: string;
fromIndex: number;
toIndex: number;
}
/** Emitted when a card is clicked */
export interface KbCardClickEvent {
card: KbCard;
columnId: string;
event: MouseEvent;
}
/** Emitted when a card is double-clicked */
export interface KbCardDblClickEvent {
card: KbCard;
columnId: string;
event: MouseEvent;
}
/** Emitted when a card is right-clicked */
export interface KbCardContextMenuEvent {
card: KbCard;
columnId: string;
event: MouseEvent;
}
/** Emitted when a new card is added via quick-add */
export interface KbCardAddEvent {
title: string;
columnId: string;
swimlaneId?: string;
}
/** Emitted when a card is deleted */
export interface KbCardDeleteEvent {
card: KbCard;
}
/** Emitted when a column header is clicked */
export interface KbColumnClickEvent {
column: KbColumn;
event: MouseEvent;
}
/** Emitted when a column is collapsed or expanded */
export interface KbColumnToggleEvent {
column: KbColumn;
collapsed: boolean;
}
/** Emitted when a column is reordered */
export interface KbColumnReorderEvent {
column: KbColumn;
fromIndex: number;
toIndex: number;
}
/** Emitted when a swimlane is collapsed or expanded */
export interface KbSwimlaneToggleEvent {
swimlane: KbSwimlane;
collapsed: boolean;
}
/** Emitted when the view mode changes */
export interface KbViewChangeEvent {
view: KbViewMode;
}
/** Emitted when the sort configuration changes */
export interface KbSortChangeEvent {
sort: KbCardSort;
}
/** Emitted when the filter configuration changes */
export interface KbFilterChangeEvent {
filter: KbCardFilter;
}
/** Emitted when a column's WIP limit is exceeded */
export interface KbWipExceededEvent {
column: KbColumn;
cardCount: number;
wipLimit: number;
}
/** Emitted when a card drag starts */
export interface KbDragStartEvent {
card: KbCard;
columnId: string;
swimlaneId?: string;
}
/** Emitted when a card drag ends */
export interface KbDragEndEvent {
card: KbCard;
dropped: boolean;
}

3
src/types/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export * from './kanban.types';
export * from './config.types';
export * from './event.types';

97
src/types/kanban.types.ts Normal file
View File

@@ -0,0 +1,97 @@
/** Priority levels for kanban cards */
export type KbPriority = 'critical' | 'high' | 'medium' | 'low' | 'none';
/** Column type classification */
export type KbColumnType = 'backlog' | 'active' | 'done' | 'archive' | 'custom';
/** Board display mode */
export type KbViewMode = 'board' | 'pipeline';
/** WIP limit status */
export type KbWipStatus = 'normal' | 'warning' | 'exceeded';
/** Available sort fields for cards */
export type KbCardSortField = 'order' | 'priority' | 'dueDate' | 'title' | 'createdAt';
/** Label attached to a card */
export interface KbLabel {
id: string;
text: string;
color: string;
}
/** User assigned to a card */
export interface KbAssignee {
id: string;
name: string;
avatar?: string;
email?: string;
}
/** Priority metadata for display */
export interface KbPriorityMeta {
level: KbPriority;
label: string;
icon: string;
color: string;
}
/** Kanban card data */
export interface KbCard {
id: string;
title: string;
description?: string;
columnId: string;
swimlaneId?: string;
order: number;
priority?: KbPriority;
labels?: KbLabel[];
assignees?: KbAssignee[];
dueDate?: Date | string;
storyPoints?: number;
subtasks?: { completed: number; total: number };
coverImage?: string;
blocked?: boolean;
blockedReason?: string;
createdAt?: Date | string;
updatedAt?: Date | string;
metadata?: Record<string, unknown>;
}
/** Kanban column definition */
export interface KbColumn {
id: string;
title: string;
color?: string;
wipLimit?: number;
collapsed?: boolean;
order: number;
allowAdd?: boolean;
columnType?: KbColumnType;
}
/** Swimlane definition for grouping cards */
export interface KbSwimlane {
id: string;
title: string;
order: number;
color?: string;
collapsible?: boolean;
collapsed?: boolean;
isDefault?: boolean;
}
/** Card sort configuration */
export interface KbCardSort {
field: KbCardSortField;
direction: 'asc' | 'desc';
}
/** Card filter configuration */
export interface KbCardFilter {
searchTerm?: string;
priorities?: KbPriority[];
labelIds?: string[];
assigneeIds?: string[];
blocked?: boolean;
}

111
src/utils/card.utils.ts Normal file
View File

@@ -0,0 +1,111 @@
import type { KbCard, KbCardSort, KbPriority } from '../types/kanban.types';
const PRIORITY_ORDER: Record<KbPriority, number> = {
critical: 0,
high: 1,
medium: 2,
low: 3,
none: 4,
};
/** Get cards belonging to a specific column, optionally filtered by swimlane */
export function getCardsByColumn(
cards: KbCard[],
columnId: string,
swimlaneId?: string,
): KbCard[] {
return cards.filter(c => {
if (c.columnId !== columnId) return false;
if (swimlaneId !== undefined && c.swimlaneId !== swimlaneId) return false;
return true;
});
}
/** Reorder cards after a move: returns new array with updated order values */
export function reorderCards(
cards: KbCard[],
movedCardId: string,
targetColumnId: string,
targetIndex: number,
targetSwimlaneId?: string,
): KbCard[] {
const moved = cards.find(c => c.id === movedCardId);
if (!moved) return cards;
// Remove from current position
const without = cards.filter(c => c.id !== movedCardId);
// Get target column cards sorted by order
const targetCards = without
.filter(c => c.columnId === targetColumnId && (targetSwimlaneId === undefined || c.swimlaneId === targetSwimlaneId))
.sort((a, b) => a.order - b.order);
// Clamp target index
const clampedIndex = Math.max(0, Math.min(targetIndex, targetCards.length));
// Insert and recalculate orders
const updatedMoved: KbCard = {
...moved,
columnId: targetColumnId,
swimlaneId: targetSwimlaneId ?? moved.swimlaneId,
order: clampedIndex,
};
// Build final array
const result: KbCard[] = [];
for (const card of without) {
if (card.columnId === targetColumnId && (targetSwimlaneId === undefined || card.swimlaneId === targetSwimlaneId)) {
continue; // Will be re-added with updated order
}
result.push(card);
}
// Re-insert target column cards with correct orders
targetCards.splice(clampedIndex, 0, updatedMoved);
for (let i = 0; i < targetCards.length; i++) {
result.push({ ...targetCards[i], order: i });
}
return result;
}
/** Sort cards by the given sort configuration */
export function sortCards(cards: KbCard[], sort: KbCardSort): KbCard[] {
const sorted = [...cards];
const dir = sort.direction === 'asc' ? 1 : -1;
sorted.sort((a, b) => {
switch (sort.field) {
case 'order':
return (a.order - b.order) * dir;
case 'priority': {
const pa = PRIORITY_ORDER[a.priority ?? 'none'];
const pb = PRIORITY_ORDER[b.priority ?? 'none'];
return (pa - pb) * dir;
}
case 'dueDate': {
const da = a.dueDate ? new Date(a.dueDate).getTime() : Infinity;
const db = b.dueDate ? new Date(b.dueDate).getTime() : Infinity;
return (da - db) * dir;
}
case 'title':
return a.title.localeCompare(b.title) * dir;
case 'createdAt': {
const ca = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const cb = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return (ca - cb) * dir;
}
default:
return 0;
}
});
return sorted;
}
/** Compute the next order value for a new card in a column */
export function computeCardOrder(cards: KbCard[], columnId: string): number {
const columnCards = cards.filter(c => c.columnId === columnId);
if (columnCards.length === 0) return 0;
return Math.max(...columnCards.map(c => c.order)) + 1;
}

27
src/utils/date.utils.ts Normal file
View File

@@ -0,0 +1,27 @@
/** Format a date for display on a card */
export function formatCardDate(date: Date | string | undefined, locale: string = 'en-US'): string {
if (!date) return '';
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return '';
const now = new Date();
const diffMs = d.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Tomorrow';
if (diffDays === -1) return 'Yesterday';
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric' });
}
/** Check if a date is overdue (before today) */
export function isOverdue(date: Date | string | undefined): boolean {
if (!date) return false;
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return false;
const now = new Date();
now.setHours(0, 0, 0, 0);
return d.getTime() < now.getTime();
}

47
src/utils/filter.utils.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { KbCard, KbCardFilter } from '../types/kanban.types';
/** Apply filters to a list of cards */
export function applyCardFilters(cards: KbCard[], filter: KbCardFilter): KbCard[] {
let result = cards;
if (filter.searchTerm) {
const term = filter.searchTerm.toLowerCase();
result = result.filter(c =>
c.title.toLowerCase().includes(term) ||
(c.description && c.description.toLowerCase().includes(term))
);
}
if (filter.priorities && filter.priorities.length > 0) {
result = result.filter(c => filter.priorities!.includes(c.priority ?? 'none'));
}
if (filter.labelIds && filter.labelIds.length > 0) {
result = result.filter(c =>
c.labels?.some(l => filter.labelIds!.includes(l.id))
);
}
if (filter.assigneeIds && filter.assigneeIds.length > 0) {
result = result.filter(c =>
c.assignees?.some(a => filter.assigneeIds!.includes(a.id))
);
}
if (filter.blocked !== undefined) {
result = result.filter(c => (c.blocked ?? false) === filter.blocked);
}
return result;
}
/** Count the number of active filters */
export function countActiveFilters(filter: KbCardFilter): number {
let count = 0;
if (filter.searchTerm) count++;
if (filter.priorities && filter.priorities.length > 0) count++;
if (filter.labelIds && filter.labelIds.length > 0) count++;
if (filter.assigneeIds && filter.assigneeIds.length > 0) count++;
if (filter.blocked !== undefined) count++;
return count;
}

4
src/utils/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from './card.utils';
export * from './filter.utils';
export * from './wip.utils';
export * from './date.utils';

19
src/utils/wip.utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { KbWipStatus } from '../types/kanban.types';
/** Get the WIP status for a column based on card count and limit */
export function getWipStatus(
cardCount: number,
wipLimit: number | undefined,
warningThreshold: number = 0.8,
): KbWipStatus {
if (!wipLimit || wipLimit <= 0) return 'normal';
if (cardCount > wipLimit) return 'exceeded';
if (cardCount >= wipLimit * warningThreshold) return 'warning';
return 'normal';
}
/** Check if a WIP limit is exceeded */
export function isWipExceeded(cardCount: number, wipLimit: number | undefined): boolean {
if (!wipLimit || wipLimit <= 0) return false;
return cardCount > wipLimit;
}

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}