@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +24 -0
  3. package/dist/{KanbanEnhanced-TdUe0kQH.js → KanbanEnhanced-Do9ZB1Mh.js} +35 -32
  4. package/dist/{KanbanImpl-BtlPa7GE.js → KanbanImpl-BdocXM5T.js} +1 -1
  5. package/dist/{chevron-down-B6UH8BbF.js → chevron-down-C0JUlGjk.js} +1 -1
  6. package/dist/index.js +3 -3
  7. package/dist/index.umd.cjs +2 -2
  8. package/dist/{plus-BTqoaaEC.js → plus-CHsXVJSY.js} +1 -1
  9. package/package.json +34 -11
  10. package/.turbo/turbo-build.log +0 -32
  11. package/src/CardTemplates.tsx +0 -123
  12. package/src/InlineQuickAdd.tsx +0 -189
  13. package/src/KanbanEnhanced.tsx +0 -525
  14. package/src/KanbanImpl.tsx +0 -597
  15. package/src/ObjectKanban.EdgeCases.stories.tsx +0 -168
  16. package/src/ObjectKanban.msw.test.tsx +0 -95
  17. package/src/ObjectKanban.stories.tsx +0 -152
  18. package/src/ObjectKanban.tsx +0 -276
  19. package/src/__tests__/KanbanEnhanced.test.tsx +0 -260
  20. package/src/__tests__/KanbanGrouping.test.tsx +0 -164
  21. package/src/__tests__/KanbanSwimlanes.test.tsx +0 -194
  22. package/src/__tests__/ObjectKanbanTitle.test.tsx +0 -93
  23. package/src/__tests__/SwimlanePersistence.test.tsx +0 -159
  24. package/src/__tests__/accessibility.test.tsx +0 -296
  25. package/src/__tests__/dnd-undo-integration.test.tsx +0 -525
  26. package/src/__tests__/performance-benchmark.test.tsx +0 -306
  27. package/src/__tests__/phase13-features.test.tsx +0 -387
  28. package/src/__tests__/view-states.test.tsx +0 -403
  29. package/src/index.test.ts +0 -112
  30. package/src/index.tsx +0 -327
  31. package/src/registration.test.tsx +0 -26
  32. package/src/types.ts +0 -185
  33. package/src/useColumnWidths.ts +0 -125
  34. package/src/useCrossSwimlaneMove.ts +0 -116
  35. package/src/useQuickAddReorder.ts +0 -107
  36. package/tsconfig.json +0 -19
  37. package/vite.config.ts +0 -62
  38. package/vitest.config.ts +0 -12
  39. package/vitest.setup.ts +0 -1
@@ -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
- }