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 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.

ConcernOwner
aria-invalid on the control elementthe widget — only it renders that element
The validation message textthe host (objectui's <FormMessage />)
The required marker *the host (objectui's <FormLabel>)
  • error is a signal, not text to render. It carries the active message string — objectui feeds it from react-hook-form's fieldState.error?.message and leaves it undefined while the field is valid — but the host already renders that text below the control. Read it to set aria-invalid, nothing else. A widget that also prints it displays the same message twice (objectui#3222).
  • If you compute aria-invalid yourself, derive it from error — and mind the spread order. A host may already be injecting a correct aria-invalid: objectui's <FormControl> is a Radix Slot that does. So forward the props you don't consume onto the control you render, and never write an aria-invalid computed from something else after that spread — it silently overwrites the host's correct value with false. 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 why required is 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). Reflect required as aria-required and stop there; objectui goes further and injects aria-required itself, so forwarding your leftover props gets it for free. Do not set the native required attribute — 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:

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, user
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

Registering 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: textbox

AriaPropsSchema 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?

On this page