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

KeyRequiredShapeWhat it puts on the document
colorsColorPalette; only primary is mandatory inside itThe shadcn palette variables — renamed on the way out: surface emits --card, text emits --foreground, textSecondary emits --muted-foreground, disabled emits --muted, error emits --destructive.
borderRadiusA scale object (none/sm/base/md/lg/xl/2xl/full), not a single token--radius-sm, --radius-md, ... — and base emits the bare --radius.
shadowsThe same stops plus inner--shadow-sm, --shadow-md, ... — and base emits the bare --shadow.
typographyOne live key since #5021: fontFamily.base--font-sans.
customVarsA flat string mapEvery 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: 1rem

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

On this page