@startsimpli/ui 0.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.
- package/README.md +537 -0
- package/package.json +80 -0
- package/src/components/index.ts +50 -0
- package/src/components/navigation/sidebar.tsx +178 -0
- package/src/components/ui/accordion.tsx +58 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/badge.tsx +36 -0
- package/src/components/ui/button.tsx +57 -0
- package/src/components/ui/calendar.tsx +70 -0
- package/src/components/ui/card.tsx +68 -0
- package/src/components/ui/checkbox.tsx +30 -0
- package/src/components/ui/collapsible.tsx +12 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +200 -0
- package/src/components/ui/index.ts +24 -0
- package/src/components/ui/input.tsx +25 -0
- package/src/components/ui/label.tsx +26 -0
- package/src/components/ui/popover.tsx +31 -0
- package/src/components/ui/progress.tsx +28 -0
- package/src/components/ui/scroll-area.tsx +48 -0
- package/src/components/ui/select.tsx +160 -0
- package/src/components/ui/separator.tsx +31 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/table.tsx +117 -0
- package/src/components/ui/tabs.tsx +55 -0
- package/src/components/ui/textarea.tsx +24 -0
- package/src/components/ui/tooltip.tsx +30 -0
- package/src/components/unified-table/UnifiedTable.tsx +553 -0
- package/src/components/unified-table/__tests__/components/BulkActionBar.test.tsx +477 -0
- package/src/components/unified-table/__tests__/components/ExportButton.test.tsx +467 -0
- package/src/components/unified-table/__tests__/components/InlineEditCell.test.tsx +159 -0
- package/src/components/unified-table/__tests__/components/SavedViewsDropdown.test.tsx +128 -0
- package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +374 -0
- package/src/components/unified-table/__tests__/hooks/useColumnReorder.test.ts +191 -0
- package/src/components/unified-table/__tests__/hooks/useColumnResize.test.ts +122 -0
- package/src/components/unified-table/__tests__/hooks/useColumnVisibility.test.ts +594 -0
- package/src/components/unified-table/__tests__/hooks/useFilters.test.ts +460 -0
- package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +439 -0
- package/src/components/unified-table/__tests__/hooks/useResponsive.test.ts +421 -0
- package/src/components/unified-table/__tests__/hooks/useSelection.test.ts +367 -0
- package/src/components/unified-table/__tests__/hooks/useTableKeyboard.test.ts +803 -0
- package/src/components/unified-table/__tests__/hooks/useTableState.test.ts +210 -0
- package/src/components/unified-table/__tests__/integration/table-with-selection.test.tsx +624 -0
- package/src/components/unified-table/__tests__/utils/export.test.ts +427 -0
- package/src/components/unified-table/components/BulkActionBar/index.tsx +119 -0
- package/src/components/unified-table/components/DataTableCore/index.tsx +473 -0
- package/src/components/unified-table/components/InlineEditCell/index.tsx +159 -0
- package/src/components/unified-table/components/MobileView/Card.tsx +218 -0
- package/src/components/unified-table/components/MobileView/CardActions.tsx +126 -0
- package/src/components/unified-table/components/MobileView/README.md +411 -0
- package/src/components/unified-table/components/MobileView/index.tsx +77 -0
- package/src/components/unified-table/components/MobileView/types.ts +77 -0
- package/src/components/unified-table/components/TableFilters/index.tsx +298 -0
- package/src/components/unified-table/components/TablePagination/index.tsx +157 -0
- package/src/components/unified-table/components/Toolbar/ExportButton.tsx +229 -0
- package/src/components/unified-table/components/Toolbar/SavedViewsDropdown.tsx +251 -0
- package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +146 -0
- package/src/components/unified-table/components/Toolbar/index.tsx +3 -0
- package/src/components/unified-table/hooks/index.ts +21 -0
- package/src/components/unified-table/hooks/useColumnReorder.ts +90 -0
- package/src/components/unified-table/hooks/useColumnResize.ts +123 -0
- package/src/components/unified-table/hooks/useColumnVisibility.ts +92 -0
- package/src/components/unified-table/hooks/useFilters.ts +53 -0
- package/src/components/unified-table/hooks/usePagination.ts +120 -0
- package/src/components/unified-table/hooks/useResponsive.ts +50 -0
- package/src/components/unified-table/hooks/useSelection.ts +152 -0
- package/src/components/unified-table/hooks/useTableKeyboard.ts +206 -0
- package/src/components/unified-table/hooks/useTablePreferences.ts +198 -0
- package/src/components/unified-table/hooks/useTableState.ts +103 -0
- package/src/components/unified-table/hooks/useTableURL.test.tsx +921 -0
- package/src/components/unified-table/hooks/useTableURL.ts +301 -0
- package/src/components/unified-table/index.ts +16 -0
- package/src/components/unified-table/types.ts +393 -0
- package/src/components/unified-table/utils/export.ts +236 -0
- package/src/components/unified-table/utils/index.ts +4 -0
- package/src/components/unified-table/utils/renderers.ts +105 -0
- package/src/components/unified-table/utils/themes.ts +87 -0
- package/src/components/unified-table/utils/validation.ts +122 -0
- package/src/index.ts +6 -0
- package/src/lib/utils.ts +1 -0
- package/src/theme/contract.ts +46 -0
- package/src/theme/index.ts +9 -0
- package/src/theme/tailwind.config.js +70 -0
- package/src/theme/tailwind.preset.ts +93 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/index.ts +91 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback } from 'react'
|
|
4
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
|
|
5
|
+
import { Checkbox } from '../../../ui/checkbox'
|
|
6
|
+
import { Button } from '../../../ui/button'
|
|
7
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '../../../ui/dropdown-menu'
|
|
8
|
+
import { ArrowUpDown, ArrowUp, ArrowDown, MoreHorizontal, Loader2, GripVertical } from 'lucide-react'
|
|
9
|
+
import { DragDropContext, Droppable, Draggable, DropResult, DragStart } from '@hello-pangea/dnd'
|
|
10
|
+
import { ColumnConfig, RowAction } from '../../types'
|
|
11
|
+
import { InlineEditCell } from '../InlineEditCell'
|
|
12
|
+
import { cn } from '../../../../lib/utils'
|
|
13
|
+
|
|
14
|
+
interface DataTableCoreProps<TData> {
|
|
15
|
+
data: TData[]
|
|
16
|
+
columns: ColumnConfig<TData>[]
|
|
17
|
+
getRowId: (row: TData) => string
|
|
18
|
+
|
|
19
|
+
// Selection
|
|
20
|
+
selectionEnabled?: boolean
|
|
21
|
+
selectedIds?: Set<string>
|
|
22
|
+
onToggleRow?: (id: string) => void
|
|
23
|
+
onToggleAll?: () => void
|
|
24
|
+
allRowsSelected?: boolean
|
|
25
|
+
renderSelectionCell?: (row: TData) => React.ReactNode
|
|
26
|
+
|
|
27
|
+
// Sorting
|
|
28
|
+
sortingEnabled?: boolean
|
|
29
|
+
sortBy?: string | null
|
|
30
|
+
sortDirection?: 'asc' | 'desc'
|
|
31
|
+
onSort?: (columnId: string) => void
|
|
32
|
+
|
|
33
|
+
// Column Reordering
|
|
34
|
+
columnReorderEnabled?: boolean
|
|
35
|
+
onColumnDragEnd?: (result: DropResult) => void
|
|
36
|
+
|
|
37
|
+
// Column Resizing
|
|
38
|
+
columnResizeEnabled?: boolean
|
|
39
|
+
columnWidths?: { [columnId: string]: number }
|
|
40
|
+
onColumnResizeStart?: (columnId: string, startX: number, startWidth: number) => void
|
|
41
|
+
isResizing?: boolean
|
|
42
|
+
|
|
43
|
+
// Inline Editing
|
|
44
|
+
inlineEditEnabled?: boolean
|
|
45
|
+
onCellEdit?: (rowId: string, columnId: string, value: any, row: TData) => Promise<void>
|
|
46
|
+
|
|
47
|
+
// Row Actions
|
|
48
|
+
rowActions?: RowAction<TData>[]
|
|
49
|
+
onRowClick?: (row: TData) => void
|
|
50
|
+
|
|
51
|
+
// Loading
|
|
52
|
+
loadingRows?: Set<string>
|
|
53
|
+
|
|
54
|
+
className?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function DataTableCore<TData>({
|
|
58
|
+
data,
|
|
59
|
+
columns,
|
|
60
|
+
getRowId,
|
|
61
|
+
selectionEnabled,
|
|
62
|
+
selectedIds = new Set(),
|
|
63
|
+
onToggleRow,
|
|
64
|
+
onToggleAll,
|
|
65
|
+
allRowsSelected,
|
|
66
|
+
renderSelectionCell,
|
|
67
|
+
sortingEnabled,
|
|
68
|
+
sortBy,
|
|
69
|
+
sortDirection,
|
|
70
|
+
onSort,
|
|
71
|
+
columnReorderEnabled,
|
|
72
|
+
onColumnDragEnd,
|
|
73
|
+
columnResizeEnabled,
|
|
74
|
+
columnWidths = {},
|
|
75
|
+
onColumnResizeStart,
|
|
76
|
+
isResizing,
|
|
77
|
+
inlineEditEnabled,
|
|
78
|
+
onCellEdit,
|
|
79
|
+
rowActions,
|
|
80
|
+
onRowClick,
|
|
81
|
+
loadingRows = new Set(),
|
|
82
|
+
className,
|
|
83
|
+
}: DataTableCoreProps<TData>) {
|
|
84
|
+
// State for tracking which cell is being edited
|
|
85
|
+
const [editingCell, setEditingCell] = useState<{ rowId: string; columnId: string } | null>(null)
|
|
86
|
+
|
|
87
|
+
// State for tracking if a column is being dragged (to disable sorting during drag)
|
|
88
|
+
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
|
89
|
+
|
|
90
|
+
// Drag handlers
|
|
91
|
+
const handleDragStart = useCallback((_: DragStart) => {
|
|
92
|
+
setIsDraggingColumn(true)
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const handleDragEnd = useCallback((result: DropResult) => {
|
|
96
|
+
setIsDraggingColumn(false)
|
|
97
|
+
onColumnDragEnd?.(result)
|
|
98
|
+
}, [onColumnDragEnd])
|
|
99
|
+
|
|
100
|
+
// Get effective column width (resized or default)
|
|
101
|
+
const getEffectiveWidth = (column: ColumnConfig<TData>) => {
|
|
102
|
+
if (columnResizeEnabled && columnWidths[column.id]) {
|
|
103
|
+
return columnWidths[column.id]
|
|
104
|
+
}
|
|
105
|
+
return column.width
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Resize handle with stable callback reference
|
|
109
|
+
const handleResizeMouseDown = useCallback((columnId: string, e: React.MouseEvent | React.PointerEvent) => {
|
|
110
|
+
e.stopPropagation()
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
const th = (e.target as HTMLElement).closest('th')
|
|
113
|
+
if (th && onColumnResizeStart) {
|
|
114
|
+
onColumnResizeStart(columnId, e.clientX, th.offsetWidth)
|
|
115
|
+
}
|
|
116
|
+
}, [onColumnResizeStart])
|
|
117
|
+
|
|
118
|
+
// Resize handle component - wider hit area for easier grabbing
|
|
119
|
+
const renderResizeHandle = (columnId: string) => {
|
|
120
|
+
if (!columnResizeEnabled) return null
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-resize-handle="true"
|
|
125
|
+
className="absolute right-0 top-0 h-full w-5 cursor-col-resize z-50 flex items-center justify-center group/resize pointer-events-auto"
|
|
126
|
+
style={{ marginRight: '-10px' }}
|
|
127
|
+
onMouseDown={(e) => handleResizeMouseDown(columnId, e)}
|
|
128
|
+
onPointerDown={(e) => handleResizeMouseDown(columnId, e)}
|
|
129
|
+
onDragStart={(e) => e.preventDefault()}
|
|
130
|
+
>
|
|
131
|
+
{/* Visible indicator line */}
|
|
132
|
+
<div className="w-[3px] h-full bg-transparent group-hover/resize:bg-primary transition-colors rounded-full" />
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const renderCellContent = (column: ColumnConfig<TData>, row: TData) => {
|
|
138
|
+
if (column.cell) {
|
|
139
|
+
return column.cell(row)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (column.accessorFn) {
|
|
143
|
+
return column.accessorFn(row)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (column.accessorKey) {
|
|
147
|
+
const value = (row as any)[column.accessorKey]
|
|
148
|
+
return value !== null && value !== undefined ? String(value) : '-'
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return '-'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Sort indicator component for cleaner rendering
|
|
155
|
+
const SortIndicator = ({ columnId }: { columnId: string }) => {
|
|
156
|
+
const isSorted = sortBy === columnId
|
|
157
|
+
const direction = isSorted ? sortDirection : null
|
|
158
|
+
|
|
159
|
+
if (direction === 'asc') {
|
|
160
|
+
return <ArrowUp className="ml-2 h-4 w-4" />
|
|
161
|
+
} else if (direction === 'desc') {
|
|
162
|
+
return <ArrowDown className="ml-2 h-4 w-4" />
|
|
163
|
+
}
|
|
164
|
+
return <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Handle sort click - disabled during column dragging
|
|
168
|
+
const handleSortClick = useCallback((columnId: string) => {
|
|
169
|
+
if (isDraggingColumn) return // Don't sort while dragging columns
|
|
170
|
+
onSort?.(columnId)
|
|
171
|
+
}, [isDraggingColumn, onSort])
|
|
172
|
+
|
|
173
|
+
const renderHeader = (column: ColumnConfig<TData>, inDragContext = false) => {
|
|
174
|
+
if (typeof column.header === 'function') {
|
|
175
|
+
return column.header({})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// In drag context, render a simpler header (sorting handled separately)
|
|
179
|
+
if (inDragContext) {
|
|
180
|
+
return <span>{column.header}</span>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (sortingEnabled && column.sortable !== false) {
|
|
184
|
+
return (
|
|
185
|
+
<Button
|
|
186
|
+
variant="ghost"
|
|
187
|
+
onClick={() => handleSortClick(column.id)}
|
|
188
|
+
className="-ml-4 h-8 hover:bg-transparent"
|
|
189
|
+
>
|
|
190
|
+
{column.header}
|
|
191
|
+
<SortIndicator columnId={column.id} />
|
|
192
|
+
</Button>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return column.header
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const renderColumnHeaders = () => {
|
|
200
|
+
if (columnReorderEnabled && onColumnDragEnd) {
|
|
201
|
+
return (
|
|
202
|
+
<DragDropContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
|
203
|
+
<TableHeader>
|
|
204
|
+
<Droppable droppableId="columns" direction="horizontal">
|
|
205
|
+
{(provided) => (
|
|
206
|
+
<TableRow
|
|
207
|
+
ref={provided.innerRef}
|
|
208
|
+
{...provided.droppableProps}
|
|
209
|
+
>
|
|
210
|
+
{/* Selection column */}
|
|
211
|
+
{selectionEnabled && (
|
|
212
|
+
<TableHead className="w-12">
|
|
213
|
+
<Checkbox
|
|
214
|
+
checked={allRowsSelected}
|
|
215
|
+
onCheckedChange={onToggleAll}
|
|
216
|
+
aria-label="Select all"
|
|
217
|
+
/>
|
|
218
|
+
</TableHead>
|
|
219
|
+
)}
|
|
220
|
+
|
|
221
|
+
{/* Draggable data columns */}
|
|
222
|
+
{columns.map((column, index) => (
|
|
223
|
+
<Draggable key={column.id} draggableId={column.id} index={index}>
|
|
224
|
+
{(provided, snapshot) => (
|
|
225
|
+
<TableHead
|
|
226
|
+
ref={provided.innerRef}
|
|
227
|
+
{...provided.draggableProps}
|
|
228
|
+
style={{
|
|
229
|
+
...provided.draggableProps.style,
|
|
230
|
+
width: getEffectiveWidth(column),
|
|
231
|
+
minWidth: column.minWidth,
|
|
232
|
+
maxWidth: column.maxWidth,
|
|
233
|
+
}}
|
|
234
|
+
className={cn(
|
|
235
|
+
'relative group overflow-visible',
|
|
236
|
+
snapshot.isDragging && 'bg-primary/10 shadow-lg ring-2 ring-primary/20 z-50',
|
|
237
|
+
columnResizeEnabled && 'select-none'
|
|
238
|
+
)}
|
|
239
|
+
>
|
|
240
|
+
{/* Drag handle area - leaves space for resize handle on the right */}
|
|
241
|
+
<div
|
|
242
|
+
{...provided.dragHandleProps}
|
|
243
|
+
className={cn(
|
|
244
|
+
'flex items-center gap-2 cursor-grab active:cursor-grabbing',
|
|
245
|
+
'hover:bg-muted/50 rounded px-1 -mx-1 py-1 transition-colors',
|
|
246
|
+
columnResizeEnabled && 'pr-4'
|
|
247
|
+
)}
|
|
248
|
+
>
|
|
249
|
+
<GripVertical className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
|
250
|
+
{/* Sortable header text */}
|
|
251
|
+
<div
|
|
252
|
+
className="flex-1 flex items-center"
|
|
253
|
+
onClick={(e) => {
|
|
254
|
+
// Only trigger sort if not dragging and column is sortable
|
|
255
|
+
if (!snapshot.isDragging && sortingEnabled && column.sortable !== false) {
|
|
256
|
+
e.stopPropagation()
|
|
257
|
+
handleSortClick(column.id)
|
|
258
|
+
}
|
|
259
|
+
}}
|
|
260
|
+
>
|
|
261
|
+
{renderHeader(column, true)}
|
|
262
|
+
{sortingEnabled && column.sortable !== false && (
|
|
263
|
+
<SortIndicator columnId={column.id} />
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
{renderResizeHandle(column.id)}
|
|
268
|
+
</TableHead>
|
|
269
|
+
)}
|
|
270
|
+
</Draggable>
|
|
271
|
+
))}
|
|
272
|
+
{provided.placeholder}
|
|
273
|
+
|
|
274
|
+
{/* Actions column */}
|
|
275
|
+
{rowActions && rowActions.length > 0 && (
|
|
276
|
+
<TableHead className="w-12">
|
|
277
|
+
<span className="sr-only">Actions</span>
|
|
278
|
+
</TableHead>
|
|
279
|
+
)}
|
|
280
|
+
</TableRow>
|
|
281
|
+
)}
|
|
282
|
+
</Droppable>
|
|
283
|
+
</TableHeader>
|
|
284
|
+
</DragDropContext>
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Standard non-draggable headers
|
|
289
|
+
return (
|
|
290
|
+
<TableHeader>
|
|
291
|
+
<TableRow>
|
|
292
|
+
{/* Selection column */}
|
|
293
|
+
{selectionEnabled && (
|
|
294
|
+
<TableHead className="w-12">
|
|
295
|
+
<Checkbox
|
|
296
|
+
checked={allRowsSelected}
|
|
297
|
+
onCheckedChange={onToggleAll}
|
|
298
|
+
aria-label="Select all"
|
|
299
|
+
/>
|
|
300
|
+
</TableHead>
|
|
301
|
+
)}
|
|
302
|
+
|
|
303
|
+
{/* Data columns */}
|
|
304
|
+
{columns.map((column) => (
|
|
305
|
+
<TableHead
|
|
306
|
+
key={column.id}
|
|
307
|
+
className={cn('relative group overflow-visible', columnResizeEnabled && 'select-none')}
|
|
308
|
+
style={{
|
|
309
|
+
width: getEffectiveWidth(column),
|
|
310
|
+
minWidth: column.minWidth,
|
|
311
|
+
maxWidth: column.maxWidth,
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
{renderHeader(column)}
|
|
315
|
+
{renderResizeHandle(column.id)}
|
|
316
|
+
</TableHead>
|
|
317
|
+
))}
|
|
318
|
+
|
|
319
|
+
{/* Actions column */}
|
|
320
|
+
{rowActions && rowActions.length > 0 && (
|
|
321
|
+
<TableHead className="w-12">
|
|
322
|
+
<span className="sr-only">Actions</span>
|
|
323
|
+
</TableHead>
|
|
324
|
+
)}
|
|
325
|
+
</TableRow>
|
|
326
|
+
</TableHeader>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<div className={cn('rounded-md border', className)}>
|
|
332
|
+
<Table>
|
|
333
|
+
{renderColumnHeaders()}
|
|
334
|
+
|
|
335
|
+
<TableBody>
|
|
336
|
+
{data.length === 0 ? (
|
|
337
|
+
<TableRow>
|
|
338
|
+
<TableCell
|
|
339
|
+
colSpan={columns.length + (selectionEnabled ? 1 : 0) + (rowActions ? 1 : 0)}
|
|
340
|
+
className="h-24 text-center"
|
|
341
|
+
>
|
|
342
|
+
No results found.
|
|
343
|
+
</TableCell>
|
|
344
|
+
</TableRow>
|
|
345
|
+
) : (
|
|
346
|
+
data.map((row, rowIndex) => {
|
|
347
|
+
const rowId = getRowId(row)
|
|
348
|
+
const isSelected = selectedIds.has(rowId)
|
|
349
|
+
const isLoading = loadingRows.has(rowId)
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<TableRow
|
|
353
|
+
key={rowId}
|
|
354
|
+
data-row-index={rowIndex}
|
|
355
|
+
data-state={isSelected && 'selected'}
|
|
356
|
+
onClick={() => onRowClick?.(row)}
|
|
357
|
+
tabIndex={0}
|
|
358
|
+
className={cn(
|
|
359
|
+
onRowClick && 'cursor-pointer hover:bg-muted/50',
|
|
360
|
+
isLoading && 'opacity-50'
|
|
361
|
+
)}
|
|
362
|
+
>
|
|
363
|
+
{/* Selection cell */}
|
|
364
|
+
{selectionEnabled && (
|
|
365
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
366
|
+
<div className="flex items-center gap-1">
|
|
367
|
+
<Checkbox
|
|
368
|
+
checked={isSelected}
|
|
369
|
+
onCheckedChange={() => onToggleRow?.(rowId)}
|
|
370
|
+
aria-label="Select row"
|
|
371
|
+
/>
|
|
372
|
+
{renderSelectionCell?.(row)}
|
|
373
|
+
</div>
|
|
374
|
+
</TableCell>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
{/* Data cells */}
|
|
378
|
+
{columns.map((column) => {
|
|
379
|
+
const isEditing = editingCell?.rowId === rowId && editingCell?.columnId === column.id
|
|
380
|
+
const canEdit = inlineEditEnabled && column.editable && onCellEdit
|
|
381
|
+
|
|
382
|
+
// Get the cell value for editing
|
|
383
|
+
const getCellValue = () => {
|
|
384
|
+
if (column.accessorFn) return column.accessorFn(row)
|
|
385
|
+
if (column.accessorKey) {
|
|
386
|
+
const keys = column.accessorKey.split('.')
|
|
387
|
+
return keys.reduce((obj, key) => obj?.[key], row as any)
|
|
388
|
+
}
|
|
389
|
+
return undefined
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<TableCell
|
|
394
|
+
key={column.id}
|
|
395
|
+
style={{
|
|
396
|
+
width: getEffectiveWidth(column),
|
|
397
|
+
minWidth: column.minWidth,
|
|
398
|
+
maxWidth: column.maxWidth,
|
|
399
|
+
}}
|
|
400
|
+
className={cn(
|
|
401
|
+
canEdit && !isEditing && 'cursor-pointer hover:bg-muted/50'
|
|
402
|
+
)}
|
|
403
|
+
onClick={(e) => {
|
|
404
|
+
if (canEdit && !isEditing) {
|
|
405
|
+
e.stopPropagation()
|
|
406
|
+
setEditingCell({ rowId, columnId: column.id })
|
|
407
|
+
}
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
{isEditing ? (
|
|
411
|
+
<InlineEditCell
|
|
412
|
+
value={getCellValue()}
|
|
413
|
+
columnId={column.id}
|
|
414
|
+
rowId={rowId}
|
|
415
|
+
editType={column.editType}
|
|
416
|
+
editOptions={column.editOptions}
|
|
417
|
+
validate={column.validate ? (v) => column.validate!(v, row) : undefined}
|
|
418
|
+
onSave={async (value) => {
|
|
419
|
+
await onCellEdit!(rowId, column.id, value, row)
|
|
420
|
+
setEditingCell(null)
|
|
421
|
+
}}
|
|
422
|
+
onCancel={() => setEditingCell(null)}
|
|
423
|
+
/>
|
|
424
|
+
) : (
|
|
425
|
+
<>
|
|
426
|
+
{isLoading && <Loader2 className="h-4 w-4 animate-spin inline-block mr-2" />}
|
|
427
|
+
{renderCellContent(column, row)}
|
|
428
|
+
</>
|
|
429
|
+
)}
|
|
430
|
+
</TableCell>
|
|
431
|
+
)
|
|
432
|
+
})}
|
|
433
|
+
|
|
434
|
+
{/* Actions cell */}
|
|
435
|
+
{rowActions && rowActions.length > 0 && (
|
|
436
|
+
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
437
|
+
<DropdownMenu>
|
|
438
|
+
<DropdownMenuTrigger asChild>
|
|
439
|
+
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
440
|
+
<span className="sr-only">Open menu</span>
|
|
441
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
442
|
+
</Button>
|
|
443
|
+
</DropdownMenuTrigger>
|
|
444
|
+
<DropdownMenuContent align="end">
|
|
445
|
+
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
|
446
|
+
{rowActions.map((action, idx) => {
|
|
447
|
+
const isDisabled = action.disabled?.(row) || false
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<DropdownMenuItem
|
|
451
|
+
key={action.id}
|
|
452
|
+
onClick={() => !isDisabled && action.onClick(row)}
|
|
453
|
+
disabled={isDisabled}
|
|
454
|
+
className={cn(action.destructive && 'text-red-600')}
|
|
455
|
+
>
|
|
456
|
+
{action.icon && <action.icon className="mr-2 h-4 w-4" />}
|
|
457
|
+
{action.label}
|
|
458
|
+
</DropdownMenuItem>
|
|
459
|
+
)
|
|
460
|
+
})}
|
|
461
|
+
</DropdownMenuContent>
|
|
462
|
+
</DropdownMenu>
|
|
463
|
+
</TableCell>
|
|
464
|
+
)}
|
|
465
|
+
</TableRow>
|
|
466
|
+
)
|
|
467
|
+
})
|
|
468
|
+
)}
|
|
469
|
+
</TableBody>
|
|
470
|
+
</Table>
|
|
471
|
+
</div>
|
|
472
|
+
)
|
|
473
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect, useCallback } from 'react'
|
|
4
|
+
import { Input } from '../../../ui/input'
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from '../../../ui/select'
|
|
12
|
+
import { Check, X, Loader2 } from 'lucide-react'
|
|
13
|
+
import { cn } from '../../../../lib/utils'
|
|
14
|
+
|
|
15
|
+
interface InlineEditCellProps {
|
|
16
|
+
value: any
|
|
17
|
+
columnId: string
|
|
18
|
+
rowId: string
|
|
19
|
+
editType?: 'text' | 'number' | 'select' | 'date'
|
|
20
|
+
editOptions?: string[]
|
|
21
|
+
onSave: (value: any) => Promise<void>
|
|
22
|
+
onCancel: () => void
|
|
23
|
+
validate?: (value: any) => string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function InlineEditCell({
|
|
27
|
+
value: initialValue,
|
|
28
|
+
columnId,
|
|
29
|
+
rowId,
|
|
30
|
+
editType = 'text',
|
|
31
|
+
editOptions = [],
|
|
32
|
+
onSave,
|
|
33
|
+
onCancel,
|
|
34
|
+
validate,
|
|
35
|
+
}: InlineEditCellProps) {
|
|
36
|
+
const [value, setValue] = useState(initialValue ?? '')
|
|
37
|
+
const [error, setError] = useState<string | null>(null)
|
|
38
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
39
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
40
|
+
|
|
41
|
+
// Focus the input on mount
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (inputRef.current) {
|
|
44
|
+
inputRef.current.focus()
|
|
45
|
+
inputRef.current.select()
|
|
46
|
+
}
|
|
47
|
+
}, [])
|
|
48
|
+
|
|
49
|
+
// Handle save
|
|
50
|
+
const handleSave = useCallback(async () => {
|
|
51
|
+
// Validate if validator provided
|
|
52
|
+
if (validate) {
|
|
53
|
+
const validationError = validate(value)
|
|
54
|
+
if (validationError) {
|
|
55
|
+
setError(validationError)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setIsSaving(true)
|
|
61
|
+
setError(null)
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
await onSave(value)
|
|
65
|
+
} catch (err) {
|
|
66
|
+
setError(err instanceof Error ? err.message : 'Failed to save')
|
|
67
|
+
setIsSaving(false)
|
|
68
|
+
}
|
|
69
|
+
}, [value, validate, onSave])
|
|
70
|
+
|
|
71
|
+
// Handle keyboard events
|
|
72
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
73
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
74
|
+
e.preventDefault()
|
|
75
|
+
handleSave()
|
|
76
|
+
} else if (e.key === 'Escape') {
|
|
77
|
+
e.preventDefault()
|
|
78
|
+
onCancel()
|
|
79
|
+
}
|
|
80
|
+
}, [handleSave, onCancel])
|
|
81
|
+
|
|
82
|
+
// Render select editor
|
|
83
|
+
if (editType === 'select') {
|
|
84
|
+
return (
|
|
85
|
+
<div className="flex items-center gap-1">
|
|
86
|
+
<Select
|
|
87
|
+
value={String(value)}
|
|
88
|
+
onValueChange={(newValue) => {
|
|
89
|
+
setValue(newValue)
|
|
90
|
+
// Auto-save on select
|
|
91
|
+
onSave(newValue)
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<SelectTrigger className="h-8 w-full">
|
|
95
|
+
<SelectValue />
|
|
96
|
+
</SelectTrigger>
|
|
97
|
+
<SelectContent>
|
|
98
|
+
{editOptions.map((option) => (
|
|
99
|
+
<SelectItem key={option} value={option}>
|
|
100
|
+
{option}
|
|
101
|
+
</SelectItem>
|
|
102
|
+
))}
|
|
103
|
+
</SelectContent>
|
|
104
|
+
</Select>
|
|
105
|
+
<button
|
|
106
|
+
onClick={onCancel}
|
|
107
|
+
className="p-1 hover:bg-muted rounded"
|
|
108
|
+
disabled={isSaving}
|
|
109
|
+
>
|
|
110
|
+
<X className="h-4 w-4 text-muted-foreground" />
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Render text/number/date editor
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex flex-col gap-1">
|
|
119
|
+
<div className="flex items-center gap-1">
|
|
120
|
+
<Input
|
|
121
|
+
ref={inputRef}
|
|
122
|
+
type={editType === 'number' ? 'number' : editType === 'date' ? 'date' : 'text'}
|
|
123
|
+
value={value}
|
|
124
|
+
onChange={(e) => {
|
|
125
|
+
setValue(e.target.value)
|
|
126
|
+
setError(null)
|
|
127
|
+
}}
|
|
128
|
+
onKeyDown={handleKeyDown}
|
|
129
|
+
className={cn(
|
|
130
|
+
'h-8 w-full',
|
|
131
|
+
error && 'border-red-500 focus-visible:ring-red-500'
|
|
132
|
+
)}
|
|
133
|
+
disabled={isSaving}
|
|
134
|
+
/>
|
|
135
|
+
<button
|
|
136
|
+
onClick={handleSave}
|
|
137
|
+
className="p-1 hover:bg-green-100 rounded"
|
|
138
|
+
disabled={isSaving}
|
|
139
|
+
>
|
|
140
|
+
{isSaving ? (
|
|
141
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
142
|
+
) : (
|
|
143
|
+
<Check className="h-4 w-4 text-green-600" />
|
|
144
|
+
)}
|
|
145
|
+
</button>
|
|
146
|
+
<button
|
|
147
|
+
onClick={onCancel}
|
|
148
|
+
className="p-1 hover:bg-red-100 rounded"
|
|
149
|
+
disabled={isSaving}
|
|
150
|
+
>
|
|
151
|
+
<X className="h-4 w-4 text-red-600" />
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
{error && (
|
|
155
|
+
<span className="text-xs text-red-500">{error}</span>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|