@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,298 @@
1
+ 'use client'
2
+
3
+ import { Button } from '../../../ui/button'
4
+ import { Badge } from '../../../ui/badge'
5
+ import { Input } from '../../../ui/input'
6
+ import { Label } from '../../../ui/label'
7
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
8
+ import { Filter, XCircle, ChevronDown, ChevronUp } from 'lucide-react'
9
+ import { FilterConfig, FilterSection, UseFiltersReturn } from '../../types'
10
+ import { cn } from '../../../../lib/utils'
11
+ import { useState, useCallback } from 'react'
12
+
13
+ interface TableFiltersProps {
14
+ config: FilterConfig
15
+ filters: UseFiltersReturn
16
+ className?: string
17
+ defaultExpanded?: boolean
18
+ }
19
+
20
+ export function TableFilters({ config, filters, className, defaultExpanded = false }: TableFiltersProps) {
21
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded)
22
+ const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
23
+
24
+ const toggleSection = (sectionId: string) => {
25
+ setExpandedSections((prev) => {
26
+ const next = new Set(prev)
27
+ if (next.has(sectionId)) {
28
+ next.delete(sectionId)
29
+ } else {
30
+ next.add(sectionId)
31
+ }
32
+ return next
33
+ })
34
+ }
35
+
36
+ const renderChipsFilter = (section: FilterConfig['sections'][0]) => {
37
+ if (section.type !== 'chips' || !section.filters) return null
38
+
39
+ return (
40
+ <div className="flex flex-wrap items-center gap-3">
41
+ {section.filters.map((filter) => {
42
+ if (!filter.options) return null
43
+
44
+ return (
45
+ <div key={filter.id} className="flex items-center gap-2">
46
+ <Label className="text-sm text-muted-foreground whitespace-nowrap">
47
+ {filter.label}:
48
+ </Label>
49
+ <div className="flex gap-1">
50
+ {filter.options.map((option) => {
51
+ const isActive = filters.filters[filter.id] === option
52
+
53
+ return (
54
+ <Button
55
+ key={option}
56
+ variant={isActive ? 'default' : 'outline'}
57
+ size="sm"
58
+ onClick={() => {
59
+ if (isActive) {
60
+ filters.clearFilter(filter.id)
61
+ } else {
62
+ filters.setFilter(filter.id, option)
63
+ }
64
+ }}
65
+ className="h-7 px-2 text-xs"
66
+ >
67
+ {option}
68
+ </Button>
69
+ )
70
+ })}
71
+ </div>
72
+ </div>
73
+ )
74
+ })}
75
+ </div>
76
+ )
77
+ }
78
+
79
+ const renderBucketsFilter = (section: FilterConfig['sections'][0]) => {
80
+ if (section.type !== 'buckets' || !section.buckets) return null
81
+
82
+ const currentValue = filters.filters[section.id] || 'all'
83
+
84
+ return (
85
+ <div className="flex items-center gap-1.5">
86
+ <Label htmlFor={section.id} className="text-xs text-muted-foreground whitespace-nowrap">
87
+ {section.label}:
88
+ </Label>
89
+ <Select value={currentValue} onValueChange={(value) => filters.setFilter(section.id, value)}>
90
+ <SelectTrigger id={section.id} className="w-32 h-8 text-xs">
91
+ <SelectValue placeholder="Select range" />
92
+ </SelectTrigger>
93
+ <SelectContent>
94
+ {section.buckets.map((bucket, idx) => (
95
+ <SelectItem key={idx} value={bucket.label.toLowerCase().replace(/\s+/g, '-')}>
96
+ {bucket.label}
97
+ </SelectItem>
98
+ ))}
99
+ </SelectContent>
100
+ </Select>
101
+ </div>
102
+ )
103
+ }
104
+
105
+ const renderDropdownFilter = (filter: NonNullable<FilterConfig['sections'][0]['filters']>[0]) => {
106
+ if (!filter.options) return null
107
+
108
+ const currentValue = filters.filters[filter.id] || 'all'
109
+ // Filter out 'all' from options since we add it explicitly below
110
+ const filteredOptions = filter.options.filter((option: string) => option.toLowerCase() !== 'all')
111
+
112
+ return (
113
+ <div key={filter.id} className="flex items-center gap-1.5">
114
+ <Label htmlFor={filter.id} className="text-xs text-muted-foreground whitespace-nowrap">
115
+ {filter.label}:
116
+ </Label>
117
+ <Select value={currentValue} onValueChange={(value) => filters.setFilter(filter.id, value)}>
118
+ <SelectTrigger id={filter.id} className="w-28 h-8 text-xs">
119
+ <SelectValue placeholder="All" />
120
+ </SelectTrigger>
121
+ <SelectContent>
122
+ <SelectItem value="all">All</SelectItem>
123
+ {filteredOptions.map((option: string) => (
124
+ <SelectItem key={option} value={option}>
125
+ {option === 'null' ? 'Not Set' : option}
126
+ </SelectItem>
127
+ ))}
128
+ </SelectContent>
129
+ </Select>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ const renderSearchFilter = (filter: NonNullable<FilterConfig['sections'][0]['filters']>[0]) => {
135
+ const currentValue = filters.filters[filter.id] || ''
136
+
137
+ return (
138
+ <div key={filter.id} className="flex items-center gap-1.5">
139
+ <Label htmlFor={filter.id} className="text-xs text-muted-foreground whitespace-nowrap">
140
+ {filter.label}:
141
+ </Label>
142
+ <Input
143
+ id={filter.id}
144
+ type="text"
145
+ placeholder={filter.label}
146
+ value={currentValue}
147
+ onChange={(e) => filters.setFilter(filter.id, e.target.value)}
148
+ className="w-32 h-8 text-xs"
149
+ />
150
+ </div>
151
+ )
152
+ }
153
+
154
+ const renderCollapsibleSection = (section: FilterConfig['sections'][0]) => {
155
+ if (section.type !== 'collapsible' || !section.filters) return null
156
+
157
+ const isExpanded = expandedSections.has(section.id)
158
+
159
+ return (
160
+ <div key={section.id} className="border rounded-lg p-3">
161
+ <Button
162
+ variant="ghost"
163
+ size="sm"
164
+ onClick={() => toggleSection(section.id)}
165
+ className="w-full justify-between -ml-2"
166
+ >
167
+ <span className="font-medium">{section.label}</span>
168
+ {isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
169
+ </Button>
170
+
171
+ {isExpanded && (
172
+ <div className="mt-3 space-y-3">
173
+ {section.filters.map((filter) => {
174
+ if (filter.type === 'dropdown') {
175
+ return renderDropdownFilter(filter)
176
+ }
177
+ if (filter.type === 'search') {
178
+ return renderSearchFilter(filter)
179
+ }
180
+ return null
181
+ })}
182
+ </div>
183
+ )}
184
+ </div>
185
+ )
186
+ }
187
+
188
+ // Render custom filter using the provided render function
189
+ const renderCustomFilter = useCallback((section: FilterSection) => {
190
+ if (section.type !== 'custom' || !section.customFilter) return null
191
+
192
+ const currentValue = filters.filters[section.id] ?? section.customFilter.defaultValue
193
+ const onChange = (value: any) => {
194
+ if (value === section.customFilter?.defaultValue || value === undefined || value === null) {
195
+ filters.clearFilter(section.id)
196
+ } else {
197
+ filters.setFilter(section.id, value)
198
+ }
199
+ }
200
+
201
+ return (
202
+ <div key={section.id} className="flex items-center gap-1.5">
203
+ {section.label && (
204
+ <Label className="text-xs text-muted-foreground whitespace-nowrap">
205
+ {section.label}:
206
+ </Label>
207
+ )}
208
+ {section.customFilter.render(currentValue, onChange)}
209
+ </div>
210
+ )
211
+ }, [filters])
212
+
213
+ const hasActiveFilters = filters.hasActiveFilters()
214
+ const activeFilterCount = filters.getActiveFilterCount()
215
+
216
+ return (
217
+ <div className={cn('rounded-lg border bg-muted/30', className)}>
218
+ {/* Collapsible Header */}
219
+ <button
220
+ type="button"
221
+ onClick={() => setIsExpanded(!isExpanded)}
222
+ className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
223
+ >
224
+ <div className="flex items-center gap-2">
225
+ <Filter className="h-4 w-4 text-muted-foreground" />
226
+ <span className="text-sm font-medium">Filters</span>
227
+ {hasActiveFilters && (
228
+ <Badge variant="secondary" className="text-xs">
229
+ {activeFilterCount}
230
+ </Badge>
231
+ )}
232
+ </div>
233
+
234
+ <div className="flex items-center gap-2">
235
+ {hasActiveFilters && (
236
+ <Button
237
+ variant="ghost"
238
+ size="sm"
239
+ onClick={(e) => {
240
+ e.stopPropagation()
241
+ filters.clearAllFilters()
242
+ }}
243
+ className="gap-1 h-6 px-2 text-xs"
244
+ >
245
+ <XCircle className="h-3 w-3" />
246
+ Clear
247
+ </Button>
248
+ )}
249
+ {isExpanded ? (
250
+ <ChevronUp className="h-4 w-4 text-muted-foreground" />
251
+ ) : (
252
+ <ChevronDown className="h-4 w-4 text-muted-foreground" />
253
+ )}
254
+ </div>
255
+ </button>
256
+
257
+ {/* Expanded filter content */}
258
+ {isExpanded && (
259
+ <div className="px-3 pb-3 pt-1">
260
+ {/* Compact horizontal layout for all filters */}
261
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
262
+ {config.sections.map((section) => {
263
+ if (section.type === 'chips') {
264
+ return <div key={section.id}>{renderChipsFilter(section)}</div>
265
+ }
266
+
267
+ if (section.type === 'buckets') {
268
+ return <div key={section.id}>{renderBucketsFilter(section)}</div>
269
+ }
270
+
271
+ if (section.type === 'collapsible') {
272
+ return renderCollapsibleSection(section)
273
+ }
274
+
275
+ if (section.type === 'dropdown' && section.filters) {
276
+ return section.filters.map((filter) => (
277
+ <div key={filter.id}>{renderDropdownFilter(filter)}</div>
278
+ ))
279
+ }
280
+
281
+ if (section.type === 'search' && section.filters) {
282
+ return section.filters.map((filter) => (
283
+ <div key={filter.id}>{renderSearchFilter(filter)}</div>
284
+ ))
285
+ }
286
+
287
+ if (section.type === 'custom') {
288
+ return <div key={section.id}>{renderCustomFilter(section)}</div>
289
+ }
290
+
291
+ return null
292
+ })}
293
+ </div>
294
+ </div>
295
+ )}
296
+ </div>
297
+ )
298
+ }
@@ -0,0 +1,157 @@
1
+ 'use client'
2
+
3
+ import { Button } from '../../../ui/button'
4
+ import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react'
5
+ import { UsePaginationReturn } from '../../types'
6
+
7
+ interface TablePaginationProps {
8
+ pagination: UsePaginationReturn
9
+ showSelectAllPrompt?: boolean
10
+ onSelectAllPages?: () => void
11
+ totalFilteredCount?: number
12
+ className?: string
13
+ }
14
+
15
+ export function TablePagination({
16
+ pagination,
17
+ showSelectAllPrompt,
18
+ onSelectAllPages,
19
+ totalFilteredCount,
20
+ className,
21
+ }: TablePaginationProps) {
22
+ const {
23
+ currentPage,
24
+ totalPages,
25
+ totalCount,
26
+ canGoNext,
27
+ canGoPrevious,
28
+ goToPage,
29
+ goToFirstPage,
30
+ goToLastPage,
31
+ goToNextPage,
32
+ goToPreviousPage,
33
+ getPageNumbers,
34
+ getDisplayRange,
35
+ } = pagination
36
+
37
+ const { start, end } = getDisplayRange()
38
+ const pageNumbers = getPageNumbers()
39
+
40
+ return (
41
+ <div className={className}>
42
+ <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
43
+ {/* Item count display */}
44
+ <div className="text-sm text-muted-foreground">
45
+ Showing <span className="font-medium">{start}-{end}</span> of{' '}
46
+ <span className="font-medium">{totalCount}</span>
47
+ {totalFilteredCount && totalFilteredCount !== totalCount && (
48
+ <span> filtered items</span>
49
+ )}
50
+ </div>
51
+
52
+ {/* Page controls */}
53
+ {totalPages > 1 && (
54
+ <div className="flex items-center gap-2">
55
+ <span className="text-sm text-muted-foreground hidden sm:inline">
56
+ Page {currentPage} of {totalPages}
57
+ </span>
58
+
59
+ <div className="flex gap-1">
60
+ {/* First page */}
61
+ <Button
62
+ variant="outline"
63
+ size="sm"
64
+ onClick={goToFirstPage}
65
+ disabled={!canGoPrevious}
66
+ className="h-8 w-8 p-0"
67
+ title="First page"
68
+ >
69
+ <ChevronsLeft className="h-4 w-4" />
70
+ </Button>
71
+
72
+ {/* Previous page */}
73
+ <Button
74
+ variant="outline"
75
+ size="sm"
76
+ onClick={goToPreviousPage}
77
+ disabled={!canGoPrevious}
78
+ className="h-8 w-8 p-0"
79
+ title="Previous page"
80
+ >
81
+ <ChevronLeft className="h-4 w-4" />
82
+ </Button>
83
+
84
+ {/* Page numbers */}
85
+ {pageNumbers.map((pageNum, idx) => {
86
+ if (pageNum === '...') {
87
+ return (
88
+ <span
89
+ key={`ellipsis-${idx}`}
90
+ className="flex items-center px-2 text-muted-foreground"
91
+ >
92
+ •••
93
+ </span>
94
+ )
95
+ }
96
+
97
+ const page = pageNum as number
98
+ return (
99
+ <Button
100
+ key={page}
101
+ variant={page === currentPage ? 'default' : 'outline'}
102
+ size="sm"
103
+ onClick={() => goToPage(page)}
104
+ className="h-8 w-8 p-0"
105
+ >
106
+ {page}
107
+ </Button>
108
+ )
109
+ })}
110
+
111
+ {/* Next page */}
112
+ <Button
113
+ variant="outline"
114
+ size="sm"
115
+ onClick={goToNextPage}
116
+ disabled={!canGoNext}
117
+ className="h-8 w-8 p-0"
118
+ title="Next page"
119
+ >
120
+ <ChevronRight className="h-4 w-4" />
121
+ </Button>
122
+
123
+ {/* Last page */}
124
+ <Button
125
+ variant="outline"
126
+ size="sm"
127
+ onClick={goToLastPage}
128
+ disabled={!canGoNext}
129
+ className="h-8 w-8 p-0"
130
+ title="Last page"
131
+ >
132
+ <ChevronsRight className="h-4 w-4" />
133
+ </Button>
134
+ </div>
135
+ </div>
136
+ )}
137
+ </div>
138
+
139
+ {/* Select all pages prompt */}
140
+ {showSelectAllPrompt && onSelectAllPages && totalFilteredCount && totalFilteredCount > end && (
141
+ <div className="mt-3 flex items-center justify-center p-3 bg-blue-50 border border-blue-200 rounded-lg">
142
+ <span className="text-sm text-blue-900 mr-2">
143
+ Select all <span className="font-semibold">{totalFilteredCount}</span> items?
144
+ </span>
145
+ <Button
146
+ size="sm"
147
+ variant="link"
148
+ onClick={onSelectAllPages}
149
+ className="text-blue-700 hover:text-blue-900 font-semibold p-0 h-auto"
150
+ >
151
+ Select all {totalFilteredCount} items
152
+ </Button>
153
+ </div>
154
+ )}
155
+ </div>
156
+ )
157
+ }
@@ -0,0 +1,229 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback } from 'react'
4
+ import { Button } from '../../../ui/button'
5
+ import {
6
+ DropdownMenu,
7
+ DropdownMenuContent,
8
+ DropdownMenuItem,
9
+ DropdownMenuSeparator,
10
+ DropdownMenuTrigger,
11
+ } from '../../../ui/dropdown-menu'
12
+ import { Download, FileSpreadsheet, FileText, Loader2 } from 'lucide-react'
13
+ import { ColumnConfig } from '../../types'
14
+ import { exportToCSV, exportToExcel, generateExportFilename } from '../../utils/export'
15
+
16
+ export type ExportScope = 'all' | 'filtered' | 'selected'
17
+
18
+ export interface ExportButtonProps<TData> {
19
+ data: TData[]
20
+ filteredData?: TData[]
21
+ selectedData?: TData[]
22
+ columns: ColumnConfig<TData>[]
23
+ baseFilename?: string
24
+ disabled?: boolean
25
+ className?: string
26
+ showProgress?: boolean
27
+ onExportStart?: (format: 'csv' | 'excel', scope: ExportScope) => void
28
+ onExportComplete?: (format: 'csv' | 'excel', scope: ExportScope, rowCount: number) => void
29
+ onExportError?: (error: Error) => void
30
+ }
31
+
32
+ export function ExportButton<TData>({
33
+ data,
34
+ filteredData,
35
+ selectedData,
36
+ columns,
37
+ baseFilename = 'export',
38
+ disabled = false,
39
+ className,
40
+ showProgress = true,
41
+ onExportStart,
42
+ onExportComplete,
43
+ onExportError,
44
+ }: ExportButtonProps<TData>) {
45
+ const [isExporting, setIsExporting] = useState(false)
46
+ const [progress, setProgress] = useState(0)
47
+
48
+ // Determine what export options are available
49
+ const hasFilteredData = filteredData && filteredData.length > 0 && filteredData.length < data.length
50
+ const hasSelectedData = selectedData && selectedData.length > 0
51
+
52
+ const handleExport = useCallback(
53
+ async (format: 'csv' | 'excel', scope: ExportScope) => {
54
+ setIsExporting(true)
55
+ setProgress(0)
56
+
57
+ try {
58
+ onExportStart?.(format, scope)
59
+
60
+ // Determine which data to export
61
+ let dataToExport: TData[]
62
+ let scopeLabel: string
63
+
64
+ switch (scope) {
65
+ case 'selected':
66
+ if (!selectedData || selectedData.length === 0) {
67
+ throw new Error('No rows selected')
68
+ }
69
+ dataToExport = selectedData
70
+ scopeLabel = 'selected'
71
+ break
72
+ case 'filtered':
73
+ if (!filteredData || filteredData.length === 0) {
74
+ throw new Error('No filtered data available')
75
+ }
76
+ dataToExport = filteredData
77
+ scopeLabel = 'filtered'
78
+ break
79
+ case 'all':
80
+ default:
81
+ dataToExport = data
82
+ scopeLabel = 'all'
83
+ break
84
+ }
85
+
86
+ // Simulate progress for large datasets
87
+ if (showProgress && dataToExport.length > 100) {
88
+ setProgress(25)
89
+ await new Promise(resolve => setTimeout(resolve, 100))
90
+ setProgress(50)
91
+ }
92
+
93
+ // Generate filename with timestamp and scope
94
+ const filename = `${baseFilename}_${scopeLabel}_${generateExportFilename('')}`.replace(
95
+ /__/g,
96
+ '_'
97
+ )
98
+
99
+ // Export based on format
100
+ if (format === 'csv') {
101
+ exportToCSV(dataToExport, columns, filename)
102
+ } else {
103
+ exportToExcel(dataToExport, columns, filename)
104
+ }
105
+
106
+ if (showProgress) {
107
+ setProgress(100)
108
+ }
109
+
110
+ onExportComplete?.(format, scope, dataToExport.length)
111
+ } catch (error) {
112
+ console.error('Export failed:', error)
113
+ const errorObj = error instanceof Error ? error : new Error('Export failed')
114
+ onExportError?.(errorObj)
115
+ } finally {
116
+ // Reset state after a brief delay to show completion
117
+ if (showProgress) {
118
+ setTimeout(() => {
119
+ setIsExporting(false)
120
+ setProgress(0)
121
+ }, 500)
122
+ } else {
123
+ setIsExporting(false)
124
+ setProgress(0)
125
+ }
126
+ }
127
+ },
128
+ [
129
+ data,
130
+ filteredData,
131
+ selectedData,
132
+ columns,
133
+ baseFilename,
134
+ showProgress,
135
+ onExportStart,
136
+ onExportComplete,
137
+ onExportError,
138
+ ]
139
+ )
140
+
141
+ const isDisabled = disabled || isExporting || data.length === 0
142
+
143
+ return (
144
+ <DropdownMenu>
145
+ <DropdownMenuTrigger asChild>
146
+ <Button
147
+ variant="outline"
148
+ size="sm"
149
+ disabled={isDisabled}
150
+ className={className}
151
+ aria-label="Export data"
152
+ >
153
+ {isExporting ? (
154
+ <>
155
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
156
+ Exporting{showProgress && progress > 0 ? ` ${progress}%` : '...'}
157
+ </>
158
+ ) : (
159
+ <>
160
+ <Download className="mr-2 h-4 w-4" />
161
+ Export
162
+ </>
163
+ )}
164
+ </Button>
165
+ </DropdownMenuTrigger>
166
+ <DropdownMenuContent align="end" className="w-56">
167
+ {/* CSV Export Options */}
168
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">CSV Format</div>
169
+ <DropdownMenuItem
170
+ onClick={() => handleExport('csv', 'all')}
171
+ disabled={isExporting || data.length === 0}
172
+ >
173
+ <FileText className="mr-2 h-4 w-4" />
174
+ Export All ({data.length} rows)
175
+ </DropdownMenuItem>
176
+ {hasFilteredData && (
177
+ <DropdownMenuItem
178
+ onClick={() => handleExport('csv', 'filtered')}
179
+ disabled={isExporting}
180
+ >
181
+ <FileText className="mr-2 h-4 w-4" />
182
+ Export Filtered ({filteredData?.length || 0} rows)
183
+ </DropdownMenuItem>
184
+ )}
185
+ {hasSelectedData && (
186
+ <DropdownMenuItem
187
+ onClick={() => handleExport('csv', 'selected')}
188
+ disabled={isExporting}
189
+ >
190
+ <FileText className="mr-2 h-4 w-4" />
191
+ Export Selected ({selectedData?.length || 0} rows)
192
+ </DropdownMenuItem>
193
+ )}
194
+
195
+ <DropdownMenuSeparator />
196
+
197
+ {/* Excel Export Options */}
198
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
199
+ Excel Format
200
+ </div>
201
+ <DropdownMenuItem
202
+ onClick={() => handleExport('excel', 'all')}
203
+ disabled={isExporting || data.length === 0}
204
+ >
205
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
206
+ Export All ({data.length} rows)
207
+ </DropdownMenuItem>
208
+ {hasFilteredData && (
209
+ <DropdownMenuItem
210
+ onClick={() => handleExport('excel', 'filtered')}
211
+ disabled={isExporting}
212
+ >
213
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
214
+ Export Filtered ({filteredData?.length || 0} rows)
215
+ </DropdownMenuItem>
216
+ )}
217
+ {hasSelectedData && (
218
+ <DropdownMenuItem
219
+ onClick={() => handleExport('excel', 'selected')}
220
+ disabled={isExporting}
221
+ >
222
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
223
+ Export Selected ({selectedData?.length || 0} rows)
224
+ </DropdownMenuItem>
225
+ )}
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ )
229
+ }