- {fields.map((field: FormFieldConfig) => {
- const {
- name,
- label,
- description,
- type = 'input',
- required: staticRequired = false,
- disabled: fieldDisabled = false,
- validation = {},
- condition,
- colSpan,
- hidden,
- widget,
- visibleOn,
- readonly: staticReadonly,
- visibleWhen,
- readonlyWhen,
- requiredWhen,
- ...fieldProps
- } = field;
-
- // Skip hidden fields
- if (hidden) return null;
-
- // Legacy `condition: { field, equals/notEquals/in }` — translated
- // to CEL and evaluated on the canonical engine over the seeded
- // live record (issue #1584), so it agrees with `visibleWhen` and
- // the server. Fail-open (a broken predicate shows the field),
- // matching the CEL rules below.
- const legacyConditionCel = legacyConditionToCel(condition);
- if (legacyConditionCel && !evalFieldPredicate(legacyConditionCel, ruleRecord, true)) {
- return null;
- }
-
- // Field-level CEL conditional rules (B2). Evaluated reactively
- // against the live record via the canonical engine — same
- // dialect the server enforces (requiredWhen / readonlyWhen), so
- // the UX and the persisted verdict agree. A field with no rules
- // resolves to its static flags unchanged.
- const ruleState = resolveFieldRuleState(
- { visibleWhen, readonlyWhen, requiredWhen },
- ruleRecord,
- { required: staticRequired, readonly: staticReadonly === true },
- );
- if (!ruleState.visible) return null;
-
- // View-level conditional visibility — spec FormField.visibleOn,
- // authored on the form view (not the object field). Same
- // canonical CEL engine and record scope as visibleWhen; both
- // the bare-string and `{ dialect, source }` wire shapes are
- // accepted, and a broken predicate fails open (#2212).
- if (visibleOn != null && !evalFieldPredicate(visibleOn, ruleRecord, true)) {
- return null;
- }
- const required = ruleState.required;
- const readonly = ruleState.readonly;
-
- // Section divider — renders a collapsible FormSection header inline
- // so all fields share the same form instance (enables cross-section conditions).
- if (type === 'section-divider') {
- const fp = fieldProps as any;
- return (
-
- );
- }
-
- // Build validation rules
- const rules: any = {
- ...validation,
- };
-
- if (required) {
- rules.required = typeof validation.required === 'string'
- ? validation.required
- : t('validation.required', { field: label || name });
- }
-
- // Localize the standard validation messages emitted by
- // buildValidationRules. Each such rule carries a `messageKey`
- // and leaves `message` undefined for the auto-generated case
- // (a field-authored message is a string and is left untouched);
- // we fill the blanks through i18n so they track the label's
- // language. A fresh object avoids mutating the shared rule.
- const localizeRule = (
- rule: any,
- interp?: (r: any) => Record
,
- ) => {
- if (
- rule && typeof rule === 'object' &&
- rule.message == null && typeof rule.messageKey === 'string'
- ) {
- return {
- ...rule,
- message: t(rule.messageKey, { field: label || name, ...(interp?.(rule)) }),
- };
- }
- return rule;
- };
- if (rules.minLength) rules.minLength = localizeRule(rules.minLength, r => ({ min: r.value }));
- if (rules.maxLength) rules.maxLength = localizeRule(rules.maxLength, r => ({ max: r.value }));
- if (rules.min) rules.min = localizeRule(rules.min, r => ({ min: r.value }));
- if (rules.max) rules.max = localizeRule(rules.max, r => ({ max: r.value }));
- if (rules.pattern) rules.pattern = localizeRule(rules.pattern);
-
- // Use field.id or field.name for stable keys (never use index alone)
- const fieldKey = field.id ?? name;
-
- // Resolve the component type: prefer an explicit form-config
- // `widget`, then the object-schema field's own `widget` render
- // hint (carried on the field metadata, e.g. `object-ref`,
- // `filter-condition`, `recipient-picker`), then the bare `type`.
- // The nested-metadata fallback matters because some field
- // builders (e.g. the field-group/section layout path in
- // ObjectForm) pass the field metadata through without hoisting
- // its `widget` to the top-level form-config, which would
- // otherwise degrade a picker field to its raw `type` input.
- const resolvedType = widget || (fieldProps as any).field?.widget || type;
-
- // Cascading / role-gated option lists (#2284). For option fields,
- // narrow the set by each option's `visibleWhen` (evaluated against
- // the live record + `current_user`), and gate the whole control
- // while a declared `dependsOn` parent is still empty — surfacing a
- // "select the parent first" hint instead of an unfiltered list.
- const isOptionField =
- resolvedType === 'select' ||
- resolvedType === 'radio' ||
- resolvedType === 'multiselect' ||
- resolvedType === 'checkboxes';
- const rawOptions = (fieldProps as any).options as SelectOption[] | undefined;
- // Resolve gating + `visibleWhen` filtering through the shared
- // core helper so this pre-filter can't drift from the widgets'
- // `useCascadingOptions` hook (#2715). Non-option fields pass
- // their options through untouched.
- const cascade = isOptionField
- ? resolveCascadingOptions(rawOptions, ruleRecord, (field as any).dependsOn, predicateScope)
- : null;
- const optionGroupGated = cascade?.gated ?? false;
- const dependsOnFields = cascade?.dependsOnFields ?? [];
- const effectiveOptions = cascade ? cascade.options : rawOptions;
- const gatedHint = optionGroupGated
- ? `Select ${dependsOnFields.map((fn) => fieldLabelByName[fn] || fn).join(' / ')} first`
- : undefined;
-
- // colSpan classes for grid layout.
- //
- // When the container uses container-query-based grid classes
- // (e.g. `@md:grid-cols-2`), the grid's base is `grid-cols-1`
- // on narrow containers. Applying a bare `col-span-2` in that
- // state causes CSS grid to synthesize an implicit 2nd column
- // track, distorting column widths. We must mirror the same
- // container-query prefix on the col-span utilities so they
- // only engage once the grid is actually multi-column.
- // The effective container is whatever wraps the fields: either
- // `fieldContainerClass` (overrides, typically container-query based)
- // or the locally-computed `gridClass` (viewport-based).
- const containerClass = schema.fieldContainerClass || gridClass;
- // Match both container-query (`@md:`) and viewport (`md:`) prefixes.
- // Return an explicit, statically-detectable class so Tailwind JIT
- // can scan and include it.
- const pickSpanClass = (targetCols: number): string => {
- const re = /(@)?(sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl):grid-cols-(\d+)/g;
- const matches = Array.from(containerClass.matchAll(re)).map(m => ({
- at: m[1] || '',
- bp: m[2],
- cols: Number(m[3]),
- }));
- if (!matches.length) {
- // No responsive/container prefix found — bare class is safe
- // because the grid is already multi-column at all widths.
- if (targetCols === 2) return 'col-span-2';
- if (targetCols === 3) return 'col-span-3';
- return 'col-span-4';
- }
- const hit = matches.find(m => m.cols >= targetCols) || matches[matches.length - 1];
- const key = `${hit.at}${hit.bp}:${targetCols}`;
- // Explicit literal map so Tailwind JIT discovers these classes.
- const table: Record = {
- '@sm:2': '@sm:col-span-2',
- '@md:2': '@md:col-span-2',
- '@lg:2': '@lg:col-span-2',
- '@xl:2': '@xl:col-span-2',
- '@2xl:2': '@2xl:col-span-2',
- '@sm:3': '@sm:col-span-3',
- '@md:3': '@md:col-span-3',
- '@lg:3': '@lg:col-span-3',
- '@xl:3': '@xl:col-span-3',
- '@2xl:3': '@2xl:col-span-3',
- '@4xl:3': '@4xl:col-span-3',
- '@sm:4': '@sm:col-span-4',
- '@md:4': '@md:col-span-4',
- '@lg:4': '@lg:col-span-4',
- '@xl:4': '@xl:col-span-4',
- '@2xl:4': '@2xl:col-span-4',
- '@4xl:4': '@4xl:col-span-4',
- 'sm:2': 'sm:col-span-2',
- 'md:2': 'md:col-span-2',
- 'lg:2': 'lg:col-span-2',
- 'xl:2': 'xl:col-span-2',
- 'sm:3': 'sm:col-span-3',
- 'md:3': 'md:col-span-3',
- 'lg:3': 'lg:col-span-3',
- 'xl:3': 'xl:col-span-3',
- 'sm:4': 'sm:col-span-4',
- 'md:4': 'md:col-span-4',
- 'lg:4': 'lg:col-span-4',
- 'xl:4': 'xl:col-span-4',
- };
- return table[key] || (targetCols === 2 ? 'col-span-2' : targetCols === 3 ? 'col-span-3' : 'col-span-4');
- };
-
- const colSpanClass = colSpan && colSpan > 1
- ? colSpan === 2 ? pickSpanClass(2)
- : colSpan === 3 ? pickSpanClass(3)
- : colSpan >= 4 ? pickSpanClass(4)
- : ''
- : '';
-
- // Metadata-derived stable locator (ADR-0054 C4): the renderer
- // emits it from the object + field name so every generated form
- // inherits it with zero per-app work. Object prefix omitted when
- // the form schema has no owning object.
- const fieldTestId = `field:${schema.objectName ? `${schema.objectName}.` : ''}${name}`;
-
- return (
- (
-
- {label && (
-
- {label}
- {required && (
-
- *
-
- )}
-
- )}
-
- {/* Render the actual field component based on resolved type */}
- {renderFieldComponent(resolvedType, {
- ...fieldProps,
- // specialized fields needs raw metadata, but we should traverse down if it exists
- // field is the field configuration loop variable
- field: (field as any).field || field,
- ...formField,
- inputType: fieldProps.inputType,
- options: isOptionField ? effectiveOptions : fieldProps.options,
- placeholder: fieldProps.placeholder ?? (resolvedType === 'select' ? t('common.selectOption') : undefined),
- // `disabled` means "not interactive, muted"; `readonly` means
- // "shown plainly, not editable" — keep them distinct so widgets
- // that implement a real readonly display (e.g. EmailField's
- // mailto link) actually receive it instead of always collapsing
- // to the grayed-out disabled look. A dependency-gated option
- // list (#2284) is disabled until its controlling field is set.
- disabled: disabled || fieldDisabled || isSubmitting || optionGroupGated,
- readonly,
- // Gate hint shown when a dependent option list is still
- // waiting on its controlling field (#2284).
- emptyHint: gatedHint,
- dataSource: contextDataSource,
- // Live form values for dependent (cascading) lookups
- // (#2215): the widget's `dependsOn` gate + filters must
- // re-scope as the user picks the parent field in THIS
- // form, not read a stale record snapshot. Forwarded to
- // data-source widgets only (see stripRegisteredFieldProps).
- dependentValues: ruleRecord,
- })}
-
- {description && (
- {description}
- )}
-
-
- )}
- />
- );
- })}
+
+ {fields.map(renderFormField)}
)}
diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md
index 0ef4b2bbd6..607be1bd74 100644
--- a/packages/plugin-form/README.md
+++ b/packages/plugin-form/README.md
@@ -83,6 +83,37 @@ interface FormField {
}
```
+### Tabbed field layout (`fieldTabs`)
+
+A sectioned form is **one** form. Instead of rendering a form per section — which
+strands every section but the first outside the submit, and (in tabs) lets the
+inactive panel unmount with its values — declare tabs on the single form and let
+the renderer distribute the fields:
+
+```typescript
+{
+ type: 'form',
+ fields: [/* every tab's fields, in one flat list */],
+ fieldTabs: [
+ { key: 'basics', label: 'Basics', fields: ['subject', 'status'] },
+ { key: 'detail', label: 'Detail', description: 'Anything else', fields: ['description'] },
+ ],
+ defaultFieldTab?: 'basics', // defaults to the first tab
+ fieldTabsPosition?: 'top', // 'top' | 'bottom' | 'left' | 'right'
+}
+```
+
+- All panels are **force-mounted** and only CSS-hidden, so a tab the user leaves
+ keeps its values *and* its validation (react-hook-form skips unmounted fields,
+ which is how a required field on an unopened tab used to reach the server).
+- A failed submit **activates the tab holding the first offending field** and
+ marks every tab with a rejected field (`data-error` on the trigger) — for both
+ client-side rules and server `fields[]` rejections.
+- Fields no tab claims render above the tab strip rather than disappearing.
+- Needs at least two tabs, and is ignored when the form uses `children`.
+
+`ModalForm` (`contentLayout: 'tabbed'`) and `TabbedForm` are built on this.
+
## Examples
### Basic Form
diff --git a/packages/plugin-form/src/ModalForm.tsx b/packages/plugin-form/src/ModalForm.tsx
index c70f024a72..ea29d5f954 100644
--- a/packages/plugin-form/src/ModalForm.tsx
+++ b/packages/plugin-form/src/ModalForm.tsx
@@ -24,10 +24,6 @@ import {
Skeleton,
Button,
cn,
- Tabs,
- TabsList,
- TabsTrigger,
- TabsContent,
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
@@ -38,7 +34,6 @@ import {
AlertDialogCancel,
} from '@object-ui/components';
import { Loader2 } from 'lucide-react';
-import { FormSection } from './FormSection';
import { MasterDetailForm } from './MasterDetailForm';
import { SchemaRenderer, useSafeFieldLabel, usePreviewMode } from '@object-ui/react';
import { createSafeTranslation } from '@object-ui/i18n';
@@ -51,7 +46,6 @@ import {
filterSystemFields,
inferColumns,
inferModalSize,
- sectionFormLayout,
CONTAINER_GRID_COLS,
} from './autoLayout';
import { deriveFieldGroupSections } from './fieldGroups';
@@ -76,9 +70,13 @@ export interface ModalFormSectionConfig {
description?: string;
columns?: 1 | 2 | 3 | 4;
fields: (string | FormField)[];
- /** Custom CSS class for the section's Card wrapper. */
+ /** Custom CSS class for the section's header row (stacked layout). */
className?: string;
- /** Custom CSS class for the section's field grid. */
+ /**
+ * Custom CSS class for the section's field grid. Applied to the section's tab
+ * panel in the `tabbed` layout; in the stacked layout every section shares one
+ * grid inside the single form (#2959), so there is none to override.
+ */
gridClassName?: string;
}
@@ -515,14 +513,15 @@ export const ModalForm: React.FC = ({
);
}
- // Explicit sections layout (curated form view).
+ // Explicit sections layout (curated form view) — ONE form for ALL sections.
//
- // KNOWN LIMITATION: each section renders its own SchemaRenderer, i.e. its
- // own react-hook-form instance and