@moontra/moonui-pro 2.3.8 → 2.4.1

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.
@@ -33,18 +33,35 @@ import {
33
33
  Settings,
34
34
  Lock,
35
35
  Sparkles,
36
- Loader2
36
+ Loader2,
37
+ FileDown,
38
+ FileJson,
39
+ FileSpreadsheet
37
40
  } from 'lucide-react'
38
41
  import { cn } from '../../lib/utils'
39
42
  import { useSubscription } from '../../hooks/use-subscription'
40
43
  import { motion, AnimatePresence } from 'framer-motion'
44
+ import { DataTableColumnToggle } from './data-table-column-toggle'
45
+ import { DataTableBulkActions, type BulkAction } from './data-table-bulk-actions'
46
+ import { exportData, type ExportFormat, getVisibleColumns } from './data-table-export'
47
+ import { DataTableFilterDrawer, type FilterCondition } from './data-table-filter-drawer'
48
+ import {
49
+ DropdownMenu,
50
+ DropdownMenuContent,
51
+ DropdownMenuItem,
52
+ DropdownMenuTrigger,
53
+ } from '../ui/dropdown-menu'
41
54
 
42
55
  interface DataTableProps<TData, TValue> {
43
56
  columns: ColumnDef<TData, TValue>[]
44
57
  data: TData[]
45
58
  searchable?: boolean
46
59
  filterable?: boolean
47
- exportable?: boolean
60
+ exportable?: boolean | {
61
+ formats?: ExportFormat[]
62
+ filename?: string
63
+ onExport?: (data: TData[], format: ExportFormat) => void
64
+ }
48
65
  selectable?: boolean
49
66
  pagination?: boolean
50
67
  pageSize?: number
@@ -55,6 +72,7 @@ interface DataTableProps<TData, TValue> {
55
72
  renderSubComponent?: (props: { row: { original: TData; id: string } }) => React.ReactNode
56
73
  expandedRows?: Set<string>
57
74
  onRowExpandChange?: (expandedRows: Set<string>) => void
75
+ bulkActions?: BulkAction<TData>[]
58
76
  // Additional props for compatibility
59
77
  enableSorting?: boolean
60
78
  enableFiltering?: boolean
@@ -116,6 +134,7 @@ export function DataTable<TData, TValue>({
116
134
  renderSubComponent,
117
135
  expandedRows: controlledExpandedRows,
118
136
  onRowExpandChange,
137
+ bulkActions = [],
119
138
  features = {},
120
139
  theme = {},
121
140
  texts = {},
@@ -174,6 +193,7 @@ export function DataTable<TData, TValue>({
174
193
  const [globalFilter, setGlobalFilter] = React.useState('')
175
194
  const [isPaginationLoading, setIsPaginationLoading] = React.useState(false)
176
195
  const [internalExpandedRows, setInternalExpandedRows] = React.useState<Set<string>>(new Set())
196
+ const [filterDrawerOpen, setFilterDrawerOpen] = React.useState(false)
177
197
 
178
198
  // Use controlled or internal expanded state
179
199
  const expandedRows = controlledExpandedRows || internalExpandedRows
@@ -213,6 +233,11 @@ export function DataTable<TData, TValue>({
213
233
  pageSize: actualPageSize,
214
234
  },
215
235
  },
236
+ // Prevent re-renders on state changes
237
+ autoResetAll: false,
238
+ autoResetPageIndex: false,
239
+ autoResetExpanded: false,
240
+ getRowId: (row: TData) => (row as any).id || (row as any).orderId || Math.random().toString(),
216
241
  })
217
242
 
218
243
  React.useEffect(() => {
@@ -224,11 +249,19 @@ export function DataTable<TData, TValue>({
224
249
 
225
250
  // Memoize row model to prevent unnecessary re-renders when only expanded state changes
226
251
  const tableState = table.getState()
227
- const rows = React.useMemo(
228
- () => table.getRowModel().rows,
229
- // Only recalculate when data or filtering/sorting changes, not on expand/collapse
230
- [stableData, tableState.sorting, tableState.columnFilters, tableState.globalFilter, tableState.pagination.pageIndex, tableState.pagination.pageSize]
231
- )
252
+ const rowModel = table.getRowModel()
253
+
254
+ // Use a ref to track if rows actually changed
255
+ const rowsRef = React.useRef(rowModel.rows)
256
+ const rowsChanged = React.useMemo(() => {
257
+ const changed = rowsRef.current !== rowModel.rows
258
+ if (changed) {
259
+ rowsRef.current = rowModel.rows
260
+ }
261
+ return changed
262
+ }, [rowModel.rows])
263
+
264
+ const rows = rowsRef.current
232
265
 
233
266
  // Merge features with defaults
234
267
  const enabledFeatures = {
@@ -241,14 +274,51 @@ export function DataTable<TData, TValue>({
241
274
  export: features.export !== false || exportable,
242
275
  }
243
276
 
244
- const handleExport = () => {
277
+ const handleExport = async (format: ExportFormat) => {
278
+ const selectedRows = table.getFilteredSelectedRowModel().rows
279
+ const dataToExport = selectedRows.length > 0
280
+ ? selectedRows.map(row => row.original)
281
+ : table.getFilteredRowModel().rows.map(row => row.original)
282
+
283
+ // Use custom export handler if provided
284
+ if (typeof exportable === 'object' && exportable.onExport) {
285
+ exportable.onExport(dataToExport, format)
286
+ return
287
+ }
288
+
289
+ // Use legacy onExport if provided
245
290
  if (onExport) {
246
- const selectedRows = table.getFilteredSelectedRowModel().rows
247
- const dataToExport = selectedRows.length > 0
248
- ? selectedRows.map(row => row.original)
249
- : table.getFilteredRowModel().rows.map(row => row.original)
250
291
  onExport(dataToExport)
292
+ return
293
+ }
294
+
295
+ // Default export behavior
296
+ const filename = typeof exportable === 'object' && exportable.filename
297
+ ? exportable.filename
298
+ : 'data-export'
299
+
300
+ const visibleColumns = getVisibleColumns(columns as any, columnVisibility)
301
+
302
+ await exportData(dataToExport as Record<string, any>[], {
303
+ format,
304
+ filename,
305
+ columns: visibleColumns,
306
+ includeHeaders: true
307
+ })
308
+ }
309
+
310
+ // Parse export options
311
+ const exportFormats: ExportFormat[] = React.useMemo(() => {
312
+ if (!exportable) return []
313
+ if (exportable === true) return ['csv', 'json']
314
+ if (typeof exportable === 'object' && exportable.formats) {
315
+ return exportable.formats
251
316
  }
317
+ return ['csv', 'json']
318
+ }, [exportable])
319
+
320
+ const clearRowSelection = () => {
321
+ table.resetRowSelection()
252
322
  }
253
323
 
254
324
  return (
@@ -271,25 +341,66 @@ export function DataTable<TData, TValue>({
271
341
  )}
272
342
 
273
343
  {filterable && (
274
- <Button variant="outline" size="sm">
344
+ <Button
345
+ variant="outline"
346
+ size="sm"
347
+ onClick={() => setFilterDrawerOpen(true)}
348
+ >
275
349
  <span suppressHydrationWarning><Filter className="mr-2 h-4 w-4" /></span>
276
350
  Filters
351
+ {columnFilters.length > 0 && (
352
+ <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground">
353
+ {columnFilters.length}
354
+ </span>
355
+ )}
277
356
  </Button>
278
357
  )}
358
+
359
+ {/* Bulk actions */}
360
+ {selectable && bulkActions.length > 0 && (
361
+ <DataTableBulkActions
362
+ selectedRows={table.getFilteredSelectedRowModel().rows.map(row => row.original)}
363
+ actions={bulkActions}
364
+ onClearSelection={clearRowSelection}
365
+ />
366
+ )}
279
367
  </div>
280
368
 
281
369
  <div className="flex items-center space-x-2">
282
- {exportable && (
283
- <Button variant="outline" size="sm" onClick={handleExport}>
284
- <span suppressHydrationWarning><Download className="mr-2 h-4 w-4" /></span>
285
- Export
286
- </Button>
370
+ {/* Export dropdown */}
371
+ {exportable && exportFormats.length > 0 && (
372
+ <DropdownMenu>
373
+ <DropdownMenuTrigger asChild>
374
+ <Button variant="outline" size="sm">
375
+ <span suppressHydrationWarning><Download className="mr-2 h-4 w-4" /></span>
376
+ Export
377
+ </Button>
378
+ </DropdownMenuTrigger>
379
+ <DropdownMenuContent align="end">
380
+ {exportFormats.includes('csv') && (
381
+ <DropdownMenuItem onClick={() => handleExport('csv')}>
382
+ <FileSpreadsheet className="mr-2 h-4 w-4" />
383
+ Export as CSV
384
+ </DropdownMenuItem>
385
+ )}
386
+ {exportFormats.includes('json') && (
387
+ <DropdownMenuItem onClick={() => handleExport('json')}>
388
+ <FileJson className="mr-2 h-4 w-4" />
389
+ Export as JSON
390
+ </DropdownMenuItem>
391
+ )}
392
+ {exportFormats.includes('xlsx') && (
393
+ <DropdownMenuItem onClick={() => handleExport('xlsx')}>
394
+ <FileDown className="mr-2 h-4 w-4" />
395
+ Export as Excel
396
+ </DropdownMenuItem>
397
+ )}
398
+ </DropdownMenuContent>
399
+ </DropdownMenu>
287
400
  )}
288
401
 
289
- <Button variant="outline" size="sm">
290
- <span suppressHydrationWarning><Settings className="mr-2 h-4 w-4" /></span>
291
- Columns
292
- </Button>
402
+ {/* Column visibility toggle */}
403
+ <DataTableColumnToggle table={table} />
293
404
  </div>
294
405
  </div>
295
406
 
@@ -355,71 +466,14 @@ export function DataTable<TData, TValue>({
355
466
  const isExpanded = enableExpandable && expandedRows.has(rowId)
356
467
 
357
468
  return (
358
- <React.Fragment key={rowId}>
359
- <tr
360
- className={cn(
361
- "border-b transition-colors hover:bg-muted/50",
362
- row.getIsSelected() && "bg-muted",
363
- isExpanded && "border-b-0"
364
- )}
365
- >
366
- {row.getVisibleCells().map((cell) => (
367
- <td key={cell.id} className="moonui-data-table-td p-4 align-middle">
368
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
369
- </td>
370
- ))}
371
- </tr>
372
-
373
- <AnimatePresence initial={false}>
374
- {isExpanded && renderSubComponent && (
375
- <motion.tr
376
- key={`${row.id}-expanded`}
377
- initial={{ height: 0, opacity: 0 }}
378
- animate={{
379
- height: "auto",
380
- opacity: 1,
381
- transition: {
382
- height: {
383
- duration: 0.3,
384
- ease: "easeOut"
385
- },
386
- opacity: {
387
- duration: 0.2,
388
- delay: 0.1
389
- }
390
- }
391
- }}
392
- exit={{
393
- height: 0,
394
- opacity: 0,
395
- transition: {
396
- height: {
397
- duration: 0.3,
398
- ease: "easeIn"
399
- },
400
- opacity: {
401
- duration: 0.2
402
- }
403
- }
404
- }}
405
- style={{ overflow: "hidden" }}
406
- className="border-b"
407
- >
408
- <td colSpan={columns.length} className="p-0">
409
- <motion.div
410
- initial={{ y: -10 }}
411
- animate={{ y: 0 }}
412
- exit={{ y: -10 }}
413
- transition={{ duration: 0.2 }}
414
- className="border-t border-border/50"
415
- >
416
- {renderSubComponent({ row: { original: row.original, id: rowId } })}
417
- </motion.div>
418
- </td>
419
- </motion.tr>
420
- )}
421
- </AnimatePresence>
422
- </React.Fragment>
469
+ <TableRow
470
+ key={rowId}
471
+ row={row}
472
+ columns={columns}
473
+ isExpanded={isExpanded}
474
+ enableExpandable={enableExpandable}
475
+ renderSubComponent={renderSubComponent}
476
+ />
423
477
  );
424
478
  })}
425
479
  </>
@@ -543,6 +597,15 @@ export function DataTable<TData, TValue>({
543
597
  </div>
544
598
  </div>
545
599
  )}
600
+
601
+ {/* Filter Drawer */}
602
+ {filterable && (
603
+ <DataTableFilterDrawer
604
+ table={table}
605
+ open={filterDrawerOpen}
606
+ onOpenChange={setFilterDrawerOpen}
607
+ />
608
+ )}
546
609
  </div>
547
610
  )
548
611
  }
@@ -624,5 +687,73 @@ export function useExpandableRows(initialExpanded: Set<string> = new Set()) {
624
687
  };
625
688
  }
626
689
 
690
+ // Memoized table row component
691
+ interface TableRowProps {
692
+ row: Row<any>
693
+ columns: ColumnDef<any, any>[]
694
+ isExpanded: boolean
695
+ enableExpandable: boolean
696
+ renderSubComponent?: (props: { row: { original: any; id: string } }) => React.ReactNode
697
+ }
698
+
699
+ const TableRow = React.memo(({
700
+ row,
701
+ columns,
702
+ isExpanded,
703
+ enableExpandable,
704
+ renderSubComponent
705
+ }: TableRowProps) => {
706
+ const rowId = (row.original as any).id || row.id
707
+
708
+ return (
709
+ <>
710
+ <tr
711
+ className={cn(
712
+ "border-b transition-colors hover:bg-muted/50",
713
+ row.getIsSelected() && "bg-muted",
714
+ isExpanded && "border-b-0"
715
+ )}
716
+ >
717
+ {row.getVisibleCells().map((cell) => (
718
+ <td key={cell.id} className="moonui-data-table-td p-4 align-middle">
719
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
720
+ </td>
721
+ ))}
722
+ </tr>
723
+
724
+ {isExpanded && renderSubComponent && (
725
+ <tr className="border-b">
726
+ <td colSpan={columns.length} className="p-0 overflow-hidden">
727
+ <div
728
+ className="transition-all duration-300 ease-out"
729
+ style={{
730
+ maxHeight: isExpanded ? '1000px' : '0',
731
+ opacity: isExpanded ? 1 : 0,
732
+ }}
733
+ >
734
+ <div className="border-t border-border/50">
735
+ {renderSubComponent({ row: { original: row.original, id: rowId } })}
736
+ </div>
737
+ </div>
738
+ </td>
739
+ </tr>
740
+ )}
741
+ </>
742
+ )
743
+ }, (prevProps, nextProps) => {
744
+ // Custom comparison - only re-render if row data or expanded state changed
745
+ const prevRowId = (prevProps.row.original as any).id || prevProps.row.id
746
+ const nextRowId = (nextProps.row.original as any).id || nextProps.row.id
747
+
748
+ return prevRowId === nextRowId &&
749
+ prevProps.isExpanded === nextProps.isExpanded &&
750
+ prevProps.row.getIsSelected() === nextProps.row.getIsSelected()
751
+ })
752
+
753
+ TableRow.displayName = 'TableRow'
754
+
627
755
  // Re-export types for convenience
628
756
  export { type ColumnDef } from "@tanstack/react-table";
757
+ export type { BulkAction } from './data-table-bulk-actions';
758
+ export type { ExportFormat } from './data-table-export';
759
+ export type { FilterCondition, FilterOperator } from './data-table-filter-drawer';
@@ -0,0 +1,141 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5
+
6
+ import { cn } from "../../lib/utils"
7
+ import { buttonVariants } from "./button"
8
+
9
+ const AlertDialog = AlertDialogPrimitive.Root
10
+
11
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12
+
13
+ const AlertDialogPortal = AlertDialogPrimitive.Portal
14
+
15
+ const AlertDialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <AlertDialogPrimitive.Overlay
20
+ className={cn(
21
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ className
23
+ )}
24
+ {...props}
25
+ ref={ref}
26
+ />
27
+ ))
28
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29
+
30
+ const AlertDialogContent = React.forwardRef<
31
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
33
+ >(({ className, ...props }, ref) => (
34
+ <AlertDialogPortal>
35
+ <AlertDialogOverlay />
36
+ <AlertDialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ </AlertDialogPortal>
45
+ ))
46
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47
+
48
+ const AlertDialogHeader = ({
49
+ className,
50
+ ...props
51
+ }: React.HTMLAttributes<HTMLDivElement>) => (
52
+ <div
53
+ className={cn(
54
+ "flex flex-col space-y-2 text-center sm:text-left",
55
+ className
56
+ )}
57
+ {...props}
58
+ />
59
+ )
60
+ AlertDialogHeader.displayName = "AlertDialogHeader"
61
+
62
+ const AlertDialogFooter = ({
63
+ className,
64
+ ...props
65
+ }: React.HTMLAttributes<HTMLDivElement>) => (
66
+ <div
67
+ className={cn(
68
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ )
74
+ AlertDialogFooter.displayName = "AlertDialogFooter"
75
+
76
+ const AlertDialogTitle = React.forwardRef<
77
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
78
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
79
+ >(({ className, ...props }, ref) => (
80
+ <AlertDialogPrimitive.Title
81
+ ref={ref}
82
+ className={cn("text-lg font-semibold", className)}
83
+ {...props}
84
+ />
85
+ ))
86
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87
+
88
+ const AlertDialogDescription = React.forwardRef<
89
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
90
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
91
+ >(({ className, ...props }, ref) => (
92
+ <AlertDialogPrimitive.Description
93
+ ref={ref}
94
+ className={cn("text-sm text-muted-foreground", className)}
95
+ {...props}
96
+ />
97
+ ))
98
+ AlertDialogDescription.displayName =
99
+ AlertDialogPrimitive.Description.displayName
100
+
101
+ const AlertDialogAction = React.forwardRef<
102
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
103
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
104
+ >(({ className, ...props }, ref) => (
105
+ <AlertDialogPrimitive.Action
106
+ ref={ref}
107
+ className={cn(buttonVariants(), className)}
108
+ {...props}
109
+ />
110
+ ))
111
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112
+
113
+ const AlertDialogCancel = React.forwardRef<
114
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
115
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
116
+ >(({ className, ...props }, ref) => (
117
+ <AlertDialogPrimitive.Cancel
118
+ ref={ref}
119
+ className={cn(
120
+ buttonVariants({ variant: "outline" }),
121
+ "mt-2 sm:mt-0",
122
+ className
123
+ )}
124
+ {...props}
125
+ />
126
+ ))
127
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128
+
129
+ export {
130
+ AlertDialog,
131
+ AlertDialogPortal,
132
+ AlertDialogOverlay,
133
+ AlertDialogTrigger,
134
+ AlertDialogContent,
135
+ AlertDialogHeader,
136
+ AlertDialogFooter,
137
+ AlertDialogTitle,
138
+ AlertDialogDescription,
139
+ AlertDialogAction,
140
+ AlertDialogCancel,
141
+ }