Skip to content

Widgets

Everything visible on a page is a widget. A text label, an input field, a data table, a layout column. There is no other rendering abstraction. You compose widgets into trees, and those trees become your screens.

The widget tree

A page's content is a flat array of widget nodes. Each node can contain children, forming a recursive tree.

typescript
definePage({
  key: 'sales.orders',
  label: 'Sales Orders',
  widgets: [
    {
      type: 'table',
      source: { model: 'sales.order' },
      children: [
        { type: 'column', props: { label: 'Name' }, bind: { field: 'name' } },
        {
          type: 'column',
          props: { label: 'Status' },
          children: [{ type: 'badge', bind: { field: 'status' } }],
        },
      ],
    },
  ],
});

The framework walks this tree top-down, resolves each widget type from the registry, evaluates conditions, resolves bindings, and renders the component.

WidgetNode

Every widget instance in the tree is a WidgetNode. This is the shape that code-first authoring, the builder API, and the visual editor all produce.

typescript
interface WidgetNode {
  id?: string;
  type: string;
  props?: Record<string, any>;
  bind?: WidgetBinding;
  source?: WidgetSource;
  visible?: Condition | Condition[];
  on?: Record<string, WidgetAction | WidgetAction[]>;
  children?: WidgetNode[];
}
FieldTypeRequiredDescription
idstringnoStable identifier for the visual editor. Runtime ignores it.
typestringyesReferences a registered widget name.
propsobjectnoStatic configuration or expression strings.
bindWidgetBindingnoData binding declaration.
sourceWidgetSourcenoData source for the data widget.
visibleCondition[]noConditional rendering. Multiple conditions AND together.
onobjectnoTrigger-to-action wiring.
childrenWidgetNode[]noChild widgets. Only valid if the widget is a container.

Widget categories

Every widget belongs to one category.

CategoryPurposeExamples
inputAccepts user input bound to a fieldinput
displayShows data (read-only)text, badge, icon, image
layoutArranges children spatiallygroup, section, split, grid, divider, spacer, column, drawer, modal
actionTriggers behavior on interactionbutton
dataFetches data and provides contextdata, repeat, table

Data binding

Binding connects a widget to data. The bind field declares the connection.

Field binding

Binds to a field on the nearest record in scope.

json
{ "bind": { "field": "customer_id" } }

The runtime provides: value, setValue, and field metadata (type, label, required, options).

Expression binding

Computed read-only value.

json
{ "bind": { "expression": "{{qty * rate}}" } }

Model binding

Widget fetches its own list data. Used by the table widget.

json
{ "source": { "model": "sales.order", "filters": { "status": "draft" }, "limit": 10 } }

No binding

Widget renders from props only. Omit bind.

The data widget

The data widget is the data boundary. It fetches data and provides context to all its children.

typescript
interface WidgetSource {
  model: string;
  id?: string;
}

The rule is simple. id present means single record. id absent means collection.

typescript
// Single record by route param
{ type: 'data', source: { model: 'sales.order', id: '$route.id' }, children: [
  { type: 'input', bind: { field: 'customer_id' } },
  { type: 'input', bind: { field: 'order_date' } },
] }

// Collection with repeat
{ type: 'data', source: { model: 'sales.product' }, children: [
  { type: 'repeat', props: { layout: 'grid', columns: 3 }, children: [
    { type: 'text', bind: { field: 'name' } },
    { type: 'badge', bind: { field: 'status' } },
  ] },
] }

Context tree

The runtime maintains a context tree mirroring the widget tree.

  • Layout widgets (group, section, split, grid) do not create context. They pass through.
  • The data widget in record mode creates a context scope from its fetched record.
  • The data widget in collection mode provides records for child repeat or table widgets.
  • The repeat widget iterates and creates a row-level context per record.
  • The table widget creates a context scope. Each row gets its own row-level context.
  • Nested data widgets create child scopes with $parent reference.

Scope resolution

ReferenceResolves to
fieldNameField on nearest record in scope
$parent.fieldNameOne context level up
$root.fieldNameTop-level page record
$state.keyNamePage-level UI state
$route.idRoute parameter
$response.fieldNameService response (inside onSuccess/onError)

Reactive variables

All dynamic behavior is controlled by reactive variables set via setValue actions.

$state

Flat key-value store for ephemeral UI state. Shared across the entire page. Never persisted.

typescript
$state.activeTab;
$state.selectedId;
$state.drawerOpen;

Any widget can read or write any key. Widgets referencing a $state key re-render when it changes.

$filter

Controls filtering on list data sources.

$filter.sales.order.status = 'draft'               // eq
$filter.sales.order.status = ['draft', 'pending']  // in
$filter.sales.order.total__gt = 100                // greater than
$filter.sales.order.name__like = 'acme'            // contains

Suffixes: __gt, __gte, __lt, __lte, __like, __empty, __not_empty.

$sort

Controls sort on list data sources. Prefix - for descending.

$sort.sales.order = '-date,name'

$page

Controls pagination. Value is a page number.

$page.sales.order = 2

$route

Read-only. Contains route parameters.

$route.id

Triggers and actions

Widgets emit triggers. Actions respond to them.

Triggers

Events declared by a widget. Wired via the on field.

Widget typeTriggers
Input widgetschange, focus, blur
Action widgetsclick
Table widgetrowClick, select, pageChange
Any widgetmount

Actions

What happens in response to a trigger.

typescript
// Set a value
{ type: 'setValue', field: '$state.selectedId', value: '{{id}}' }

// Call a service
{ type: 'service', name: 'sales.submitOrder', onSuccess: { type: 'navigate', path: '/sales/orders' } }

// Sequence multiple actions
{ type: 'sequence', actions: [
  { type: 'validate' },
  { type: 'model.update', data: { status: 'submitted' } },
  { type: 'navigate', path: '/sales/orders' },
] }

Full action types: setValue, clearValue, setValues, service, validate, model.create, model.update, model.delete, model.fetch, model.list, navigate, focus, addRow, removeRow, duplicateRow, sequence, conditional.

Conditional rendering

The visible field controls whether a widget renders.

json
{ "visible": { "field": "$state.drawerOpen", "operator": "eq", "value": true } }

Multiple conditions AND together:

json
{
  "visible": [
    { "field": "status", "operator": "eq", "value": "draft" },
    { "field": "total", "operator": "gt", "value": 0 }
  ]
}

Overlay widgets (drawer, modal) use visible for open/close state. No special open/close actions needed. Set $state.drawerOpen = true to show, false to hide.

Expressions

Any string containing is an expression. A small sandboxed expression language for computing values.

{{qty * rate}}
{{format_currency(sum(items.amount), $parent.currency)}}
{{if(status == 'draft', 'Pending', upper(status))}}

Operators: +, -, *, /, %, ==, !=, >, <, >=, <=, &&, ||, !.

Resolution order

When the renderer encounters a widget type:

  1. Module custom widgets (registered via defineWidget() in the module)
  2. Framework built-in widgets
  3. Error at boot

Next steps