@@ -53,6 +53,16 @@ import {
5353
5454type SortDirection = 'asc' | 'desc' | null ;
5555
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+
5666// Default English fallback translations for the data table
5767const TABLE_DEFAULT_TRANSLATIONS : Record < string , string > = {
5868 'table.rowsPerPage' : 'Rows per page' ,
@@ -147,6 +157,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
147157 resizableColumns = true ,
148158 reorderableColumns = true ,
149159 editable = false ,
160+ singleClickEdit = false ,
161+ selectionStyle = 'always' ,
150162 rowClassName,
151163 rowStyle,
152164 className,
@@ -171,6 +183,31 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
171183 } ) ) ;
172184 } , [ rawColumns ] ) ;
173185
186+ // Auto-size columns: estimate width from header and data content for columns without explicit widths
187+ const autoSizedWidths = useMemo ( ( ) => {
188+ const widths : Record < string , number > = { } ;
189+ const cols = rawColumns . map ( ( col : any ) => ( {
190+ header : col . header || col . label ,
191+ accessorKey : col . accessorKey || col . name ,
192+ width : col . width ,
193+ } ) ) ;
194+ for ( const col of cols ) {
195+ if ( col . width ) continue ; // Skip columns with explicit widths
196+ const headerLen = ( col . header || '' ) . length ;
197+ let maxLen = headerLen ;
198+ // Sample up to 50 rows for content width estimation
199+ const sampleRows = data . slice ( 0 , 50 ) ;
200+ for ( const row of sampleRows ) {
201+ const val = row [ col . accessorKey ] ;
202+ const len = val != null ? String ( val ) . length : 0 ;
203+ if ( len > maxLen ) maxLen = len ;
204+ }
205+ // Estimate pixel width: ~8px per character + 48px padding, min 80, max 400
206+ widths [ col . accessorKey ] = Math . min ( 400 , Math . max ( 80 , maxLen * 8 + 48 ) ) ;
207+ }
208+ return widths ;
209+ } , [ rawColumns , data ] ) ;
210+
174211 // State management
175212 const [ searchQuery , setSearchQuery ] = useState ( '' ) ;
176213 const [ sortColumn , setSortColumn ] = useState < string | null > ( null ) ;
@@ -693,14 +730,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
693730 </ TableHead >
694731 ) }
695732 { columns . map ( ( col , index ) => {
696- const columnWidth = columnWidths [ col . accessorKey ] || col . width ;
733+ const columnWidth = columnWidths [ col . accessorKey ] || col . width || autoSizedWidths [ col . accessorKey ] ;
697734 const isDragging = draggedColumn === index ;
698735 const isDragOver = dragOverColumn === index ;
699736 const isFrozen = frozenColumns > 0 && index < frozenColumns ;
700737 const frozenOffset = isFrozen
701738 ? columns . slice ( 0 , index ) . reduce ( ( sum , c , i ) => {
702739 if ( i < frozenColumns ) {
703- const w = columnWidths [ c . accessorKey ] || c . width ;
740+ const w = columnWidths [ c . accessorKey ] || c . width || autoSizedWidths [ c . accessorKey ] ;
704741 return sum + ( typeof w === 'number' ? w : w ? parseInt ( String ( w ) , 10 ) || 150 : 150 ) ;
705742 }
706743 return sum ;
@@ -745,7 +782,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
745782 { col . headerIcon && (
746783 < span className = "text-muted-foreground flex-shrink-0" > { col . headerIcon } </ span >
747784 ) }
748- < span className = "text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 " > { col . header } </ span >
785+ < span className = "text-xs font-normal text-muted-foreground" > { col . header } </ span >
749786 { sortable && col . sortable !== false && getSortIcon ( col . accessorKey ) }
750787 </ div >
751788 { resizableColumns && col . resizable !== false && (
@@ -766,18 +803,33 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
766803 </ TableHeader >
767804 < TableBody >
768805 { paginatedData . length === 0 ? (
769- < TableRow >
770- < TableCell
771- colSpan = { columns . length + ( selectable ? 1 : 0 ) + ( showRowNumbers ? 1 : 0 ) + ( rowActions ? 1 : 0 ) }
772- className = "h-96 text-center text-muted-foreground"
773- >
774- < div className = "flex flex-col items-center justify-center gap-2" >
775- < Search className = "h-8 w-8 text-muted-foreground/50" />
776- < p > No results found</ p >
777- < p className = "text-xs text-muted-foreground/50" > Try adjusting your filters or search query.</ p >
778- </ div >
779- </ TableCell >
780- </ TableRow >
806+ < >
807+ < TableRow >
808+ < TableCell
809+ colSpan = { columns . length + ( selectable ? 1 : 0 ) + ( showRowNumbers ? 1 : 0 ) + ( rowActions ? 1 : 0 ) }
810+ className = "h-24 text-center text-muted-foreground"
811+ >
812+ < div className = "flex flex-col items-center justify-center gap-2" >
813+ < Search className = "h-8 w-8 text-muted-foreground/50" />
814+ < p > No results found</ p >
815+ < p className = "text-xs text-muted-foreground/50" > Try adjusting your filters or search query.</ p >
816+ </ div >
817+ </ TableCell >
818+ </ TableRow >
819+ { /* Ghost placeholder rows – visual skeleton to maintain table height when empty */ }
820+ { Array . from ( { length : GHOST_ROW_COUNT } ) . map ( ( _ , i ) => (
821+ < TableRow key = { `ghost-${ i } ` } className = "hover:bg-transparent opacity-[0.15] pointer-events-none" data-testid = "ghost-row" >
822+ { selectable && < TableCell className = "p-3" > < div className = "h-4 w-4 rounded border border-muted-foreground/30" /> </ TableCell > }
823+ { showRowNumbers && < TableCell className = "text-center p-3" > < div className = "h-3 w-6 mx-auto rounded bg-muted-foreground/30" /> </ TableCell > }
824+ { columns . map ( ( _col , ci ) => (
825+ < TableCell key = { ci } className = "p-3" >
826+ < div className = { cn ( "h-3 rounded bg-muted-foreground/30" , ghostCellWidth ( ci , columns . length ) ) } />
827+ </ TableCell >
828+ ) ) }
829+ { rowActions && < TableCell className = "p-3" > < div className = "h-3 w-8 rounded bg-muted-foreground/30" /> </ TableCell > }
830+ </ TableRow >
831+ ) ) }
832+ </ >
781833 ) : (
782834 < >
783835 { paginatedData . map ( ( row , rowIndex ) => {
@@ -810,11 +862,20 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
810862 } }
811863 >
812864 { selectable && (
813- < TableCell className = { cn ( frozenColumns > 0 && "sticky left-0 z-10 bg-background" ) } >
814- < Checkbox
815- checked = { isSelected }
816- onCheckedChange = { ( checked ) => handleSelectRow ( rowId , checked as boolean ) }
817- />
865+ < TableCell className = { cn ( frozenColumns > 0 && "sticky left-0 z-10 bg-background" , selectionStyle === 'hover' && "relative" ) } >
866+ { selectionStyle === 'hover' ? (
867+ < div className = { cn ( "transition-opacity" , isSelected ? "opacity-100" : "opacity-0 group-hover/row:opacity-100" ) } >
868+ < Checkbox
869+ checked = { isSelected }
870+ onCheckedChange = { ( checked ) => handleSelectRow ( rowId , checked as boolean ) }
871+ />
872+ </ div >
873+ ) : (
874+ < Checkbox
875+ checked = { isSelected }
876+ onCheckedChange = { ( checked ) => handleSelectRow ( rowId , checked as boolean ) }
877+ />
878+ ) }
818879 </ TableCell >
819880 ) }
820881 { showRowNumbers && (
@@ -833,21 +894,22 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
833894 ) : schema . onRowClick && (
834895 < button
835896 type = "button"
836- className = "absolute inset-0 hidden group-hover/row:flex items-center justify-center text-muted-foreground hover:text-primary"
897+ 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 "
837898 data-testid = "row-expand-button"
838899 onClick = { ( e ) => {
839900 e . stopPropagation ( ) ;
840901 schema . onRowClick ?.( row ) ;
841902 } }
842903 title = "Open record"
843904 >
844- < Expand className = "h-3.5 w-3.5" />
905+ < span > Open</ span >
906+ < ChevronRight className = "h-3 w-3" />
845907 </ button >
846908 ) }
847909 </ TableCell >
848910 ) }
849911 { columns . map ( ( col , colIndex ) => {
850- const columnWidth = columnWidths [ col . accessorKey ] || col . width ;
912+ const columnWidth = columnWidths [ col . accessorKey ] || col . width || autoSizedWidths [ col . accessorKey ] ;
851913 const originalValue = row [ col . accessorKey ] ;
852914 const hasPendingChange = rowChanges [ col . accessorKey ] !== undefined ;
853915 const cellValue = hasPendingChange ? rowChanges [ col . accessorKey ] : originalValue ;
@@ -857,7 +919,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
857919 const frozenOffset = isFrozen
858920 ? columns . slice ( 0 , colIndex ) . reduce ( ( sum , c , i ) => {
859921 if ( i < frozenColumns ) {
860- const w = columnWidths [ c . accessorKey ] || c . width ;
922+ const w = columnWidths [ c . accessorKey ] || c . width || autoSizedWidths [ c . accessorKey ] ;
861923 return sum + ( typeof w === 'number' ? w : w ? parseInt ( String ( w ) , 10 ) || 150 : 150 ) ;
862924 }
863925 return sum ;
@@ -882,7 +944,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
882944 maxWidth : columnWidth ,
883945 ...( isFrozen && { left : frozenOffset } ) ,
884946 } }
885- onDoubleClick = { ( ) => isEditable && startEdit ( rowIndex , col . accessorKey ) }
947+ onDoubleClick = { ( ) => isEditable && ! singleClickEdit && startEdit ( rowIndex , col . accessorKey ) }
948+ onClick = { ( ) => isEditable && singleClickEdit && startEdit ( rowIndex , col . accessorKey ) }
886949 onKeyDown = { ( e ) => handleCellKeyDown ( e , rowIndex , col . accessorKey ) }
887950 tabIndex = { 0 }
888951 >
0 commit comments