@object-ui/plugin-grid 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
3
+ import type { BaseSchema } from '@object-ui/types';
4
+ import { createStorybookDataSource } from '@storybook-config/datasource';
5
+
6
+ const meta = {
7
+ title: 'Plugins/ObjectGrid/Edge Cases',
8
+ component: SchemaRenderer,
9
+ parameters: {
10
+ layout: 'padded',
11
+ },
12
+ tags: ['autodocs'],
13
+ argTypes: {
14
+ schema: { table: { disable: true } },
15
+ },
16
+ } satisfies Meta<any>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ const dataSource = createStorybookDataSource();
22
+
23
+ const renderStory = (args: any) => (
24
+ <SchemaRendererProvider dataSource={dataSource}>
25
+ <SchemaRenderer schema={args as unknown as BaseSchema} />
26
+ </SchemaRendererProvider>
27
+ );
28
+
29
+ // ── Empty Data ────────────────────────────────────────────────
30
+
31
+ export const EmptyData: Story = {
32
+ name: 'Empty – No Rows',
33
+ render: renderStory,
34
+ args: {
35
+ type: 'object-grid',
36
+ objectName: 'Employee',
37
+ columns: [
38
+ { field: 'id', header: 'ID', width: 80 },
39
+ { field: 'name', header: 'Name' },
40
+ { field: 'email', header: 'Email' },
41
+ { field: 'department', header: 'Department' },
42
+ ],
43
+ data: [],
44
+ pagination: false,
45
+ className: 'w-full',
46
+ } as any,
47
+ };
48
+
49
+ // ── Single Row ────────────────────────────────────────────────
50
+
51
+ export const SingleRow: Story = {
52
+ name: 'Single Row',
53
+ render: renderStory,
54
+ args: {
55
+ type: 'object-grid',
56
+ objectName: 'Employee',
57
+ columns: [
58
+ { field: 'id', header: 'ID', width: 80 },
59
+ { field: 'name', header: 'Name', sortable: true },
60
+ { field: 'email', header: 'Email' },
61
+ { field: 'department', header: 'Department' },
62
+ ],
63
+ data: [
64
+ { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering' },
65
+ ],
66
+ pagination: false,
67
+ className: 'w-full',
68
+ } as any,
69
+ };
70
+
71
+ // ── Many Columns ──────────────────────────────────────────────
72
+
73
+ export const ManyColumns: Story = {
74
+ name: 'Many Columns (15+)',
75
+ render: renderStory,
76
+ args: {
77
+ type: 'object-grid',
78
+ objectName: 'WideTable',
79
+ columns: Array.from({ length: 18 }, (_, i) => ({
80
+ field: `col${i + 1}`,
81
+ header: `Column ${i + 1}`,
82
+ sortable: i < 5,
83
+ })),
84
+ data: Array.from({ length: 5 }, (_, row) => {
85
+ const record: Record<string, any> = {};
86
+ for (let c = 1; c <= 18; c++) {
87
+ record[`col${c}`] = `R${row + 1}-C${c}`;
88
+ }
89
+ return record;
90
+ }),
91
+ pagination: false,
92
+ className: 'w-full',
93
+ } as any,
94
+ };
95
+
96
+ // ── Very Long Cell Values ─────────────────────────────────────
97
+
98
+ const LONG_VALUE =
99
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
100
+
101
+ export const LongCellValues: Story = {
102
+ name: 'Very Long Cell Values',
103
+ render: renderStory,
104
+ args: {
105
+ type: 'object-grid',
106
+ objectName: 'Article',
107
+ columns: [
108
+ { field: 'id', header: 'ID', width: 60 },
109
+ { field: 'title', header: 'Title' },
110
+ { field: 'abstract', header: 'Abstract' },
111
+ { field: 'author', header: 'Author' },
112
+ ],
113
+ data: [
114
+ { id: 1, title: LONG_VALUE, abstract: LONG_VALUE + ' ' + LONG_VALUE, author: 'Dr. Extremely Long Author Name The Third Junior' },
115
+ { id: 2, title: 'Short', abstract: 'Brief.', author: 'Bob' },
116
+ { id: 3, title: LONG_VALUE, abstract: LONG_VALUE, author: LONG_VALUE },
117
+ ],
118
+ pagination: false,
119
+ className: 'w-full',
120
+ } as any,
121
+ };
122
+
123
+ // ── Null / Undefined Values ───────────────────────────────────
124
+
125
+ export const NullAndUndefinedValues: Story = {
126
+ name: 'Null / Undefined Cell Values',
127
+ render: renderStory,
128
+ args: {
129
+ type: 'object-grid',
130
+ objectName: 'Sparse',
131
+ columns: [
132
+ { field: 'id', header: 'ID', width: 60 },
133
+ { field: 'name', header: 'Name' },
134
+ { field: 'email', header: 'Email' },
135
+ { field: 'phone', header: 'Phone' },
136
+ { field: 'notes', header: 'Notes' },
137
+ ],
138
+ data: [
139
+ { id: 1, name: 'Alice', email: null, phone: undefined, notes: '' },
140
+ { id: 2, name: null, email: 'bob@example.com', phone: null, notes: undefined },
141
+ { id: 3, name: undefined, email: undefined, phone: undefined, notes: null },
142
+ { id: 4, name: 'Dave', email: 'dave@example.com', phone: '+1-555-0100', notes: 'Complete record' },
143
+ ],
144
+ pagination: false,
145
+ className: 'w-full',
146
+ } as any,
147
+ };
@@ -0,0 +1,139 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
3
+ import type { BaseSchema } from '@object-ui/types';
4
+ import { createStorybookDataSource } from '@storybook-config/datasource';
5
+
6
+ const meta = {
7
+ title: 'Plugins/ObjectGrid',
8
+ component: SchemaRenderer,
9
+ parameters: {
10
+ layout: 'padded',
11
+ },
12
+ tags: ['autodocs'],
13
+ argTypes: {
14
+ schema: { table: { disable: true } },
15
+ },
16
+ } satisfies Meta<any>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ const dataSource = createStorybookDataSource();
22
+
23
+ const renderStory = (args: any) => (
24
+ <SchemaRendererProvider dataSource={dataSource}>
25
+ <SchemaRenderer schema={args as unknown as BaseSchema} />
26
+ </SchemaRendererProvider>
27
+ );
28
+
29
+ export const Default: Story = {
30
+ render: renderStory,
31
+ args: {
32
+ type: 'object-grid',
33
+ objectName: 'Employee',
34
+ columns: [
35
+ { field: 'id', header: 'ID', width: 80 },
36
+ { field: 'name', header: 'Name', sortable: true, filterable: true },
37
+ { field: 'email', header: 'Email', sortable: true, filterable: true },
38
+ { field: 'department', header: 'Department', sortable: true },
39
+ { field: 'status', header: 'Status', sortable: true },
40
+ ],
41
+ data: [
42
+ { id: 1, name: 'Alice Johnson', email: 'alice@example.com', department: 'Engineering', status: 'Active' },
43
+ { id: 2, name: 'Bob Smith', email: 'bob@example.com', department: 'Marketing', status: 'Active' },
44
+ { id: 3, name: 'Carol White', email: 'carol@example.com', department: 'Sales', status: 'Inactive' },
45
+ { id: 4, name: 'Dave Brown', email: 'dave@example.com', department: 'Engineering', status: 'Active' },
46
+ { id: 5, name: 'Eve Davis', email: 'eve@example.com', department: 'HR', status: 'Active' },
47
+ ],
48
+ pagination: true,
49
+ pageSize: 10,
50
+ className: 'w-full',
51
+ } as any,
52
+ };
53
+
54
+ export const WithRowActions: Story = {
55
+ render: renderStory,
56
+ args: {
57
+ type: 'object-grid',
58
+ objectName: 'Task',
59
+ columns: [
60
+ { field: 'id', header: 'ID', width: 80 },
61
+ { field: 'title', header: 'Title', sortable: true, filterable: true },
62
+ { field: 'assignee', header: 'Assignee', sortable: true },
63
+ { field: 'priority', header: 'Priority', sortable: true },
64
+ { field: 'status', header: 'Status', sortable: true },
65
+ ],
66
+ actions: [
67
+ { label: 'View', action: 'view' },
68
+ { label: 'Edit', action: 'edit' },
69
+ { label: 'Delete', action: 'delete', variant: 'destructive' },
70
+ ],
71
+ data: [
72
+ { id: 1, title: 'Fix login bug', assignee: 'Alice', priority: 'High', status: 'In Progress' },
73
+ { id: 2, title: 'Add dark mode', assignee: 'Bob', priority: 'Medium', status: 'To Do' },
74
+ { id: 3, title: 'Update docs', assignee: 'Carol', priority: 'Low', status: 'Done' },
75
+ ],
76
+ pagination: true,
77
+ pageSize: 10,
78
+ className: 'w-full',
79
+ } as any,
80
+ };
81
+
82
+ /**
83
+ * CRM Deals Pipeline — demonstrates professional data formatting:
84
+ * - Currency with thousand separators (Amount column, right-aligned)
85
+ * - Percentage with progress bar (Probability column, right-aligned)
86
+ * - Formatted dates (Close Date column)
87
+ * - Colored badges for stage/status (Stage column)
88
+ * - Bold clickable name as primary link (Name column)
89
+ * - Empty value placeholder (Account column)
90
+ */
91
+ export const CRMDeals: Story = {
92
+ name: 'CRM Deals Pipeline',
93
+ render: renderStory,
94
+ args: {
95
+ type: 'object-grid',
96
+ objectName: 'Deal',
97
+ columns: [
98
+ { field: 'name', label: 'Name', link: true, sortable: true },
99
+ { field: 'account', label: 'Account' },
100
+ { field: 'stage', label: 'Stage', type: 'select', sortable: true },
101
+ { field: 'amount', label: 'Amount', type: 'currency', sortable: true },
102
+ { field: 'probability', label: 'Probability', type: 'percent', sortable: true },
103
+ { field: 'close_date', label: 'Close Date', type: 'date', sortable: true },
104
+ ],
105
+ data: [
106
+ { _id: '1', name: 'ObjectStack Enterprise License', account: '', stage: 'Closed Won', amount: 150000, probability: 100, close_date: '2024-01-15T00:00:00.000Z' },
107
+ { _id: '2', name: 'Cloud Migration Project', account: 'Acme Corp', stage: 'Negotiation', amount: 85000, probability: 60, close_date: '2024-03-20T00:00:00.000Z' },
108
+ { _id: '3', name: 'Annual Support Renewal', account: '', stage: 'Proposal', amount: 42000, probability: 80, close_date: '2024-02-28T00:00:00.000Z' },
109
+ { _id: '4', name: 'Custom Integration Development', account: 'TechFlow Inc', stage: 'Qualification', amount: 230000, probability: 30, close_date: '2024-06-15T00:00:00.000Z' },
110
+ { _id: '5', name: 'Data Analytics Platform', account: '', stage: 'Closed Lost', amount: 95000, probability: 0, close_date: '2024-01-10T00:00:00.000Z' },
111
+ { _id: '6', name: 'Security Audit Contract', account: 'SecureNet', stage: 'Closed Won', amount: 67500, probability: 100, close_date: '2024-02-01T00:00:00.000Z' },
112
+ { _id: '7', name: 'Mobile App Development', account: '', stage: 'Discovery', amount: 180000, probability: 15, close_date: '2024-08-30T00:00:00.000Z' },
113
+ ],
114
+ pagination: false,
115
+ className: 'w-full',
116
+ } as any,
117
+ };
118
+
119
+ export const EditableGrid: Story = {
120
+ render: renderStory,
121
+ args: {
122
+ type: 'object-grid',
123
+ objectName: 'Product',
124
+ columns: [
125
+ { field: 'sku', header: 'SKU', width: 100, editable: false },
126
+ { field: 'name', header: 'Name', sortable: true },
127
+ { field: 'price', header: 'Price', sortable: true },
128
+ { field: 'stock', header: 'Stock', sortable: true },
129
+ ],
130
+ data: [
131
+ { sku: 'SKU-001', name: 'Widget A', price: '$19.99', stock: 50 },
132
+ { sku: 'SKU-002', name: 'Widget B', price: '$29.99', stock: 30 },
133
+ { sku: 'SKU-003', name: 'Widget C', price: '$9.99', stock: 120 },
134
+ ],
135
+ editable: true,
136
+ pagination: false,
137
+ className: 'w-full',
138
+ } as any,
139
+ };
@@ -26,13 +26,16 @@ import type { ObjectGridSchema, DataSource, ListColumn, ViewData } from '@object
26
26
  import { SchemaRenderer, useDataScope, useNavigationOverlay, useAction } from '@object-ui/react';
27
27
  import { getCellRenderer } from '@object-ui/fields';
28
28
  import { Button, NavigationOverlay } from '@object-ui/components';
29
+ import { usePullToRefresh } from '@object-ui/mobile';
29
30
  import {
30
31
  DropdownMenu,
31
32
  DropdownMenuContent,
32
33
  DropdownMenuItem,
33
34
  DropdownMenuTrigger,
34
35
  } from '@object-ui/components';
35
- import { Edit, Trash2, MoreVertical } from 'lucide-react';
36
+ import { Edit, Trash2, MoreVertical, ChevronRight, ChevronDown } from 'lucide-react';
37
+ import { useRowColor } from './useRowColor';
38
+ import { useGroupedData } from './useGroupedData';
36
39
 
37
40
  export interface ObjectGridProps {
38
41
  schema: ObjectGridSchema;
@@ -120,6 +123,24 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
120
123
  const [loading, setLoading] = useState(true);
121
124
  const [error, setError] = useState<Error | null>(null);
122
125
  const [objectSchema, setObjectSchema] = useState<any>(null);
126
+ const [useCardView, setUseCardView] = useState(false);
127
+ const [refreshKey, setRefreshKey] = useState(0);
128
+
129
+ const handlePullRefresh = useCallback(async () => {
130
+ setRefreshKey(k => k + 1);
131
+ }, []);
132
+
133
+ const { ref: pullRef, isRefreshing, pullDistance } = usePullToRefresh<HTMLDivElement>({
134
+ onRefresh: handlePullRefresh,
135
+ enabled: !!dataSource && !!schema.objectName,
136
+ });
137
+
138
+ useEffect(() => {
139
+ const checkWidth = () => setUseCardView(window.innerWidth < 480);
140
+ checkWidth();
141
+ window.addEventListener('resize', checkWidth);
142
+ return () => window.removeEventListener('resize', checkWidth);
143
+ }, []);
123
144
 
124
145
  // Check if data is passed directly (from ListView)
125
146
  const passedData = (rest as any).data;
@@ -272,7 +293,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
272
293
  return () => {
273
294
  cancelled = true;
274
295
  };
275
- }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig]);
296
+ }, [objectName, schemaFields, schemaColumns, schemaFilter, schemaSort, schemaPagination, schemaPageSize, dataSource, hasInlineData, dataConfig, refreshKey]);
276
297
 
277
298
  // --- NavigationConfig support ---
278
299
  // Must be called before any early returns to satisfy React hooks rules
@@ -286,6 +307,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
286
307
  // --- Action support for action columns ---
287
308
  const { execute: executeAction } = useAction();
288
309
 
310
+ // --- Row color support ---
311
+ const getRowClassName = useRowColor(schema.rowColor);
312
+
313
+ // --- Grouping support ---
314
+ const { groups, isGrouped, toggleGroup } = useGroupedData(schema.grouping, data);
315
+
289
316
  const generateColumns = useCallback(() => {
290
317
  // Use normalized columns (support both new and legacy)
291
318
  const cols = normalizeColumns(schemaColumns);
@@ -305,7 +332,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
305
332
  if ('field' in firstCol) {
306
333
  return (cols as ListColumn[])
307
334
  .filter((col) => col?.field && typeof col.field === 'string' && !col.hidden)
308
- .map((col) => {
335
+ .map((col, colIndex) => {
309
336
  const header = col.label || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' ');
310
337
 
311
338
  // Build custom cell renderer based on column configuration
@@ -319,11 +346,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
319
346
  cellRenderer = (value: any, row: any) => {
320
347
  const displayContent = CellRenderer
321
348
  ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
322
- : String(value ?? '');
349
+ : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
323
350
  return (
324
351
  <button
325
352
  type="button"
326
- className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
353
+ className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
327
354
  onClick={(e) => {
328
355
  e.stopPropagation();
329
356
  navigation.handleClick(row);
@@ -338,11 +365,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
338
365
  cellRenderer = (value: any, row: any) => {
339
366
  const displayContent = CellRenderer
340
367
  ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
341
- : String(value ?? '');
368
+ : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
342
369
  return (
343
370
  <button
344
371
  type="button"
345
- className="text-primary underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
372
+ className="text-primary font-medium underline-offset-4 hover:underline cursor-pointer bg-transparent border-none p-0 text-left font-inherit"
346
373
  onClick={(e) => {
347
374
  e.stopPropagation();
348
375
  navigation.handleClick(row);
@@ -357,7 +384,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
357
384
  cellRenderer = (value: any, row: any) => {
358
385
  const displayContent = CellRenderer
359
386
  ? <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
360
- : String(value ?? '');
387
+ : (value != null && value !== '' ? String(value) : <span className="text-muted-foreground">-</span>);
361
388
  return (
362
389
  <button
363
390
  type="button"
@@ -379,13 +406,28 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
379
406
  cellRenderer = (value: any) => (
380
407
  <CellRenderer value={value} field={{ name: col.field, type: col.type || 'text' } as any} />
381
408
  );
409
+ } else {
410
+ // Default renderer with empty value handling
411
+ cellRenderer = (value: any) => (
412
+ value != null && value !== ''
413
+ ? <span>{String(value)}</span>
414
+ : <span className="text-muted-foreground">-</span>
415
+ );
382
416
  }
383
417
 
418
+ // Auto-infer alignment from field type if not explicitly set
419
+ const numericTypes = ['number', 'currency', 'percent'];
420
+ const inferredAlign = col.align || (col.type && numericTypes.includes(col.type) ? 'right' as const : undefined);
421
+
422
+ // Determine if column should be hidden on mobile
423
+ const isEssential = colIndex === 0 || (col as any).essential === true;
424
+
384
425
  return {
385
426
  header,
386
427
  accessorKey: col.field,
428
+ ...(!isEssential && { className: 'hidden sm:table-cell' }),
387
429
  ...(col.width && { width: col.width }),
388
- ...(col.align && { align: col.align }),
430
+ ...(inferredAlign && { align: inferredAlign }),
389
431
  sortable: col.sortable !== false,
390
432
  ...(col.resizable !== undefined && { resizable: col.resizable }),
391
433
  ...(col.wrap !== undefined && { wrap: col.wrap }),
@@ -431,9 +473,11 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
431
473
  if (field.permissions && field.permissions.read === false) return;
432
474
 
433
475
  const CellRenderer = getCellRenderer(field.type);
476
+ const numericTypes = ['number', 'currency', 'percent'];
434
477
  generatedColumns.push({
435
478
  header: field.label || fieldName,
436
479
  accessorKey: fieldName,
480
+ ...(numericTypes.includes(field.type) && { align: 'right' }),
437
481
  cell: (value: any) => <CellRenderer value={value} field={field} />,
438
482
  sortable: field.sortable !== false,
439
483
  });
@@ -444,7 +488,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
444
488
 
445
489
  if (error) {
446
490
  return (
447
- <div className="p-4 border border-red-300 bg-red-50 rounded-md">
491
+ <div className="p-3 sm:p-4 border border-red-300 bg-red-50 rounded-md">
448
492
  <h3 className="text-red-800 font-semibold">Error loading grid</h3>
449
493
  <p className="text-red-600 text-sm mt-1">{error.message}</p>
450
494
  </div>
@@ -453,7 +497,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
453
497
 
454
498
  if (loading && data.length === 0) {
455
499
  return (
456
- <div className="p-8 text-center">
500
+ <div className="p-4 sm:p-8 text-center">
457
501
  <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
458
502
  <p className="mt-2 text-sm text-gray-600">Loading grid...</p>
459
503
  </div>
@@ -472,7 +516,7 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
472
516
  cell: (_value: any, row: any) => (
473
517
  <DropdownMenu>
474
518
  <DropdownMenuTrigger asChild>
475
- <Button variant="ghost" size="icon" className="h-8 w-8">
519
+ <Button variant="ghost" size="icon" className="h-8 w-8 min-h-[44px] min-w-[44px] sm:min-h-0 sm:min-w-0">
476
520
  <MoreVertical className="h-4 w-4" />
477
521
  <span className="sr-only">Open menu</span>
478
522
  </Button>
@@ -536,6 +580,8 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
536
580
  reorderableColumns: schema.reorderableColumns ?? false,
537
581
  editable: schema.editable ?? false,
538
582
  className: schema.className,
583
+ cellClassName: 'px-2 py-1.5 sm:px-3 sm:py-2 md:px-4 md:py-2.5',
584
+ rowClassName: schema.rowColor ? (row: any, _idx: number) => getRowClassName(row) : undefined,
539
585
  onSelectionChange: onRowSelect,
540
586
  onRowClick: navigation.handleClick,
541
587
  onCellChange: onCellChange,
@@ -543,6 +589,15 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
543
589
  onBatchSave: onBatchSave,
544
590
  };
545
591
 
592
+ /** Build a per-group data-table schema (inherits everything except data & pagination). */
593
+ const buildGroupTableSchema = (groupRows: any[]) => ({
594
+ ...dataTableSchema,
595
+ caption: undefined,
596
+ data: groupRows,
597
+ pagination: false,
598
+ searchable: false,
599
+ });
600
+
546
601
  // Build record detail title
547
602
  const detailTitle = schema.label
548
603
  ? `${schema.label} Detail`
@@ -550,13 +605,82 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
550
605
  ? `${schema.objectName.charAt(0).toUpperCase() + schema.objectName.slice(1)} Detail`
551
606
  : 'Record Detail';
552
607
 
608
+ // Mobile card-view fallback for screens below 480px
609
+ if (useCardView && data.length > 0 && !isGrouped) {
610
+ const displayColumns = generateColumns().filter((c: any) => c.accessorKey !== '_actions');
611
+ return (
612
+ <>
613
+ <div className="space-y-2 p-2">
614
+ {data.map((row, idx) => (
615
+ <div
616
+ key={row.id || row._id || idx}
617
+ className="border rounded-lg p-3 bg-card hover:bg-accent/50 cursor-pointer transition-colors touch-manipulation"
618
+ onClick={() => navigation.handleClick(row)}
619
+ >
620
+ {displayColumns.slice(0, 4).map((col: any) => (
621
+ <div key={col.accessorKey} className="flex justify-between items-center py-1">
622
+ <span className="text-xs text-muted-foreground">{col.header}</span>
623
+ <span className="text-sm font-medium truncate ml-2 text-right">
624
+ {col.cell ? col.cell(row[col.accessorKey], row) : String(row[col.accessorKey] ?? '—')}
625
+ </span>
626
+ </div>
627
+ ))}
628
+ </div>
629
+ ))}
630
+ </div>
631
+ {navigation.isOverlay && (
632
+ <NavigationOverlay {...navigation} title={detailTitle}>
633
+ {(record) => (
634
+ <div className="space-y-3">
635
+ {Object.entries(record).map(([key, value]) => (
636
+ <div key={key} className="flex flex-col">
637
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
638
+ {key.replace(/_/g, ' ')}
639
+ </span>
640
+ <span className="text-sm">{String(value ?? '—')}</span>
641
+ </div>
642
+ ))}
643
+ </div>
644
+ )}
645
+ </NavigationOverlay>
646
+ )}
647
+ </>
648
+ );
649
+ }
650
+
651
+ // Render grid content: grouped (multiple tables with headers) or flat (single table)
652
+ const gridContent = isGrouped ? (
653
+ <div className="space-y-2">
654
+ {groups.map((group) => (
655
+ <div key={group.key} className="border rounded-md">
656
+ <button
657
+ type="button"
658
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm font-medium text-left bg-muted/50 hover:bg-muted transition-colors"
659
+ onClick={() => toggleGroup(group.key)}
660
+ >
661
+ {group.collapsed
662
+ ? <ChevronRight className="h-4 w-4 shrink-0" />
663
+ : <ChevronDown className="h-4 w-4 shrink-0" />}
664
+ <span>{group.label}</span>
665
+ <span className="ml-auto text-xs text-muted-foreground">{group.rows.length}</span>
666
+ </button>
667
+ {!group.collapsed && (
668
+ <SchemaRenderer schema={buildGroupTableSchema(group.rows)} />
669
+ )}
670
+ </div>
671
+ ))}
672
+ </div>
673
+ ) : (
674
+ <SchemaRenderer schema={dataTableSchema} />
675
+ );
676
+
553
677
  // For split mode, wrap the grid in the ResizablePanelGroup
554
678
  if (navigation.isOverlay && navigation.mode === 'split') {
555
679
  return (
556
680
  <NavigationOverlay
557
681
  {...navigation}
558
682
  title={detailTitle}
559
- mainContent={<SchemaRenderer schema={dataTableSchema} />}
683
+ mainContent={gridContent}
560
684
  >
561
685
  {(record) => (
562
686
  <div className="space-y-3">
@@ -575,8 +699,16 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
575
699
  }
576
700
 
577
701
  return (
578
- <>
579
- <SchemaRenderer schema={dataTableSchema} />
702
+ <div ref={pullRef} className="relative h-full">
703
+ {pullDistance > 0 && (
704
+ <div
705
+ className="flex items-center justify-center text-xs text-muted-foreground"
706
+ style={{ height: pullDistance }}
707
+ >
708
+ {isRefreshing ? 'Refreshing…' : 'Pull to refresh'}
709
+ </div>
710
+ )}
711
+ {gridContent}
580
712
  {navigation.isOverlay && (
581
713
  <NavigationOverlay
582
714
  {...navigation}
@@ -596,6 +728,6 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
596
728
  )}
597
729
  </NavigationOverlay>
598
730
  )}
599
- </>
731
+ </div>
600
732
  );
601
733
  };