@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,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,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
|
+
}
|