---
title: "Schema components: layout, display, projection"
description: "Compose forms and detail views from layouts, fields, and display projections."
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.

# Schema components: layout, display, projection

This page documents component taxonomy and projection semantics. Layout components live in
`packages/core/src/schema/layout/`, display components in
`packages/core/src/schema/display/`, and the projection pass in
`packages/core/src/serialization/descriptor_compiler.ts`. Everything below is
re-exported from `@adonia/core`; you reach it through the `s` builder handed to
`BaseResource.schema(s)`.

## One tree, three modes

A resource declares **one** schema. The compiler projects it into `create`,
`edit`, and `detail` descriptors — layout is preserved, fields become display
entries in `detail`, and anything that does not belong in a mode is *absent*
from it. There is no `visible: false` on the wire (protocol §2 rule 3).

<!-- @sample-preamble
import { BaseResource, F } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent } from '@adonia/core'
import Post from '#models/post'
-->

```ts
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  schema(s: SchemaBuilder): SchemaComponent[] {
return [
  s.grid().columns(3).components([
    s.section('Content').columns(2).columnSpan(2).components([
      F.text('title').label('Title').required(),
      F.text('body').label('Body').columnSpan('full'),
    ]),
    s.aside().columnSpan(1).components([
      F.text('slug').label('Slug').readonlyOn('edit'),
      s.textEntry('publishedAt').label('Published at').format('datetime'),
    ]),
  ]),
]
  }
}
```

## Layout components (§7.1)

| Factory | Type key | Notes |
| --- | --- | --- |
| `s.section(label?)` | `section` | Titled group with its own column grid |
| `s.grid()` | `grid` | Column grid; also the implicit tree root |
| `s.tabs()` / `s.tab(label?)` | `tabs` / `tab` | Tab strip and one panel |
| `s.wizard()` / `s.step(label?)` | `wizard` / `step` | Stepper and one step |
| `s.aside()` | `aside` | Secondary column of a record page |
| `s.fieldset(label?)` | `fieldset` | Bordered group of related controls |
| `s.card(label?)` | `card` | Elevated surface |
| `s.divider(label?)` | `divider` | Horizontal rule; childless |
| `s.spacer()` | `spacer` | Empty space / empty grid cell; childless |

Every container carries the same DSL, so there is nothing to memorize per
component: `columns(n)`, `columnSpan(n | 'full')`, `collapsible()`,
`collapsed()`, `icon(name)`, `description(text)`, `label(text)`,
`visible(cb)` (the §7.1 spelling of `canSee`), and `components([...])`.
`divider`/`spacer` have no children, so they carry only the leaf half of that
DSL — `columnSpan` and `visible`/`canSee` — plus `label` and `size`
respectively.

Two rules make layout safe to nest arbitrarily:

- **State keys stay flat.** `tabs > tab > tabs > tab > section > fieldset >
  F.text('subtitle')` contributes exactly `subtitle`. Layout never namespaces
  a key (protocol §2 rule 2), so `visibleWhen` `var` paths and
  `reactive.sets[].target` always resolve.
- **Emptied containers disappear.** When every child of a container is
  projected away, the container goes with it. The grid root is the sole
  exception — `descriptor.schema` is always a single `grid` (protocol §2
  rule 4), possibly with no children.

## Display components (§7.1)

Read-only projections of one record attribute, declared directly in the schema
tree and rendered in **`detail` only** — they carry no form state, so they are
absent from `create`/`edit` trees, from the validator, and from the fill set.

| Factory | Type key | Typed props |
| --- | --- | --- |
| `s.textEntry(attr)` | `text-entry` | `format(name)`, `copyable()` |
| `s.badgeEntry(attr)` | `badge-entry` | `colors(map)`, `icons(map)` |
| `s.imageEntry(attr)` | `image-entry` | `disk(name)`, `visibility(v)`, `height(px)` |
| `s.codeEntry(attr)` | `code-entry` | `language(name)` |
| `s.keyValueEntry(attr)` | `key-value-entry` | `keyLabel(text)`, `valueLabel(text)` |
| `s.relationEntry(attr)` | `relation-entry` | `resource(slug)`, `optionLabel(attr)`, `link(target)`, `multiple()` |
| `s.html(attr)` | `html` | — (markup is sanitized on write, not here) |

All seven share `label`, `helper`, `columnSpan`, `canSee`/`visible`, and
`withProps`. Each carries its attribute as the node `key`, so a hand-written
`s.textEntry('title')` is indistinguishable on the wire from the node
`F.text('title')` projects to.

## Projection semantics (§7.4)

`compile(resource, mode, ctx, record?)` resolves four independent gates, in
this order, for every node:

1. **`canSee(ctx, record)`** — request-dependent removal.
2. **`visibleOn` / `hiddenOn`** — static, per rendering context.
   `create`/`edit` compile against `'form'`; `detail` compiles against
   `'detail'`. (`'index'` is table derivation's own context, §7.5.)
3. **Category** — display entries exist in `detail` only.
4. **`readonlyOn(mode)`** — the node stays, and gains `readonly: true` on its
   props for the matching form sub-mode.

Gates 1–3 are *omission*, and omission is total: the node is missing from the
tree, from `descriptor.stateKeys`, from the compiled validator, and from the
fill set, all in one pass. That is what makes mass assignment impossible by
construction (§19) — the fill set is derived from the compiled schema for the
exact mode of the submission, never from the payload. `readonlyOn` is the one
gate that keeps the node: a read-only field still renders and still validates,
but it is dropped from that sub-mode's fill set, so a tampered value cannot
reach the model.

In `detail`, each field is replaced by its `displayType`
(`text-input → text-entry`, `image → image-entry`, `rich-text → html`, …).
That is an **instance** getter, not just a class static: a choice field
(`select`, `radio`, `checkbox-list`) projects to `badge-entry` when its own
options carry colours and to `text-entry` when they do not, and the same
split drives its `columnType` (`badge` vs `text`, §7.5).

Only chrome props survive the projection — `label`, `helper`, `columnSpan`,
`icon`, `description`, `prefix`, `suffix`, plus `format`, which protocol §8
mandates for the date family (`datetime → text-entry` with
`format: 'datetime'`). Validation props (`required`, `maxLength`,
`nullable`, …) and input-only props (`placeholder`, `default`, `disabled`,
`readonly`) are dropped, and the `reactive` block is dropped entirely:
visibility was already resolved server-side.

`collectFields(resource, ctx?, record?, context?)` is the shared walk behind
all of this. Passing a `context` applies gate 2; validator and fill derivation
pass `'form'` (the default of `collectFieldMetas`), while table derivation
passes nothing because it projects into `'index'` itself.

## Tests

`packages/core/tests/layout_components.spec.ts` (component × mode descriptor
snapshots under `tests/fixtures/snapshots/`, shared DSL, flat state keys,
empty-layout omission) and `packages/core/tests/projection.spec.ts` (the
four gates against tree + `stateKeys` + validator + fill set).

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