Skip to content

Commit e74fd1e

Browse files
authored
Merge pull request #907 from objectstack-ai/copilot/optimize-grid-list-ux
2 parents e1bdc9f + 03cdb5d commit e74fd1e

File tree

9 files changed

+168
-38
lines changed

9 files changed

+168
-38
lines changed

ROADMAP.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -746,6 +746,52 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
746746
- [x] 11 new tests (SystemHubPage, AppManagementPage, PermissionManagementPage)
747747
- [x] Total: 20 system page tests passing
748748

749+
### P1.13 Airtable Grid/List UX Optimization ✅
750+
751+
> **Status:** Complete — Grid/List components now match Airtable UX patterns for date formatting, row interactions, editing, density, headers, filters, and empty states.
752+
753+
**Date Field Humanized Format:**
754+
- [x] `formatDate`, `formatDateTime`, `DateTimeCellRenderer` use browser locale (`undefined` instead of `'en-US'`)
755+
- [x] All date columns auto-format to localized human-readable format (e.g., "2024/2/28 12:57am")
756+
757+
**Row Hover "Open >" Button:**
758+
- [x] Expand button changed from icon-only `<Expand>` to text "Open >" with `<ChevronRight>` icon
759+
- [x] Consistent across Grid and ListView (shown on row hover)
760+
761+
**Single-Click Edit Mode:**
762+
- [x] Added `singleClickEdit` prop to `DataTableSchema` and `ObjectGridSchema`
763+
- [x] When true, clicking a cell enters edit mode (instead of double-click)
764+
765+
**Default Compact Row Height:**
766+
- [x] ObjectGrid default changed from `'medium'` to `'compact'` (32-36px rows)
767+
- [x] ListView default density changed from `'comfortable'` to `'compact'`
768+
- [x] Row height toggle preserved in toolbar
769+
770+
**Single-Click Edit Mode:**
771+
- [x] Added `singleClickEdit` prop to `DataTableSchema` and `ObjectGridSchema`
772+
- [x] ObjectGrid defaults `singleClickEdit` to `true` (click-to-edit by default)
773+
- [x] InlineEditing component already compatible (click-to-edit native)
774+
775+
**Column Header Minimal Style:**
776+
- [x] Headers use `text-xs font-normal text-muted-foreground` (was `text-[11px] font-semibold uppercase tracking-wider`)
777+
- [x] Sort arrows inline with header text
778+
779+
**Filter Pill/Chip Styling:**
780+
- [x] Filter badges use `rounded-full` for Airtable-style pill appearance
781+
- [x] "More" overflow button matches pill styling
782+
783+
**Column Width Auto-Sizing:**
784+
- [x] Auto column width estimation based on header and data content (80-400px range)
785+
- [x] Samples up to 50 rows for width calculation
786+
787+
**Row Selection Checkbox Style:**
788+
- [x] Added `selectionStyle` prop ('always'|'hover') to `DataTableSchema`
789+
- [x] 'hover' mode shows checkboxes only on row hover
790+
791+
**Empty Table Ghost Row:**
792+
- [x] Empty tables show 3 ghost placeholder rows with skeleton-like appearance
793+
- [x] Ghost rows use varying widths for visual variety
794+
749795
---
750796

751797
## 🧩 P2 — Polish & Advanced Features

packages/components/src/renderers/complex/data-table.tsx

Lines changed: 88 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ import {
5353

5454
type 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
5767
const 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
>

packages/fields/src/index.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ export function formatDate(value: string | Date, style?: string): string {
163163
return formatRelativeDate(date);
164164
}
165165

166-
// Default format: MMM DD, YYYY
167-
return date.toLocaleDateString('en-US', {
166+
// Default format: locale-aware human-readable (e.g. "Jan 15, 2024" or "2024/1/15")
167+
return date.toLocaleDateString(undefined, {
168168
year: 'numeric',
169169
month: 'short',
170170
day: 'numeric',
@@ -179,7 +179,7 @@ export function formatDateTime(value: string | Date): string {
179179
const date = typeof value === 'string' ? new Date(value) : value;
180180
if (isNaN(date.getTime())) return '-';
181181

182-
return date.toLocaleDateString('en-US', {
182+
return date.toLocaleDateString(undefined, {
183183
year: 'numeric',
184184
month: 'short',
185185
day: 'numeric',
@@ -334,12 +334,12 @@ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactE
334334
const date = typeof value === 'string' ? new Date(value) : value;
335335
if (isNaN(date.getTime())) return <span className="text-muted-foreground">-</span>;
336336

337-
const datePart = date.toLocaleDateString('en-US', {
337+
const datePart = date.toLocaleDateString(undefined, {
338338
month: 'numeric',
339339
day: 'numeric',
340340
year: 'numeric',
341341
});
342-
const timePart = date.toLocaleTimeString('en-US', {
342+
const timePart = date.toLocaleTimeString(undefined, {
343343
hour: 'numeric',
344344
minute: '2-digit',
345345
hour12: true,

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
182182
const [useCardView, setUseCardView] = useState(false);
183183
const [refreshKey, setRefreshKey] = useState(0);
184184
const [showExport, setShowExport] = useState(false);
185-
const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'medium');
185+
const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'compact');
186186
const [selectedRows, setSelectedRows] = useState<any[]>([]);
187187

188188
// Column state persistence (order and widths)
@@ -971,6 +971,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
971971
resizableColumns: schema.resizable ?? schema.resizableColumns ?? true,
972972
reorderableColumns: schema.reorderableColumns ?? false,
973973
editable: schema.editable ?? false,
974+
singleClickEdit: schema.singleClickEdit ?? true,
974975
className: schema.className,
975976
cellClassName: rowHeightMode === 'compact'
976977
? 'px-3 py-1 text-[13px] leading-tight'

packages/plugin-list/src/ListView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,7 @@ export const ListView: React.FC<ListViewProps> = ({
469469
};
470470
return map[schema.rowHeight] || 'comfortable';
471471
}
472-
return 'comfortable';
472+
return 'compact';
473473
}, [schema.densityMode, schema.rowHeight]);
474474
const density = useDensityMode(resolvedDensity);
475475

@@ -1312,7 +1312,7 @@ export const ListView: React.FC<ListViewProps> = ({
13121312
size="sm"
13131313
className={cn(
13141314
"h-7 px-2 text-muted-foreground hover:text-primary text-xs hidden lg:flex transition-colors duration-150",
1315-
density.mode !== 'comfortable' && "bg-primary/10 border border-primary/20 text-primary"
1315+
density.mode !== 'compact' && "bg-primary/10 border border-primary/20 text-primary"
13161316
)}
13171317
onClick={density.cycle}
13181318
title={`Density: ${density.mode}`}

packages/plugin-list/src/UserFilters.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible,
205205
<button
206206
data-testid={`filter-badge-${f.field}`}
207207
className={cn(
208-
'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
208+
'inline-flex items-center gap-1 rounded-full border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
209209
hasSelection
210210
? 'border-primary/30 bg-primary/5 text-primary'
211211
: 'border-border bg-background hover:bg-accent text-foreground',
@@ -285,7 +285,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible,
285285
<PopoverTrigger asChild>
286286
<button
287287
data-testid="user-filters-more"
288-
className="inline-flex items-center gap-1 rounded-md border border-border bg-background hover:bg-accent text-foreground h-7 px-2.5 text-xs font-medium transition-colors shrink-0"
288+
className="inline-flex items-center gap-1 rounded-full border border-border bg-background hover:bg-accent text-foreground h-7 px-2.5 text-xs font-medium transition-colors shrink-0"
289289
>
290290
<span>More</span>
291291
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-muted text-[10px] font-medium">

packages/plugin-list/src/__tests__/ListView.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,8 +300,8 @@ describe('ListView', () => {
300300

301301
renderWithProvider(<ListView schema={schema} />);
302302

303-
// Default density mode is 'comfortable'
304-
const densityButton = screen.getByTitle('Density: comfortable');
303+
// Default density mode is 'compact'
304+
const densityButton = screen.getByTitle('Density: compact');
305305
expect(densityButton).toBeInTheDocument();
306306
});
307307

packages/types/src/data-display.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ export interface DataTableSchema extends BaseSchema {
341341
* @default false
342342
*/
343343
selectable?: boolean | 'single' | 'multiple';
344+
/**
345+
* Selection checkbox display style
346+
* - 'always': Checkboxes are always visible
347+
* - 'hover': Checkboxes only appear on row hover
348+
* @default 'always'
349+
*/
350+
selectionStyle?: 'always' | 'hover';
344351
/**
345352
* Enable column sorting
346353
* @default true
@@ -388,6 +395,12 @@ export interface DataTableSchema extends BaseSchema {
388395
* @default false
389396
*/
390397
editable?: boolean;
398+
/**
399+
* Enable single-click editing mode
400+
* When true with editable, clicking a cell enters edit mode (instead of double-click)
401+
* @default false
402+
*/
403+
singleClickEdit?: boolean;
391404
/**
392405
* Cell value change handler
393406
* Called when a cell value is edited

0 commit comments

Comments
 (0)