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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
node_modules/
|
||||
.angular/
|
||||
*.tgz
|
||||
12
ng-package.json
Normal file
12
ng-package.json
Normal 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
3767
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
package.json
Normal file
39
package.json
Normal 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
9
src/components/index.ts
Normal 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';
|
||||
1
src/components/kb-board/index.ts
Normal file
1
src/components/kb-board/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-board.component';
|
||||
97
src/components/kb-board/kb-board.component.html
Normal file
97
src/components/kb-board/kb-board.component.html
Normal 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>
|
||||
115
src/components/kb-board/kb-board.component.scss
Normal file
115
src/components/kb-board/kb-board.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
220
src/components/kb-board/kb-board.component.ts
Normal file
220
src/components/kb-board/kb-board.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/kb-card-def/index.ts
Normal file
1
src/components/kb-card-def/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-card-def.directive';
|
||||
27
src/components/kb-card-def/kb-card-def.directive.ts
Normal file
27
src/components/kb-card-def/kb-card-def.directive.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
src/components/kb-card/index.ts
Normal file
1
src/components/kb-card/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-card.component';
|
||||
137
src/components/kb-card/kb-card.component.html
Normal file
137
src/components/kb-card/kb-card.component.html
Normal 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>
|
||||
}
|
||||
221
src/components/kb-card/kb-card.component.scss
Normal file
221
src/components/kb-card/kb-card.component.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
102
src/components/kb-card/kb-card.component.ts
Normal file
102
src/components/kb-card/kb-card.component.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
1
src/components/kb-column-header-def/index.ts
Normal file
1
src/components/kb-column-header-def/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-column-header-def.directive';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1
src/components/kb-column/index.ts
Normal file
1
src/components/kb-column/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-column.component';
|
||||
99
src/components/kb-column/kb-column.component.html
Normal file
99
src/components/kb-column/kb-column.component.html
Normal 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>
|
||||
185
src/components/kb-column/kb-column.component.scss
Normal file
185
src/components/kb-column/kb-column.component.scss
Normal 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;
|
||||
}
|
||||
147
src/components/kb-column/kb-column.component.ts
Normal file
147
src/components/kb-column/kb-column.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
src/components/kb-quick-add/index.ts
Normal file
1
src/components/kb-quick-add/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-quick-add.component';
|
||||
25
src/components/kb-quick-add/kb-quick-add.component.html
Normal file
25
src/components/kb-quick-add/kb-quick-add.component.html
Normal 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>
|
||||
}
|
||||
58
src/components/kb-quick-add/kb-quick-add.component.scss
Normal file
58
src/components/kb-quick-add/kb-quick-add.component.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
61
src/components/kb-quick-add/kb-quick-add.component.ts
Normal file
61
src/components/kb-quick-add/kb-quick-add.component.ts
Normal 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('');
|
||||
}
|
||||
}
|
||||
1
src/components/kb-swimlane/index.ts
Normal file
1
src/components/kb-swimlane/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-swimlane.component';
|
||||
27
src/components/kb-swimlane/kb-swimlane.component.html
Normal file
27
src/components/kb-swimlane/kb-swimlane.component.html
Normal 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>
|
||||
109
src/components/kb-swimlane/kb-swimlane.component.scss
Normal file
109
src/components/kb-swimlane/kb-swimlane.component.scss
Normal 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;
|
||||
}
|
||||
24
src/components/kb-swimlane/kb-swimlane.component.ts
Normal file
24
src/components/kb-swimlane/kb-swimlane.component.ts
Normal 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() });
|
||||
}
|
||||
}
|
||||
1
src/components/kb-toolbar/index.ts
Normal file
1
src/components/kb-toolbar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-toolbar.component';
|
||||
87
src/components/kb-toolbar/kb-toolbar.component.html
Normal file
87
src/components/kb-toolbar/kb-toolbar.component.html
Normal 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>
|
||||
175
src/components/kb-toolbar/kb-toolbar.component.scss
Normal file
175
src/components/kb-toolbar/kb-toolbar.component.scss
Normal 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;
|
||||
}
|
||||
60
src/components/kb-toolbar/kb-toolbar.component.ts
Normal file
60
src/components/kb-toolbar/kb-toolbar.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
1
src/components/kb-wip-indicator/index.ts
Normal file
1
src/components/kb-wip-indicator/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kb-wip-indicator.component';
|
||||
@@ -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
48
src/index.ts
Normal 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
1
src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './kanban-config.provider';
|
||||
35
src/providers/kanban-config.provider.ts
Normal file
35
src/providers/kanban-config.provider.ts
Normal 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
3
src/services/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './kanban-board.service';
|
||||
export * from './kanban-drag.service';
|
||||
export * from './kanban-filter.service';
|
||||
88
src/services/kanban-board.service.ts
Normal file
88
src/services/kanban-board.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
128
src/services/kanban-drag.service.ts
Normal file
128
src/services/kanban-drag.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
66
src/services/kanban-filter.service.ts
Normal file
66
src/services/kanban-filter.service.ts
Normal 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
2
src/styles/_index.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@forward 'tokens';
|
||||
@forward 'mixins';
|
||||
141
src/styles/_mixins.scss
Normal file
141
src/styles/_mixins.scss
Normal 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
167
src/styles/_tokens.scss
Normal 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
25
src/types/config.types.ts
Normal 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
105
src/types/event.types.ts
Normal 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
3
src/types/index.ts
Normal 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
97
src/types/kanban.types.ts
Normal 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
111
src/utils/card.utils.ts
Normal 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
27
src/utils/date.utils.ts
Normal 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
47
src/utils/filter.utils.ts
Normal 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
4
src/utils/index.ts
Normal 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
19
src/utils/wip.utils.ts
Normal 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
28
tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user