ObjectStackObjectStack

Widget Contract

Standard props, events, and lifecycle for ObjectUI components

The Widget Contract defines the standard interface that custom ObjectUI field widgets implement. This contract ensures consistency across renderers and lets custom widgets integrate predictably with the rest of the UI system.

The contract has two halves, both defined in packages/spec/src/ui/widget.zod.ts:

  • FieldWidgetProps — the props every field widget receives at render time.
  • WidgetManifest — the static declaration that registers a custom widget (its supported field types, lifecycle hooks, events, configurable properties, and how its code is loaded).

Philosophy: Props Down, Events Up

ObjectUI follows React's unidirectional data flow pattern:

┌─────────────────────────────────────┐
│         Parent Component            │
│  (Form, Section, Container)         │
└─────────────┬───────────────────────┘

              │ Props ↓ (value, field, record, options)

┌─────────────▼───────────────────────┐
│       Field Widget                  │
│  (TextField, Select, Lookup)        │
└─────────────┬───────────────────────┘

              │ onChange ↑ (new value)

┌─────────────▼───────────────────────┐
│         Parent Component            │
│  (Updates record state)             │
└─────────────────────────────────────┘

Key Principles:

  • Props are immutable: Widgets receive props, never modify them.
  • Value changes flow up: A widget calls onChange(newValue); the parent owns the record state.
  • State is lifted: Record/form state lives in the parent, not inside field widgets.

Field Widget Props

Every field widget receives a standard set of props. This is the contract that custom field components and plugin UI extensions implement — the source of truth is FieldWidgetPropsSchema in packages/spec/src/ui/widget.zod.ts.

interface FieldWidgetProps {
  // Current field value. Type depends on the field type.
  value: unknown;

  // Callback to update the field value. Call when user interaction changes it.
  onChange: (newValue: unknown) => void;

  // Read-only mode flag. When true, display the value but don't allow editing.
  readonly: boolean;

  // Required field flag. Indicate the required state visually and validate accordingly.
  required: boolean;

  // Validation error message to display, when present.
  error?: string;

  // Complete field definition from the schema (type, constraints, options, etc.).
  field: Field;

  // The complete record being edited — useful for cross-field logic.
  record?: Record<string, unknown>;

  // Custom options passed to the widget (themes, behaviors, etc.).
  options?: Record<string, unknown>;
}

Example: Custom Widget Implementation

A custom widget is a React component that consumes FieldWidgetProps:

import type { FieldWidgetProps } from '@objectstack/spec/ui';

function CustomRatingField({ value, onChange, readonly, required, error }: FieldWidgetProps) {
  return (
    <div className="rating-field" aria-invalid={!!error}>
      {[1, 2, 3, 4, 5].map((star) => (
        <Star
          key={star}
          filled={Number(value) >= star}
          onClick={() => !readonly && onChange(star)}
        />
      ))}
      {error && <span className="error">{error}</span>}
    </div>
  );
}

Field Types

Each field declares a type. The renderer auto-infers a widget from the type; a custom widget name on the field view overrides that inference. The full set of field types is defined by the FieldType enum in packages/spec/src/data/field.zod.ts:

GroupTypes
Texttext, textarea, email, url, phone, password, secret
Rich contentmarkdown, html, richtext, code
Numbersnumber, currency, percent, slider, progress, rating
Date & timedate, datetime, time
Logicboolean, toggle
Selectionselect, multiselect, radio, checkboxes, tags
Relationallookup, master_detail, tree
Mediaimage, file, avatar, video, audio, signature, qrcode
Calculated/systemformula, summary, autonumber
Embeddedcomposite, repeater, record, json
Enhancedlocation, address, color
AI/MLvector

Phone numbers use the phone field type (there is no tel type). Multi-select uses multiselect; a checkbox group uses checkboxes.

Overriding the Inferred Widget

In a view, the field type auto-infers the widget. Set an explicit widget only when inference is insufficient (see FormFieldSchema in packages/spec/src/ui/view.zod.ts):

fields:
  - field: description
  # Custom widget override
  - field: priority
    widget: rating

Widget Manifest

A custom widget is registered through a Widget Manifest (WidgetManifestSchema). The manifest declares the widget's identity, the field types it supports, its lifecycle and events, its configurable properties, and how its code is loaded.

interface WidgetManifest {
  name: string;                 // snake_case identifier
  label: string;                // Display name
  description?: string;
  version?: string;             // semver
  author?: string;
  icon?: string;
  fieldTypes?: string[];        // Supported field types, e.g. ['date', 'datetime']
  category?: 'input' | 'display' | 'picker' | 'editor' | 'custom'; // default: 'custom'

  lifecycle?: WidgetLifecycle;  // Lifecycle hooks
  events?: WidgetEvent[];       // Custom events emitted
  properties?: WidgetProperty[]; // Configurable properties
  implementation?: WidgetSource; // How to load the widget code

  dependencies?: { name: string; version?: string; url?: string }[];
  aria?: AriaProps;             // ARIA accessibility attributes
  performance?: PerformanceConfig;
}

Example:

name: custom_date_picker
label: Custom Date Picker
version: 1.0.0
author: Acme Inc
fieldTypes: [date, datetime]
category: picker
implementation:
  type: npm
  packageName: '@acme/custom-date-picker'
  version: 1.0.0

Widget Source

implementation is a discriminated union on type that tells the host how to load the widget code:

# NPM package
implementation:
  type: npm
  packageName: '@acme/widgets'
  version: latest
  exportName: DatePicker   # optional named export

# Module Federation remote
implementation:
  type: remote
  url: https://cdn.example.com/remoteEntry.js
  moduleName: ./DatePicker
  scope: acme_widgets

# Inline code
implementation:
  type: inline
  code: |
    return value ? new Date(value).toLocaleDateString() : '';

Lifecycle Hooks

Widget lifecycle hooks are defined on the manifest as code-body strings (not function references). They follow WidgetLifecycleSchema:

interface WidgetLifecycle {
  onMount?: string;     // Initialization when the widget mounts
  onUpdate?: string;    // Runs when props change (receives prevProps)
  onUnmount?: string;   // Cleanup before the widget unmounts
  onValidate?: string;  // Custom validation; return an error message or null
  onFocus?: string;     // Runs on focus
  onBlur?: string;      // Runs on blur
  onError?: string;     // Error handling
}

Example:

lifecycle:
  onMount: "initializeDatePicker(); loadOptions();"
  onUpdate: "if (prevProps.value !== props.value) { updateDisplay() }"
  onValidate: "return value && value.length >= 10 ? null : 'Minimum 10 characters'"
  onUnmount: "destroyDatePicker(); cancelPendingRequests();"

Custom Events

A widget can declare custom events it emits via WidgetEventSchema:

interface WidgetEvent {
  name: string;                          // lowercase, dash-separated, e.g. 'value-change'
  label?: string;
  description?: string;
  bubbles?: boolean;                     // default false
  cancelable?: boolean;                  // default false
  payload?: Record<string, unknown>;     // payload shape
}

Example:

events:
  - name: search-complete
    label: Search Complete
    bubbles: true
    payload:
      query: string
      results: object

Configurable Properties

A widget exposes configuration knobs through WidgetPropertySchema. These describe the options a builder can set when placing the widget:

interface WidgetProperty {
  name: string;          // camelCase
  label?: string;
  type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'function' | 'any';
  required?: boolean;    // default false
  default?: unknown;
  description?: string;
  validation?: Record<string, unknown>; // min/max, regex, enum, etc.
  category?: string;     // for grouping in the builder UI
}

Example:

properties:
  - name: maxLength
    label: Maximum Length
    type: number
    required: false
    default: 100
    description: Maximum input length
    category: validation

Accessibility

A widget manifest carries ARIA metadata through the shared AriaProps schema (packages/spec/src/ui/i18n.zod.ts). The supported attributes are intentionally minimal:

interface AriaProps {
  ariaLabel?: string;       // Accessible label for screen readers
  ariaDescribedBy?: string; // ID of an element that describes this widget
  role?: string;            // WAI-ARIA role override
}

Example:

aria:
  ariaLabel: Credit card number, 16 digits
  ariaDescribedBy: credit_card_help
  role: textbox

Performance

Performance tuning is supplied through the shared PerformanceConfig schema (packages/spec/src/ui/responsive.zod.ts) on the manifest's performance field. Use it for options such as virtualization and lazy rendering for widgets that handle large datasets.

Theme

ObjectUI theming is defined by ThemeSchema in packages/spec/src/ui/theme.zod.ts. A theme requires a name, label, and colors palette. The mode is one of light, dark, or auto, and density is one of compact, regular, or spacious. borderRadius is a scale object (none/sm/base/md/lg/...), not a single token:

name: corporate
label: Corporate
mode: light
density: regular
colors:
  # ColorPalette configuration
borderRadius:
  base: 0.25rem
  md: 0.375rem

Widgets inherit the active theme automatically; they do not each carry a full set of color/typography props.

What's Next?

On this page