Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/12548-section-columns-per-section.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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(
<Form
schema={{
type: 'form',
mode: 'create',
showSubmit: false,
showCancel: false,
fieldContainerClass,
fields: [{ name: 'f', label: 'F', type: 'input', ...(colSpan ? { colSpan } : {}) }],
}}
/>,
);
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']);
});
});
58 changes: 57 additions & 1 deletion packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,60 @@ const BARE_SPAN_CLASS: Record<number, string> = {
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<number, string> = {
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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
filterSystemFields,
inferColumns,
inferModalSize,
sectionedGridLayout,
CONTAINER_GRID_COLS,
} from './autoLayout';
import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups';
Expand Down Expand Up @@ -834,15 +835,18 @@ export const ModalForm: React.FC<ModalFormProps> = ({
// 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
.map((s) => clampCol(s.columns))
.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.
Expand Down Expand Up @@ -872,9 +876,7 @@ export const ModalForm: React.FC<ModalFormProps> = ({
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);
Expand Down
14 changes: 10 additions & 4 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 (
<UploadGateProvider gate={uploadGate}>
Expand Down
13 changes: 6 additions & 7 deletions packages/plugin-form/src/SplitForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -500,7 +500,10 @@ export const SplitForm: React.FC<SplitFormProps> = ({
.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`
Expand Down Expand Up @@ -528,11 +531,7 @@ export const SplitForm: React.FC<SplitFormProps> = ({
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;
};
Expand Down
11 changes: 6 additions & 5 deletions packages/plugin-form/src/TabbedForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -590,7 +590,10 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
// 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);
Expand All @@ -603,9 +606,7 @@ export const TabbedForm: React.FC<TabbedFormProps> = ({
// 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)),
};
});

Expand Down
12 changes: 7 additions & 5 deletions packages/plugin-form/src/WizardForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1230,7 +1230,11 @@ export const WizardForm: React.FC<WizardFormProps> = ({
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
Expand All @@ -1251,9 +1255,7 @@ export const WizardForm: React.FC<WizardFormProps> = ({
// 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',
Expand Down
Loading
Loading