From 97726afa447c7148a94a10609e492c40ce55a3e2 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 22:45:27 +0800 Subject: [PATCH 1/4] feat(plugin-form,plugin-view): consume adaptive record surface + semantic span; fix #2515 (#2578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/plugin-form/src/ObjectForm.tsx | 63 ++++++++++----- .../src/__tests__/autoLayout.test.ts | 63 +++++++++++++++ packages/plugin-form/src/autoLayout.ts | 77 ++++++++++++++++--- packages/plugin-form/src/sectionFields.ts | 1 + packages/plugin-view/src/ObjectView.tsx | 10 ++- packages/plugin-view/src/recordSurface.ts | 64 +++++++++++++++ packages/types/src/form.ts | 13 +++- 7 files changed, 255 insertions(+), 36 deletions(-) create mode 100644 packages/plugin-view/src/recordSurface.ts diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx index 811ef23dd..47f0d671e 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -740,6 +740,23 @@ const SimpleObjectForm: React.FC = ({ // Derived (fieldGroup) sections were computed from the filtered field list; // explicit sections keep the authored field selection as-is. const sourceFields = fieldGroupSections ? groupableFields : formFields; + // #2578: honour per-section `columns`. The form renders as ONE grid (one + // react-hook-form instance); each section lays its OWN fields out at its + // declared density within that grid. Grid width = explicit form `columns`, + // else the widest section, else inferred from field count (the + // fieldGroup-derived path declares no per-section columns and keeps its + // historical inferred multi-column layout). + const clampCol = (n: unknown): number | undefined => + typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; + const declaredSectionCols = effectiveSections + .map(s => clampCol((s as any).columns)) + .filter((c): c is number => c != null); + const approxInputs = effectiveSections.reduce( + (n, s) => n + (Array.isArray(s.fields) ? s.fields.length : 0), 0, + ); + const formColumns = + clampCol(schema.columns) ?? + (declaredSectionCols.length ? Math.max(...declaredSectionCols) : inferColumns(approxInputs)); const groupedFields: FormField[] = []; effectiveSections.forEach((section, index) => { // Section field defs may carry a per-field `visibleOn` predicate (spec @@ -753,8 +770,17 @@ const SimpleObjectForm: React.FC = ({ const sectionFields = applyFieldPerms(sourceFields.filter(f => sectionFieldNames.includes(f.name))) .map(f => { const def = sectionDefByName.get(f.name); - const visibleOn = def && typeof def === 'object' ? (def as any).visibleOn : undefined; - return visibleOn != null ? ({ ...f, visibleOn } as FormField) : f; + if (!def || typeof def !== 'object') return f; + // Carry the section field def's layout/visibility overrides onto the + // resolved field — the name-only filter above would otherwise drop + // them. #2578: `span`/`colSpan` are how a section controls per-field + // width; #2212: `visibleOn`. + const d = def as any; + const merged: any = { ...f }; + if (d.visibleOn != null) merged.visibleOn = d.visibleOn; + if (d.colSpan != null) merged.colSpan = d.colSpan; + if (d.span != null) merged.span = d.span; + return merged as FormField; }); if (sectionFields.length === 0) return; @@ -779,30 +805,27 @@ const SimpleObjectForm: React.FC = ({ } as FormField); } + // #2578: lay THIS section's fields out at its declared column density + // within the shared form grid (span-aware; wide fields still full-row). + const secCols = clampCol((section as any).columns); + const laid = formColumns > 1 ? applyAutoColSpan(sectionFields, formColumns, secCols) : sectionFields; + // Collapsed groups keep their fields registered (values preserved) but // hidden from the DOM. An untitled bucket is never collapsible. if (label && isCollapsed) { - groupedFields.push(...sectionFields.map(f => ({ ...f, hidden: true }))); + groupedFields.push(...laid.map(f => ({ ...f, hidden: true }))); } else { - groupedFields.push(...sectionFields); + groupedFields.push(...laid); } }); - // Multi-column layout parity with the flat path: infer a column count from - // the rendered field count and let wide fields (textarea/markdown/…) span - // the full row. The field grid uses container-query classes — with the - // @container wrapper below — so it tracks the form's own width: a grouped - // form in a wide dialog goes 2-up while a narrow drawer stays stacked. - // Section dividers span the full row via their own `col-span-full`. - const inputCount = groupedFields.filter( - f => (f as any).type !== 'section-divider' && !(f as any).hidden, - ).length; - const columns = - schema.columns && schema.columns > 0 - ? Math.min(Math.floor(schema.columns), 4) - : inferColumns(inputCount); - const laidOutFields = columns > 1 ? applyAutoColSpan(groupedFields, columns) : groupedFields; - const fieldContainerClass = containerGridColsFor(columns); + // Per-section colSpan was applied in the loop above — each section at its + // own density within the shared `formColumns` grid. The field grid uses + // container-query classes (with the @container wrapper below), so it tracks + // the form's own width: a grouped form in a wide dialog goes multi-column + // while a narrow drawer stays stacked. Section dividers span the full row. + const laidOutFields = groupedFields; + const fieldContainerClass = containerGridColsFor(formColumns); return (
@@ -812,7 +835,7 @@ const SimpleObjectForm: React.FC = ({ objectName: schema.objectName, fields: laidOutFields, layout: formLayout, - columns, + columns: formColumns, ...(fieldContainerClass ? { fieldContainerClass } : {}), defaultValues: finalDefaultValues, showSubmit: schema.showSubmit !== false && schema.mode !== 'view', diff --git a/packages/plugin-form/src/__tests__/autoLayout.test.ts b/packages/plugin-form/src/__tests__/autoLayout.test.ts index 78a9bfede..1a7fa5bd3 100644 --- a/packages/plugin-form/src/__tests__/autoLayout.test.ts +++ b/packages/plugin-form/src/__tests__/autoLayout.test.ts @@ -5,6 +5,7 @@ import { inferColumns, inferModalSize, applyAutoColSpan, + resolveColSpan, filterCreateModeFields, filterSystemFields, applyAutoLayout, @@ -446,4 +447,66 @@ describe('autoLayout', () => { expect(result.columns).toBe(1); }); }); + + describe('resolveColSpan (#2578 — span-aware, clamping)', () => { + it("span: 'full' spans the whole row at any grid width", () => { + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', span: 'full' } as any, 2)).toBe(2); + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', span: 'full' } as any, 4)).toBe(4); + }); + + it('clamps a legacy colSpan to the current grid (never overflows)', () => { + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', colSpan: 4 } as any, 2)).toBe(2); + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text', colSpan: 3 } as any, 4)).toBe(3); + }); + + it("span: 'auto' (default) gives wide widgets the full row, scalars one cell", () => { + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:textarea' } as any, 2)).toBe(2); + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 2)).toBe(1); + }); + + it('honours a section density narrower than the grid', () => { + // grid 2, section wants 1 column → each field takes the whole row + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 2, 1)).toBe(2); + // grid 4, section wants 2 columns → each field takes half + expect(resolveColSpan({ name: 'x', label: 'X', type: 'field:text' } as any, 4, 2)).toBe(2); + }); + }); + + describe('applyAutoColSpan with per-section density (#2578)', () => { + it('lays a 1-column section out full-width within a 2-column grid', () => { + const fields: FormField[] = [ + { name: 'a', label: 'A', type: 'field:text' }, + { name: 'b', label: 'B', type: 'field:text' }, + ]; + expect(applyAutoColSpan(fields, 2, 1).map(f => f.colSpan)).toEqual([2, 2]); + }); + + it('leaves a 2-column section at one cell per field in a 2-column grid', () => { + const fields: FormField[] = [ + { name: 'a', label: 'A', type: 'field:text' }, + { name: 'b', label: 'B', type: 'field:text' }, + ]; + expect(applyAutoColSpan(fields, 2, 2).map(f => f.colSpan)).toEqual([undefined, undefined]); + }); + + it("respects span: 'full' on a scalar field", () => { + const fields: FormField[] = [ + { name: 'summary', label: 'Summary', type: 'field:text', span: 'full' } as any, + { name: 'code', label: 'Code', type: 'field:text' }, + ]; + const result = applyAutoColSpan(fields, 2, 2); + expect(result[0].colSpan).toBe(2); + expect(result[1].colSpan).toBeUndefined(); + }); + + it('leaves section-divider rows untouched but fills their fields', () => { + const fields: FormField[] = [ + { name: '__section_x', label: 'X', type: 'section-divider', colSpan: 4 } as any, + { name: 'a', label: 'A', type: 'field:text' }, + ]; + const result = applyAutoColSpan(fields, 2, 1); + expect(result[0].colSpan).toBe(4); // divider unchanged + expect(result[1].colSpan).toBe(2); // field filled to full row + }); + }); }); diff --git a/packages/plugin-form/src/autoLayout.ts b/packages/plugin-form/src/autoLayout.ts index 03227ef26..6756723c5 100644 --- a/packages/plugin-form/src/autoLayout.ts +++ b/packages/plugin-form/src/autoLayout.ts @@ -90,24 +90,77 @@ export function inferColumns(fieldCount: number): number { return 2; } +/** Clamp a raw column-ish number into 1..4 (the grid keys we support). */ +function clampGrid(n: number | undefined): number { + return n && n > 0 ? Math.min(Math.floor(n), 4) : 1; +} + /** - * Apply colSpan to wide fields so they span the full row. - * Only sets colSpan if the field does not already have one explicitly set. + * Resolve the effective colSpan (grid cells) a field occupies, given the form's + * grid width and the section's desired column count (#2578). * - * @returns A new array of fields with colSpan applied where needed. + * The column count is derived per surface (mobile 1 / modal 2 / page 3-4), so + * the robust field-width primitive is the relative `span`, NOT an absolute + * colSpan. Precedence: + * 1. `span: 'full'` → whole row (grid cells). + * 2. legacy `colSpan` → honoured but CLAMPED to the grid (never overflows). + * 3. wide widget type → whole row (textarea/markdown/… — the `span:'auto'` default). + * 4. section density → grid / sectionColumns, so `sectionColumns` fields fill a row. + * + * `sectionColumns` defaults to the grid width, i.e. one field per cell (the flat + * form's historical behaviour) when there is no narrower section. */ -export function applyAutoColSpan(fields: FormField[], columns: number): FormField[] { - if (columns <= 1) return fields; +export function resolveColSpan( + field: FormField, + gridColumns: number, + sectionColumns?: number, +): number { + const grid = clampGrid(gridColumns); + if (grid <= 1) return 1; + + const span = (field as { span?: 'auto' | 'full' }).span; + if (span === 'full') return grid; + + // Legacy absolute colSpan — honoured but clamped so it can never overflow the + // current grid (the fragility `span` exists to replace). + if (field.colSpan != null) { + return Math.min(Math.max(1, Math.floor(field.colSpan)), grid); + } - return fields.map(field => { - // User-defined colSpan takes priority - if (field.colSpan !== undefined) return field; + // `span: 'auto'` (or unset): wide widgets take the whole row. + if (field.type && isWideFieldType(field.type)) return grid; - // Wide field types should span full row - if (field.type && isWideFieldType(field.type)) { - return { ...field, colSpan: columns }; - } + // Otherwise fill the section's density: N per row → grid/N cells each. + const secCols = Math.min(Math.max(1, Math.floor(sectionColumns || grid)), grid); + return Math.min(grid, Math.max(1, Math.round(grid / secCols))); +} +/** + * Apply the resolved colSpan to each field so the form's CSS grid lays them out + * at the intended density. Span-aware and clamping (#2578); wide fields still + * span the full row (the `span: 'auto'` default). `section-divider` rows are + * left untouched (they carry their own full-row span). + * + * @param gridColumns The form's grid column count (1-4). + * @param sectionColumns The desired columns for THIS group of fields (defaults + * to the grid width = one field per cell). + * @returns A new array of fields with colSpan applied where needed. + */ +export function applyAutoColSpan( + fields: FormField[], + gridColumns: number, + sectionColumns?: number, +): FormField[] { + const grid = clampGrid(gridColumns); + if (grid <= 1) return fields; + + return fields.map(field => { + if ((field as { type?: string }).type === 'section-divider') return field; + const eff = resolveColSpan(field, grid, sectionColumns); + if (eff > 1) return { ...field, colSpan: eff }; + // eff === 1: strip any stale larger colSpan so it doesn't overflow a + // narrower grid than the author imagined. + if (field.colSpan != null && field.colSpan !== 1) return { ...field, colSpan: 1 }; return field; }); } diff --git a/packages/plugin-form/src/sectionFields.ts b/packages/plugin-form/src/sectionFields.ts index e33de0ea9..474dfeccf 100644 --- a/packages/plugin-form/src/sectionFields.ts +++ b/packages/plugin-form/src/sectionFields.ts @@ -128,6 +128,7 @@ export function normalizeSectionField( if (fd.immutable != null) base.immutable = fd.immutable; if (fd.hidden != null) base.hidden = fd.hidden; if (fd.colSpan != null) base.colSpan = fd.colSpan; + if (fd.span != null) base.span = fd.span; if (fd.options != null) base.options = fd.options; if (fd.multiple != null) base.multiple = fd.multiple; if (fd.reference != null) base.reference = fd.reference; diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index f8a48ac37..687873bc1 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -54,11 +54,13 @@ import { Tabs, TabsList, TabsTrigger, + useIsMobile, } from '@object-ui/components'; import { Plus } from 'lucide-react'; import { buildExpandFields } from '@object-ui/core'; import { SchemaRenderer as ImportedSchemaRenderer } from '@object-ui/react'; import { ViewSwitcher } from './ViewSwitcher'; +import { deriveRecordSurface } from './recordSurface'; /** * SchemaRenderer from @object-ui/react, used to render sub-view schemas. @@ -392,8 +394,12 @@ export const ObjectView: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [schema.objectName, dataSource, currentViewType, filterValues, sortConfig, refreshKey, currentNamedViewConfig, activeView, renderListView]); - // Determine layout mode - const layout = schema.layout || 'drawer'; + // Determine layout mode. #2578: default the record surface from how heavy the + // object is — a field-heavy object opens create/edit/detail as a full page, a + // light one as a drawer. Mobile always pages. An explicit `schema.layout` (or + // a per-view navigation config, handled in handleRowClick) still wins. + const isMobile = useIsMobile(); + const layout = schema.layout || deriveRecordSurface(objectSchema, { viewport: isMobile ? 'mobile' : 'desktop' }); // Determine enabled operations const operations = schema.operations || schema.table?.operations || { diff --git a/packages/plugin-view/src/recordSurface.ts b/packages/plugin-view/src/recordSurface.ts new file mode 100644 index 000000000..6293ae2fc --- /dev/null +++ b/packages/plugin-view/src/recordSurface.ts @@ -0,0 +1,64 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Local mirror of `@objectstack/spec` `deriveRecordSurface` (framework #2578). + * + * A record's default surface — full `page` vs a `drawer`/`modal` overlay — is + * DERIVED from how heavy the record is (visible, non-system field count), not + * authored: per ADR-0085 §2 a `recordSurface` object key would fail the + * admission test (field count is machine-inferable). Field-heavy objects open + * create/edit/detail as a full page; light ones as a drawer. Mobile always + * pages (overlays are cramped on phones). An explicit `schema.layout` or + * per-view navigation config still wins — this is only the default. + * + * Kept local because objectui pins `@objectstack/spec@^11.7.0`, which predates + * the export. Consolidate to + * `import { deriveRecordSurface } from '@objectstack/spec/data'` when objectui + * adopts spec >= 11.10 (framework #2578). The field set + threshold below mirror + * the spec helper exactly so the two agree. + */ + +/** Audit/system fields excluded from the "how heavy is this record" count. */ +const RECORD_SURFACE_SYSTEM_FIELDS: ReadonlySet = new Set([ + 'created_at', 'created_by', 'updated_at', 'updated_by', + 'organization_id', 'tenant_id', 'is_deleted', 'deleted_at', +]); + +/** At/above this many authorable fields, a record opens as a full page. */ +export const RECORD_SURFACE_PAGE_THRESHOLD = 12; + +export type RecordSurface = 'page' | 'drawer'; + +export interface RecordSurfaceOptions { + viewport?: 'mobile' | 'desktop'; + pageThreshold?: number; +} + +/** Count visible, non-system fields on an object schema. */ +function countAuthorableFields(objectSchema: unknown): number { + const fields = (objectSchema as { fields?: unknown } | null)?.fields; + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return 0; + let n = 0; + for (const [name, f] of Object.entries(fields as Record)) { + if (f?.hidden === true) continue; + if (RECORD_SURFACE_SYSTEM_FIELDS.has(name)) continue; + n++; + } + return n; +} + +/** + * Derive the default record surface for an object schema. Field-heavy → `page`, + * otherwise `drawer`; mobile always `page`. + */ +export function deriveRecordSurface(objectSchema: unknown, opts: RecordSurfaceOptions = {}): RecordSurface { + if (opts.viewport === 'mobile') return 'page'; + const threshold = opts.pageThreshold ?? RECORD_SURFACE_PAGE_THRESHOLD; + return countAuthorableFields(objectSchema) >= threshold ? 'page' : 'drawer'; +} diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 6ac367fe0..1f93a75e4 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -887,11 +887,20 @@ export interface FormField { */ [key: string]: any; /** - * Column span for grid layouts (1-4). - * Aligns with @objectstack/spec FormField.colSpan. + * Column span for grid layouts (1-4). Legacy — prefer `span`. + * Aligns with @objectstack/spec FormField.colSpan. The renderer clamps it to + * the current (per-surface derived) column count so it can never overflow. * @default 1 */ colSpan?: number; + /** + * Relative field width, decoupled from the (auto-derived) column count so it + * stays correct at 1/2/3/4 columns (#2578). `'auto'` (default): width from + * the widget type × current columns (wide widgets take the whole row); + * `'full'`: whole row at any column count. Aligns with + * @objectstack/spec FormField.span. Prefer this over `colSpan`. + */ + span?: 'auto' | 'full'; } /** From a56cb420f26e6cdfc81c73a2763f0c9ac0a71160 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 23:05:32 +0800 Subject: [PATCH 2/4] feat(app-shell): auto full-page record surface for field-heavy objects in the console (#2578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/app-shell/src/views/ObjectView.tsx | 12 ++++++++++-- packages/plugin-view/src/index.tsx | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 3cf8d5ae8..197e0d51c 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -20,7 +20,7 @@ const ImportWizard = lazy(() => ); import { ListView } from '@object-ui/plugin-list'; import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail'; -import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog } from '@object-ui/plugin-view'; +import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface } from '@object-ui/plugin-view'; import type { ViewTabItem } from '@object-ui/plugin-view'; // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). @@ -957,7 +957,15 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an const detailNavigation: ViewNavigationConfig = useMemo( () => activeView?.navigation ?? - objectDef.navigation ?? { mode: 'drawer', width: 'min(92vw, 1280px)' }, + objectDef.navigation ?? + // #2578: default a FIELD-HEAVY object's record peek to a full page + // (a form-heavy record is cramped in a drawer); light objects keep + // the drawer peek. This automates the "heavy detail object can set + // navigation.mode = 'page'" note above. An authored view/object + // `navigation` still wins. + (deriveRecordSurface(objectDef) === 'page' + ? { mode: 'page' } + : { mode: 'drawer', width: 'min(92vw, 1280px)' }), [activeView?.navigation, objectDef.navigation] ); const drawerRecordId = searchParams.get('recordId'); diff --git a/packages/plugin-view/src/index.tsx b/packages/plugin-view/src/index.tsx index edaac66dc..7f6080cea 100644 --- a/packages/plugin-view/src/index.tsx +++ b/packages/plugin-view/src/index.tsx @@ -18,6 +18,8 @@ import { SharedViewLink } from './SharedViewLink'; export { ObjectView, ViewSwitcher, FilterUI, SortUI, SharedViewLink }; export { ViewTabBar } from './ViewTabBar'; export { ManageViewsDialog } from './ManageViewsDialog'; +export { deriveRecordSurface, RECORD_SURFACE_PAGE_THRESHOLD } from './recordSurface'; +export type { RecordSurface } from './recordSurface'; export type { ObjectViewProps } from './ObjectView'; export type { ViewSwitcherProps } from './ViewSwitcher'; export type { ViewTabBarProps, ViewTabItem, AvailableViewType } from './ViewTabBar'; From da4e3f11c7a4e47d2d88a26e63a09e89805caf13 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 5 Jul 2026 00:38:58 +0800 Subject: [PATCH 3/4] feat(plugin-form,plugin-view,app-shell): responsive column cap + runtime overlay width (#2578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- packages/app-shell/src/views/ObjectView.tsx | 35 ++++++++++++------- .../src/__tests__/autoLayout.test.ts | 7 ++-- packages/plugin-form/src/autoLayout.ts | 21 +++++++---- packages/plugin-view/src/index.tsx | 4 +-- packages/plugin-view/src/recordSurface.ts | 32 +++++++++++++++++ packages/types/src/objectql.ts | 9 ++++- 6 files changed, 85 insertions(+), 23 deletions(-) diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 197e0d51c..0f910657e 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -20,7 +20,7 @@ const ImportWizard = lazy(() => ); import { ListView } from '@object-ui/plugin-list'; import { DetailView, RecordChatterPanel } from '@object-ui/plugin-detail'; -import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface } from '@object-ui/plugin-view'; +import { ObjectView as PluginObjectView, ViewTabBar, ManageViewsDialog, deriveRecordSurface, overlayWidthFor } from '@object-ui/plugin-view'; import type { ViewTabItem } from '@object-ui/plugin-view'; // Plugin registration is handled by the host app (e.g. apps/console/src/main.tsx // uses ComponentRegistry.registerLazy so heavy plugins stay code-split). @@ -955,18 +955,29 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an // is drawer-by-default. Per-view config can still override (e.g. a heavy // detail object can set `navigation.mode = 'page'`). const detailNavigation: ViewNavigationConfig = useMemo( - () => - activeView?.navigation ?? - objectDef.navigation ?? - // #2578: default a FIELD-HEAVY object's record peek to a full page - // (a form-heavy record is cramped in a drawer); light objects keep - // the drawer peek. This automates the "heavy detail object can set - // navigation.mode = 'page'" note above. An authored view/object - // `navigation` still wins. - (deriveRecordSurface(objectDef) === 'page' + () => { + const authored = activeView?.navigation ?? objectDef.navigation; + if (authored) { + // Authored config wins. For an overlay, resolve the `size` + // bucket (or 'auto') to a viewport-clamped width when no explicit + // width was given — #2578: a pixel width can't be authored blind. + if (authored.mode === 'page') return authored; + return { + ...authored, + width: authored.width ?? overlayWidthFor( + (authored as { size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full' }).size, + objectDef, + ), + }; + } + // #2578: derive surface + width from FIELD COUNT. Field-heavy → full + // page (cramped in a drawer); light → a drawer sized to the content + // and clamped to the viewport. Mobile always pages (in deriveRecordSurface). + return deriveRecordSurface(objectDef) === 'page' ? { mode: 'page' } - : { mode: 'drawer', width: 'min(92vw, 1280px)' }), - [activeView?.navigation, objectDef.navigation] + : { mode: 'drawer', width: overlayWidthFor('auto', objectDef) }; + }, + [activeView?.navigation, objectDef] ); const drawerRecordId = searchParams.get('recordId'); diff --git a/packages/plugin-form/src/__tests__/autoLayout.test.ts b/packages/plugin-form/src/__tests__/autoLayout.test.ts index 1a7fa5bd3..c0e8f8801 100644 --- a/packages/plugin-form/src/__tests__/autoLayout.test.ts +++ b/packages/plugin-form/src/__tests__/autoLayout.test.ts @@ -65,10 +65,13 @@ describe('autoLayout', () => { expect(inferColumns(3)).toBe(1); }); - it('returns 2 columns for 4+ fields', () => { + it('scales the column CAP with field count (#2578)', () => { expect(inferColumns(4)).toBe(2); expect(inferColumns(8)).toBe(2); - expect(inferColumns(20)).toBe(2); + expect(inferColumns(9)).toBe(3); + expect(inferColumns(15)).toBe(3); + expect(inferColumns(16)).toBe(4); + expect(inferColumns(60)).toBe(4); }); }); diff --git a/packages/plugin-form/src/autoLayout.ts b/packages/plugin-form/src/autoLayout.ts index 6756723c5..777f432cc 100644 --- a/packages/plugin-form/src/autoLayout.ts +++ b/packages/plugin-form/src/autoLayout.ts @@ -78,16 +78,25 @@ export function isAutoGeneratedFieldType(type: string): boolean { } /** - * Infer optimal number of columns based on the number of visible fields - * and whether any wide fields are present. + * Infer the MAX column count from the field count (#2578). This is an UPPER + * BOUND — the actual column count at render time is decided by the CSS + * container queries in {@link CONTAINER_GRID_COLS} against the form's REAL + * width, so a narrow drawer stays 1–2 columns while the SAME form on a wide + * page spreads out to this cap. The cap scales with field count so a light + * form is never thinned across too many columns, while a field-heavy record + * can use the full width. * - * Rules: - * - 0-3 fields → 1 column - * - 4+ fields → 2 columns + * Rules (upper bound; container queries clamp to the actual width): + * - 0–3 fields → 1 column + * - 4–8 fields → 2 columns + * - 9–15 fields → 3 columns + * - 16+ fields → 4 columns */ export function inferColumns(fieldCount: number): number { if (fieldCount <= 3) return 1; - return 2; + if (fieldCount <= 8) return 2; + if (fieldCount <= 15) return 3; + return 4; } /** Clamp a raw column-ish number into 1..4 (the grid keys we support). */ diff --git a/packages/plugin-view/src/index.tsx b/packages/plugin-view/src/index.tsx index 7f6080cea..02525ce7c 100644 --- a/packages/plugin-view/src/index.tsx +++ b/packages/plugin-view/src/index.tsx @@ -18,8 +18,8 @@ import { SharedViewLink } from './SharedViewLink'; export { ObjectView, ViewSwitcher, FilterUI, SortUI, SharedViewLink }; export { ViewTabBar } from './ViewTabBar'; export { ManageViewsDialog } from './ManageViewsDialog'; -export { deriveRecordSurface, RECORD_SURFACE_PAGE_THRESHOLD } from './recordSurface'; -export type { RecordSurface } from './recordSurface'; +export { deriveRecordSurface, RECORD_SURFACE_PAGE_THRESHOLD, deriveOverlaySize, overlayWidthFor } from './recordSurface'; +export type { RecordSurface, OverlaySize } from './recordSurface'; export type { ObjectViewProps } from './ObjectView'; export type { ViewSwitcherProps } from './ViewSwitcher'; export type { ViewTabBarProps, ViewTabItem, AvailableViewType } from './ViewTabBar'; diff --git a/packages/plugin-view/src/recordSurface.ts b/packages/plugin-view/src/recordSurface.ts index 6293ae2fc..09cd6f947 100644 --- a/packages/plugin-view/src/recordSurface.ts +++ b/packages/plugin-view/src/recordSurface.ts @@ -62,3 +62,35 @@ export function deriveRecordSurface(objectSchema: unknown, opts: RecordSurfaceOp const threshold = opts.pageThreshold ?? RECORD_SURFACE_PAGE_THRESHOLD; return countAuthorableFields(objectSchema) >= threshold ? 'page' : 'drawer'; } + +/** + * Overlay size bucket for a drawer/modal (mirrors spec `NavigationConfig.size` + * / `FormView.modalSize`). #2578: width is a runtime concern — the author can't + * know the client viewport — so buckets map to a pixel CAP that the renderer + * always clamps to the viewport (`min(cap, 92vw)`). + */ +export type OverlaySize = 'sm' | 'md' | 'lg' | 'xl' | 'full'; + +/** Pixel cap per bucket; always clamped to the viewport at render (min(cap, 92vw)). */ +const OVERLAY_SIZE_PX: Record = { + sm: 480, md: 720, lg: 960, xl: 1200, full: 1600, +}; + +/** Derive the overlay size bucket from field count (the `size: 'auto'` path). */ +export function deriveOverlaySize(objectSchema: unknown): OverlaySize { + const n = countAuthorableFields(objectSchema); + if (n <= 3) return 'sm'; + if (n <= 8) return 'md'; + if (n <= 15) return 'lg'; + return 'xl'; +} + +/** + * Resolve an overlay `size` (bucket or `'auto'`/absent) to a viewport-clamped + * CSS width. `'auto'` derives the bucket from field count. The `min(cap, 92vw)` + * clamp is why the AUTHOR never needs the client width — the client applies it. + */ +export function overlayWidthFor(size: 'auto' | OverlaySize | undefined, objectSchema: unknown): string { + const bucket = (!size || size === 'auto') ? deriveOverlaySize(objectSchema) : size; + return `min(92vw, ${OVERLAY_SIZE_PX[bucket]}px)`; +} diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index afa3dda7b..fd1489108 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1540,7 +1540,14 @@ export interface ViewNavigationConfig { /** Open in new tab (for page/new_window modes) */ openNewTab?: boolean; - /** Width for drawer/modal/split modes (e.g., '600px', '50%') */ + /** + * [#2578] Overlay size bucket for drawer/modal detail. `'auto'` (default): + * the renderer derives it from field count and clamps to the viewport. + * Prefer this over the pixel `width`. + */ + size?: 'auto' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; + + /** @deprecated [#2578 → `size`] Pixel/percent width — can't be authored blind. Renderer fallback only. */ width?: string | number; } From 839305a4ffa85781f95e1a2f2c1bcf82a4300f4f Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 5 Jul 2026 00:54:05 +0800 Subject: [PATCH 4/4] docs(changeset): adaptive record surface + span + responsive columns (framework#2578) Co-Authored-By: Claude Opus 4.8 --- .changeset/adaptive-record-surface-2578.md | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .changeset/adaptive-record-surface-2578.md diff --git a/.changeset/adaptive-record-surface-2578.md b/.changeset/adaptive-record-surface-2578.md new file mode 100644 index 000000000..060677a4f --- /dev/null +++ b/.changeset/adaptive-record-surface-2578.md @@ -0,0 +1,30 @@ +--- +"@object-ui/plugin-form": minor +"@object-ui/plugin-view": minor +"@object-ui/app-shell": minor +"@object-ui/types": minor +--- + +feat: adaptive record surface + semantic field span + responsive columns (framework#2578) + +Field-heavy objects (all metadata is AI-authored) now present themselves without +any authored presentation config: + +- **Adaptive surface** — a record's create/edit/detail opens as a full page when + the object is field-heavy, or a drawer when it is light. Derived from field + count (`deriveRecordSurface`), not authored; mobile always pages. Wired into the + app-shell ObjectView detail navigation (an authored view/object `navigation` + still wins). +- **Semantic field span** — `FormField.span` (`auto`/`full`) is a width primitive + decoupled from the (per-surface derived) column count; legacy `colSpan` is + clamped so it never overflows. `ObjectForm` now honours per-section `columns` + and carries `span`/`colSpan` from section defs — fixes the bug where + `type:'simple'` ignored `section.columns` and grouped fields rendered single + column. +- **Responsive columns** — `inferColumns` scales the column CAP with field count + (≤3→1, ≤8→2, ≤15→3, 16+→4); the ACTUAL column count follows the form's real + width via CSS container queries, so the same form goes 1→2→3→4 columns as a + drawer widens or becomes a page. +- **Runtime overlay width** — `NavigationConfig.size` bucket is resolved to a + viewport-clamped width at runtime (`overlayWidthFor`); a pixel width is never + authored (the author cannot know the client viewport).