diff --git a/ROADMAP.md b/ROADMAP.md
index a50da4a04..6a7215b75 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1414,7 +1414,34 @@ All 313 `@object-ui/fields` tests pass.
---
-### DetailView Rendering Optimization (March 2026)
+### DetailView/RecordDetailView SDUI Optimization (March 2026)
+
+> Type-aware rendering, responsive layout, virtual scrolling, metadata-driven highlights, performance optimization, and activity panel collapse-when-empty.
+
+**HeaderHighlight (`@object-ui/plugin-detail`):**
+- [x] Use `getCellRenderer` for type-aware display (currency → `$250,000.00`, select → Badge, etc.) instead of raw `String(value)`
+- [x] Add `objectSchema` prop for field metadata enrichment (type, options, currency, precision, format)
+
+**autoLayout (`@object-ui/plugin-detail`):**
+- [x] `inferDetailColumns` accepts optional `containerWidth` for responsive column capping (`<640px→1col`, `<900px→max 2col`)
+- [x] `applyDetailAutoLayout` passes through `containerWidth` parameter
+
+**RecordChatterPanel (`@object-ui/plugin-detail`):**
+- [x] `collapseWhenEmpty` prop: auto-collapse panel when no feed items exist
+- [x] Pass `collapseWhenEmpty` through to embedded `RecordActivityTimeline`
+
+**DetailSection (`@object-ui/plugin-detail`):**
+- [x] `virtualScroll` config (`VirtualScrollOptions`): progressive batch rendering for sections with many fields
+- [x] Export `VirtualScrollOptions` type from package index
+
+**RecordDetailView (`apps/console`):**
+- [x] Wrap `detailSchema` construction with `useMemo` (deps: `objectDef`, `pureRecordId`, `related`, `childRelatedData`, `actionRefreshKey`)
+- [x] Remove hardcoded `HIGHLIGHT_FIELD_NAMES`; read exclusively from `objectDef.views.detail.highlightFields` (no fallback)
+- [x] Enable `collapseWhenEmpty` + `collapsible: true` on `RecordChatterPanel`
+
+**Tests:** 125 plugin-detail tests passing (17 new) covering HeaderHighlight type-aware rendering, autoLayout responsive columns, RecordChatterPanel collapseWhenEmpty, DetailSection virtualScroll.
+
+---
> Platform-level DetailView enhancements: auto-grouping from form sections, empty value hiding, smart header with primaryField/summaryFields, responsive breakpoint fix, and activity timeline collapse.
diff --git a/apps/console/src/components/RecordDetailView.tsx b/apps/console/src/components/RecordDetailView.tsx
index 318b098dc..3f4a6b187 100644
--- a/apps/console/src/components/RecordDetailView.tsx
+++ b/apps/console/src/components/RecordDetailView.tsx
@@ -30,9 +30,6 @@ interface RecordDetailViewProps {
const FALLBACK_USER = { id: 'current-user', name: 'Demo User' };
-/** Field names automatically promoted to the highlight banner when present. */
-const HIGHLIGHT_FIELD_NAMES = ['status', 'stage', 'priority', 'category', 'type', 'owner', 'amount'];
-
export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailViewProps) {
const { objectName, recordId } = useParams();
const { showDebug } = useMetadataInspector();
@@ -398,16 +395,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
});
})();
- // Build highlightFields: prefer explicit config, fallback to auto-detect key fields
- const explicitHighlight: HighlightField[] | undefined = objectDef.views?.detail?.highlightFields;
- const highlightFields: HighlightField[] = explicitHighlight
- ?? Object.entries(objectDef.fields || {})
- .filter(([key]: [string, any]) => HIGHLIGHT_FIELD_NAMES.includes(key))
- .map(([key, def]: [string, any]) => ({
- name: key,
- label: def.label || key,
- ...(def.type && { type: def.type }),
- }));
+ // Build highlightFields: exclusively from objectDef metadata (no hardcoded fallback)
+ const highlightFields: HighlightField[] = objectDef.views?.detail?.highlightFields ?? [];
// Build sectionGroups from objectDef detail/form config if available
const sectionGroups: SectionGroup[] | undefined =
@@ -421,7 +410,7 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
data: childRelatedData[childObject] || [],
}));
- const detailSchema: DetailViewSchema = {
+ const detailSchema: DetailViewSchema = useMemo(() => ({
type: 'detail-view',
objectName: objectDef.name,
resourceId: pureRecordId,
@@ -443,7 +432,8 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
actions: recordHeaderActions,
} as any],
}),
- };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }), [objectDef.name, pureRecordId, childRelatedData, actionRefreshKey]);
return (
@@ -482,13 +472,14 @@ export function RecordDetailView({ dataSource, objects, onEdit }: RecordDetailVi
{
expect(bar).toHaveAttribute('aria-valuenow', '50');
});
});
+
+// =========================================================================
+// Object value safety (coerceToSafeValue) — prevents React error #310
+// =========================================================================
+import {
+ NumberCellRenderer,
+ CurrencyCellRenderer,
+ FormulaCellRenderer,
+ coerceToSafeValue,
+} from '../index';
+
+describe('coerceToSafeValue', () => {
+ it('should pass through primitives unchanged', () => {
+ expect(coerceToSafeValue('hello')).toBe('hello');
+ expect(coerceToSafeValue(42)).toBe(42);
+ expect(coerceToSafeValue(true)).toBe(true);
+ expect(coerceToSafeValue(null)).toBe(null);
+ expect(coerceToSafeValue(undefined)).toBe(undefined);
+ });
+
+ it('should extract number from MongoDB $numberDecimal', () => {
+ expect(coerceToSafeValue({ $numberDecimal: '250000' })).toBe(250000);
+ });
+
+ it('should extract string from MongoDB $oid', () => {
+ expect(coerceToSafeValue({ $oid: 'abc123' })).toBe('abc123');
+ });
+
+ it('should extract string from MongoDB $date', () => {
+ expect(coerceToSafeValue({ $date: '2024-01-01T00:00:00Z' })).toBe('2024-01-01T00:00:00Z');
+ });
+
+ it('should extract name from expanded reference object', () => {
+ expect(coerceToSafeValue({ _id: 'x', name: 'Acme Corp' })).toBe('Acme Corp');
+ });
+
+ it('should extract label when name is not present', () => {
+ expect(coerceToSafeValue({ _id: 'x', label: 'Active' })).toBe('Active');
+ });
+
+ it('should fall back to _id when no name/label', () => {
+ expect(coerceToSafeValue({ _id: 'abc123' })).toBe('abc123');
+ });
+
+ it('should handle arrays of primitives', () => {
+ expect(coerceToSafeValue(['a', 'b', 'c'])).toBe('a, b, c');
+ });
+
+ it('should handle arrays of objects', () => {
+ expect(coerceToSafeValue([{ name: 'Alice' }, { name: 'Bob' }])).toBe('Alice, Bob');
+ });
+
+ it('should convert Date to ISO string', () => {
+ const d = new Date('2024-06-15T12:00:00Z');
+ expect(coerceToSafeValue(d)).toBe('2024-06-15T12:00:00.000Z');
+ });
+});
+
+describe('NumberCellRenderer object safety', () => {
+ it('should handle MongoDB $numberDecimal without crashing', () => {
+ const { container } = render(
+
+ );
+ expect(container.innerHTML).not.toBe('');
+ expect(screen.getByText('250,000')).toBeInTheDocument();
+ });
+
+ it('should handle expanded reference object without crashing', () => {
+ const { container } = render(
+
+ );
+ expect(container.innerHTML).not.toBe('');
+ // Should render the extracted name string, not crash
+ expect(screen.getByText('Not a number')).toBeInTheDocument();
+ });
+});
+
+describe('CurrencyCellRenderer object safety', () => {
+ it('should handle MongoDB $numberDecimal', () => {
+ const { container } = render(
+
+ );
+ expect(container.innerHTML).not.toBe('');
+ expect(screen.getByText(/5,000/)).toBeInTheDocument();
+ });
+});
+
+describe('TextCellRenderer object safety', () => {
+ it('should extract name from object instead of [object Object]', () => {
+ render(
+
+ );
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
+ });
+
+ it('should handle arrays of objects', () => {
+ render(
+
+ );
+ expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
+ });
+});
+
+describe('FormulaCellRenderer object safety', () => {
+ it('should extract value from MongoDB $numberDecimal', () => {
+ render(
+
+ );
+ expect(screen.getByText('42.5')).toBeInTheDocument();
+ });
+});
diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx
index 1f2e179a0..9148cf62a 100644
--- a/packages/fields/src/index.tsx
+++ b/packages/fields/src/index.tsx
@@ -60,6 +60,39 @@ export interface CellRendererProps {
onChange?: (value: any) => void;
}
+/**
+ * Coerce a value to a safe primitive for rendering.
+ * Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
+ * reference objects, and arrays so that no raw object is ever passed as
+ * a React child — preventing React error #310.
+ */
+export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
+ if (value == null) return value as null | undefined;
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value;
+ if (value instanceof Date) return value.toISOString();
+ if (Array.isArray(value)) {
+ return value.map((v) => {
+ if (v != null && typeof v === 'object') {
+ const obj = v as Record;
+ return String(obj.name || obj.label || obj._id || '[Object]');
+ }
+ return String(v);
+ }).join(', ');
+ }
+ if (typeof value === 'object') {
+ const obj = value as Record;
+ // MongoDB numeric wrapper: { $numberDecimal: "250000" }
+ if ('$numberDecimal' in obj) return Number(obj.$numberDecimal);
+ // MongoDB ObjectId wrapper: { $oid: "abc123" }
+ if ('$oid' in obj) return String(obj.$oid);
+ // MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
+ if ('$date' in obj) return String(obj.$date);
+ // Expanded reference / general object: extract name/label/_id
+ return String(obj.name || obj.label || obj._id || '[Object]');
+ }
+ return String(value);
+}
+
/**
* Format currency value
*/
@@ -192,7 +225,8 @@ export function formatDateTime(value: string | Date): string {
* Text field cell renderer
*/
export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
- return {(value != null && value !== '') ? String(value) : '-'};
+ const safe = coerceToSafeValue(value);
+ return {(safe != null && safe !== '') ? String(safe) : '-'};
}
/**
@@ -201,11 +235,13 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
if (value == null) return -;
+ const safe = coerceToSafeValue(value);
const numField = field as any;
const precision = numField.precision ?? 0;
- const formatted = typeof value === 'number'
- ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(value)
- : value;
+ const num = Number(safe);
+ const formatted = !isNaN(num)
+ ? new Intl.NumberFormat('en-US', { minimumFractionDigits: precision, maximumFractionDigits: precision }).format(num)
+ : String(safe);
return {formatted};
}
@@ -216,9 +252,11 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
if (value == null) return -;
+ const safe = coerceToSafeValue(value);
const currencyField = field as any;
const currency = currencyField.currency || 'USD';
- const formatted = formatCurrency(Number(value), currency);
+ const num = Number(safe);
+ const formatted = !isNaN(num) ? formatCurrency(num, currency) : String(safe);
return {formatted};
}
@@ -232,9 +270,13 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
if (value == null) return -;
+ const safe = coerceToSafeValue(value);
const percentField = field as any;
const precision = percentField.precision ?? 0;
- const numValue = Number(value);
+ const numValue = Number(safe);
+ if (isNaN(numValue)) {
+ return {String(safe)};
+ }
// Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
// Fields like "progress" or "completion" store values as 0-100, not 0-1
const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
@@ -319,17 +361,18 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
*/
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
if (!value) return -;
+ const safe = coerceToSafeValue(value);
const dateField = field as any;
const style = dateField.format || 'relative';
- const formatted = formatDate(value, style);
+ const formatted = formatDate(safe as string | Date, style);
// Determine if date is overdue (in the past)
- const date = typeof value === 'string' ? new Date(value) : value;
+ const date = typeof safe === 'string' ? new Date(safe) : safe;
const isValidDate = date instanceof Date && !isNaN(date.getTime());
const startOfToday = new Date();
startOfToday.setHours(0, 0, 0, 0);
const isOverdue = isValidDate && date < startOfToday;
- const isoString = isValidDate ? date.toISOString() : String(value);
+ const isoString = isValidDate ? date.toISOString() : String(safe);
return (
-;
- const date = typeof value === 'string' ? new Date(value) : value;
- if (isNaN(date.getTime())) return -;
+ const safe = coerceToSafeValue(value);
+ const date = typeof safe === 'string' ? new Date(safe) : safe;
+ if (!(date instanceof Date) || isNaN(date.getTime())) return -;
const datePart = date.toLocaleDateString(undefined, {
month: 'numeric',
@@ -478,12 +522,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
if (!value) return -;
+ const safe = String(coerceToSafeValue(value) ?? '');
const [copied, setCopied] = React.useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
- navigator.clipboard.writeText(String(value)).then(() => {
+ navigator.clipboard.writeText(safe).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}).catch(() => { /* clipboard not available */ });
@@ -497,10 +542,10 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
asChild
>
e.stopPropagation()}
>
- {value}
+ {safe}
@@ -194,6 +198,7 @@ export const RecordChatterPanel: React.FC = ({
onToggleSubscription={onToggleSubscription}
filterMode={filterMode}
onFilterChange={onFilterChange}
+ collapseWhenEmpty={collapseWhenEmpty}
/>
)}
diff --git a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx
index 239d91cc3..2ed40bf1d 100644
--- a/packages/plugin-detail/src/__tests__/DetailSection.test.tsx
+++ b/packages/plugin-detail/src/__tests__/DetailSection.test.tsx
@@ -391,6 +391,67 @@ describe('DetailSection', () => {
});
});
});
+
+ it('should initially render a batch when virtualScroll is enabled with many fields', () => {
+ const section = {
+ title: 'Virtual',
+ fields: Array.from({ length: 50 }, (_, i) => ({
+ name: `field_${i}`,
+ label: `Field ${i}`,
+ type: 'text',
+ })),
+ };
+ const { container } = render(
+
+ );
+ const grid = container.querySelector('.grid');
+ expect(grid).toBeTruthy();
+ // Initially should render only the batch (10 fields), not all 50
+ const fieldElements = grid!.children;
+ expect(fieldElements.length).toBeLessThanOrEqual(10);
+ });
+
+ it('should render all fields when virtualScroll is disabled', () => {
+ const section = {
+ title: 'No Virtual',
+ fields: Array.from({ length: 50 }, (_, i) => ({
+ name: `field_${i}`,
+ label: `Field ${i}`,
+ type: 'text',
+ })),
+ };
+ const { container } = render(
+
+ );
+ const grid = container.querySelector('.grid');
+ expect(grid).toBeTruthy();
+ expect(grid!.children.length).toBe(50);
+ });
+
+ it('should render all fields when virtualScroll is enabled but field count is below batch size', () => {
+ const section = {
+ title: 'Small',
+ fields: Array.from({ length: 5 }, (_, i) => ({
+ name: `field_${i}`,
+ label: `Field ${i}`,
+ type: 'text',
+ })),
+ };
+ const { container } = render(
+
+ );
+ const grid = container.querySelector('.grid');
+ expect(grid).toBeTruthy();
+ expect(grid!.children.length).toBe(5);
+ });
});
describe('getResponsiveSpanClass', () => {
diff --git a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx
index 8ff2718b3..2ed206d7a 100644
--- a/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx
+++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.test.tsx
@@ -65,4 +65,149 @@ describe('HeaderHighlight', () => {
render();
expect(screen.getByText('💰')).toBeInTheDocument();
});
+
+ it('should render currency fields with formatted value via CellRenderer', () => {
+ const currencyFields: HighlightField[] = [
+ { name: 'amount', label: 'Amount', type: 'currency' },
+ ];
+ render();
+ // CurrencyCellRenderer should format — should NOT show raw "250000"
+ expect(screen.queryByText('250000')).not.toBeInTheDocument();
+ expect(screen.getByText(/250,000/)).toBeInTheDocument();
+ });
+
+ it('should render select fields as badge via CellRenderer', () => {
+ const selectFields: HighlightField[] = [
+ { name: 'stage', label: 'Stage', type: 'select' },
+ ];
+ render(
+
+ );
+ expect(screen.getByText('Prospecting')).toBeInTheDocument();
+ });
+
+ it('should enrich field type from objectSchema when field.type is not set', () => {
+ const fieldsNoType: HighlightField[] = [
+ { name: 'amount', label: 'Amount' },
+ ];
+ render(
+
+ );
+ // CurrencyCellRenderer should format, not raw String()
+ expect(screen.queryByText('5000')).not.toBeInTheDocument();
+ expect(screen.getByText(/5,000/)).toBeInTheDocument();
+ });
+
+ it('should fall back to text when no type info is available', () => {
+ const fieldsNoType: HighlightField[] = [
+ { name: 'custom', label: 'Custom' },
+ ];
+ render();
+ expect(screen.getByText('raw-value')).toBeInTheDocument();
+ });
+
+ it('should safely render object values without crashing (React error #310 guard)', () => {
+ // Simulates MongoDB Decimal128 or expanded reference objects
+ const fieldsWithNumber: HighlightField[] = [
+ { name: 'amount', label: 'Amount' },
+ { name: 'account', label: 'Account' },
+ ];
+ const objectData = {
+ amount: { $numberDecimal: '250000' },
+ account: { _id: 'abc', name: 'Acme Corp' },
+ };
+ const objectSchema = {
+ fields: {
+ amount: { type: 'number' },
+ account: { type: 'text' },
+ },
+ };
+ // Should NOT crash — cell renderers coerce values via coerceToSafeValue
+ const { container } = render(
+
+ );
+ expect(container.innerHTML).not.toBe('');
+ // NumberCellRenderer coerces $numberDecimal to number
+ expect(screen.getByText('250,000')).toBeInTheDocument();
+ // TextCellRenderer extracts name from object
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
+ });
+
+ it('should safely render lookup object values via LookupCellRenderer', () => {
+ const lookupFields: HighlightField[] = [
+ { name: 'owner', label: 'Owner' },
+ ];
+ const lookupData = {
+ owner: { _id: 'u1', name: 'Jane Doe' },
+ };
+ const objectSchema = {
+ fields: {
+ owner: { type: 'lookup' },
+ },
+ };
+ // LookupCellRenderer handles object values natively
+ render(
+
+ );
+ expect(screen.getByText('Jane Doe')).toBeInTheDocument();
+ });
+
+ it('should safely render array values via TextCellRenderer', () => {
+ const arrayFields: HighlightField[] = [
+ { name: 'tags', label: 'Tags' },
+ ];
+ const arrayData = {
+ tags: ['urgent', 'follow-up'],
+ };
+ const { container } = render(
+
+ );
+ expect(container.innerHTML).not.toBe('');
+ // TextCellRenderer coerces arrays to comma-separated string
+ expect(screen.getByText('urgent, follow-up')).toBeInTheDocument();
+ });
+
+ it('should safely render array of objects via TextCellRenderer', () => {
+ const arrayFields: HighlightField[] = [
+ { name: 'contacts', label: 'Contacts' },
+ ];
+ const arrayData = {
+ contacts: [{ name: 'Alice' }, { name: 'Bob' }],
+ };
+ const { container } = render(
+
+ );
+ expect(container.innerHTML).not.toBe('');
+ // TextCellRenderer coerces array of objects to "Alice, Bob"
+ expect(screen.getByText('Alice, Bob')).toBeInTheDocument();
+ });
});
diff --git a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx
index ed06f54d7..ecf2d8e8e 100644
--- a/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx
+++ b/packages/plugin-detail/src/__tests__/RecordChatterPanel.test.tsx
@@ -224,4 +224,42 @@ describe('RecordChatterPanel', () => {
expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
});
});
+
+ describe('collapseWhenEmpty', () => {
+ it('should auto-collapse when empty and collapseWhenEmpty is true (inline mode)', () => {
+ render(
+ ,
+ );
+ // Should be collapsed because items is empty
+ expect(screen.getByLabelText('Show discussion')).toBeInTheDocument();
+ });
+
+ it('should not auto-collapse when items exist and collapseWhenEmpty is true', () => {
+ render(
+ ,
+ );
+ // Should be expanded because there are items
+ expect(screen.getByText('Activity')).toBeInTheDocument();
+ });
+
+ it('should auto-collapse sidebar when empty and collapseWhenEmpty is true', () => {
+ render(
+ ,
+ );
+ // Should be collapsed
+ expect(screen.getByLabelText('Open discussion panel')).toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/plugin-detail/src/__tests__/autoLayout.test.ts b/packages/plugin-detail/src/__tests__/autoLayout.test.ts
index 57effc89a..1537ce44a 100644
--- a/packages/plugin-detail/src/__tests__/autoLayout.test.ts
+++ b/packages/plugin-detail/src/__tests__/autoLayout.test.ts
@@ -37,6 +37,33 @@ describe('Detail Auto-Layout', () => {
expect(inferDetailColumns(15)).toBe(3);
expect(inferDetailColumns(50)).toBe(3);
});
+
+ it('should cap to 1 column when containerWidth < 640', () => {
+ expect(inferDetailColumns(15, 500)).toBe(1);
+ expect(inferDetailColumns(5, 639)).toBe(1);
+ });
+
+ it('should cap to 2 columns when containerWidth < 900', () => {
+ expect(inferDetailColumns(15, 800)).toBe(2);
+ expect(inferDetailColumns(15, 640)).toBe(2);
+ expect(inferDetailColumns(5, 899)).toBe(2);
+ });
+
+ it('should not cap columns when containerWidth >= 900', () => {
+ expect(inferDetailColumns(15, 900)).toBe(3);
+ expect(inferDetailColumns(15, 1200)).toBe(3);
+ });
+
+ it('should not cap below field-count inference', () => {
+ // 2 fields → 1 column, containerWidth 800 would cap at 2, but inference says 1
+ expect(inferDetailColumns(2, 800)).toBe(1);
+ });
+
+ it('should still work without containerWidth (backward compatible)', () => {
+ expect(inferDetailColumns(15)).toBe(3);
+ expect(inferDetailColumns(5)).toBe(2);
+ expect(inferDetailColumns(2)).toBe(1);
+ });
});
describe('isWideFieldType', () => {
@@ -180,5 +207,22 @@ describe('Detail Auto-Layout', () => {
expect(result.columns).toBe(2);
expect(result.fields.length).toBe(8);
});
+
+ it('should cap columns based on containerWidth when provided', () => {
+ const fields = Array.from({ length: 15 }, (_, i) => ({
+ name: `f${i}`, label: `F${i}`, type: 'text',
+ }));
+ // Narrow container: should cap to 1
+ const result = applyDetailAutoLayout(fields, undefined, 500);
+ expect(result.columns).toBe(1);
+ });
+
+ it('should still respect explicit schemaColumns regardless of containerWidth', () => {
+ const fields = Array.from({ length: 15 }, (_, i) => ({
+ name: `f${i}`, label: `F${i}`, type: 'text',
+ }));
+ const result = applyDetailAutoLayout(fields, 3, 500);
+ expect(result.columns).toBe(3);
+ });
});
});
diff --git a/packages/plugin-detail/src/autoLayout.ts b/packages/plugin-detail/src/autoLayout.ts
index 8d8fa1bee..33033bf86 100644
--- a/packages/plugin-detail/src/autoLayout.ts
+++ b/packages/plugin-detail/src/autoLayout.ts
@@ -46,16 +46,31 @@ export function isWideFieldType(type: string): boolean {
/**
* Infer optimal number of columns for a detail section based on field count.
+ * When containerWidth is provided, limits columns for narrower viewports.
*
- * Rules:
+ * Rules (field-count based):
* - 0-3 fields → 1 column
* - 4-10 fields → 2 columns
* - 11+ fields → 3 columns
+ *
+ * Responsive capping (when containerWidth is supplied):
+ * - containerWidth < 640px → max 1 column
+ * - containerWidth < 900px → max 2 columns
+ * - containerWidth >= 900px → no cap
*/
-export function inferDetailColumns(fieldCount: number): number {
- if (fieldCount <= 3) return 1;
- if (fieldCount <= 10) return 2;
- return 3;
+export function inferDetailColumns(fieldCount: number, containerWidth?: number): number {
+ let cols: number;
+ if (fieldCount <= 3) cols = 1;
+ else if (fieldCount <= 10) cols = 2;
+ else cols = 3;
+
+ // Apply responsive capping when container width is known
+ if (containerWidth !== undefined) {
+ if (containerWidth < 640) return Math.min(cols, 1);
+ if (containerWidth < 900) return Math.min(cols, 2);
+ }
+
+ return cols;
}
/**
@@ -89,11 +104,13 @@ export function applyAutoSpan(
*
* @param fields - The section fields
* @param schemaColumns - User-provided columns (from DetailViewSection or DetailViewSchema)
+ * @param containerWidth - Optional container width in px for responsive column capping
* @returns Object with processed fields and inferred columns
*/
export function applyDetailAutoLayout(
fields: DetailViewField[],
- schemaColumns: number | undefined
+ schemaColumns: number | undefined,
+ containerWidth?: number
): { fields: DetailViewField[]; columns: number } {
// If user explicitly set columns, respect it but still apply auto span
if (schemaColumns !== undefined) {
@@ -101,8 +118,8 @@ export function applyDetailAutoLayout(
return { fields: processed, columns: schemaColumns };
}
- // Infer columns from field count
- const columns = inferDetailColumns(fields.length);
+ // Infer columns from field count (with optional container-width capping)
+ const columns = inferDetailColumns(fields.length, containerWidth);
// Apply auto span for wide fields
const processed = applyAutoSpan(fields, columns);
diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx
index 9b710cfa9..5495c3451 100644
--- a/packages/plugin-detail/src/index.tsx
+++ b/packages/plugin-detail/src/index.tsx
@@ -36,7 +36,7 @@ export { SubscriptionToggle } from './SubscriptionToggle';
export { ReactionPicker } from './ReactionPicker';
export { ThreadedReplies } from './ThreadedReplies';
export type { DetailViewProps } from './DetailView';
-export type { DetailSectionProps } from './DetailSection';
+export type { DetailSectionProps, VirtualScrollOptions } from './DetailSection';
export type { DetailTabsProps } from './DetailTabs';
export type { RelatedListProps } from './RelatedList';
export type { SectionGroupProps } from './SectionGroup';