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:
| Group | Types |
|---|---|
| Text | text, textarea, email, url, phone, password, secret |
| Rich content | markdown, html, richtext, code |
| Numbers | number, currency, percent, slider, progress, rating |
| Date & time | date, datetime, time |
| Logic | boolean, toggle |
| Selection | select, multiselect, radio, checkboxes, tags |
| Relational | lookup, master_detail, tree |
| Media | image, file, avatar, video, audio, signature, qrcode |
| Calculated/system | formula, summary, autonumber |
| Embedded | composite, repeater, record, json |
| Enhanced | location, address, color |
| AI/ML | vector |
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: ratingWidget 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.0Widget 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: objectConfigurable 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: validationAccessibility
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: textboxPerformance
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.375remWidgets inherit the active theme automatically; they do not each carry a full set of color/typography props.
What's Next?
Action Protocol
Define buttons, triggers, and navigation flows
Plugin Development
Build and register your own widgets and extensions
Related Resources
- Component Reference - UI component configuration API
- Layout DSL - Composing views and pages