@object-ui/plugin-detail 3.1.1 → 3.1.3

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.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +43 -41
  2. package/CHANGELOG.md +20 -0
  3. package/dist/{AddressField-B1iVr404.js → AddressField-BtiTrEpf.js} +1 -1
  4. package/dist/{AvatarField-Duw4xOLZ.js → AvatarField-CwlnWNSf.js} +7 -7
  5. package/dist/{BooleanField-CZ4axVeq.js → BooleanField-DpMXU2ya.js} +1 -1
  6. package/dist/{CodeField-BSz-mk2v.js → CodeField-gwmcFihg.js} +2 -2
  7. package/dist/{ColorField-B522ad8m.js → ColorField-CWmF_zoW.js} +1 -1
  8. package/dist/{CurrencyField-Cwr3_pow.js → CurrencyField-BF3tYAgm.js} +1 -1
  9. package/dist/{DateField-DCo6dxud.js → DateField-a6Ka9ph2.js} +1 -1
  10. package/dist/{DateTimeField-BWfBuANO.js → DateTimeField-C4wWOEiw.js} +1 -1
  11. package/dist/{EmailField-CpwbdVCU.js → EmailField-DJqiQ4sp.js} +1 -1
  12. package/dist/{FileField-DVAUAJ8e.js → FileField-ChjjCydz.js} +1 -1
  13. package/dist/{GeolocationField-DNCKitgo.js → GeolocationField-BnkeUBek.js} +1 -1
  14. package/dist/{GridField-DSblZNfp.js → GridField-DoHqc2ON.js} +6 -6
  15. package/dist/{ImageField-DBAlnMon.js → ImageField-Ld7SHA8N.js} +1 -1
  16. package/dist/{LocationField-DsHsXA6R.js → LocationField-Bgu-vMAE.js} +1 -1
  17. package/dist/{MasterDetailField-Db8b7Gqs.js → MasterDetailField-Bp5WBTzU.js} +3 -3
  18. package/dist/{NumberField-0IGp7lcA.js → NumberField-uBqVZ-gt.js} +1 -1
  19. package/dist/{ObjectField-BLApgJtS.js → ObjectField-BH1Md9gH.js} +6 -6
  20. package/dist/{PasswordField-pHKyNlmo.js → PasswordField-D8GZjY7d.js} +1 -1
  21. package/dist/{PercentField-CwgKmlIb.js → PercentField-DyK8vg8M.js} +1 -1
  22. package/dist/{PhoneField-lKtbYOdN.js → PhoneField-B3qJyLP0.js} +1 -1
  23. package/dist/{QRCodeField-BTTasT3w.js → QRCodeField-CGiRTCZq.js} +1 -1
  24. package/dist/{RatingField-De2X-l44.js → RatingField-CWVaJNyf.js} +4 -4
  25. package/dist/{RichTextField-B5QnvUOr.js → RichTextField-CusveP9T.js} +1 -1
  26. package/dist/{SelectField-C9AZRHWu.js → SelectField-UdDfsEZo.js} +1 -1
  27. package/dist/{SignatureField-BgcEmYzd.js → SignatureField-DFvPKbuI.js} +1 -1
  28. package/dist/{SliderField-BzrttVOY.js → SliderField-C-HvGV9e.js} +1 -1
  29. package/dist/{TextAreaField-DSE_CaU6.js → TextAreaField-C5KygUT3.js} +1 -1
  30. package/dist/{TextField-DFQ4T9PR.js → TextField-oUjuqQ1x.js} +1 -1
  31. package/dist/{TimeField-F0cfmsps.js → TimeField-SsQ6rfk5.js} +1 -1
  32. package/dist/{UrlField-DLXrFIH-.js → UrlField-kd48Ip95.js} +1 -1
  33. package/dist/{UserField-PXMmxJY9.js → UserField-BOjE_CAz.js} +11 -11
  34. package/dist/index-D2t9pLAg.js +99948 -0
  35. package/dist/index.js +10 -10
  36. package/dist/index.umd.cjs +118 -49
  37. package/dist/plugin-detail.css +1 -1
  38. package/dist/src/DetailSection.d.ts +19 -0
  39. package/dist/src/DetailSection.d.ts.map +1 -1
  40. package/dist/src/DetailView.d.ts.map +1 -1
  41. package/dist/src/HeaderHighlight.d.ts +2 -0
  42. package/dist/src/HeaderHighlight.d.ts.map +1 -1
  43. package/dist/src/RecordChatterPanel.d.ts +2 -0
  44. package/dist/src/RecordChatterPanel.d.ts.map +1 -1
  45. package/dist/src/autoLayout.d.ts +10 -3
  46. package/dist/src/autoLayout.d.ts.map +1 -1
  47. package/dist/src/index.d.ts +1 -1
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/package.json +7 -7
  50. package/src/DetailSection.tsx +79 -22
  51. package/src/DetailView.tsx +16 -9
  52. package/src/HeaderHighlight.tsx +22 -1
  53. package/src/RecordChatterPanel.tsx +6 -1
  54. package/src/RelatedList.tsx +1 -1
  55. package/src/__tests__/DetailSection.test.tsx +171 -1
  56. package/src/__tests__/DetailView.test.tsx +31 -0
  57. package/src/__tests__/HeaderHighlight.test.tsx +145 -0
  58. package/src/__tests__/RecordChatterPanel.test.tsx +38 -0
  59. package/src/__tests__/RelatedList.test.tsx +3 -3
  60. package/src/__tests__/autoLayout.test.ts +44 -0
  61. package/src/autoLayout.ts +25 -8
  62. package/src/index.tsx +1 -1
  63. package/dist/LookupField-CsT0QQz2.js +0 -96
  64. package/dist/index-qQ1C-yUR.js +0 -59976
  65. package/src/registration.test.tsx +0 -18
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { describe, it, expect } from 'vitest';
10
10
  import { render, screen } from '@testing-library/react';
11
- import { DetailSection } from '../DetailSection';
11
+ import { DetailSection, getResponsiveSpanClass } from '../DetailSection';
12
12
 
13
13
  describe('DetailSection', () => {
14
14
  it('should render text fields as plain text', () => {
@@ -317,4 +317,174 @@ describe('DetailSection', () => {
317
317
  // Should use 'text' renderer, not 'number'
318
318
  expect(screen.getByText('Alice')).toBeInTheDocument();
319
319
  });
320
+
321
+ it('should use responsive span classes for wide fields in 3-column layout', () => {
322
+ const section = {
323
+ title: 'Wide Fields',
324
+ fields: Array.from({ length: 12 }, (_, i) => ({
325
+ name: `field_${i}`,
326
+ label: `Field ${i}`,
327
+ type: i === 5 ? 'textarea' : 'text',
328
+ })),
329
+ };
330
+ const { container } = render(
331
+ <DetailSection section={section} data={{}} />
332
+ );
333
+ const grid = container.querySelector('.grid');
334
+ expect(grid).toBeTruthy();
335
+ expect(grid!.className).toContain('lg:grid-cols-3');
336
+ // Wide field (textarea) should have responsive span, not bare col-span-3
337
+ const fields = container.querySelectorAll('[class*="col-span"]');
338
+ fields.forEach((field) => {
339
+ // No bare col-span-3 at base level — must be lg: prefixed
340
+ const classes = field.className.split(/\s+/);
341
+ const hasBareSpan3 = classes.some((c: string) => c === 'col-span-3');
342
+ expect(hasBareSpan3).toBe(false);
343
+ });
344
+ });
345
+
346
+ it('should use responsive span classes for wide fields in 2-column layout', () => {
347
+ const section = {
348
+ title: 'Wide Fields',
349
+ fields: [
350
+ { name: 'a', label: 'A', type: 'text' },
351
+ { name: 'b', label: 'B', type: 'text' },
352
+ { name: 'c', label: 'C', type: 'text' },
353
+ { name: 'd', label: 'D', type: 'text' },
354
+ { name: 'notes', label: 'Notes', type: 'textarea' },
355
+ ],
356
+ };
357
+ const { container } = render(
358
+ <DetailSection section={section} data={{}} />
359
+ );
360
+ const grid = container.querySelector('.grid');
361
+ expect(grid!.className).toContain('md:grid-cols-2');
362
+ // Wide field should have md:col-span-2, not bare col-span-2
363
+ const fields = container.querySelectorAll('[class*="col-span"]');
364
+ fields.forEach((field) => {
365
+ const classes = field.className.split(/\s+/);
366
+ const hasBareSpan2 = classes.some((c: string) => c === 'col-span-2');
367
+ expect(hasBareSpan2).toBe(false);
368
+ });
369
+ });
370
+
371
+ it('should not apply col-span at base breakpoint to prevent implicit grid columns on mobile', () => {
372
+ const section = {
373
+ title: 'Mobile Safe',
374
+ fields: Array.from({ length: 15 }, (_, i) => ({
375
+ name: `field_${i}`,
376
+ label: `Field ${i}`,
377
+ type: i === 0 ? 'textarea' : 'text',
378
+ })),
379
+ };
380
+ const { container } = render(
381
+ <DetailSection section={section} data={{}} />
382
+ );
383
+ // Ensure no bare col-span-N (N>1) classes without responsive prefix
384
+ const allElements = container.querySelectorAll('*');
385
+ allElements.forEach((el) => {
386
+ const classes = el.className?.split?.(/\s+/) || [];
387
+ classes.forEach((cls: string) => {
388
+ if (cls.match(/^col-span-[2-9]$/)) {
389
+ throw new Error(`Found bare "${cls}" class without responsive prefix — would break mobile single-column layout`);
390
+ }
391
+ });
392
+ });
393
+ });
394
+
395
+ it('should initially render a batch when virtualScroll is enabled with many fields', () => {
396
+ const section = {
397
+ title: 'Virtual',
398
+ fields: Array.from({ length: 50 }, (_, i) => ({
399
+ name: `field_${i}`,
400
+ label: `Field ${i}`,
401
+ type: 'text',
402
+ })),
403
+ };
404
+ const { container } = render(
405
+ <DetailSection
406
+ section={section}
407
+ data={{}}
408
+ virtualScroll={{ enabled: true, batchSize: 10 }}
409
+ />
410
+ );
411
+ const grid = container.querySelector('.grid');
412
+ expect(grid).toBeTruthy();
413
+ // Initially should render only the batch (10 fields), not all 50
414
+ const fieldElements = grid!.children;
415
+ expect(fieldElements.length).toBeLessThanOrEqual(10);
416
+ });
417
+
418
+ it('should render all fields when virtualScroll is disabled', () => {
419
+ const section = {
420
+ title: 'No Virtual',
421
+ fields: Array.from({ length: 50 }, (_, i) => ({
422
+ name: `field_${i}`,
423
+ label: `Field ${i}`,
424
+ type: 'text',
425
+ })),
426
+ };
427
+ const { container } = render(
428
+ <DetailSection section={section} data={{}} />
429
+ );
430
+ const grid = container.querySelector('.grid');
431
+ expect(grid).toBeTruthy();
432
+ expect(grid!.children.length).toBe(50);
433
+ });
434
+
435
+ it('should render all fields when virtualScroll is enabled but field count is below batch size', () => {
436
+ const section = {
437
+ title: 'Small',
438
+ fields: Array.from({ length: 5 }, (_, i) => ({
439
+ name: `field_${i}`,
440
+ label: `Field ${i}`,
441
+ type: 'text',
442
+ })),
443
+ };
444
+ const { container } = render(
445
+ <DetailSection
446
+ section={section}
447
+ data={{}}
448
+ virtualScroll={{ enabled: true, batchSize: 20 }}
449
+ />
450
+ );
451
+ const grid = container.querySelector('.grid');
452
+ expect(grid).toBeTruthy();
453
+ expect(grid!.children.length).toBe(5);
454
+ });
455
+ });
456
+
457
+ describe('getResponsiveSpanClass', () => {
458
+ it('should return empty string for no span', () => {
459
+ expect(getResponsiveSpanClass(undefined, 2)).toBe('');
460
+ });
461
+
462
+ it('should return empty string for span=1', () => {
463
+ expect(getResponsiveSpanClass(1, 3)).toBe('');
464
+ });
465
+
466
+ it('should return empty string for 1-column layout', () => {
467
+ expect(getResponsiveSpanClass(3, 1)).toBe('');
468
+ });
469
+
470
+ it('should return md:col-span-2 for span=2 in 2-column layout', () => {
471
+ expect(getResponsiveSpanClass(2, 2)).toBe('md:col-span-2');
472
+ });
473
+
474
+ it('should cap span to 2 in 2-column layout', () => {
475
+ expect(getResponsiveSpanClass(3, 2)).toBe('md:col-span-2');
476
+ expect(getResponsiveSpanClass(6, 2)).toBe('md:col-span-2');
477
+ });
478
+
479
+ it('should return md:col-span-2 for span=2 in 3-column layout', () => {
480
+ expect(getResponsiveSpanClass(2, 3)).toBe('md:col-span-2');
481
+ });
482
+
483
+ it('should return responsive classes for span=3 in 3-column layout', () => {
484
+ expect(getResponsiveSpanClass(3, 3)).toBe('md:col-span-2 lg:col-span-3');
485
+ });
486
+
487
+ it('should cap span to 3 in 3-column layout', () => {
488
+ expect(getResponsiveSpanClass(6, 3)).toBe('md:col-span-2 lg:col-span-3');
489
+ });
320
490
  });
@@ -539,6 +539,37 @@ describe('DetailView', () => {
539
539
  expect(onBack).toHaveBeenCalled();
540
540
  });
541
541
 
542
+ it('should try fallback with alternate ID when first findOne throws an error', async () => {
543
+ let callCount = 0;
544
+ const mockDataSource = {
545
+ findOne: vi.fn().mockImplementation((_obj: string, id: string) => {
546
+ callCount++;
547
+ if (callCount === 1) {
548
+ // First call throws (simulate server error)
549
+ return Promise.reject(new Error('Server error'));
550
+ }
551
+ // Second call (fallback) succeeds
552
+ return Promise.resolve({ name: 'Alice' });
553
+ }),
554
+ } as any;
555
+
556
+ const schema: DetailViewSchema = {
557
+ type: 'detail-view',
558
+ title: 'Contact Details',
559
+ objectName: 'contact',
560
+ resourceId: 'contact-123',
561
+ fields: [{ name: 'name', label: 'Name' }],
562
+ };
563
+
564
+ const { findByText } = render(<DetailView schema={schema} dataSource={mockDataSource} />);
565
+ // The fallback should find the record using the stripped ID
566
+ expect(await findByText('Alice')).toBeInTheDocument();
567
+ // findOne should be called twice: first with original ID, then with stripped prefix
568
+ expect(mockDataSource.findOne).toHaveBeenCalledTimes(2);
569
+ expect(mockDataSource.findOne).toHaveBeenNthCalledWith(1, 'contact', 'contact-123');
570
+ expect(mockDataSource.findOne).toHaveBeenNthCalledWith(2, 'contact', '123');
571
+ });
572
+
542
573
  it('should call findOne with $expand when objectSchema has lookup fields', async () => {
543
574
  const mockDataSource = {
544
575
  getObjectSchema: vi.fn().mockResolvedValue({
@@ -65,4 +65,149 @@ describe('HeaderHighlight', () => {
65
65
  render(<HeaderHighlight fields={fieldsWithIcon} data={{ revenue: '$5M' }} />);
66
66
  expect(screen.getByText('💰')).toBeInTheDocument();
67
67
  });
68
+
69
+ it('should render currency fields with formatted value via CellRenderer', () => {
70
+ const currencyFields: HighlightField[] = [
71
+ { name: 'amount', label: 'Amount', type: 'currency' },
72
+ ];
73
+ render(<HeaderHighlight fields={currencyFields} data={{ amount: 250000 }} />);
74
+ // CurrencyCellRenderer should format — should NOT show raw "250000"
75
+ expect(screen.queryByText('250000')).not.toBeInTheDocument();
76
+ expect(screen.getByText(/250,000/)).toBeInTheDocument();
77
+ });
78
+
79
+ it('should render select fields as badge via CellRenderer', () => {
80
+ const selectFields: HighlightField[] = [
81
+ { name: 'stage', label: 'Stage', type: 'select' },
82
+ ];
83
+ render(
84
+ <HeaderHighlight
85
+ fields={selectFields}
86
+ data={{ stage: 'prospecting' }}
87
+ objectSchema={{
88
+ fields: {
89
+ stage: {
90
+ type: 'select',
91
+ options: [
92
+ { value: 'prospecting', label: 'Prospecting', color: 'blue' },
93
+ ],
94
+ },
95
+ },
96
+ }}
97
+ />
98
+ );
99
+ expect(screen.getByText('Prospecting')).toBeInTheDocument();
100
+ });
101
+
102
+ it('should enrich field type from objectSchema when field.type is not set', () => {
103
+ const fieldsNoType: HighlightField[] = [
104
+ { name: 'amount', label: 'Amount' },
105
+ ];
106
+ render(
107
+ <HeaderHighlight
108
+ fields={fieldsNoType}
109
+ data={{ amount: 5000 }}
110
+ objectSchema={{
111
+ fields: {
112
+ amount: { type: 'currency', currency: 'USD' },
113
+ },
114
+ }}
115
+ />
116
+ );
117
+ // CurrencyCellRenderer should format, not raw String()
118
+ expect(screen.queryByText('5000')).not.toBeInTheDocument();
119
+ expect(screen.getByText(/5,000/)).toBeInTheDocument();
120
+ });
121
+
122
+ it('should fall back to text when no type info is available', () => {
123
+ const fieldsNoType: HighlightField[] = [
124
+ { name: 'custom', label: 'Custom' },
125
+ ];
126
+ render(<HeaderHighlight fields={fieldsNoType} data={{ custom: 'raw-value' }} />);
127
+ expect(screen.getByText('raw-value')).toBeInTheDocument();
128
+ });
129
+
130
+ it('should safely render object values without crashing (React error #310 guard)', () => {
131
+ // Simulates MongoDB Decimal128 or expanded reference objects
132
+ const fieldsWithNumber: HighlightField[] = [
133
+ { name: 'amount', label: 'Amount' },
134
+ { name: 'account', label: 'Account' },
135
+ ];
136
+ const objectData = {
137
+ amount: { $numberDecimal: '250000' },
138
+ account: { id: 'abc', name: 'Acme Corp' },
139
+ };
140
+ const objectSchema = {
141
+ fields: {
142
+ amount: { type: 'number' },
143
+ account: { type: 'text' },
144
+ },
145
+ };
146
+ // Should NOT crash — cell renderers coerce values via coerceToSafeValue
147
+ const { container } = render(
148
+ <HeaderHighlight
149
+ fields={fieldsWithNumber}
150
+ data={objectData}
151
+ objectSchema={objectSchema}
152
+ />
153
+ );
154
+ expect(container.innerHTML).not.toBe('');
155
+ // NumberCellRenderer coerces $numberDecimal to number
156
+ expect(screen.getByText('250,000')).toBeInTheDocument();
157
+ // TextCellRenderer extracts name from object
158
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
159
+ });
160
+
161
+ it('should safely render lookup object values via LookupCellRenderer', () => {
162
+ const lookupFields: HighlightField[] = [
163
+ { name: 'owner', label: 'Owner' },
164
+ ];
165
+ const lookupData = {
166
+ owner: { id: 'u1', name: 'Jane Doe' },
167
+ };
168
+ const objectSchema = {
169
+ fields: {
170
+ owner: { type: 'lookup' },
171
+ },
172
+ };
173
+ // LookupCellRenderer handles object values natively
174
+ render(
175
+ <HeaderHighlight
176
+ fields={lookupFields}
177
+ data={lookupData}
178
+ objectSchema={objectSchema}
179
+ />
180
+ );
181
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
182
+ });
183
+
184
+ it('should safely render array values via TextCellRenderer', () => {
185
+ const arrayFields: HighlightField[] = [
186
+ { name: 'tags', label: 'Tags' },
187
+ ];
188
+ const arrayData = {
189
+ tags: ['urgent', 'follow-up'],
190
+ };
191
+ const { container } = render(
192
+ <HeaderHighlight fields={arrayFields} data={arrayData} />
193
+ );
194
+ expect(container.innerHTML).not.toBe('');
195
+ // TextCellRenderer coerces arrays to comma-separated string
196
+ expect(screen.getByText('urgent, follow-up')).toBeInTheDocument();
197
+ });
198
+
199
+ it('should safely render array of objects via TextCellRenderer', () => {
200
+ const arrayFields: HighlightField[] = [
201
+ { name: 'contacts', label: 'Contacts' },
202
+ ];
203
+ const arrayData = {
204
+ contacts: [{ name: 'Alice' }, { name: 'Bob' }],
205
+ };
206
+ const { container } = render(
207
+ <HeaderHighlight fields={arrayFields} data={arrayData} />
208
+ );
209
+ expect(container.innerHTML).not.toBe('');
210
+ // TextCellRenderer coerces array of objects to "Alice, Bob"
211
+ expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
212
+ });
68
213
  });
@@ -224,4 +224,42 @@ describe('RecordChatterPanel', () => {
224
224
  expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
225
225
  });
226
226
  });
227
+
228
+ describe('collapseWhenEmpty', () => {
229
+ it('should auto-collapse when empty and collapseWhenEmpty is true (inline mode)', () => {
230
+ render(
231
+ <RecordChatterPanel
232
+ config={{ position: 'bottom', collapsible: true }}
233
+ collapseWhenEmpty
234
+ items={[]}
235
+ />,
236
+ );
237
+ // Should be collapsed because items is empty
238
+ expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
239
+ });
240
+
241
+ it('should not auto-collapse when items exist and collapseWhenEmpty is true', () => {
242
+ render(
243
+ <RecordChatterPanel
244
+ config={{ position: 'bottom', collapsible: true }}
245
+ collapseWhenEmpty
246
+ items={mockItems}
247
+ />,
248
+ );
249
+ // Should be expanded because there are items
250
+ expect(screen.getByText('Activity')).toBeInTheDocument();
251
+ });
252
+
253
+ it('should auto-collapse sidebar when empty and collapseWhenEmpty is true', () => {
254
+ render(
255
+ <RecordChatterPanel
256
+ config={{ position: 'right', collapsible: true }}
257
+ collapseWhenEmpty
258
+ items={[]}
259
+ />,
260
+ );
261
+ // Should be collapsed
262
+ expect(screen.getByLabelText('Open discussion panel')).toBeInTheDocument();
263
+ });
264
+ });
227
265
  });
@@ -71,7 +71,7 @@ describe('RelatedList', () => {
71
71
  fields: {
72
72
  product: { type: 'string', label: 'Product' },
73
73
  quantity: { type: 'number', label: 'Quantity' },
74
- _id: { type: 'string', label: 'ID' },
74
+ id: { type: 'string', label: 'ID' },
75
75
  },
76
76
  }),
77
77
  find: vi.fn(),
@@ -92,12 +92,12 @@ describe('RelatedList', () => {
92
92
  expect(mockDataSource.getObjectSchema).toHaveBeenCalledWith('order_item');
93
93
  });
94
94
 
95
- // Verify columns are generated from schema (excluding _id)
95
+ // Verify columns are generated from schema (excluding id)
96
96
  await waitFor(() => {
97
97
  expect(screen.getByText('Product')).toBeInTheDocument();
98
98
  expect(screen.getByText('Quantity')).toBeInTheDocument();
99
99
  });
100
- // _id should be filtered out
100
+ // id should be filtered out
101
101
  expect(screen.queryByText('ID')).not.toBeInTheDocument();
102
102
  });
103
103
 
@@ -37,6 +37,33 @@ describe('Detail Auto-Layout', () => {
37
37
  expect(inferDetailColumns(15)).toBe(3);
38
38
  expect(inferDetailColumns(50)).toBe(3);
39
39
  });
40
+
41
+ it('should cap to 1 column when containerWidth < 640', () => {
42
+ expect(inferDetailColumns(15, 500)).toBe(1);
43
+ expect(inferDetailColumns(5, 639)).toBe(1);
44
+ });
45
+
46
+ it('should cap to 2 columns when containerWidth < 900', () => {
47
+ expect(inferDetailColumns(15, 800)).toBe(2);
48
+ expect(inferDetailColumns(15, 640)).toBe(2);
49
+ expect(inferDetailColumns(5, 899)).toBe(2);
50
+ });
51
+
52
+ it('should not cap columns when containerWidth >= 900', () => {
53
+ expect(inferDetailColumns(15, 900)).toBe(3);
54
+ expect(inferDetailColumns(15, 1200)).toBe(3);
55
+ });
56
+
57
+ it('should not cap below field-count inference', () => {
58
+ // 2 fields → 1 column, containerWidth 800 would cap at 2, but inference says 1
59
+ expect(inferDetailColumns(2, 800)).toBe(1);
60
+ });
61
+
62
+ it('should still work without containerWidth (backward compatible)', () => {
63
+ expect(inferDetailColumns(15)).toBe(3);
64
+ expect(inferDetailColumns(5)).toBe(2);
65
+ expect(inferDetailColumns(2)).toBe(1);
66
+ });
40
67
  });
41
68
 
42
69
  describe('isWideFieldType', () => {
@@ -180,5 +207,22 @@ describe('Detail Auto-Layout', () => {
180
207
  expect(result.columns).toBe(2);
181
208
  expect(result.fields.length).toBe(8);
182
209
  });
210
+
211
+ it('should cap columns based on containerWidth when provided', () => {
212
+ const fields = Array.from({ length: 15 }, (_, i) => ({
213
+ name: `f${i}`, label: `F${i}`, type: 'text',
214
+ }));
215
+ // Narrow container: should cap to 1
216
+ const result = applyDetailAutoLayout(fields, undefined, 500);
217
+ expect(result.columns).toBe(1);
218
+ });
219
+
220
+ it('should still respect explicit schemaColumns regardless of containerWidth', () => {
221
+ const fields = Array.from({ length: 15 }, (_, i) => ({
222
+ name: `f${i}`, label: `F${i}`, type: 'text',
223
+ }));
224
+ const result = applyDetailAutoLayout(fields, 3, 500);
225
+ expect(result.columns).toBe(3);
226
+ });
183
227
  });
184
228
  });
package/src/autoLayout.ts CHANGED
@@ -46,16 +46,31 @@ export function isWideFieldType(type: string): boolean {
46
46
 
47
47
  /**
48
48
  * Infer optimal number of columns for a detail section based on field count.
49
+ * When containerWidth is provided, limits columns for narrower viewports.
49
50
  *
50
- * Rules:
51
+ * Rules (field-count based):
51
52
  * - 0-3 fields → 1 column
52
53
  * - 4-10 fields → 2 columns
53
54
  * - 11+ fields → 3 columns
55
+ *
56
+ * Responsive capping (when containerWidth is supplied):
57
+ * - containerWidth < 640px → max 1 column
58
+ * - containerWidth < 900px → max 2 columns
59
+ * - containerWidth >= 900px → no cap
54
60
  */
55
- export function inferDetailColumns(fieldCount: number): number {
56
- if (fieldCount <= 3) return 1;
57
- if (fieldCount <= 10) return 2;
58
- return 3;
61
+ export function inferDetailColumns(fieldCount: number, containerWidth?: number): number {
62
+ let cols: number;
63
+ if (fieldCount <= 3) cols = 1;
64
+ else if (fieldCount <= 10) cols = 2;
65
+ else cols = 3;
66
+
67
+ // Apply responsive capping when container width is known
68
+ if (containerWidth !== undefined) {
69
+ if (containerWidth < 640) return Math.min(cols, 1);
70
+ if (containerWidth < 900) return Math.min(cols, 2);
71
+ }
72
+
73
+ return cols;
59
74
  }
60
75
 
61
76
  /**
@@ -89,11 +104,13 @@ export function applyAutoSpan(
89
104
  *
90
105
  * @param fields - The section fields
91
106
  * @param schemaColumns - User-provided columns (from DetailViewSection or DetailViewSchema)
107
+ * @param containerWidth - Optional container width in px for responsive column capping
92
108
  * @returns Object with processed fields and inferred columns
93
109
  */
94
110
  export function applyDetailAutoLayout(
95
111
  fields: DetailViewField[],
96
- schemaColumns: number | undefined
112
+ schemaColumns: number | undefined,
113
+ containerWidth?: number
97
114
  ): { fields: DetailViewField[]; columns: number } {
98
115
  // If user explicitly set columns, respect it but still apply auto span
99
116
  if (schemaColumns !== undefined) {
@@ -101,8 +118,8 @@ export function applyDetailAutoLayout(
101
118
  return { fields: processed, columns: schemaColumns };
102
119
  }
103
120
 
104
- // Infer columns from field count
105
- const columns = inferDetailColumns(fields.length);
121
+ // Infer columns from field count (with optional container-width capping)
122
+ const columns = inferDetailColumns(fields.length, containerWidth);
106
123
 
107
124
  // Apply auto span for wide fields
108
125
  const processed = applyAutoSpan(fields, columns);
package/src/index.tsx CHANGED
@@ -36,7 +36,7 @@ export { SubscriptionToggle } from './SubscriptionToggle';
36
36
  export { ReactionPicker } from './ReactionPicker';
37
37
  export { ThreadedReplies } from './ThreadedReplies';
38
38
  export type { DetailViewProps } from './DetailView';
39
- export type { DetailSectionProps } from './DetailSection';
39
+ export type { DetailSectionProps, VirtualScrollOptions } from './DetailSection';
40
40
  export type { DetailTabsProps } from './DetailTabs';
41
41
  export type { RelatedListProps } from './RelatedList';
42
42
  export type { SectionGroupProps } from './SectionGroup';
@@ -1,96 +0,0 @@
1
- import { jsx as l, jsxs as r } from "react/jsx-runtime";
2
- import { useState as y } from "react";
3
- import { h as u, X as D, D as j, i as k, B, j as C, k as O, l as F, m as I, I as L } from "./index-qQ1C-yUR.js";
4
- function R({ value: s, onChange: n, field: x, readonly: g, ...b }) {
5
- const [N, f] = y(!1), [h, v] = y(""), c = x || b.schema, o = c?.options || [], i = c.multiple || !1, d = c.display_field || "label", p = o.filter(
6
- (e) => e.label.toLowerCase().includes(h.toLowerCase())
7
- ), t = i ? (Array.isArray(s) ? s : []).map(
8
- (e) => o.find((a) => a.value === e)
9
- ).filter(Boolean) : s ? [o.find((e) => e.value === s)].filter(Boolean) : [], w = (e) => {
10
- if (i) {
11
- const a = Array.isArray(s) ? s : [], m = a.includes(e.value);
12
- n(m ? a.filter((A) => A !== e.value) : [...a, e.value]);
13
- } else
14
- n(e.value), f(!1);
15
- }, S = (e) => {
16
- if (i) {
17
- const a = Array.isArray(s) ? s : [];
18
- n(a.filter((m) => m !== e));
19
- } else
20
- n(null);
21
- };
22
- return g ? t.length ? i ? /* @__PURE__ */ l("div", { className: "flex flex-wrap gap-1", children: t.map((e, a) => /* @__PURE__ */ l(u, { variant: "outline", children: e?.[d] || e?.label }, a)) }) : /* @__PURE__ */ l("span", { className: "text-sm", children: t[0]?.[d] || t[0]?.label }) : /* @__PURE__ */ l("span", { className: "text-sm", children: "-" }) : /* @__PURE__ */ r("div", { className: "space-y-2", children: [
23
- t.length > 0 && /* @__PURE__ */ l("div", { className: "flex flex-wrap gap-1", children: t.map((e, a) => /* @__PURE__ */ r(
24
- u,
25
- {
26
- variant: "outline",
27
- className: "gap-1",
28
- children: [
29
- e?.[d] || e?.label,
30
- /* @__PURE__ */ l(
31
- "button",
32
- {
33
- onClick: () => S(e?.value),
34
- className: "ml-1 hover:text-destructive",
35
- type: "button",
36
- "aria-label": `Remove ${e?.[d] || e?.label}`,
37
- children: /* @__PURE__ */ l(D, { className: "size-3" })
38
- }
39
- )
40
- ]
41
- },
42
- a
43
- )) }),
44
- /* @__PURE__ */ r(j, { open: N, onOpenChange: f, children: [
45
- /* @__PURE__ */ l(k, { asChild: !0, children: /* @__PURE__ */ r(
46
- B,
47
- {
48
- variant: "outline",
49
- className: "w-full justify-start text-left font-normal",
50
- type: "button",
51
- children: [
52
- /* @__PURE__ */ l(C, { className: "mr-2 size-4" }),
53
- t.length === 0 ? c?.placeholder || "Select..." : i ? `${t.length} selected` : "Change selection"
54
- ]
55
- }
56
- ) }),
57
- /* @__PURE__ */ r(O, { className: "max-w-md", children: [
58
- /* @__PURE__ */ l(F, { children: /* @__PURE__ */ r(I, { children: [
59
- c?.label || "Select",
60
- " ",
61
- i && "(multiple)"
62
- ] }) }),
63
- /* @__PURE__ */ r("div", { className: "space-y-4", children: [
64
- /* @__PURE__ */ l(
65
- L,
66
- {
67
- placeholder: "Search...",
68
- value: h,
69
- onChange: (e) => v(e.target.value),
70
- className: "w-full"
71
- }
72
- ),
73
- /* @__PURE__ */ l("div", { className: "max-h-64 overflow-y-auto space-y-1", children: p.length === 0 ? /* @__PURE__ */ l("p", { className: "text-sm text-gray-500 text-center py-4", children: "No options found" }) : p.map((e) => {
74
- const a = i ? (Array.isArray(s) ? s : []).includes(e.value) : s === e.value;
75
- return /* @__PURE__ */ r(
76
- "button",
77
- {
78
- onClick: () => w(e),
79
- className: `w-full text-left px-3 py-2 rounded-md text-sm hover:bg-gray-100 flex items-center justify-between ${a ? "bg-blue-50 text-blue-700" : ""}`,
80
- type: "button",
81
- children: [
82
- /* @__PURE__ */ l("span", { children: e.label }),
83
- a && /* @__PURE__ */ l(u, { variant: "default", className: "ml-2", children: "Selected" })
84
- ]
85
- },
86
- e.value
87
- );
88
- }) })
89
- ] })
90
- ] })
91
- ] })
92
- ] });
93
- }
94
- export {
95
- R as LookupField
96
- };