@object-ui/plugin-kanban 3.1.5 → 3.3.1

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +24 -0
  3. package/dist/{KanbanEnhanced-CvxO2soF.js → KanbanEnhanced-Do9ZB1Mh.js} +36 -33
  4. package/dist/{KanbanImpl-ii52_k8g.js → KanbanImpl-BdocXM5T.js} +2 -2
  5. package/dist/{chevron-down-DpXJN6OX.js → chevron-down-C0JUlGjk.js} +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +35 -26
  8. package/dist/index.umd.cjs +4 -4
  9. package/dist/packages/plugin-kanban/src/CardTemplates.d.ts.map +1 -0
  10. package/dist/packages/plugin-kanban/src/InlineQuickAdd.d.ts.map +1 -0
  11. package/dist/packages/plugin-kanban/src/KanbanEnhanced.d.ts.map +1 -0
  12. package/dist/packages/plugin-kanban/src/KanbanImpl.d.ts.map +1 -0
  13. package/dist/packages/plugin-kanban/src/ObjectKanban.EdgeCases.stories.d.ts.map +1 -0
  14. package/dist/packages/plugin-kanban/src/ObjectKanban.d.ts.map +1 -0
  15. package/dist/packages/plugin-kanban/src/ObjectKanban.stories.d.ts.map +1 -0
  16. package/dist/packages/plugin-kanban/src/index.d.ts.map +1 -0
  17. package/dist/packages/plugin-kanban/src/types.d.ts.map +1 -0
  18. package/dist/packages/plugin-kanban/src/useColumnWidths.d.ts.map +1 -0
  19. package/dist/packages/plugin-kanban/src/useCrossSwimlaneMove.d.ts.map +1 -0
  20. package/dist/packages/plugin-kanban/src/useQuickAddReorder.d.ts.map +1 -0
  21. package/dist/{plus-CAtTu4zt.js → plus-CHsXVJSY.js} +39 -36
  22. package/dist/{sortable.esm-DzUCoMzQ.js → sortable.esm-LJG1TjKd.js} +4 -4
  23. package/package.json +35 -12
  24. package/.turbo/turbo-build.log +0 -32
  25. package/dist/src/CardTemplates.d.ts.map +0 -1
  26. package/dist/src/InlineQuickAdd.d.ts.map +0 -1
  27. package/dist/src/KanbanEnhanced.d.ts.map +0 -1
  28. package/dist/src/KanbanImpl.d.ts.map +0 -1
  29. package/dist/src/ObjectKanban.EdgeCases.stories.d.ts.map +0 -1
  30. package/dist/src/ObjectKanban.d.ts.map +0 -1
  31. package/dist/src/ObjectKanban.stories.d.ts.map +0 -1
  32. package/dist/src/index.d.ts.map +0 -1
  33. package/dist/src/types.d.ts.map +0 -1
  34. package/dist/src/useColumnWidths.d.ts.map +0 -1
  35. package/dist/src/useCrossSwimlaneMove.d.ts.map +0 -1
  36. package/dist/src/useQuickAddReorder.d.ts.map +0 -1
  37. package/src/CardTemplates.tsx +0 -123
  38. package/src/InlineQuickAdd.tsx +0 -189
  39. package/src/KanbanEnhanced.tsx +0 -525
  40. package/src/KanbanImpl.tsx +0 -597
  41. package/src/ObjectKanban.EdgeCases.stories.tsx +0 -168
  42. package/src/ObjectKanban.msw.test.tsx +0 -91
  43. package/src/ObjectKanban.stories.tsx +0 -152
  44. package/src/ObjectKanban.tsx +0 -262
  45. package/src/__tests__/KanbanEnhanced.test.tsx +0 -260
  46. package/src/__tests__/KanbanGrouping.test.tsx +0 -164
  47. package/src/__tests__/KanbanSwimlanes.test.tsx +0 -194
  48. package/src/__tests__/ObjectKanbanTitle.test.tsx +0 -93
  49. package/src/__tests__/SwimlanePersistence.test.tsx +0 -159
  50. package/src/__tests__/accessibility.test.tsx +0 -296
  51. package/src/__tests__/dnd-undo-integration.test.tsx +0 -525
  52. package/src/__tests__/performance-benchmark.test.tsx +0 -306
  53. package/src/__tests__/phase13-features.test.tsx +0 -387
  54. package/src/__tests__/view-states.test.tsx +0 -403
  55. package/src/index.test.ts +0 -112
  56. package/src/index.tsx +0 -327
  57. package/src/registration.test.tsx +0 -26
  58. package/src/types.ts +0 -185
  59. package/src/useColumnWidths.ts +0 -125
  60. package/src/useCrossSwimlaneMove.ts +0 -116
  61. package/src/useQuickAddReorder.ts +0 -107
  62. package/tsconfig.json +0 -19
  63. package/vite.config.ts +0 -61
  64. package/vitest.config.ts +0 -12
  65. package/vitest.setup.ts +0 -1
  66. /package/dist/{src → packages/plugin-kanban/src}/CardTemplates.d.ts +0 -0
  67. /package/dist/{src → packages/plugin-kanban/src}/InlineQuickAdd.d.ts +0 -0
  68. /package/dist/{src → packages/plugin-kanban/src}/KanbanEnhanced.d.ts +0 -0
  69. /package/dist/{src → packages/plugin-kanban/src}/KanbanImpl.d.ts +0 -0
  70. /package/dist/{src → packages/plugin-kanban/src}/ObjectKanban.EdgeCases.stories.d.ts +0 -0
  71. /package/dist/{src → packages/plugin-kanban/src}/ObjectKanban.d.ts +0 -0
  72. /package/dist/{src → packages/plugin-kanban/src}/ObjectKanban.stories.d.ts +0 -0
  73. /package/dist/{src → packages/plugin-kanban/src}/index.d.ts +0 -0
  74. /package/dist/{src → packages/plugin-kanban/src}/types.d.ts +0 -0
  75. /package/dist/{src → packages/plugin-kanban/src}/useColumnWidths.d.ts +0 -0
  76. /package/dist/{src → packages/plugin-kanban/src}/useCrossSwimlaneMove.d.ts +0 -0
  77. /package/dist/{src → packages/plugin-kanban/src}/useQuickAddReorder.d.ts +0 -0
@@ -1,189 +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 { Button, Input } from "@object-ui/components"
11
- import { Check, X } from "lucide-react"
12
- import type { InlineFieldDefinition } from "./types"
13
-
14
- const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
15
-
16
- export interface InlineQuickAddProps {
17
- /** Column where the card will be added */
18
- columnId: string
19
- /** Field definitions for inline editing */
20
- fields: InlineFieldDefinition[]
21
- /** Called with field values when the form is submitted */
22
- onSubmit: (columnId: string, values: Record<string, any>) => void
23
- /** Called when the form is cancelled */
24
- onCancel: () => void
25
- /** Pre-filled values (e.g. from a card template) */
26
- defaultValues?: Record<string, any>
27
- }
28
-
29
- /**
30
- * InlineQuickAdd renders form fields directly inside the kanban column
31
- * without opening a dialog or modal. Submit with Enter or the Save button;
32
- * cancel with Escape or the Cancel button.
33
- */
34
- export const InlineQuickAdd: React.FC<InlineQuickAddProps> = ({
35
- columnId,
36
- fields,
37
- onSubmit,
38
- onCancel,
39
- defaultValues,
40
- }) => {
41
- const [values, setValues] = React.useState<Record<string, any>>(() => {
42
- const initial: Record<string, any> = {}
43
- for (const field of fields) {
44
- initial[field.name] = defaultValues?.[field.name] ?? field.defaultValue ?? ""
45
- }
46
- return initial
47
- })
48
-
49
- const firstInputRef = React.useRef<HTMLInputElement | HTMLSelectElement>(null)
50
-
51
- React.useEffect(() => {
52
- // Auto-focus first field on mount
53
- const timer = setTimeout(() => firstInputRef.current?.focus(), 0)
54
- return () => clearTimeout(timer)
55
- }, [])
56
-
57
- const handleChange = React.useCallback((name: string, value: any) => {
58
- setValues(prev => ({ ...prev, [name]: value }))
59
- }, [])
60
-
61
- const handleSubmit = React.useCallback(() => {
62
- // Require at least one non-empty value
63
- const hasValue = Object.values(values).some(v =>
64
- typeof v === "string" ? v.trim().length > 0 : v != null
65
- )
66
- if (hasValue) {
67
- onSubmit(columnId, values)
68
- }
69
- }, [columnId, values, onSubmit])
70
-
71
- const handleKeyDown = React.useCallback(
72
- (e: React.KeyboardEvent) => {
73
- if (e.key === "Enter") {
74
- e.preventDefault()
75
- handleSubmit()
76
- } else if (e.key === "Escape") {
77
- e.preventDefault()
78
- onCancel()
79
- }
80
- },
81
- [handleSubmit, onCancel],
82
- )
83
-
84
- return (
85
- <div
86
- className="mt-2 rounded-lg border border-primary/30 bg-card p-3 space-y-2 shadow-sm"
87
- onKeyDown={handleKeyDown}
88
- role="form"
89
- aria-label="Quick add card"
90
- >
91
- {fields.map((field, idx) => (
92
- <div key={field.name} className="space-y-1">
93
- <label
94
- htmlFor={`qa-${columnId}-${field.name}`}
95
- className="text-xs font-mono text-muted-foreground"
96
- >
97
- {field.label ?? field.name}
98
- </label>
99
- {renderField(field, values[field.name], handleChange, idx === 0 ? firstInputRef : undefined, `qa-${columnId}-${field.name}`)}
100
- </div>
101
- ))}
102
- <div className="flex items-center gap-2 pt-1">
103
- <Button
104
- type="button"
105
- size="sm"
106
- className="h-7 text-xs gap-1"
107
- onClick={handleSubmit}
108
- >
109
- <Check className="h-3 w-3" />
110
- Save
111
- </Button>
112
- <Button
113
- type="button"
114
- variant="ghost"
115
- size="sm"
116
- className="h-7 text-xs gap-1"
117
- onClick={onCancel}
118
- >
119
- <X className="h-3 w-3" />
120
- Cancel
121
- </Button>
122
- </div>
123
- </div>
124
- )
125
- }
126
-
127
- /** Renders a single field based on its type. */
128
- function renderField(
129
- field: InlineFieldDefinition,
130
- value: any,
131
- onChange: (name: string, value: any) => void,
132
- ref?: React.Ref<any>,
133
- id?: string,
134
- ) {
135
- const commonClasses = "text-sm h-8"
136
-
137
- switch (field.type) {
138
- case "select":
139
- return (
140
- <select
141
- ref={ref as React.Ref<HTMLSelectElement>}
142
- id={id}
143
- value={value ?? ""}
144
- onChange={e => onChange(field.name, e.target.value)}
145
- className={cn(
146
- commonClasses,
147
- "w-full rounded-md border border-input bg-background px-2 py-1 text-sm ring-offset-background",
148
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
149
- )}
150
- >
151
- <option value="">{field.placeholder ?? `Select ${field.label ?? field.name}...`}</option>
152
- {(field.options ?? []).map(opt => (
153
- <option key={opt.value} value={opt.value}>
154
- {opt.label}
155
- </option>
156
- ))}
157
- </select>
158
- )
159
-
160
- case "number":
161
- return (
162
- <Input
163
- ref={ref as React.Ref<HTMLInputElement>}
164
- id={id}
165
- type="number"
166
- value={value ?? ""}
167
- onChange={e => onChange(field.name, e.target.value === "" ? "" : Number(e.target.value))}
168
- placeholder={field.placeholder ?? `Enter ${field.label ?? field.name}...`}
169
- className={commonClasses}
170
- />
171
- )
172
-
173
- case "text":
174
- default:
175
- return (
176
- <Input
177
- ref={ref as React.Ref<HTMLInputElement>}
178
- id={id}
179
- type="text"
180
- value={value ?? ""}
181
- onChange={e => onChange(field.name, e.target.value)}
182
- placeholder={field.placeholder ?? `Enter ${field.label ?? field.name}...`}
183
- className={commonClasses}
184
- />
185
- )
186
- }
187
- }
188
-
189
- export default InlineQuickAdd
@@ -1,525 +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 { useVirtualizer } from "@tanstack/react-virtual"
11
- import {
12
- DndContext,
13
- DragEndEvent,
14
- DragOverlay,
15
- DragStartEvent,
16
- PointerSensor,
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, Button, Input } from "@object-ui/components"
29
- import { ChevronDown, ChevronRight, AlertTriangle, Plus } from "lucide-react"
30
-
31
- const cn = (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' ')
32
-
33
- export interface KanbanCard {
34
- id: string
35
- title: string
36
- description?: string
37
- badges?: Array<{ label: string; variant?: "default" | "secondary" | "destructive" | "outline" }>
38
- coverImage?: string
39
- [key: string]: any
40
- }
41
-
42
- export interface KanbanColumn {
43
- id: string
44
- title: string
45
- cards: KanbanCard[]
46
- limit?: number
47
- className?: string
48
- collapsed?: boolean
49
- }
50
-
51
- export interface ConditionalFormattingRule {
52
- field: string
53
- operator: 'equals' | 'not_equals' | 'contains' | 'in'
54
- value: string | string[]
55
- backgroundColor?: string
56
- borderColor?: string
57
- }
58
-
59
- export interface KanbanEnhancedProps {
60
- columns: KanbanColumn[]
61
- onCardMove?: (cardId: string, fromColumnId: string, toColumnId: string, newIndex: number) => void
62
- onColumnToggle?: (columnId: string, collapsed: boolean) => void
63
- enableVirtualScrolling?: boolean
64
- virtualScrollThreshold?: number
65
- className?: string
66
- quickAdd?: boolean
67
- onQuickAdd?: (columnId: string, title: string) => void
68
- conditionalFormatting?: ConditionalFormattingRule[]
69
- }
70
-
71
- function getCardStyles(card: KanbanCard, rules?: ConditionalFormattingRule[]): React.CSSProperties {
72
- if (!rules || rules.length === 0) return {}
73
-
74
- for (const rule of rules) {
75
- const fieldValue = card[rule.field]
76
- if (fieldValue === undefined || fieldValue === null) continue
77
-
78
- let matches = false
79
- const strValue = String(fieldValue)
80
-
81
- switch (rule.operator) {
82
- case 'equals':
83
- matches = strValue === String(rule.value)
84
- break
85
- case 'not_equals':
86
- matches = strValue !== String(rule.value)
87
- break
88
- case 'contains':
89
- matches = strValue.toLowerCase().includes(String(rule.value).toLowerCase())
90
- break
91
- case 'in':
92
- matches = Array.isArray(rule.value) && rule.value.includes(strValue)
93
- break
94
- }
95
-
96
- if (matches) {
97
- return {
98
- ...(rule.backgroundColor ? { backgroundColor: rule.backgroundColor } : {}),
99
- ...(rule.borderColor ? { borderColor: rule.borderColor } : {}),
100
- }
101
- }
102
- }
103
- return {}
104
- }
105
-
106
- function SortableCard({ card, conditionalFormatting }: { card: KanbanCard; conditionalFormatting?: ConditionalFormattingRule[] }) {
107
- const {
108
- attributes,
109
- listeners,
110
- setNodeRef,
111
- transform,
112
- transition,
113
- isDragging,
114
- } = useSortable({ id: card.id })
115
-
116
- const style = {
117
- transform: CSS.Transform.toString(transform),
118
- transition,
119
- opacity: isDragging ? 0.5 : undefined,
120
- }
121
-
122
- const cardStyles = getCardStyles(card, conditionalFormatting)
123
-
124
- return (
125
- <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
126
- <Card className="mb-2 cursor-grab active:cursor-grabbing border-border bg-card/60 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300 group" style={cardStyles}>
127
- {card.coverImage && (
128
- <div className="w-full h-32 overflow-hidden rounded-t-lg">
129
- <img
130
- src={card.coverImage}
131
- alt=""
132
- className="w-full h-full object-cover"
133
- loading="lazy"
134
- />
135
- </div>
136
- )}
137
- <CardHeader className="p-4">
138
- <CardTitle className="text-sm font-medium font-mono tracking-tight text-foreground group-hover:text-primary transition-colors">{card.title}</CardTitle>
139
- {card.description && (
140
- <CardDescription className="text-xs text-muted-foreground font-mono">
141
- {card.description}
142
- </CardDescription>
143
- )}
144
- </CardHeader>
145
- {card.badges && card.badges.length > 0 && (
146
- <CardContent className="p-4 pt-0">
147
- <div className="flex flex-wrap gap-1">
148
- {card.badges.map((badge, index) => (
149
- <Badge key={index} variant={badge.variant || "default"} className="text-xs">
150
- {badge.label}
151
- </Badge>
152
- ))}
153
- </div>
154
- </CardContent>
155
- )}
156
- </Card>
157
- </div>
158
- )
159
- }
160
-
161
- function VirtualizedCardList({ cards, parentRef, conditionalFormatting }: { cards: KanbanCard[]; parentRef: React.RefObject<HTMLDivElement | null>; conditionalFormatting?: ConditionalFormattingRule[] }) {
162
- const rowVirtualizer = useVirtualizer({
163
- count: cards.length,
164
- getScrollElement: () => parentRef.current,
165
- estimateSize: () => 120,
166
- overscan: 5,
167
- })
168
-
169
- return (
170
- <div
171
- style={{
172
- height: `${rowVirtualizer.getTotalSize()}px`,
173
- width: '100%',
174
- position: 'relative',
175
- }}
176
- >
177
- {rowVirtualizer.getVirtualItems().map((virtualItem) => {
178
- const card = cards[virtualItem.index]
179
- return (
180
- <div
181
- key={card.id}
182
- style={{
183
- position: 'absolute',
184
- top: 0,
185
- left: 0,
186
- width: '100%',
187
- transform: `translateY(${virtualItem.start}px)`,
188
- }}
189
- >
190
- <SortableCard card={card} conditionalFormatting={conditionalFormatting} />
191
- </div>
192
- )
193
- })}
194
- </div>
195
- )
196
- }
197
-
198
- function QuickAddForm({ columnId, onAdd }: { columnId: string; onAdd: (columnId: string, title: string) => void }) {
199
- const [isAdding, setIsAdding] = React.useState(false)
200
- const [title, setTitle] = React.useState('')
201
- const inputRef = React.useRef<HTMLInputElement>(null)
202
-
203
- const handleSubmit = () => {
204
- const trimmed = title.trim()
205
- if (trimmed) {
206
- onAdd(columnId, trimmed)
207
- setTitle('')
208
- }
209
- setIsAdding(false)
210
- }
211
-
212
- const handleKeyDown = (e: React.KeyboardEvent) => {
213
- if (e.key === 'Enter') {
214
- e.preventDefault()
215
- handleSubmit()
216
- } else if (e.key === 'Escape') {
217
- setTitle('')
218
- setIsAdding(false)
219
- }
220
- }
221
-
222
- if (!isAdding) {
223
- return (
224
- <Button
225
- variant="ghost"
226
- size="sm"
227
- className="w-full mt-2 text-muted-foreground hover:text-foreground"
228
- onClick={() => {
229
- setIsAdding(true)
230
- setTimeout(() => inputRef.current?.focus(), 0)
231
- }}
232
- >
233
- <Plus className="h-4 w-4 mr-1" />
234
- Add Card
235
- </Button>
236
- )
237
- }
238
-
239
- return (
240
- <div className="mt-2 space-y-2">
241
- <Input
242
- ref={inputRef}
243
- value={title}
244
- onChange={(e) => setTitle(e.target.value)}
245
- onKeyDown={handleKeyDown}
246
- onBlur={handleSubmit}
247
- placeholder="Enter card title..."
248
- className="text-sm"
249
- autoFocus
250
- />
251
- </div>
252
- )
253
- }
254
-
255
- function KanbanColumnEnhanced({
256
- column,
257
- cards,
258
- onToggle,
259
- enableVirtual,
260
- quickAdd,
261
- onQuickAdd,
262
- conditionalFormatting,
263
- }: {
264
- column: KanbanColumn
265
- cards: KanbanCard[]
266
- onToggle: (collapsed: boolean) => void
267
- enableVirtual: boolean
268
- quickAdd?: boolean
269
- onQuickAdd?: (columnId: string, title: string) => void
270
- conditionalFormatting?: ConditionalFormattingRule[]
271
- }) {
272
- const safeCards = cards || []
273
- const scrollRef = React.useRef<HTMLDivElement>(null)
274
- const { setNodeRef } = useSortable({
275
- id: column.id,
276
- data: {
277
- type: "column",
278
- },
279
- })
280
-
281
- const isLimitExceeded = column.limit && safeCards.length >= column.limit
282
- const isNearLimit = column.limit && safeCards.length >= column.limit * 0.8
283
-
284
- return (
285
- <div
286
- ref={setNodeRef}
287
- className={cn(
288
- "flex flex-col flex-shrink-0 rounded-lg border border-border bg-card/20 backdrop-blur-sm shadow-xl transition-all",
289
- column.collapsed ? "w-16" : "w-80",
290
- column.className
291
- )}
292
- >
293
- <div className="p-4 border-b border-border/50 bg-muted/20 flex items-center justify-between">
294
- <div className="flex items-center gap-2 flex-1 min-w-0">
295
- <Button
296
- variant="ghost"
297
- size="sm"
298
- className="h-6 w-6 p-0"
299
- onClick={() => onToggle(!column.collapsed)}
300
- >
301
- {column.collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
302
- </Button>
303
- {!column.collapsed && (
304
- <>
305
- <h3 className="font-mono text-sm font-semibold tracking-wider text-primary/90 uppercase truncate">
306
- {column.title}
307
- </h3>
308
- <div className="flex items-center gap-2">
309
- <span className={cn(
310
- "font-mono text-xs",
311
- isLimitExceeded ? "text-destructive" : isNearLimit ? "text-yellow-500" : "text-muted-foreground"
312
- )}>
313
- {safeCards.length}
314
- {column.limit && ` / ${column.limit}`}
315
- </span>
316
- {isLimitExceeded && (
317
- <Badge variant="destructive" className="text-xs">
318
- Full
319
- </Badge>
320
- )}
321
- {isNearLimit && !isLimitExceeded && (
322
- <AlertTriangle className="h-3 w-3 text-yellow-500" />
323
- )}
324
- </div>
325
- </>
326
- )}
327
- </div>
328
- {column.collapsed && (
329
- <div className="flex flex-col items-center gap-1">
330
- <span className="font-mono text-xs font-bold text-primary/90 [writing-mode:vertical-rl] rotate-180">
331
- {column.title}
332
- </span>
333
- <Badge variant="secondary" className="text-xs">
334
- {safeCards.length}
335
- </Badge>
336
- </div>
337
- )}
338
- </div>
339
- {!column.collapsed && (
340
- <div ref={scrollRef} className="flex-1 p-4 overflow-y-auto" style={{ maxHeight: '600px' }}>
341
- <SortableContext
342
- items={safeCards.map((c) => c.id)}
343
- strategy={verticalListSortingStrategy}
344
- >
345
- {enableVirtual ? (
346
- <VirtualizedCardList cards={safeCards} parentRef={scrollRef} conditionalFormatting={conditionalFormatting} />
347
- ) : (
348
- <div className="space-y-2">
349
- {safeCards.map((card) => (
350
- <SortableCard key={card.id} card={card} conditionalFormatting={conditionalFormatting} />
351
- ))}
352
- </div>
353
- )}
354
- </SortableContext>
355
- {quickAdd && onQuickAdd && (
356
- <QuickAddForm columnId={column.id} onAdd={onQuickAdd} />
357
- )}
358
- </div>
359
- )}
360
- </div>
361
- )
362
- }
363
-
364
- export function KanbanEnhanced({
365
- columns,
366
- onCardMove,
367
- onColumnToggle,
368
- enableVirtualScrolling = false,
369
- virtualScrollThreshold = 50,
370
- className,
371
- quickAdd,
372
- onQuickAdd,
373
- conditionalFormatting,
374
- }: KanbanEnhancedProps) {
375
- const [activeCard, setActiveCard] = React.useState<KanbanCard | null>(null)
376
-
377
- const safeColumns = React.useMemo(() => {
378
- return (columns || []).map(col => ({
379
- ...col,
380
- cards: col.cards || []
381
- }));
382
- }, [columns]);
383
-
384
- const [boardColumns, setBoardColumns] = React.useState<KanbanColumn[]>(safeColumns)
385
-
386
- React.useEffect(() => {
387
- setBoardColumns(safeColumns)
388
- }, [safeColumns])
389
-
390
- const sensors = useSensors(
391
- useSensor(PointerSensor, {
392
- activationConstraint: {
393
- distance: 8,
394
- },
395
- })
396
- )
397
-
398
- const handleDragStart = (event: DragStartEvent) => {
399
- const { active } = event
400
- const card = findCard(active.id as string)
401
- setActiveCard(card)
402
- }
403
-
404
- const handleDragEnd = (event: DragEndEvent) => {
405
- const { active, over } = event
406
- setActiveCard(null)
407
-
408
- if (!over) return
409
-
410
- const activeId = active.id as string
411
- const overId = over.id as string
412
-
413
- if (activeId === overId) return
414
-
415
- const activeColumn = findColumnByCardId(activeId)
416
- const overColumn = findColumnByCardId(overId) || findColumnById(overId)
417
-
418
- if (!activeColumn || !overColumn) return
419
-
420
- if (activeColumn.id === overColumn.id) {
421
- const cards = [...activeColumn.cards]
422
- const oldIndex = cards.findIndex((c) => c.id === activeId)
423
- const newIndex = cards.findIndex((c) => c.id === overId)
424
-
425
- const newCards = arrayMove(cards, oldIndex, newIndex)
426
- setBoardColumns((prev) =>
427
- prev.map((col) =>
428
- col.id === activeColumn.id ? { ...col, cards: newCards } : col
429
- )
430
- )
431
- } else {
432
- const activeCards = [...activeColumn.cards]
433
- const overCards = [...overColumn.cards]
434
- const activeIndex = activeCards.findIndex((c) => c.id === activeId)
435
-
436
- const isDroppingOnColumn = overId === overColumn.id
437
- const overIndex = isDroppingOnColumn
438
- ? overCards.length
439
- : overCards.findIndex((c) => c.id === overId)
440
-
441
- const [movedCard] = activeCards.splice(activeIndex, 1)
442
- overCards.splice(overIndex, 0, movedCard)
443
-
444
- setBoardColumns((prev) =>
445
- prev.map((col) => {
446
- if (col.id === activeColumn.id) {
447
- return { ...col, cards: activeCards }
448
- }
449
- if (col.id === overColumn.id) {
450
- return { ...col, cards: overCards }
451
- }
452
- return col
453
- })
454
- )
455
-
456
- if (onCardMove) {
457
- onCardMove(activeId, activeColumn.id, overColumn.id, overIndex)
458
- }
459
- }
460
- }
461
-
462
- const findCard = React.useCallback(
463
- (cardId: string): KanbanCard | null => {
464
- for (const column of boardColumns) {
465
- const card = column.cards.find((c) => c.id === cardId)
466
- if (card) return card
467
- }
468
- return null
469
- },
470
- [boardColumns]
471
- )
472
-
473
- const findColumnByCardId = React.useCallback(
474
- (cardId: string): KanbanColumn | null => {
475
- return boardColumns.find((col) => col.cards.some((c) => c.id === cardId)) || null
476
- },
477
- [boardColumns]
478
- )
479
-
480
- const findColumnById = React.useCallback(
481
- (columnId: string): KanbanColumn | null => {
482
- return boardColumns.find((col) => col.id === columnId) || null
483
- },
484
- [boardColumns]
485
- )
486
-
487
- const handleColumnToggle = React.useCallback((columnId: string, collapsed: boolean) => {
488
- setBoardColumns(prev =>
489
- prev.map(col => col.id === columnId ? { ...col, collapsed } : col)
490
- )
491
- onColumnToggle?.(columnId, collapsed)
492
- }, [onColumnToggle])
493
-
494
- return (
495
- <DndContext
496
- sensors={sensors}
497
- collisionDetection={closestCorners}
498
- onDragStart={handleDragStart}
499
- onDragEnd={handleDragEnd}
500
- >
501
- <div className={cn("flex gap-4 overflow-x-auto p-4", className)}>
502
- {boardColumns.map((column) => {
503
- const shouldUseVirtual = enableVirtualScrolling && column.cards.length > virtualScrollThreshold
504
- return (
505
- <KanbanColumnEnhanced
506
- key={column.id}
507
- column={column}
508
- cards={column.cards}
509
- onToggle={(collapsed) => handleColumnToggle(column.id, collapsed)}
510
- enableVirtual={shouldUseVirtual}
511
- quickAdd={quickAdd}
512
- onQuickAdd={onQuickAdd}
513
- conditionalFormatting={conditionalFormatting}
514
- />
515
- )
516
- })}
517
- </div>
518
- <DragOverlay>
519
- {activeCard ? <SortableCard card={activeCard} conditionalFormatting={conditionalFormatting} /> : null}
520
- </DragOverlay>
521
- </DndContext>
522
- )
523
- }
524
-
525
- export default KanbanEnhanced;