Skip to content

Field and display components

The built-in field set and display component registry use the frozen protocol §8 type keys. Everything lives in packages/ui: the field components in src/components/fields/, the display projections in src/components/display/, and the registrations in src/registry/index.ts.

Server half: fields (§7.3/§11.1). State layer: the form engine (§13.3).

A field component owns no state

Every field receives { node, control } and nothing else. control.value is the flat form state for its key and control.setValue is the only way to change it — there is no local copy, no defaultValue, no “commit on blur” buffer. A component that kept its own would disagree with the engine the moment a sets derivation, a reset(), or a server round-trip rewrote the key underneath it.

export function MyField({ node, control }: FieldProps) {
  const chrome = useFieldChrome(node, control)
  return (
    <FieldShell chrome={chrome} type="vendor/my-field" fieldKey={node.key}>
      <input id={chrome.controlId} value={stringValue(control.value)}
             onChange={(e) => control.setValue(e.target.value)} />
    </FieldShell>
  )
}

useFieldChrome reconciles the node’s props with the controller’s reactive verdicts (disabled, readonly, error) and mints the ids; FieldShell renders the label, the prefix/suffix affixes, the helper and the error. Using them is what makes a custom field indistinguishable from a built-in one.

The set

Type key Control Form state Notable props
text-input <input type=text> + <datalist> string maxLength, minLength, maskpattern, datalist
textarea <textarea> string rows, autosize
number <input type=number> number | null min, max, step, decimal
slider one or two <input type=range> number or [from, to] min, max, step, decimal, range
select <select> or an ARIA combobox scalar or array options, multiple, native, searchable
radio <fieldset> of radios scalar options, inline
checkbox <input type=checkbox> boolean inline
toggle role="switch" button boolean onLabel, offLabel, onColor
checkbox-list <fieldset> of checkboxes array options, columns, minSelected, maxSelected
date <input type=date> YYYY-MM-DD after, before
datetime <input type=datetime-local> UTC ISO instant after, before, displayTimezone
time <input type=time> HH:mm:ss after, before
color picker or text + swatches string notation, alpha, swatches
hidden <input type=hidden> any scalar
code textarea + lazy highlighter string language, rows, lineNumbers, wrap
json textarea with parse feedback any JSON value rows, prettify
key-value add/remove/edit rows object keyLabel, valueLabel, addable, deletable, editableKeys, minPairs, maxPairs
markdown textarea + lazy preview string preview (side-by-side/tab/none), rows
rich-text lazy contenteditable WYSIWYG sanitized HTML toolbar (preset name), toolbarItems (expanded list), rows
repeater add/remove/reorder item sub-forms array of item objects min, max, reorderable, collapsible, itemLabel, layout
file drag-and-drop upload key string or key array acceptedTypes, maxSizeMb, multiple
image the same, with previews and a lazy crop editor key string or key array + imageEditor, dimensions, previewWidths
belongs-to search box + <select>, optional create modal target primary key resource, searchable, preload, creatable
belongs-to-many checkbox list + pivot sub-forms key array, or { id, …pivot }[] resource, searchable, preload, pivotFields

has-many and morph-to are Phase-3 rows with no server half either. They render as <UnknownComponent> — which is exactly what protocol §8 mandates for a missing client half, and why the gap stays visible rather than silent.

Heavy editors are lazy chunks

§18 budgets the base panel at 225 KB gzip with the rich-text, code and image editors as lazy chunks. Four modules live under src/components/fields/editors/ and are reachable only through lazyEditor(() => import('./editors/x.js')):

Chunk Loaded by When
code_editor code on mount, in the browser
markdown_preview markdown unless preview: 'none'
rich_text_editor rich-text on mount, in the browser
image_editor image only when props.imageEditor, only when the user clicks Edit

lazyEditor wraps React.lazy in <ClientOnly> + <Suspense>, so the server pass and the hydration pass both render the FALLBACK. The fallback is never a spinner: each shell renders a working <textarea> underneath, so a panel whose chunk never arrives (offline, blocked CDN, slow first paint) still edits and submits the field.

Two tests hold the line: tests/fields/lazy_chunks.test.ts walks the source graph and fails on a static import of anything under editors/, and D2-7’s pnpm size fails CI on the same rule against a real bundle.

rich-text: the client is not the security boundary

Neither the shell nor the editor chunk sanitizes anything, deliberately. rich-text sanitizes on dehydrate, server-side, against the configured allowlist (§7.3), so the column holds safe markup for every consumer — this panel, an API, a public page, an export. A second allowlist on the client would drift from the server’s, and the looser of the two would silently decide what gets stored. What the toolbar restricts is what the editor makes easy, which is an authoring affordance.

markdown’s preview is the opposite case and takes the opposite approach: its source is what the user is typing this instant, so it has passed through no sanitizer. The preview therefore builds React elements and never touches dangerouslySetInnerHTML — a <script> in the draft is text, structurally, with no injection point to get wrong.

Uploads carry a key, never bytes

file/image state is the KEY STRING the field.upload endpoint minted (a key array under multiple()). Protocol §4 is explicit that the server never accepts inline file data, and FileField.dehydrateValue refuses any key it did not mint or the record does not already hold.

The widget POSTs one file per request to control.urls.upload through the UploadTransport (default: XMLHttpRequest, because fetch cannot report request-body progress), reads { key, url } back, and commits the key. The url becomes the row’s preview; a key already in state on an edit page shows its name instead, because form state carries the key and resolving it to a (possibly signed) URL is a server-side act.

Both transports are replaceable, which is how a host adds auth headers or a gateway prefix:

export function PanelRoot({ children }: { children: ReactNode }) {
  return (
    <AdoniaProvider transports={{ fetch: myFetch, upload: myUploader }}>
      {children}
    </AdoniaProvider>
  )
}

repeater: state boundary, nested error paths

The node carries both key and children, and the children key an ITEM (protocol §8). Three consequences the client half implements:

  • collectFieldNodes and collectStateKeys stop at the first keyed node, so the form’s flat state holds items and nothing from inside it;
  • each item renders through SchemaRenderer against the item as its state map, so a child keeps its own key (title, never items[2].title) and every field type — including another repeater — works inside a row;
  • errors arrive as runtime paths (items.2.title) with no rewriting. FieldController is ABI-frozen and carries only the node’s own message, so SchemaRenderer overlays the scoped subset as props.itemErrors ({ '2.title': '…' }), exactly as it already overlays refetched options and reactive required. belongs-to-many’s pivot sub-forms use the same route.

Row-scoped reactivity is deliberately not evaluated on the client: a ./x rule resolves against the row but an absolute path resolves against the root form state, which a field component behind the frozen controller does not have. §11.2 mirroring makes the server’s per-row prune authoritative anyway, and failing OPEN is the safe direction — a field shown that the server drops costs a keystroke; one wrongly hidden costs the user their data.

The date family does no arithmetic it does not have to

date and time are CIVIL values (docs/fields.md): the wire string and the control value are literally the same characters, because running a zone conversion over a zone-less day is the classic off-by-one-day bug. Only datetime converts, since it is an absolute instant on the wire and wall-clock in the control. It renders in displayTimezone, falling back to the runtime’s own zone — declare the prop when the panel is server-rendered across zones, or the SSR pass and its hydration will disagree.

Zone math uses Intl alone (src/components/fields/temporal.ts); Luxon is a server-side optional peer and never reaches the browser bundle.

Selection values keep their wire type

props.options values ride the wire as they were declared, and the §11.1 base is vine.enum(values) over those same values. A select over numeric ids therefore commits 1, not "1" — the DOM string is a rendering detail carried separately as the option’s token.

Dependent options: the component never fetches

A select whose node carries reactive.refetch renders a loading state and waits. The engine owns the capability URL, the withState slice, the debounce and the abort; SchemaRenderer overlays the response onto props.options.

The two states are distinguished by the prop, not by a flag:

  • reactive.refetch declared and no options prop → the answer has not arrived → “Loading options…”, aria-busy;
  • options present but empty → the server answered, and the answer is nothing → “No options available”.

A dependent select whose parent narrows the list to zero has to say so rather than spin forever, which is why the distinction is worth a rule.

Display projections

In detail mode each field is replaced by its displayType counterpart (§7.4), carrying chrome props only — label, helper, columnSpan, icon, description, prefix, suffix and format. Every one of them is a labelled <dl> pair rendered by EntryShell.

Type key Renders
text-entry the scalar, with format (date/datetime/time) applied and the raw value kept on <time datetime>; copyable adds a clipboard button
badge-entry a coloured pill per value, keyed through props.colors / props.icons
image-entry the server-resolved (and, for a private disk, signed) URL
code-entry a read-only <pre><code>, structured values pretty-printed
key-value-entry a two-column <table> with keyLabel/valueLabel headers
relation-entry the related label(s), linked when the value carries a server-built url
repeater-entry one block per item, each rendered through the children the server already projected
html trusted markup, injected verbatim — see below

html trusts its input, deliberately

Sanitization is the server’s job and happens on the way IN. rich-text sanitizes on dehydrate against the configured allowlist (§7.3, B2-5), so the database holds safe markup and every consumer is safe — this panel, an API, a public page, an export — not just the one that remembered to clean up. Sanitizing on read instead would leave a client-side bypass (or an SSR pass with no DOM) serving the raw value, and a second allowlist would drift from the server’s, with the looser of the two deciding what renders.

The consequence is a rule: an html entry pointed at an attribute that never passed through a sanitizing dehydrate is an XSS hole, and closing it is the author’s job. s.html('body') over a rich-text field is safe by construction; s.html() over a column filled by an importer is not.

Accessibility is part of the contract

Every component in the tranche is asserted against axe (WCAG 2.0/2.1 A + AA) in packages/ui/tests/fields/. The rules the shell encodes once:

  • a real <label htmlFor> for single controls, a <legend> for groups (radio, checkbox-list, a range slider), the control inside its label for a checkbox;
  • aria-describedby chaining affixes → constraint hints → helper → error, in reading order, and aria-invalid mirroring control.error;
  • the error message carrying role="alert", so a validation round-trip is announced;
  • keyboard operation on every custom control: the select’s combobox opens on ArrowDown/Enter, moves with the arrows and Home/End, selects with Enter/Space and closes on Escape, keeping focus on the trigger and pointing aria-activedescendant at the active option.

Tests

packages/ui/tests/fields/, one file per component plus:

  • support.tsx — the shared harness. renderField hands the component a spy-backed, INERT FieldController (value in, setValue out, nothing read back), which is the contract for a stateless control; renderStatefulField feeds every commit back as the next value, for the collection fields whose reconciliation is itself the behaviour under test. renderFromSnapshot renders from the committed server snapshot for the type (packages/core/tests/fixtures/snapshots/) rather than from a hand-written node — the shared fixture is what lets the two halves of a field type be built in parallel without drifting.
  • lazy_chunks.test.ts — walks the source graph and fails on a static import of anything under components/fields/editors/.
  • upload.test.tsx / relation.test.tsx — the file/image and belongs-to* pairs have no committed server snapshot yet (B2-4 and B2-7 shipped the field classes but not the descriptor fixtures), so their nodes are written from the field classes’ prop bags. registry_completeness picks the pair up automatically the day those snapshots land.
  • registry_completeness.test.tsx — walks every committed field-* snapshot and asserts each type resolves to a registered component. A server field type cannot ship without its client half.
  • ssr.test.tsx — runs in the node environment (no window, no document) and renders the whole tranche to static markup, which is the §13.4 SSR guarantee stated as a test.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close