Skip to content

Commit 8bf6295

Browse files
os-zhuangclaude
andauthored
feat: adaptive record surface + semantic span + responsive columns; fix #2515 (framework#2578) (#2237)
* feat(plugin-form,plugin-view): consume adaptive record surface + semantic span; fix #2515 (#2578) Step 2 (objectui renderer) of #2578: - autoLayout: resolveColSpan — span-aware (`span:'full'` → whole row), clamps a legacy colSpan to the current grid (never overflows), and lays each section out at its own column density. applyAutoColSpan gains a `sectionColumns` arg. - ObjectForm (simple/grouped path): honour per-section `columns` within the shared form grid, and carry `span`/`colSpan` from section field defs — fixes #2515 (section.columns was ignored; grouped fields rendered single-column). - FormField.span ('auto'|'full') added to @object-ui/types; carried through sectionFields. - ObjectView: default the record surface from field count via a local deriveRecordSurface mirror (field-heavy → full page; mobile → page); explicit schema.layout / navigation config still wins. Consolidate to the @objectstack/spec export when objectui adopts spec >= 11.10. autoLayout 43 tests (16 new); plugin-form 173 tests green; changed files type-clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(app-shell): auto full-page record surface for field-heavy objects in the console (#2578) The console renders app-shell's ObjectView, which owns the record-detail surface (defaulted to 'drawer'). Wire deriveRecordSurface so a FIELD-HEAVY object's record peek defaults to a full page — automating the "a heavy detail object can set navigation.mode = 'page'" note the code already carried — while light objects keep the drawer peek. An authored view/object `navigation` still wins. Also export deriveRecordSurface from @object-ui/plugin-view. Browser-verified against a live showcase server: field_zoo (57 fields) row-click navigates to a full /record page; product (6 fields) opens a drawer overlay. turbo type-check green (29/29). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(plugin-form,plugin-view,app-shell): responsive column cap + runtime overlay width (#2578) - inferColumns: scale the column CAP with field count (<=3->1, <=8->2, <=15->3, 16+->4). The cap is an upper bound; CSS container queries pick the ACTUAL column count from the form's real width, so the same form goes 1->2->3->4 columns as a drawer widens or becomes a page. - recordSurface: deriveOverlaySize + overlayWidthFor — map a size bucket (or 'auto' from field count) to a viewport-clamped width min(cap, 92vw). Consumes spec NavigationConfig.size (no inert key). - app-shell ObjectView: detailNavigation resolves size->width at runtime; the auto drawer is sized to the content. Authored navigation still wins. - types: ViewNavigationConfig.size. autoLayout 43 tests; turbo type-check 29/29. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(changeset): adaptive record surface + span + responsive columns (framework#2578) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 19f2533 commit 8bf6295

11 files changed

Lines changed: 371 additions & 50 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@object-ui/plugin-form": minor
3+
"@object-ui/plugin-view": minor
4+
"@object-ui/app-shell": minor
5+
"@object-ui/types": minor
6+
---
7+
8+
feat: adaptive record surface + semantic field span + responsive columns (framework#2578)
9+
10+
Field-heavy objects (all metadata is AI-authored) now present themselves without
11+
any authored presentation config:
12+
13+
- **Adaptive surface** — a record's create/edit/detail opens as a full page when
14+
the object is field-heavy, or a drawer when it is light. Derived from field
15+
count (`deriveRecordSurface`), not authored; mobile always pages. Wired into the
16+
app-shell ObjectView detail navigation (an authored view/object `navigation`
17+
still wins).
18+
- **Semantic field span**`FormField.span` (`auto`/`full`) is a width primitive
19+
decoupled from the (per-surface derived) column count; legacy `colSpan` is
20+
clamped so it never overflows. `ObjectForm` now honours per-section `columns`
21+
and carries `span`/`colSpan` from section defs — fixes the bug where
22+
`type:'simple'` ignored `section.columns` and grouped fields rendered single
23+
column.
24+
- **Responsive columns**`inferColumns` scales the column CAP with field count
25+
(≤3→1, ≤8→2, ≤15→3, 16+→4); the ACTUAL column count follows the form's real
26+
width via CSS container queries, so the same form goes 1→2→3→4 columns as a
27+
drawer widens or becomes a page.
28+
- **Runtime overlay width**`NavigationConfig.size` bucket is resolved to a
29+
viewport-clamped width at runtime (`overlayWidthFor`); a pixel width is never
30+
authored (the author cannot know the client viewport).

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const ImportWizard = lazy(() =>
2020
);
2121
import { ListView } from '@object-ui/plugin-list';
2222
import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail';
23-
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view';
23+
import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view';
2424
import type { ViewTabItem } from '@object-ui/plugin-view';
2525
// Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx
2626
// uses ComponentRegistry.registerLazy so heavy plugins stay code-split).
@@ -955,10 +955,29 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
955955
// is drawer-by-default. Per-view config can still override (e.g. a heavy
956956
// detail object can set `navigation.mode = 'page'`).
957957
const detailNavigation: ViewNavigationConfig = useMemo(
958-
() =>
959-
activeView?.navigation ??
960-
objectDef.navigation ?? { mode: 'drawer', width: 'min(92vw, 1280px)' },
961-
[activeView?.navigation, objectDef.navigation]
958+
() => {
959+
const authored = activeView?.navigation ?? objectDef.navigation;
960+
if (authored) {
961+
// Authored config wins. For an overlay, resolve the `size`
962+
// bucket (or 'auto') to a viewport-clamped width when no explicit
963+
// width was given — #2578: a pixel width can't be authored blind.
964+
if (authored.mode === 'page') return authored;
965+
return {
966+
...authored,
967+
width: authored.width ?? overlayWidthFor(
968+
(authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size,
969+
objectDef,
970+
),
971+
};
972+
}
973+
// #2578: derive surface + width from FIELD COUNT. Field-heavy → full
974+
// page (cramped in a drawer); light → a drawer sized to the content
975+
// and clamped to the viewport. Mobile always pages (in deriveRecordSurface).
976+
return deriveRecordSurface(objectDef) === 'page'
977+
? { mode: 'page' }
978+
: { mode: 'drawer', width: overlayWidthFor('auto', objectDef) };
979+
},
980+
[activeView?.navigation, objectDef]
962981
);
963982
const drawerRecordId = searchParams.get('recordId');
964983

packages/plugin-form/src/ObjectForm.tsx

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,23 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
749749
// Derived (fieldGroup) sections were computed from the filtered field list;
750750
// explicit sections keep the authored field selection as-is.
751751
const sourceFields = fieldGroupSections ? groupableFields : formFields;
752+
// #2578: honour per-section `columns`. The form renders as ONE grid (one
753+
// react-hook-form instance); each section lays its OWN fields out at its
754+
// declared density within that grid. Grid width = explicit form `columns`,
755+
// else the widest section, else inferred from field count (the
756+
// fieldGroup-derived path declares no per-section columns and keeps its
757+
// historical inferred multi-column layout).
758+
const clampCol = (n: unknown): number | undefined =>
759+
typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined;
760+
const declaredSectionCols = effectiveSections
761+
.map(s => clampCol((s as any).columns))
762+
.filter((c): c is number => c != null);
763+
const approxInputs = effectiveSections.reduce(
764+
(n, s) => n + (Array.isArray(s.fields) ? s.fields.length : 0), 0,
765+
);
766+
const formColumns =
767+
clampCol(schema.columns) ??
768+
(declaredSectionCols.length ? Math.max(...declaredSectionCols) : inferColumns(approxInputs));
752769
const groupedFields: FormField[] = [];
753770
effectiveSections.forEach((section, index) => {
754771
// Section field defs may carry a per-field `visibleOn` predicate (spec
@@ -762,8 +779,17 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
762779
const sectionFields = applyFieldPerms(sourceFields.filter(f => sectionFieldNames.includes(f.name)))
763780
.map(f => {
764781
const def = sectionDefByName.get(f.name);
765-
const visibleOn = def && typeof def === 'object' ? (def as any).visibleOn : undefined;
766-
return visibleOn != null ? ({ ...f, visibleOn } as FormField) : f;
782+
if (!def || typeof def !== 'object') return f;
783+
// Carry the section field def's layout/visibility overrides onto the
784+
// resolved field — the name-only filter above would otherwise drop
785+
// them. #2578: `span`/`colSpan` are how a section controls per-field
786+
// width; #2212: `visibleOn`.
787+
const d = def as any;
788+
const merged: any = { ...f };
789+
if (d.visibleOn != null) merged.visibleOn = d.visibleOn;
790+
if (d.colSpan != null) merged.colSpan = d.colSpan;
791+
if (d.span != null) merged.span = d.span;
792+
return merged as FormField;
767793
});
768794
if (sectionFields.length === 0) return;
769795

@@ -788,30 +814,27 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
788814
} as FormField);
789815
}
790816

817+
// #2578: lay THIS section's fields out at its declared column density
818+
// within the shared form grid (span-aware; wide fields still full-row).
819+
const secCols = clampCol((section as any).columns);
820+
const laid = formColumns > 1 ? applyAutoColSpan(sectionFields, formColumns, secCols) : sectionFields;
821+
791822
// Collapsed groups keep their fields registered (values preserved) but
792823
// hidden from the DOM. An untitled bucket is never collapsible.
793824
if (label && isCollapsed) {
794-
groupedFields.push(...sectionFields.map(f => ({ ...f, hidden: true })));
825+
groupedFields.push(...laid.map(f => ({ ...f, hidden: true })));
795826
} else {
796-
groupedFields.push(...sectionFields);
827+
groupedFields.push(...laid);
797828
}
798829
});
799830

800-
// Multi-column layout parity with the flat path: infer a column count from
801-
// the rendered field count and let wide fields (textarea/markdown/…) span
802-
// the full row. The field grid uses container-query classes — with the
803-
// @container wrapper below — so it tracks the form's own width: a grouped
804-
// form in a wide dialog goes 2-up while a narrow drawer stays stacked.
805-
// Section dividers span the full row via their own `col-span-full`.
806-
const inputCount = groupedFields.filter(
807-
f => (f as any).type !== 'section-divider' && !(f as any).hidden,
808-
).length;
809-
const columns =
810-
schema.columns && schema.columns > 0
811-
? Math.min(Math.floor(schema.columns), 4)
812-
: inferColumns(inputCount);
813-
const laidOutFields = columns > 1 ? applyAutoColSpan(groupedFields, columns) : groupedFields;
814-
const fieldContainerClass = containerGridColsFor(columns);
831+
// Per-section colSpan was applied in the loop above — each section at its
832+
// own density within the shared `formColumns` grid. The field grid uses
833+
// container-query classes (with the @container wrapper below), so it tracks
834+
// the form's own width: a grouped form in a wide dialog goes multi-column
835+
// while a narrow drawer stays stacked. Section dividers span the full row.
836+
const laidOutFields = groupedFields;
837+
const fieldContainerClass = containerGridColsFor(formColumns);
815838

816839
return (
817840
<div className="w-full @container">
@@ -821,7 +844,7 @@ const SimpleObjectForm: React.FC<ObjectFormProps> = ({
821844
objectName: schema.objectName,
822845
fields: laidOutFields,
823846
layout: formLayout,
824-
columns,
847+
columns: formColumns,
825848
...(fieldContainerClass ? { fieldContainerClass } : {}),
826849
defaultValues: finalDefaultValues,
827850
showSubmit: schema.showSubmit !== false && schema.mode !== 'view',

packages/plugin-form/src/__tests__/autoLayout.test.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
inferColumns,
66
inferModalSize,
77
applyAutoColSpan,
8+
resolveColSpan,
89
filterCreateModeFields,
910
filterSystemFields,
1011
applyAutoLayout,
@@ -64,10 +65,13 @@ describe('autoLayout', () => {
6465
expect(inferColumns(3)).toBe(1);
6566
});
6667

67-
it('returns 2 columns for 4+ fields', () => {
68+
it('scales the column CAP with field count (#2578)', () => {
6869
expect(inferColumns(4)).toBe(2);
6970
expect(inferColumns(8)).toBe(2);
70-
expect(inferColumns(20)).toBe(2);
71+
expect(inferColumns(9)).toBe(3);
72+
expect(inferColumns(15)).toBe(3);
73+
expect(inferColumns(16)).toBe(4);
74+
expect(inferColumns(60)).toBe(4);
7175
});
7276
});
7377

@@ -446,4 +450,66 @@ describe('autoLayout', () => {
446450
expect(result.columns).toBe(1);
447451
});
448452
});
453+
454+
describe('resolveColSpan (#2578 — span-aware, clamping)', () => {
455+
it("span: 'full' spans the whole row at any grid width", () => {
456+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', span: 'full' } as any, 2)).toBe(2);
457+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', span: 'full' } as any, 4)).toBe(4);
458+
});
459+
460+
it('clamps a legacy colSpan to the current grid (never overflows)', () => {
461+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', colSpan: 4 } as any, 2)).toBe(2);
462+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', colSpan: 3 } as any, 4)).toBe(3);
463+
});
464+
465+
it("span: 'auto' (default) gives wide widgets the full row, scalars one cell", () => {
466+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:textarea' } as any, 2)).toBe(2);
467+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 2)).toBe(1);
468+
});
469+
470+
it('honours a section density narrower than the grid', () => {
471+
// grid 2, section wants 1 column → each field takes the whole row
472+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 2, 1)).toBe(2);
473+
// grid 4, section wants 2 columns → each field takes half
474+
expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 4, 2)).toBe(2);
475+
});
476+
});
477+
478+
describe('applyAutoColSpan with per-section density (#2578)', () => {
479+
it('lays a 1-column section out full-width within a 2-column grid', () => {
480+
const fields: FormField[] = [
481+
{ name: 'a', label: 'A', type: 'field:text' },
482+
{ name: 'b', label: 'B', type: 'field:text' },
483+
];
484+
expect(applyAutoColSpan(fields, 2, 1).map(f => f.colSpan)).toEqual([2, 2]);
485+
});
486+
487+
it('leaves a 2-column section at one cell per field in a 2-column grid', () => {
488+
const fields: FormField[] = [
489+
{ name: 'a', label: 'A', type: 'field:text' },
490+
{ name: 'b', label: 'B', type: 'field:text' },
491+
];
492+
expect(applyAutoColSpan(fields, 2, 2).map(f => f.colSpan)).toEqual([undefined, undefined]);
493+
});
494+
495+
it("respects span: 'full' on a scalar field", () => {
496+
const fields: FormField[] = [
497+
{ name: 'summary', label: 'Summary', type: 'field:text', span: 'full' } as any,
498+
{ name: 'code', label: 'Code', type: 'field:text' },
499+
];
500+
const result = applyAutoColSpan(fields, 2, 2);
501+
expect(result[0].colSpan).toBe(2);
502+
expect(result[1].colSpan).toBeUndefined();
503+
});
504+
505+
it('leaves section-divider rows untouched but fills their fields', () => {
506+
const fields: FormField[] = [
507+
{ name: '__section_x', label: 'X', type: 'section-divider', colSpan: 4 } as any,
508+
{ name: 'a', label: 'A', type: 'field:text' },
509+
];
510+
const result = applyAutoColSpan(fields, 2, 1);
511+
expect(result[0].colSpan).toBe(4); // divider unchanged
512+
expect(result[1].colSpan).toBe(2); // field filled to full row
513+
});
514+
});
449515
});

packages/plugin-form/src/autoLayout.ts

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -78,36 +78,98 @@ export function isAutoGeneratedFieldType(type: string): boolean {
7878
}
7979

8080
/**
81-
* Infer optimal number of columns based on the number of visible fields
82-
* and whether any wide fields are present.
81+
* Infer the MAX column count from the field count (#2578). This is an UPPER
82+
* BOUND — the actual column count at render time is decided by the CSS
83+
* container queries in {@link CONTAINER_GRID_COLS} against the form's REAL
84+
* width, so a narrow drawer stays 1–2 columns while the SAME form on a wide
85+
* page spreads out to this cap. The cap scales with field count so a light
86+
* form is never thinned across too many columns, while a field-heavy record
87+
* can use the full width.
8388
*
84-
* Rules:
85-
* - 0-3 fields → 1 column
86-
* - 4+ fields → 2 columns
89+
* Rules (upper bound; container queries clamp to the actual width):
90+
* - 0–3 fields → 1 column
91+
* - 4–8 fields → 2 columns
92+
* - 9–15 fields → 3 columns
93+
* - 16+ fields → 4 columns
8794
*/
8895
export function inferColumns(fieldCount: number): number {
8996
if (fieldCount <= 3) return 1;
90-
return 2;
97+
if (fieldCount <= 8) return 2;
98+
if (fieldCount <= 15) return 3;
99+
return 4;
100+
}
101+
102+
/** Clamp a raw column-ish number into 1..4 (the grid keys we support). */
103+
function clampGrid(n: number | undefined): number {
104+
return n && n > 0 ? Math.min(Math.floor(n), 4) : 1;
91105
}
92106

93107
/**
94-
* Apply colSpan to wide fields so they span the full row.
95-
* Only sets colSpan if the field does not already have one explicitly set.
108+
* Resolve the effective colSpan (grid cells) a field occupies, given the form's
109+
* grid width and the section's desired column count (#2578).
96110
*
97-
* @returns A new array of fields with colSpan applied where needed.
111+
* The column count is derived per surface (mobile 1 / modal 2 / page 3-4), so
112+
* the robust field-width primitive is the relative `span`, NOT an absolute
113+
* colSpan. Precedence:
114+
* 1. `span: 'full'` → whole row (grid cells).
115+
* 2. legacy `colSpan` → honoured but CLAMPED to the grid (never overflows).
116+
* 3. wide widget type → whole row (textarea/markdown/… — the `span:'auto'` default).
117+
* 4. section density → grid / sectionColumns, so `sectionColumns` fields fill a row.
118+
*
119+
* `sectionColumns` defaults to the grid width, i.e. one field per cell (the flat
120+
* form's historical behaviour) when there is no narrower section.
98121
*/
99-
export function applyAutoColSpan(fields: FormField[], columns: number): FormField[] {
100-
if (columns <= 1) return fields;
122+
export function resolveColSpan(
123+
field: FormField,
124+
gridColumns: number,
125+
sectionColumns?: number,
126+
): number {
127+
const grid = clampGrid(gridColumns);
128+
if (grid <= 1) return 1;
129+
130+
const span = (field as { span?: 'auto' | 'full' }).span;
131+
if (span === 'full') return grid;
132+
133+
// Legacy absolute colSpan — honoured but clamped so it can never overflow the
134+
// current grid (the fragility `span` exists to replace).
135+
if (field.colSpan != null) {
136+
return Math.min(Math.max(1, Math.floor(field.colSpan)), grid);
137+
}
101138

102-
return fields.map(field => {
103-
// User-defined colSpan takes priority
104-
if (field.colSpan !== undefined) return field;
139+
// `span: 'auto'` (or unset): wide widgets take the whole row.
140+
if (field.type && isWideFieldType(field.type)) return grid;
105141

106-
// Wide field types should span full row
107-
if (field.type && isWideFieldType(field.type)) {
108-
return { ...field, colSpan: columns };
109-
}
142+
// Otherwise fill the section's density: N per row → grid/N cells each.
143+
const secCols = Math.min(Math.max(1, Math.floor(sectionColumns || grid)), grid);
144+
return Math.min(grid, Math.max(1, Math.round(grid / secCols)));
145+
}
110146

147+
/**
148+
* Apply the resolved colSpan to each field so the form's CSS grid lays them out
149+
* at the intended density. Span-aware and clamping (#2578); wide fields still
150+
* span the full row (the `span: 'auto'` default). `section-divider` rows are
151+
* left untouched (they carry their own full-row span).
152+
*
153+
* @param gridColumns The form's grid column count (1-4).
154+
* @param sectionColumns The desired columns for THIS group of fields (defaults
155+
* to the grid width = one field per cell).
156+
* @returns A new array of fields with colSpan applied where needed.
157+
*/
158+
export function applyAutoColSpan(
159+
fields: FormField[],
160+
gridColumns: number,
161+
sectionColumns?: number,
162+
): FormField[] {
163+
const grid = clampGrid(gridColumns);
164+
if (grid <= 1) return fields;
165+
166+
return fields.map(field => {
167+
if ((field as { type?: string }).type === 'section-divider') return field;
168+
const eff = resolveColSpan(field, grid, sectionColumns);
169+
if (eff > 1) return { ...field, colSpan: eff };
170+
// eff === 1: strip any stale larger colSpan so it doesn't overflow a
171+
// narrower grid than the author imagined.
172+
if (field.colSpan != null && field.colSpan !== 1) return { ...field, colSpan: 1 };
111173
return field;
112174
});
113175
}

packages/plugin-form/src/sectionFields.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export function normalizeSectionField(
128128
if (fd.immutable != null) base.immutable = fd.immutable;
129129
if (fd.hidden != null) base.hidden = fd.hidden;
130130
if (fd.colSpan != null) base.colSpan = fd.colSpan;
131+
if (fd.span != null) base.span = fd.span;
131132
if (fd.options != null) base.options = fd.options;
132133
if (fd.multiple != null) base.multiple = fd.multiple;
133134
if (fd.reference != null) base.reference = fd.reference;

0 commit comments

Comments
 (0)