diff --git a/.changeset/12548-section-columns-per-section.md b/.changeset/12548-section-columns-per-section.md
new file mode 100644
index 0000000000..5872a80751
--- /dev/null
+++ b/.changeset/12548-section-columns-per-section.md
@@ -0,0 +1,17 @@
+---
+'@object-ui/plugin-form': patch
+'@object-ui/components': patch
+---
+
+A sectioned create or edit form draws every section at its own `columns` count per row, whatever the other sections declare (objectui#12548).
+
+`@objectstack/spec` `FormSectionSchema.columns` is a per-section count, but all of a form's sections share one field grid, sized by the widest section, and each field spanned `Math.round(grid / sectionColumns)` cells of it. A section whose count did not fit that grid drew the wrong number per row. A `columns: 2` section beside a `columns: 3` one spanned 2 of 3 cells, so it drew one field per row and left the right third empty. A `columns: 3` section beside a `columns: 4` one spanned 1 of 4 cells, so it drew four per row.
+
+`ModalForm`, `ObjectForm`, `SplitForm`, `TabbedForm` and `WizardForm` now share one rule:
+
+- When every section's count is 1 or the grid's own width, the form keeps the grid and spans it always had, class for class.
+- Otherwise the field grid is 12 tracks wide from `@md` (`grid-cols-1 @md:grid-cols-12`). Each field spans 12 / 6 / 4 / 3 twelfths of the row for a 1 / 2 / 3 / 4-column section. `span: 'full'` and wide widgets take the row, and an authored `colSpan` counts its section's columns.
+
+The form renderer draws a twelfths `colSpan` with the step ladder each count has on its own grid: at most two per row at `@md`, three at `@2xl` and four at `@4xl`. Below `@md` the form stays one column.
+
+No type, prop or export changes. A form with no section `columns` is unchanged.
diff --git a/packages/components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx b/packages/components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx
new file mode 100644
index 0000000000..e5d027f9fc
--- /dev/null
+++ b/packages/components/src/renderers/form/__tests__/form-twelve-track-span-12548.test.tsx
@@ -0,0 +1,86 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#12548 — the col-span classes the form renderer emits for a
+ * twelfths `colSpan` on a 12-track field container.
+ *
+ * `plugin-form` draws a sectioned form whose sections' `columns` counts do not
+ * all fit one 1–4 grid on `grid gap-4 grid-cols-1 @md:grid-cols-12`, with
+ * each field's `colSpan` in twelfths of the row (12 / 6 / 4 / 3 for a
+ * 1 / 2 / 3 / 4-column section). This file pins the emission itself; the
+ * cross-layer leg, per host, is
+ * `plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx`. The
+ * container class is written out as a literal for the reason the 9244 sibling
+ * gives: components must not depend on plugin-form.
+ */
+
+import React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import { ComponentRegistry } from '@object-ui/core';
+import '../../../renderers';
+
+afterEach(() => cleanup());
+
+const TWELVE = 'grid gap-4 grid-cols-1 @md:grid-cols-12';
+
+function spanClasses(colSpan: number | undefined, fieldContainerClass: string): string[] {
+ cleanup();
+ const Form = ComponentRegistry.get('form')!;
+ render(
+
,
+ );
+ const cell = document.body.querySelector('[data-field="f"]');
+ if (!cell) throw new Error('field not rendered: f');
+ return (cell.getAttribute('class') || '').split(/\s+/).filter((t) => /(^|:)col-span-/.test(t));
+}
+
+describe('objectui#12548 — twelfths on a 12-track container keep each count’s step ladder', () => {
+ it('a whole row (1 per row) is 12 from @md', () => {
+ expect(spanClasses(12, TWELVE)).toEqual(['@md:col-span-12']);
+ });
+
+ it('2 per row is 6 from @md', () => {
+ expect(spanClasses(6, TWELVE)).toEqual(['@md:col-span-6']);
+ });
+
+ it('3 per row reads 2 at @md and 3 from @2xl', () => {
+ expect(spanClasses(4, TWELVE)).toEqual(['@md:col-span-6', '@2xl:col-span-4']);
+ });
+
+ it('4 per row reads 2 at @md, 3 at @2xl and 4 from @4xl', () => {
+ expect(spanClasses(3, TWELVE)).toEqual(['@md:col-span-6', '@2xl:col-span-4', '@4xl:col-span-3']);
+ });
+
+ it('2 of 3 and 3 of 4 take the row until their count fits', () => {
+ expect(spanClasses(8, TWELVE)).toEqual(['@md:col-span-12', '@2xl:col-span-8']);
+ expect(spanClasses(9, TWELVE)).toEqual(['@md:col-span-12', '@4xl:col-span-9']);
+ });
+
+ it('clamps an oversized colSpan to the row, and leaves an unset one at one track', () => {
+ expect(spanClasses(20, TWELVE)).toEqual(['@md:col-span-12']);
+ expect(spanClasses(undefined, TWELVE)).toEqual([]);
+ });
+
+ it('CONTROL: the 1–4 containers emit what they always did', () => {
+ const three = 'grid gap-4 grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3';
+ expect(spanClasses(3, three)).toEqual(['@md:col-span-2', '@2xl:col-span-3']);
+ expect(spanClasses(6, three)).toEqual(['@md:col-span-2', '@2xl:col-span-3']);
+ expect(spanClasses(2, three)).toEqual(['@md:col-span-2']);
+ });
+});
diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx
index 3664bbb220..b067131cdc 100644
--- a/packages/components/src/renderers/form/form.tsx
+++ b/packages/components/src/renderers/form/form.tsx
@@ -240,6 +240,60 @@ const BARE_SPAN_CLASS: Record = {
4: 'col-span-4',
};
+/**
+ * The tier class of a 12-track field container — one column below `@md`,
+ * twelve tracks from `@md` up. `@object-ui/plugin-form` draws a sectioned form
+ * on it when the sections' `columns` counts do not all fit one 1–4 grid
+ * (objectui#12548), and a field's `colSpan` there is in TWELFTHS of the row.
+ *
+ * ⛔ Kept a whole literal: it is also what puts the utility into this
+ * package's published `style.css`, whose Tailwind scan reads only this
+ * package's source.
+ */
+const TWELVE_TRACK_TIER = '@md:grid-cols-12';
+
+const isTwelveTrackContainer = (containerClass: string): boolean =>
+ containerClass.split(/\s+/).includes(TWELVE_TRACK_TIER);
+
+/**
+ * The col-span classes of a twelfths `colSpan` on a {@link TWELVE_TRACK_TIER} container.
+ *
+ * A share of the row that a 1–4 column count yields (12 / 6 / 4 / 3, and the
+ * 8 and 9 an authored `colSpan` of 2 of 3 or 3 of 4 yields) keeps the step
+ * ladder the 1–4 container classes give that count: at most two fields per row
+ * at `@md`, three at `@2xl`, four at `@4xl` — the tiers of
+ * `grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4`. So a
+ * 3-column section reads two per row at `@md` and three from `@2xl`, exactly as
+ * it does on its own grid. A share reduces to lowest terms `k / d` and draws
+ * `min(k, p) / p` of the row at a tier allowing `p = min(d, tier)` per row.
+ * Other twelfths are no 1–4 count's share and are drawn as written.
+ *
+ * Every tier earns a class from `@md`, because the base tier is one column and
+ * the twelve-track tiers have no cell narrower than a twelfth to fall back to.
+ *
+ * ⛔ Literals, never assembled: Tailwind's scanner reads source text.
+ */
+const TWELVE_TRACK_SPAN_CLASS: Record = {
+ 1: '@md:col-span-1',
+ 2: '@md:col-span-2',
+ 3: '@md:col-span-6 @2xl:col-span-4 @4xl:col-span-3',
+ 4: '@md:col-span-6 @2xl:col-span-4',
+ 5: '@md:col-span-5',
+ 6: '@md:col-span-6',
+ 7: '@md:col-span-7',
+ 8: '@md:col-span-12 @2xl:col-span-8',
+ 9: '@md:col-span-12 @4xl:col-span-9',
+ 10: '@md:col-span-10',
+ 11: '@md:col-span-11',
+ 12: '@md:col-span-12',
+};
+
+/** {@link TWELVE_TRACK_SPAN_CLASS} for a field's `colSpan`; none set ⇒ one track, as the grid draws it. */
+function twelveTrackSpanClass(colSpan: number | undefined): string {
+ if (!colSpan || !(colSpan >= 1)) return '';
+ return TWELVE_TRACK_SPAN_CLASS[Math.min(Math.floor(colSpan), 12)];
+}
+
/**
* The col-span classes a field must carry to occupy `targetCols` cells of
* `containerClass` — ONE CLASS PER TIER, not one class for the widest tier
@@ -3328,7 +3382,9 @@ ComponentRegistry.register('form',
const pickSpanClass = (targetCols: number): string =>
spanLadderFor(containerClass, targetCols);
- const colSpanClass = colSpan && colSpan > 1
+ const colSpanClass = isTwelveTrackContainer(containerClass)
+ ? twelveTrackSpanClass(colSpan)
+ : colSpan && colSpan > 1
? colSpan === 2 ? pickSpanClass(2)
: colSpan === 3 ? pickSpanClass(3)
: colSpan >= 4 ? pickSpanClass(4)
diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx
index 7b295fa7d3..7d4829841f 100644
--- a/packages/plugin-form/src/ModalForm.tsx
+++ b/packages/plugin-form/src/ModalForm.tsx
@@ -48,6 +48,7 @@ import {
filterSystemFields,
inferColumns,
inferModalSize,
+ sectionedGridLayout,
CONTAINER_GRID_COLS,
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
@@ -834,7 +835,9 @@ export const ModalForm: React.FC = ({
// The form is ONE grid. Its width is the explicit form `columns`, else the
// widest section; each section then lays ITS fields out at its own
// declared density within that grid via colSpan (objectstack-ai/objectstack#2578), exactly like the
- // full-page sectioned form.
+ // full-page sectioned form — by the one sectioned-form rule, which
+ // switches to a 12-track grid when the sections' counts do not all fit
+ // the widest one (objectui#12548).
const clampCol = (n: unknown): number | undefined =>
typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined;
const declaredCols = sections
@@ -842,7 +845,8 @@ export const ModalForm: React.FC = ({
.filter((c): c is number => c != null);
const formColumns = (clampCol(schema.columns)
?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4;
- const containerFieldClass = CONTAINER_GRID_COLS[formColumns];
+ const sectionGrid = sectionedGridLayout(formColumns, sections.map((s) => clampCol(s.columns)));
+ const containerFieldClass = sectionGrid.fieldContainerClass;
// FLS first, so a section whose every field is non-readable drops out
// entirely (header included) instead of leaving an empty group.
@@ -872,9 +876,7 @@ export const ModalForm: React.FC = ({
visibleWhen: section.visibleWhen,
className: section.className,
gridClassName: section.gridClassName,
- fields: formColumns > 1
- ? applyAutoColSpan(body, formColumns, clampCol(section.columns))
- : body,
+ fields: sectionGrid.layOut(body, clampCol(section.columns)),
};
})
.filter((g) => g.fields.length > 0);
diff --git a/packages/plugin-form/src/ObjectForm.tsx b/packages/plugin-form/src/ObjectForm.tsx
index 35bf1b3eb0..71d19d3d5d 100644
--- a/packages/plugin-form/src/ObjectForm.tsx
+++ b/packages/plugin-form/src/ObjectForm.tsx
@@ -44,11 +44,10 @@ import { ModalForm } from './ModalForm';
import { MasterDetailForm } from './MasterDetailForm';
import {
applyAutoLayout,
- applyAutoColSpan,
- containerGridColsFor,
filterAutoGeneratedFields,
filterSystemFields,
inferColumns,
+ sectionedGridLayout,
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
import { mergeCustomFields } from './customFieldsMerge';
@@ -1648,6 +1647,13 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
const formColumns =
clampCol(schema.columns) ??
(declaredSectionCols.length ? Math.max(...declaredSectionCols) : inferColumns(approxInputs));
+ // The one sectioned-form rule (objectui#12548): every section at its own
+ // declared count on the shared grid, 12 tracks wide when the counts do
+ // not all fit the grid `formColumns` names.
+ const sectionGrid = sectionedGridLayout(
+ formColumns,
+ effectiveSections.map(s => clampCol((s as any).columns)),
+ );
const groupedFields: FormField[] = [];
// The section builder every other arm uses (objectui#10475), handed this
// arm's parent field POOL: a section's members come out in the section's
@@ -1778,7 +1784,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
// objectstack-ai/objectstack#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;
+ const laid = sectionGrid.layOut(sectionFields, secCols);
// Collapsed groups keep their fields registered (values preserved) but
// hidden from the DOM. A section with no row is never collapsible, so it
@@ -1796,7 +1802,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
// 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);
+ const fieldContainerClass = sectionGrid.fieldContainerClass;
return (
diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx
index dc5261306c..54da7c5070 100644
--- a/packages/plugin-form/src/SplitForm.tsx
+++ b/packages/plugin-form/src/SplitForm.tsx
@@ -35,7 +35,7 @@ import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWri
import { ClosedAffordanceNotice } from './closedAffordanceNotice';
import { snapshotLoadedRecord, advanceLoadedRecord, type LoadedRecordSnapshot } from './sanitize';
import { formWritePayload } from './writePayload';
-import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
+import { sectionedGridLayout } from './autoLayout';
import { useOccSave } from './occSave';
import {
NO_LOAD_FAILURES,
@@ -500,7 +500,10 @@ export const SplitForm: React.FC = ({
.filter((c): c is number => c != null);
const formColumns = (clampCol(schema.columns)
?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4;
- const containerFieldClass = containerGridColsFor(formColumns);
+ // The one sectioned-form rule (objectui#12548): each section at its own
+ // count on the shared grid — 12 tracks wide when the counts do not all fit.
+ const sectionGrid = sectionedGridLayout(formColumns, schema.sections.map((s) => clampCol(s.columns)));
+ const containerFieldClass = sectionGrid.fieldContainerClass;
/**
* One panel's field list: each section contributes an inline `section-divider`
@@ -528,11 +531,7 @@ export const SplitForm: React.FC = ({
className: section.className,
} as any);
}
- out.push(
- ...(formColumns > 1
- ? applyAutoColSpan(body, formColumns, clampCol(section.columns))
- : body),
- );
+ out.push(...sectionGrid.layOut(body, clampCol(section.columns)));
});
return out;
};
diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx
index 7b8687eb4b..1152e89e8d 100644
--- a/packages/plugin-form/src/TabbedForm.tsx
+++ b/packages/plugin-form/src/TabbedForm.tsx
@@ -25,7 +25,7 @@ import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWri
import { ClosedAffordanceNotice } from './closedAffordanceNotice';
import { snapshotLoadedRecord, advanceLoadedRecord, type LoadedRecordSnapshot } from './sanitize';
import { formWritePayload } from './writePayload';
-import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
+import { sectionedGridLayout } from './autoLayout';
import { useOccSave } from './occSave';
import {
NO_LOAD_FAILURES,
@@ -590,7 +590,10 @@ export const TabbedForm: React.FC = ({
// ModalForm use, so one piece of metadata lays out the same in every host.
const formColumns =
clampCol(schema.columns) ?? (declaredCols.length ? Math.max(...declaredCols) : 1);
- const containerFieldClass = containerGridColsFor(formColumns);
+ // The one sectioned-form rule (objectui#12548): each tab at its own count on
+ // the shared grid — 12 tracks wide when the counts do not all fit.
+ const sectionGrid = sectionedGridLayout(formColumns, schema.sections.map((s) => clampCol(s.columns)));
+ const containerFieldClass = sectionGrid.fieldContainerClass;
const tabGroups = schema.sections.map((section, index) => {
const body = buildSectionFields(section);
@@ -603,9 +606,7 @@ export const TabbedForm: React.FC = ({
// BOTH synthesis paths below can read it — the tab arm and the sub-two-tab
// degradation — instead of each re-deriving it from `schema.sections`.
visibleWhen: section.visibleWhen,
- fields: formColumns > 1
- ? applyAutoColSpan(body, formColumns, clampCol(section.columns))
- : body,
+ fields: sectionGrid.layOut(body, clampCol(section.columns)),
};
});
diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx
index 9c3ecf1b47..0d0e1eef3e 100644
--- a/packages/plugin-form/src/WizardForm.tsx
+++ b/packages/plugin-form/src/WizardForm.tsx
@@ -29,7 +29,7 @@ import { closedFormAffordance, fieldWriteGate, gateFormFields } from './fieldWri
import { ClosedAffordanceNotice } from './closedAffordanceNotice';
import { snapshotLoadedRecord, advanceLoadedRecord, type LoadedRecordSnapshot } from './sanitize';
import { formWritePayload } from './writePayload';
-import { applyAutoColSpan, containerGridColsFor } from './autoLayout';
+import { sectionedGridLayout } from './autoLayout';
import { resolveSuccessNavigate, type SubmitBehavior } from './successBehavior';
import { resolveSubmitRedirect, submitRedirectScope } from './submitRedirect';
import {
@@ -1230,7 +1230,11 @@ export const WizardForm: React.FC = ({
const clampCol = (n: unknown): number | undefined =>
typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined;
const stepGridColumns = clampCol(schema.columns) ?? clampCol(currentSection?.columns) ?? 1;
- const stepFieldContainerClass = containerGridColsFor(stepGridColumns);
+ // The one sectioned-form rule (objectui#12548), over the one section this
+ // step draws: a form-level grid wider than the step's own count goes 12
+ // tracks wide, so the step still draws its own count per row.
+ const stepGrid = sectionedGridLayout(stepGridColumns, [clampCol(currentSection?.columns)]);
+ const stepFieldContainerClass = stepGrid.fieldContainerClass;
// The current step's child `form` node, typed as the node it is
// (objectui#11354). `SchemaRenderer`'s `schema` slot is `BaseSchema`, so a
@@ -1251,9 +1255,7 @@ export const WizardForm: React.FC = ({
// Unlike the tabbed/split hosts there is no widest-section
// fallback: wizard steps never share a viewport, so there is no
// shared grid to size, and each step keeps its authored width.
- fields: stepGridColumns > 1
- ? applyAutoColSpan(currentSectionFields, stepGridColumns, clampCol(currentSection?.columns))
- : currentSectionFields,
+ fields: stepGrid.layOut(currentSectionFields, clampCol(currentSection?.columns)),
columns: stepGridColumns,
...(stepFieldContainerClass ? { fieldContainerClass: stepFieldContainerClass } : {}),
layout: 'vertical',
diff --git a/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx b/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx
new file mode 100644
index 0000000000..f49f3ba337
--- /dev/null
+++ b/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx
@@ -0,0 +1,321 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#12548 — every authored section renders its OWN `columns` count of
+ * fields per row, whatever the other sections declare.
+ *
+ * `@objectstack/spec` `FormSectionSchema.columns` (`1 | 2 | 3 | 4`) is a
+ * per-section column count. A sectioned host draws all its sections in ONE
+ * field grid (one form, one react-hook-form instance), and that grid used to be
+ * sized by the widest section, with every field spanning
+ * `Math.round(grid / sectionColumns)` cells. Whenever a section's count did not
+ * divide the grid, the section drew the wrong number per row: a `columns: 2`
+ * section on a 3-wide grid spans 2 of 3 cells — ONE field per row — and on a
+ * 4-wide grid a `columns: 3` section spans 1 cell — FOUR per row.
+ *
+ * The rule now (`sectionedGridLayout` in `autoLayout.ts`, called by all five
+ * hosts): a form whose sections all divide its grid keeps that grid, byte for
+ * byte; otherwise the grid is 12 tracks wide from `@md` and a field spans
+ * twelfths of the row. These pins read the class attributes the real renderer
+ * draws, per host, because the claim is about what renders.
+ */
+
+import React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, waitFor, cleanup } from '@testing-library/react';
+import { registerAllFields } from '@object-ui/fields';
+import { ComponentRegistry } from '@object-ui/core';
+import type { FormField } from '@object-ui/types';
+import '@object-ui/components';
+import {
+ CONTAINER_GRID_COLS,
+ TWELVE_TRACK_GRID_COLS,
+ applyAutoColSpan,
+ sectionedGridLayout,
+} from '../autoLayout';
+import { ModalForm } from '../ModalForm';
+import { ObjectForm } from '../ObjectForm';
+import { SplitForm } from '../SplitForm';
+import { TabbedForm } from '../TabbedForm';
+import { WizardForm } from '../WizardForm';
+
+registerAllFields();
+
+afterEach(() => cleanup());
+
+const FIELD_NAMES = ['c1', 'c2', 'c3', 'c4', 'full', 'q1', 'q2', 'q3', 'd1', 'd2'];
+
+const makeDataSource = (): any => ({
+ getObjectSchema: vi.fn().mockResolvedValue({
+ name: 'lead',
+ fields: Object.fromEntries(
+ FIELD_NAMES.map((n) => [n, { type: 'text', label: `Field ${n}` }]),
+ ),
+ }),
+ create: vi.fn().mockResolvedValue({ id: '1' }),
+ update: vi.fn().mockResolvedValue({ id: '1' }),
+ findOne: vi.fn().mockResolvedValue({}),
+});
+
+type Section = { name: string; label: string; columns?: number; fields: unknown[] };
+
+/** HotCRM `crm_lead`'s shape, reduced: a 2 / 3 / 1 mixed form with a `span: 'full'` field. */
+const MIXED: Section[] = [
+ { name: 'contact', label: 'Contact', columns: 2, fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3', 'c4'] },
+ { name: 'classification', label: 'Classification', columns: 3, fields: ['q1', 'q2', 'q3'] },
+ { name: 'notes', label: 'Notes', columns: 1, fields: ['d1', 'd2'] },
+];
+
+const single = (columns: number): Section[] => [
+ { name: 'contact', label: 'Contact', columns, fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3'] },
+ { name: 'classification', label: 'Classification', columns, fields: ['q1', 'q2', 'q3'] },
+];
+
+type Host = 'modal' | 'object' | 'split' | 'tabbed' | 'wizard';
+
+function mount(host: Host, sections: Section[], extra: Record = {}) {
+ const schema = {
+ type: 'object-form',
+ objectName: 'lead',
+ mode: 'create',
+ title: 'New Lead',
+ sections,
+ ...extra,
+ } as any;
+ const ds = makeDataSource();
+ switch (host) {
+ case 'modal':
+ return render();
+ case 'object':
+ return render();
+ case 'split':
+ return render();
+ case 'tabbed':
+ return render();
+ case 'wizard':
+ return render();
+ }
+}
+
+/** The grid classes of the container holding `name`, and that field's own span classes. */
+function readField(name: string): { grid: string[]; span: string[] } {
+ const cell = document.body.querySelector(`[data-field="${name}"]`);
+ if (!cell) throw new Error(`field not rendered: ${name}`);
+ const tokens = (el: Element | null) => (el?.getAttribute('class') || '').split(/\s+/).filter(Boolean);
+ return {
+ grid: tokens(cell.parentElement).filter((t) => /(^|:)grid-cols-/.test(t)),
+ span: tokens(cell).filter((t) => /(^|:)col-span-/.test(t)),
+ };
+}
+
+async function settle(name: string) {
+ await waitFor(() => expect(document.body.querySelector(`[data-field="${name}"]`)).not.toBeNull());
+}
+
+// The multi-column tiers of a container-query grid, narrowest first. A
+// field's count per row at a tier is (tracks in force there) / (its span in
+// force there); Tailwind's prefixes are min-width, so the class in force at a
+// tier is the widest-prefixed one at or below it.
+const TIERS = ['@md', '@2xl', '@4xl'] as const;
+function perRowAt(read: { grid: string[]; span: string[] }, tier: (typeof TIERS)[number]): number {
+ const upTo = TIERS.slice(0, TIERS.indexOf(tier) + 1);
+ const inForce = (list: string[], stem: string) => {
+ let v: number | undefined;
+ for (const t of upTo) {
+ const hit = list.find((c) => c.startsWith(`${t}:${stem}`));
+ if (hit) v = Number(hit.split('-').pop());
+ }
+ return v;
+ };
+ const tracks = inForce(read.grid, 'grid-cols-') ?? 1;
+ const span = inForce(read.span, 'col-span-') ?? 1;
+ return tracks / span;
+}
+
+const HOSTS: Host[] = ['modal', 'object', 'split', 'tabbed'];
+
+describe('objectui#12548 — a 2 / 3 / 1 mixed form draws 2 / 3 / 1 per row', () => {
+ for (const host of HOSTS) {
+ it(`${host}: each section at its own count, span: 'full' takes the row`, async () => {
+ mount(host, MIXED);
+ await settle('d2');
+ const c1 = readField('c1');
+ const q1 = readField('q1');
+ const d1 = readField('d1');
+ const full = readField('full');
+
+ // Below `@md` the grid is one column (the base class), whatever the section.
+ expect(c1.grid).toContain('grid-cols-1');
+
+ // At the widest tier each section reads its authored count.
+ expect(perRowAt(c1, '@4xl'), 'columns: 2 section').toBe(2);
+ expect(perRowAt(q1, '@4xl'), 'columns: 3 section').toBe(3);
+ expect(perRowAt(d1, '@4xl'), 'columns: 1 section').toBe(1);
+ expect(perRowAt(full, '@4xl'), "span: 'full'").toBe(1);
+
+ // `@md` is where multi-column starts: two per row there for the 2- and
+ // 3-column sections (the 3-column step ladder 2 → 3 at `@2xl` is kept).
+ expect(perRowAt(c1, '@md')).toBe(2);
+ expect(perRowAt(q1, '@md')).toBe(2);
+ expect(perRowAt(q1, '@2xl')).toBe(3);
+ expect(perRowAt(c1, '@2xl')).toBe(2);
+ expect(perRowAt(d1, '@md')).toBe(1);
+ expect(perRowAt(full, '@md')).toBe(1);
+ });
+ }
+
+ it('wizard: a form-level grid wider than the step draws the step at its own count', async () => {
+ // A wizard shows one section per step and sizes its grid from the form
+ // `columns`, else the step's own — so a mix only arises under a form-level
+ // `columns` wider than the step.
+ mount('wizard', MIXED, { columns: 3 });
+ await settle('c4');
+ expect(perRowAt(readField('c1'), '@4xl')).toBe(2);
+ expect(perRowAt(readField('c1'), '@md')).toBe(2);
+ expect(perRowAt(readField('full'), '@4xl')).toBe(1);
+ });
+
+ it('a 3-column section beside a 4-column one draws three per row, not four', async () => {
+ mount('modal', [
+ { name: 'a', label: 'A', columns: 4, fields: ['c1', 'c2', 'c3', 'c4'] },
+ { name: 'b', label: 'B', columns: 3, fields: ['q1', 'q2', 'q3'] },
+ ]);
+ await settle('q3');
+ expect(perRowAt(readField('q1'), '@4xl')).toBe(3);
+ // The 4-column section keeps its own step ladder: 2 → 3 → 4.
+ expect(perRowAt(readField('c1'), '@md')).toBe(2);
+ expect(perRowAt(readField('c1'), '@2xl')).toBe(3);
+ expect(perRowAt(readField('c1'), '@4xl')).toBe(4);
+ });
+});
+
+/**
+ * CONTROL — a single-density form keeps the grid it always had. The literal
+ * classes below are the ones `main` drew before objectui#12548; they must not
+ * move.
+ */
+describe('objectui#12548 CONTROL — single-density forms are byte-identical', () => {
+ const BEFORE: Record = {
+ 2: { grid: ['grid-cols-1', '@md:grid-cols-2'], plain: [], full: ['@md:col-span-2'] },
+ 3: {
+ grid: ['grid-cols-1', '@md:grid-cols-2', '@2xl:grid-cols-3'],
+ plain: [],
+ full: ['@md:col-span-2', '@2xl:col-span-3'],
+ },
+ };
+ for (const host of HOSTS) {
+ for (const columns of [2, 3]) {
+ it(`${host}: all sections columns: ${columns}`, async () => {
+ mount(host, single(columns));
+ await settle('q3');
+ const c1 = readField('c1');
+ expect(c1.grid).toEqual(BEFORE[columns].grid);
+ expect(c1.span).toEqual(BEFORE[columns].plain);
+ expect(readField('q1').span).toEqual(BEFORE[columns].plain);
+ expect(readField('full').span).toEqual(BEFORE[columns].full);
+ });
+ }
+ }
+});
+
+describe('objectui#12548 — the 12-track ladder matches the 1–4 grids, density by density', () => {
+ // The drift guard between the two halves of this card: the renderer's
+ // twelfths table hard-codes the step ladder of the 1–4 container classes,
+ // so read the tiers out of `CONTAINER_GRID_COLS[4]` and require every
+ // density to draw `min(columns, tier)` per row at each of them.
+ const tiers = Array.from(CONTAINER_GRID_COLS[4]!.matchAll(/(@\w+):grid-cols-(\d+)/g)).map((m) => ({
+ tier: m[1] as (typeof TIERS)[number],
+ cols: Number(m[2]),
+ }));
+
+ it('reads the same three tiers this file measures', () => {
+ expect(tiers.map((t) => t.tier)).toEqual([...TIERS]);
+ });
+
+ for (const columns of [1, 2, 3, 4]) {
+ it(`a columns: ${columns} section on the 12-track grid`, () => {
+ const grid = sectionedGridLayout(4, [4, 3, 2, 1]);
+ expect(grid.fieldContainerClass).toBe(TWELVE_TRACK_GRID_COLS);
+ const [field] = grid.layOut([{ name: 'x', label: 'X', type: 'input' }] as FormField[], columns);
+ const Form = ComponentRegistry.get('form')!;
+ render(
+ ,
+ );
+ for (const { tier, cols } of tiers) {
+ expect(perRowAt(readField('x'), tier), `${tier}`).toBe(Math.min(columns, cols));
+ }
+ });
+ }
+});
+
+describe('objectui#12548 — sectionedGridLayout (the one rule)', () => {
+ const f = (over: Partial & Record = {}) =>
+ ({ name: 'x', label: 'X', type: 'input', ...over }) as FormField;
+
+ it('keeps the 1–4 grid and applyAutoColSpan when every section is 1 or the grid width', () => {
+ for (const [cols, sections] of [
+ [2, [2, 2]],
+ [3, [3, 1, undefined]],
+ [4, [4, 1]],
+ ] as const) {
+ const grid = sectionedGridLayout(cols, sections);
+ expect(grid.fieldContainerClass).toBe(CONTAINER_GRID_COLS[cols]);
+ const fields = [f(), f({ name: 'y', span: 'full' }), f({ name: 'z', colSpan: 2 })];
+ for (const s of sections) {
+ expect(grid.layOut(fields, s)).toEqual(applyAutoColSpan(fields, cols, s));
+ }
+ }
+ });
+
+ it('leaves a one-column form alone', () => {
+ const grid = sectionedGridLayout(1, [1]);
+ expect(grid.fieldContainerClass).toBeUndefined();
+ const fields = [f()];
+ expect(grid.layOut(fields, 1)).toBe(fields);
+ });
+
+ it('switches to twelfths when a section count is neither 1 nor the grid width', () => {
+ const grid = sectionedGridLayout(3, [2, 3, 1]);
+ expect(grid.fieldContainerClass).toBe(TWELVE_TRACK_GRID_COLS);
+ expect(grid.columns).toBe(3);
+ expect(grid.layOut([f()], 2)[0].colSpan).toBe(6);
+ expect(grid.layOut([f()], 3)[0].colSpan).toBe(4);
+ expect(grid.layOut([f()], 1)[0].colSpan).toBe(12);
+ // A section that declares nothing fills the grid, as it always did.
+ expect(grid.layOut([f()], undefined)[0].colSpan).toBe(4);
+ });
+
+ it("span: 'full' and a wide widget take the row; an authored colSpan keeps winning", () => {
+ const grid = sectionedGridLayout(3, [2, 3]);
+ expect(grid.layOut([f({ span: 'full' })], 3)[0].colSpan).toBe(12);
+ expect(grid.layOut([f({ type: 'field:textarea' })], 2)[0].colSpan).toBe(12);
+ expect(grid.layOut([f({ colSpan: 2 })], 3)[0].colSpan).toBe(8);
+ // Clamped to the section's own columns.
+ expect(grid.layOut([f({ colSpan: 4 })], 2)[0].colSpan).toBe(12);
+ });
+
+ it('clamps a section count to the form grid — the form view outranks it', () => {
+ const grid = sectionedGridLayout(2, [4]);
+ expect(grid.fieldContainerClass).toBe(CONTAINER_GRID_COLS[2]);
+ });
+
+ it('never touches a section-divider row', () => {
+ const divider = { name: '__s', type: 'section-divider', colSpan: 4 } as unknown as FormField;
+ expect(sectionedGridLayout(3, [2, 3]).layOut([divider], 2)[0]).toBe(divider);
+ });
+});
diff --git a/packages/plugin-form/src/autoLayout.ts b/packages/plugin-form/src/autoLayout.ts
index f97afabe20..2635fef127 100644
--- a/packages/plugin-form/src/autoLayout.ts
+++ b/packages/plugin-form/src/autoLayout.ts
@@ -328,6 +328,109 @@ export function containerGridColsFor(columns: number | undefined): string | unde
return CONTAINER_GRID_COLS[cols];
}
+/**
+ * The field grid of a sectioned form whose sections do not all fit one grid
+ * (objectui#12548): one column below `@md`, twelve tracks from `@md` up.
+ *
+ * Twelve is the least count every authored section density divides
+ * (`@objectstack/spec` `FormSectionSchema.columns` is `1 | 2 | 3 | 4`), so in
+ * this grid a field's colSpan is in TWELFTHS of the row: 12 / 6 / 4 / 3 for a
+ * 1 / 2 / 3 / 4-column section. The form renderer draws a twelfths colSpan as
+ * one class per tier, keeping each density's step ladder from
+ * {@link CONTAINER_GRID_COLS} (at most two per row at `@md`, three at `@2xl`,
+ * four at `@4xl`); its table is `TWELVE_TRACK_SPAN_CLASS` in
+ * `@object-ui/components`' form renderer.
+ *
+ * ⛔ A literal, never assembled: Tailwind's scanner reads source text.
+ */
+export const TWELVE_TRACK_GRID_COLS = 'grid gap-4 grid-cols-1 @md:grid-cols-12';
+
+/** The field grid a sectioned host draws, and how it lays out one section's fields on it. */
+export interface SectionedGridLayout {
+ /** The form's grid width in the 1–4 vocabulary (`FormSchema.columns`). */
+ columns: number;
+ /** The field container's grid classes; `undefined` for a one-column form. */
+ fieldContainerClass: string | undefined;
+ /** Lay one section's fields out at that section's declared `columns`. */
+ layOut: (fields: FormField[], sectionColumns?: number) => FormField[];
+}
+
+/**
+ * The ONE layout rule of a sectioned form — `ModalForm`, `ObjectForm`,
+ * `SplitForm`, `TabbedForm` and `WizardForm` all call it (objectui#12548).
+ *
+ * Every section shares the form's single field grid (one form, one
+ * react-hook-form instance — #2153/#2959), and every authored section must
+ * still draw its OWN `columns` count per row: `FormSectionSchema.columns` is a
+ * per-section count, whatever the other sections declare.
+ *
+ * - When every section's count is 1 or the grid's own width, the grid is
+ * {@link CONTAINER_GRID_COLS}' and the spans are {@link applyAutoColSpan}'s,
+ * byte for byte what a single-density form always drew: on that grid a
+ * one-column section's fields take the row and a full-width section's take
+ * one cell each, which is exactly their count.
+ * - Otherwise a section's count does not fit that grid at some tier — a
+ * `columns: 2` section on a 3-wide grid spanned 2 of 3 cells, ONE field per
+ * row, and a `columns: 3` section on a 4-wide grid spanned 1 cell, FOUR per
+ * row (`Math.round(grid / sectionColumns)`). The grid becomes
+ * {@link TWELVE_TRACK_GRID_COLS} and each field spans twelfths of the row:
+ * 1. `span: 'full'`, or a wide widget type → 12, the whole row;
+ * 2. an authored `colSpan` → that many of its SECTION's columns, clamped
+ * to them (it keeps winning over the density);
+ * 3. otherwise → 12 / the section's columns.
+ *
+ * A section's count is clamped to the grid width, as it always was: the form
+ * view's own `columns` outranks how a section fills it.
+ *
+ * @param formColumns The form's grid width (form `columns`, else the widest section).
+ * @param sectionColumns The declared `columns` of every section the form draws.
+ */
+export function sectionedGridLayout(
+ formColumns: number | undefined,
+ sectionColumns: ReadonlyArray,
+): SectionedGridLayout {
+ const grid = clampGrid(formColumns);
+ const density = (declared: number | undefined) =>
+ Math.min(clampGrid(declared ?? grid), grid);
+ const fits = sectionColumns.every((c) => {
+ const d = density(c);
+ return d === 1 || d === grid;
+ });
+
+ if (fits) {
+ return {
+ columns: grid,
+ fieldContainerClass: containerGridColsFor(grid),
+ layOut: (fields, declared) =>
+ grid > 1 ? applyAutoColSpan(fields, grid, declared) : fields,
+ };
+ }
+
+ return {
+ columns: grid,
+ fieldContainerClass: TWELVE_TRACK_GRID_COLS,
+ layOut: (fields, declared) => {
+ const d = density(declared);
+ return fields.map((field) => {
+ if ((field as { type?: string }).type === 'section-divider') return field;
+ return { ...field, colSpan: twelfthsFor(field, d) };
+ });
+ },
+ };
+}
+
+/** The twelfths of a row one field spans in a `sectionColumns`-wide section. */
+function twelfthsFor(field: FormField, sectionColumns: number): number {
+ const span = (field as { span?: 'auto' | 'full' }).span;
+ if (span === 'full') return 12;
+ if (field.colSpan != null) {
+ const cells = Math.min(Math.max(1, Math.floor(field.colSpan)), sectionColumns);
+ return (12 * cells) / sectionColumns;
+ }
+ if (field.type && isWideFieldType(field.type)) return 12;
+ return 12 / sectionColumns;
+}
+
/**
* Build the layout-related props for a *section's* inner form (`SchemaRenderer`).
*