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