From 9371c94539f3d3ca6bb12530be79c1dc163df3b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:42:51 +0000 Subject: [PATCH 1/5] Initial plan From adf2d13e7024197f7eae6fc68cf4dea02134de6b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:51:19 +0000 Subject: [PATCH 2/5] feat: Airtable-style Grid/List UX optimizations - Date fields use browser locale for humanized formatting - Row hover shows "Open >" text button instead of icon-only - Add singleClickEdit prop for click-to-edit mode - Default compact row height in ObjectGrid - Column headers use minimal font-normal style - Empty table shows ghost row placeholders Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/renderers/complex/data-table.tsx | 50 +++++++++++++------ packages/fields/src/index.tsx | 10 ++-- packages/plugin-grid/src/ObjectGrid.tsx | 3 +- packages/types/src/data-display.ts | 6 +++ packages/types/src/objectql.ts | 9 +++- 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index eac281db7..e08f1a95a 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -147,6 +147,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { resizableColumns = true, reorderableColumns = true, editable = false, + singleClickEdit = false, rowClassName, rowStyle, className, @@ -745,7 +746,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { {col.headerIcon && ( {col.headerIcon} )} - {col.header} + {col.header} {sortable && col.sortable !== false && getSortIcon(col.accessorKey)} {resizableColumns && col.resizable !== false && ( @@ -766,18 +767,33 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { {paginatedData.length === 0 ? ( - - - - - No results found - Try adjusting your filters or search query. - - - + <> + + + + + No results found + Try adjusting your filters or search query. + + + + {/* Ghost placeholder rows */} + {Array.from({ length: 3 }).map((_, i) => ( + + {selectable && } + {showRowNumbers && } + {columns.map((col, ci) => ( + + + + ))} + {rowActions && } + + ))} + > ) : ( <> {paginatedData.map((row, rowIndex) => { @@ -833,7 +849,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { ) : schema.onRowClick && ( { e.stopPropagation(); @@ -841,7 +857,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { }} title="Open record" > - + Open + )} @@ -882,7 +899,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { maxWidth: columnWidth, ...(isFrozen && { left: frozenOffset }), }} - onDoubleClick={() => isEditable && startEdit(rowIndex, col.accessorKey)} + onDoubleClick={() => isEditable && !singleClickEdit && startEdit(rowIndex, col.accessorKey)} + onClick={() => isEditable && singleClickEdit && startEdit(rowIndex, col.accessorKey)} onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)} tabIndex={0} > diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 705bc2e45..89390a480 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -163,8 +163,8 @@ export function formatDate(value: string | Date, style?: string): string { return formatRelativeDate(date); } - // Default format: MMM DD, YYYY - return date.toLocaleDateString('en-US', { + // Default format: locale-aware human-readable (e.g. "Jan 15, 2024" or "2024/1/15") + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', @@ -179,7 +179,7 @@ export function formatDateTime(value: string | Date): string { const date = typeof value === 'string' ? new Date(value) : value; if (isNaN(date.getTime())) return '-'; - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', @@ -334,12 +334,12 @@ export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactE const date = typeof value === 'string' ? new Date(value) : value; if (isNaN(date.getTime())) return -; - const datePart = date.toLocaleDateString('en-US', { + const datePart = date.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric', year: 'numeric', }); - const timePart = date.toLocaleTimeString('en-US', { + const timePart = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true, diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 92a3522bb..f572e3926 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -182,7 +182,7 @@ export const ObjectGrid: React.FC = ({ const [useCardView, setUseCardView] = useState(false); const [refreshKey, setRefreshKey] = useState(0); const [showExport, setShowExport] = useState(false); - const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'medium'); + const [rowHeightMode, setRowHeightMode] = useState<'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'>(schema.rowHeight ?? 'compact'); const [selectedRows, setSelectedRows] = useState([]); // Column state persistence (order and widths) @@ -971,6 +971,7 @@ export const ObjectGrid: React.FC = ({ resizableColumns: schema.resizable ?? schema.resizableColumns ?? true, reorderableColumns: schema.reorderableColumns ?? false, editable: schema.editable ?? false, + singleClickEdit: schema.singleClickEdit ?? false, className: schema.className, cellClassName: rowHeightMode === 'compact' ? 'px-3 py-1 text-[13px] leading-tight' diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 9aef0ac55..3587642ff 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -388,6 +388,12 @@ export interface DataTableSchema extends BaseSchema { * @default false */ editable?: boolean; + /** + * Enable single-click editing mode + * When true with editable, clicking a cell enters edit mode (instead of double-click) + * @default false + */ + singleClickEdit?: boolean; /** * Cell value change handler * Called when a cell value is edited diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9f9265ec5..ddd2d603b 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -497,6 +497,13 @@ export interface ObjectGridSchema extends BaseSchema { * @default false */ editable?: boolean; + + /** + * Enable single-click editing mode + * When true with editable, clicking a cell enters edit mode (instead of double-click) + * @default false + */ + singleClickEdit?: boolean; /** * Grouping Configuration (Airtable-style) @@ -532,7 +539,7 @@ export interface ObjectGridSchema extends BaseSchema { * Row height preset for the grid. * Controls the density of grid rows. * Aligned with @objectstack/spec RowHeight enum. - * @default 'medium' + * @default 'compact' */ rowHeight?: 'compact' | 'short' | 'medium' | 'tall' | 'extra_tall'; From 66459f30a95b86c175860ae491d347367d4439be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 06:58:12 +0000 Subject: [PATCH 3/5] feat: filter pill/chip styling, selection style, auto column widths, fix TS warning - Filter badges use rounded-full for Airtable-style pill appearance - Add selectionStyle prop ('always'|'hover') for checkbox visibility - Add auto column width estimation from data content - Fix unused variable in ghost row template Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- .../src/renderers/complex/data-table.tsx | 55 +++++++++++++++---- packages/plugin-list/src/UserFilters.tsx | 4 +- packages/types/src/data-display.ts | 7 +++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index e08f1a95a..dd0244849 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -148,6 +148,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { reorderableColumns = true, editable = false, singleClickEdit = false, + selectionStyle = 'always', rowClassName, rowStyle, className, @@ -172,6 +173,31 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { })); }, [rawColumns]); + // Auto-size columns: estimate width from header and data content for columns without explicit widths + const autoSizedWidths = useMemo(() => { + const widths: Record = {}; + const cols = rawColumns.map((col: any) => ({ + header: col.header || col.label, + accessorKey: col.accessorKey || col.name, + width: col.width, + })); + for (const col of cols) { + if (col.width) continue; // Skip columns with explicit widths + const headerLen = (col.header || '').length; + let maxLen = headerLen; + // Sample up to 50 rows for content width estimation + const sampleRows = data.slice(0, 50); + for (const row of sampleRows) { + const val = row[col.accessorKey]; + const len = val != null ? String(val).length : 0; + if (len > maxLen) maxLen = len; + } + // Estimate pixel width: ~8px per character + 48px padding, min 80, max 400 + widths[col.accessorKey] = Math.min(400, Math.max(80, maxLen * 8 + 48)); + } + return widths; + }, [rawColumns, data]); + // State management const [searchQuery, setSearchQuery] = useState(''); const [sortColumn, setSortColumn] = useState(null); @@ -694,14 +720,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { )} {columns.map((col, index) => { - const columnWidth = columnWidths[col.accessorKey] || col.width; + const columnWidth = columnWidths[col.accessorKey] || col.width || autoSizedWidths[col.accessorKey]; const isDragging = draggedColumn === index; const isDragOver = dragOverColumn === index; const isFrozen = frozenColumns > 0 && index < frozenColumns; const frozenOffset = isFrozen ? columns.slice(0, index).reduce((sum, c, i) => { if (i < frozenColumns) { - const w = columnWidths[c.accessorKey] || c.width; + const w = columnWidths[c.accessorKey] || c.width || autoSizedWidths[c.accessorKey]; return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150); } return sum; @@ -785,7 +811,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { {selectable && } {showRowNumbers && } - {columns.map((col, ci) => ( + {columns.map((_col, ci) => ( @@ -826,11 +852,20 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { }} > {selectable && ( - 0 && "sticky left-0 z-10 bg-background")}> - handleSelectRow(rowId, checked as boolean)} - /> + 0 && "sticky left-0 z-10 bg-background", selectionStyle === 'hover' && "relative")}> + {selectionStyle === 'hover' ? ( + + handleSelectRow(rowId, checked as boolean)} + /> + + ) : ( + handleSelectRow(rowId, checked as boolean)} + /> + )} )} {showRowNumbers && ( @@ -864,7 +899,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { )} {columns.map((col, colIndex) => { - const columnWidth = columnWidths[col.accessorKey] || col.width; + const columnWidth = columnWidths[col.accessorKey] || col.width || autoSizedWidths[col.accessorKey]; const originalValue = row[col.accessorKey]; const hasPendingChange = rowChanges[col.accessorKey] !== undefined; const cellValue = hasPendingChange ? rowChanges[col.accessorKey] : originalValue; @@ -874,7 +909,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { const frozenOffset = isFrozen ? columns.slice(0, colIndex).reduce((sum, c, i) => { if (i < frozenColumns) { - const w = columnWidths[c.accessorKey] || c.width; + const w = columnWidths[c.accessorKey] || c.width || autoSizedWidths[c.accessorKey]; return sum + (typeof w === 'number' ? w : w ? parseInt(String(w), 10) || 150 : 150); } return sum; diff --git a/packages/plugin-list/src/UserFilters.tsx b/packages/plugin-list/src/UserFilters.tsx index 819bfb280..375cd8cb4 100644 --- a/packages/plugin-list/src/UserFilters.tsx +++ b/packages/plugin-list/src/UserFilters.tsx @@ -205,7 +205,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible, More diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 3587642ff..9ed0ab01e 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -341,6 +341,13 @@ export interface DataTableSchema extends BaseSchema { * @default false */ selectable?: boolean | 'single' | 'multiple'; + /** + * Selection checkbox display style + * - 'always': Checkboxes are always visible + * - 'hover': Checkboxes only appear on row hover + * @default 'always' + */ + selectionStyle?: 'always' | 'hover'; /** * Enable column sorting * @default true From 9b0a4ccb7d06e39c8ea252e7ea5c0399f5ceeac3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:01:03 +0000 Subject: [PATCH 4/5] refactor: address code review feedback - extract ghost row constants and helpers - Extract GHOST_ROW_COUNT constant with documentation - Extract ghostCellWidth() helper function for readability - Add clarifying comments for auto-sizing memo - Update ROADMAP.md with P1.13 Airtable Grid UX section Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 40 +++++++++++++++++++ .../src/renderers/complex/data-table.tsx | 16 ++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3c3a7664f..aa60b227d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -746,6 +746,46 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind - [x] 11 new tests (SystemHubPage, AppManagementPage, PermissionManagementPage) - [x] Total: 20 system page tests passing +### P1.13 Airtable Grid/List UX Optimization ✅ + +> **Status:** Complete — Grid/List components now match Airtable UX patterns for date formatting, row interactions, editing, density, headers, filters, and empty states. + +**Date Field Humanized Format:** +- [x] `formatDate`, `formatDateTime`, `DateTimeCellRenderer` use browser locale (`undefined` instead of `'en-US'`) +- [x] All date columns auto-format to localized human-readable format (e.g., "2024/2/28 12:57am") + +**Row Hover "Open >" Button:** +- [x] Expand button changed from icon-only `` to text "Open >" with `` icon +- [x] Consistent across Grid and ListView (shown on row hover) + +**Single-Click Edit Mode:** +- [x] Added `singleClickEdit` prop to `DataTableSchema` and `ObjectGridSchema` +- [x] When true, clicking a cell enters edit mode (instead of double-click) + +**Default Compact Row Height:** +- [x] ObjectGrid default changed from `'medium'` to `'compact'` (32-36px rows) +- [x] Row height toggle preserved in toolbar + +**Column Header Minimal Style:** +- [x] Headers use `text-xs font-normal text-muted-foreground` (was `text-[11px] font-semibold uppercase tracking-wider`) +- [x] Sort arrows inline with header text + +**Filter Pill/Chip Styling:** +- [x] Filter badges use `rounded-full` for Airtable-style pill appearance +- [x] "More" overflow button matches pill styling + +**Column Width Auto-Sizing:** +- [x] Auto column width estimation based on header and data content (80-400px range) +- [x] Samples up to 50 rows for width calculation + +**Row Selection Checkbox Style:** +- [x] Added `selectionStyle` prop ('always'|'hover') to `DataTableSchema` +- [x] 'hover' mode shows checkboxes only on row hover + +**Empty Table Ghost Row:** +- [x] Empty tables show 3 ghost placeholder rows with skeleton-like appearance +- [x] Ghost rows use varying widths for visual variety + --- ## 🧩 P2 — Polish & Advanced Features diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index dd0244849..dc6f976a2 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -53,6 +53,16 @@ import { type SortDirection = 'asc' | 'desc' | null; +/** Number of skeleton rows shown when the table has no data */ +const GHOST_ROW_COUNT = 3; + +/** Returns a Tailwind width class for ghost cell placeholders to create visual variety */ +function ghostCellWidth(columnIndex: number, totalColumns: number): string { + if (columnIndex === 0) return 'w-3/4'; + if (columnIndex === totalColumns - 1) return 'w-1/3'; + return 'w-1/2'; +} + // Default English fallback translations for the data table const TABLE_DEFAULT_TRANSLATIONS: Record = { 'table.rowsPerPage': 'Rows per page', @@ -806,14 +816,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { - {/* Ghost placeholder rows */} - {Array.from({ length: 3 }).map((_, i) => ( + {/* Ghost placeholder rows – visual skeleton to maintain table height when empty */} + {Array.from({ length: GHOST_ROW_COUNT }).map((_, i) => ( {selectable && } {showRowNumbers && } {columns.map((_col, ci) => ( - + ))} {rowActions && } From 03cdb5db8853e2ef7a180c69761a0888620c1b71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 28 Feb 2026 07:32:16 +0000 Subject: [PATCH 5/5] fix: complete remaining Airtable UX gaps - ListView default density changed from 'comfortable' to 'compact' - ListView density toggle highlight checks against 'compact' (new default) - ObjectGrid singleClickEdit defaults to true (click-to-edit by default) - Update test to match new default density - Update ROADMAP.md with complete status Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 6 ++++++ packages/plugin-grid/src/ObjectGrid.tsx | 2 +- packages/plugin-list/src/ListView.tsx | 4 ++-- packages/plugin-list/src/__tests__/ListView.test.tsx | 4 ++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aa60b227d..083ffad03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -764,8 +764,14 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind **Default Compact Row Height:** - [x] ObjectGrid default changed from `'medium'` to `'compact'` (32-36px rows) +- [x] ListView default density changed from `'comfortable'` to `'compact'` - [x] Row height toggle preserved in toolbar +**Single-Click Edit Mode:** +- [x] Added `singleClickEdit` prop to `DataTableSchema` and `ObjectGridSchema` +- [x] ObjectGrid defaults `singleClickEdit` to `true` (click-to-edit by default) +- [x] InlineEditing component already compatible (click-to-edit native) + **Column Header Minimal Style:** - [x] Headers use `text-xs font-normal text-muted-foreground` (was `text-[11px] font-semibold uppercase tracking-wider`) - [x] Sort arrows inline with header text diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index f572e3926..e734d6c08 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -971,7 +971,7 @@ export const ObjectGrid: React.FC = ({ resizableColumns: schema.resizable ?? schema.resizableColumns ?? true, reorderableColumns: schema.reorderableColumns ?? false, editable: schema.editable ?? false, - singleClickEdit: schema.singleClickEdit ?? false, + singleClickEdit: schema.singleClickEdit ?? true, className: schema.className, cellClassName: rowHeightMode === 'compact' ? 'px-3 py-1 text-[13px] leading-tight' diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 6d972969c..c76725029 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -469,7 +469,7 @@ export const ListView: React.FC = ({ }; return map[schema.rowHeight] || 'comfortable'; } - return 'comfortable'; + return 'compact'; }, [schema.densityMode, schema.rowHeight]); const density = useDensityMode(resolvedDensity); @@ -1312,7 +1312,7 @@ export const ListView: React.FC = ({ size="sm" className={cn( "h-7 px-2 text-muted-foreground hover:text-primary text-xs hidden lg:flex transition-colors duration-150", - density.mode !== 'comfortable' && "bg-primary/10 border border-primary/20 text-primary" + density.mode !== 'compact' && "bg-primary/10 border border-primary/20 text-primary" )} onClick={density.cycle} title={`Density: ${density.mode}`} diff --git a/packages/plugin-list/src/__tests__/ListView.test.tsx b/packages/plugin-list/src/__tests__/ListView.test.tsx index 5ab3192a9..f2c6afa1c 100644 --- a/packages/plugin-list/src/__tests__/ListView.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.test.tsx @@ -300,8 +300,8 @@ describe('ListView', () => { renderWithProvider(); - // Default density mode is 'comfortable' - const densityButton = screen.getByTitle('Density: comfortable'); + // Default density mode is 'compact' + const densityButton = screen.getByTitle('Density: compact'); expect(densityButton).toBeInTheDocument(); });
No results found
Try adjusting your filters or search query.