@superdangerous/app-framework 4.16.35 → 4.16.37

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.
@@ -1,53 +0,0 @@
1
- import { type ReactNode } from 'react';
2
- import { X } from 'lucide-react';
3
- import { Card } from '../../components/base/card';
4
- import { Button } from '../../components/base/button';
5
- import { cn } from '../../src/utils/cn';
6
-
7
- export interface BatchActionsBarProps {
8
- /** Number of selected items */
9
- selectedCount: number;
10
- /** Callback to clear selection */
11
- onClear: () => void;
12
- /** Action buttons to display on the right side */
13
- children: ReactNode;
14
- /** Label for the selected items (default: "item"/"items") */
15
- itemLabel?: string;
16
- /** Additional CSS classes */
17
- className?: string;
18
- }
19
-
20
- /**
21
- * A horizontal bar that appears when items are selected,
22
- * showing the count and providing batch action buttons.
23
- */
24
- export function BatchActionsBar({
25
- selectedCount,
26
- onClear,
27
- children,
28
- itemLabel,
29
- className,
30
- }: BatchActionsBarProps) {
31
- if (selectedCount === 0) return null;
32
-
33
- const label = itemLabel ?? (selectedCount === 1 ? 'item' : 'items');
34
-
35
- return (
36
- <Card className={cn("p-3 bg-primary/5 border-primary/20", className)}>
37
- <div className="flex items-center justify-between">
38
- <div className="flex items-center gap-3">
39
- <span className="font-medium text-sm" role="status" aria-live="polite">
40
- {selectedCount} {label} selected
41
- </span>
42
- <Button variant="ghost" size="sm" onClick={onClear} aria-label="Clear selection">
43
- <X className="h-4 w-4 mr-1" aria-hidden="true" />
44
- Clear
45
- </Button>
46
- </div>
47
- <div className="flex items-center gap-2" role="group" aria-label="Batch actions">
48
- {children}
49
- </div>
50
- </div>
51
- </Card>
52
- );
53
- }
@@ -1,111 +0,0 @@
1
- import { Columns3, Check, Eye, EyeOff } from 'lucide-react';
2
- import { Button } from '../../components/base/button';
3
- import {
4
- DropdownMenu,
5
- DropdownMenuContent,
6
- DropdownMenuItem,
7
- DropdownMenuLabel,
8
- DropdownMenuSeparator,
9
- DropdownMenuTrigger,
10
- } from '../../components/base/dropdown-menu';
11
- import { cn } from '../../src/utils/cn';
12
- import type { ColumnConfig } from '../hooks/useColumnVisibility';
13
-
14
- interface ColumnVisibilityProps {
15
- columns: ColumnConfig[];
16
- isColumnVisible: (columnId: string) => boolean;
17
- toggleColumn: (columnId: string) => void;
18
- showAllColumns: () => void;
19
- hideAllColumns: () => void;
20
- }
21
-
22
- export function ColumnVisibility({
23
- columns,
24
- isColumnVisible,
25
- toggleColumn,
26
- showAllColumns,
27
- hideAllColumns,
28
- }: ColumnVisibilityProps) {
29
- const visibleCount = columns.filter(c => isColumnVisible(c.id)).length;
30
- const toggleableColumns = columns.filter(c => !c.locked);
31
-
32
- return (
33
- <DropdownMenu>
34
- <DropdownMenuTrigger asChild>
35
- <Button variant="outline" size="sm" className="gap-2">
36
- <Columns3 className="h-4 w-4" />
37
- Columns
38
- <span className="text-muted-foreground text-xs">
39
- ({visibleCount}/{columns.length})
40
- </span>
41
- </Button>
42
- </DropdownMenuTrigger>
43
- <DropdownMenuContent align="end" className="w-48">
44
- <DropdownMenuLabel className="font-normal text-xs text-muted-foreground">
45
- Toggle columns
46
- </DropdownMenuLabel>
47
- <DropdownMenuSeparator />
48
-
49
- {columns.map(column => {
50
- const visible = isColumnVisible(column.id);
51
- const isLocked = column.locked === true;
52
-
53
- return (
54
- <DropdownMenuItem
55
- key={column.id}
56
- onClick={(e) => {
57
- e.preventDefault();
58
- if (!isLocked) {
59
- toggleColumn(column.id);
60
- }
61
- }}
62
- className={cn(
63
- 'gap-2 cursor-pointer',
64
- isLocked && 'opacity-50 cursor-not-allowed'
65
- )}
66
- disabled={isLocked}
67
- >
68
- <div className="w-4 h-4 flex items-center justify-center">
69
- {visible ? (
70
- <Check className="h-3.5 w-3.5 text-primary" />
71
- ) : (
72
- <div className="h-3.5 w-3.5" />
73
- )}
74
- </div>
75
- <span className="flex-1">{column.label}</span>
76
- {isLocked && (
77
- <span className="text-xs text-muted-foreground">Required</span>
78
- )}
79
- </DropdownMenuItem>
80
- );
81
- })}
82
-
83
- {toggleableColumns.length > 1 && (
84
- <>
85
- <DropdownMenuSeparator />
86
- <DropdownMenuItem
87
- onClick={(e) => {
88
- e.preventDefault();
89
- showAllColumns();
90
- }}
91
- className="gap-2 cursor-pointer"
92
- >
93
- <Eye className="h-4 w-4" />
94
- Show All
95
- </DropdownMenuItem>
96
- <DropdownMenuItem
97
- onClick={(e) => {
98
- e.preventDefault();
99
- hideAllColumns();
100
- }}
101
- className="gap-2 cursor-pointer"
102
- >
103
- <EyeOff className="h-4 w-4" />
104
- Hide Optional
105
- </DropdownMenuItem>
106
- </>
107
- )}
108
- </DropdownMenuContent>
109
- </DropdownMenu>
110
- );
111
- }
@@ -1,498 +0,0 @@
1
- import { useMemo, useCallback, useState, useEffect } from 'react';
2
- import {
3
- Table,
4
- TableBody,
5
- TableCell,
6
- TableHead,
7
- TableHeader,
8
- TableRow,
9
- } from '../../components/base/table';
10
- import { TooltipProvider } from '../../components/base/tooltip';
11
- import { ChevronUp, ChevronDown, ArrowUpDown, Loader2, Check, Eye, EyeOff } from 'lucide-react';
12
- import { cn } from '../../src/utils/cn';
13
- import { useResizableColumns } from '../hooks/useResizableColumns';
14
- import { useColumnVisibility } from '../hooks/useColumnVisibility';
15
- import { useColumnOrder, useColumnDragDrop } from '../hooks/useColumnOrder';
16
- import { usePagination } from '../hooks/usePagination';
17
- import { Pagination } from './Pagination';
18
- import type {
19
- DataTableProps,
20
- ColumnDef,
21
- ColumnConfigCompat,
22
- ColumnSizeConfig,
23
- } from './types';
24
-
25
- /**
26
- * DataTable - Generic data table with full feature set
27
- *
28
- * Features:
29
- * - Column resizing (drag handles)
30
- * - Column reordering (drag-drop)
31
- * - Column visibility toggle
32
- * - Row selection with checkboxes
33
- * - Sorting
34
- * - Pagination
35
- * - Sticky actions column
36
- * - Context menu support
37
- * - Header context menu for column visibility
38
- */
39
- export function DataTable<T>({
40
- data,
41
- columns,
42
- storageKey,
43
- getRowId,
44
- selectable = false,
45
- selectedIds,
46
- onSelectionChange,
47
- onRowClick,
48
- onRowContextMenu,
49
- sortField,
50
- sortOrder,
51
- onSort,
52
- actionsColumn,
53
- actionsColumnWidth = 80,
54
- pageSize = 25,
55
- pagination: externalPagination,
56
- hidePagination = false,
57
- className,
58
- rowClassName,
59
- enableHeaderContextMenu = true,
60
- lockedColumns = [],
61
- defaultColumnOrder,
62
- loading = false,
63
- emptyState,
64
- }: DataTableProps<T>) {
65
- // Build column configs for hooks
66
- const columnSizeConfig = useMemo<ColumnSizeConfig[]>(() => {
67
- const configs: ColumnSizeConfig[] = [];
68
-
69
- if (selectable) {
70
- configs.push({ key: 'select', defaultWidth: 40, minWidth: 40 });
71
- }
72
-
73
- columns.forEach((col) => {
74
- configs.push({
75
- key: col.id,
76
- defaultWidth: col.width?.default ?? 150,
77
- minWidth: col.width?.min ?? 80,
78
- maxWidth: col.width?.max,
79
- });
80
- });
81
-
82
- if (actionsColumn) {
83
- configs.push({ key: 'actions', defaultWidth: actionsColumnWidth, minWidth: 60 });
84
- }
85
-
86
- return configs;
87
- }, [columns, selectable, actionsColumn, actionsColumnWidth]);
88
-
89
- const columnVisibilityConfig = useMemo<ColumnConfigCompat[]>(() => {
90
- return columns.map((col) => ({
91
- id: col.id,
92
- label: typeof col.header === 'string' ? col.header : col.id,
93
- defaultVisible: col.visibility?.default ?? true,
94
- locked: col.visibility?.locked,
95
- }));
96
- }, [columns]);
97
-
98
- const defaultOrder = useMemo(() => {
99
- if (defaultColumnOrder) return defaultColumnOrder;
100
- const order: string[] = [];
101
- if (selectable) order.push('select');
102
- columns.forEach((col) => order.push(col.id));
103
- if (actionsColumn) order.push('actions');
104
- return order;
105
- }, [defaultColumnOrder, columns, selectable, actionsColumn]);
106
-
107
- // Initialize hooks
108
- const {
109
- getResizeHandleProps,
110
- getColumnStyle,
111
- getTableStyle,
112
- } = useResizableColumns({
113
- tableId: storageKey,
114
- columns: columnSizeConfig,
115
- });
116
-
117
- const columnVisibility = useColumnVisibility({
118
- columns: columnVisibilityConfig,
119
- storageKey,
120
- });
121
-
122
- const { columnOrder, moveColumn } = useColumnOrder({
123
- storageKey: `${storageKey}-order`,
124
- defaultOrder,
125
- });
126
-
127
- const { dragState, getDragHandleProps, showDropIndicator } = useColumnDragDrop(
128
- columnOrder,
129
- moveColumn,
130
- [...lockedColumns, 'select', 'actions']
131
- );
132
-
133
- // Use external pagination if provided, otherwise use internal
134
- const internalPagination = usePagination({
135
- data,
136
- pageSize,
137
- storageKey,
138
- });
139
-
140
- const pagination = externalPagination || internalPagination;
141
-
142
- // Header context menu state
143
- const [headerContextMenu, setHeaderContextMenu] = useState<{ x: number; y: number } | null>(null);
144
-
145
- const handleHeaderContextMenu = useCallback((e: React.MouseEvent) => {
146
- if (!enableHeaderContextMenu) return;
147
- e.preventDefault();
148
- setHeaderContextMenu({ x: e.clientX, y: e.clientY });
149
- }, [enableHeaderContextMenu]);
150
-
151
- // Close header context menu on click outside
152
- useEffect(() => {
153
- if (!headerContextMenu) return;
154
- const close = () => setHeaderContextMenu(null);
155
- window.addEventListener('click', close);
156
- return () => {
157
- window.removeEventListener('click', close);
158
- };
159
- }, [headerContextMenu]);
160
-
161
- // Selection helpers
162
- const isAllSelected = useMemo(() => {
163
- if (!selectable || !selectedIds || pagination.paginatedData.length === 0) return false;
164
- return pagination.paginatedData.every((item) => selectedIds.has(getRowId(item)));
165
- }, [selectable, selectedIds, pagination.paginatedData, getRowId]);
166
-
167
- const isSomeSelected = useMemo(() => {
168
- if (!selectable || !selectedIds) return false;
169
- const selected = pagination.paginatedData.filter((item) => selectedIds.has(getRowId(item)));
170
- return selected.length > 0 && selected.length < pagination.paginatedData.length;
171
- }, [selectable, selectedIds, pagination.paginatedData, getRowId]);
172
-
173
- const toggleSelection = useCallback((itemId: string) => {
174
- if (!onSelectionChange || !selectedIds) return;
175
- const next = new Set(selectedIds);
176
- if (next.has(itemId)) {
177
- next.delete(itemId);
178
- } else {
179
- next.add(itemId);
180
- }
181
- onSelectionChange(next);
182
- }, [selectedIds, onSelectionChange]);
183
-
184
- const selectAll = useCallback(() => {
185
- if (!onSelectionChange) return;
186
- const ids = new Set(pagination.paginatedData.map(getRowId));
187
- onSelectionChange(ids);
188
- }, [pagination.paginatedData, getRowId, onSelectionChange]);
189
-
190
- const clearSelection = useCallback(() => {
191
- if (!onSelectionChange) return;
192
- onSelectionChange(new Set());
193
- }, [onSelectionChange]);
194
-
195
- // Get sort icon
196
- const getSortIcon = useCallback((field: string) => {
197
- if (sortField !== field) {
198
- return <ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground" />;
199
- }
200
- return sortOrder === 'asc'
201
- ? <ChevronUp className="h-3.5 w-3.5" />
202
- : <ChevronDown className="h-3.5 w-3.5" />;
203
- }, [sortField, sortOrder]);
204
-
205
- // Find column def by id
206
- const getColumnDef = useCallback((id: string): ColumnDef<T> | undefined => {
207
- return columns.find((col) => col.id === id);
208
- }, [columns]);
209
-
210
- // Render header cell content
211
- const renderHeaderContent = useCallback((col: ColumnDef<T>) => {
212
- const headerProps = {
213
- columnId: col.id,
214
- isSorted: sortField === col.sortKey,
215
- sortDirection: sortField === col.sortKey ? sortOrder : undefined,
216
- };
217
-
218
- if (typeof col.header === 'function') {
219
- return col.header(headerProps);
220
- }
221
-
222
- if (col.sortKey && onSort) {
223
- return (
224
- <button
225
- onClick={() => onSort(col.sortKey!)}
226
- className={cn(
227
- 'flex items-center gap-1 hover:text-foreground transition-colors',
228
- sortField === col.sortKey && 'text-foreground font-medium'
229
- )}
230
- >
231
- {col.header}
232
- {getSortIcon(col.sortKey)}
233
- </button>
234
- );
235
- }
236
-
237
- return col.header;
238
- }, [sortField, sortOrder, onSort, getSortIcon]);
239
-
240
- return (
241
- <TooltipProvider>
242
- <>
243
- {/* Table with scroll container and border - supports both horizontal and vertical scroll with sticky header */}
244
- <div className="overflow-auto border rounded-lg h-full">
245
- <Table
246
- style={getTableStyle()}
247
- className={cn('resizable-table sticky-actions-table', className)}
248
- >
249
- <TableHeader className="sticky top-0 z-20 bg-muted">
250
- <TableRow onContextMenu={handleHeaderContextMenu} className="border-t-0">
251
- {columnOrder.map((colKey) => {
252
- // Select column (sticky on left)
253
- if (colKey === 'select' && selectable) {
254
- return (
255
- <TableHead
256
- key="select"
257
- className="sticky-select-header w-10 sticky left-0 z-20 bg-muted relative after:absolute after:right-0 after:top-0 after:bottom-0 after:w-px after:bg-border"
258
- >
259
- <input
260
- type="checkbox"
261
- checked={isAllSelected}
262
- ref={(el) => {
263
- if (el) el.indeterminate = isSomeSelected;
264
- }}
265
- onChange={(e) => e.target.checked ? selectAll() : clearSelection()}
266
- className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
267
- title={isAllSelected ? 'Deselect all' : 'Select all visible'}
268
- />
269
- </TableHead>
270
- );
271
- }
272
-
273
- // Data columns
274
- const col = getColumnDef(colKey);
275
- if (!col) return null;
276
- if (!columnVisibility.isColumnVisible(colKey)) return null;
277
-
278
- return (
279
- <TableHead
280
- key={colKey}
281
- style={getColumnStyle(colKey)}
282
- {...getDragHandleProps(colKey)}
283
- className={cn(
284
- 'cursor-grab relative',
285
- dragState.draggedId === colKey && 'column-dragging opacity-50',
286
- showDropIndicator(colKey) && 'drop-indicator'
287
- )}
288
- >
289
- {renderHeaderContent(col)}
290
- <div {...getResizeHandleProps(colKey)} />
291
- </TableHead>
292
- );
293
- })}
294
-
295
- {/* Actions column header */}
296
- {actionsColumn && (
297
- <TableHead
298
- key="actions"
299
- className="sticky right-0 z-20 bg-muted text-center relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-border"
300
- style={{ width: actionsColumnWidth, minWidth: actionsColumnWidth, maxWidth: actionsColumnWidth }}
301
- >
302
- Actions
303
- </TableHead>
304
- )}
305
- </TableRow>
306
- </TableHeader>
307
-
308
- <TableBody>
309
- {loading ? (
310
- <TableRow>
311
- <TableCell
312
- colSpan={columnOrder.length + (actionsColumn ? 1 : 0)}
313
- className="!p-0 h-32"
314
- >
315
- <div className="sticky left-0 w-screen max-w-full h-full bg-background flex justify-center items-center">
316
- <div className="flex items-center gap-2 text-muted-foreground">
317
- <Loader2 className="h-5 w-5 animate-spin" />
318
- Loading...
319
- </div>
320
- </div>
321
- </TableCell>
322
- </TableRow>
323
- ) : pagination.paginatedData.length === 0 ? null : (
324
- pagination.paginatedData.map((item) => {
325
- const rowId = getRowId(item);
326
- const isSelected = selectedIds?.has(rowId) ?? false;
327
-
328
- return (
329
- <TableRow
330
- key={rowId}
331
- className={cn(
332
- 'group cursor-pointer bg-background hover:bg-muted transition-none',
333
- isSelected && 'bg-primary/5',
334
- rowClassName?.(item)
335
- )}
336
- onClick={() => onRowClick?.(item)}
337
- onContextMenu={(e) => {
338
- if (onRowContextMenu) {
339
- e.preventDefault();
340
- onRowContextMenu(item, { x: e.clientX, y: e.clientY });
341
- }
342
- }}
343
- >
344
- {columnOrder.map((colKey) => {
345
- // Select cell (sticky on left)
346
- if (colKey === 'select' && selectable) {
347
- return (
348
- <TableCell
349
- key="select"
350
- className={cn(
351
- 'sticky-select-cell w-10 sticky left-0 z-10 relative after:absolute after:right-0 after:top-0 after:bottom-0 after:w-px after:bg-border',
352
- 'bg-background group-hover:bg-muted',
353
- isSelected && 'bg-primary/5 group-hover:bg-primary/10'
354
- )}
355
- onClick={(e) => e.stopPropagation()}
356
- >
357
- <input
358
- type="checkbox"
359
- checked={isSelected}
360
- onChange={() => toggleSelection(rowId)}
361
- className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
362
- />
363
- </TableCell>
364
- );
365
- }
366
-
367
- // Data cells
368
- const col = getColumnDef(colKey);
369
- if (!col) return null;
370
- if (!columnVisibility.isColumnVisible(colKey)) return null;
371
-
372
- const cellProps = {
373
- columnId: colKey,
374
- isDragging: dragState.draggedId === colKey,
375
- };
376
-
377
- return (
378
- <TableCell
379
- key={colKey}
380
- style={getColumnStyle(colKey)}
381
- className={cn(
382
- col.className,
383
- dragState.draggedId === colKey && 'column-dragging'
384
- )}
385
- >
386
- {col.cell(item, cellProps)}
387
- </TableCell>
388
- );
389
- })}
390
-
391
- {/* Actions cell */}
392
- {actionsColumn && (
393
- <TableCell
394
- key="actions"
395
- className={cn(
396
- 'sticky right-0 z-10 text-center relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-px before:bg-border',
397
- 'bg-background group-hover:bg-muted',
398
- isSelected && 'bg-primary/5 group-hover:bg-primary/10'
399
- )}
400
- style={{ width: actionsColumnWidth, minWidth: actionsColumnWidth, maxWidth: actionsColumnWidth }}
401
- onClick={(e) => e.stopPropagation()}
402
- >
403
- {actionsColumn(item)}
404
- </TableCell>
405
- )}
406
- </TableRow>
407
- );
408
- })
409
- )}
410
- </TableBody>
411
- </Table>
412
- </div>
413
-
414
- {/* Empty state - rendered outside table for proper positioning */}
415
- {!loading && pagination.paginatedData.length === 0 && (
416
- <div className="empty-state-container flex-1 flex items-center justify-center bg-background">
417
- {emptyState || <span className="block text-center text-muted-foreground py-8">No data</span>}
418
- </div>
419
- )}
420
-
421
- {/* Pagination (hidden when using external pagination controls) */}
422
- {!hidePagination && !loading && pagination.totalPages > 1 && (
423
- <Pagination
424
- page={pagination.page}
425
- pageSize={pagination.pageSize}
426
- totalItems={pagination.totalItems}
427
- totalPages={pagination.totalPages}
428
- startIndex={pagination.startIndex}
429
- endIndex={pagination.endIndex}
430
- canGoPrev={pagination.canGoPrev}
431
- canGoNext={pagination.canGoNext}
432
- onPageChange={pagination.setPage}
433
- onPageSizeChange={pagination.setPageSize}
434
- onNextPage={pagination.nextPage}
435
- onPrevPage={pagination.prevPage}
436
- onFirstPage={'firstPage' in pagination ? (pagination as { firstPage: () => void }).firstPage : () => pagination.setPage(1)}
437
- onLastPage={'lastPage' in pagination ? (pagination as { lastPage: () => void }).lastPage : () => pagination.setPage(pagination.totalPages)}
438
- pageSizeOptions={pagination.pageSizeOptions}
439
- />
440
- )}
441
-
442
- {/* Header Context Menu for Column Visibility */}
443
- {headerContextMenu && (
444
- <div
445
- className="fixed z-50 min-w-[200px] overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95"
446
- style={{
447
- top: headerContextMenu.y,
448
- left: headerContextMenu.x,
449
- maxHeight: `calc(100vh - ${headerContextMenu.y}px - 20px)`,
450
- }}
451
- onClick={(e) => e.stopPropagation()}
452
- >
453
- <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
454
- Toggle columns
455
- </div>
456
- <div className="h-px bg-border my-1" />
457
- {columnVisibility.columns.map(column => {
458
- const visible = columnVisibility.isColumnVisible(column.id);
459
- const isLocked = column.locked === true;
460
- return (
461
- <button
462
- key={column.id}
463
- onClick={() => !isLocked && columnVisibility.toggleColumn(column.id)}
464
- disabled={isLocked}
465
- className={cn(
466
- 'flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-sm hover:bg-accent',
467
- isLocked && 'opacity-50 cursor-not-allowed'
468
- )}
469
- >
470
- <div className="w-4 h-4 flex items-center justify-center">
471
- {visible && <Check className="h-3.5 w-3.5 text-primary" />}
472
- </div>
473
- <span className="flex-1 text-left">{column.label}</span>
474
- {isLocked && <span className="text-xs text-muted-foreground">Required</span>}
475
- </button>
476
- );
477
- })}
478
- <div className="h-px bg-border my-1" />
479
- <button
480
- onClick={() => columnVisibility.showAllColumns()}
481
- className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-sm hover:bg-accent"
482
- >
483
- <Eye className="h-4 w-4" />
484
- Show All
485
- </button>
486
- <button
487
- onClick={() => columnVisibility.hideAllColumns()}
488
- className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-sm hover:bg-accent"
489
- >
490
- <EyeOff className="h-4 w-4" />
491
- Hide Optional
492
- </button>
493
- </div>
494
- )}
495
- </>
496
- </TooltipProvider>
497
- );
498
- }