Layout
The layout tree starts at a FormRoot. Containers nest: the root, groups and repeating groups hold a vertical flow of children; rows hold cells; grids hold placements. Every layout property is part of the model, so the editor's preview and a production FormView render the same thing.
Constants
These bound every layout.
| Constant | Value | Used for |
|---|---|---|
gapScale | [0, 4, 8, 12, 16, 24] | The only allowed rowGap and columnGap values. |
defaultGap | 16 | Default for every gap. |
rowColumnOptions | {6, 12} | Allowed FormRow.columns and FreeGrid.columns. |
maxRowCells | 6 | Most cells in one row. |
defaultStackBelow | 600.0 | Width in px below which a row stacks. |
gridRowHeight | 88.0 | Fixed height of one grid row. Not stored in the model. |
Containers
| Node | Properties | At fill time |
|---|---|---|
FormRoot | id, children, rowGap | A vertical flow under the form's title and description. |
FormGroup | id, title, collapsible, initiallyCollapsed (only when collapsible), rowGap, children | A titled section that collapses when allowed. |
RepeatingGroup | id, title, minItems (default 0), maxItems (null for no limit), collapsible, initiallyCollapsed, rowGap, children | One block per entry, each with its own values. Add stops at maxItems; Remove is disabled at minItems. |
FormRow | id, columns (6 or 12), cells (1 to 6 FormCell(child, span)), columnGap, stackBelow | Cells side by side by span. |
FreeGrid | id, columns (6 or 12), rows (at least 1), placements (GridPlacement(child, x, y, width, height)), rowGap, columnGap | Items at fixed cells; each row is 88 px tall. |
Groups
FormGroup(
id: 'deviation',
title: 'Deviation',
collapsible: true,
rowGap: 12,
children: [
const FieldSlot(id: 'slot_batch', fieldKey: 'batch'),
],
)Repeating groups
A repeating group stores its value under its own id as a list of entry maps, one map per entry. Each entry is a new value scope: a formula inside the group reads the fields of the same entry. A repeating group cannot contain another repeating group.
RepeatingGroup(
id: 'materials',
title: 'Affected materials',
minItems: 1,
maxItems: 10,
children: [/* rows and slots for material, qty, unit_cost, line_cost */],
)Rows
A row divides its width into 6 or 12 columns. Each cell takes a span of those columns. Spans must fit in the row, and no span may be smaller than its child's minimum (see below).
FormRow(
id: 'deviation_row',
columns: 12,
columnGap: 16,
stackBelow: 720,
cells: const [
FormCell(child: FieldSlot(id: 'slot_title', fieldKey: 'title'), span: 6),
FormCell(child: FieldSlot(id: 'slot_severity', fieldKey: 'severity'), span: 3),
FormCell(child: FieldSlot(id: 'slot_discovered', fieldKey: 'discovered_at'), span: 3),
],
)A row cell holds a field slot, content, or a group (a group in a cell acts as a column). Rows, grids and repeating groups are flow-only: they cannot appear anywhere inside a row cell or a grid, not even inside a group there.
Free grids
A free grid places each child at x, y with a width and height in grid cells. Placements must stay inside columns × rows, must not overlap, and must be at least the child's minimum size. Row height is fixed at gridRowHeight (88 px), so a height of 2 is two rows plus the gap between them.
FreeGrid(
id: 'assessment',
columns: 12,
rows: 2,
placements: const [
GridPlacement(child: FieldSlot(id: 'slot_what', fieldKey: 'what_happened'), x: 0, y: 0, width: 8, height: 2),
GridPlacement(child: FieldSlot(id: 'slot_capa', fieldKey: 'capa_owner'), x: 8, y: 0, width: 4, height: 1),
GridPlacement(child: FieldSlot(id: 'slot_confidence', fieldKey: 'confidence'), x: 8, y: 1, width: 4, height: 1),
],
)Minimum sizes
minimumSizeOf(node, fields) returns a FormElementSize(columns, rows, minWidth), with columns counted out of 12. The model refuses spans and placements below it, the editor clamps resizes to it, and FormView uses minWidth to decide when to stack.
| Element | Columns × rows | Min width |
|---|---|---|
| toggle, number, formula, rating, date-only; heading, divider, spacer; an empty group | 2 × 1 | 120 px |
| text, choice, reference, money, quantity, duration, time, date-time; text block, callout, image, data display, custom content, custom field | 3 × 1 | 180 px |
| date range, date-time range | 4 × 1 | 240 px |
| multiline text, attachment | 4 × 2 | 240 px |
For containers:
- a group takes its widest child's columns and largest minimum width, with the children's rows added up;
- a row adds up its cells' minimums (columns capped at 12) and takes the tallest cell;
- a grid is 12 columns wide and as tall as its row count.
minimumColumnsIn(6, size) halves the column count and rounds up for a 6-column container.
Stacking at fill time
FormView keeps layouts readable on narrow screens:
- A row stacks its cells into one column when its width is below
stackBelow, or as soon as any cell would be narrower than its child's minimum width. - A grid stacks its items in reading order when any shown item would be narrower than its minimum width.
Changing layout in code
Containers have a copyWith for their own properties. RepeatingGroup.copyWith(maxItems:) takes a record so that (value: null) removes the limit.
final roomy = group.copyWith(rowGap: 24, collapsible: true);
final unlimited = materials.copyWith(maxItems: (value: null));
final tight = row.copyWith(columnGap: 8, stackBelow: 720);
final dense = grid.copyWith(rowGap: 8, columnGap: 8);These return new nodes. To place one in a definition, rebuild the definition, or use the editor controller, which also validates the result and records an undo step:
controller.updateNode('deviation', (node) => (node as FormGroup).copyWith(rowGap: 24));
controller.setRepeatLimits('materials', maxItems: (value: 20));
controller.setRowColumns('material_row', 6);