@object-ui/components 3.0.2 → 3.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/.turbo/turbo-build.log +12 -12
- package/CHANGELOG.md +8 -0
- package/dist/index.css +1 -1
- package/dist/index.js +24701 -22929
- package/dist/index.umd.cjs +37 -37
- package/dist/src/custom/config-field-renderer.d.ts +21 -0
- package/dist/src/custom/config-panel-renderer.d.ts +81 -0
- package/dist/src/custom/config-row.d.ts +27 -0
- package/dist/src/custom/index.d.ts +5 -0
- package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
- package/dist/src/custom/navigation-overlay.d.ts +8 -0
- package/dist/src/custom/section-header.d.ts +31 -0
- package/dist/src/debug/DebugPanel.d.ts +39 -0
- package/dist/src/debug/index.d.ts +9 -0
- package/dist/src/hooks/use-config-draft.d.ts +46 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/renderers/action/action-bar.d.ts +23 -0
- package/dist/src/types/config-panel.d.ts +92 -0
- package/dist/src/ui/sheet.d.ts +2 -0
- package/dist/src/ui/sidebar.d.ts +4 -0
- package/package.json +17 -17
- package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
- package/src/__tests__/action-bar.test.tsx +172 -0
- package/src/__tests__/config-field-renderer.test.tsx +307 -0
- package/src/__tests__/config-panel-renderer.test.tsx +580 -0
- package/src/__tests__/config-primitives.test.tsx +106 -0
- package/src/__tests__/mobile-accessibility.test.tsx +120 -0
- package/src/__tests__/navigation-overlay.test.tsx +97 -0
- package/src/__tests__/use-config-draft.test.tsx +295 -0
- package/src/custom/config-field-renderer.tsx +276 -0
- package/src/custom/config-panel-renderer.tsx +306 -0
- package/src/custom/config-row.tsx +50 -0
- package/src/custom/index.ts +5 -0
- package/src/custom/mobile-dialog-content.tsx +67 -0
- package/src/custom/navigation-overlay.tsx +42 -4
- package/src/custom/section-header.tsx +68 -0
- package/src/debug/DebugPanel.tsx +313 -0
- package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
- package/src/{index.test.ts → debug/index.ts} +2 -7
- package/src/hooks/use-config-draft.ts +127 -0
- package/src/index.css +4 -0
- package/src/index.ts +15 -0
- package/src/renderers/action/action-bar.tsx +202 -0
- package/src/renderers/action/index.ts +1 -0
- package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
- package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
- package/src/renderers/complex/data-table.tsx +346 -43
- package/src/renderers/data-display/breadcrumb.tsx +3 -2
- package/src/renderers/form/form.tsx +4 -4
- package/src/renderers/navigation/header-bar.tsx +69 -10
- package/src/stories/ConfigPanel.stories.tsx +232 -0
- package/src/types/config-panel.ts +101 -0
- package/src/ui/dialog.tsx +20 -3
- package/src/ui/sheet.tsx +6 -3
- package/src/ui/sidebar.tsx +93 -9
|
@@ -11,6 +11,7 @@ import React, { useState, useMemo, useRef, useEffect } from 'react';
|
|
|
11
11
|
import { cn } from '../../lib/utils';
|
|
12
12
|
import { ComponentRegistry } from '@object-ui/core';
|
|
13
13
|
import type { DataTableSchema } from '@object-ui/types';
|
|
14
|
+
import { useObjectTranslation } from '@object-ui/react';
|
|
14
15
|
import {
|
|
15
16
|
Table,
|
|
16
17
|
TableHeader,
|
|
@@ -46,10 +47,80 @@ import {
|
|
|
46
47
|
GripVertical,
|
|
47
48
|
Save,
|
|
48
49
|
X,
|
|
50
|
+
Plus,
|
|
51
|
+
Expand,
|
|
49
52
|
} from 'lucide-react';
|
|
50
53
|
|
|
51
54
|
type SortDirection = 'asc' | 'desc' | null;
|
|
52
55
|
|
|
56
|
+
/** Number of skeleton rows shown when the table has no data */
|
|
57
|
+
const GHOST_ROW_COUNT = 3;
|
|
58
|
+
|
|
59
|
+
/** Returns a Tailwind width class for ghost cell placeholders to create visual variety */
|
|
60
|
+
function ghostCellWidth(columnIndex: number, totalColumns: number): string {
|
|
61
|
+
if (columnIndex === 0) return 'w-3/4';
|
|
62
|
+
if (columnIndex === totalColumns - 1) return 'w-1/3';
|
|
63
|
+
return 'w-1/2';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Default English fallback translations for the data table
|
|
67
|
+
const TABLE_DEFAULT_TRANSLATIONS: Record<string, string> = {
|
|
68
|
+
'table.rowsPerPage': 'Rows per page',
|
|
69
|
+
'table.pageInfo': 'Page {{current}} of {{total}}',
|
|
70
|
+
'table.totalRecords': '{{count}} total',
|
|
71
|
+
'table.noResults': 'No results found',
|
|
72
|
+
'table.noResultsHint': 'Try adjusting your filters or search query.',
|
|
73
|
+
'table.sortAsc': 'Sort ascending',
|
|
74
|
+
'table.sortDesc': 'Sort descending',
|
|
75
|
+
'table.hideColumn': 'Hide column',
|
|
76
|
+
'table.cancelAll': 'Cancel All',
|
|
77
|
+
'table.saveAll': 'Save All ({{count}})',
|
|
78
|
+
'table.exportCSV': 'Export CSV',
|
|
79
|
+
'table.addRecord': 'Add record',
|
|
80
|
+
'table.open': 'Open',
|
|
81
|
+
'table.search': 'Search...',
|
|
82
|
+
'table.modified': '{{count}} row modified',
|
|
83
|
+
'table.selected': '{{count}} selected',
|
|
84
|
+
'common.actions': 'Actions',
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Safe wrapper for useObjectTranslation that falls back to English defaults
|
|
89
|
+
* when I18nProvider is not available (e.g., standalone usage).
|
|
90
|
+
*/
|
|
91
|
+
function useTableTranslation() {
|
|
92
|
+
try {
|
|
93
|
+
const result = useObjectTranslation();
|
|
94
|
+
const testValue = result.t('table.rowsPerPage');
|
|
95
|
+
if (testValue === 'table.rowsPerPage') {
|
|
96
|
+
return {
|
|
97
|
+
t: (key: string, options?: Record<string, unknown>) => {
|
|
98
|
+
let value = TABLE_DEFAULT_TRANSLATIONS[key] || key;
|
|
99
|
+
if (options) {
|
|
100
|
+
for (const [k, v] of Object.entries(options)) {
|
|
101
|
+
value = value.replace(`{{${k}}}`, String(v));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { t: result.t };
|
|
109
|
+
} catch {
|
|
110
|
+
return {
|
|
111
|
+
t: (key: string, options?: Record<string, unknown>) => {
|
|
112
|
+
let value = TABLE_DEFAULT_TRANSLATIONS[key] || key;
|
|
113
|
+
if (options) {
|
|
114
|
+
for (const [k, v] of Object.entries(options)) {
|
|
115
|
+
value = value.replace(`{{${k}}}`, String(v));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
53
124
|
/**
|
|
54
125
|
* Enterprise-level data table component with Airtable-like features.
|
|
55
126
|
*
|
|
@@ -89,7 +160,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
89
160
|
const {
|
|
90
161
|
caption,
|
|
91
162
|
columns: rawColumns = [],
|
|
92
|
-
data = [],
|
|
163
|
+
data: rawData = [],
|
|
93
164
|
pagination = true,
|
|
94
165
|
pageSize: initialPageSize = 10,
|
|
95
166
|
searchable = true,
|
|
@@ -100,10 +171,23 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
100
171
|
resizableColumns = true,
|
|
101
172
|
reorderableColumns = true,
|
|
102
173
|
editable = false,
|
|
174
|
+
singleClickEdit = false,
|
|
175
|
+
selectionStyle = 'always',
|
|
103
176
|
rowClassName,
|
|
177
|
+
rowStyle,
|
|
104
178
|
className,
|
|
179
|
+
frozenColumns = 0,
|
|
180
|
+
showRowNumbers = false,
|
|
181
|
+
showAddRow = false,
|
|
105
182
|
} = schema;
|
|
106
183
|
|
|
184
|
+
// i18n support for pagination labels
|
|
185
|
+
const { t } = useTableTranslation();
|
|
186
|
+
|
|
187
|
+
// Ensure data is always an array – provider config objects or null/undefined
|
|
188
|
+
// must not reach array operations like .filter() / .some()
|
|
189
|
+
const data = Array.isArray(rawData) ? rawData : [];
|
|
190
|
+
|
|
107
191
|
// Normalize columns to support legacy keys (label/name) from existing JSONs
|
|
108
192
|
const initialColumns = useMemo(() => {
|
|
109
193
|
return rawColumns.map((col: any) => ({
|
|
@@ -113,6 +197,31 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
113
197
|
}));
|
|
114
198
|
}, [rawColumns]);
|
|
115
199
|
|
|
200
|
+
// Auto-size columns: estimate width from header and data content for columns without explicit widths
|
|
201
|
+
const autoSizedWidths = useMemo(() => {
|
|
202
|
+
const widths: Record<string, number> = {};
|
|
203
|
+
const cols = rawColumns.map((col: any) => ({
|
|
204
|
+
header: col.header || col.label,
|
|
205
|
+
accessorKey: col.accessorKey || col.name,
|
|
206
|
+
width: col.width,
|
|
207
|
+
}));
|
|
208
|
+
for (const col of cols) {
|
|
209
|
+
if (col.width) continue; // Skip columns with explicit widths
|
|
210
|
+
const headerLen = (col.header || '').length;
|
|
211
|
+
let maxLen = headerLen;
|
|
212
|
+
// Sample up to 50 rows for content width estimation
|
|
213
|
+
const sampleRows = data.slice(0, 50);
|
|
214
|
+
for (const row of sampleRows) {
|
|
215
|
+
const val = row[col.accessorKey];
|
|
216
|
+
const len = val != null ? String(val).length : 0;
|
|
217
|
+
if (len > maxLen) maxLen = len;
|
|
218
|
+
}
|
|
219
|
+
// Estimate pixel width: ~8px per character + 48px padding, min 80, max 400
|
|
220
|
+
widths[col.accessorKey] = Math.min(400, Math.max(80, maxLen * 8 + 48));
|
|
221
|
+
}
|
|
222
|
+
return widths;
|
|
223
|
+
}, [rawColumns, data]);
|
|
224
|
+
|
|
116
225
|
// State management
|
|
117
226
|
const [searchQuery, setSearchQuery] = useState('');
|
|
118
227
|
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
@@ -129,6 +238,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
129
238
|
// Track pending changes for multi-cell editing: rowIndex -> { columnKey -> newValue }
|
|
130
239
|
const [pendingChanges, setPendingChanges] = useState<Map<number, Record<string, any>>>(new Map());
|
|
131
240
|
const [isSaving, setIsSaving] = useState(false);
|
|
241
|
+
// Column header context menu state
|
|
242
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; columnKey: string } | null>(null);
|
|
132
243
|
|
|
133
244
|
// Refs for column resizing
|
|
134
245
|
const resizingColumn = useRef<string | null>(null);
|
|
@@ -204,6 +315,25 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
204
315
|
}
|
|
205
316
|
};
|
|
206
317
|
|
|
318
|
+
// Column header context menu handler
|
|
319
|
+
const handleColumnContextMenu = (e: React.MouseEvent, columnKey: string) => {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
setContextMenu({ x: e.clientX, y: e.clientY, columnKey });
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const hideColumn = (columnKey: string) => {
|
|
325
|
+
setColumns(prev => prev.filter(c => c.accessorKey !== columnKey));
|
|
326
|
+
setContextMenu(null);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// Close context menu on outside click
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
if (!contextMenu) return;
|
|
332
|
+
const close = () => setContextMenu(null);
|
|
333
|
+
document.addEventListener('click', close);
|
|
334
|
+
return () => document.removeEventListener('click', close);
|
|
335
|
+
}, [contextMenu]);
|
|
336
|
+
|
|
207
337
|
const handleSelectAll = (checked: boolean) => {
|
|
208
338
|
const newSelected = new Set<any>();
|
|
209
339
|
if (checked) {
|
|
@@ -263,12 +393,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
263
393
|
|
|
264
394
|
const getSortIcon = (columnKey: string) => {
|
|
265
395
|
if (sortColumn !== columnKey) {
|
|
266
|
-
return <ChevronsUpDown className="h-
|
|
396
|
+
return <ChevronsUpDown className="h-3 w-3 ml-0.5 opacity-0 group-hover:opacity-50 transition-opacity" />;
|
|
267
397
|
}
|
|
268
398
|
if (sortDirection === 'asc') {
|
|
269
|
-
return <ChevronUp className="h-
|
|
399
|
+
return <ChevronUp className="h-3 w-3 ml-0.5 text-primary" />;
|
|
270
400
|
}
|
|
271
|
-
return <ChevronDown className="h-
|
|
401
|
+
return <ChevronDown className="h-3 w-3 ml-0.5 text-primary" />;
|
|
272
402
|
};
|
|
273
403
|
|
|
274
404
|
// Column resizing handlers
|
|
@@ -456,6 +586,21 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
456
586
|
};
|
|
457
587
|
|
|
458
588
|
const handleCellKeyDown = (e: React.KeyboardEvent, rowIndex: number, columnKey: string) => {
|
|
589
|
+
// Copy cell value with Ctrl+C / Cmd+C
|
|
590
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !editingCell) {
|
|
591
|
+
e.preventDefault();
|
|
592
|
+
const globalIdx = (currentPage - 1) * pageSize + rowIndex;
|
|
593
|
+
const row = sortedData[globalIdx];
|
|
594
|
+
if (row) {
|
|
595
|
+
const value = row[columnKey];
|
|
596
|
+
const text = value != null ? String(value) : '';
|
|
597
|
+
navigator.clipboard.writeText(text).catch(() => {
|
|
598
|
+
// Fallback for environments without clipboard API
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
459
604
|
if (!editable) return;
|
|
460
605
|
|
|
461
606
|
const column = columns.find(col => col.accessorKey === columnKey);
|
|
@@ -519,7 +664,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
519
664
|
<div className="relative w-full sm:max-w-sm flex-1">
|
|
520
665
|
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
521
666
|
<Input
|
|
522
|
-
placeholder=
|
|
667
|
+
placeholder={t('table.search')}
|
|
523
668
|
value={searchQuery}
|
|
524
669
|
onChange={(e) => {
|
|
525
670
|
setSearchQuery(e.target.value);
|
|
@@ -535,7 +680,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
535
680
|
{hasPendingChanges && (
|
|
536
681
|
<>
|
|
537
682
|
<div className="text-sm text-muted-foreground">
|
|
538
|
-
{
|
|
683
|
+
{t('table.modified', { count: pendingChanges.size })}
|
|
539
684
|
</div>
|
|
540
685
|
<Button
|
|
541
686
|
variant="outline"
|
|
@@ -544,7 +689,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
544
689
|
disabled={isSaving}
|
|
545
690
|
>
|
|
546
691
|
<X className="h-4 w-4 mr-2" />
|
|
547
|
-
|
|
692
|
+
{t('table.cancelAll')}
|
|
548
693
|
</Button>
|
|
549
694
|
<Button
|
|
550
695
|
variant="default"
|
|
@@ -553,7 +698,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
553
698
|
disabled={isSaving}
|
|
554
699
|
>
|
|
555
700
|
<Save className="h-4 w-4 mr-2" />
|
|
556
|
-
|
|
701
|
+
{t('table.saveAll', { count: pendingChanges.size })}
|
|
557
702
|
</Button>
|
|
558
703
|
</>
|
|
559
704
|
)}
|
|
@@ -566,13 +711,13 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
566
711
|
disabled={sortedData.length === 0}
|
|
567
712
|
>
|
|
568
713
|
<Download className="h-4 w-4 mr-2" />
|
|
569
|
-
|
|
714
|
+
{t('table.exportCSV')}
|
|
570
715
|
</Button>
|
|
571
716
|
)}
|
|
572
717
|
|
|
573
718
|
{selectable && selectedRowIds.size > 0 && (
|
|
574
719
|
<div className="text-sm text-muted-foreground">
|
|
575
|
-
{selectedRowIds.size}
|
|
720
|
+
{t('table.selected', { count: selectedRowIds.size })}
|
|
576
721
|
</div>
|
|
577
722
|
)}
|
|
578
723
|
</div>
|
|
@@ -583,20 +728,35 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
583
728
|
<div className="rounded-md border flex-1 min-h-0 overflow-auto relative bg-background [-webkit-overflow-scrolling:touch] shadow-[inset_-8px_0_8px_-8px_rgba(0,0,0,0.08)]">
|
|
584
729
|
<Table>
|
|
585
730
|
{caption && <TableCaption>{caption}</TableCaption>}
|
|
586
|
-
<TableHeader className="sticky top-0 bg-
|
|
731
|
+
<TableHeader className="sticky top-0 bg-muted/30 z-10">
|
|
587
732
|
<TableRow>
|
|
588
733
|
{selectable && (
|
|
589
|
-
<TableHead className="w-
|
|
734
|
+
<TableHead className={cn("w-10 bg-muted/30", frozenColumns > 0 && "sticky left-0 z-20")}>
|
|
590
735
|
<Checkbox
|
|
591
736
|
checked={allPageRowsSelected ? true : somePageRowsSelected ? 'indeterminate' : false}
|
|
592
737
|
onCheckedChange={handleSelectAll}
|
|
593
738
|
/>
|
|
594
739
|
</TableHead>
|
|
595
740
|
)}
|
|
741
|
+
{showRowNumbers && (
|
|
742
|
+
<TableHead className={cn("w-10 bg-muted/30 text-center", frozenColumns > 0 && "sticky z-20")} style={frozenColumns > 0 ? { left: selectable ? 40 : 0 } : undefined}>
|
|
743
|
+
<span className="text-xs text-muted-foreground">#</span>
|
|
744
|
+
</TableHead>
|
|
745
|
+
)}
|
|
596
746
|
{columns.map((col, index) => {
|
|
597
|
-
const columnWidth = columnWidths[col.accessorKey] || col.width;
|
|
747
|
+
const columnWidth = columnWidths[col.accessorKey] || col.width || autoSizedWidths[col.accessorKey];
|
|
598
748
|
const isDragging = draggedColumn === index;
|
|
599
749
|
const isDragOver = dragOverColumn === index;
|
|
750
|
+
const isFrozen = frozenColumns > 0 && index < frozenColumns;
|
|
751
|
+
const frozenOffset = isFrozen
|
|
752
|
+
? columns.slice(0, index).reduce((sum, c, i) => {
|
|
753
|
+
if (i < frozenColumns) {
|
|
754
|
+
const w = columnWidths[c.accessorKey] || c.width || autoSizedWidths[c.accessorKey];
|
|
755
|
+
return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150);
|
|
756
|
+
}
|
|
757
|
+
return sum;
|
|
758
|
+
}, (selectable ? 40 : 0) + (showRowNumbers ? 40 : 0))
|
|
759
|
+
: undefined;
|
|
600
760
|
|
|
601
761
|
return (
|
|
602
762
|
<TableHead
|
|
@@ -608,11 +768,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
608
768
|
isDragOver && 'border-l-2 border-primary',
|
|
609
769
|
col.align === 'right' && 'text-right',
|
|
610
770
|
col.align === 'center' && 'text-center',
|
|
611
|
-
'relative group bg-
|
|
771
|
+
'relative group bg-muted/30',
|
|
772
|
+
isFrozen && 'sticky z-20',
|
|
773
|
+
isFrozen && index === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]',
|
|
612
774
|
)}
|
|
613
775
|
style={{
|
|
614
776
|
width: columnWidth,
|
|
615
|
-
minWidth: columnWidth
|
|
777
|
+
minWidth: columnWidth,
|
|
778
|
+
...(isFrozen && { left: frozenOffset }),
|
|
616
779
|
}}
|
|
617
780
|
draggable={reorderableColumns}
|
|
618
781
|
onDragStart={(e) => handleColumnDragStart(e, index)}
|
|
@@ -620,6 +783,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
620
783
|
onDrop={(e) => handleColumnDrop(e, index)}
|
|
621
784
|
onDragEnd={handleColumnDragEnd}
|
|
622
785
|
onClick={() => sortable && col.sortable !== false && handleSort(col.accessorKey)}
|
|
786
|
+
onContextMenu={(e) => handleColumnContextMenu(e, col.accessorKey)}
|
|
623
787
|
>
|
|
624
788
|
<div className={cn(
|
|
625
789
|
"flex items-center",
|
|
@@ -629,7 +793,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
629
793
|
{reorderableColumns && (
|
|
630
794
|
<GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing flex-shrink-0" />
|
|
631
795
|
)}
|
|
632
|
-
|
|
796
|
+
{col.headerIcon && (
|
|
797
|
+
<span className="text-muted-foreground flex-shrink-0">{col.headerIcon}</span>
|
|
798
|
+
)}
|
|
799
|
+
<span className="text-xs font-normal text-muted-foreground">{col.header}</span>
|
|
633
800
|
{sortable && col.sortable !== false && getSortIcon(col.accessorKey)}
|
|
634
801
|
</div>
|
|
635
802
|
{resizableColumns && col.resizable !== false && (
|
|
@@ -644,24 +811,39 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
644
811
|
);
|
|
645
812
|
})}
|
|
646
813
|
{rowActions && (
|
|
647
|
-
<TableHead className="w-24 text-right bg-
|
|
814
|
+
<TableHead className="w-24 text-right bg-muted/30">{t('common.actions')}</TableHead>
|
|
648
815
|
)}
|
|
649
816
|
</TableRow>
|
|
650
817
|
</TableHeader>
|
|
651
818
|
<TableBody>
|
|
652
819
|
{paginatedData.length === 0 ? (
|
|
653
|
-
|
|
654
|
-
<
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
<
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
820
|
+
<>
|
|
821
|
+
<TableRow>
|
|
822
|
+
<TableCell
|
|
823
|
+
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
|
|
824
|
+
className="h-24 text-center text-muted-foreground"
|
|
825
|
+
>
|
|
826
|
+
<div className="flex flex-col items-center justify-center gap-2">
|
|
827
|
+
<Search className="h-8 w-8 text-muted-foreground/50" />
|
|
828
|
+
<p>{t('table.noResults')}</p>
|
|
829
|
+
<p className="text-xs text-muted-foreground/50">{t('table.noResultsHint')}</p>
|
|
830
|
+
</div>
|
|
831
|
+
</TableCell>
|
|
832
|
+
</TableRow>
|
|
833
|
+
{/* Ghost placeholder rows – visual skeleton to maintain table height when empty */}
|
|
834
|
+
{Array.from({ length: GHOST_ROW_COUNT }).map((_, i) => (
|
|
835
|
+
<TableRow key={`ghost-${i}`} className="hover:bg-transparent opacity-[0.15] pointer-events-none" data-testid="ghost-row">
|
|
836
|
+
{selectable && <TableCell className="p-3"><div className="h-4 w-4 rounded border border-muted-foreground/30" /></TableCell>}
|
|
837
|
+
{showRowNumbers && <TableCell className="text-center p-3"><div className="h-3 w-6 mx-auto rounded bg-muted-foreground/30" /></TableCell>}
|
|
838
|
+
{columns.map((_col, ci) => (
|
|
839
|
+
<TableCell key={ci} className="p-3">
|
|
840
|
+
<div className={cn("h-3 rounded bg-muted-foreground/30", ghostCellWidth(ci, columns.length))} />
|
|
841
|
+
</TableCell>
|
|
842
|
+
))}
|
|
843
|
+
{rowActions && <TableCell className="p-3"><div className="h-3 w-8 rounded bg-muted-foreground/30" /></TableCell>}
|
|
844
|
+
</TableRow>
|
|
845
|
+
))}
|
|
846
|
+
</>
|
|
665
847
|
) : (
|
|
666
848
|
<>
|
|
667
849
|
{paginatedData.map((row, rowIndex) => {
|
|
@@ -676,10 +858,12 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
676
858
|
key={rowId}
|
|
677
859
|
data-state={isSelected ? 'selected' : undefined}
|
|
678
860
|
className={cn(
|
|
861
|
+
"bg-background border-b border-border hover:bg-muted/30 group/row",
|
|
679
862
|
schema.onRowClick && "cursor-pointer",
|
|
680
863
|
rowHasChanges && "bg-amber-50 dark:bg-amber-950/20",
|
|
681
864
|
rowClassName && rowClassName(row, rowIndex)
|
|
682
865
|
)}
|
|
866
|
+
style={rowStyle ? rowStyle(row, rowIndex) : undefined}
|
|
683
867
|
onClick={(e) => {
|
|
684
868
|
if (schema.onRowClick && !e.defaultPrevented) {
|
|
685
869
|
// Simple heuristic to avoid triggering on interactive elements if they didn't stop propagation
|
|
@@ -692,20 +876,69 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
692
876
|
}}
|
|
693
877
|
>
|
|
694
878
|
{selectable && (
|
|
695
|
-
<TableCell>
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
879
|
+
<TableCell className={cn(frozenColumns > 0 && "sticky left-0 z-10 bg-background", selectionStyle === 'hover' && "relative")}>
|
|
880
|
+
{selectionStyle === 'hover' ? (
|
|
881
|
+
<div className={cn("transition-opacity", isSelected ? "opacity-100" : "opacity-0 group-hover/row:opacity-100")}>
|
|
882
|
+
<Checkbox
|
|
883
|
+
checked={isSelected}
|
|
884
|
+
onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
|
|
885
|
+
/>
|
|
886
|
+
</div>
|
|
887
|
+
) : (
|
|
888
|
+
<Checkbox
|
|
889
|
+
checked={isSelected}
|
|
890
|
+
onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
|
|
891
|
+
/>
|
|
892
|
+
)}
|
|
893
|
+
</TableCell>
|
|
894
|
+
)}
|
|
895
|
+
{showRowNumbers && (
|
|
896
|
+
<TableCell className={cn("text-center w-10 relative", frozenColumns > 0 && "sticky z-10 bg-background")} style={frozenColumns > 0 ? { left: selectable ? 40 : 0 } : undefined}>
|
|
897
|
+
<span className={cn("text-xs text-muted-foreground tabular-nums select-none", selectable ? "group-hover/row:hidden" : "group-hover/row:invisible")}>
|
|
898
|
+
{globalIndex + 1}
|
|
899
|
+
</span>
|
|
900
|
+
{selectable ? (
|
|
901
|
+
<div className="absolute inset-0 hidden group-hover/row:flex items-center justify-center">
|
|
902
|
+
<Checkbox
|
|
903
|
+
checked={isSelected}
|
|
904
|
+
onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
|
|
905
|
+
data-testid="row-hover-checkbox"
|
|
906
|
+
/>
|
|
907
|
+
</div>
|
|
908
|
+
) : schema.onRowClick && (
|
|
909
|
+
<button
|
|
910
|
+
type="button"
|
|
911
|
+
className="absolute inset-0 hidden group-hover/row:flex items-center justify-center gap-0.5 text-xs font-medium text-primary hover:text-primary/80"
|
|
912
|
+
data-testid="row-expand-button"
|
|
913
|
+
onClick={(e) => {
|
|
914
|
+
e.stopPropagation();
|
|
915
|
+
schema.onRowClick?.(row);
|
|
916
|
+
}}
|
|
917
|
+
title="Open record"
|
|
918
|
+
>
|
|
919
|
+
<span>{t('table.open')}</span>
|
|
920
|
+
<ChevronRight className="h-3 w-3" />
|
|
921
|
+
</button>
|
|
922
|
+
)}
|
|
700
923
|
</TableCell>
|
|
701
924
|
)}
|
|
702
925
|
{columns.map((col, colIndex) => {
|
|
703
|
-
const columnWidth = columnWidths[col.accessorKey] || col.width;
|
|
926
|
+
const columnWidth = columnWidths[col.accessorKey] || col.width || autoSizedWidths[col.accessorKey];
|
|
704
927
|
const originalValue = row[col.accessorKey];
|
|
705
928
|
const hasPendingChange = rowChanges[col.accessorKey] !== undefined;
|
|
706
929
|
const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue;
|
|
707
930
|
const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === col.accessorKey;
|
|
708
931
|
const isEditable = editable && col.editable !== false;
|
|
932
|
+
const isFrozen = frozenColumns > 0 && colIndex < frozenColumns;
|
|
933
|
+
const frozenOffset = isFrozen
|
|
934
|
+
? columns.slice(0, colIndex).reduce((sum, c, i) => {
|
|
935
|
+
if (i < frozenColumns) {
|
|
936
|
+
const w = columnWidths[c.accessorKey] || c.width || autoSizedWidths[c.accessorKey];
|
|
937
|
+
return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150);
|
|
938
|
+
}
|
|
939
|
+
return sum;
|
|
940
|
+
}, (selectable ? 40 : 0) + (showRowNumbers ? 40 : 0))
|
|
941
|
+
: undefined;
|
|
709
942
|
|
|
710
943
|
return (
|
|
711
944
|
<TableCell
|
|
@@ -715,14 +948,18 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
715
948
|
col.align === 'right' && 'text-right',
|
|
716
949
|
col.align === 'center' && 'text-center',
|
|
717
950
|
isEditable && !isEditing && "cursor-text hover:bg-muted/50",
|
|
718
|
-
hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400"
|
|
951
|
+
hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400",
|
|
952
|
+
isFrozen && 'sticky z-10 bg-background',
|
|
953
|
+
isFrozen && colIndex === frozenColumns - 1 && 'border-r-2 border-border shadow-[2px_0_4px_-2px_rgba(0,0,0,0.1)]',
|
|
719
954
|
)}
|
|
720
955
|
style={{
|
|
721
956
|
width: columnWidth,
|
|
722
957
|
minWidth: columnWidth,
|
|
723
|
-
maxWidth: columnWidth
|
|
958
|
+
maxWidth: columnWidth,
|
|
959
|
+
...(isFrozen && { left: frozenOffset }),
|
|
724
960
|
}}
|
|
725
|
-
onDoubleClick={() => isEditable && startEdit(rowIndex, col.accessorKey)}
|
|
961
|
+
onDoubleClick={() => isEditable && !singleClickEdit && startEdit(rowIndex, col.accessorKey)}
|
|
962
|
+
onClick={() => isEditable && singleClickEdit && startEdit(rowIndex, col.accessorKey)}
|
|
726
963
|
onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)}
|
|
727
964
|
tabIndex={0}
|
|
728
965
|
>
|
|
@@ -790,10 +1027,28 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
790
1027
|
</TableRow>
|
|
791
1028
|
);
|
|
792
1029
|
})}
|
|
793
|
-
{/*
|
|
794
|
-
{
|
|
1030
|
+
{/* Add record row (Airtable-style) */}
|
|
1031
|
+
{showAddRow && (
|
|
1032
|
+
<TableRow
|
|
1033
|
+
className="hover:bg-muted/30 cursor-pointer border-b border-border"
|
|
1034
|
+
data-testid="add-record-row"
|
|
1035
|
+
onClick={() => schema.onAddRecord?.()}
|
|
1036
|
+
>
|
|
1037
|
+
<TableCell
|
|
1038
|
+
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
|
|
1039
|
+
className="h-9 px-3 py-1.5"
|
|
1040
|
+
>
|
|
1041
|
+
<span className="flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground transition-colors">
|
|
1042
|
+
<Plus className="h-3.5 w-3.5" />
|
|
1043
|
+
{t('table.addRecord')}
|
|
1044
|
+
</span>
|
|
1045
|
+
</TableCell>
|
|
1046
|
+
</TableRow>
|
|
1047
|
+
)}
|
|
1048
|
+
{/* Filler rows to maintain height consistency (only when pagination is enabled) */}
|
|
1049
|
+
{pagination && paginatedData.length > 0 && Array.from({ length: Math.max(0, pageSize - paginatedData.length) }).map((_, i) => (
|
|
795
1050
|
<TableRow key={`empty-${i}`} className="hover:bg-transparent">
|
|
796
|
-
<TableCell colSpan={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)} className="h-[52px] p-0" />
|
|
1051
|
+
<TableCell colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)} className="h-[52px] p-0" />
|
|
797
1052
|
</TableRow>
|
|
798
1053
|
))}
|
|
799
1054
|
</>
|
|
@@ -806,7 +1061,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
806
1061
|
{pagination && sortedData.length > 0 && (
|
|
807
1062
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-2">
|
|
808
1063
|
<div className="flex items-center gap-2">
|
|
809
|
-
<span className="text-xs sm:text-sm text-muted-foreground">
|
|
1064
|
+
<span className="text-xs sm:text-sm text-muted-foreground">{t('table.rowsPerPage')}:</span>
|
|
810
1065
|
<Select
|
|
811
1066
|
value={pageSize.toString()}
|
|
812
1067
|
onValueChange={(value) => {
|
|
@@ -829,7 +1084,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
829
1084
|
|
|
830
1085
|
<div className="flex items-center gap-2">
|
|
831
1086
|
<span className="text-xs sm:text-sm text-muted-foreground">
|
|
832
|
-
|
|
1087
|
+
{t('table.pageInfo', { current: currentPage, total: totalPages })} <span className="hidden sm:inline">({t('table.totalRecords', { count: sortedData.length })})</span>
|
|
833
1088
|
</span>
|
|
834
1089
|
<div className="flex items-center gap-1">
|
|
835
1090
|
<Button
|
|
@@ -868,6 +1123,54 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
|
|
|
868
1123
|
</div>
|
|
869
1124
|
</div>
|
|
870
1125
|
)}
|
|
1126
|
+
|
|
1127
|
+
{/* Column header context menu */}
|
|
1128
|
+
{contextMenu && (
|
|
1129
|
+
<div
|
|
1130
|
+
className="fixed z-50 min-w-[160px] rounded-md border bg-popover p-1 shadow-md"
|
|
1131
|
+
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
1132
|
+
data-testid="column-context-menu"
|
|
1133
|
+
onClick={(e) => e.stopPropagation()}
|
|
1134
|
+
>
|
|
1135
|
+
{sortable && (
|
|
1136
|
+
<>
|
|
1137
|
+
<button
|
|
1138
|
+
type="button"
|
|
1139
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
|
1140
|
+
onClick={() => {
|
|
1141
|
+
setSortColumn(contextMenu.columnKey);
|
|
1142
|
+
setSortDirection('asc');
|
|
1143
|
+
setContextMenu(null);
|
|
1144
|
+
}}
|
|
1145
|
+
>
|
|
1146
|
+
<ChevronUp className="h-3.5 w-3.5" />
|
|
1147
|
+
{t('table.sortAsc')}
|
|
1148
|
+
</button>
|
|
1149
|
+
<button
|
|
1150
|
+
type="button"
|
|
1151
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
|
1152
|
+
onClick={() => {
|
|
1153
|
+
setSortColumn(contextMenu.columnKey);
|
|
1154
|
+
setSortDirection('desc');
|
|
1155
|
+
setContextMenu(null);
|
|
1156
|
+
}}
|
|
1157
|
+
>
|
|
1158
|
+
<ChevronDown className="h-3.5 w-3.5" />
|
|
1159
|
+
{t('table.sortDesc')}
|
|
1160
|
+
</button>
|
|
1161
|
+
<div className="my-1 h-px bg-border" />
|
|
1162
|
+
</>
|
|
1163
|
+
)}
|
|
1164
|
+
<button
|
|
1165
|
+
type="button"
|
|
1166
|
+
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground cursor-pointer"
|
|
1167
|
+
onClick={() => hideColumn(contextMenu.columnKey)}
|
|
1168
|
+
>
|
|
1169
|
+
<X className="h-3.5 w-3.5" />
|
|
1170
|
+
{t('table.hideColumn')}
|
|
1171
|
+
</button>
|
|
1172
|
+
</div>
|
|
1173
|
+
)}
|
|
871
1174
|
</div>
|
|
872
1175
|
);
|
|
873
1176
|
};
|
|
@@ -10,6 +10,7 @@ import { ComponentRegistry } from '@object-ui/core';
|
|
|
10
10
|
import type { BreadcrumbSchema } from '@object-ui/types';
|
|
11
11
|
import { Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator } from '../../ui/breadcrumb';
|
|
12
12
|
import { renderChildren } from '../../lib/utils';
|
|
13
|
+
import { resolveI18nLabel } from '@object-ui/react';
|
|
13
14
|
|
|
14
15
|
ComponentRegistry.register('breadcrumb',
|
|
15
16
|
({ schema, ...props }: { schema: BreadcrumbSchema; [key: string]: any }) => {
|
|
@@ -31,9 +32,9 @@ ComponentRegistry.register('breadcrumb',
|
|
|
31
32
|
<div key={idx} className="flex items-center">
|
|
32
33
|
<BreadcrumbItem>
|
|
33
34
|
{idx === (schema.items?.length || 0) - 1 ? (
|
|
34
|
-
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
|
35
|
+
<BreadcrumbPage>{resolveI18nLabel(item.label) ?? ''}</BreadcrumbPage>
|
|
35
36
|
) : (
|
|
36
|
-
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
|
|
37
|
+
<BreadcrumbLink href={item.href}>{resolveI18nLabel(item.label) ?? ''}</BreadcrumbLink>
|
|
37
38
|
)}
|
|
38
39
|
</BreadcrumbItem>
|
|
39
40
|
{idx < (schema.items?.length || 0) - 1 && <BreadcrumbSeparator />}
|
|
@@ -161,12 +161,12 @@ ComponentRegistry.register('form',
|
|
|
161
161
|
};
|
|
162
162
|
|
|
163
163
|
// Determine grid classes based on columns (explicit classes for Tailwind JIT)
|
|
164
|
-
// Mobile-first: 1 column on mobile,
|
|
164
|
+
// Mobile-first: 1 column on mobile, responsive breakpoints for larger screens
|
|
165
165
|
const gridColsClass =
|
|
166
166
|
columns === 1 ? '' :
|
|
167
|
-
columns === 2 ? '
|
|
168
|
-
columns === 3 ? '
|
|
169
|
-
'
|
|
167
|
+
columns === 2 ? 'md:grid-cols-2' :
|
|
168
|
+
columns === 3 ? 'md:grid-cols-2 lg:grid-cols-3' :
|
|
169
|
+
'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4';
|
|
170
170
|
|
|
171
171
|
const gridClass = columns > 1
|
|
172
172
|
? cn('grid gap-4', gridColsClass)
|