@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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
</
|
|
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 {
|
|
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'
|