diff --git a/ROADMAP.md b/ROADMAP.md index 5d3953481..0a50f84be 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -798,7 +798,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th **Global Theme & Design Tokens:** - [x] Hardcoded gray colors in `GridField.tsx`, `ReportRenderer.tsx`, and `ObjectGrid.tsx` replaced with theme tokens (`text-muted-foreground`, `bg-muted`, `border-border`, `border-foreground`) - [x] Global font-family (`Inter`, ui-sans-serif, system-ui) injected in `index.css` `:root` -- [x] `--config-panel-width: 280px` CSS custom property added for unified config panel sizing +- [x] `--config-panel-width` CSS custom property added for unified config panel sizing (updated to `320px` in P2.8) - [x] Border radius standardized to `rounded-lg` across report/grid components - [x] `transition-colors duration-150` added to all interactive elements (toolbar buttons, tab bar, sidebar menu buttons) - [x] `LayoutRenderer.tsx` outer shell `bg-slate-50/50 dark:bg-zinc-950` replaced with `bg-background` theme token @@ -833,6 +833,48 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] Active tab indicator changed to bottom border (`border-b-2 border-primary font-medium text-foreground`) - [x] `transition-colors duration-150` added to tab buttons +### P2.8 Airtable Parity: Product View & Global UI Detail Optimization + +> Platform-level grid, toolbar, sidebar, and config panel optimizations for Airtable-level experience (Issue #768). + +**Platform: DataTable & ObjectGrid Enhancements:** +- [x] `rowStyle` callback prop added to `DataTableSchema` type — enables inline CSSProperties per row +- [x] `` in data-table.tsx applies `rowStyle` callback for runtime row styling +- [x] ObjectGrid: `conditionalFormatting` rules wired to `rowStyle` — evaluates both spec-format (`condition`/`style`) and ObjectUI-format (`field`/`operator`/`value`) rules per row using `evaluatePlainCondition` from `@object-ui/core` +- [x] Row number (#) column: hover shows `` for multi-select (when `selectable` mode is enabled), replacing expand icon + +**Platform: ListView Toolbar:** +- [x] Visual `
` separators (`h-4 w-px bg-border/60`) between toolbar button groups: Search | Hide Fields | Filter/Sort/Group | Color/Density | Export +- [x] Separators conditionally rendered only when adjacent groups are visible +- [x] Inline search moved to toolbar left end (`w-48`, Airtable-style) +- [x] Density button: activated state highlight (`bg-primary/10 border border-primary/20`) when density is non-default + +**Platform: ViewTabBar:** +- [x] Tab "•" dot indicator replaced with descriptive badge (`F`/`S`/`FS`) + tooltip showing "Active filters", "Active sort" + +**Platform: Console Sidebar:** +- [x] Recent items section: default collapsed with chevron toggle (saves sidebar space) + +**Platform: ViewConfigPanel Advanced Sections:** +- [x] `userActions`, `sharing`, and `accessibility` sections set to `defaultCollapsed: true` — common settings remain expanded, advanced settings folded by default + +**Platform: Config Panel Width:** +- [x] `--config-panel-width` CSS variable increased from `280px` to `320px` for wider config panel + +**CRM Example: Product Grid Column Configs:** +- [x] All columns upgraded from `string[]` to `ListColumn[]` with explicit `field`, `label`, `width`, `type`, `align` +- [x] `IS ACTIVE`: `type: 'boolean'` for `` rendering +- [x] Price: `type: 'currency'`, `align: 'right'` for `formatCurrency` formatting +- [x] Default `rowHeight: 'short'` for compact density +- [x] Conditional formatting: stock=0 red, stock<5 yellow warning +- [x] Column widths: NAME 250, SKU 120, CATEGORY 110, PRICE 120, STOCK 80 + +**Tests:** +- [x] 7 new CRM metadata tests validating column types, widths, rowHeight, conditionalFormatting +- [x] 136 ViewConfigPanel tests updated for defaultCollapsed sections (expand before access) +- [x] 411 ListView + ViewTabBar tests passing +- [x] 11 AppSidebar tests passing + ### P2.5 PWA & Offline (Real Sync) - [ ] Background sync queue → real server sync (replace simulation) diff --git a/apps/console/src/__tests__/ViewConfigPanel.test.tsx b/apps/console/src/__tests__/ViewConfigPanel.test.tsx index 36b7b048a..ee2f20a84 100644 --- a/apps/console/src/__tests__/ViewConfigPanel.test.tsx +++ b/apps/console/src/__tests__/ViewConfigPanel.test.tsx @@ -1310,6 +1310,8 @@ describe('ViewConfigPanel', () => { /> ); + // Expand defaultCollapsed userActions section + fireEvent.click(screen.getByTestId('section-userActions')); expect(screen.getByTestId('toggle-inlineEdit')).toBeInTheDocument(); expect(screen.getByTestId('toggle-addDeleteRecordsInline')).toBeInTheDocument(); expect(screen.getByTestId('select-navigation-mode')).toBeInTheDocument(); @@ -1327,6 +1329,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByTestId('toggle-inlineEdit')); expect(onViewUpdate).toHaveBeenCalledWith('inlineEdit', false); }); @@ -1630,6 +1633,8 @@ describe('ViewConfigPanel', () => { /> ); + // Expand defaultCollapsed userActions section + fireEvent.click(screen.getByTestId('section-userActions')); // List-level inline actions should be in the User Actions collapsible section expect(screen.getByTestId('toggle-inlineEdit')).toBeInTheDocument(); expect(screen.getByTestId('toggle-addDeleteRecordsInline')).toBeInTheDocument(); @@ -2105,6 +2110,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-sharing')); expect(screen.getByTestId('toggle-sharing-enabled')).toBeInTheDocument(); }); @@ -2118,6 +2124,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-sharing')); expect(screen.getByTestId('select-sharing-visibility')).toBeInTheDocument(); expect(screen.getByTestId('select-sharing-visibility')).toHaveValue('team'); }); @@ -2134,6 +2141,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-sharing')); fireEvent.click(screen.getByTestId('toggle-sharing-enabled')); expect(onViewUpdate).toHaveBeenCalledWith('sharing', expect.objectContaining({ enabled: true })); }); @@ -2148,6 +2156,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-accessibility')); expect(screen.getByTestId('input-aria-label')).toBeInTheDocument(); expect(screen.getByTestId('input-aria-describedBy')).toBeInTheDocument(); expect(screen.getByTestId('select-aria-live')).toBeInTheDocument(); @@ -2178,6 +2187,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByText('console.objectView.rowActions')); expect(screen.getByTestId('row-actions-selector')).toBeInTheDocument(); }); @@ -2192,6 +2202,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByText('console.objectView.bulkActions')); expect(screen.getByTestId('bulk-actions-selector')).toBeInTheDocument(); }); @@ -2285,6 +2296,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-accessibility')); fireEvent.change(screen.getByTestId('input-aria-label'), { target: { value: 'Contacts table' } }); expect(onViewUpdate).toHaveBeenCalledWith('aria', expect.objectContaining({ label: 'Contacts table' })); }); @@ -2301,6 +2313,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-accessibility')); fireEvent.change(screen.getByTestId('input-aria-describedBy'), { target: { value: 'table-desc' } }); expect(onViewUpdate).toHaveBeenCalledWith('aria', expect.objectContaining({ describedBy: 'table-desc' })); }); @@ -2317,6 +2330,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-accessibility')); fireEvent.change(screen.getByTestId('select-aria-live'), { target: { value: 'polite' } }); expect(onViewUpdate).toHaveBeenCalledWith('aria', expect.objectContaining({ live: 'polite' })); }); @@ -2333,6 +2347,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByText('console.objectView.rowActions')); fireEvent.change(screen.getByTestId('input-rowActions'), { target: { value: 'edit, delete' } }); expect(onViewUpdate).toHaveBeenCalledWith('rowActions', ['edit', 'delete']); @@ -2350,6 +2365,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByText('console.objectView.bulkActions')); fireEvent.change(screen.getByTestId('input-bulkActions'), { target: { value: 'delete, export' } }); expect(onViewUpdate).toHaveBeenCalledWith('bulkActions', ['delete', 'export']); @@ -2433,6 +2449,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByTestId('toggle-addDeleteRecordsInline')); expect(onViewUpdate).toHaveBeenCalledWith('addDeleteRecordsInline', false); }); @@ -2449,6 +2466,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-sharing')); expect(screen.getByTestId('toggle-sharing-enabled')).toBeInTheDocument(); expect(screen.queryByTestId('select-sharing-visibility')).not.toBeInTheDocument(); }); @@ -2463,6 +2481,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-sharing')); expect(screen.queryByTestId('select-sharing-visibility')).not.toBeInTheDocument(); }); @@ -2530,6 +2549,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByText('console.objectView.bulkActions')); fireEvent.change(screen.getByTestId('input-bulkActions'), { target: { value: '' } }); expect(onViewUpdate).toHaveBeenCalledWith('bulkActions', []); @@ -2547,6 +2567,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-userActions')); fireEvent.click(screen.getByText('console.objectView.rowActions')); fireEvent.change(screen.getByTestId('input-rowActions'), { target: { value: '' } }); expect(onViewUpdate).toHaveBeenCalledWith('rowActions', []); @@ -2661,6 +2682,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-sharing')); fireEvent.change(screen.getByTestId('select-sharing-visibility'), { target: { value: 'organization' } }); expect(onViewUpdate).toHaveBeenCalledWith('sharing', expect.objectContaining({ enabled: true, @@ -2682,6 +2704,7 @@ describe('ViewConfigPanel', () => { /> ); + fireEvent.click(screen.getByTestId('section-accessibility')); const select = screen.getByTestId('select-aria-live'); ['polite', 'assertive', 'off'].forEach((mode) => { fireEvent.change(select, { target: { value: mode } }); diff --git a/apps/console/src/components/AppSidebar.tsx b/apps/console/src/components/AppSidebar.tsx index c6e60f9e4..d2a22bf4d 100644 --- a/apps/console/src/components/AppSidebar.tsx +++ b/apps/console/src/components/AppSidebar.tsx @@ -48,6 +48,7 @@ import { StarOff, Search, Pencil, + ChevronRight, } from 'lucide-react'; import { NavigationRenderer } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; @@ -224,6 +225,9 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri // Search filter state for sidebar navigation const [navSearchQuery, setNavSearchQuery] = React.useState(''); + // Recent section collapsed by default + const [recentExpanded, setRecentExpanded] = React.useState(false); + // Visibility evaluation from Console expression context const { evaluator } = useExpressionContext(); const evalVis = React.useCallback( @@ -414,13 +418,18 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri )} - {/* Recent Items */} + {/* Recent Items (default collapsed) */} {recentItems.length > 0 && ( - + setRecentExpanded(prev => !prev)} + > + Recent + {recentExpanded && ( {recentItems.slice(0, 5).map(item => ( @@ -437,6 +446,7 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri ))} + )} )} diff --git a/apps/console/src/utils/view-config-schema.tsx b/apps/console/src/utils/view-config-schema.tsx index de4b38b35..976448674 100644 --- a/apps/console/src/utils/view-config-schema.tsx +++ b/apps/console/src/utils/view-config-schema.tsx @@ -1202,6 +1202,7 @@ function buildUserActionsSection( key: 'userActions', title: t('console.objectView.userActions'), collapsible: true, + defaultCollapsed: true, fields: [ // spec: NamedListView.inlineEdit (grid-only: only ObjectGrid supports inline editing) buildSwitchField('inlineEdit', t('console.objectView.inlineEdit'), 'toggle-inlineEdit', true, @@ -1290,6 +1291,7 @@ function buildSharingSection( key: 'sharing', title: t('console.objectView.sharing'), collapsible: true, + defaultCollapsed: true, fields: [ // spec: NamedListView.sharing.enabled { @@ -1349,6 +1351,7 @@ function buildAccessibilitySection( key: 'accessibility', title: t('console.objectView.accessibility'), collapsible: true, + defaultCollapsed: true, fields: [ // spec: NamedListView.aria.label { diff --git a/examples/crm/src/__tests__/crm-metadata.test.ts b/examples/crm/src/__tests__/crm-metadata.test.ts index 3710d28c3..c52b2bba1 100644 --- a/examples/crm/src/__tests__/crm-metadata.test.ts +++ b/examples/crm/src/__tests__/crm-metadata.test.ts @@ -122,6 +122,68 @@ describe('CRM Metadata Spec Compliance', () => { }); }); + describe('ProductView Airtable Parity', () => { + const allProducts = ProductView.listViews.all_products; + const activeProducts = ProductView.listViews.active_products; + + it('all_products uses detailed column configs with field, width, and type', () => { + const cols = allProducts.columns as Array>; + expect(cols.length).toBe(6); + for (const col of cols) { + expect(col).toHaveProperty('field'); + expect(col).toHaveProperty('width'); + } + }); + + it('is_active column has boolean type for Checkbox rendering', () => { + const cols = allProducts.columns as Array>; + const isActiveCol = cols.find((c) => c.field === 'is_active'); + expect(isActiveCol).toBeDefined(); + expect(isActiveCol!.type).toBe('boolean'); + }); + + it('price column has currency type and right alignment', () => { + const cols = allProducts.columns as Array>; + const priceCol = cols.find((c) => c.field === 'price'); + expect(priceCol).toBeDefined(); + expect(priceCol!.type).toBe('currency'); + expect(priceCol!.align).toBe('right'); + }); + + it('rowHeight is set to short for compact density', () => { + expect(allProducts.rowHeight).toBe('short'); + expect(activeProducts.rowHeight).toBe('short'); + }); + + it('SKU and CATEGORY columns are narrower than NAME', () => { + const cols = allProducts.columns as Array>; + const nameCol = cols.find((c) => c.field === 'name'); + const skuCol = cols.find((c) => c.field === 'sku'); + const catCol = cols.find((c) => c.field === 'category'); + expect((nameCol!.width as number)).toBeGreaterThan(skuCol!.width as number); + expect((nameCol!.width as number)).toBeGreaterThan(catCol!.width as number); + }); + + it('has conditionalFormatting for stock=0 (red) and stock<5 (warning)', () => { + const rules = allProducts.conditionalFormatting!; + expect(rules.length).toBe(2); + const zeroStock = rules.find((r: any) => r.condition?.includes('=== 0')); + expect(zeroStock).toBeDefined(); + expect((zeroStock as any).style.backgroundColor).toBe('#fee2e2'); + const lowStock = rules.find((r: any) => r.condition?.includes('< 5')); + expect(lowStock).toBeDefined(); + expect((lowStock as any).style.backgroundColor).toBe('#fef9c3'); + }); + + it('active_products also uses detailed column configs', () => { + const cols = activeProducts.columns as Array>; + expect(cols.length).toBe(6); + const priceCol = cols.find((c) => c.field === 'price'); + expect(priceCol!.type).toBe('currency'); + expect(priceCol!.align).toBe('right'); + }); + }); + describe('Actions', () => { it('all actions have name, label, type, and locations', () => { for (const action of allActions) { diff --git a/examples/crm/src/views/product.view.ts b/examples/crm/src/views/product.view.ts index 60d649d23..74d42d26f 100644 --- a/examples/crm/src/views/product.view.ts +++ b/examples/crm/src/views/product.view.ts @@ -5,16 +5,42 @@ export const ProductView = { label: 'All Products', type: 'grid' as const, data: { provider: 'object' as const, object: 'product' }, - columns: ['name', 'sku', 'category', 'price', 'stock', 'is_active'], + columns: [ + { field: 'name', label: 'NAME', width: 250 }, + { field: 'sku', label: 'SKU', width: 120 }, + { field: 'category', label: 'CATEGORY', type: 'select' as const, width: 110 }, + { field: 'price', label: 'PRICE', type: 'currency' as const, width: 120, align: 'right' as const }, + { field: 'stock', label: 'STOCK', type: 'number' as const, width: 80, align: 'right' as const }, + { field: 'is_active', label: 'IS ACTIVE', type: 'boolean' as const, width: 90 }, + ], sort: [{ field: 'name', order: 'asc' as const }], + rowHeight: 'short' as const, + conditionalFormatting: [ + { + condition: '${data.stock === 0}', + style: { backgroundColor: '#fee2e2', color: '#991b1b' }, + }, + { + condition: '${data.stock < 5}', + style: { backgroundColor: '#fef9c3', color: '#854d0e' }, + }, + ], }, active_products: { name: 'active_products', label: 'Active Products', type: 'grid' as const, data: { provider: 'object' as const, object: 'product' }, - columns: ['name', 'sku', 'category', 'price', 'stock', 'tags'], + columns: [ + { field: 'name', label: 'NAME', width: 250 }, + { field: 'sku', label: 'SKU', width: 120 }, + { field: 'category', label: 'CATEGORY', type: 'select' as const, width: 110 }, + { field: 'price', label: 'PRICE', type: 'currency' as const, width: 120, align: 'right' as const }, + { field: 'stock', label: 'STOCK', type: 'number' as const, width: 80, align: 'right' as const }, + { field: 'tags', label: 'TAGS', type: 'select' as const, width: 130 }, + ], filter: ['is_active', '=', true], + rowHeight: 'short' as const, }, }, form: { diff --git a/packages/components/src/index.css b/packages/components/src/index.css index 29399256c..9cf99573e 100644 --- a/packages/components/src/index.css +++ b/packages/components/src/index.css @@ -74,7 +74,7 @@ --radius: 0.5rem; - --config-panel-width: 280px; + --config-panel-width: 320px; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; diff --git a/packages/components/src/renderers/complex/data-table.tsx b/packages/components/src/renderers/complex/data-table.tsx index 808a3dba7..55b55a62d 100644 --- a/packages/components/src/renderers/complex/data-table.tsx +++ b/packages/components/src/renderers/complex/data-table.tsx @@ -103,6 +103,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { reorderableColumns = true, editable = false, rowClassName, + rowStyle, className, frozenColumns = 0, showRowNumbers = false, @@ -748,6 +749,7 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { rowHasChanges && "bg-amber-50 dark:bg-amber-950/20", rowClassName && rowClassName(row, rowIndex) )} + style={rowStyle ? rowStyle(row, rowIndex) : undefined} onClick={(e) => { if (schema.onRowClick && !e.defaultPrevented) { // Simple heuristic to avoid triggering on interactive elements if they didn't stop propagation @@ -769,10 +771,18 @@ const DataTableRenderer = ({ schema }: { schema: DataTableSchema }) => { )} {showRowNumbers && ( 0 && "sticky z-10 bg-background")} style={frozenColumns > 0 ? { left: selectable ? 48 : 0 } : undefined}> - + {globalIndex + 1} - {schema.onRowClick && ( + {selectable ? ( +
+ handleSelectRow(rowId, checked as boolean)} + data-testid="row-hover-checkbox" + /> +
+ ) : schema.onRowClick && ( + )} +
+ )} + + {/* --- Separator: Search | Fields --- */} + {toolbarFlags.showSearch && toolbarFlags.showHideFields && ( +
+ )} + {/* Hide Fields */} {toolbarFlags.showHideFields && ( @@ -1071,6 +1099,11 @@ export const ListView: React.FC = ({ )} + {/* --- Separator: Hide Fields | Data Manipulation --- */} + {toolbarFlags.showHideFields && (toolbarFlags.showFilters || toolbarFlags.showSort || toolbarFlags.showGroup) && ( +
+ )} + {/* Filter */} {toolbarFlags.showFilters && ( @@ -1209,6 +1242,11 @@ export const ListView: React.FC = ({ )} + {/* --- Separator: Data Manipulation | Appearance --- */} + {(toolbarFlags.showFilters || toolbarFlags.showSort || toolbarFlags.showGroup) && (toolbarFlags.showColor || toolbarFlags.showDensity) && ( +
+ )} + {/* Color */} {toolbarFlags.showColor && ( @@ -1266,7 +1304,10 @@ export const ListView: React.FC = ({ )} + {/* --- Separator: Appearance | Export --- */} + {(toolbarFlags.showColor || toolbarFlags.showDensity) && resolvedExportOptions && schema.allowExport !== false && ( +
+ )} + {/* Export */} {resolvedExportOptions && schema.allowExport !== false && ( @@ -1336,7 +1382,7 @@ export const ListView: React.FC = ({ )}
- {/* Right: Add Record + Search */} + {/* Right: Add Record */}
{/* Add Record (top position) */} {toolbarFlags.showAddRecord && toolbarFlags.addRecordPosition === 'top' && ( @@ -1351,29 +1397,6 @@ export const ListView: React.FC = ({ {t('list.addRecord')} )} - - {/* Search */} - {toolbarFlags.showSearch && ( -
- - handleSearchChange(e.target.value)} - className="pl-7 h-7 text-xs bg-muted/50 border-transparent hover:bg-muted focus:bg-background focus:border-input transition-colors" - /> - {searchTerm && ( - - )} -
- )}
diff --git a/packages/plugin-view/src/ViewTabBar.tsx b/packages/plugin-view/src/ViewTabBar.tsx index 24f37ff72..de6c149dc 100644 --- a/packages/plugin-view/src/ViewTabBar.tsx +++ b/packages/plugin-view/src/ViewTabBar.tsx @@ -378,10 +378,19 @@ export const ViewTabBar: React.FC = ({ {view.label} )} {hasIndicator && ( - + + + + {[view.hasActiveFilters && 'F', view.hasActiveSort && 'S'].filter(Boolean).join('')} + + + + {[view.hasActiveFilters && 'Active filters', view.hasActiveSort && 'Active sort'].filter(Boolean).join(', ')} + + )} {view.isDefault && ( @@ -567,7 +576,9 @@ export const ViewTabBar: React.FC = ({ {view.label} {showIndicators && (view.hasActiveFilters || view.hasActiveSort) && ( - + + {[view.hasActiveFilters && 'F', view.hasActiveSort && 'S'].filter(Boolean).join('')} + )} ); diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index cbb37dc54..9aef0ac55 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -413,6 +413,11 @@ export interface DataTableSchema extends BaseSchema { * Function that returns a CSS class string for each row */ rowClassName?: (row: any, index: number) => string | undefined; + /** + * Dynamic row inline style + * Function that returns CSSProperties for each row (e.g., from conditionalFormatting). + */ + rowStyle?: (row: any, index: number) => React.CSSProperties | undefined; /** * Number of columns to freeze (left-pin) * When set, the first N columns remain fixed while the rest scroll horizontally.