@startsimpli/ui 0.4.42 → 0.4.43

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.42",
3
+ "version": "0.4.43",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -35,7 +35,7 @@
35
35
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
36
36
  "react": "^18.0.0 || ^19.0.0",
37
37
  "react-dom": "^18.0.0 || ^19.0.0",
38
- "@startsimpli/api": "0.5.41",
38
+ "@startsimpli/api": "0.5.42",
39
39
  "@startsimpli/auth": "0.4.29"
40
40
  },
41
41
  "peerDependenciesMeta": {
@@ -1,6 +1,12 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
+ import {
5
+ DragDropContext,
6
+ Droppable,
7
+ Draggable,
8
+ type DropResult,
9
+ } from '@hello-pangea/dnd'
4
10
  import { cn } from '../../lib/utils'
5
11
 
6
12
  export interface KanbanColumnConfig {
@@ -9,6 +15,36 @@ export interface KanbanColumnConfig {
9
15
  color?: string
10
16
  }
11
17
 
18
+ /** A resolved card move derived from a drag, in board terms (not DnD terms). */
19
+ export interface KanbanMove {
20
+ cardId: string
21
+ fromColumnId: string
22
+ toColumnId: string
23
+ toIndex: number
24
+ }
25
+
26
+ /**
27
+ * Pure resolver: turn a @hello-pangea/dnd DropResult into a KanbanMove, or null
28
+ * when the drag is a no-op (dropped outside any column, or back onto the same slot).
29
+ * Exported so consumers and tests can reason about a move without a DOM.
30
+ */
31
+ export function resolveKanbanMove(result: DropResult): KanbanMove | null {
32
+ const { draggableId, source, destination } = result
33
+ if (!destination) return null
34
+ if (
35
+ source.droppableId === destination.droppableId &&
36
+ source.index === destination.index
37
+ ) {
38
+ return null
39
+ }
40
+ return {
41
+ cardId: draggableId,
42
+ fromColumnId: source.droppableId,
43
+ toColumnId: destination.droppableId,
44
+ toIndex: destination.index,
45
+ }
46
+ }
47
+
12
48
  export interface KanbanBoardProps<T> {
13
49
  columns: KanbanColumnConfig[]
14
50
  items: Record<string, T[]>
@@ -20,6 +56,15 @@ export interface KanbanBoardProps<T> {
20
56
  columnRef?: (columnId: string) => React.Ref<HTMLDivElement> | undefined
21
57
  isColumnOver?: (columnId: string) => boolean
22
58
  className?: string
59
+ /**
60
+ * DnD (opt-in). Provide BOTH `getCardId` and `onCardMove` to make cards draggable
61
+ * between columns via @hello-pangea/dnd — all drag mechanics live here in
62
+ * @startsimpli/ui; consumers stay dnd-agnostic and just persist the move in
63
+ * `onCardMove`. Omit either and the board renders exactly as before (no DnD;
64
+ * `columnRef`/`isColumnOver` honored for a parent's own manual wiring).
65
+ */
66
+ getCardId?: (item: T) => string
67
+ onCardMove?: (move: KanbanMove) => void
23
68
  }
24
69
 
25
70
  export function KanbanBoard<T>({
@@ -33,71 +78,135 @@ export function KanbanBoard<T>({
33
78
  columnRef,
34
79
  isColumnOver,
35
80
  className,
81
+ getCardId,
82
+ onCardMove,
36
83
  }: KanbanBoardProps<T>) {
37
- return (
38
- <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
39
- {columns.map((column) => {
40
- const columnItems = items[column.id] ?? []
41
- const ref = columnRef?.(column.id)
42
- const isOver = isColumnOver?.(column.id) ?? false
84
+ const dndEnabled = !!(getCardId && onCardMove)
43
85
 
44
- return (
45
- <div
46
- key={column.id}
47
- ref={ref}
48
- style={{ minWidth: columnWidth, maxWidth: columnWidth }}
49
- className={cn(
50
- 'flex flex-col rounded-lg border bg-muted/50 transition-colors',
51
- isOver && 'border-primary ring-2 ring-primary/20',
52
- )}
53
- >
54
- {/* Column Header */}
55
- {renderColumnHeader ? (
56
- renderColumnHeader(column, columnItems)
57
- ) : (
58
- <div className="flex items-center justify-between px-3 py-2 border-b">
59
- <div className="flex items-center gap-2">
60
- {column.color && (
61
- <span
62
- className="w-2.5 h-2.5 rounded-full shrink-0"
63
- style={{ backgroundColor: column.color }}
64
- aria-hidden="true"
65
- />
66
- )}
67
- <h3 className="text-sm font-semibold text-foreground truncate">
68
- {column.label}
69
- </h3>
70
- </div>
71
- <span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
72
- {columnItems.length}
73
- </span>
74
- </div>
75
- )}
86
+ const handleDragEnd = React.useCallback(
87
+ (result: DropResult) => {
88
+ const move = resolveKanbanMove(result)
89
+ if (move) onCardMove?.(move)
90
+ },
91
+ [onCardMove],
92
+ )
93
+
94
+ const columnHeader = (column: KanbanColumnConfig, columnItems: T[]) =>
95
+ renderColumnHeader ? (
96
+ renderColumnHeader(column, columnItems)
97
+ ) : (
98
+ <div className="flex items-center justify-between px-3 py-2 border-b">
99
+ <div className="flex items-center gap-2">
100
+ {column.color && (
101
+ <span
102
+ className="w-2.5 h-2.5 rounded-full shrink-0"
103
+ style={{ backgroundColor: column.color }}
104
+ aria-hidden="true"
105
+ />
106
+ )}
107
+ <h3 className="text-sm font-semibold text-foreground truncate">
108
+ {column.label}
109
+ </h3>
110
+ </div>
111
+ <span className="inline-flex items-center justify-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
112
+ {columnItems.length}
113
+ </span>
114
+ </div>
115
+ )
116
+
117
+ const emptyState = (
118
+ <p className="text-xs text-muted-foreground text-center py-8">
119
+ {emptyColumnMessage}
120
+ </p>
121
+ )
76
122
 
77
- {/* Column Body */}
78
- <div className="flex-1 overflow-y-auto p-2 space-y-2">
79
- {columnItems.length === 0 ? (
80
- <p className="text-xs text-muted-foreground text-center py-8">
81
- {emptyColumnMessage}
82
- </p>
83
- ) : (
84
- columnItems.map((item, index) => (
85
- <React.Fragment key={index}>
86
- {renderCard(item)}
87
- </React.Fragment>
88
- ))
89
- )}
90
- </div>
123
+ const renderColumn = (column: KanbanColumnConfig) => {
124
+ const columnItems = items[column.id] ?? []
125
+ const footer = renderColumnFooter && (
126
+ <div className="border-t px-3 py-2">{renderColumnFooter(column)}</div>
127
+ )
91
128
 
92
- {/* Column Footer */}
93
- {renderColumnFooter && (
94
- <div className="border-t px-3 py-2">
95
- {renderColumnFooter(column)}
129
+ if (dndEnabled) {
130
+ return (
131
+ <div
132
+ key={column.id}
133
+ style={{ minWidth: columnWidth, maxWidth: columnWidth }}
134
+ className="flex flex-col rounded-lg border bg-muted/50 transition-colors"
135
+ >
136
+ {columnHeader(column, columnItems)}
137
+ <Droppable droppableId={column.id}>
138
+ {(provided, snapshot) => (
139
+ <div
140
+ ref={provided.innerRef}
141
+ {...provided.droppableProps}
142
+ className={cn(
143
+ 'flex-1 overflow-y-auto p-2 space-y-2 transition-colors',
144
+ snapshot.isDraggingOver && 'bg-primary/5',
145
+ )}
146
+ >
147
+ {columnItems.length === 0 && !snapshot.isDraggingOver
148
+ ? emptyState
149
+ : columnItems.map((item, index) => {
150
+ const id = getCardId!(item)
151
+ return (
152
+ <Draggable key={id} draggableId={id} index={index}>
153
+ {(dp, ds) => (
154
+ <div
155
+ ref={dp.innerRef}
156
+ {...dp.draggableProps}
157
+ {...dp.dragHandleProps}
158
+ className={cn(ds.isDragging && 'opacity-80')}
159
+ >
160
+ {renderCard(item)}
161
+ </div>
162
+ )}
163
+ </Draggable>
164
+ )
165
+ })}
166
+ {provided.placeholder}
96
167
  </div>
97
168
  )}
98
- </div>
99
- )
100
- })}
169
+ </Droppable>
170
+ {footer}
171
+ </div>
172
+ )
173
+ }
174
+
175
+ // Non-DnD (backward compatible): honor columnRef/isColumnOver for a parent's own
176
+ // manual drag wiring, render cards keyed by index as before.
177
+ const ref = columnRef?.(column.id)
178
+ const isOver = isColumnOver?.(column.id) ?? false
179
+ return (
180
+ <div
181
+ key={column.id}
182
+ ref={ref}
183
+ style={{ minWidth: columnWidth, maxWidth: columnWidth }}
184
+ className={cn(
185
+ 'flex flex-col rounded-lg border bg-muted/50 transition-colors',
186
+ isOver && 'border-primary ring-2 ring-primary/20',
187
+ )}
188
+ >
189
+ {columnHeader(column, columnItems)}
190
+ <div className="flex-1 overflow-y-auto p-2 space-y-2">
191
+ {columnItems.length === 0
192
+ ? emptyState
193
+ : columnItems.map((item, index) => (
194
+ <React.Fragment key={index}>{renderCard(item)}</React.Fragment>
195
+ ))}
196
+ </div>
197
+ {footer}
198
+ </div>
199
+ )
200
+ }
201
+
202
+ const board = (
203
+ <div className={cn('flex gap-4 overflow-x-auto pb-4', className)}>
204
+ {columns.map(renderColumn)}
101
205
  </div>
102
206
  )
207
+
208
+ if (dndEnabled) {
209
+ return <DragDropContext onDragEnd={handleDragEnd}>{board}</DragDropContext>
210
+ }
211
+ return board
103
212
  }
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react'
2
- import { KanbanBoard } from '../KanbanBoard'
2
+ import type { DropResult } from '@hello-pangea/dnd'
3
+ import { KanbanBoard, resolveKanbanMove } from '../KanbanBoard'
3
4
 
4
5
  interface Card {
5
6
  id: string
@@ -154,4 +155,66 @@ describe('KanbanBoard', () => {
154
155
  const firstCol = container.querySelector('[style*="320"]')
155
156
  expect(firstCol).toHaveClass('ring-2')
156
157
  })
158
+
159
+ it('renders draggable cards when getCardId + onCardMove are provided (DnD enabled)', () => {
160
+ const onCardMove = jest.fn()
161
+ render(
162
+ <KanbanBoard
163
+ columns={columns}
164
+ items={items}
165
+ renderCard={renderCard}
166
+ getCardId={(c) => c.id}
167
+ onCardMove={onCardMove}
168
+ />
169
+ )
170
+ // cards still render through the @hello-pangea/dnd path without crashing
171
+ expect(screen.getByTestId('card-1')).toBeInTheDocument()
172
+ expect(screen.getByTestId('card-3')).toBeInTheDocument()
173
+ expect(screen.getByText('To Do')).toBeInTheDocument()
174
+ })
175
+ })
176
+
177
+ describe('resolveKanbanMove', () => {
178
+ const drop = (
179
+ draggableId: string,
180
+ from: string,
181
+ fromIndex: number,
182
+ to?: string,
183
+ toIndex?: number,
184
+ ): DropResult =>
185
+ ({
186
+ draggableId,
187
+ type: 'DEFAULT',
188
+ source: { droppableId: from, index: fromIndex },
189
+ destination: to === undefined ? null : { droppableId: to, index: toIndex ?? 0 },
190
+ reason: 'DROP',
191
+ mode: 'FLUID',
192
+ combine: null,
193
+ } as unknown as DropResult)
194
+
195
+ it('resolves a cross-column move', () => {
196
+ expect(resolveKanbanMove(drop('c1', 'new', 0, 'surfaced', 2))).toEqual({
197
+ cardId: 'c1',
198
+ fromColumnId: 'new',
199
+ toColumnId: 'surfaced',
200
+ toIndex: 2,
201
+ })
202
+ })
203
+
204
+ it('returns null when dropped outside any column', () => {
205
+ expect(resolveKanbanMove(drop('c1', 'new', 0))).toBeNull()
206
+ })
207
+
208
+ it('returns null when dropped back onto the same slot', () => {
209
+ expect(resolveKanbanMove(drop('c1', 'new', 1, 'new', 1))).toBeNull()
210
+ })
211
+
212
+ it('resolves a within-column reorder', () => {
213
+ expect(resolveKanbanMove(drop('c1', 'new', 0, 'new', 3))).toEqual({
214
+ cardId: 'c1',
215
+ fromColumnId: 'new',
216
+ toColumnId: 'new',
217
+ toIndex: 3,
218
+ })
219
+ })
157
220
  })
@@ -1,2 +1,2 @@
1
- export { KanbanBoard } from './KanbanBoard'
2
- export type { KanbanBoardProps, KanbanColumnConfig } from './KanbanBoard'
1
+ export { KanbanBoard, resolveKanbanMove } from './KanbanBoard'
2
+ export type { KanbanBoardProps, KanbanColumnConfig, KanbanMove } from './KanbanBoard'