diff --git a/ROADMAP.md b/ROADMAP.md index 3c3a7664f..083ffad03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -746,6 +746,52 @@ 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] 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 + +**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 eac281db7..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', @@ -147,6 +157,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { resizableColumns = true, reorderableColumns = true, editable = false, + singleClickEdit = false, + selectionStyle = 'always', rowClassName, rowStyle, className, @@ -171,6 +183,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); @@ -693,14 +730,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; @@ -745,7 +782,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 +803,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 – visual skeleton to maintain table height when empty */} + {Array.from({ length: GHOST_ROW_COUNT }).map((_, i) => ( + + {selectable &&
} + {showRowNumbers &&
} + {columns.map((_col, ci) => ( + +
+ + ))} + {rowActions &&
} + + ))} + ) : ( <> {paginatedData.map((row, rowIndex) => { @@ -810,11 +862,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 && ( @@ -833,7 +894,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { ) : schema.onRowClick && ( )}
)} {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; @@ -857,7 +919,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; @@ -882,7 +944,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..e734d6c08 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 ?? 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/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,