@object-ui/plugin-kanban 3.3.0 → 3.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +24 -0
- package/dist/{KanbanEnhanced-TdUe0kQH.js → KanbanEnhanced-Do9ZB1Mh.js} +35 -32
- package/dist/{KanbanImpl-BtlPa7GE.js → KanbanImpl-BdocXM5T.js} +1 -1
- package/dist/{chevron-down-B6UH8BbF.js → chevron-down-C0JUlGjk.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/index.umd.cjs +2 -2
- package/dist/{plus-BTqoaaEC.js → plus-CHsXVJSY.js} +1 -1
- package/package.json +34 -11
- package/.turbo/turbo-build.log +0 -32
- package/src/CardTemplates.tsx +0 -123
- package/src/InlineQuickAdd.tsx +0 -189
- package/src/KanbanEnhanced.tsx +0 -525
- package/src/KanbanImpl.tsx +0 -597
- package/src/ObjectKanban.EdgeCases.stories.tsx +0 -168
- package/src/ObjectKanban.msw.test.tsx +0 -95
- package/src/ObjectKanban.stories.tsx +0 -152
- package/src/ObjectKanban.tsx +0 -276
- package/src/__tests__/KanbanEnhanced.test.tsx +0 -260
- package/src/__tests__/KanbanGrouping.test.tsx +0 -164
- package/src/__tests__/KanbanSwimlanes.test.tsx +0 -194
- package/src/__tests__/ObjectKanbanTitle.test.tsx +0 -93
- package/src/__tests__/SwimlanePersistence.test.tsx +0 -159
- package/src/__tests__/accessibility.test.tsx +0 -296
- package/src/__tests__/dnd-undo-integration.test.tsx +0 -525
- package/src/__tests__/performance-benchmark.test.tsx +0 -306
- package/src/__tests__/phase13-features.test.tsx +0 -387
- package/src/__tests__/view-states.test.tsx +0 -403
- package/src/index.test.ts +0 -112
- package/src/index.tsx +0 -327
- package/src/registration.test.tsx +0 -26
- package/src/types.ts +0 -185
- package/src/useColumnWidths.ts +0 -125
- package/src/useCrossSwimlaneMove.ts +0 -116
- package/src/useQuickAddReorder.ts +0 -107
- package/tsconfig.json +0 -19
- package/vite.config.ts +0 -62
- package/vitest.config.ts +0 -12
- package/vitest.setup.ts +0 -1
package/src/KanbanImpl.tsx
DELETED
|
@@ -1,597 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as React from "react"
|
|
10
|
-
import {
|
|
11
|
-
DndContext,
|
|
12
|
-
DragEndEvent,
|
|
13
|
-
DragOverlay,
|
|
14
|
-
DragStartEvent,
|
|
15
|
-
PointerSensor,
|
|
16
|
-
TouchSensor,
|
|
17
|
-
useSensor,
|
|
18
|
-
useSensors,
|
|
19
|
-
closestCorners,
|
|
20
|
-
} from "@dnd-kit/core"
|
|
21
|
-
import {
|
|
22
|
-
SortableContext,
|
|
23
|
-
arrayMove,
|
|
24
|
-
useSortable,
|
|
25
|
-
verticalListSortingStrategy,
|
|
26
|
-
} from "@dnd-kit/sortable"
|
|
27
|
-
import { CSS } from "@dnd-kit/utilities"
|
|
28
|
-
import { Badge, Card, CardHeader, CardTitle, CardDescription, CardContent, ScrollArea, Button, Input } from "@object-ui/components"
|
|
29
|
-
import { useHasDndProvider, useDnd } from "@object-ui/react"
|
|
30
|
-
import { Plus } from "lucide-react"
|
|
31
|
-
|
|
32
|
-
// Utility function to merge class names (inline to avoid external dependency)
|
|
33
|
-
const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
|
|
34
|
-
|
|
35
|
-
const UNCATEGORIZED_LANE = 'Uncategorized'
|
|
36
|
-
|
|
37
|
-
export interface KanbanCard {
|
|
38
|
-
id: string
|
|
39
|
-
title: string
|
|
40
|
-
description?: string
|
|
41
|
-
badges?: Array<{ label: string; variant?: "default" | "secondary" | "destructive" | "outline" }>
|
|
42
|
-
coverImage?: string
|
|
43
|
-
[key: string]: any
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface KanbanColumn {
|
|
47
|
-
id: string
|
|
48
|
-
title: string
|
|
49
|
-
cards: KanbanCard[]
|
|
50
|
-
limit?: number
|
|
51
|
-
className?: string
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface ConditionalFormattingRule {
|
|
55
|
-
field: string
|
|
56
|
-
operator: 'equals' | 'not_equals' | 'contains' | 'in'
|
|
57
|
-
value: string | string[]
|
|
58
|
-
backgroundColor?: string
|
|
59
|
-
borderColor?: string
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface KanbanBoardProps {
|
|
63
|
-
columns: KanbanColumn[]
|
|
64
|
-
onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
|
|
65
|
-
onCardClick?: (card: KanbanCard) => void
|
|
66
|
-
className?: string
|
|
67
|
-
quickAdd?: boolean
|
|
68
|
-
onQuickAdd?: (columnId: string, title: string) => void
|
|
69
|
-
coverImageField?: string
|
|
70
|
-
conditionalFormatting?: ConditionalFormattingRule[]
|
|
71
|
-
/** Field name for swimlane rows (2D grouping) */
|
|
72
|
-
swimlaneField?: string
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Evaluate conditional formatting rules for a card.
|
|
77
|
-
* Returns CSS style overrides for backgroundColor and borderColor.
|
|
78
|
-
*/
|
|
79
|
-
function getCardStyles(card: KanbanCard, rules?: ConditionalFormattingRule[]): React.CSSProperties {
|
|
80
|
-
if (!rules || rules.length === 0) return {}
|
|
81
|
-
|
|
82
|
-
for (const rule of rules) {
|
|
83
|
-
const fieldValue = card[rule.field]
|
|
84
|
-
if (fieldValue === undefined || fieldValue === null) continue
|
|
85
|
-
|
|
86
|
-
let matches = false
|
|
87
|
-
const strValue = String(fieldValue)
|
|
88
|
-
|
|
89
|
-
switch (rule.operator) {
|
|
90
|
-
case 'equals':
|
|
91
|
-
matches = strValue === String(rule.value)
|
|
92
|
-
break
|
|
93
|
-
case 'not_equals':
|
|
94
|
-
matches = strValue !== String(rule.value)
|
|
95
|
-
break
|
|
96
|
-
case 'contains':
|
|
97
|
-
matches = strValue.toLowerCase().includes(String(rule.value).toLowerCase())
|
|
98
|
-
break
|
|
99
|
-
case 'in':
|
|
100
|
-
matches = Array.isArray(rule.value) && rule.value.includes(strValue)
|
|
101
|
-
break
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (matches) {
|
|
105
|
-
return {
|
|
106
|
-
...(rule.backgroundColor ? { backgroundColor: rule.backgroundColor } : {}),
|
|
107
|
-
...(rule.borderColor ? { borderColor: rule.borderColor } : {}),
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
return {}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function SortableCard({ card, onCardClick, conditionalFormatting }: { card: KanbanCard; onCardClick?: (card: KanbanCard) => void; conditionalFormatting?: ConditionalFormattingRule[] }) {
|
|
115
|
-
const {
|
|
116
|
-
attributes,
|
|
117
|
-
listeners,
|
|
118
|
-
setNodeRef,
|
|
119
|
-
transform,
|
|
120
|
-
transition,
|
|
121
|
-
isDragging,
|
|
122
|
-
} = useSortable({ id: card.id })
|
|
123
|
-
|
|
124
|
-
const style = {
|
|
125
|
-
transform: CSS.Transform.toString(transform),
|
|
126
|
-
transition,
|
|
127
|
-
opacity: isDragging ? 0.5 : undefined,
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const cardStyles = getCardStyles(card, conditionalFormatting)
|
|
131
|
-
|
|
132
|
-
return (
|
|
133
|
-
<div ref={setNodeRef} style={style} {...attributes} {...listeners} role="listitem" aria-label={card.title}
|
|
134
|
-
onClick={() => onCardClick?.(card)}
|
|
135
|
-
>
|
|
136
|
-
<Card className="mb-2 cursor-grab active:cursor-grabbing border-border border-l-4 border-l-primary/40 bg-card/60 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 group touch-manipulation" style={cardStyles}>
|
|
137
|
-
{card.coverImage && (
|
|
138
|
-
<div className="w-full h-32 overflow-hidden rounded-t-lg">
|
|
139
|
-
<img
|
|
140
|
-
src={card.coverImage}
|
|
141
|
-
alt=""
|
|
142
|
-
className="w-full h-full object-cover"
|
|
143
|
-
loading="lazy"
|
|
144
|
-
/>
|
|
145
|
-
</div>
|
|
146
|
-
)}
|
|
147
|
-
<CardHeader className="p-2 sm:p-4">
|
|
148
|
-
<CardTitle className="text-xs sm:text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
|
|
149
|
-
{card.description && (
|
|
150
|
-
<CardDescription className="text-xs text-muted-foreground font-mono line-clamp-2 sm:line-clamp-none">
|
|
151
|
-
{card.description}
|
|
152
|
-
</CardDescription>
|
|
153
|
-
)}
|
|
154
|
-
</CardHeader>
|
|
155
|
-
{card.badges && card.badges.length > 0 && (
|
|
156
|
-
<CardContent className="p-2 sm:p-4 pt-0">
|
|
157
|
-
<div className="flex flex-wrap gap-1">
|
|
158
|
-
{card.badges.map((badge, index) => (
|
|
159
|
-
<Badge key={index} variant={badge.variant || "default"} className="text-xs">
|
|
160
|
-
{badge.label}
|
|
161
|
-
</Badge>
|
|
162
|
-
))}
|
|
163
|
-
</div>
|
|
164
|
-
</CardContent>
|
|
165
|
-
)}
|
|
166
|
-
</Card>
|
|
167
|
-
</div>
|
|
168
|
-
)
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function QuickAddForm({ columnId, onAdd }: { columnId: string; onAdd: (columnId: string, title: string) => void }) {
|
|
172
|
-
const [isAdding, setIsAdding] = React.useState(false)
|
|
173
|
-
const [title, setTitle] = React.useState('')
|
|
174
|
-
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
175
|
-
|
|
176
|
-
const handleSubmit = () => {
|
|
177
|
-
const trimmed = title.trim()
|
|
178
|
-
if (trimmed) {
|
|
179
|
-
onAdd(columnId, trimmed)
|
|
180
|
-
setTitle('')
|
|
181
|
-
}
|
|
182
|
-
setIsAdding(false)
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
186
|
-
if (e.key === 'Enter') {
|
|
187
|
-
e.preventDefault()
|
|
188
|
-
handleSubmit()
|
|
189
|
-
} else if (e.key === 'Escape') {
|
|
190
|
-
setTitle('')
|
|
191
|
-
setIsAdding(false)
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (!isAdding) {
|
|
196
|
-
return (
|
|
197
|
-
<Button
|
|
198
|
-
variant="ghost"
|
|
199
|
-
size="sm"
|
|
200
|
-
className="w-full mt-2 text-muted-foreground hover:text-foreground"
|
|
201
|
-
onClick={() => {
|
|
202
|
-
setIsAdding(true)
|
|
203
|
-
setTimeout(() => inputRef.current?.focus(), 0)
|
|
204
|
-
}}
|
|
205
|
-
>
|
|
206
|
-
<Plus className="h-4 w-4 mr-1" />
|
|
207
|
-
Add Card
|
|
208
|
-
</Button>
|
|
209
|
-
)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return (
|
|
213
|
-
<div className="mt-2 space-y-2">
|
|
214
|
-
<Input
|
|
215
|
-
ref={inputRef}
|
|
216
|
-
value={title}
|
|
217
|
-
onChange={(e) => setTitle(e.target.value)}
|
|
218
|
-
onKeyDown={handleKeyDown}
|
|
219
|
-
onBlur={handleSubmit}
|
|
220
|
-
placeholder="Enter card title..."
|
|
221
|
-
className="text-sm"
|
|
222
|
-
autoFocus
|
|
223
|
-
/>
|
|
224
|
-
</div>
|
|
225
|
-
)
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function KanbanColumnView({
|
|
229
|
-
column,
|
|
230
|
-
cards,
|
|
231
|
-
onCardClick,
|
|
232
|
-
quickAdd,
|
|
233
|
-
onQuickAdd,
|
|
234
|
-
conditionalFormatting,
|
|
235
|
-
}: {
|
|
236
|
-
column: KanbanColumn
|
|
237
|
-
cards: KanbanCard[]
|
|
238
|
-
onCardClick?: (card: KanbanCard) => void
|
|
239
|
-
quickAdd?: boolean
|
|
240
|
-
onQuickAdd?: (columnId: string, title: string) => void
|
|
241
|
-
conditionalFormatting?: ConditionalFormattingRule[]
|
|
242
|
-
}) {
|
|
243
|
-
const safeCards = cards || [];
|
|
244
|
-
const { setNodeRef } = useSortable({
|
|
245
|
-
id: column.id,
|
|
246
|
-
data: {
|
|
247
|
-
type: "column",
|
|
248
|
-
},
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
const isLimitExceeded = column.limit && safeCards.length >= column.limit
|
|
252
|
-
|
|
253
|
-
return (
|
|
254
|
-
<div
|
|
255
|
-
ref={setNodeRef}
|
|
256
|
-
role="group"
|
|
257
|
-
aria-label={column.title}
|
|
258
|
-
className={cn(
|
|
259
|
-
"flex flex-col w-[85vw] sm:w-80 flex-shrink-0 rounded-lg border border-border bg-card/20 backdrop-blur-sm shadow-xl snap-start",
|
|
260
|
-
column.className
|
|
261
|
-
)}
|
|
262
|
-
>
|
|
263
|
-
<div className="p-3 sm:p-4 border-b border-border/50 bg-muted/30 rounded-t-lg">
|
|
264
|
-
<div className="flex items-center justify-between">
|
|
265
|
-
<h3 id={`kanban-col-${column.id}`} className="font-mono text-xs sm:text-sm font-semibold tracking-wider text-primary/90 uppercase truncate">{column.title}</h3>
|
|
266
|
-
<div className="flex items-center gap-2">
|
|
267
|
-
<Badge variant="secondary" className="text-xs font-mono tabular-nums">
|
|
268
|
-
{safeCards.length}
|
|
269
|
-
{column.limit && ` / ${column.limit}`}
|
|
270
|
-
</Badge>
|
|
271
|
-
{isLimitExceeded && (
|
|
272
|
-
<Badge variant="destructive" className="text-xs">
|
|
273
|
-
Full
|
|
274
|
-
</Badge>
|
|
275
|
-
)}
|
|
276
|
-
</div>
|
|
277
|
-
</div>
|
|
278
|
-
</div>
|
|
279
|
-
<ScrollArea className="flex-1 p-4">
|
|
280
|
-
<SortableContext
|
|
281
|
-
items={safeCards.map((c) => c.id)}
|
|
282
|
-
strategy={verticalListSortingStrategy}
|
|
283
|
-
>
|
|
284
|
-
<div className="space-y-2" role="list" aria-label={`${column.title} cards`}>
|
|
285
|
-
{safeCards.length === 0 && (
|
|
286
|
-
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground/50">
|
|
287
|
-
<span className="text-xs font-mono">No cards</span>
|
|
288
|
-
</div>
|
|
289
|
-
)}
|
|
290
|
-
{safeCards.map((card) => (
|
|
291
|
-
<SortableCard key={card.id} card={card} onCardClick={onCardClick} conditionalFormatting={conditionalFormatting} />
|
|
292
|
-
))}
|
|
293
|
-
</div>
|
|
294
|
-
</SortableContext>
|
|
295
|
-
{quickAdd && onQuickAdd && (
|
|
296
|
-
<QuickAddForm columnId={column.id} onAdd={onQuickAdd} />
|
|
297
|
-
)}
|
|
298
|
-
</ScrollArea>
|
|
299
|
-
</div>
|
|
300
|
-
)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Bridge wrapper that reads the ObjectUI DnD context and injects it into KanbanBoardInner. */
|
|
304
|
-
function DndBridge({ children }: { children: (dnd: ReturnType<typeof useDnd>) => React.ReactNode }) {
|
|
305
|
-
const dnd = useDnd()
|
|
306
|
-
return <>{children(dnd)}</>
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
export default function KanbanBoard({ columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, swimlaneField }: KanbanBoardProps) {
|
|
310
|
-
const hasDnd = useHasDndProvider()
|
|
311
|
-
|
|
312
|
-
if (hasDnd) {
|
|
313
|
-
return (
|
|
314
|
-
<DndBridge>
|
|
315
|
-
{(dnd) => <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={dnd} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} swimlaneField={swimlaneField} />}
|
|
316
|
-
</DndBridge>
|
|
317
|
-
)
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return <KanbanBoardInner columns={columns} onCardMove={onCardMove} onCardClick={onCardClick} className={className} dnd={null} quickAdd={quickAdd} onQuickAdd={onQuickAdd} coverImageField={coverImageField} conditionalFormatting={conditionalFormatting} swimlaneField={swimlaneField} />
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField: _coverImageField, conditionalFormatting, swimlaneField }: KanbanBoardProps & { dnd: ReturnType<typeof useDnd> | null }) {
|
|
324
|
-
const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
|
|
325
|
-
|
|
326
|
-
// Persist collapsed swimlane state per swimlaneField
|
|
327
|
-
const storageKey = swimlaneField ? `objectui:kanban-collapsed:${swimlaneField}` : null
|
|
328
|
-
const [collapsedLanes, setCollapsedLanes] = React.useState<Set<string>>(() => {
|
|
329
|
-
if (!storageKey) return new Set()
|
|
330
|
-
try {
|
|
331
|
-
const stored = localStorage.getItem(storageKey)
|
|
332
|
-
if (stored) {
|
|
333
|
-
const parsed = JSON.parse(stored)
|
|
334
|
-
if (Array.isArray(parsed)) return new Set(parsed.filter((v): v is string => typeof v === 'string'))
|
|
335
|
-
}
|
|
336
|
-
} catch { /* ignore corrupt data */ }
|
|
337
|
-
return new Set()
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
// Ensure we always have valid columns with cards array
|
|
341
|
-
const safeColumns = React.useMemo(() => {
|
|
342
|
-
return (columns || []).map(col => ({
|
|
343
|
-
...col,
|
|
344
|
-
cards: col.cards || []
|
|
345
|
-
}));
|
|
346
|
-
}, [columns]);
|
|
347
|
-
|
|
348
|
-
const [boardColumns, setBoardColumns] = React.useState<KanbanColumn[]>(safeColumns)
|
|
349
|
-
|
|
350
|
-
React.useEffect(() => {
|
|
351
|
-
setBoardColumns(safeColumns)
|
|
352
|
-
}, [safeColumns])
|
|
353
|
-
|
|
354
|
-
// Compute swimlane rows when swimlaneField is provided
|
|
355
|
-
const swimlanes = React.useMemo(() => {
|
|
356
|
-
if (!swimlaneField) return null
|
|
357
|
-
const allCards = boardColumns.flatMap(col => col.cards)
|
|
358
|
-
const laneValues = new Set<string>()
|
|
359
|
-
allCards.forEach(card => {
|
|
360
|
-
const val = card[swimlaneField]
|
|
361
|
-
laneValues.add(val != null ? String(val) : UNCATEGORIZED_LANE)
|
|
362
|
-
})
|
|
363
|
-
return Array.from(laneValues).sort()
|
|
364
|
-
}, [boardColumns, swimlaneField])
|
|
365
|
-
|
|
366
|
-
const toggleLane = React.useCallback((lane: string) => {
|
|
367
|
-
setCollapsedLanes(prev => {
|
|
368
|
-
const next = new Set(prev)
|
|
369
|
-
if (next.has(lane)) next.delete(lane)
|
|
370
|
-
else next.add(lane)
|
|
371
|
-
if (storageKey) {
|
|
372
|
-
try { localStorage.setItem(storageKey, JSON.stringify([...next])) } catch { /* quota exceeded */ }
|
|
373
|
-
}
|
|
374
|
-
return next
|
|
375
|
-
})
|
|
376
|
-
}, [storageKey])
|
|
377
|
-
|
|
378
|
-
const sensors = useSensors(
|
|
379
|
-
useSensor(PointerSensor, {
|
|
380
|
-
activationConstraint: {
|
|
381
|
-
distance: 5,
|
|
382
|
-
},
|
|
383
|
-
}),
|
|
384
|
-
useSensor(TouchSensor, {
|
|
385
|
-
activationConstraint: {
|
|
386
|
-
delay: 200,
|
|
387
|
-
tolerance: 5,
|
|
388
|
-
},
|
|
389
|
-
})
|
|
390
|
-
)
|
|
391
|
-
|
|
392
|
-
const handleDragStart = (event: DragStartEvent) => {
|
|
393
|
-
const { active } = event
|
|
394
|
-
const card = findCard(active.id as string)
|
|
395
|
-
setActiveCard(card)
|
|
396
|
-
|
|
397
|
-
// Bridge to ObjectUI spec DnD system
|
|
398
|
-
if (dnd && card) {
|
|
399
|
-
const column = findColumnByCardId(card.id)
|
|
400
|
-
if (column) {
|
|
401
|
-
dnd.startDrag({ id: card.id, type: 'kanban-card', data: card, sourceId: column.id })
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
const handleDragEnd = (event: DragEndEvent) => {
|
|
407
|
-
const { active, over } = event
|
|
408
|
-
setActiveCard(null)
|
|
409
|
-
|
|
410
|
-
if (!over) {
|
|
411
|
-
if (dnd) dnd.endDrag()
|
|
412
|
-
return
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
const activeId = active.id as string
|
|
416
|
-
const overId = over.id as string
|
|
417
|
-
|
|
418
|
-
if (activeId === overId) {
|
|
419
|
-
if (dnd) dnd.endDrag()
|
|
420
|
-
return
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const activeColumn = findColumnByCardId(activeId)
|
|
424
|
-
const overColumn = findColumnByCardId(overId) || findColumnById(overId)
|
|
425
|
-
|
|
426
|
-
if (!activeColumn || !overColumn) {
|
|
427
|
-
if (dnd) dnd.endDrag()
|
|
428
|
-
return
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (activeColumn.id === overColumn.id) {
|
|
432
|
-
// Same column reordering
|
|
433
|
-
const cards = [...activeColumn.cards]
|
|
434
|
-
const oldIndex = cards.findIndex((c) => c.id === activeId)
|
|
435
|
-
const newIndex = cards.findIndex((c) => c.id === overId)
|
|
436
|
-
|
|
437
|
-
const newCards = arrayMove(cards, oldIndex, newIndex)
|
|
438
|
-
setBoardColumns((prev) =>
|
|
439
|
-
prev.map((col) =>
|
|
440
|
-
col.id === activeColumn.id ? { ...col, cards: newCards } : col
|
|
441
|
-
)
|
|
442
|
-
)
|
|
443
|
-
} else {
|
|
444
|
-
// Moving between columns
|
|
445
|
-
const activeCards = [...activeColumn.cards]
|
|
446
|
-
const overCards = [...overColumn.cards]
|
|
447
|
-
const activeIndex = activeCards.findIndex((c) => c.id === activeId)
|
|
448
|
-
|
|
449
|
-
// Calculate target index: if dropping on column itself, append to end; otherwise insert at card position
|
|
450
|
-
const isDroppingOnColumn = overId === overColumn.id
|
|
451
|
-
const overIndex = isDroppingOnColumn
|
|
452
|
-
? overCards.length
|
|
453
|
-
: overCards.findIndex((c) => c.id === overId)
|
|
454
|
-
|
|
455
|
-
const [movedCard] = activeCards.splice(activeIndex, 1)
|
|
456
|
-
overCards.splice(overIndex, 0, movedCard)
|
|
457
|
-
|
|
458
|
-
setBoardColumns((prev) =>
|
|
459
|
-
prev.map((col) => {
|
|
460
|
-
if (col.id === activeColumn.id) {
|
|
461
|
-
return { ...col, cards: activeCards }
|
|
462
|
-
}
|
|
463
|
-
if (col.id === overColumn.id) {
|
|
464
|
-
return { ...col, cards: overCards }
|
|
465
|
-
}
|
|
466
|
-
return col
|
|
467
|
-
})
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
if (onCardMove) {
|
|
471
|
-
onCardMove(activeId, activeColumn.id, overColumn.id, overIndex)
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Bridge to ObjectUI spec DnD system
|
|
476
|
-
if (dnd) dnd.endDrag(overColumn.id)
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const findCard = React.useCallback(
|
|
480
|
-
(cardId: string): KanbanCard | null => {
|
|
481
|
-
for (const column of boardColumns) {
|
|
482
|
-
const card = column.cards.find((c) => c.id === cardId)
|
|
483
|
-
if (card) return card
|
|
484
|
-
}
|
|
485
|
-
return null
|
|
486
|
-
},
|
|
487
|
-
[boardColumns]
|
|
488
|
-
)
|
|
489
|
-
|
|
490
|
-
const findColumnByCardId = React.useCallback(
|
|
491
|
-
(cardId: string): KanbanColumn | null => {
|
|
492
|
-
return boardColumns.find((col) => col.cards.some((c) => c.id === cardId)) || null
|
|
493
|
-
},
|
|
494
|
-
[boardColumns]
|
|
495
|
-
)
|
|
496
|
-
|
|
497
|
-
const findColumnById = React.useCallback(
|
|
498
|
-
(columnId: string): KanbanColumn | null => {
|
|
499
|
-
return boardColumns.find((col) => col.id === columnId) || null
|
|
500
|
-
},
|
|
501
|
-
[boardColumns]
|
|
502
|
-
)
|
|
503
|
-
|
|
504
|
-
return (
|
|
505
|
-
<DndContext
|
|
506
|
-
sensors={sensors}
|
|
507
|
-
collisionDetection={closestCorners}
|
|
508
|
-
onDragStart={handleDragStart}
|
|
509
|
-
onDragEnd={handleDragEnd}
|
|
510
|
-
>
|
|
511
|
-
<div className="flex sm:hidden items-center justify-between px-3 pb-2 text-xs text-muted-foreground">
|
|
512
|
-
<span>{boardColumns.length} columns</span>
|
|
513
|
-
<span>← Swipe to navigate →</span>
|
|
514
|
-
</div>
|
|
515
|
-
|
|
516
|
-
{swimlanes ? (
|
|
517
|
-
/* Swimlane (2D) layout */
|
|
518
|
-
<div className={cn("flex flex-col gap-1 p-2 sm:p-4 min-w-0 overflow-hidden", className)} role="region" aria-label="Kanban board with swimlanes">
|
|
519
|
-
{/* Column headers */}
|
|
520
|
-
<div className="flex gap-3 sm:gap-4 pl-36 sm:pl-44 overflow-x-auto">
|
|
521
|
-
{boardColumns.map(col => (
|
|
522
|
-
<div key={col.id} className="w-[85vw] sm:w-80 flex-shrink-0 text-center">
|
|
523
|
-
<span className="font-mono text-xs sm:text-sm font-semibold tracking-wider text-primary/90 uppercase">{col.title}</span>
|
|
524
|
-
<span className="ml-2 font-mono text-xs text-muted-foreground">({col.cards.length})</span>
|
|
525
|
-
</div>
|
|
526
|
-
))}
|
|
527
|
-
</div>
|
|
528
|
-
|
|
529
|
-
{/* Swimlane rows */}
|
|
530
|
-
{swimlanes.map(lane => {
|
|
531
|
-
const isCollapsed = collapsedLanes.has(lane)
|
|
532
|
-
const laneCardCount = boardColumns.reduce((sum, col) =>
|
|
533
|
-
sum + col.cards.filter(c => (c[swimlaneField!] != null ? String(c[swimlaneField!]) : UNCATEGORIZED_LANE) === lane).length, 0)
|
|
534
|
-
|
|
535
|
-
return (
|
|
536
|
-
<div key={lane} className="border rounded-lg bg-muted/10">
|
|
537
|
-
{/* Lane header */}
|
|
538
|
-
<button
|
|
539
|
-
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-muted/30 transition-colors"
|
|
540
|
-
onClick={() => toggleLane(lane)}
|
|
541
|
-
aria-expanded={!isCollapsed}
|
|
542
|
-
>
|
|
543
|
-
<span className={cn("transition-transform text-xs", isCollapsed ? "" : "rotate-90")}>▶</span>
|
|
544
|
-
<span className="font-mono text-xs font-semibold text-muted-foreground uppercase tracking-wider">{lane}</span>
|
|
545
|
-
<span className="font-mono text-xs text-muted-foreground">({laneCardCount})</span>
|
|
546
|
-
</button>
|
|
547
|
-
|
|
548
|
-
{/* Lane content */}
|
|
549
|
-
{!isCollapsed && (
|
|
550
|
-
<div className="flex gap-3 sm:gap-4 overflow-x-auto px-2 pb-3 pl-36 sm:pl-44">
|
|
551
|
-
{boardColumns.map(col => {
|
|
552
|
-
const laneCards = col.cards.filter(c =>
|
|
553
|
-
(c[swimlaneField!] != null ? String(c[swimlaneField!]) : UNCATEGORIZED_LANE) === lane
|
|
554
|
-
)
|
|
555
|
-
return (
|
|
556
|
-
<div key={col.id} className="w-[85vw] sm:w-80 flex-shrink-0 min-h-[60px] rounded-md bg-card/20 p-2">
|
|
557
|
-
<SortableContext items={laneCards.map(c => c.id)} strategy={verticalListSortingStrategy}>
|
|
558
|
-
<div className="space-y-2" role="list" aria-label={`${col.title} - ${lane} cards`}>
|
|
559
|
-
{laneCards.map(card => (
|
|
560
|
-
<SortableCard key={card.id} card={card} onCardClick={onCardClick} conditionalFormatting={conditionalFormatting} />
|
|
561
|
-
))}
|
|
562
|
-
</div>
|
|
563
|
-
</SortableContext>
|
|
564
|
-
</div>
|
|
565
|
-
)
|
|
566
|
-
})}
|
|
567
|
-
</div>
|
|
568
|
-
)}
|
|
569
|
-
</div>
|
|
570
|
-
)
|
|
571
|
-
})}
|
|
572
|
-
</div>
|
|
573
|
-
) : (
|
|
574
|
-
/* Standard flat layout */
|
|
575
|
-
<div className={cn("flex gap-3 sm:gap-4 overflow-x-auto snap-x snap-mandatory p-2 sm:p-4 bg-muted/10 rounded-lg [-webkit-overflow-scrolling:touch] min-w-0", className)} role="region" aria-label="Kanban board">
|
|
576
|
-
{boardColumns.map((column) => (
|
|
577
|
-
<KanbanColumnView
|
|
578
|
-
key={column.id}
|
|
579
|
-
column={column}
|
|
580
|
-
cards={column.cards}
|
|
581
|
-
onCardClick={onCardClick}
|
|
582
|
-
quickAdd={quickAdd}
|
|
583
|
-
onQuickAdd={onQuickAdd}
|
|
584
|
-
conditionalFormatting={conditionalFormatting}
|
|
585
|
-
/>
|
|
586
|
-
))}
|
|
587
|
-
</div>
|
|
588
|
-
)}
|
|
589
|
-
|
|
590
|
-
<DragOverlay>
|
|
591
|
-
<div aria-live="assertive" aria-label={activeCard ? `Dragging ${activeCard.title}` : undefined}>
|
|
592
|
-
{activeCard ? <SortableCard card={activeCard} conditionalFormatting={conditionalFormatting} /> : null}
|
|
593
|
-
</div>
|
|
594
|
-
</DragOverlay>
|
|
595
|
-
</DndContext>
|
|
596
|
-
)
|
|
597
|
-
}
|