@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.
Files changed (86) hide show
  1. package/README.md +537 -0
  2. package/package.json +80 -0
  3. package/src/components/index.ts +50 -0
  4. package/src/components/navigation/sidebar.tsx +178 -0
  5. package/src/components/ui/accordion.tsx +58 -0
  6. package/src/components/ui/alert.tsx +59 -0
  7. package/src/components/ui/badge.tsx +36 -0
  8. package/src/components/ui/button.tsx +57 -0
  9. package/src/components/ui/calendar.tsx +70 -0
  10. package/src/components/ui/card.tsx +68 -0
  11. package/src/components/ui/checkbox.tsx +30 -0
  12. package/src/components/ui/collapsible.tsx +12 -0
  13. package/src/components/ui/dialog.tsx +122 -0
  14. package/src/components/ui/dropdown-menu.tsx +200 -0
  15. package/src/components/ui/index.ts +24 -0
  16. package/src/components/ui/input.tsx +25 -0
  17. package/src/components/ui/label.tsx +26 -0
  18. package/src/components/ui/popover.tsx +31 -0
  19. package/src/components/ui/progress.tsx +28 -0
  20. package/src/components/ui/scroll-area.tsx +48 -0
  21. package/src/components/ui/select.tsx +160 -0
  22. package/src/components/ui/separator.tsx +31 -0
  23. package/src/components/ui/skeleton.tsx +15 -0
  24. package/src/components/ui/table.tsx +117 -0
  25. package/src/components/ui/tabs.tsx +55 -0
  26. package/src/components/ui/textarea.tsx +24 -0
  27. package/src/components/ui/tooltip.tsx +30 -0
  28. package/src/components/unified-table/UnifiedTable.tsx +553 -0
  29. package/src/components/unified-table/__tests__/components/BulkActionBar.test.tsx +477 -0
  30. package/src/components/unified-table/__tests__/components/ExportButton.test.tsx +467 -0
  31. package/src/components/unified-table/__tests__/components/InlineEditCell.test.tsx +159 -0
  32. package/src/components/unified-table/__tests__/components/SavedViewsDropdown.test.tsx +128 -0
  33. package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +374 -0
  34. package/src/components/unified-table/__tests__/hooks/useColumnReorder.test.ts +191 -0
  35. package/src/components/unified-table/__tests__/hooks/useColumnResize.test.ts +122 -0
  36. package/src/components/unified-table/__tests__/hooks/useColumnVisibility.test.ts +594 -0
  37. package/src/components/unified-table/__tests__/hooks/useFilters.test.ts +460 -0
  38. package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +439 -0
  39. package/src/components/unified-table/__tests__/hooks/useResponsive.test.ts +421 -0
  40. package/src/components/unified-table/__tests__/hooks/useSelection.test.ts +367 -0
  41. package/src/components/unified-table/__tests__/hooks/useTableKeyboard.test.ts +803 -0
  42. package/src/components/unified-table/__tests__/hooks/useTableState.test.ts +210 -0
  43. package/src/components/unified-table/__tests__/integration/table-with-selection.test.tsx +624 -0
  44. package/src/components/unified-table/__tests__/utils/export.test.ts +427 -0
  45. package/src/components/unified-table/components/BulkActionBar/index.tsx +119 -0
  46. package/src/components/unified-table/components/DataTableCore/index.tsx +473 -0
  47. package/src/components/unified-table/components/InlineEditCell/index.tsx +159 -0
  48. package/src/components/unified-table/components/MobileView/Card.tsx +218 -0
  49. package/src/components/unified-table/components/MobileView/CardActions.tsx +126 -0
  50. package/src/components/unified-table/components/MobileView/README.md +411 -0
  51. package/src/components/unified-table/components/MobileView/index.tsx +77 -0
  52. package/src/components/unified-table/components/MobileView/types.ts +77 -0
  53. package/src/components/unified-table/components/TableFilters/index.tsx +298 -0
  54. package/src/components/unified-table/components/TablePagination/index.tsx +157 -0
  55. package/src/components/unified-table/components/Toolbar/ExportButton.tsx +229 -0
  56. package/src/components/unified-table/components/Toolbar/SavedViewsDropdown.tsx +251 -0
  57. package/src/components/unified-table/components/Toolbar/StandardTableToolbar.tsx +146 -0
  58. package/src/components/unified-table/components/Toolbar/index.tsx +3 -0
  59. package/src/components/unified-table/hooks/index.ts +21 -0
  60. package/src/components/unified-table/hooks/useColumnReorder.ts +90 -0
  61. package/src/components/unified-table/hooks/useColumnResize.ts +123 -0
  62. package/src/components/unified-table/hooks/useColumnVisibility.ts +92 -0
  63. package/src/components/unified-table/hooks/useFilters.ts +53 -0
  64. package/src/components/unified-table/hooks/usePagination.ts +120 -0
  65. package/src/components/unified-table/hooks/useResponsive.ts +50 -0
  66. package/src/components/unified-table/hooks/useSelection.ts +152 -0
  67. package/src/components/unified-table/hooks/useTableKeyboard.ts +206 -0
  68. package/src/components/unified-table/hooks/useTablePreferences.ts +198 -0
  69. package/src/components/unified-table/hooks/useTableState.ts +103 -0
  70. package/src/components/unified-table/hooks/useTableURL.test.tsx +921 -0
  71. package/src/components/unified-table/hooks/useTableURL.ts +301 -0
  72. package/src/components/unified-table/index.ts +16 -0
  73. package/src/components/unified-table/types.ts +393 -0
  74. package/src/components/unified-table/utils/export.ts +236 -0
  75. package/src/components/unified-table/utils/index.ts +4 -0
  76. package/src/components/unified-table/utils/renderers.ts +105 -0
  77. package/src/components/unified-table/utils/themes.ts +87 -0
  78. package/src/components/unified-table/utils/validation.ts +122 -0
  79. package/src/index.ts +6 -0
  80. package/src/lib/utils.ts +1 -0
  81. package/src/theme/contract.ts +46 -0
  82. package/src/theme/index.ts +9 -0
  83. package/src/theme/tailwind.config.js +70 -0
  84. package/src/theme/tailwind.preset.ts +93 -0
  85. package/src/utils/cn.ts +6 -0
  86. 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
+ }