diff --git a/.changeset/12592-undeclared-section-density.md b/.changeset/12592-undeclared-section-density.md new file mode 100644 index 0000000000..9f93653b5c --- /dev/null +++ b/.changeset/12592-undeclared-section-density.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-form': patch +--- + +A form section that declares no `columns` now draws two fields per row from the container's `@md` width (one below it) on every page form host — `ObjectForm`, `SplitForm`, `TabbedForm` and `WizardForm` — as the record dialog already did (objectui#12592). Before this, `ObjectForm` sized it by field count and the other three drew it one per row, or at the widest declared count. `@objectstack/spec` no longer defaults a section's `columns` to `1`, so this reaches every section an author left undeclared, a field-group section included. The rule lives once, in the shared sectioned-form layout, and a declared `columns` (`1` included) is drawn as written. + +Clause-② reading: no. Only how many columns an undeclared section draws changes; no published type, prop, export, schema read or pack key changes, and the ruling on objectui#12521 makes an undeclared density the renderer's choice by available width. diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx index 39a080aac4..ae780c8245 100644 --- a/packages/plugin-form/src/ModalForm.tsx +++ b/packages/plugin-form/src/ModalForm.tsx @@ -49,8 +49,10 @@ import { filterSystemFields, inferColumns, inferModalSize, + sectionedFormColumns, sectionedGridLayout, CONTAINER_GRID_COLS, + UNDECLARED_SECTION_COLUMNS, } from './autoLayout'; import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups'; import { resolveSectionGroupReferences } from './sectionGroups'; @@ -284,56 +286,23 @@ const modalSizeClasses: Record = { * the whole row. Line items never reach this grid: they arrive as `subforms`, * which the master-detail arm below lays out across the dialog. * - * It applies to a flat form with no `columns`, to the object's `fieldGroups`, - * and to every SECTION that declares no `columns` while the form declares - * none either: an undeclared section density is the renderer's choice, and a - * declared one — `1` included — is drawn as written (the section-`columns` - * fork on objectui#12521, ruled 6204264665). + * It applies to a flat form with no `columns`. A SECTION that declares no + * `columns` — the object's `fieldGroups` among them — gets the same count + * through the one sectioned-form rule, `sectionedGridLayout` + * (`UNDECLARED_SECTION_COLUMNS`), which every page host shares + * (objectui#12592): an undeclared section density is the renderer's choice, + * and a declared one — `1` included — is drawn as written (the + * section-`columns` fork on objectui#12521, ruled 6204264665). * - * ⚠️ Until `@objectstack/spec`'s `FormSectionSchema.columns` drops its - * `default(1)` (objectstack-ai/objectstack#23740), the spec's parse writes - * `columns: 1` into every section that omitted it — `defineView` and - * `defineStack` both return the parsed form — so a section from an authored - * stack reaches this dialog DECLARED and draws one column. The undeclared - * rule then covers only sections that arrive unparsed. ⛔ Never read a - * declared `1` as undeclared to get ahead of that release. - * - * Dialog-only on purpose: a page form, a drawer and the detail view keep - * `inferColumns`, which they mirror between them. + * The flat default is dialog-only on purpose: a flat page form, a drawer and + * the detail view keep `inferColumns`, which they mirror between them. */ -const DIALOG_DEFAULT_COLUMNS = 2; +const DIALOG_DEFAULT_COLUMNS = UNDECLARED_SECTION_COLUMNS; /** Clamp an authored column count into the 1–4 grid keys; anything else reads as not authored. */ const clampColumns = (n: unknown): number | undefined => typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; -/** - * One section's fields per row in the dialog: its declared `columns` as - * written, else the form's declared `columns` (what an undeclared section - * always filled), else {@link DIALOG_DEFAULT_COLUMNS}. - */ -const dialogSectionColumns = (sectionColumns: unknown, formColumns: unknown): number => - clampColumns(sectionColumns) ?? clampColumns(formColumns) ?? DIALOG_DEFAULT_COLUMNS; - -/** - * The ONE grid width of a sectioned dialog: the form's own `columns`, else the - * widest section's {@link dialogSectionColumns}. Read by the stacked and - * tabbed layouts and by the dialog's derived size, so the size is chosen for - * the grid that renders. Each section then draws its own count on it through - * `sectionedGridLayout`, which switches to the 12-track grid when the counts - * do not all fit (objectui#12548) — so an undeclared section beside a - * `columns: 3` one draws two per row, not three. - */ -function sectionedDialogColumns( - sections: ReadonlyArray<{ columns?: unknown }>, - formColumns: unknown, -): 1 | 2 | 3 | 4 { - const widest = sections.length - ? Math.max(...sections.map((s) => dialogSectionColumns(s.columns, formColumns))) - : DIALOG_DEFAULT_COLUMNS; - return (clampColumns(formColumns) ?? widest) as 1 | 2 | 3 | 4; -} - export const ModalForm: React.FC = ({ schema, dataSource, @@ -465,7 +434,7 @@ export const ModalForm: React.FC = ({ const effectiveModalSize = useMemo(() => { if (schema.modalSize) return schema.modalSize; const columns = effectiveSections?.length - ? sectionedDialogColumns(effectiveSections, schema.columns) + ? sectionedFormColumns(schema.columns, effectiveSections.map((s) => s.columns)) : (autoLayoutResult?.columns ?? DIALOG_DEFAULT_COLUMNS); const fieldCount = effectiveSections?.length ? effectiveSections.reduce((n, s) => n + (Array.isArray(s.fields) ? s.fields.length : 0), 0) @@ -903,15 +872,14 @@ export const ModalForm: React.FC = ({ sec.name ? sectionLabel(schema.objectName, sec.name, sec.label || sec.name) : sec.label; // The form is ONE grid. Its width is the explicit form `columns`, else the - // widest section's count, an undeclared section counting the dialog's - // default (objectui#12521); each section then lays ITS fields out at its + // widest section's count, an undeclared section counting two + // (objectui#12521, #12592); each section then lays ITS fields out at its // own count within that grid via colSpan (objectstack-ai/objectstack#2578), exactly like the // 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 sectionColumnsOf = (sec: ModalFormSectionConfig) => dialogSectionColumns(sec.columns, schema.columns); - const formColumns = sectionedDialogColumns(sections, schema.columns); - const sectionGrid = sectionedGridLayout(formColumns, sections.map(sectionColumnsOf)); + // the widest one (objectui#12548). It is handed the AUTHORED counts. + const sectionGrid = sectionedGridLayout(schema.columns, sections.map((sec) => sec.columns)); + const formColumns = sectionGrid.columns; const containerFieldClass = sectionGrid.fieldContainerClass; // FLS first, so a section whose every field is non-readable drops out @@ -942,7 +910,7 @@ export const ModalForm: React.FC = ({ visibleWhen: section.visibleWhen, className: section.className, gridClassName: section.gridClassName, - fields: sectionGrid.layOut(body, sectionColumnsOf(section)), + fields: sectionGrid.layOut(body, 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 71d19d3d5d..e743bddfe4 100644 --- a/packages/plugin-form/src/ObjectForm.tsx +++ b/packages/plugin-form/src/ObjectForm.tsx @@ -46,7 +46,6 @@ import { applyAutoLayout, filterAutoGeneratedFields, filterSystemFields, - inferColumns, sectionedGridLayout, } from './autoLayout'; import { deriveFieldGroupSections, projectSectionDivider, resolveSectionCollapse } from './fieldGroups'; @@ -1632,28 +1631,17 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource const sourceFields = fieldGroupSections ? groupableFields : formFields; // objectstack-ai/objectstack#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)); - // 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. + // declared density within that grid. The one sectioned-form rule + // (objectui#12548) is handed the AUTHORED counts and resolves the rest: grid + // width = explicit form `columns`, else the widest section, a section that + // declares nothing — a fieldGroup-derived one included — drawing two per + // row from `@md`, as the dialog does (objectui#12592). 12 tracks wide when + // the counts do not all fit. const sectionGrid = sectionedGridLayout( - formColumns, - effectiveSections.map(s => clampCol((s as any).columns)), + schema.columns, + effectiveSections.map(s => (s as any).columns), ); + const formColumns = sectionGrid.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 @@ -1783,8 +1771,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 = sectionGrid.layOut(sectionFields, secCols); + const laid = sectionGrid.layOut(sectionFields, (section as any).columns); // Collapsed groups keep their fields registered (values preserved) but // hidden from the DOM. A section with no row is never collapsible, so it diff --git a/packages/plugin-form/src/SplitForm.tsx b/packages/plugin-form/src/SplitForm.tsx index 54da7c5070..d881a63fd7 100644 --- a/packages/plugin-form/src/SplitForm.tsx +++ b/packages/plugin-form/src/SplitForm.tsx @@ -490,19 +490,15 @@ export const SplitForm: React.FC = ({ // 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 = (clampCol(schema.columns) - ?? (declaredCols.length ? Math.max(...declaredCols) : 1)) as 1 | 2 | 3 | 4; - // 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))); + // The one sectioned-form rule (objectui#12548), handed the AUTHORED counts: + // grid width = the form view's own `columns` (spec FormView.columns), else + // the widest section, an undeclared section drawing two per row from `@md` + // (objectui#12592); each section at its own count on the shared grid — 12 + // tracks wide when the counts do not all fit. ModalForm, ObjectForm, + // TabbedForm and WizardForm call the same rule, so one piece of metadata + // lays out the same in every host. + const sectionGrid = sectionedGridLayout(schema.columns, schema.sections.map((s) => s.columns)); + const formColumns = sectionGrid.columns; const containerFieldClass = sectionGrid.fieldContainerClass; /** @@ -531,7 +527,7 @@ export const SplitForm: React.FC = ({ className: section.className, } as any); } - out.push(...sectionGrid.layOut(body, clampCol(section.columns))); + out.push(...sectionGrid.layOut(body, section.columns)); }); return out; }; diff --git a/packages/plugin-form/src/TabbedForm.tsx b/packages/plugin-form/src/TabbedForm.tsx index 1152e89e8d..07196060e0 100644 --- a/packages/plugin-form/src/TabbedForm.tsx +++ b/packages/plugin-form/src/TabbedForm.tsx @@ -580,19 +580,14 @@ export const TabbedForm: React.FC = ({ // carry their own colSpan against the shared grid (sectionFormLayout parity), // never a grid wrapped around the form — that would leave the extra columns // empty (#2128). - 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); - // 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); - // 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))); + // The one sectioned-form rule (objectui#12548), handed the AUTHORED counts: + // grid width = the form view's own `columns` (spec FormView.columns), else + // the widest section, an undeclared tab drawing two per row from `@md` + // (objectui#12592); each tab at its own count on the shared grid — 12 tracks + // wide when the counts do not all fit. Every sectioned host calls the same + // rule, so one piece of metadata lays out the same in every host. + const sectionGrid = sectionedGridLayout(schema.columns, schema.sections.map((s) => s.columns)); + const formColumns = sectionGrid.columns; const containerFieldClass = sectionGrid.fieldContainerClass; const tabGroups = schema.sections.map((section, index) => { @@ -606,7 +601,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: sectionGrid.layOut(body, clampCol(section.columns)), + fields: sectionGrid.layOut(body, section.columns), }; }); diff --git a/packages/plugin-form/src/WizardForm.tsx b/packages/plugin-form/src/WizardForm.tsx index 0d0e1eef3e..b95293f29a 100644 --- a/packages/plugin-form/src/WizardForm.tsx +++ b/packages/plugin-form/src/WizardForm.tsx @@ -1227,13 +1227,13 @@ export const WizardForm: React.FC = ({ } const currentSection = schema.sections[currentStep]; - 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; // 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)]); + // step draws, handed the AUTHORED counts: 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, and a step that declares nothing draws two per row from `@md`, as + // the dialog does (objectui#12592). + const stepGrid = sectionedGridLayout(schema.columns, [currentSection?.columns]); + const stepGridColumns = stepGrid.columns; const stepFieldContainerClass = stepGrid.fieldContainerClass; // The current step's child `form` node, typed as the node it is @@ -1255,7 +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: stepGrid.layOut(currentSectionFields, clampCol(currentSection?.columns)), + fields: stepGrid.layOut(currentSectionFields, 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 index f49f3ba337..0cc6fc89d9 100644 --- a/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx +++ b/packages/plugin-form/src/__tests__/mixedSectionColumns-12548.test.tsx @@ -296,10 +296,23 @@ describe('objectui#12548 — sectionedGridLayout (the one rule)', () => { 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. + // A section that declares nothing fills the form's DECLARED grid, as it + // always did; with no form `columns` it draws two (objectui#12592). expect(grid.layOut([f()], undefined)[0].colSpan).toBe(4); }); + it('an undeclared section draws two when the form declares no columns (objectui#12592)', () => { + expect(sectionedGridLayout(undefined, [undefined, undefined]).fieldContainerClass).toBe( + CONTAINER_GRID_COLS[2], + ); + const mixed = sectionedGridLayout(undefined, [undefined, 3]); + expect(mixed.columns).toBe(3); + expect(mixed.fieldContainerClass).toBe(TWELVE_TRACK_GRID_COLS); + expect(mixed.layOut([f()], undefined)[0].colSpan).toBe(6); + // A declared 1 is drawn as written, never read as undeclared. + expect(sectionedGridLayout(undefined, [1, 1]).fieldContainerClass).toBeUndefined(); + }); + 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); diff --git a/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx b/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx new file mode 100644 index 0000000000..54ee9ef742 --- /dev/null +++ b/packages/plugin-form/src/__tests__/undeclaredSectionDensity-12592.test.tsx @@ -0,0 +1,212 @@ +/** + * 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#12592 — a section that declares no `columns` draws on every page + * host as it does in the dialog: two per row from the container's `@md` tier, + * one below it. + * + * `@objectstack/spec` dropped `FormSectionSchema.columns`' `default(1)` + * (objectstack-ai/objectstack#23740), so an undeclared section reaches every + * sectioned host. The fork on objectui#12521 (ruled 6204264665) made an + * undeclared density the renderer's choice by available width; `ModalForm` + * drew it two per row, while `ObjectForm` drew it by field count + * (`inferColumns`) and `SplitForm`, `TabbedForm` and `WizardForm` one per row + * or at the widest declared count. The rule now lives once, in + * `sectionedGridLayout` (`autoLayout.ts`), and every host hands it the authored + * counts. A declared `columns` — `1` included — is drawn as written. + * + * These pins read the class attributes the real renderer draws, per host, and + * compare each page host with the dialog, 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 '@object-ui/components'; +import { ModalForm } from '../ModalForm'; +import { ObjectForm } from '../ObjectForm'; +import { SplitForm } from '../SplitForm'; +import { TabbedForm } from '../TabbedForm'; +import { WizardForm } from '../WizardForm'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const MORE = Array.from({ length: 12 }, (_, i) => `x${i}`); +const FIELD_NAMES = ['c1', 'c2', 'c3', 'c4', 'full', 'q1', 'q2', 'q3', ...MORE]; + +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[] }; +type Host = 'modal' | 'object' | 'split' | 'tabbed' | 'wizard'; + +const PAGE_HOSTS: Exclude[] = ['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()); +} + +// A field's count per row at a container tier: (tracks in force) / (its span +// in force). Tailwind's prefixes are min-width, so the class in force at a +// tier is the widest-prefixed one at or below it; below `@md` the base class. +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 ladder = (read: { grid: string[]; span: string[] }) => TIERS.map((t) => perRowAt(read, t)); + +/** What the dialog draws for `name` under `sections` — the reference every page host must match. */ +async function dialogReading(sections: Section[], name: string) { + mount('modal', sections); + await settle(name); + const read = readField(name); + cleanup(); + return read; +} + +/** Two undeclared sections. 16 fields, so `inferColumns` would have said four. */ +const UNDECLARED: Section[] = [ + { name: 'contact', label: 'Contact', fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3', 'c4'] }, + { name: 'more', label: 'More', fields: ['q1', ...MORE] }, +]; + +/** Undeclared first (the wizard's first step), then a declared `columns: 3`. */ +const MIXED: Section[] = [ + { name: 'contact', label: 'Contact', fields: ['c1', 'c2', { field: 'full', span: 'full' }, 'c3', 'c4'] }, + { name: 'classification', label: 'Classification', columns: 3, fields: ['q1', 'q2', 'q3'] }, +]; + +describe('objectui#12592 — an undeclared section draws as the dialog does, on every page host', () => { + for (const host of PAGE_HOSTS) { + it(`${host}: every section undeclared → two per row from @md, one below`, async () => { + const reference = await dialogReading(UNDECLARED, 'c1'); + mount(host, UNDECLARED); + await settle('c1'); + const c1 = readField('c1'); + + expect(c1.grid, 'one column below @md').toContain('grid-cols-1'); + expect(ladder(c1), '@md / @2xl / @4xl').toEqual([2, 2, 2]); + expect(perRowAt(readField('full'), '@4xl'), "span: 'full' takes the row").toBe(1); + // Byte for byte the dialog's classes, not merely the same count. + expect(c1).toEqual(reference); + }); + + it(`${host}: an undeclared section beside a declared columns: 3 one still draws two`, async () => { + const reference = await dialogReading(MIXED, 'c1'); + mount(host, MIXED); + await settle('c1'); + const c1 = readField('c1'); + expect(ladder(c1)).toEqual([2, 2, 2]); + expect(ladder(c1)).toEqual(ladder(reference)); + if (host === 'wizard') { + // A wizard draws one step per grid, so the step never shares the + // dialog's 12-track grid with its declared neighbour: same count per + // row, on the plain two-column grid. + expect(c1.grid).toEqual(['grid-cols-1', '@md:grid-cols-2']); + } else { + expect(c1).toEqual(reference); + // The declared neighbour keeps its own count. + expect(ladder(readField('q1'))).toEqual([2, 3, 3]); + } + }); + } +}); + +/** + * CONTROL — a declared `columns` is drawn as written on every host, `1` + * included: the undeclared rule never reaches a section that declares. + */ +describe('objectui#12592 CONTROL — declared sections keep their declared count', () => { + const ONES: Section[] = [ + { name: 'contact', label: 'Contact', columns: 1, fields: ['c1', 'c2', 'c3', 'c4'] }, + { name: 'more', label: 'More', columns: 1, fields: ['q1', ...MORE] }, + ]; + const THREES: Section[] = [ + { name: 'contact', label: 'Contact', columns: 3, fields: ['c1', 'c2', 'c3', 'c4'] }, + { name: 'more', label: 'More', columns: 3, fields: ['q1', 'q2', 'q3'] }, + ]; + for (const host of PAGE_HOSTS) { + it(`${host}: columns: 1 stays one per row`, async () => { + mount(host, ONES); + await settle('c1'); + expect(ladder(readField('c1'))).toEqual([1, 1, 1]); + }); + + it(`${host}: columns: 3 keeps its 2 → 3 ladder`, async () => { + mount(host, THREES); + await settle('c1'); + expect(ladder(readField('c1'))).toEqual([2, 3, 3]); + }); + + it(`${host}: a declared form columns fills an undeclared section, as it always did`, async () => { + mount(host, UNDECLARED, { columns: 3 }); + await settle('c1'); + expect(ladder(readField('c1'))).toEqual([2, 3, 3]); + }); + } +}); diff --git a/packages/plugin-form/src/autoLayout.ts b/packages/plugin-form/src/autoLayout.ts index afd645b53a..d240f93c97 100644 --- a/packages/plugin-form/src/autoLayout.ts +++ b/packages/plugin-form/src/autoLayout.ts @@ -345,14 +345,60 @@ export function containerGridColsFor(columns: number | undefined): string | unde */ export const TWELVE_TRACK_GRID_COLS = 'grid gap-4 grid-cols-1 @md:grid-cols-12'; +/** + * How many fields per row a section draws where neither it nor its form + * declares `columns` (objectui#12592): two from the container's `@md` tier up, + * one below it, so a phone keeps its single column. + * + * An undeclared density is the renderer's choice by available width, and a + * declared one — `1` included — is drawn as written (the section-`columns` + * fork on objectui#12521, ruled 6204264665; `@objectstack/spec` dropped + * `FormSectionSchema.columns`' `default(1)` in objectstack-ai/objectstack#23740, + * so an undeclared section reaches every host). The container query makes + * "available width" literal: the same section draws one per row in a narrow + * drawer or on a phone, and two in a dialog or a page. + */ +export const UNDECLARED_SECTION_COLUMNS = 2; + +/** An authored column count clamped into the 1–4 grid keys; anything else reads as not declared. */ +function declaredColumns(n: unknown): number | undefined { + return typeof n === 'number' && n > 0 ? Math.min(Math.floor(n), 4) : undefined; +} + +/** + * One section's fields per row: its declared `columns` as written, else the + * form's declared `columns` (what an undeclared section always filled), else + * {@link UNDECLARED_SECTION_COLUMNS}. + */ +export function sectionColumnsFor(sectionColumns: unknown, formColumns: unknown): number { + return declaredColumns(sectionColumns) ?? declaredColumns(formColumns) ?? UNDECLARED_SECTION_COLUMNS; +} + +/** + * The ONE grid width of a sectioned form: the form's declared `columns`, else + * the widest section's {@link sectionColumnsFor}. + * + * @param formColumns The form's DECLARED `columns` (`undefined` when none). + * @param sectionColumns The DECLARED `columns` of every section the form draws. + */ +export function sectionedFormColumns( + formColumns: unknown, + sectionColumns: ReadonlyArray, +): 1 | 2 | 3 | 4 { + const widest = sectionColumns.length + ? Math.max(...sectionColumns.map((c) => sectionColumnsFor(c, formColumns))) + : UNDECLARED_SECTION_COLUMNS; + return (declaredColumns(formColumns) ?? widest) as 1 | 2 | 3 | 4; +} + /** 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; + columns: 1 | 2 | 3 | 4; /** 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[]; + /** Lay one section's fields out at that section's DECLARED `columns` (`undefined` when none). */ + layOut: (fields: FormField[], sectionColumns?: unknown) => FormField[]; } /** @@ -382,16 +428,22 @@ export interface SectionedGridLayout { * 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. + * Every host hands it what the AUTHOR wrote, never a count it resolved itself + * (objectui#12592): an undeclared section and an undeclared form resolve here, + * through {@link sectionColumnsFor} and {@link sectionedFormColumns}, so a + * section that declares nothing draws {@link UNDECLARED_SECTION_COLUMNS} per + * row in the dialog and on every page host alike. + * + * @param formColumns The form's DECLARED `columns` (`undefined` when none). + * @param sectionColumns The DECLARED `columns` of every section the form draws. */ export function sectionedGridLayout( - formColumns: number | undefined, - sectionColumns: ReadonlyArray, + formColumns: unknown, + sectionColumns: ReadonlyArray, ): SectionedGridLayout { - const grid = clampGrid(formColumns); - const density = (declared: number | undefined) => - Math.min(clampGrid(declared ?? grid), grid); + const grid = sectionedFormColumns(formColumns, sectionColumns); + const density = (declared: unknown) => + Math.min(sectionColumnsFor(declared, formColumns), grid); const fits = sectionColumns.every((c) => { const d = density(c); return d === 1 || d === grid; @@ -402,7 +454,7 @@ export function sectionedGridLayout( columns: grid, fieldContainerClass: containerGridColsFor(grid), layOut: (fields, declared) => - grid > 1 ? applyAutoColSpan(fields, grid, declared) : fields, + grid > 1 ? applyAutoColSpan(fields, grid, density(declared)) : fields, }; }