@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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +12 -12
  2. package/CHANGELOG.md +8 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.js +24701 -22929
  5. package/dist/index.umd.cjs +37 -37
  6. package/dist/src/custom/config-field-renderer.d.ts +21 -0
  7. package/dist/src/custom/config-panel-renderer.d.ts +81 -0
  8. package/dist/src/custom/config-row.d.ts +27 -0
  9. package/dist/src/custom/index.d.ts +5 -0
  10. package/dist/src/custom/mobile-dialog-content.d.ts +20 -0
  11. package/dist/src/custom/navigation-overlay.d.ts +8 -0
  12. package/dist/src/custom/section-header.d.ts +31 -0
  13. package/dist/src/debug/DebugPanel.d.ts +39 -0
  14. package/dist/src/debug/index.d.ts +9 -0
  15. package/dist/src/hooks/use-config-draft.d.ts +46 -0
  16. package/dist/src/index.d.ts +4 -0
  17. package/dist/src/renderers/action/action-bar.d.ts +23 -0
  18. package/dist/src/types/config-panel.d.ts +92 -0
  19. package/dist/src/ui/sheet.d.ts +2 -0
  20. package/dist/src/ui/sidebar.d.ts +4 -0
  21. package/package.json +17 -17
  22. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +3 -3
  23. package/src/__tests__/action-bar.test.tsx +172 -0
  24. package/src/__tests__/config-field-renderer.test.tsx +307 -0
  25. package/src/__tests__/config-panel-renderer.test.tsx +580 -0
  26. package/src/__tests__/config-primitives.test.tsx +106 -0
  27. package/src/__tests__/mobile-accessibility.test.tsx +120 -0
  28. package/src/__tests__/navigation-overlay.test.tsx +97 -0
  29. package/src/__tests__/use-config-draft.test.tsx +295 -0
  30. package/src/custom/config-field-renderer.tsx +276 -0
  31. package/src/custom/config-panel-renderer.tsx +306 -0
  32. package/src/custom/config-row.tsx +50 -0
  33. package/src/custom/index.ts +5 -0
  34. package/src/custom/mobile-dialog-content.tsx +67 -0
  35. package/src/custom/navigation-overlay.tsx +42 -4
  36. package/src/custom/section-header.tsx +68 -0
  37. package/src/debug/DebugPanel.tsx +313 -0
  38. package/src/debug/__tests__/DebugPanel.test.tsx +134 -0
  39. package/src/{index.test.ts → debug/index.ts} +2 -7
  40. package/src/hooks/use-config-draft.ts +127 -0
  41. package/src/index.css +4 -0
  42. package/src/index.ts +15 -0
  43. package/src/renderers/action/action-bar.tsx +202 -0
  44. package/src/renderers/action/index.ts +1 -0
  45. package/src/renderers/complex/__tests__/data-table-airtable-ux.test.tsx +239 -0
  46. package/src/renderers/complex/__tests__/data-table.test.ts +16 -0
  47. package/src/renderers/complex/data-table.tsx +346 -43
  48. package/src/renderers/data-display/breadcrumb.tsx +3 -2
  49. package/src/renderers/form/form.tsx +4 -4
  50. package/src/renderers/navigation/header-bar.tsx +69 -10
  51. package/src/stories/ConfigPanel.stories.tsx +232 -0
  52. package/src/types/config-panel.ts +101 -0
  53. package/src/ui/dialog.tsx +20 -3
  54. package/src/ui/sheet.tsx +6 -3
  55. 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-4 w-4 ml-1 opacity-50" />;
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-4 w-4 ml-1" />;
399
+ return <ChevronUp className="h-3 w-3 ml-0.5 text-primary" />;
270
400
  }
271
- return <ChevronDown className="h-4 w-4 ml-1" />;
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="Search..."
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
- {pendingChanges.size} row{pendingChanges.size > 1 ? 's' : ''} modified
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
- Cancel All
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
- Save All ({pendingChanges.size})
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
- Export CSV
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} selected
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-background z-10 shadow-sm">
731
+ <TableHeader className="sticky top-0 bg-muted/30 z-10">
587
732
  <TableRow>
588
733
  {selectable && (
589
- <TableHead className="w-12 bg-background">
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-background'
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
- <span>{col.header}</span>
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-background">Actions</TableHead>
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
- <TableRow>
654
- <TableCell
655
- colSpan={columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0)}
656
- className="h-96 text-center text-muted-foreground"
657
- >
658
- <div className="flex flex-col items-center justify-center gap-2">
659
- <Search className="h-8 w-8 text-muted-foreground/50" />
660
- <p>No results found</p>
661
- <p className="text-xs text-muted-foreground/50">Try adjusting your filters or search query.</p>
662
- </div>
663
- </TableCell>
664
- </TableRow>
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
- <Checkbox
697
- checked={isSelected}
698
- onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
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
- {/* Filler rows to maintain height consistency */}
794
- {paginatedData.length > 0 && Array.from({ length: Math.max(0, pageSize - paginatedData.length) }).map((_, i) => (
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">Rows per page:</span>
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
- Page {currentPage} of {totalPages} <span className="hidden sm:inline">({sortedData.length} total)</span>
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, 2 on sm, then md/lg for larger values
164
+ // Mobile-first: 1 column on mobile, responsive breakpoints for larger screens
165
165
  const gridColsClass =
166
166
  columns === 1 ? '' :
167
- columns === 2 ? 'sm:grid-cols-2' :
168
- columns === 3 ? 'sm:grid-cols-2 md:grid-cols-3' :
169
- 'sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4';
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)