@object-ui/components 0.5.0 → 3.0.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 (119) hide show
  1. package/.turbo/turbo-build.log +12 -25
  2. package/CHANGELOG.md +32 -0
  3. package/dist/index.css +1 -1
  4. package/dist/index.js +23987 -22576
  5. package/dist/index.umd.cjs +30 -30
  6. package/dist/src/custom/action-param-dialog.d.ts +21 -0
  7. package/dist/src/custom/index.d.ts +4 -0
  8. package/dist/src/custom/navigation-overlay.d.ts +50 -0
  9. package/dist/src/custom/view-skeleton.d.ts +37 -0
  10. package/dist/src/custom/view-states.d.ts +33 -0
  11. package/dist/src/index.d.ts +1 -0
  12. package/dist/src/renderers/action/action-button.d.ts +11 -0
  13. package/dist/src/renderers/action/action-group.d.ts +25 -0
  14. package/dist/src/renderers/action/action-icon.d.ts +10 -0
  15. package/dist/src/renderers/action/action-menu.d.ts +19 -0
  16. package/dist/src/renderers/action/index.d.ts +0 -0
  17. package/dist/src/renderers/action/resolve-icon.d.ts +6 -0
  18. package/package.json +20 -19
  19. package/src/__tests__/PageRendererRegions.test.tsx +664 -55
  20. package/src/__tests__/__snapshots__/snapshot-critical.test.tsx.snap +811 -0
  21. package/src/__tests__/__snapshots__/snapshot.test.tsx.snap +327 -0
  22. package/src/__tests__/accessibility.test.tsx +137 -0
  23. package/src/__tests__/api-consistency.test.tsx +596 -0
  24. package/src/__tests__/color-contrast.test.tsx +212 -0
  25. package/src/__tests__/compliance.test.tsx +72 -0
  26. package/src/__tests__/edge-cases.test.tsx +285 -0
  27. package/src/__tests__/navigation-overlay.test.tsx +273 -0
  28. package/src/__tests__/snapshot-critical.test.tsx +317 -0
  29. package/src/__tests__/snapshot.test.tsx +205 -0
  30. package/src/__tests__/view-compliance.test.tsx +153 -0
  31. package/src/__tests__/wcag-audit.test.tsx +493 -0
  32. package/src/custom/action-param-dialog.tsx +264 -0
  33. package/src/custom/index.ts +4 -0
  34. package/src/custom/navigation-overlay.tsx +296 -0
  35. package/src/custom/view-skeleton.tsx +243 -0
  36. package/src/custom/view-states.tsx +153 -0
  37. package/src/index.ts +1 -0
  38. package/src/renderers/action/action-button.tsx +147 -0
  39. package/src/renderers/action/action-group.tsx +270 -0
  40. package/src/renderers/action/action-icon.tsx +150 -0
  41. package/src/renderers/action/action-menu.tsx +203 -0
  42. package/src/renderers/action/index.ts +18 -0
  43. package/src/renderers/action/resolve-icon.ts +35 -0
  44. package/src/renderers/complex/__tests__/data-table-batch-editing.test.tsx +275 -0
  45. package/src/renderers/complex/__tests__/data-table-cell-renderer.test.tsx +120 -0
  46. package/src/renderers/complex/__tests__/data-table-editing.test.tsx +221 -0
  47. package/src/renderers/complex/data-table.tsx +269 -33
  48. package/src/renderers/complex/resizable.tsx +20 -17
  49. package/src/renderers/data-display/list.tsx +1 -1
  50. package/src/renderers/data-display/table.tsx +1 -1
  51. package/src/renderers/data-display/tree-view.tsx +2 -1
  52. package/src/renderers/form/form.tsx +33 -10
  53. package/src/renderers/index.ts +1 -0
  54. package/src/renderers/layout/aspect-ratio.tsx +1 -1
  55. package/src/renderers/layout/page.tsx +416 -52
  56. package/src/renderers/navigation/sidebar.tsx +6 -0
  57. package/src/renderers/placeholders.tsx +2 -2
  58. package/src/stories/MockedData.stories.tsx +87 -37
  59. package/src/stories-json/Accessibility.mdx +297 -0
  60. package/src/stories-json/EdgeCases.stories.tsx +160 -0
  61. package/src/stories-json/GettingStarted.mdx +89 -0
  62. package/src/stories-json/Introduction.mdx +127 -0
  63. package/src/stories-json/accordion.stories.tsx +1 -1
  64. package/src/stories-json/aggrid.stories.tsx +1 -1
  65. package/src/stories-json/alert.stories.tsx +1 -1
  66. package/src/stories-json/aspect-ratio.stories.tsx +1 -1
  67. package/src/stories-json/avatar.stories.tsx +1 -1
  68. package/src/stories-json/badge.stories.tsx +1 -1
  69. package/src/stories-json/breadcrumb.stories.tsx +1 -1
  70. package/src/stories-json/button-group.stories.tsx +1 -1
  71. package/src/stories-json/button.stories.tsx +1 -1
  72. package/src/stories-json/calendar.stories.tsx +1 -1
  73. package/src/stories-json/card.stories.tsx +1 -1
  74. package/src/stories-json/carousel.stories.tsx +1 -1
  75. package/src/stories-json/charts.stories.tsx +1 -1
  76. package/src/stories-json/chatbot.stories.tsx +1 -1
  77. package/src/stories-json/code-editor.stories.tsx +1 -1
  78. package/src/stories-json/collapsible.stories.tsx +1 -1
  79. package/src/stories-json/controls.stories.tsx +1 -1
  80. package/src/stories-json/crm-live-data.stories.tsx +154 -0
  81. package/src/stories-json/data-table.stories.tsx +80 -4
  82. package/src/stories-json/data_display_extras.stories.tsx +1 -1
  83. package/src/stories-json/date-picker.stories.tsx +1 -1
  84. package/src/stories-json/detail-view.stories.tsx +1 -1
  85. package/src/stories-json/dialog.stories.tsx +1 -1
  86. package/src/stories-json/feedback_extras.stories.tsx +1 -1
  87. package/src/stories-json/feedback_others.stories.tsx +1 -1
  88. package/src/stories-json/form-variants.stories.tsx +210 -0
  89. package/src/stories-json/form_advanced.stories.tsx +1 -1
  90. package/src/stories-json/form_extras.stories.tsx +1 -1
  91. package/src/stories-json/grid.stories.tsx +1 -1
  92. package/src/stories-json/icon.stories.tsx +1 -1
  93. package/src/stories-json/input.stories.tsx +1 -1
  94. package/src/stories-json/kanban.stories.tsx +1 -1
  95. package/src/stories-json/layout_extended.stories.tsx +1 -1
  96. package/src/stories-json/layout_flex.stories.tsx +1 -1
  97. package/src/stories-json/list-view.stories.tsx +1 -1
  98. package/src/stories-json/markdown.stories.tsx +1 -1
  99. package/src/stories-json/menus.stories.tsx +1 -1
  100. package/src/stories-json/metric-card.stories.tsx +1 -1
  101. package/src/stories-json/navigation-menu.stories.tsx +1 -1
  102. package/src/stories-json/object-aggrid-advanced.stories.tsx +389 -0
  103. package/src/stories-json/object-aggrid.stories.tsx +1 -1
  104. package/src/stories-json/object-form.stories.tsx +1 -1
  105. package/src/stories-json/object-gantt.stories.tsx +1 -1
  106. package/src/stories-json/object-grid.stories.tsx +159 -1
  107. package/src/stories-json/object-map.stories.tsx +1 -1
  108. package/src/stories-json/object-view.stories.tsx +1 -1
  109. package/src/stories-json/overlay_extras.stories.tsx +1 -1
  110. package/src/stories-json/overlay_others.stories.tsx +1 -1
  111. package/src/stories-json/resizable.stories.tsx +1 -1
  112. package/src/stories-json/select.stories.tsx +1 -1
  113. package/src/stories-json/separator.stories.tsx +1 -1
  114. package/src/stories-json/statistic.stories.tsx +1 -1
  115. package/src/stories-json/tabs.stories.tsx +1 -1
  116. package/src/stories-json/timeline.stories.tsx +1 -1
  117. package/src/stories-json/typography.stories.tsx +1 -1
  118. package/src/ui/slider.tsx +6 -2
  119. package/src/stories/Introduction.mdx +0 -34
@@ -44,6 +44,8 @@ import {
44
44
  ChevronsLeft,
45
45
  ChevronsRight,
46
46
  GripVertical,
47
+ Save,
48
+ X,
47
49
  } from 'lucide-react';
48
50
 
49
51
  type SortDirection = 'asc' | 'desc' | null;
@@ -97,6 +99,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
97
99
  rowActions = false,
98
100
  resizableColumns = true,
99
101
  reorderableColumns = true,
102
+ editable = false,
103
+ rowClassName,
100
104
  className,
101
105
  } = schema;
102
106
 
@@ -120,11 +124,17 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
120
124
  const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
121
125
  const [draggedColumn, setDraggedColumn] = useState<number | null>(null);
122
126
  const [dragOverColumn, setDragOverColumn] = useState<number | null>(null);
127
+ const [editingCell, setEditingCell] = useState<{ rowIndex: number; columnKey: string } | null>(null);
128
+ const [editValue, setEditValue] = useState<any>('');
129
+ // Track pending changes for multi-cell editing: rowIndex -> { columnKey -> newValue }
130
+ const [pendingChanges, setPendingChanges] = useState<Map<number, Record<string, any>>>(new Map());
131
+ const [isSaving, setIsSaving] = useState(false);
123
132
 
124
133
  // Refs for column resizing
125
134
  const resizingColumn = useRef<string | null>(null);
126
135
  const startX = useRef<number>(0);
127
136
  const startWidth = useRef<number>(0);
137
+ const editInputRef = useRef<HTMLInputElement>(null);
128
138
 
129
139
  // Update columns when schema changes
130
140
  useEffect(() => {
@@ -340,6 +350,141 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
340
350
  setDragOverColumn(null);
341
351
  };
342
352
 
353
+ // Cell editing handlers
354
+ const startEdit = (rowIndex: number, columnKey: string) => {
355
+ if (!editable) return;
356
+
357
+ const column = columns.find(col => col.accessorKey === columnKey);
358
+ if (column?.editable === false) return;
359
+
360
+ setEditingCell({ rowIndex, columnKey });
361
+
362
+ // Check if there's a pending change for this cell, otherwise use current data value
363
+ const rowChanges = pendingChanges.get(rowIndex);
364
+ const currentValue = paginatedData[rowIndex][columnKey];
365
+ const valueToEdit = rowChanges?.[columnKey] ?? currentValue ?? '';
366
+ setEditValue(valueToEdit);
367
+ };
368
+
369
+ const saveEdit = (force: boolean = false) => {
370
+ if (!editingCell) return;
371
+
372
+ // Don't save if we're in cancelled state (unless forced)
373
+ if (!force && editingCell === null) return;
374
+
375
+ const { rowIndex, columnKey } = editingCell;
376
+ const globalIndex = (currentPage - 1) * pageSize + rowIndex;
377
+ const row = sortedData[globalIndex];
378
+
379
+ // Update pending changes
380
+ const newPendingChanges = new Map(pendingChanges);
381
+ const rowChanges = newPendingChanges.get(rowIndex) || {};
382
+ rowChanges[columnKey] = editValue;
383
+ newPendingChanges.set(rowIndex, rowChanges);
384
+ setPendingChanges(newPendingChanges);
385
+
386
+ // Call the legacy onCellChange callback if provided
387
+ if (schema.onCellChange) {
388
+ schema.onCellChange(globalIndex, columnKey, editValue, row);
389
+ }
390
+
391
+ setEditingCell(null);
392
+ setEditValue('');
393
+ };
394
+
395
+ const cancelEdit = () => {
396
+ setEditingCell(null);
397
+ setEditValue('');
398
+ };
399
+
400
+ const saveRow = async (rowIndex: number) => {
401
+ const globalIndex = (currentPage - 1) * pageSize + rowIndex;
402
+ const row = sortedData[globalIndex];
403
+ const rowChanges = pendingChanges.get(rowIndex);
404
+
405
+ if (!rowChanges || Object.keys(rowChanges).length === 0) return;
406
+
407
+ setIsSaving(true);
408
+ try {
409
+ if (schema.onRowSave) {
410
+ await schema.onRowSave(globalIndex, rowChanges, row);
411
+ }
412
+
413
+ // Clear pending changes for this row
414
+ const newPendingChanges = new Map(pendingChanges);
415
+ newPendingChanges.delete(rowIndex);
416
+ setPendingChanges(newPendingChanges);
417
+ } catch (error) {
418
+ console.error('Failed to save row:', error);
419
+ } finally {
420
+ setIsSaving(false);
421
+ }
422
+ };
423
+
424
+ const cancelRowChanges = (rowIndex: number) => {
425
+ const newPendingChanges = new Map(pendingChanges);
426
+ newPendingChanges.delete(rowIndex);
427
+ setPendingChanges(newPendingChanges);
428
+ };
429
+
430
+ const saveBatch = async () => {
431
+ if (pendingChanges.size === 0) return;
432
+
433
+ setIsSaving(true);
434
+ try {
435
+ const changesToSave = Array.from(pendingChanges.entries()).map(([rowIndex, changes]) => {
436
+ const globalIndex = (currentPage - 1) * pageSize + rowIndex;
437
+ const row = sortedData[globalIndex];
438
+ return { rowIndex: globalIndex, changes, row };
439
+ });
440
+
441
+ if (schema.onBatchSave) {
442
+ await schema.onBatchSave(changesToSave);
443
+ }
444
+
445
+ // Clear all pending changes
446
+ setPendingChanges(new Map());
447
+ } catch (error) {
448
+ console.error('Failed to save batch:', error);
449
+ } finally {
450
+ setIsSaving(false);
451
+ }
452
+ };
453
+
454
+ const cancelAllChanges = () => {
455
+ setPendingChanges(new Map());
456
+ };
457
+
458
+ const handleCellKeyDown = (e: React.KeyboardEvent, rowIndex: number, columnKey: string) => {
459
+ if (!editable) return;
460
+
461
+ const column = columns.find(col => col.accessorKey === columnKey);
462
+ if (column?.editable === false) return;
463
+
464
+ if (e.key === 'Enter' && !editingCell) {
465
+ e.preventDefault();
466
+ startEdit(rowIndex, columnKey);
467
+ }
468
+ };
469
+
470
+ const handleEditKeyDown = (e: React.KeyboardEvent) => {
471
+ if (e.key === 'Enter') {
472
+ e.preventDefault();
473
+ saveEdit(true);
474
+ } else if (e.key === 'Escape') {
475
+ e.preventDefault();
476
+ cancelEdit();
477
+ }
478
+ };
479
+
480
+ // Auto-focus on edit input when entering edit mode
481
+ useEffect(() => {
482
+ if (editingCell && editInputRef.current) {
483
+ editInputRef.current.focus();
484
+ editInputRef.current.select();
485
+ }
486
+ }, [editingCell]);
487
+
343
488
  // Cleanup on unmount
344
489
  useEffect(() => {
345
490
  return () => {
@@ -361,16 +506,17 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
361
506
  return selectedRowIds.has(rowId);
362
507
  }) && !allPageRowsSelected;
363
508
 
364
- const showToolbar = searchable || exportable || (selectable && selectedRowIds.size > 0);
509
+ const hasPendingChanges = pendingChanges.size > 0;
510
+ const showToolbar = searchable || exportable || (selectable && selectedRowIds.size > 0) || hasPendingChanges;
365
511
 
366
512
  return (
367
- <div className={`flex flex-col h-full gap-4 ${className || ''}`}>
513
+ <div className={`flex flex-col h-full gap-2 sm:gap-4 ${className || ''}`}>
368
514
  {/* Toolbar */}
369
515
  {showToolbar && (
370
- <div className="flex items-center justify-between gap-4 flex-none">
516
+ <div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 sm:gap-4 flex-none">
371
517
  <div className="flex items-center gap-2 flex-1">
372
518
  {searchable && (
373
- <div className="relative max-w-sm flex-1">
519
+ <div className="relative w-full sm:max-w-sm flex-1">
374
520
  <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
375
521
  <Input
376
522
  placeholder="Search..."
@@ -385,7 +531,33 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
385
531
  )}
386
532
  </div>
387
533
 
388
- <div className="flex items-center gap-2">
534
+ <div className="flex flex-wrap items-center gap-2">
535
+ {hasPendingChanges && (
536
+ <>
537
+ <div className="text-sm text-muted-foreground">
538
+ {pendingChanges.size} row{pendingChanges.size > 1 ? 's' : ''} modified
539
+ </div>
540
+ <Button
541
+ variant="outline"
542
+ size="sm"
543
+ onClick={cancelAllChanges}
544
+ disabled={isSaving}
545
+ >
546
+ <X className="h-4 w-4 mr-2" />
547
+ Cancel All
548
+ </Button>
549
+ <Button
550
+ variant="default"
551
+ size="sm"
552
+ onClick={saveBatch}
553
+ disabled={isSaving}
554
+ >
555
+ <Save className="h-4 w-4 mr-2" />
556
+ Save All ({pendingChanges.size})
557
+ </Button>
558
+ </>
559
+ )}
560
+
389
561
  {exportable && (
390
562
  <Button
391
563
  variant="outline"
@@ -407,8 +579,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
407
579
  </div>
408
580
  )}
409
581
 
410
- {/* Table */}
411
- <div className="rounded-md border flex-1 min-h-0 overflow-auto relative bg-background">
582
+ {/* Table - horizontal scroll indicator via inset shadow on mobile */}
583
+ <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)]">
412
584
  <Table>
413
585
  {caption && <TableCaption>{caption}</TableCaption>}
414
586
  <TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
@@ -429,7 +601,15 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
429
601
  return (
430
602
  <TableHead
431
603
  key={col.accessorKey}
432
- className={`${col.className || ''} ${sortable && col.sortable !== false ? 'cursor-pointer select-none' : ''} ${isDragging ? 'opacity-50' : ''} ${isDragOver ? 'border-l-2 border-primary' : ''} relative group bg-background`}
604
+ className={cn(
605
+ col.className,
606
+ sortable && col.sortable !== false && 'cursor-pointer select-none',
607
+ isDragging && 'opacity-50',
608
+ isDragOver && 'border-l-2 border-primary',
609
+ col.align === 'right' && 'text-right',
610
+ col.align === 'center' && 'text-center',
611
+ 'relative group bg-background'
612
+ )}
433
613
  style={{
434
614
  width: columnWidth,
435
615
  minWidth: columnWidth
@@ -441,7 +621,10 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
441
621
  onDragEnd={handleColumnDragEnd}
442
622
  onClick={() => sortable && col.sortable !== false && handleSort(col.accessorKey)}
443
623
  >
444
- <div className="flex items-center justify-between">
624
+ <div className={cn(
625
+ "flex items-center",
626
+ col.align === 'right' ? 'justify-end' : 'justify-between'
627
+ )}>
445
628
  <div className="flex items-center gap-1">
446
629
  {reorderableColumns && (
447
630
  <GripVertical className="h-4 w-4 opacity-0 group-hover:opacity-50 cursor-grab active:cursor-grabbing flex-shrink-0" />
@@ -485,24 +668,25 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
485
668
  const globalIndex = (currentPage - 1) * pageSize + rowIndex;
486
669
  const rowId = getRowId(row, globalIndex);
487
670
  const isSelected = selectedRowIds.has(rowId);
671
+ const rowHasChanges = pendingChanges.has(rowIndex);
672
+ const rowChanges = pendingChanges.get(rowIndex) || {};
488
673
 
489
674
  return (
490
675
  <TableRow
491
676
  key={rowId}
492
677
  data-state={isSelected ? 'selected' : undefined}
493
678
  className={cn(
494
- // @ts-expect-error - onRowClick might not be in schema type definition
495
- schema.onRowClick && "cursor-pointer"
679
+ schema.onRowClick && "cursor-pointer",
680
+ rowHasChanges && "bg-amber-50 dark:bg-amber-950/20",
681
+ rowClassName && rowClassName(row, rowIndex)
496
682
  )}
497
683
  onClick={(e) => {
498
- // @ts-expect-error - onRowClick might not be in schema type definition
499
684
  if (schema.onRowClick && !e.defaultPrevented) {
500
685
  // Simple heuristic to avoid triggering on interactive elements if they didn't stop propagation
501
686
  const target = e.target as HTMLElement;
502
687
  if (target.closest('button') || target.closest('[role="checkbox"]') || target.closest('a')) {
503
688
  return;
504
689
  }
505
- // @ts-expect-error - onRowClick might not be in schema type definition
506
690
  schema.onRowClick(row);
507
691
  }
508
692
  }}
@@ -517,37 +701,89 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
517
701
  )}
518
702
  {columns.map((col, colIndex) => {
519
703
  const columnWidth = columnWidths[col.accessorKey] || col.width;
704
+ const originalValue = row[col.accessorKey];
705
+ const hasPendingChange = rowChanges[col.accessorKey] !== undefined;
706
+ const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue;
707
+ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.columnKey === col.accessorKey;
708
+ const isEditable = editable && col.editable !== false;
709
+
520
710
  return (
521
711
  <TableCell
522
712
  key={colIndex}
523
- className={col.cellClassName}
713
+ className={cn(
714
+ col.cellClassName,
715
+ col.align === 'right' && 'text-right',
716
+ col.align === 'center' && 'text-center',
717
+ isEditable && !isEditing && "cursor-text hover:bg-muted/50",
718
+ hasPendingChange && "font-semibold text-amber-700 dark:text-amber-400"
719
+ )}
524
720
  style={{
525
721
  width: columnWidth,
526
722
  minWidth: columnWidth,
527
723
  maxWidth: columnWidth
528
724
  }}
725
+ onDoubleClick={() => isEditable && startEdit(rowIndex, col.accessorKey)}
726
+ onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)}
727
+ tabIndex={0}
529
728
  >
530
- {row[col.accessorKey]}
729
+ {isEditing ? (
730
+ <Input
731
+ ref={editInputRef}
732
+ value={editValue}
733
+ onChange={(e) => setEditValue(e.target.value)}
734
+ onKeyDown={handleEditKeyDown}
735
+ className="h-8 px-2 py-1"
736
+ />
737
+ ) : typeof col.cell === 'function' ? (
738
+ col.cell(cellValue, row)
739
+ ) : (
740
+ cellValue
741
+ )}
531
742
  </TableCell>
532
743
  );
533
744
  })}
534
745
  {rowActions && (
535
746
  <TableCell className="text-right">
536
747
  <div className="flex items-center justify-end gap-1">
537
- <Button
538
- variant="ghost"
539
- size="icon-sm"
540
- onClick={() => schema.onRowEdit?.(row)}
541
- >
542
- <Edit className="h-4 w-4" />
543
- </Button>
544
- <Button
545
- variant="ghost"
546
- size="icon-sm"
547
- onClick={() => schema.onRowDelete?.(row)}
548
- >
549
- <Trash2 className="h-4 w-4 text-destructive" />
550
- </Button>
748
+ {rowHasChanges && (schema.onRowSave || schema.onBatchSave) ? (
749
+ <>
750
+ <Button
751
+ variant="ghost"
752
+ size="icon-sm"
753
+ onClick={() => cancelRowChanges(rowIndex)}
754
+ disabled={isSaving}
755
+ title="Cancel changes"
756
+ >
757
+ <X className="h-4 w-4" />
758
+ </Button>
759
+ <Button
760
+ variant="ghost"
761
+ size="icon-sm"
762
+ onClick={() => saveRow(rowIndex)}
763
+ disabled={isSaving}
764
+ title="Save row"
765
+ >
766
+ <Save className="h-4 w-4 text-green-600" />
767
+ </Button>
768
+ </>
769
+ ) : (
770
+ <>
771
+ <Button
772
+ variant="ghost"
773
+ size="icon-sm"
774
+ onClick={() => schema.onRowEdit?.(row)}
775
+ >
776
+ <Edit className="h-4 w-4" />
777
+ </Button>
778
+ <Button
779
+ variant="ghost"
780
+ size="icon-sm"
781
+ onClick={() => schema.onRowDelete?.(row)}
782
+ >
783
+ <Trash2 className="h-4 w-4 text-destructive" />
784
+ </Button>
785
+ </>
786
+ )}
551
787
  </div>
552
788
  </TableCell>
553
789
  )}
@@ -568,9 +804,9 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
568
804
 
569
805
  {/* Pagination */}
570
806
  {pagination && sortedData.length > 0 && (
571
- <div className="flex items-center justify-between">
807
+ <div className="flex flex-col sm:flex-row items-center justify-between gap-2">
572
808
  <div className="flex items-center gap-2">
573
- <span className="text-sm text-muted-foreground">Rows per page:</span>
809
+ <span className="text-xs sm:text-sm text-muted-foreground">Rows per page:</span>
574
810
  <Select
575
811
  value={pageSize.toString()}
576
812
  onValueChange={(value) => {
@@ -592,8 +828,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
592
828
  </div>
593
829
 
594
830
  <div className="flex items-center gap-2">
595
- <span className="text-sm text-muted-foreground">
596
- Page {currentPage} of {totalPages} ({sortedData.length} total)
831
+ <span className="text-xs sm:text-sm text-muted-foreground">
832
+ Page {currentPage} of {totalPages} <span className="hidden sm:inline">({sortedData.length} total)</span>
597
833
  </span>
598
834
  <div className="flex items-center gap-1">
599
835
  <Button
@@ -17,23 +17,26 @@ import {
17
17
  import { renderChildren } from '../../lib/utils';
18
18
 
19
19
  ComponentRegistry.register('resizable',
20
- ({ schema, className, ...props }: { schema: ResizableSchema; className?: string; [key: string]: any }) => (
21
- <ResizablePanelGroup
22
- orientation={(schema.direction || 'horizontal') as "horizontal" | "vertical"}
23
- className={className}
24
- {...props}
25
- style={{ minHeight: schema.minHeight || '200px' }}
26
- >
27
- {schema.panels?.map((panel: any, index: number) => (
28
- <React.Fragment key={index}>
29
- <ResizablePanel defaultSize={panel.defaultSize} minSize={panel.minSize} maxSize={panel.maxSize}>
30
- {renderChildren(panel.content)}
31
- </ResizablePanel>
32
- {index < schema.panels.length - 1 && <ResizableHandle withHandle={schema.withHandle} />}
33
- </React.Fragment>
34
- ))}
35
- </ResizablePanelGroup>
36
- ),
20
+ ({ schema, className, ...props }: { schema: ResizableSchema; className?: string; [key: string]: any }) => {
21
+ const panels = Array.isArray(schema.panels) ? schema.panels : [];
22
+ return (
23
+ <ResizablePanelGroup
24
+ orientation={(schema.direction || 'horizontal') as "horizontal" | "vertical"}
25
+ className={className}
26
+ {...props}
27
+ style={{ minHeight: schema.minHeight || '200px' }}
28
+ >
29
+ {panels.map((panel: any, index: number) => (
30
+ <React.Fragment key={index}>
31
+ <ResizablePanel defaultSize={panel.defaultSize} minSize={panel.minSize} maxSize={panel.maxSize}>
32
+ {renderChildren(panel.content)}
33
+ </ResizablePanel>
34
+ {index < panels.length - 1 && <ResizableHandle withHandle={schema.withHandle} />}
35
+ </React.Fragment>
36
+ ))}
37
+ </ResizablePanelGroup>
38
+ );
39
+ },
37
40
  {
38
41
  namespace: 'ui',
39
42
  label: 'Resizable Panel Group',
@@ -15,7 +15,7 @@ ComponentRegistry.register('list',
15
15
  ({ schema, className, ...props }: { schema: ListSchema; className?: string; [key: string]: any }) => {
16
16
  // Support data binding
17
17
  const boundData = useDataScope(schema.bind);
18
- const items = boundData || schema.items || [];
18
+ const items = Array.isArray(boundData) ? boundData : Array.isArray(schema.items) ? schema.items : [];
19
19
 
20
20
  // We use 'ol' or 'ul' based on ordered prop
21
21
  const ListTag = schema.ordered ? 'ol' : 'ul';
@@ -23,7 +23,7 @@ export const SimpleTableRenderer = ({ schema, className }: any) => {
23
23
  // Try to get data from binding first, then fall back to inline data
24
24
  const boundData = useDataScope(schema.bind);
25
25
  const data = boundData || schema.data || schema.props?.data || [];
26
- const columns = schema.columns || schema.props?.columns || [];
26
+ const columns = Array.isArray(schema.columns) ? schema.columns : Array.isArray(schema.props?.columns) ? schema.props.columns : [];
27
27
 
28
28
  // If we have data but it's not an array, show error.
29
29
  // If data is undefined, we might just be loading or empty.
@@ -102,7 +102,8 @@ ComponentRegistry.register('tree-view',
102
102
 
103
103
  // Support data binding
104
104
  const boundData = useDataScope(schema.bind);
105
- const nodes = boundData || schema.nodes || schema.data || [];
105
+ const rawNodes = boundData || schema.nodes || schema.data || [];
106
+ const nodes = Array.isArray(rawNodes) ? rawNodes : [];
106
107
 
107
108
  return (
108
109
  <div className={cn(
@@ -228,9 +228,17 @@ ComponentRegistry.register('form',
228
228
  disabled: fieldDisabled = false,
229
229
  validation = {},
230
230
  condition,
231
+ colSpan,
232
+ hidden,
233
+ widget,
234
+ visibleOn,
235
+ readonly,
231
236
  ...fieldProps
232
237
  } = field;
233
238
 
239
+ // Skip hidden fields
240
+ if (hidden) return null;
241
+
234
242
  // Handle conditional rendering with null/undefined safety
235
243
  if (condition) {
236
244
  const watchField = condition.field;
@@ -264,6 +272,17 @@ ComponentRegistry.register('form',
264
272
  // Use field.id or field.name for stable keys (never use index alone)
265
273
  const fieldKey = field.id ?? name;
266
274
 
275
+ // Resolve the component type: prefer widget override, fallback to field type
276
+ const resolvedType = widget || type;
277
+
278
+ // colSpan classes for grid layout
279
+ const colSpanClass = colSpan && colSpan > 1
280
+ ? colSpan === 2 ? 'col-span-2'
281
+ : colSpan === 3 ? 'col-span-3'
282
+ : colSpan >= 4 ? 'col-span-4'
283
+ : ''
284
+ : '';
285
+
267
286
  return (
268
287
  <FormField
269
288
  key={fieldKey}
@@ -271,9 +290,9 @@ ComponentRegistry.register('form',
271
290
  name={name}
272
291
  rules={rules}
273
292
  render={({ field: formField }) => (
274
- <FormItem>
293
+ <FormItem className={colSpanClass || undefined}>
275
294
  {label && (
276
- <FormLabel>
295
+ <FormLabel className="text-xs sm:text-sm">
277
296
  {label}
278
297
  {required && (
279
298
  <span className="text-destructive ml-1" aria-label="required">
@@ -283,8 +302,8 @@ ComponentRegistry.register('form',
283
302
  </FormLabel>
284
303
  )}
285
304
  <FormControl>
286
- {/* Render the actual field component based on type */}
287
- {renderFieldComponent(type, {
305
+ {/* Render the actual field component based on resolved type */}
306
+ {renderFieldComponent(resolvedType, {
288
307
  ...fieldProps,
289
308
  // specialized fields needs raw metadata, but we should traverse down if it exists
290
309
  // field is the field configuration loop variable
@@ -293,7 +312,7 @@ ComponentRegistry.register('form',
293
312
  inputType: fieldProps.inputType,
294
313
  options: fieldProps.options,
295
314
  placeholder: fieldProps.placeholder,
296
- disabled: disabled || fieldDisabled || isSubmitting,
315
+ disabled: disabled || fieldDisabled || readonly || isSubmitting,
297
316
  })}
298
317
  </FormControl>
299
318
  {description && (
@@ -310,13 +329,14 @@ ComponentRegistry.register('form',
310
329
 
311
330
  {/* Form Actions */}
312
331
  {(schema.showActions !== false) && (
313
- <div className={`flex gap-2 ${layout === 'horizontal' ? 'justify-end' : 'justify-start'} mt-6`}>
332
+ <div className={`flex flex-col sm:flex-row gap-2 ${layout === 'horizontal' ? 'sm:justify-end' : 'sm:justify-start'} mt-6`}>
314
333
  {showCancel && (
315
334
  <Button
316
335
  type="button"
317
336
  variant="outline"
318
337
  onClick={handleCancel}
319
338
  disabled={isSubmitting || disabled}
339
+ className="w-full sm:w-auto"
320
340
  >
321
341
  {cancelLabel}
322
342
  </Button>
@@ -325,6 +345,7 @@ ComponentRegistry.register('form',
325
345
  <Button
326
346
  type="submit"
327
347
  disabled={isSubmitting || disabled}
348
+ className="w-full sm:w-auto"
328
349
  >
329
350
  {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
330
351
  {submitLabel}
@@ -441,12 +462,12 @@ function renderFieldComponent(type: string, props: RenderFieldProps) {
441
462
  if (inputType === 'file') {
442
463
  // File inputs cannot be controlled with value prop
443
464
  const { value, ...fileProps } = fieldProps;
444
- return <Input type="file" placeholder={placeholder} {...fileProps} />;
465
+ return <Input type="file" placeholder={placeholder} className="min-h-[44px] sm:min-h-0" {...fileProps} />;
445
466
  }
446
- return <Input type={inputType || 'text'} placeholder={placeholder} {...fieldProps} value={fieldProps.value ?? ''} />;
467
+ return <Input type={inputType || 'text'} placeholder={placeholder} className="min-h-[44px] sm:min-h-0" {...fieldProps} value={fieldProps.value ?? ''} />;
447
468
 
448
469
  case 'textarea':
449
- return <Textarea placeholder={placeholder} {...fieldProps} value={fieldProps.value ?? ''} />;
470
+ return <Textarea placeholder={placeholder} className="min-h-[44px] sm:min-h-0" {...fieldProps} value={fieldProps.value ?? ''} />;
450
471
 
451
472
  case 'checkbox': {
452
473
  // For checkbox, we need to handle the value differently
@@ -455,6 +476,7 @@ function renderFieldComponent(type: string, props: RenderFieldProps) {
455
476
  <Checkbox
456
477
  checked={value}
457
478
  onCheckedChange={onChange}
479
+ className="min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0"
458
480
  {...checkboxProps}
459
481
  />
460
482
  );
@@ -467,6 +489,7 @@ function renderFieldComponent(type: string, props: RenderFieldProps) {
467
489
  <Switch
468
490
  checked={value}
469
491
  onCheckedChange={onChange}
492
+ className="min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0"
470
493
  {...switchProps}
471
494
  />
472
495
  );
@@ -483,7 +506,7 @@ function renderFieldComponent(type: string, props: RenderFieldProps) {
483
506
 
484
507
  return (
485
508
  <Select value={selectValue} onValueChange={selectOnChange} {...selectProps}>
486
- <SelectTrigger>
509
+ <SelectTrigger className="min-h-[44px] sm:min-h-0">
487
510
  <SelectValue placeholder={placeholder ?? 'Select an option'} />
488
511
  </SelectTrigger>
489
512
  <SelectContent>
@@ -15,3 +15,4 @@ import './feedback';
15
15
  import './overlay';
16
16
  import './disclosure';
17
17
  import './complex';
18
+ import './action';
@@ -28,7 +28,7 @@ ComponentRegistry.register('aspect-ratio',
28
28
  {...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }}
29
29
  >
30
30
  {schema.image ? (
31
- <img src={schema.image} alt={schema.alt || ''} className="rounded-md object-cover w-full h-full" />
31
+ <img src={schema.image} alt={schema.alt || ''} loading="lazy" className="rounded-md object-cover w-full h-full" />
32
32
  ) : (
33
33
  renderChildren(schema.children || schema.body)
34
34
  )}