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 is FieldWidgetProps (packages/spec/src/ui/widget.zod.ts) — the props
every field widget receives at render time. Implement it and your widget drops into any
ObjectStack form.
The protocol declares what a widget receives, not how it is registered.
Registration belongs to the renderer, and metadata reaches it by naming a widget
with a string — see Registering a Widget. The
WidgetManifest family that used to sit beside this contract was removed in
@objectstack/spec 17.0.0 (#5055, ADR-0049): no schema anywhere ever carried it.
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. Reflect it as `aria-required` on the control you render.
// Never draw your own required marker — the host's label already owns the `*`.
required: boolean;
// The active validation message, absent while the field is valid. A signal for
// `aria-invalid`, not text for the widget to render: the host renders the
// message itself. See "Who Renders What" below.
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"
// `error` drives the invalid STATE. Its text is the host's to render.
aria-invalid={!!error}
// The required STATE, on the control. Not a second asterisk.
aria-required={required || undefined}
>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
filled={Number(value) >= star}
onClick={() => !readonly && onChange(star)}
/>
))}
</div>
);
}Who Renders What
A widget shares a field's chrome with its host, and each piece below has exactly one owner. Rendering one the host already renders is the classic custom-widget defect: the same sentence, or the same asterisk, appears twice.
| Concern | Owner |
|---|---|
aria-invalid on the control element | the widget — only it renders that element |
| The validation message text | the host (objectui's <FormMessage />) |
The required marker * | the host (objectui's <FormLabel>) |
erroris a signal, not text to render. It carries the active message string — objectui feeds it from react-hook-form'sfieldState.error?.messageand leaves itundefinedwhile the field is valid — but the host already renders that text below the control. Read it to setaria-invalid, nothing else. A widget that also prints it displays the same message twice (objectui#3222).- If you compute
aria-invalidyourself, derive it fromerror— and mind the spread order. A host may already be injecting a correctaria-invalid: objectui's<FormControl>is a RadixSlotthat does. So forward the props you don't consume onto the control you render, and never write anaria-invalidcomputed from something else after that spread — it silently overwrites the host's correct value withfalse. That is exactly what seven built-in objectui widgets did while the slot went unproduced, so an invalid field was never announced to a screen reader (objectui#3222). - Never draw your own required marker. The host's label owns the
*; a second author for it produces the same double display, which is whyrequiredis deliberately absent from objectui's rendered props type. The one thing a widget genuinely adds is the state on the control —aria-required, which assistive tech announces as a state instead of folding it into the accessible name, and which keeps working for a field rendered with no label at all (objectui#3290). Reflectrequiredasaria-requiredand stop there; objectui goes further and injectsaria-requireditself, so forwarding your leftover props gets it for free. Do not set the nativerequiredattribute — that arms the browser's own constraint-validation bubble alongside the host's messages.
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, user |
| 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: ratingRegistering a Widget
A field widget is named by a string, not by a metadata document. In a view, the
field's type auto-infers a widget and an explicit widget name overrides that
inference; the name resolves against the widgets the renderer has registered.
That is the whole authorable surface, and it is the one shown under
Overriding the Inferred Widget above.
Registration itself belongs to the renderer. In objectui, @object-ui/core's
WidgetRegistry holds the runtime manifests (RuntimeWidgetManifest in
@object-ui/types) and decides how a widget's code is discovered and loaded. The
protocol declares the props contract that a widget must implement; it does not
declare the registry.
WidgetManifest and its family were removed in @objectstack/spec 17.0.0
(#5055, ADR-0049 enforce-or-remove). WidgetManifestSchema,
WidgetLifecycleSchema, WidgetEventSchema, WidgetPropertySchema and
WidgetSourceSchema — with the npm / remote / inline implementation
union, the onMount / onValidate lifecycle hooks and the declarable
properties and events — described a registration capability the platform
never had. No schema anywhere carried them: there was no key on any
metadata type whose value was a widget manifest, so no document could reach
these shapes and nothing ever parsed one. They are TS2305 on
@objectstack/spec/ui after upgrade.
Nothing needs rewriting. A manifest was never writable, so no stored
metadata can contain one, and field.widget: my_picker — the key that names a
widget for real — is untouched. If you were building a value of one of these
types in your own TypeScript, delete it: the object was received by nobody.
Widget registration returns as protocol metadata only through the enforce route of ADR-0049 — a registry and a loader first, then the vocabulary that describes what they actually do.
Accessibility
AriaProps (packages/spec/src/ui/i18n.zod.ts) is the shared ARIA shape carried
by the live UI schemas — views, pages, page components, charts and actions all
declare an aria: block. The supported attributes are intentionally minimal:
interface AriaProps {
ariaLabel?: string; // Accessible label for screen readers
ariaDescribedBy?: string; // ID of an element that describes this element
role?: string; // WAI-ARIA role override
}Example:
aria:
ariaLabel: Credit card number, 16 digits
ariaDescribedBy: credit_card_help
role: textboxAriaPropsSchema is .strict() (#4001), so a misspelled key is a parse failure
carrying its own prescription rather than an accessible name that silently
disappears. Note that a widget does not author this block — the shapes above do.
What a widget contributes is the state on the control it renders
(aria-invalid, aria-required), per Who Renders What.
Performance
There is no performance block anywhere in this contract. Virtualization for
large datasets is configured on the view: set the boolean virtualScroll on a
list-shaped view (ListViewSchema in packages/spec/src/ui/view.zod.ts). That is
the only virtual-scrolling switch objectui reads.
widget.performance was removed at the #3896 audit close-out and its tombstone
was subsumed by #5055 when the manifest that carried it was itself removed — so
there is no longer a key to reject, because there is no longer a shape to author
it into. Use the view's virtualScroll.
Theme
ObjectUI theming is defined by ThemeSchema in packages/spec/src/ui/theme.zod.ts, and it declares ten authorable keys — that list is the whole vocabulary. Five of them are identity and inheritance: name (a snake_case identifier) and label are required, description is optional, mode is one of light, dark, or auto (default light), and extends names another theme to inherit from. The other five are the token surface:
| Key | Required | Shape | What it puts on the document |
|---|---|---|---|
colors | ✅ | ColorPalette; only primary is mandatory inside it | The shadcn palette variables — renamed on the way out: surface emits --card, text emits --foreground, textSecondary emits --muted-foreground, disabled emits --muted, error emits --destructive. |
borderRadius | — | A scale object (none/sm/base/md/lg/xl/2xl/full), not a single token | --radius-sm, --radius-md, ... — and base emits the bare --radius. |
shadows | — | The same stops plus inner | --shadow-sm, --shadow-md, ... — and base emits the bare --shadow. |
typography | — | One live key since #5021: fontFamily.base | --font-sans. |
customVars | — | A flat string map | Every entry verbatim, -- prefixed if you omit it: z-modal: '1050' emits --z-modal: 1050. This is the declared door for any other custom property. |
name: corporate
label: Corporate
mode: light
colors:
primary: '#2563eb'
surface: '#ffffff'
text: '#111827'
borderRadius:
base: 0.25rem
md: 0.375rem
shadows:
base: '0 1px 3px rgb(0 0 0 / 0.1)'
typography:
fontFamily:
base: 'Inter, system-ui, sans-serif'
customVars:
space-4: 1remThemeSchema is .strict() (#4001), so a key outside that list is a parse failure at defineStack({ themes }) / defineTheme(), carrying its own prescription — not a value silently dropped while the theme still reports valid.
Older theme samples no longer parse — check yours before copying it forward.
#3494 removed spacing, breakpoints, logo, density, wcagContrast,
rtl, touchTarget and keyboardNavigation: the theme engine never emitted a
variable for any of them, so authoring one was a silent no-op. #5021
(@objectstack/spec 17.0.0, ADR-0049) removed animation, zIndex, the
typography.fontSize / fontWeight / lineHeight / letterSpacing scales and
typography.fontFamily.heading / mono: those were emitted, faithfully and
for years, but no first-party component or stylesheet has ever read one.
The prescription in both waves is customVars, and it is a byte-for-byte
replacement — customVars carrying font-size-lg: 1.125rem puts exactly the
same --font-size-lg on the document the retired scale did. Run
os migrate meta --from 16 to rewrite stored metadata automatically.
Widgets inherit the active theme automatically; they do not each carry their own copy of the palette or the font stack.
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