tembro 4.0.0 → 4.1.0

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 (68) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/components/charts/charts.d.ts +16 -5
  3. package/dist/components/display/chat.cjs +1 -0
  4. package/dist/components/display/chat.d.ts +94 -0
  5. package/dist/components/display/chat.js +1 -0
  6. package/dist/components/display/index.d.ts +1 -0
  7. package/dist/components/display/kanban.d.ts +33 -1
  8. package/dist/components/layout/sidebar.d.ts +24 -2
  9. package/dist/components/modern/rich-text-editor.d.ts +13 -1
  10. package/dist/components/theme-provider.d.ts +19 -6
  11. package/dist/components/ui/badge/index.d.ts +8 -2
  12. package/dist/src/components/charts/charts.cjs +1 -1
  13. package/dist/src/components/charts/charts.js +343 -252
  14. package/dist/src/components/display/activity-feed.cjs +1 -1
  15. package/dist/src/components/display/activity-feed.js +4 -1
  16. package/dist/src/components/display/chat.cjs +1 -0
  17. package/dist/src/components/display/chat.js +372 -0
  18. package/dist/src/components/display/index.cjs +1 -1
  19. package/dist/src/components/display/index.js +17 -16
  20. package/dist/src/components/display/kanban.cjs +1 -1
  21. package/dist/src/components/display/kanban.js +321 -176
  22. package/dist/src/components/display/timeline.cjs +1 -1
  23. package/dist/src/components/display/timeline.js +5 -5
  24. package/dist/src/components/inputs/tag-input.cjs +1 -1
  25. package/dist/src/components/inputs/tag-input.js +6 -2
  26. package/dist/src/components/layout/index.cjs +1 -1
  27. package/dist/src/components/layout/index.js +4 -4
  28. package/dist/src/components/layout/public.cjs +1 -1
  29. package/dist/src/components/layout/public.js +4 -4
  30. package/dist/src/components/layout/sidebar.cjs +1 -1
  31. package/dist/src/components/layout/sidebar.js +326 -228
  32. package/dist/src/components/layout/workspace-layout.cjs +1 -1
  33. package/dist/src/components/layout/workspace-layout.js +3 -3
  34. package/dist/src/components/modern/rich-text-editor.cjs +1 -1
  35. package/dist/src/components/modern/rich-text-editor.js +216 -89
  36. package/dist/src/components/theme-provider.cjs +1 -1
  37. package/dist/src/components/theme-provider.js +58 -36
  38. package/dist/src/components/ui/badge/index.cjs +1 -1
  39. package/dist/src/components/ui/badge/index.js +59 -47
  40. package/dist/src/components/ui/input/clearable.cjs +1 -1
  41. package/dist/src/components/ui/input/clearable.js +5 -2
  42. package/dist/src/components/ui/input/primitive.cjs +1 -1
  43. package/dist/src/components/ui/input/primitive.js +1 -1
  44. package/dist/src/index.cjs +1 -1
  45. package/dist/src/index.js +87 -86
  46. package/dist/src/public-component-surface.cjs +1 -1
  47. package/dist/src/public-component-surface.js +5 -0
  48. package/package.json +1 -1
  49. package/packages/cli/dist/index.cjs +3 -3
  50. package/packages/cli/vendor/src/components/charts/charts.tsx +55 -15
  51. package/packages/cli/vendor/src/components/display/activity-feed.tsx +1 -1
  52. package/packages/cli/vendor/src/components/display/chat.tsx +237 -0
  53. package/packages/cli/vendor/src/components/display/index.ts +1 -0
  54. package/packages/cli/vendor/src/components/display/kanban.tsx +219 -72
  55. package/packages/cli/vendor/src/components/display/timeline.tsx +10 -10
  56. package/packages/cli/vendor/src/components/inputs/tag-input.tsx +3 -2
  57. package/packages/cli/vendor/src/components/layout/sidebar.tsx +212 -51
  58. package/packages/cli/vendor/src/components/layout/workspace-layout.tsx +3 -3
  59. package/packages/cli/vendor/src/components/modern/rich-text-editor.tsx +115 -32
  60. package/packages/cli/vendor/src/components/theme-provider.tsx +82 -36
  61. package/packages/cli/vendor/src/components/ui/badge/index.tsx +29 -8
  62. package/packages/cli/vendor/src/components/ui/input/clearable.tsx +2 -2
  63. package/packages/cli/vendor/src/components/ui/input/primitive.tsx +1 -1
  64. package/packages/cli/vendor/src/public-component-surface.ts +1 -0
  65. package/packages/cli/vendor/templates/showcase/src/showcase/layout/HeroSection.tsx +2 -2
  66. package/packages/cli/vendor/templates/showcase/src/showcase/layout/WorkbenchSidebar.tsx +1 -1
  67. package/packages/cli/vendor/templates/styles/globals.css +136 -33
  68. package/registry.json +3 -1
@@ -11,9 +11,12 @@ import {
11
11
  type DragStartEvent,
12
12
  } from "@dnd-kit/react"
13
13
  import { isSortable, useSortable } from "@dnd-kit/react/sortable"
14
- import { GripVerticalIcon } from "lucide-react"
15
-
16
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
14
+ import { CalendarDaysIcon, CheckIcon, GripVerticalIcon, PlusIcon } from "lucide-react"
15
+
16
+ import { Avatar } from "@/components/display/avatar"
17
+ import { Badge } from "@/components/ui/badge"
18
+ import { Button } from "@/components/ui/button"
19
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
17
20
  import { cn } from "@/lib/utils"
18
21
 
19
22
  const KANBAN_CARD_PREFIX = "tembro-kanban-card:"
@@ -36,31 +39,42 @@ function getColumnKey(id: string | number | symbol | undefined) {
36
39
  return value.startsWith(KANBAN_COLUMN_PREFIX) ? value.slice(KANBAN_COLUMN_PREFIX.length) : value
37
40
  }
38
41
 
39
- export type KanbanCard = {
42
+ export type KanbanCard = {
40
43
  key: string
41
44
  title: React.ReactNode
42
45
  description?: React.ReactNode
43
46
  meta?: React.ReactNode
44
47
  extra?: React.ReactNode
45
- actions?: React.ReactNode
46
- disabled?: boolean
47
- }
48
+ actions?: React.ReactNode
49
+ labels?: Array<React.ReactNode | { key: string; label: React.ReactNode; tone?: "neutral" | "info" | "success" | "warning" | "danger" | "muted" }>
50
+ priority?: "low" | "medium" | "high" | "urgent"
51
+ assignee?: { name: string; src?: string; fallback?: React.ReactNode }
52
+ dueDate?: React.ReactNode
53
+ selected?: boolean
54
+ disabled?: boolean
55
+ }
48
56
 
49
57
  export type KanbanColumn = {
50
58
  key: string
51
59
  title: React.ReactNode
52
60
  description?: React.ReactNode
53
61
  cards: KanbanCard[]
54
- count?: React.ReactNode
55
- disabled?: boolean
56
- }
62
+ count?: React.ReactNode
63
+ limit?: number
64
+ color?: string
65
+ actions?: React.ReactNode
66
+ footer?: React.ReactNode
67
+ disabled?: boolean
68
+ }
57
69
 
58
70
  export type KanbanCardRenderContext = {
59
71
  index: number
60
72
  isDragging: boolean
61
- isDropTarget: boolean
62
- handle: React.ReactNode
63
- }
73
+ isDropTarget: boolean
74
+ selected: boolean
75
+ selectControl: React.ReactNode
76
+ handle: React.ReactNode
77
+ }
64
78
 
65
79
  export type KanbanCardMove = {
66
80
  card: KanbanCard
@@ -74,20 +88,33 @@ export type KanbanBoardProps = Omit<React.ComponentProps<"div">, "defaultValue">
74
88
  columns?: KanbanColumn[]
75
89
  defaultColumns?: KanbanColumn[]
76
90
  onColumnsChange?: (columns: KanbanColumn[], change: KanbanCardMove) => void
77
- onCardMove?: (change: KanbanCardMove) => void
91
+ onCardMove?: (change: KanbanCardMove) => void
92
+ canMoveCard?: (change: KanbanCardMove) => boolean
78
93
  renderCard?: (
79
94
  card: KanbanCard,
80
95
  column: KanbanColumn,
81
96
  context: KanbanCardRenderContext
82
97
  ) => React.ReactNode
83
- onCardClick?: (card: KanbanCard, column: KanbanColumn) => void
98
+ onCardClick?: (card: KanbanCard, column: KanbanColumn) => void
99
+ onAddCard?: (column: KanbanColumn) => void
100
+ onAddColumn?: () => void
101
+ renderColumnHeader?: (column: KanbanColumn) => React.ReactNode
102
+ renderColumnFooter?: (column: KanbanColumn) => React.ReactNode
103
+ selectionMode?: "none" | "single" | "multiple"
104
+ selectedCardKeys?: string[]
105
+ defaultSelectedCardKeys?: string[]
106
+ onSelectionChange?: (keys: string[], cards: KanbanCard[]) => void
107
+ allowCrossColumn?: boolean
108
+ allowReorder?: boolean
109
+ density?: "compact" | "comfortable"
110
+ columnWidth?: number | string
84
111
  disabled?: boolean
85
112
  emptyColumn?: React.ReactNode
86
113
  dragHandleLabel?: (card: KanbanCard, column: KanbanColumn) => string
87
114
  columnClassName?: string
88
115
  cardClassName?: string
89
- overlayClassName?: string
90
- }
116
+ overlayClassName?: string
117
+ }
91
118
 
92
119
  type KanbanCardViewProps = {
93
120
  card: KanbanCard
@@ -96,6 +123,9 @@ type KanbanCardViewProps = {
96
123
  boardDisabled: boolean
97
124
  renderCard?: KanbanBoardProps["renderCard"]
98
125
  onCardClick?: KanbanBoardProps["onCardClick"]
126
+ selected: boolean
127
+ selectionMode: NonNullable<KanbanBoardProps["selectionMode"]>
128
+ onSelect: (card: KanbanCard) => void
99
129
  dragHandleLabel?: KanbanBoardProps["dragHandleLabel"]
100
130
  cardClassName?: string
101
131
  onNativeDragStart?: (source: NativeDragSource) => void
@@ -191,6 +221,37 @@ function moveKanbanCardToTarget(
191
221
  },
192
222
  }
193
223
  }
224
+
225
+ function getKanbanChange(before: KanbanColumn[], after: KanbanColumn[], cardKey: string): KanbanCardMove | null {
226
+ const fromColumn = before.find((column) => column.cards.some((card) => card.key === cardKey))
227
+ const toColumn = after.find((column) => column.cards.some((card) => card.key === cardKey))
228
+ const card = fromColumn?.cards.find((item) => item.key === cardKey)
229
+ if (!fromColumn || !toColumn || !card) return null
230
+ return {
231
+ card,
232
+ fromColumn,
233
+ toColumn,
234
+ fromIndex: fromColumn.cards.findIndex((item) => item.key === cardKey),
235
+ toIndex: toColumn.cards.findIndex((item) => item.key === cardKey),
236
+ }
237
+ }
238
+
239
+ function isKanbanMoveAllowed(
240
+ before: KanbanColumn[],
241
+ after: KanbanColumn[],
242
+ cardKey: string,
243
+ allowCrossColumn: boolean,
244
+ allowReorder: boolean,
245
+ canMoveCard?: KanbanBoardProps["canMoveCard"]
246
+ ) {
247
+ const change = getKanbanChange(before, after, cardKey)
248
+ if (!change) return false
249
+ const crossColumn = change.fromColumn.key !== change.toColumn.key
250
+ if (crossColumn && !allowCrossColumn) return false
251
+ if (!crossColumn && !allowReorder) return false
252
+ if (crossColumn && change.toColumn.limit !== undefined && change.toColumn.cards.length > change.toColumn.limit) return false
253
+ return canMoveCard?.(change) ?? true
254
+ }
194
255
 
195
256
  const KanbanDragHandle = React.forwardRef<HTMLButtonElement, {
196
257
  label: string
@@ -210,23 +271,36 @@ const KanbanDragHandle = React.forwardRef<HTMLButtonElement, {
210
271
  )
211
272
  })
212
273
 
213
- function DefaultKanbanCard({ card, handle }: { card: KanbanCard; handle: React.ReactNode }) {
214
- return (
215
- <Card className="transition-colors hover:bg-muted/40">
216
- <CardHeader className="p-3 pb-1">
217
- <div className="flex items-start justify-between gap-2">
218
- <CardTitle className="text-sm">{card.title}</CardTitle>
219
- <div className="flex items-center gap-1">
274
+ function DefaultKanbanCard({ card, handle, selectControl }: { card: KanbanCard; handle: React.ReactNode; selectControl?: React.ReactNode }) {
275
+ const priorityTone = card.priority === "urgent" ? "danger" : card.priority === "high" ? "warning" : card.priority === "medium" ? "info" : "muted"
276
+ return (
277
+ <Card className="border-border/80 shadow-sm transition-[border-color,box-shadow,transform] hover:-translate-y-0.5 hover:border-foreground/20 hover:shadow-md">
278
+ <CardHeader className="p-3.5 pb-2">
279
+ <div className="flex items-start justify-between gap-2">
280
+ <div className="flex min-w-0 items-start gap-2">
281
+ {selectControl}
282
+ <CardTitle className="min-w-0 text-sm leading-5">{card.title}</CardTitle>
283
+ </div>
284
+ <div className="flex items-center gap-1">
220
285
  {card.extra}
221
286
  {handle}
222
287
  </div>
223
288
  </div>
224
289
  </CardHeader>
225
- {(card.description || card.meta) ? (
226
- <CardContent className="grid gap-2 p-3 pt-0 text-xs text-muted-foreground">
227
- {card.description}
228
- {card.meta ? <div>{card.meta}</div> : null}
229
- </CardContent>
290
+ {(card.description || card.meta || card.labels?.length || card.priority || card.assignee || card.dueDate || card.actions) ? (
291
+ <CardContent className="grid gap-3 p-3.5 pt-0 text-xs text-muted-foreground">
292
+ {card.description ? <div className="line-clamp-3 leading-5">{card.description}</div> : null}
293
+ {card.labels?.length || card.priority ? <div className="flex flex-wrap gap-1.5">
294
+ {card.priority ? <Badge size="sm" tone={priorityTone} variant="soft" label={card.priority} showDot /> : null}
295
+ {card.labels?.map((label, index) => React.isValidElement(label) ? React.cloneElement(label, { key: label.key ?? index }) : typeof label === "object" && label !== null && "key" in label ? <Badge key={label.key} size="sm" variant="outline" tone={label.tone} label={label.label} /> : <Badge key={index} size="sm" variant="outline" label={label} />)}
296
+ </div> : null}
297
+ {card.meta ? <div>{card.meta}</div> : null}
298
+ {(card.assignee || card.dueDate) ? <div className="flex items-center justify-between gap-3 border-t pt-2.5">
299
+ {card.assignee ? <div className="flex min-w-0 items-center gap-2"><Avatar size="xs" name={card.assignee.name} src={card.assignee.src} fallback={card.assignee.fallback} /><span className="truncate">{card.assignee.name}</span></div> : <span />}
300
+ {card.dueDate ? <span className="inline-flex shrink-0 items-center gap-1"><CalendarDaysIcon className="size-3.5" />{card.dueDate}</span> : null}
301
+ </div> : null}
302
+ {card.actions ? <div className="flex flex-wrap items-center gap-2">{card.actions}</div> : null}
303
+ </CardContent>
230
304
  ) : null}
231
305
  </Card>
232
306
  )
@@ -239,6 +313,9 @@ function KanbanCardView({
239
313
  boardDisabled,
240
314
  renderCard,
241
315
  onCardClick,
316
+ selected,
317
+ selectionMode,
318
+ onSelect,
242
319
  dragHandleLabel,
243
320
  cardClassName,
244
321
  onNativeDragStart,
@@ -257,18 +334,31 @@ function KanbanCardView({
257
334
  transition: { duration: 180, easing: "cubic-bezier(0.2, 0, 0, 1)", idle: true },
258
335
  })
259
336
  const label = dragHandleLabel?.(card, column) ?? `Move ${String(card.title)}`
260
- const handle = (
337
+ const handle = (
261
338
  <KanbanDragHandle
262
339
  ref={handleRef as React.Ref<HTMLButtonElement>}
263
340
  label={label}
264
341
  disabled={disabled}
265
342
  />
266
- )
267
- const context: KanbanCardRenderContext = {
343
+ )
344
+ const selectControl = selectionMode !== "none" ? (
345
+ <button
346
+ type="button"
347
+ aria-label={`${selected ? "Deselect" : "Select"} ${String(card.title)}`}
348
+ aria-pressed={selected}
349
+ className={cn("mt-0.5 grid size-4 shrink-0 place-items-center rounded border bg-background text-primary outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", selected && "border-primary bg-primary text-primary-foreground")}
350
+ onClick={(event) => { event.stopPropagation(); onSelect(card) }}
351
+ >
352
+ {selected ? <CheckIcon className="size-3" strokeWidth={3} /> : null}
353
+ </button>
354
+ ) : null
355
+ const context: KanbanCardRenderContext = {
268
356
  index,
269
357
  isDragging,
270
- isDropTarget,
271
- handle,
358
+ isDropTarget,
359
+ selected,
360
+ selectControl,
361
+ handle,
272
362
  }
273
363
 
274
364
  return (
@@ -278,15 +368,15 @@ function KanbanCardView({
278
368
  data-dragging={isDragging || undefined}
279
369
  data-drop-target={isDropTarget || undefined}
280
370
  data-disabled={disabled || undefined}
281
- role={onCardClick && !disabled ? "button" : undefined}
282
- tabIndex={onCardClick && !disabled ? 0 : undefined}
371
+ role="group"
283
372
  aria-label={typeof card.title === "string" ? card.title : undefined}
284
373
  draggable={!disabled}
285
374
  className={cn(
286
375
  "rounded-lg outline-none transition-[opacity,box-shadow]",
287
376
  onCardClick && !disabled && "cursor-pointer focus-visible:ring-2 focus-visible:ring-ring",
288
377
  isDropTarget && "ring-2 ring-primary/30",
289
- isDragging && "opacity-30",
378
+ isDragging && "opacity-30",
379
+ selected && "ring-2 ring-primary/35",
290
380
  disabled && "opacity-55",
291
381
  cardClassName
292
382
  )}
@@ -310,15 +400,8 @@ function KanbanCardView({
310
400
  event.stopPropagation()
311
401
  onNativeDrop?.({ columnKey: column.key, index })
312
402
  }}
313
- onKeyDown={(event) => {
314
- if (event.target !== event.currentTarget || disabled || !onCardClick) return
315
- if (event.key === "Enter" || event.key === " ") {
316
- event.preventDefault()
317
- onCardClick(card, column)
318
- }
319
- }}
320
403
  >
321
- {renderCard?.(card, column, context) ?? <DefaultKanbanCard card={card} handle={handle} />}
404
+ {renderCard?.(card, column, context) ?? <DefaultKanbanCard card={card} handle={handle} selectControl={selectControl} />}
322
405
  </article>
323
406
  )
324
407
  }
@@ -327,7 +410,13 @@ function KanbanColumnView({
327
410
  column,
328
411
  boardDisabled,
329
412
  renderCard,
330
- onCardClick,
413
+ onCardClick,
414
+ selectedKeys,
415
+ selectionMode,
416
+ onSelect,
417
+ onAddCard,
418
+ renderColumnHeader,
419
+ renderColumnFooter,
331
420
  dragHandleLabel,
332
421
  emptyColumn,
333
422
  columnClassName,
@@ -335,11 +424,18 @@ function KanbanColumnView({
335
424
  onNativeDragStart,
336
425
  onNativeDragEnd,
337
426
  onNativeDrop,
427
+ density,
338
428
  }: {
339
429
  column: KanbanColumn
340
430
  boardDisabled: boolean
341
431
  renderCard?: KanbanBoardProps["renderCard"]
342
- onCardClick?: KanbanBoardProps["onCardClick"]
432
+ onCardClick?: KanbanBoardProps["onCardClick"]
433
+ selectedKeys: Set<string>
434
+ selectionMode: NonNullable<KanbanBoardProps["selectionMode"]>
435
+ onSelect: (card: KanbanCard) => void
436
+ onAddCard?: KanbanBoardProps["onAddCard"]
437
+ renderColumnHeader?: KanbanBoardProps["renderColumnHeader"]
438
+ renderColumnFooter?: KanbanBoardProps["renderColumnFooter"]
343
439
  dragHandleLabel?: KanbanBoardProps["dragHandleLabel"]
344
440
  emptyColumn: React.ReactNode
345
441
  columnClassName?: string
@@ -347,6 +443,7 @@ function KanbanColumnView({
347
443
  onNativeDragStart?: (source: NativeDragSource) => void
348
444
  onNativeDragEnd?: () => void
349
445
  onNativeDrop?: (target: NativeDropTarget) => void
446
+ density: NonNullable<KanbanBoardProps["density"]>
350
447
  }) {
351
448
  const { ref, isDropTarget } = useDroppable({
352
449
  id: getColumnId(column.key),
@@ -362,10 +459,11 @@ function KanbanColumnView({
362
459
  ref={ref as React.Ref<HTMLElement>}
363
460
  data-slot="kanban-column"
364
461
  data-drop-target={isDropTarget || undefined}
365
- data-disabled={column.disabled || undefined}
462
+ data-disabled={column.disabled || undefined}
463
+ data-density={density}
366
464
  aria-label={typeof column.title === "string" ? column.title : undefined}
367
465
  className={cn(
368
- "grid min-h-40 content-start gap-3 rounded-lg border bg-muted/35 p-3 transition-[border-color,background-color,box-shadow]",
466
+ "grid min-h-40 content-start gap-3 rounded-lg border bg-muted/25 p-3 transition-[border-color,background-color,box-shadow] data-[density=compact]:gap-2 data-[density=compact]:p-2.5",
369
467
  isDropTarget && "border-primary/60 bg-primary/5 ring-2 ring-primary/15",
370
468
  column.disabled && "opacity-60",
371
469
  columnClassName
@@ -379,15 +477,18 @@ function KanbanColumnView({
379
477
  onNativeDrop?.({ columnKey: column.key, index: column.cards.length })
380
478
  }}
381
479
  >
382
- <div className="flex items-start justify-between gap-3">
383
- <div className="grid gap-0.5">
480
+ {renderColumnHeader?.(column) ?? <div className="flex items-start justify-between gap-3">
481
+ {column.color ? <span className="mt-1.5 size-2 shrink-0 rounded-full" style={{ backgroundColor: column.color }} /> : null}
482
+ <div className="grid gap-0.5">
384
483
  <h3 className="text-sm font-semibold text-foreground">{column.title}</h3>
385
484
  {column.description ? <p className="text-xs text-muted-foreground">{column.description}</p> : null}
386
485
  </div>
387
- <div className="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">
388
- {column.count ?? column.cards.length}
389
- </div>
390
- </div>
486
+ <div className="ml-auto flex items-center gap-1">
487
+ {column.actions}
488
+ {onAddCard && !column.disabled ? <Button size="icon-xs" variant="ghost" iconOnly aria-label={`Add card to ${String(column.title)}`} onClick={() => onAddCard(column)}><PlusIcon /></Button> : null}
489
+ <Badge size="sm" variant={column.limit !== undefined && column.cards.length >= column.limit ? "soft" : "outline"} tone={column.limit !== undefined && column.cards.length >= column.limit ? "warning" : "muted"} label={column.count ?? (column.limit === undefined ? column.cards.length : `${column.cards.length}/${column.limit}`)} />
490
+ </div>
491
+ </div>}
391
492
  <div className="grid min-h-16 content-start gap-2">
392
493
  {column.cards.length === 0 ? (
393
494
  <div className="grid min-h-20 place-items-center rounded-lg border border-dashed p-3 text-center text-xs text-muted-foreground">
@@ -403,7 +504,10 @@ function KanbanColumnView({
403
504
  index={index}
404
505
  boardDisabled={boardDisabled}
405
506
  renderCard={renderCard}
406
- onCardClick={onCardClick}
507
+ onCardClick={onCardClick}
508
+ selected={selectedKeys.has(card.key) || card.selected === true}
509
+ selectionMode={selectionMode}
510
+ onSelect={onSelect}
407
511
  dragHandleLabel={dragHandleLabel}
408
512
  cardClassName={cardClassName}
409
513
  onNativeDragStart={onNativeDragStart}
@@ -411,9 +515,10 @@ function KanbanColumnView({
411
515
  onNativeDrop={onNativeDrop}
412
516
  />
413
517
  ))
414
- )}
415
- </div>
416
- </section>
518
+ )}
519
+ </div>
520
+ {renderColumnFooter?.(column) ?? column.footer ?? (onAddCard && !column.disabled ? <Button size="sm" variant="ghost" className="w-full justify-start" leftIcon={<PlusIcon />} onClick={() => onAddCard(column)}>Add task</Button> : null)}
521
+ </section>
417
522
  )
418
523
  }
419
524
 
@@ -421,9 +526,22 @@ function KanbanBoard({
421
526
  columns: columnsProp,
422
527
  defaultColumns = [],
423
528
  onColumnsChange,
424
- onCardMove,
529
+ onCardMove,
530
+ canMoveCard,
425
531
  renderCard,
426
- onCardClick,
532
+ onCardClick,
533
+ onAddCard,
534
+ onAddColumn,
535
+ renderColumnHeader,
536
+ renderColumnFooter,
537
+ selectionMode = "none",
538
+ selectedCardKeys,
539
+ defaultSelectedCardKeys = [],
540
+ onSelectionChange,
541
+ allowCrossColumn = true,
542
+ allowReorder = true,
543
+ density = "comfortable",
544
+ columnWidth = 320,
427
545
  disabled = false,
428
546
  emptyColumn = "Drop cards here.",
429
547
  dragHandleLabel,
@@ -439,9 +557,22 @@ function KanbanBoard({
439
557
  const [activeCardId, setActiveCardId] = React.useState<string | null>(null)
440
558
  const draftColumnsRef = React.useRef<KanbanColumn[] | null>(null)
441
559
  const nativeDragRef = React.useRef<NativeDragSource | null>(null)
560
+ const [internalSelectedKeys, setInternalSelectedKeys] = React.useState(defaultSelectedCardKeys)
442
561
  const columns = columnsProp ?? internalColumns
443
562
  const renderedColumns = draftColumns ?? columns
444
- const isControlled = columnsProp !== undefined
563
+ const isControlled = columnsProp !== undefined
564
+ const resolvedSelectedKeys = selectedCardKeys ?? internalSelectedKeys
565
+ const selectedKeySet = React.useMemo(() => new Set(resolvedSelectedKeys), [resolvedSelectedKeys])
566
+
567
+ function updateSelection(card: KanbanCard) {
568
+ if (selectionMode === "none" || card.disabled) return
569
+ const next = selectionMode === "single"
570
+ ? (selectedKeySet.has(card.key) ? [] : [card.key])
571
+ : (selectedKeySet.has(card.key) ? resolvedSelectedKeys.filter((key) => key !== card.key) : [...resolvedSelectedKeys, card.key])
572
+ if (selectedCardKeys === undefined) setInternalSelectedKeys(next)
573
+ const allCards = columns.flatMap((column) => column.cards)
574
+ onSelectionChange?.(next, allCards.filter((item) => next.includes(item.key)))
575
+ }
445
576
 
446
577
  function handleDragStart(event: DragStartEvent) {
447
578
  const source = event.operation.source
@@ -458,7 +589,9 @@ function KanbanBoard({
458
589
  if (!source || !isSortable(source)) return
459
590
 
460
591
  const currentColumns = draftColumnsRef.current ?? columns
461
- const nextColumns = moveKanbanCards(currentColumns, event)
592
+ const nextColumns = moveKanbanCards(currentColumns, event)
593
+ const cardKey = getCardKey(String(source.id))
594
+ if (!isKanbanMoveAllowed(columns, nextColumns, cardKey, allowCrossColumn, allowReorder, canMoveCard)) return
462
595
  draftColumnsRef.current = nextColumns
463
596
  setDraftColumns(nextColumns)
464
597
  }
@@ -473,7 +606,7 @@ function KanbanBoard({
473
606
  return
474
607
  }
475
608
 
476
- const finalColumns = draftColumnsRef.current ?? moveKanbanCards(columns, event)
609
+ const finalColumns = draftColumnsRef.current ?? moveKanbanCards(columns, event)
477
610
  const cardKey = getCardKey(String(source.id))
478
611
  const fromColumnKey = getColumnKey(source.initialGroup)
479
612
  const toColumnKey = getColumnKey(source.group)
@@ -484,15 +617,16 @@ function KanbanBoard({
484
617
  draftColumnsRef.current = null
485
618
  setDraftColumns(null)
486
619
 
487
- if (!card || !fromColumn || !toColumn) return
620
+ if (!card || !fromColumn || !toColumn) return
488
621
 
489
- const change: KanbanCardMove = {
622
+ const change: KanbanCardMove = {
490
623
  card,
491
624
  fromColumn,
492
625
  toColumn,
493
626
  fromIndex: source.initialIndex,
494
627
  toIndex: source.index,
495
- }
628
+ }
629
+ if (!isKanbanMoveAllowed(columns, finalColumns, card.key, allowCrossColumn, allowReorder, canMoveCard)) return
496
630
  if (!isControlled) setInternalColumns(finalColumns)
497
631
  onColumnsChange?.(finalColumns, change)
498
632
  onCardMove?.(change)
@@ -505,6 +639,7 @@ function KanbanBoard({
505
639
 
506
640
  const result = moveKanbanCardToTarget(columns, source, target)
507
641
  if (!result) return
642
+ if (!isKanbanMoveAllowed(columns, result.columns, result.change.card.key, allowCrossColumn, allowReorder, canMoveCard)) return
508
643
 
509
644
  if (!isControlled) setInternalColumns(result.columns)
510
645
  onColumnsChange?.(result.columns, result.change)
@@ -527,9 +662,11 @@ function KanbanBoard({
527
662
  {...props}
528
663
  role="group"
529
664
  aria-label={ariaLabel}
530
- data-slot="kanban-board"
665
+ data-slot="kanban-board"
666
+ data-density={density}
667
+ style={{ "--kanban-column-width": typeof columnWidth === "number" ? `${columnWidth}px` : columnWidth, ...props.style } as React.CSSProperties}
531
668
  className={cn(
532
- "grid gap-4 sm:auto-cols-[minmax(280px,1fr)] sm:grid-flow-col sm:overflow-x-auto sm:pb-2",
669
+ "grid gap-4 sm:auto-cols-[var(--kanban-column-width)] sm:grid-flow-col sm:overflow-x-auto sm:pb-2",
533
670
  className
534
671
  )}
535
672
  >
@@ -539,7 +676,13 @@ function KanbanBoard({
539
676
  column={column}
540
677
  boardDisabled={disabled}
541
678
  renderCard={renderCard}
542
- onCardClick={onCardClick}
679
+ onCardClick={onCardClick}
680
+ selectedKeys={selectedKeySet}
681
+ selectionMode={selectionMode}
682
+ onSelect={updateSelection}
683
+ onAddCard={onAddCard}
684
+ renderColumnHeader={renderColumnHeader}
685
+ renderColumnFooter={renderColumnFooter}
543
686
  dragHandleLabel={dragHandleLabel}
544
687
  emptyColumn={emptyColumn}
545
688
  columnClassName={columnClassName}
@@ -547,8 +690,10 @@ function KanbanBoard({
547
690
  onNativeDragStart={(source) => { nativeDragRef.current = source }}
548
691
  onNativeDragEnd={() => { nativeDragRef.current = null }}
549
692
  onNativeDrop={handleNativeDrop}
693
+ density={density}
550
694
  />
551
- ))}
695
+ ))}
696
+ {onAddColumn ? <button type="button" className="flex min-h-32 items-center justify-center gap-2 rounded-lg border border-dashed bg-muted/15 text-sm font-medium text-muted-foreground outline-none transition-colors hover:border-foreground/25 hover:bg-muted/35 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" onClick={onAddColumn}><PlusIcon className="size-4" />Add column</button> : null}
552
697
  </div>
553
698
 
554
699
  <DragOverlay>
@@ -559,7 +704,9 @@ function KanbanBoard({
559
704
  isDragging: true,
560
705
  isDropTarget: false,
561
706
  handle: null,
562
- }) ?? <DefaultKanbanCard card={activeEntry.card} handle={null} />}
707
+ selected: selectedKeySet.has(activeEntry.card.key),
708
+ selectControl: null,
709
+ }) ?? <DefaultKanbanCard card={activeEntry.card} handle={null} />}
563
710
  </div>
564
711
  ) : null}
565
712
  </DragOverlay>
@@ -78,28 +78,28 @@ function Timeline({
78
78
  )
79
79
  }
80
80
 
81
- function TimelineDot({ item }: { item: TimelineItem }) {
81
+ function TimelineDot({ item }: { item: TimelineItem }) {
82
82
  const tone = item.tone ?? "default"
83
83
 
84
84
  return (
85
85
  <span
86
86
  data-slot="timeline-dot"
87
87
  data-tone={tone}
88
- className={cn("flex size-7 shrink-0 items-center justify-center rounded-full border text-[10px]", dotClassName[tone])}
88
+ className={cn("relative z-10 grid size-7 shrink-0 place-items-center rounded-full border-2 border-background text-[10px] shadow-[0_0_0_1px_var(--border)]", dotClassName[tone])}
89
89
  >
90
90
  {item.icon ?? <CircleIcon className="size-2 fill-current" />}
91
91
  </span>
92
92
  )
93
93
  }
94
94
 
95
- function TimelineVerticalItem({ item, compact, className, isLast }: { item: TimelineItem; compact: boolean; className?: string; isLast?: boolean }) {
96
- return (
97
- <div data-slot="timeline-item" className={cn("grid grid-cols-[auto_1fr] gap-3", className, item.className)}>
98
- <div className="flex flex-col items-center">
99
- <TimelineDot item={item} />
100
- {!isLast && <div data-slot="timeline-line" className="w-px flex-1 bg-border" />}
101
- </div>
102
- <div className={cn("min-w-0 pb-5", compact && "pb-3")}>
95
+ function TimelineVerticalItem({ item, compact, className, isLast }: { item: TimelineItem; compact: boolean; className?: string; isLast?: boolean }) {
96
+ return (
97
+ <div data-slot="timeline-item" className={cn("relative grid grid-cols-[1.75rem_minmax(0,1fr)] gap-3", className, item.className)}>
98
+ <div className="relative flex justify-center">
99
+ <TimelineDot item={item} />
100
+ {!isLast && <div data-slot="timeline-line" className="absolute left-1/2 top-7 h-[calc(100%_-_1.75rem)] w-px -translate-x-1/2 bg-border" />}
101
+ </div>
102
+ <div className={cn("min-w-0 pb-5 pt-0.5", compact && "pb-3")}>
103
103
  <div className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
104
104
  <div className="min-w-0">
105
105
  {item.title && <div className="text-sm font-medium text-foreground">{item.title}</div>}
@@ -86,7 +86,8 @@ function TagInput({
86
86
  {!readOnly && !disabled && (
87
87
  <button
88
88
  type="button"
89
- className="inline-flex size-5 items-center justify-center rounded-full text-muted-foreground transition hover:bg-[color:var(--aui-control-bg)] hover:text-foreground"
89
+ className="grid size-5 shrink-0 place-items-center rounded-full p-0 leading-none text-muted-foreground transition hover:bg-[color:var(--aui-control-bg)] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
90
+ aria-label={`Remove ${tag}`}
90
91
  onClick={(event) => {
91
92
  stopInteractivePropagation(event)
92
93
  removeTag(tag)
@@ -94,7 +95,7 @@ function TagInput({
94
95
  onMouseDown={stopInteractivePropagation}
95
96
  onDoubleClick={stopInteractivePropagation}
96
97
  >
97
- <XIcon className="size-3" />
98
+ <XIcon className="size-3" strokeWidth={2} />
98
99
  </button>
99
100
  )}
100
101
  </Badge>