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 — and since 17.0.0
(#7176) there is no authorable virtual-scrolling switch anywhere else either.
The view-level boolean virtualScroll this section used to point at was retired
under ADR-0049 enforce-or-remove: every measured reader only copied the key
forward and the grid renderer never applied it, so authoring it was a
parse-clean no-op. Large datasets page via the view's pagination block.
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. The view-level virtualScroll it used to defer to was itself retired
at #7176 (pass-through-only — copied by every bridge, applied by nothing); real
list virtualization is an implementation card first, and the key stays retired
pending it.
Theme
The themes authoring surface was retired in @objectstack/spec 17.1
(#10485, ADR-0049 enforce-or-remove). Authored themes were parsed and stored,
but no framework package ever read them back and nothing selected an active
theme, so a declared theme never changed anything on screen. A stack that still
declares themes: is now refused at parse with a prescription pointing here.
app.branding is the one colour surface. Set
branding.primaryColor / branding.accentColor on the app
(packages/spec/src/ui/app.zod.ts): objectui's AppShell converts them to
HSL and writes --primary, --primary-foreground, --ring,
--sidebar-primary, --sidebar-ring, --accent and --accent-foreground,
re-deriving them on the light/dark flip.
export const MyApp = defineApp({
name: 'my_app',
label: 'My App',
branding: { primaryColor: '#2563eb', accentColor: '#06b6d4' },
});Widgets read the resulting CSS variables (hsl(var(--primary)), …); they do
not each carry their own copy of the palette.
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