From e718b5670169c10a076084aa32b5d576b6874085 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:49:14 +0800 Subject: [PATCH] fix(form): a tabbed/split form honours the form view's own `columns` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FormView.columns is a spec key (view.zod), but only ObjectForm's simple path and ModalForm read it. TabbedForm and SplitForm derived the grid width from the per-section `columns` alone, so a view declaring `columns: 3` rendered 3 columns in a modal and single-column as a tab or split — the same metadata laying out differently depending on which host picked it up. Both now resolve the grid the way the other hosts already did: explicit form `columns` ?? widest section's `columns` ?? 1 The two keys answer different questions and the precedence reflects that: the view's `columns` is how wide the grid is, a section's `columns` is how densely that section fills it (via per-field colSpan). `columns` is declared on TabbedFormSchema / SplitFormSchema accordingly — ObjectForm already spread it through, it was simply being dropped on arrival. WizardForm is the remaining host that ignores it; left out deliberately, since a wizard's grid is per-STEP and needs the density/width split introduced properly rather than a one-line precedence swap. Co-Authored-By: Claude Opus 5 --- .changeset/form-view-columns-every-host.md | 21 +++++ content/docs/plugins/plugin-form.mdx | 11 +++ packages/plugin-form/README.md | 18 ++++ packages/plugin-form/src/SplitForm.tsx | 25 ++++-- packages/plugin-form/src/TabbedForm.tsx | 16 +++- .../plugin-form/src/sectionColumns.test.tsx | 88 ++++++++++++++++++- 6 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 .changeset/form-view-columns-every-host.md diff --git a/.changeset/form-view-columns-every-host.md b/.changeset/form-view-columns-every-host.md new file mode 100644 index 000000000..9ccce1ead --- /dev/null +++ b/.changeset/form-view-columns-every-host.md @@ -0,0 +1,21 @@ +--- +"@object-ui/plugin-form": patch +--- + +fix(form): a tabbed/split form honours the form view's own `columns` + +`FormView.columns` is a spec key, but only `ObjectForm`'s simple path and +`ModalForm` read it. `TabbedForm` and `SplitForm` derived the grid width from the +per-section `columns` alone, so a view declaring `columns: 3` rendered 3 columns +in a modal and **single-column** as a tab or split — the same metadata laying out +differently depending on which host picked it up. + +Both now resolve the grid the way the other hosts already did: + + explicit form `columns` ?? widest section's `columns` ?? 1 + +The two keys answer different questions and the precedence reflects that: the +view's `columns` is how wide the grid is, a section's `columns` is how densely +that section fills it (via per-field `colSpan`). `columns` is declared on +`TabbedFormSchema` / `SplitFormSchema` accordingly — `ObjectForm` already spread +it through, it was simply being dropped on arrival. diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 6b2cf498f..75beca9a2 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -62,6 +62,17 @@ interface FormField { } ``` +### Column width of a sectioned form + +A sectioned form renders as ONE grid. The form view's `columns` (spec +`FormView.columns`) sets how wide that grid is; a section's `columns` sets how +densely that section fills it. The view's value wins; without it the grid takes +the widest section's density, and without either it is single-column. + +`ObjectForm` (simple), `ModalForm`, `TabbedForm` and `SplitForm` all resolve it +that way, so the same metadata lays out identically in every host. The grid is +applied to the field container inside the form, never wrapped around the `
`. + ### Tabbed field layout (`fieldTabs`) A sectioned form stays **one** form: declare the tabs on it and the renderer diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 539e248f8..e92b5328a 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -83,6 +83,24 @@ interface FormField { } ``` +### Column width of a sectioned form + +A sectioned form renders as ONE grid, and two keys decide its shape: + +| Key | Meaning | +|---|---| +| the form view's `columns` | how wide the grid is (1–4) — spec `FormView.columns` | +| a section's `columns` | how densely THAT section fills the grid (via per-field `colSpan`) | + +The view's `columns` wins; without it the grid takes the widest section's +density, and without either it is single-column. Every sectioned host resolves it +the same way — `ObjectForm` (simple), `ModalForm`, `TabbedForm`, `SplitForm` — +so one piece of metadata lays out identically wherever it is rendered. + +The grid is applied to the field container **inside** the form, never wrapped +around the `` (which would put the whole form in cell 1 and leave the other +columns empty — #2128). + ### Tabbed field layout (`fieldTabs`) A sectioned form is **one** form. Instead of rendering a form per section — which diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index 233565943..6accee80c 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -71,6 +71,14 @@ export interface SplitFormSchema { */ splitResizable?: boolean; + /** + * Grid width for the whole form (1–4). Aligns with @objectstack/spec + * FormView.columns and OUTRANKS the per-section `columns`, which say how a + * section fills the grid rather than how wide it is. Omitted = the widest + * section's density. + */ + columns?: number; + // Common form props showSubmit?: boolean; submitText?: string; @@ -246,17 +254,22 @@ export const SplitForm: React.FC = ({ ); } - // The form is ONE grid, as wide as the widest section; each section then lays - // ITS fields out at its own declared density within that grid via colSpan - // (#2578) — the same arrangement the stacked/tabbed sectioned forms use. The - // grid lives on the FIELD container inside the form, never wrapped around the - // form (that leaves the extra columns permanently empty, #2128). + // The form is ONE grid; each section then lays ITS fields out at its own + // declared density within that grid via colSpan (#2578) — the same arrangement + // the stacked/tabbed sectioned forms use. The grid lives on the FIELD + // container inside the form, never wrapped around the form (that leaves the + // extra columns permanently empty, #2128). + // + // Grid width: the form view's own `columns` first (spec FormView.columns), + // else the widest section. Same precedence ObjectForm's simple path and + // ModalForm use, so one piece of metadata lays out the same in every host. const clampCol = (n: unknown): number | undefined => typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; const declaredCols = schema.sections .map((s) => clampCol(s.columns)) .filter((c): c is number => c != null); - const formColumns = (declaredCols.length ? Math.max(...declaredCols) : 1) as 1 | 2 | 3 | 4; + const formColumns = (clampCol(schema.columns) + ?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4; const containerFieldClass = containerGridColsFor(formColumns); /** diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index f659cea09..4f8dc6dcb 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -86,11 +86,19 @@ export interface TabbedFormSchema { */ sections: FormSectionConfig[]; + /** + * Grid width for the whole form (1–4). Aligns with @objectstack/spec + * FormView.columns and OUTRANKS the per-section `columns`, which say how a + * section fills the grid rather than how wide it is. Omitted = the widest + * section's density. + */ + columns?: number; + /** * Default active tab (section name) */ defaultTab?: string; - + /** * Tab position * @default 'top' @@ -347,7 +355,11 @@ export const TabbedForm: React.FC = ({ const declaredCols = schema.sections .map((s) => clampCol(s.columns)) .filter((c): c is number => c != null); - const formColumns = declaredCols.length ? Math.max(...declaredCols) : 1; + // Grid width: the form view's own `columns` first (spec FormView.columns), + // else the widest section. Same precedence ObjectForm's simple path and + // 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); const tabGroups = schema.sections.map((section, index) => { diff --git a/packages/plugin-form/src/sectionColumns.test.tsx b/packages/plugin-form/src/sectionColumns.test.tsx index f93d4cebd..8122efd4b 100644 --- a/packages/plugin-form/src/sectionColumns.test.tsx +++ b/packages/plugin-form/src/sectionColumns.test.tsx @@ -22,11 +22,13 @@ * lone — can't silently regress. */ -import { describe, it, expect, vi } from 'vitest'; -import { render, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; import React from 'react'; import { registerAllFields } from '@object-ui/fields'; import { ModalForm } from './ModalForm'; +import { TabbedForm } from './TabbedForm'; +import { SplitForm } from './SplitForm'; import { sectionFormLayout } from './autoLayout'; registerAllFields(); @@ -67,6 +69,88 @@ const makeDataSource = (): any => ({ findOne: vi.fn(), }); +afterEach(() => { + cleanup(); +}); + +/** + * The form view's OWN `columns` is a spec key (view.zod FormView.columns), so a + * sectioned host must honour it — and it OUTRANKS the per-section densities, + * which describe how a section fills the grid rather than how wide the grid is. + * + * `ObjectForm` (simple path) and `ModalForm` already resolved it as + * `explicit form columns ?? widest section ?? 1`. `TabbedForm` and `SplitForm` + * read only the sections, so a view that declared `columns: 3` silently + * rendered single-column in a tabbed or split form while the same metadata gave + * 3 columns in a modal. + */ +describe('view-level `columns` outranks per-section columns', () => { + const SECTIONS = [ + { name: 's1', label: 'One', fields: ['a', 'b'] }, + { name: 's2', label: 'Two', fields: ['c', 'd'] }, + ]; + + it('TabbedForm honours the form view’s columns', async () => { + render( + , + ); + + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull(); + }); + + it('SplitForm honours the form view’s columns', async () => { + render( + , + ); + + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).not.toBeNull(); + }); + + it('falls back to the widest section when the view declares no columns', async () => { + render( + , + ); + + await waitFor(() => expect(document.body.querySelector('form')).toBeTruthy()); + expect(document.body.querySelector('[class*="@md:grid-cols-2"]')).not.toBeNull(); + expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).toBeNull(); + }); +}); + describe('ModalForm — grouped section multi-column layout (#2128)', () => { it('renders a 2-column section grid whose direct children are the fields, not a lone ', async () => { render(