Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Expand>` to text "Open >" with `<ChevronRight>` 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
Expand Down
113 changes: 88 additions & 25 deletions packages/components/src/renderers/complex/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'table.rowsPerPage': 'Rows per page',
Expand Down Expand Up @@ -147,6 +157,8 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
resizableColumns = true,
reorderableColumns = true,
editable = false,
singleClickEdit = false,
selectionStyle = 'always',
rowClassName,
rowStyle,
className,
Expand All @@ -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<string, number> = {};
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<string | null>(null);
Expand Down Expand Up @@ -693,14 +730,14 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
</TableHead>
)}
{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;
Expand Down Expand Up @@ -745,7 +782,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
{col.headerIcon && (
<span className="text-muted-foreground flex-shrink-0">{col.headerIcon}</span>
)}
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">{col.header}</span>
<span className="text-xs font-normal text-muted-foreground">{col.header}</span>
{sortable && col.sortable !== false && getSortIcon(col.accessorKey)}
</div>
{resizableColumns && col.resizable !== false && (
Expand All @@ -766,18 +803,33 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
</TableHeader>
<TableBody>
{paginatedData.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
className="h-96 text-center text-muted-foreground"
>
<div className="flex flex-col items-center justify-center gap-2">
<Search className="h-8 w-8 text-muted-foreground/50" />
<p>No results found</p>
<p className="text-xs text-muted-foreground/50">Try adjusting your filters or search query.</p>
</div>
</TableCell>
</TableRow>
<>
<TableRow>
<TableCell
colSpan={columns.length + (selectable ? 1 : 0) + (showRowNumbers ? 1 : 0) + (rowActions ? 1 : 0)}
className="h-24 text-center text-muted-foreground"
>
<div className="flex flex-col items-center justify-center gap-2">
<Search className="h-8 w-8 text-muted-foreground/50" />
<p>No results found</p>
<p className="text-xs text-muted-foreground/50">Try adjusting your filters or search query.</p>
</div>
</TableCell>
</TableRow>
{/* Ghost placeholder rows – visual skeleton to maintain table height when empty */}
{Array.from({ length: GHOST_ROW_COUNT }).map((_, i) => (
<TableRow key={`ghost-${i}`} className="hover:bg-transparent opacity-[0.15] pointer-events-none" data-testid="ghost-row">
{selectable && <TableCell className="p-3"><div className="h-4 w-4 rounded border border-muted-foreground/30" /></TableCell>}
{showRowNumbers && <TableCell className="text-center p-3"><div className="h-3 w-6 mx-auto rounded bg-muted-foreground/30" /></TableCell>}
{columns.map((_col, ci) => (
<TableCell key={ci} className="p-3">
<div className={cn("h-3 rounded bg-muted-foreground/30", ghostCellWidth(ci, columns.length))} />
</TableCell>
))}
{rowActions && <TableCell className="p-3"><div className="h-3 w-8 rounded bg-muted-foreground/30" /></TableCell>}
</TableRow>
))}
</>
) : (
<>
{paginatedData.map((row, rowIndex) => {
Expand Down Expand Up @@ -810,11 +862,20 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
}}
>
{selectable && (
<TableCell className={cn(frozenColumns > 0 && "sticky left-0 z-10 bg-background")}>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
/>
<TableCell className={cn(frozenColumns > 0 && "sticky left-0 z-10 bg-background", selectionStyle === 'hover' && "relative")}>
{selectionStyle === 'hover' ? (
<div className={cn("transition-opacity", isSelected ? "opacity-100" : "opacity-0 group-hover/row:opacity-100")}>
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
/>
</div>
) : (
<Checkbox
checked={isSelected}
onCheckedChange={(checked) => handleSelectRow(rowId, checked as boolean)}
/>
)}
</TableCell>
)}
{showRowNumbers && (
Expand All @@ -833,21 +894,22 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => {
) : schema.onRowClick && (
<button
type="button"
className="absolute inset-0 hidden group-hover/row:flex items-center justify-center text-muted-foreground hover:text-primary"
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"
data-testid="row-expand-button"
onClick={(e) => {
e.stopPropagation();
schema.onRowClick?.(row);
}}
title="Open record"
>
<Expand className="h-3.5 w-3.5" />
<span>Open</span>
<ChevronRight className="h-3 w-3" />
</button>
)}
</TableCell>
)}
{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;
Expand All @@ -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;
Expand All @@ -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)}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

singleClickEdit click handler will bubble to the row's onClick handler. When schema.onRowClick is set (e.g., ObjectGrid navigation), clicking an editable cell in single-click mode will both enter edit mode and trigger row navigation. Prevent propagation (or add a guard in the row click heuristic) when starting edit to avoid accidental navigation.

Suggested change
onClick={() => isEditable && singleClickEdit && startEdit(rowIndex, col.accessorKey)}
onClick={(e) => {
if (isEditable && singleClickEdit) {
e.stopPropagation();
startEdit(rowIndex, col.accessorKey);
}
}}

Copilot uses AI. Check for mistakes.
onKeyDown={(e) => handleCellKeyDown(e, rowIndex, col.accessorKey)}
tabIndex={0}
>
Expand Down
10 changes: 5 additions & 5 deletions packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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 <span className="text-muted-foreground">-</span>;

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,
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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<any[]>([]);

// Column state persistence (order and widths)
Expand Down Expand Up @@ -971,6 +971,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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'
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ export const ListView: React.FC<ListViewProps> = ({
};
return map[schema.rowHeight] || 'comfortable';
}
return 'comfortable';
return 'compact';
}, [schema.densityMode, schema.rowHeight]);
const density = useDensityMode(resolvedDensity);

Expand Down Expand Up @@ -1312,7 +1312,7 @@ export const ListView: React.FC<ListViewProps> = ({
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}`}
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-list/src/UserFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible,
<button
data-testid={`filter-badge-${f.field}`}
className={cn(
'inline-flex items-center gap-1 rounded-md border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
'inline-flex items-center gap-1 rounded-full border h-7 px-2.5 text-xs font-medium transition-colors shrink-0',
hasSelection
? 'border-primary/30 bg-primary/5 text-primary'
: 'border-border bg-background hover:bg-accent text-foreground',
Expand Down Expand Up @@ -285,7 +285,7 @@ function DropdownFilters({ fields, objectDef, data, onFilterChange, maxVisible,
<PopoverTrigger asChild>
<button
data-testid="user-filters-more"
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"
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"
>
<span>More</span>
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-muted text-[10px] font-medium">
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-list/src/__tests__/ListView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,8 @@ describe('ListView', () => {

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

// 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();
});

Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/data-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -388,6 +395,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
Expand Down
Loading