---
title: "Field and display components"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Field and display components

The built-in field set and display component registry use the frozen
[protocol §8](/protocol/v1) 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](/fields) (§7.3/§11.1). State layer:
[the form engine](/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.

<!-- @sample-preamble
import { FieldShell, stringValue, useFieldChrome } from '@adonia/ui'
import type { FieldProps } from '@adonia/ui'
-->

```tsx
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`, `mask` → `pattern`, `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:

<!-- @sample-preamble
import type { ReactNode } from 'react'
import { AdoniaProvider } from '@adonia/ui'
import type { OptionsFetcher, UploadTransport } from '@adonia/ui'
declare const myFetch: OptionsFetcher
declare const myUploader: UploadTransport
-->

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

Source: https://adonia.pages.dev/field-components/index.mdx
