@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,251 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Button } from '../../../ui/button'
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuLabel,
10
+ DropdownMenuSeparator,
11
+ DropdownMenuTrigger,
12
+ } from '../../../ui/dropdown-menu'
13
+ import {
14
+ Dialog,
15
+ DialogContent,
16
+ DialogDescription,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from '../../../ui/dialog'
21
+ import { Input } from '../../../ui/input'
22
+ import { Label } from '../../../ui/label'
23
+ import { Checkbox } from '../../../ui/checkbox'
24
+ import { BookmarkPlus, ChevronDown, Check, Trash2, Star } from 'lucide-react'
25
+ import { SavedView } from '../../types'
26
+ import { cn } from '../../../../lib/utils'
27
+
28
+ export interface SavedViewsDropdownProps {
29
+ views: SavedView[]
30
+ currentViewId?: string | null
31
+ onSaveView: (view: Omit<SavedView, 'id' | 'createdAt'>) => Promise<SavedView>
32
+ onUpdateView?: (viewId: string, updates: Partial<SavedView>) => Promise<void>
33
+ onDeleteView?: (viewId: string) => Promise<void>
34
+ onLoadView: (viewId: string) => void
35
+ // Current state to save
36
+ getCurrentViewState: () => Omit<SavedView, 'id' | 'name' | 'createdAt'>
37
+ }
38
+
39
+ export function SavedViewsDropdown({
40
+ views,
41
+ currentViewId,
42
+ onSaveView,
43
+ onUpdateView,
44
+ onDeleteView,
45
+ onLoadView,
46
+ getCurrentViewState,
47
+ }: SavedViewsDropdownProps) {
48
+ const [isOpen, setIsOpen] = useState(false)
49
+ const [isSaveDialogOpen, setIsSaveDialogOpen] = useState(false)
50
+ const [viewName, setViewName] = useState('')
51
+ const [isDefault, setIsDefault] = useState(false)
52
+ const [isSaving, setIsSaving] = useState(false)
53
+ const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
54
+
55
+ const currentView = views.find(v => v.id === currentViewId)
56
+
57
+ const handleSaveView = async () => {
58
+ if (!viewName.trim()) return
59
+
60
+ setIsSaving(true)
61
+ try {
62
+ const viewState = getCurrentViewState()
63
+ await onSaveView({
64
+ name: viewName.trim(),
65
+ isDefault,
66
+ ...viewState,
67
+ })
68
+ setIsSaveDialogOpen(false)
69
+ setViewName('')
70
+ setIsDefault(false)
71
+ } catch (error) {
72
+ console.error('Failed to save view:', error)
73
+ } finally {
74
+ setIsSaving(false)
75
+ }
76
+ }
77
+
78
+ const handleDeleteView = async (viewId: string) => {
79
+ if (!onDeleteView) return
80
+
81
+ try {
82
+ await onDeleteView(viewId)
83
+ setDeleteConfirmId(null)
84
+ } catch (error) {
85
+ console.error('Failed to delete view:', error)
86
+ }
87
+ }
88
+
89
+ const handleSetDefault = async (viewId: string) => {
90
+ if (!onUpdateView) return
91
+
92
+ try {
93
+ // Remove default from all other views
94
+ for (const view of views) {
95
+ if (view.isDefault && view.id !== viewId) {
96
+ await onUpdateView(view.id, { isDefault: false })
97
+ }
98
+ }
99
+ // Set this view as default
100
+ await onUpdateView(viewId, { isDefault: true })
101
+ } catch (error) {
102
+ console.error('Failed to set default view:', error)
103
+ }
104
+ }
105
+
106
+ return (
107
+ <>
108
+ <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
109
+ <DropdownMenuTrigger asChild>
110
+ <Button variant="outline" size="sm">
111
+ <BookmarkPlus className="mr-2 h-4 w-4" />
112
+ {currentView ? currentView.name : 'Views'}
113
+ <ChevronDown className="ml-2 h-4 w-4" />
114
+ </Button>
115
+ </DropdownMenuTrigger>
116
+ <DropdownMenuContent align="end" className="w-56">
117
+ <DropdownMenuLabel>Saved Views</DropdownMenuLabel>
118
+ <DropdownMenuSeparator />
119
+
120
+ {views.length === 0 ? (
121
+ <DropdownMenuItem disabled>
122
+ No saved views
123
+ </DropdownMenuItem>
124
+ ) : (
125
+ views.map((view) => (
126
+ <DropdownMenuItem
127
+ key={view.id}
128
+ className="flex items-center justify-between cursor-pointer"
129
+ onSelect={(e) => {
130
+ e.preventDefault()
131
+ if (deleteConfirmId === view.id) return
132
+ onLoadView(view.id)
133
+ setIsOpen(false)
134
+ }}
135
+ >
136
+ <div className="flex items-center gap-2">
137
+ {currentViewId === view.id && (
138
+ <Check className="h-4 w-4 text-primary" />
139
+ )}
140
+ {currentViewId !== view.id && <div className="w-4" />}
141
+ <span>{view.name}</span>
142
+ {view.isDefault && (
143
+ <Star className="h-3 w-3 text-yellow-500 fill-yellow-500" />
144
+ )}
145
+ </div>
146
+ <div className="flex items-center gap-1">
147
+ {onUpdateView && !view.isDefault && (
148
+ <button
149
+ onClick={(e) => {
150
+ e.stopPropagation()
151
+ handleSetDefault(view.id)
152
+ }}
153
+ className="p-1 hover:bg-muted rounded opacity-50 hover:opacity-100"
154
+ title="Set as default"
155
+ >
156
+ <Star className="h-3 w-3" />
157
+ </button>
158
+ )}
159
+ {onDeleteView && (
160
+ <button
161
+ onClick={(e) => {
162
+ e.stopPropagation()
163
+ if (deleteConfirmId === view.id) {
164
+ handleDeleteView(view.id)
165
+ } else {
166
+ setDeleteConfirmId(view.id)
167
+ setTimeout(() => setDeleteConfirmId(null), 3000)
168
+ }
169
+ }}
170
+ className={cn(
171
+ 'p-1 hover:bg-muted rounded',
172
+ deleteConfirmId === view.id
173
+ ? 'text-red-600 hover:text-red-700'
174
+ : 'opacity-50 hover:opacity-100'
175
+ )}
176
+ title={deleteConfirmId === view.id ? 'Click again to confirm' : 'Delete view'}
177
+ >
178
+ <Trash2 className="h-3 w-3" />
179
+ </button>
180
+ )}
181
+ </div>
182
+ </DropdownMenuItem>
183
+ ))
184
+ )}
185
+
186
+ <DropdownMenuSeparator />
187
+ <DropdownMenuItem
188
+ onSelect={(e) => {
189
+ e.preventDefault()
190
+ setIsSaveDialogOpen(true)
191
+ setIsOpen(false)
192
+ }}
193
+ >
194
+ <BookmarkPlus className="mr-2 h-4 w-4" />
195
+ Save Current View
196
+ </DropdownMenuItem>
197
+ </DropdownMenuContent>
198
+ </DropdownMenu>
199
+
200
+ <Dialog open={isSaveDialogOpen} onOpenChange={setIsSaveDialogOpen}>
201
+ <DialogContent className="sm:max-w-[425px]">
202
+ <DialogHeader>
203
+ <DialogTitle>Save View</DialogTitle>
204
+ <DialogDescription>
205
+ Save your current table configuration as a named view.
206
+ </DialogDescription>
207
+ </DialogHeader>
208
+ <div className="grid gap-4 py-4">
209
+ <div className="grid gap-2">
210
+ <Label htmlFor="view-name">View Name</Label>
211
+ <Input
212
+ id="view-name"
213
+ value={viewName}
214
+ onChange={(e) => setViewName(e.target.value)}
215
+ placeholder="My Custom View"
216
+ onKeyDown={(e) => {
217
+ if (e.key === 'Enter') handleSaveView()
218
+ }}
219
+ />
220
+ </div>
221
+ <div className="flex items-center gap-2">
222
+ <Checkbox
223
+ id="is-default"
224
+ checked={isDefault}
225
+ onCheckedChange={(checked) => setIsDefault(checked === true)}
226
+ />
227
+ <Label htmlFor="is-default" className="cursor-pointer">
228
+ Set as default view
229
+ </Label>
230
+ </div>
231
+ </div>
232
+ <DialogFooter>
233
+ <Button
234
+ variant="outline"
235
+ onClick={() => setIsSaveDialogOpen(false)}
236
+ disabled={isSaving}
237
+ >
238
+ Cancel
239
+ </Button>
240
+ <Button
241
+ onClick={handleSaveView}
242
+ disabled={!viewName.trim() || isSaving}
243
+ >
244
+ {isSaving ? 'Saving...' : 'Save View'}
245
+ </Button>
246
+ </DialogFooter>
247
+ </DialogContent>
248
+ </Dialog>
249
+ </>
250
+ )
251
+ }
@@ -0,0 +1,146 @@
1
+ 'use client'
2
+
3
+ import { Input } from '../../../ui/input'
4
+ import { Button } from '../../../ui/button'
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuCheckboxItem,
9
+ DropdownMenuTrigger,
10
+ } from '../../../ui/dropdown-menu'
11
+ import { Columns3, ChevronDown } from 'lucide-react'
12
+ import { cn } from '../../../../lib/utils'
13
+ import { ExportButton, ExportButtonProps } from './ExportButton'
14
+ import { SavedViewsDropdown, SavedViewsDropdownProps } from './SavedViewsDropdown'
15
+ import { ColumnConfig, ColumnVisibilityState, SavedView } from '../../types'
16
+ import { RefObject } from 'react'
17
+
18
+ export interface StandardTableToolbarProps<TData> {
19
+ // Search
20
+ searchEnabled?: boolean
21
+ searchPlaceholder?: string
22
+ searchValue?: string
23
+ onSearchChange?: (value: string) => void
24
+ searchInputRef?: RefObject<HTMLInputElement>
25
+ searchAutoFocus?: boolean
26
+
27
+ // Column visibility
28
+ columnVisibilityEnabled?: boolean
29
+ hideableColumns?: ColumnConfig<TData>[]
30
+ columnVisibility?: ColumnVisibilityState
31
+ onToggleColumnVisibility?: (columnId: string) => void
32
+
33
+ // Export
34
+ exportEnabled?: boolean
35
+ exportProps?: Omit<ExportButtonProps<TData>, 'data' | 'columns'>
36
+ exportData?: TData[]
37
+ exportColumns?: ColumnConfig<TData>[]
38
+
39
+ // Saved views
40
+ savedViewsEnabled?: boolean
41
+ savedViews?: SavedView[]
42
+ currentViewId?: string | null
43
+ savedViewsProps?: Omit<SavedViewsDropdownProps, 'views' | 'currentViewId'>
44
+
45
+ className?: string
46
+ }
47
+
48
+ /**
49
+ * StandardTableToolbar provides a consistent toolbar layout across all tables.
50
+ *
51
+ * Layout (left to right):
52
+ * - Search input (left)
53
+ * - Export button
54
+ * - Columns dropdown (right)
55
+ */
56
+ export function StandardTableToolbar<TData>({
57
+ searchEnabled,
58
+ searchPlaceholder = 'Search...',
59
+ searchValue = '',
60
+ onSearchChange,
61
+ searchInputRef,
62
+ searchAutoFocus,
63
+ columnVisibilityEnabled,
64
+ hideableColumns = [],
65
+ columnVisibility = {},
66
+ onToggleColumnVisibility,
67
+ exportEnabled,
68
+ exportProps,
69
+ exportData = [],
70
+ exportColumns = [],
71
+ savedViewsEnabled,
72
+ savedViews = [],
73
+ currentViewId,
74
+ savedViewsProps,
75
+ className,
76
+ }: StandardTableToolbarProps<TData>) {
77
+ const hasRightSideButtons = exportEnabled || columnVisibilityEnabled || savedViewsEnabled
78
+
79
+ return (
80
+ <div className={cn('flex items-center justify-between gap-4', className)}>
81
+ {/* Left side: Search */}
82
+ <div className="flex items-center gap-3 flex-1">
83
+ {searchEnabled ? (
84
+ <Input
85
+ type="text"
86
+ placeholder={searchPlaceholder}
87
+ value={searchValue}
88
+ onChange={(e) => onSearchChange?.(e.target.value)}
89
+ ref={searchInputRef}
90
+ autoFocus={searchAutoFocus}
91
+ className="max-w-xl w-full"
92
+ />
93
+ ) : (
94
+ <div /> // Spacer
95
+ )}
96
+ </div>
97
+
98
+ {/* Right side: Toolbar buttons */}
99
+ {hasRightSideButtons && (
100
+ <div className="flex items-center gap-2">
101
+ {/* Export button */}
102
+ {exportEnabled && exportProps && (
103
+ <ExportButton
104
+ data={exportData}
105
+ columns={exportColumns}
106
+ {...exportProps}
107
+ />
108
+ )}
109
+
110
+ {/* Column visibility dropdown */}
111
+ {columnVisibilityEnabled && hideableColumns.length > 0 && (
112
+ <DropdownMenu>
113
+ <DropdownMenuTrigger asChild>
114
+ <Button variant="outline" size="sm">
115
+ <Columns3 className="mr-2 h-4 w-4" />
116
+ Columns
117
+ <ChevronDown className="ml-2 h-4 w-4" />
118
+ </Button>
119
+ </DropdownMenuTrigger>
120
+ <DropdownMenuContent align="end" className="w-48">
121
+ {hideableColumns.map((column) => (
122
+ <DropdownMenuCheckboxItem
123
+ key={column.id}
124
+ checked={columnVisibility[column.id] !== false}
125
+ onCheckedChange={() => onToggleColumnVisibility?.(column.id)}
126
+ >
127
+ {typeof column.header === 'string' ? column.header : column.id}
128
+ </DropdownMenuCheckboxItem>
129
+ ))}
130
+ </DropdownMenuContent>
131
+ </DropdownMenu>
132
+ )}
133
+
134
+ {/* Saved views dropdown */}
135
+ {savedViewsEnabled && savedViewsProps && (
136
+ <SavedViewsDropdown
137
+ views={savedViews}
138
+ currentViewId={currentViewId}
139
+ {...savedViewsProps}
140
+ />
141
+ )}
142
+ </div>
143
+ )}
144
+ </div>
145
+ )
146
+ }
@@ -0,0 +1,3 @@
1
+ export { ExportButton, type ExportButtonProps, type ExportScope } from './ExportButton'
2
+ export { StandardTableToolbar, type StandardTableToolbarProps } from './StandardTableToolbar'
3
+ export { SavedViewsDropdown, type SavedViewsDropdownProps } from './SavedViewsDropdown'
@@ -0,0 +1,21 @@
1
+ export { useTableState } from './useTableState'
2
+ export { useSelection } from './useSelection'
3
+ export { usePagination } from './usePagination'
4
+ export { useFilters } from './useFilters'
5
+ export { useResponsive } from './useResponsive'
6
+ export type { ResponsiveBreakpoints, ViewMode } from './useResponsive'
7
+
8
+ export { useColumnVisibility } from './useColumnVisibility'
9
+ export type { UseColumnVisibilityOptions } from './useColumnVisibility'
10
+
11
+ export { useTablePreferences } from './useTablePreferences'
12
+
13
+ export { useTableKeyboard } from './useTableKeyboard'
14
+ export type { UseTableKeyboardProps, UseTableKeyboardReturn } from './useTableKeyboard'
15
+
16
+ export { useTableURL } from './useTableURL'
17
+ export type { UseTableURLConfig, TableURLState, UseTableURLReturn } from './useTableURL'
18
+
19
+ export { useColumnReorder } from './useColumnReorder'
20
+
21
+ export { useColumnResize } from './useColumnResize'
@@ -0,0 +1,90 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useMemo } from 'react'
4
+ import { DropResult } from '@hello-pangea/dnd'
5
+ import { ColumnConfig } from '../types'
6
+
7
+ interface UseColumnReorderOptions<TData> {
8
+ columns: ColumnConfig<TData>[]
9
+ initialOrder?: string[] | null
10
+ enabled?: boolean
11
+ onOrderChange?: (order: string[]) => void
12
+ }
13
+
14
+ interface UseColumnReorderReturn<TData> {
15
+ orderedColumns: ColumnConfig<TData>[]
16
+ columnOrder: string[]
17
+ handleDragEnd: (result: DropResult) => void
18
+ resetOrder: () => void
19
+ }
20
+
21
+ export function useColumnReorder<TData>({
22
+ columns,
23
+ initialOrder = null,
24
+ enabled = true,
25
+ onOrderChange,
26
+ }: UseColumnReorderOptions<TData>): UseColumnReorderReturn<TData> {
27
+ // Initialize order from provided order or default to column IDs
28
+ const defaultOrder = useMemo(() => columns.map(c => c.id), [columns])
29
+
30
+ const [columnOrder, setColumnOrder] = useState<string[]>(() => {
31
+ if (initialOrder && initialOrder.length > 0) {
32
+ // Validate that all columns exist and add any new ones
33
+ const validOrder = initialOrder.filter(id => columns.some(c => c.id === id))
34
+ const newColumns = columns.filter(c => !initialOrder.includes(c.id)).map(c => c.id)
35
+ return [...validOrder, ...newColumns]
36
+ }
37
+ return defaultOrder
38
+ })
39
+
40
+ // Reorder columns based on current order
41
+ const orderedColumns = useMemo(() => {
42
+ if (!enabled) return columns
43
+
44
+ const columnMap = new Map(columns.map(c => [c.id, c]))
45
+ const ordered: ColumnConfig<TData>[] = []
46
+
47
+ // Add columns in order
48
+ for (const id of columnOrder) {
49
+ const column = columnMap.get(id)
50
+ if (column) {
51
+ ordered.push(column)
52
+ }
53
+ }
54
+
55
+ // Add any columns not in the order (new columns)
56
+ for (const column of columns) {
57
+ if (!columnOrder.includes(column.id)) {
58
+ ordered.push(column)
59
+ }
60
+ }
61
+
62
+ return ordered
63
+ }, [columns, columnOrder, enabled])
64
+
65
+ // Handle drag end
66
+ const handleDragEnd = useCallback((result: DropResult) => {
67
+ if (!result.destination) return
68
+ if (result.source.index === result.destination.index) return
69
+
70
+ const newOrder = Array.from(columnOrder)
71
+ const [removed] = newOrder.splice(result.source.index, 1)
72
+ newOrder.splice(result.destination.index, 0, removed)
73
+
74
+ setColumnOrder(newOrder)
75
+ onOrderChange?.(newOrder)
76
+ }, [columnOrder, onOrderChange])
77
+
78
+ // Reset to default order
79
+ const resetOrder = useCallback(() => {
80
+ setColumnOrder(defaultOrder)
81
+ onOrderChange?.(defaultOrder)
82
+ }, [defaultOrder, onOrderChange])
83
+
84
+ return {
85
+ orderedColumns,
86
+ columnOrder,
87
+ handleDragEnd,
88
+ resetOrder,
89
+ }
90
+ }
@@ -0,0 +1,123 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useRef, useEffect } from 'react'
4
+
5
+ interface ColumnWidths {
6
+ [columnId: string]: number
7
+ }
8
+
9
+ interface UseColumnResizeOptions {
10
+ enabled?: boolean
11
+ initialWidths?: ColumnWidths | null
12
+ minWidth?: number
13
+ onWidthChange?: (widths: ColumnWidths) => void
14
+ }
15
+
16
+ interface UseColumnResizeReturn {
17
+ columnWidths: ColumnWidths
18
+ getColumnWidth: (columnId: string, defaultWidth?: number) => number | undefined
19
+ startResize: (columnId: string, startX: number, startWidth: number) => void
20
+ resetWidths: () => void
21
+ isResizing: boolean
22
+ resizingColumn: string | null
23
+ }
24
+
25
+ export function useColumnResize({
26
+ enabled = true,
27
+ initialWidths = null,
28
+ minWidth = 50,
29
+ onWidthChange,
30
+ }: UseColumnResizeOptions = {}): UseColumnResizeReturn {
31
+ const [columnWidths, setColumnWidths] = useState<ColumnWidths>(() => initialWidths ?? {})
32
+ const [resizingColumn, setResizingColumn] = useState<string | null>(null)
33
+ const resizeRef = useRef<{
34
+ columnId: string
35
+ startX: number
36
+ startWidth: number
37
+ } | null>(null)
38
+
39
+ // Store dependencies in refs to avoid useCallback recreation
40
+ const minWidthRef = useRef(minWidth)
41
+ const onWidthChangeRef = useRef(onWidthChange)
42
+ useEffect(() => {
43
+ minWidthRef.current = minWidth
44
+ onWidthChangeRef.current = onWidthChange
45
+ }, [minWidth, onWidthChange])
46
+
47
+ // Get column width (undefined allows CSS default)
48
+ const getColumnWidth = useCallback((columnId: string, defaultWidth?: number): number | undefined => {
49
+ if (!enabled) return defaultWidth
50
+ return columnWidths[columnId] ?? defaultWidth
51
+ }, [enabled, columnWidths])
52
+
53
+ // Start resizing a column
54
+ const startResize = useCallback((columnId: string, startX: number, startWidth: number) => {
55
+ if (!enabled) return
56
+ resizeRef.current = { columnId, startX, startWidth }
57
+ setResizingColumn(columnId)
58
+ }, [enabled])
59
+
60
+ // Handle pointer/mouse move during resize - uses refs to avoid recreating
61
+ const handlePointerMove = useCallback((e: PointerEvent | MouseEvent) => {
62
+ if (!resizeRef.current) return
63
+
64
+ const { columnId, startX, startWidth } = resizeRef.current
65
+ const diff = e.clientX - startX
66
+ const newWidth = Math.max(minWidthRef.current, startWidth + diff)
67
+
68
+ setColumnWidths(prev => ({ ...prev, [columnId]: newWidth }))
69
+ }, [])
70
+
71
+ // Handle pointer/mouse up to end resize - uses refs to avoid recreating
72
+ const handlePointerUp = useCallback(() => {
73
+ if (!resizeRef.current) return
74
+
75
+ resizeRef.current = null
76
+ setResizingColumn(null)
77
+
78
+ // Notify parent of the final width changes
79
+ setColumnWidths(current => {
80
+ onWidthChangeRef.current?.(current)
81
+ return current
82
+ })
83
+ }, [])
84
+
85
+ // Add/remove global pointer/mouse listeners during resize
86
+ useEffect(() => {
87
+ if (resizingColumn) {
88
+ // Add listeners for both pointer and mouse events for cross-browser support
89
+ document.addEventListener('pointermove', handlePointerMove)
90
+ document.addEventListener('pointerup', handlePointerUp)
91
+ document.addEventListener('mousemove', handlePointerMove)
92
+ document.addEventListener('mouseup', handlePointerUp)
93
+ // Prevent text selection during resize
94
+ document.body.style.cursor = 'col-resize'
95
+ document.body.style.userSelect = 'none'
96
+
97
+ return () => {
98
+ // Remove listeners
99
+ document.removeEventListener('pointermove', handlePointerMove)
100
+ document.removeEventListener('pointerup', handlePointerUp)
101
+ document.removeEventListener('mousemove', handlePointerMove)
102
+ document.removeEventListener('mouseup', handlePointerUp)
103
+ document.body.style.cursor = ''
104
+ document.body.style.userSelect = ''
105
+ }
106
+ }
107
+ }, [resizingColumn, handlePointerMove, handlePointerUp])
108
+
109
+ // Reset all widths
110
+ const resetWidths = useCallback(() => {
111
+ setColumnWidths({})
112
+ onWidthChange?.({})
113
+ }, [onWidthChange])
114
+
115
+ return {
116
+ columnWidths,
117
+ getColumnWidth,
118
+ startResize,
119
+ resetWidths,
120
+ isResizing: resizingColumn !== null,
121
+ resizingColumn,
122
+ }
123
+ }