` / `` / `` nodes inside a `.ProseMirror` wrapper, while the viewer emits class-tagged elements (`.coar-markdown-list-item`, etc.) — and the shared stylesheet covers both via parallel `:where(…)` selectors:
| Concern | Note |
|---|---|
| Vertical rhythm | Block margins apply to direct children of `.coar-markdown` (viewer) **and** `.coar-markdown .ProseMirror` (editor). |
| Typography | Heading sizes, blockquote inset, list indentation, `` weight (700), inline-code color, link underline — all defined once. |
| Tables | Zebra alternation uses `:nth-child( of :not([data-is-header]))` to handle Milkdown's ``-inside-` ` shape and the viewer's classic `` / ` ` split with one rule. |
| Task lists | `` in both panes; the visual checkbox is a `::before` pseudo-element (no native ` `). Completed items get the muted-color strikethrough. |
| Cell padding | `` user-agent margin reset to `0` inside `
` / `` / ` ` — without the reset PM's auto-wrapped paragraph would add ~1em of vertical whitespace per row. |
If you embed the editor next to a viewer pane (the playground's "viewer pane" toggle does exactly this), the two should render the same source identically. Differences narrow down to design tokens you can override globally:
| Variable | Default | Effect |
|---|---|---|
| `--coar-markdown-heading-block-start` | `var(--coar-spacing-xl, 2rem)` | Extra space above every top-level heading. Lower for tighter docs, raise for more whitespace. |
| `--coar-markdown-space-2` | `var(--coar-spacing-m, 1rem)` | Default block-end margin. Drives paragraph / list / table / blockquote spacing. |
| `--coar-markdown-link` | `var(--coar-text-brand-primary)` | Link color (also applied to inline code). |
| `--coar-markdown-border` | `var(--coar-border-neutral-tertiary)` | Used by tables, blockquote, ` `. |
## Toolbar layout (`tools`)
Pass a `tools` array to control which buttons the **sidebar** toolbar exposes and
in what order. When omitted, the default layout is used. **The array order IS the
toolbar order** — entries render top-to-bottom (or left-to-right) as listed.
```vue
```
::: warning Order is now significant
Earlier, a flat `tools` array was an order-insensitive whitelist filtering a fixed
sequence. It is now an **ordered layout**. If you relied on canonical ordering,
list the tools in the order you want; the default (omit `tools`) is unchanged.
:::
### Groups and custom items
An entry can also be a **flyout group** (a submenu) or a `'divider'`. A group
bundles any mix of built-in tools and [custom-embed](/components/markdown-embeds)
inserts (`embed:`) behind one button:
```ts
import type { CoarMarkdownEditorToolEntry } from '@cocoar/vue-markdown-editor';
const tools: CoarMarkdownEditorToolEntry[] = [
'bold', 'italic', 'headings',
'divider',
{ flyout: ['table', 'image', 'embed:chart'], label: 'Insert', icon: 'plus' },
'divider',
'undo', 'redo',
];
```
| Entry shape | Meaning |
|---|---|
| `'bold'` (a `CoarMarkdownEditorTool`) | A built-in tool. |
| `` `embed:chart` `` (a `` `embed:${string}` ``) | A registered custom embed's insert item — see [Custom Embeds](/components/markdown-embeds). |
| `{ flyout: ToolRef[], label?, icon? }` | A flyout submenu containing any of the above. |
| `'divider'` | A separator. |
### Tool identifiers
| Tool | Description |
|---|---|
| `bold` `italic` `strikethrough` `inlineCode` | Inline marks |
| `textColor` | Text color picker — see [Text Color](#text-color) |
| `headings` | Heading flyout (H1–H6 + paragraph) |
| `bulletList` `orderedList` `taskList` | List variants |
| `indent` `outdent` | List nesting controls |
| `blockquote` `horizontalRule` | Block elements |
| `codeBlock` `table` `image` | Insert blocks (sidebar only) |
| `tableOps` | Insert/Delete row/col, shown contextually when cursor is inside a table |
| `clearFormatting` | Strip all marks + reset block to paragraph |
| `undo` `redo` | History |
::: info Markdown-only formatting
Only formatting that round-trips through Markdown is exposed. There is intentionally **no underline, font-family, font-size, or alignment** — these have no Markdown representation and would silently break round-trip persistence. **Text color** is the one exception: it round-trips as plain inline HTML through a strict whitelist sanitizer (see [Text Color](#text-color)).
When migrating from a richtext editor that exposed those tools, the closest Markdown-native substitutes are:
| Richtext tool | Markdown equivalent |
|---|---|
| Font size | `headings` — H1–H6 provide the typographic hierarchy |
| Bold / italic | `bold` / `italic` (no change) |
| Bulleted / numbered list | `bulletList` / `orderedList` |
| Indent / outdent (in lists) | `indent` / `outdent` |
| Clear / eraser | `clearFormatting` |
| Underline, color, alignment, font-family | *no equivalent — drop or accept embedded HTML* |
:::
## Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `modelValue` | `string` | `''` | Markdown content (use with `v-model`) |
| `readonly` | `boolean` | `false` | Disable editing (keeps the layout, suppresses the toolbar) |
| `disabled` | `boolean` | `false` | Disabled state — non-interactive, dimmed. Auto-picked up from `CoarFormField` |
| `error` | `boolean` | `false` | Error state — adds outline + `aria-invalid`. Auto-picked up from `CoarFormField.error` |
| `id` | `string` | *(auto)* | HTML id. Auto-generated if omitted; `CoarFormField`'s id takes precedence |
| `name` | `string` | *undefined* | Reflected as `data-name` for form-submission tooling |
| `required` | `boolean` | `false` | Sets `aria-required="true"` |
| `placeholder` | `string` | `''` | Markdown hint shown while the editor is empty. Overlay-only — never written to `modelValue`. See [Placeholder](#placeholder) |
| `sourceToggle` | `boolean` | `false` | Show a Rendered ↔ Source toggle for editing the raw Markdown. See [Source view](#source-view-raw-markdown) |
| `toolbarMode` | `'floating' \| 'fixed' \| 'both'` | `'floating'` | Toolbar layout |
| `toolbarPosition` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'left'` | Toolbar edge when `toolbarMode` is `'fixed'` or `'both'`. `top`/`bottom` render a horizontal toolbar; flyouts open along the perpendicular axis. |
| `tools` | `CoarMarkdownEditorToolEntry[]` | *default layout* | Ordered toolbar layout (built-in ids, `embed:` refs, `{ flyout }` groups, `'divider'`). See [Toolbar layout](#toolbar-layout-tools) |
| `flavor` | `'commonmark' \| 'gfm' \| 'cocoar' \| { gfm?, textColor?, embeds? }` | `'cocoar'` | Portability contract — hard-enforces which features can be authored. See [Flavors](#flavors-portability) |
| `embeds` | `EmbedRegistry` | *undefined* | Custom-embed registry (`:::key{props}` → your component). Requires the `embeds` capability (`cocoar` flavor). See [Custom Embeds](/components/markdown-embeds) |
| `uploadImage` | `(file: File) => Promise<{ url: string; alt?: string }>` | *undefined* | Enables paste / drag-drop image upload. Returns the stored image's URL. See [Images](#images) |
| `pickImage` | `(ctx: ImagePickContext) => void` | *undefined* | Override the Insert Image button with your own asset picker. See [Custom image source](#custom-image-source-pickimage) |
## Events
| Event | Payload | Description |
|---|---|---|
| `update:modelValue` | `string` | Fired on every internal markdown change. The editor de-duplicates — if a parent echoes the value back unchanged, no second update fires. |
## Floating-Toolbar Contexts
The floating toolbar swaps its contents based on what's selected:
| Selection | Toolbar |
|---|---|
| Text outside a table | Bold, Italic, Strikethrough, Inline Code, Headings flyout, Blockquote |
| Text inside a table cell | Row insert above/below, Column insert left/right, Delete cell, plus Bold/Italic/Code |
Detection runs on the ProseMirror selection state via `editorViewCtx`. CellSelections are ProseMirror-internal and don't fire `selectionchange` — the column- and row-handle toolbars referenced in the architecture below are not wired up yet (see [TODO](#todo)).
## Architecture Notes
### Why Milkdown (not TipTap, not Crepe)
| | Milkdown Kit | TipTap | Milkdown Crepe |
|---|---|---|---|
| Data format | **Markdown-first** (lossless round-trip) | JSON-first (lossy markdown export) | Markdown-first |
| Shared stack | Same as `@cocoar/vue-markdown-core`: unified@^11, remark-parse@^11, remark-gfm@^4 | No overlap | Same |
| Bundle | ~137 KB gzip (Kit) | Similar | ~2 MB (CodeMirror, KaTeX, etc.) |
| UI control | Full — headless, own components | Full — headless | Limited — predefined Notion-like UI |
| License | MIT | MIT (core), paid (collab) | MIT |
The Kit approach gives full control over UI while sharing the remark pipeline with the existing `@cocoar/vue-markdown-core` parser and `` viewer.
### Why no `tableBlock` plugin
`@milkdown/components/table-block` provides edge-handle buttons, button-group popups, and drag-to-reorder, but:
* Clicking in a cell auto-selects all content (breaks normal editing)
* Button-group popup overlaps with the floating toolbar
* CellSelection is internal to ProseMirror — `window.getSelection()` returns `type: "None"`, so `selectionchange` doesn't fire and detection is unreliable
* Requires ~200 lines of CSS to style properly
Table operations are instead exposed via the floating toolbar (when the cursor is inside a cell) and the sidebar toolbar (always available in fixed mode). Custom edge-handles will be built when a stable design is settled.
## TODO
* \[ ] Link insert/edit dialog
* \[x] Image support (insert by URL, paste / drag-drop upload, custom `pickImage`)
* \[x] Table create (size picker + `|CxR|`), column alignment, delete table
* \[x] Hover edge-handles (row/column grips on all four edges → insert / delete menu)
* \[ ] Task list checkbox rendering and toggling
* \[ ] Use `computeOverlayCoordinates` for floating toolbar positioning instead of viewport clamping
* \[ ] Slash commands for block insertions
* \[ ] Block drag handle
* \[ ] Code block syntax highlighting (Prism or Shiki)
---
---
url: /components/markdown-embeds.md
description: >-
Markdown custom embeds — register Vue components rendered from :::key{props}
directives in both viewer and editor, with lossless round-trip to plain text
---
# Custom Embeds
Embed your own Vue components into markdown with a `:::key{props}` directive. The
**same** registry drives the shared viewer (``) and the editor
(``): a registered component renders read-only when reading
and editable when writing, and the directive round-trips losslessly to text.
The embedded component has **zero dependency on markdown** — it's a plain
component with normal props that a consumer registers from the outside. The
markdown packages never depend on it; the registry is the only meeting point.
::: info Spans three packages
The directive parser lives in `@cocoar/vue-markdown-core` (Vue-free); the registry
and renderer in `@cocoar/vue-markdown`; the editor NodeView + insert affordance in
`@cocoar/vue-markdown-editor`. Registering an embed once lights up all three.
:::
The demo registers one embed under the key `stat`. Edit the metric inline in the
editor, or insert a fresh one from the **Insert ▾** flyout in the left rail — the
viewer (right) updates from the same markdown.
## The directive
A custom embed is a standalone block on its own line:
```
:::stat{label="Revenue" value="1,284" trend="+12.4%" tone=positive}
```
* `stat` is the **key** — it selects the registered component.
* The `{…}` attributes become the component's props. Values are strings;
bareword values may be unquoted (`tone=positive`, `id=2f1c0b9e-…`), values with
spaces or specials are quoted (`label="Revenue"`). A valueless attribute
(`{interactive}`) is an empty string.
The parser is **registry-agnostic**: any `:::key{…}` parses to a generic `embed`
node and round-trips, even when no component is registered for that key (it then
renders as a labelled placeholder — see [Unknown keys](#unknown-keys)).
::: tip Lossless round-trip
`parse → serialize → parse` is a fixed point. Canonical forms like
`:::map{id=}` are byte-stable; non-canonical input (extra quoting) is
normalized on the first serialize and stable thereafter.
:::
## Registering an embed
The registry maps a key to a definition. Pass it to the viewer and/or editor via
the `embeds` prop:
```ts
import type { EmbedRegistry } from '@cocoar/vue-markdown';
import StatCard from './StatCard.vue'; // your read-only component
import StatCardConfig from './StatCardConfig.vue'; // your editable component
const embeds: EmbedRegistry = {
stat: {
viewer: StatCard, // required — read-only render (viewer + editor fallback)
editor: StatCardConfig, // optional — editable variant in the editor
insert: { // optional — toolbar insert affordance
label: 'Stat card',
icon: 'layout-grid',
},
},
};
```
| Field | Type | Description |
|---|---|---|
| `viewer` | `Component` | **Required.** Read-only render for this key. Used by the viewer, and as the editor's fallback when no `editor` is supplied. Receives the directive attributes as props. |
| `editor` | `Component` | *Optional.* Editable variant mounted in the editor. Receives a single `controller` prop — see [The editor contract](#the-editor-contract). Falls back to `viewer` when omitted. |
| `insert` | `EmbedInsertIntegration` | *Optional.* Toolbar insert affordance — `{ label?, icon?, pick? }`. See [Toolbar insert](#toolbar-insert). |
::: tip `markRaw` your components
Wrap component definitions in `markRaw(...)` when building the registry so Vue
doesn't make them reactive: `{ viewer: markRaw(StatCard) }`.
:::
## Viewer
Pass `embeds` to ``. The directive renders through the registered
`viewer`, read-only:
```vue
```
An app-wide default works too — `app.provide(MARKDOWN_EMBEDS_KEY, embeds)`. A
per-instance `embeds` prop wins over the provided value.
## Editor
Custom embeds are **non-portable**, so they're gated behind the `cocoar`
[flavor](/components/markdown-editor#flavors-portability) (or an explicit
`{ embeds: true }` capability). Pass the same registry via `embeds`:
```vue
```
The editor folds a `:::key{…}` line into an **atomic block** rendered by a live
NodeView. When the registry entry provides an `editor` component it's mounted
editable; otherwise the read-only `viewer` is shown. Either way the directive
round-trips on save.
### The editor contract
An editor component receives a single, typed `controller` prop — there is no
`v-model` emit string to remember. The controller carries the current attributes
and the write-back methods:
```ts
interface EmbedEditorController = Record> {
readonly props: Readonly; // current directive attributes
update(next: T): void; // replace the whole bag → writes to markdown
patch(partial: Partial): void; // merge a partial patch (the common case)
}
interface EmbedEditorProps = Record> {
controller: EmbedEditorController;
}
```
Type your component with `EmbedEditorProps` and call `controller.patch(...)` to
write changes back. Those writes flow into the ProseMirror node and round-trip to
the `:::key{props}` markdown — which is the **only** difference between an editor
and the viewer: the viewer renders an immutable parsed document and cannot write.
```vue
```
::: tip Any edit UX — inline, modal, picker
The editor component is yours. It can edit inline (as above), or render the
read-only preview plus an **Edit** button that opens a `useDialog()` modal and
calls `controller.update(next)` on save. The library only provides the write
channel; the configuration UI is the embed's to design.
:::
::: info Generic attribute typing
`EmbedEditorProps` is generic, so a specific embed can type its own bag —
`defineProps>()` — instead of the
default `Record`.
:::
## Toolbar insert
Give a registry entry an `insert` and it can be placed in the toolbar. The
**registry** defines the *item* (icon, label, behaviour); the editor's
[`tools`](/components/markdown-editor#toolbar-layout-tools) layout decides
*where* it appears — referenced by `embed:`:
```ts
const tools: CoarMarkdownEditorToolEntry[] = [
'bold', 'italic', 'headings',
'divider',
{ flyout: ['embed:stat'], label: 'Insert', icon: 'plus' }, // submenu, builtin + embed mixable
'divider',
'undo', 'redo',
];
```
```vue
```
::: warning Insert lives in the sidebar
Like the table / image / code-block buttons, the insert item is a **sidebar**
tool — use `toolbar-mode="fixed"` or `"both"` so the rail is visible. Embeds are
block inserts, not text formatting, so they never appear in the floating toolbar.
:::
`EmbedInsertIntegration`:
| Field | Type | Description |
|---|---|---|
| `label` | `string` | Item label / tooltip. Defaults to the key. |
| `icon` | `string` | `CoarIcon` name. Defaults to `layout-grid`. |
| `pick` | `() => Promise \| null> \| Record \| null` | *Optional.* Resolve the **start attributes** for a new embed — e.g. open a picker dialog. Return `null` to cancel. When omitted, a bare `:::key` is inserted. |
A `pick` callback is where you'd open a chooser (which map? which chart?) and
return its props, e.g. `() => ({ id: chosenGuid })` → inserts `:::map{id=…}`.
## Unknown keys
A `:::key{…}` whose key isn't registered still parses and round-trips — it just
renders as a labelled placeholder (`🧩 Unknown embed: :::key`) in both the viewer
and the editor, so the author sees that an embed is there instead of a blank gap.
This also means a document moved to an app that hasn't registered the embed
degrades gracefully rather than losing content.
## Security
In the JS renderer, attribute values are bound as **Vue props / text**, never via
`innerHTML` — so untrusted author text (a label like ` `)
is inert by construction; no manual HTML escaping is needed. (A server-side
string-lowering renderer in another language must escape on its own.)
## API reference
### `@cocoar/vue-markdown-core`
| Export | Description |
|---|---|
| `parseEmbedDirective(line)` | Parse a single line into `{ key, props } \| null`. |
| `serializeEmbedDirective({ key, props })` | Serialize back to the canonical `:::key{props}` form. |
| `toEmbedProps(value)` | Coerce an unknown value into a clean `Record`. |
| `'embed'` node type | Added to `MarkdownNodeType`; `attrs` carry `{ key, props }`. |
### `@cocoar/vue-markdown`
| Export | Description |
|---|---|
| `EmbedRegistry` | `Record` — the key → definition map. |
| `EmbedDefinition` | `{ viewer, editor?, insert? }`. |
| `EmbedEditorProps` / `EmbedEditorController` | The editor component's `controller` contract. |
| `EmbedInsertIntegration` | `{ label?, icon?, pick? }`. |
| `MARKDOWN_EMBEDS_KEY` | Inject key for an app-wide registry. |
| `EmbedRenderer` | The shared resolve-and-render component (used internally by viewer + editor). |
### `@cocoar/vue-markdown-editor`
| Export | Description |
|---|---|
| `CoarMarkdownEditorToolEntry` | A `tools` entry: a ref, a `{ flyout }` group, or `'divider'`. |
| `CoarMarkdownEditorToolRef` | `CoarMarkdownEditorTool \| ` `` `embed:${string}` ``. |
| `CoarMarkdownEditorToolFlyout` | `{ flyout, label?, icon? }`. |
All embed types are re-exported from `@cocoar/vue-markdown-editor`, so a consumer
can import everything from one place.
---
---
url: /components/markdown-diagrams.md
description: >-
@cocoar/vue-markdown-mermaid — renders mermaid code fences in CoarMarkdown as
Cocoar-themed diagrams; lazy-loaded, strict security, degrades to plain code
blocks
---
# Diagrams
Render diagrams inside markdown from a fenced code block — \`\`\`mermaid
— using [`@cocoar/vue-markdown-mermaid`](https://www.npmjs.com/package/@cocoar/vue-markdown-mermaid).
The diagram source lives in the markdown as a normal code fence, so it
round-trips losslessly and **degrades to a readable code block** anywhere the
renderer isn't installed (strict CommonMark, a viewer-only build, a native mobile
renderer).
::: info Two mechanisms, two shapes
A **fenced code block** is for content whose *body is authored text in a DSL* —
diagrams, code. A [custom embed](/components/markdown-embeds) (`:::key{props}`) is
for a *single-line reference + a few props* rendered by a rich, visually-edited
component (e.g. a map). Diagrams belong in a fence; the map belongs in an embed.
:::
::: tip Two packages
The renderer lives in the standalone, markdown-free **`@cocoar/vue-mermaid`**
([``](/components/mermaid) — usable anywhere).
**`@cocoar/vue-markdown-mermaid`** is the thin adapter that registers it as a
fence renderer. Rendering a diagram **outside** markdown? See the
[**Mermaid Diagram**](/components/mermaid) page.
:::
## How it works
The markdown packages have **no dependency on Mermaid**. `` exposes
an open, language-keyed **fence-renderer registry**: register a component for a
fence language and that language renders through it instead of as a plain code
block. Installing `@cocoar/vue-markdown-mermaid` and registering it is the opt-in.
* **On disk** a diagram is a fenced code block with the `mermaid` info string.
It's ordinary CommonMark — nothing custom to parse.
* **Rendering** is client-only (Mermaid needs a DOM) and lazy — Mermaid is
dynamically imported on the first diagram, so viewer pages without one never
pay for it.
* **Theming** maps Cocoar design tokens onto Mermaid's `themeVariables`, so
diagrams match the app's fonts and palette.
* **Security**: Mermaid runs with `securityLevel: 'strict'` — author diagram text
is treated as untrusted (HTML in labels is sanitized).
* **Invalid diagrams** degrade to an error box that still shows the raw source;
they never throw up to the app.
## Install
```bash
pnpm add @cocoar/vue-markdown-mermaid
```
`vue` and `@cocoar/vue-markdown` are peer dependencies; `@cocoar/vue-mermaid`
(which carries Mermaid, dynamically imported on first render) comes along as a
regular dependency. Import its stylesheet once (diagram wrapper, error box, zoom
viewport):
```ts
import '@cocoar/vue-mermaid/styles';
```
## Usage
Pass the ready-made registry fragment to the viewer's `fenceRenderers` prop:
```vue
```
A diagram in the source is just a fenced code block (shown here inside a wider
fence so it isn't rendered):
````text
```mermaid
flowchart LR
A[Start] --> B{Choice}
B -->|yes| C[Do it]
B -->|no| D[Skip]
```
````
An app-wide default works too — `app.provide(MARKDOWN_FENCE_RENDERERS_KEY, mermaidFenceRenderers)`.
A per-instance `fence-renderers` prop wins over the provided value.
## Zoom & pan
Per-diagram options are configured on the **registry** (the fence contract only
passes `{ code, language }` to a component), via `createMermaidFenceRenderers`:
```ts
import { createMermaidFenceRenderers } from '@cocoar/vue-markdown-mermaid';
const fenceRenderers = createMermaidFenceRenderers({ zoomable: true });
```
Each diagram then sits in a fixed-height viewport with **+ / − / ⤢** controls,
**Ctrl/⌘ + wheel** zoom, **drag** to pan and **double-click** to reset. Plain
wheel and one-finger touch scrolling are left to the page, so a diagram never
traps the scroll. Set the height with the `--coar-mermaid-height` CSS variable
(default `420px`).
## Registering your own fence renderer
The registry is open — any language can be mapped to any component. Register your
own (e.g. a Graphviz renderer) alongside Mermaid, or replace Mermaid entirely:
```ts
import type { FenceRegistry } from '@cocoar/vue-markdown';
import { mermaidFenceRenderers } from '@cocoar/vue-markdown-mermaid';
import MyGraphviz from './MyGraphviz.vue';
// Component receives `{ code, language }` (FenceRendererProps).
const fenceRenderers: FenceRegistry = {
...mermaidFenceRenderers,
dot: MyGraphviz,
};
```
A registered component receives the fence's raw text as `code` and the info
string as `language`. This is the same seam a future BPMN or PlantUML renderer
would plug into — no change to the markdown core.
## API reference
### `@cocoar/vue-markdown`
| Export | Description |
|---|---|
| `FenceRegistry` | `Record` — fence language → renderer component. Keys match case-insensitively. |
| `FenceRendererProps` | `{ code: string; language: string }` — props a registered renderer receives. |
| `MARKDOWN_FENCE_RENDERERS_KEY` | Inject key for an app-wide registry. |
| `resolveFenceRenderer(registry, language)` | The case-insensitive lookup used by `DefaultCodeBlock`. |
### `@cocoar/vue-markdown-mermaid` (the fence adapter)
| Export | Description |
|---|---|
| `mermaidFenceRenderers` | Ready-to-spread `FenceRegistry` fragment (`{ mermaid }`), no zoom. |
| `createMermaidFenceRenderers(options?)` | Build a registry with options baked in — `{ zoomable }`. |
### `@cocoar/vue-mermaid` (the standalone renderer)
| Export | Description |
|---|---|
| `CoarMermaidDiagram` | The renderer component (`{ code, language, zoomable }` props). Use directly to render diagrams outside markdown. |
| `buildMermaidThemeVariables(getToken, resolveColor?)` | Pure Cocoar-token → Mermaid-theme mapping. |
| `makeCssColorResolver()` / `readCssTokens(el?)` | Browser-backed color/token resolvers for the bridge. |
---
---
url: /components/mermaid.md
description: >-
CoarMermaidDiagram — standalone Mermaid diagram component for Vue 3 rendering
from a source string; Cocoar-themed, lazily loaded, with opt-in zoom/pan
---
# Mermaid Diagram
A standalone [Mermaid](https://mermaid.js.org/) diagram component for Vue 3.
`` renders a diagram from a Mermaid source **string** —
Cocoar-themed, lazy-loaded, with opt-in zoom/pan.
It knows **nothing about markdown** or any embedding layer — feed it a diagram
source and it renders. To turn ` ```mermaid ` fenced code blocks inside
`` into diagrams, use the thin adapter on the
[**Markdown Diagrams**](/components/markdown-diagrams) page (it registers this
same component as a fence renderer).
::: info Separate package
```bash
pnpm add @cocoar/vue-mermaid
```
`vue` is the only peer dependency. Mermaid is a regular dependency, imported
**lazily** on first render (its own chunk). Import the stylesheet once:
```ts
import '@cocoar/vue-mermaid/styles';
```
:::
## Usage
```vue
```
## Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `code` | `string` | — | The Mermaid diagram source. |
| `language` | `string` | `'mermaid'` | Info-string label; handy when one component is reused for several fence keys. |
| `zoomable` | `boolean` | `false` | Enable the zoom/pan viewport. |
## How it works
* **Rendering is client-only** — Mermaid needs a DOM to measure and lay out, so
nothing renders on the server or before mount. (Wrap it in `` in an
SSR context like VitePress.)
* **Lazy** — Mermaid is dynamically imported on the first diagram, so pages
without one never pay for it.
* **Cocoar-themed** — design tokens map onto Mermaid's `themeVariables`. Cocoar
color tokens are `oklch(...)`, which Mermaid's parser can't read, so they're
normalized to sRGB first (via a 1×1 canvas).
* **Font-safe** — rendering waits for `document.fonts.ready`, so labels aren't
clipped by boxes that were measured before the web font loaded.
* **Security** — Mermaid runs with `securityLevel: 'strict'`; author diagram text
is treated as untrusted (HTML in labels is sanitized).
* **Invalid source degrades** to an error box that still shows the raw source —
it never throws up to the app.
::: tip Many diagrams on one page
Renders are serialized internally — `mermaid.render` shares global state and
isn't concurrency-safe, so several diagrams mounting at once would otherwise
corrupt each other.
:::
## Zoom & pan
With `zoomable`, the diagram sits in a fixed-height viewport with:
* **+ / − / ⤢ buttons** (top-right) — the primary, touch-friendly zoom;
* **Ctrl / ⌘ + wheel** — zoom toward the cursor;
* **drag** — pan (mouse / pen);
* **double-click** — reset.
Plain mouse-wheel scrolling is deliberately **not** captured, so a diagram never
traps the page scroll. On touch, one-finger scrolling still scrolls the page
(zoom via the buttons). Set the viewport height with the `--coar-mermaid-height`
CSS variable (default `420px`):
```css
.my-diagram { --coar-mermaid-height: 600px; }
```
## Theming
Theming is applied automatically from the ambient Cocoar tokens. The mapping is
exported as pure functions if you need to build your own Mermaid config:
| Export | Description |
|---|---|
| `buildMermaidThemeVariables(getToken, resolveColor?)` | Cocoar token getter → Mermaid `themeVariables`. |
| `readCssTokens(el?)` | `getComputedStyle`-backed token getter. |
| `makeCssColorResolver()` | Canvas-backed CSS-color → sRGB resolver (handles `oklch(...)`). |
## In markdown
To render diagrams from ` ```mermaid ` fences inside ``, don't wire
this component up by hand — install `@cocoar/vue-markdown-mermaid` and pass its
`mermaidFenceRenderers` to the viewer's `fenceRenderers` prop. See
[**Markdown Diagrams**](/components/markdown-diagrams).
---
---
url: /components/script-editor.md
description: >-
@cocoar/vue-script-editor — Monaco-based TypeScript/JavaScript/JSON editor
with v-model, custom type definitions, constrained mode with protected lines
and automatic theming
---
# Script Editor
A Monaco-based code editor for Vue 3 with Cocoar Design System theming. Supports **TypeScript, JavaScript, and JSON**, with first-class support for user-supplied type definitions (IntelliSense for your domain types).
::: info Separate Package
```bash
pnpm add @cocoar/vue-script-editor monaco-editor
```
`monaco-editor` is a peer dependency — consumers install and configure it themselves. This keeps the library bundle small and lets each app decide which Monaco languages and features to ship.
:::
## Worker Setup
Monaco offloads language services to Web Workers. Register them once **before any editor mounts**. Pick the pattern that matches your app's shape.
### SPA (client-only, Vite)
The common case. Register at application entry (`src/main.ts` or equivalent):
```ts
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
self.MonacoEnvironment = {
getWorker(_id, label) {
if (label === 'typescript' || label === 'javascript') return new TsWorker();
if (label === 'json') return new JsonWorker();
return new EditorWorker();
},
};
```
Omit the JSON branch if your app never uses `language="json"`.
### SSR / static-generation (VitePress, Nuxt, Astro)
Monaco touches `window` and the DOM, so it cannot run during server-side rendering. Defer both worker registration and the editor import to `onMounted`, and wrap the template in ``:
```vue
```
::: info Where does `` come from?
VitePress and Nuxt register `` globally. In a plain Vite SPA you don't need it — the SPA pattern above is simpler. In Astro use `client:only="vue"` on the component instead.
:::
Other bundlers (Webpack, Rollup, esbuild, or a CDN setup) use the same `self.MonacoEnvironment.getWorker` contract with different worker-import syntax — see [Monaco's official docs](https://github.com/microsoft/monaco-editor/tree/main/docs).
## Basic Usage
The editor exposes a plain `v-model` for the source text. Use `language` to choose between `'typescript'` (default), `'javascript'`, and `'json'`.
```vue
```
::: tip Sizing
The editor fills its parent container. Either pass a `height` prop (`"160px"`, `240`, `"40vh"`) or wrap in a parent with explicit height. The editor's own default `min-height: 200px` only applies when no parent height is set.
:::
## Form Integration
`CoarScriptEditor` is a full citizen of the Cocoar form ecosystem. Drop it inside `CoarFormField` and label, error message, `aria-describedby` wiring, and disabled state propagate automatically — the same way `CoarTextInput` and `CoarSelect` behave. Use `variant="inline"` for a compact form-field look (no line numbers, no gutter, tight padding) and `script-mode` to suppress the "top-level return/await" errors Monaco normally emits for full `.ts` programs.
`preamble` gives you per-editor type context without polluting the global TS namespace: the declaration lines render invisibly above the user script (hidden + locked), and `modelValue` only round-trips the user portion.
```vue
```
### `preamble` — per-editor type context
`preamble` is a hidden, auto-locked prefix prepended to the editor content. It's rendered invisibly, can't be edited or cursored into, and never appears in the emitted `modelValue`. The TypeScript service sees it as normal source, so IntelliSense resolves symbols declared inside it.
Typical use-case: your runtime executes the user's script as a function body with a specific set of bindings (`query`, `ctx`, `request`, …). Declare them in the preamble so the editor IntelliSense matches the runtime shape exactly:
```vue
```
**When to use `preamble` vs `extraLibs`:**
| Signal | Use `preamble` | Use `extraLibs` |
| ------------------------------------------------- | ---------------------------------------- | ---------------------------------- |
| Scope should be limited to this editor instance | ✅ | ❌ (globally ambient) |
| Different editors need different variable names | ✅ | ❌ |
| App-wide shared domain types, interfaces | ❌ | ✅ |
| You want the declaration to match a runtime shape | ✅ (declaration is literal code) | 〰️ (works if wrapped in `declare global`) |
The two are complementary — most real forms use `extraLibs` for interfaces (`TodoQuery`, `Todo`, …) and `preamble` for the bindings that actually exist at runtime (`declare const query: TodoQuery`).
### `script-mode` — suppress script-body diagnostics
Enables suppression of the diagnostic codes TypeScript emits for "script body" constructs that are invalid in a full program but expected in an executable snippet:
| Code | Suppressed meaning |
| ------ | ----------------------------------------------------------------------------- |
| `1108` | `return` statement outside a function |
| `1208` | Cannot use `export` in a non-module (`export {}` forces module scope) |
| `1375` | `await` allowed only in async functions |
| `2304` | Cannot find name … (when the user relies on pre-injected globals) |
| `2695` | Left-hand side of assignment is invalid |
| `7027` | Unreachable code detected |
::: warning Global side-effect
`script-mode` calls `monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions` which is **shared across every TS/JS editor on the page**. The codes are additive — Cocoar merges them into the existing ignore list and never clears them, so toggling `script-mode` off does not restore the diagnostics. If your app mixes "full program" editors with "script body" editors, use the escape-hatch (`monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions`) from the consumer side for finer control.
:::
### Compact `variant="inline"`
Flipping `variant` to `'inline'` restyles Monaco for form-field use — no line numbers, no glyph margin, folding off, context menu off, tight 8px padding, word wrap on, and a hover/focus ring that matches `CoarTextInput`.
```vue
```
Use `'editor'` (the default) whenever the editor is the page focus — IDE-like experience, line numbers, full gutter.
## Custom Type Definitions (`extraLibs`)
Inject `.d.ts` contents into the editor's TypeScript service to get autocomplete, hover types, and inline diagnostics for your domain objects — without polluting the global TS server.
```vue
```
Each entry maps to `monaco.languages.typescript.typescriptDefaults.addExtraLib(...)` (or `javascriptDefaults` in JS mode). Use a stable, unique `filePath` per lib — Monaco keys its entries on the path.
## Runtime lib configuration
Monaco ships with a default lib set of `es5 + dom + webworker.importscripts + scripthost` — surfacing thousands of browser APIs (`document.*`, `fetch`, `localStorage`, `WScript`, …) in IntelliSense. For script editors backed by a non-browser runtime (e.g. Jint/Edge.js in a .NET host), autocompleting those APIs would lure users into writing code that crashes at execution time.
`CoarScriptEditor` therefore forces Monaco to `lib: ['es2024']` the first time it's mounted, dropping the browser-specific libs and keeping only the standard ECMAScript surface. It also applies to both TS and JS defaults and sets `target: ES2024`, `allowNonTsExtensions: true`, `noResolve: true`.
Host-specific globals (e.g. your runtime's `fetch`, `require`, `exit`) should be layered on top via `extraLibs` — opt-in and explicit, so what Monaco shows matches what Jint can run.
If you need a different lib set for non-Jint scenarios, call `monaco.languages.typescript.typescriptDefaults.setCompilerOptions(...)` yourself **after** the first `CoarScriptEditor` has mounted — `setCompilerOptions` is a module-global last-writer-wins, so your override takes effect immediately for every editor on the page.
::: warning `filePath` must start with `file:///`
Monaco's TypeScript service silently ignores declarations registered under any other URI scheme, so a value like `'types/foo.d.ts'` will compile without error but produce no IntelliSense. In development mode the component emits a `console.warn` when it detects this, but there's no runtime error — it's easy to miss in production. Always prefix with `file:///`.
:::
::: warning Untrusted content
`extraLibs.content` is parsed by Monaco's TypeScript service as a `.d.ts` file — it is not `eval`'d, so arbitrary code in `content` cannot execute in the browser. Declaration files *can* however expose surprising types / module augmentations. If the content comes from untrusted sources (e.g. another tenant's template), treat it as you would any other user-generated data: validate server-side and consider sandboxing the editor in an iframe with a restrictive CSP.
:::
## JSON mode
Set `language="json"` to edit JSON with Monaco's native JSON services — syntax validation, bracket matching, format-on-save, and optional schema-based IntelliSense all work out of the box.
```vue
```
::: tip JSON schemas
`extraLibs` is TypeScript-specific and is ignored in JSON mode. For schema-driven validation and autocompletion, call Monaco's JSON defaults directly — typically once at app entry:
```ts
import * as monaco from 'monaco-editor';
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas: [
{
uri: 'https://my-app/schemas/config.json',
fileMatch: ['*.json'],
schema: { type: 'object', required: ['name'], properties: { name: { type: 'string' } } },
},
],
});
```
If you need per-editor schema attachment, use the `getEditor()` escape hatch to access the model's URI and register matching schemas.
:::
## Read-only & Minimap
Both boolean flags default to `false`. Toggle `readonly` for viewer-style contexts and `minimap` when long files benefit from the overview gutter.
## Constrained Mode (Protected Lines)
Any line of the source that contains `// @locked` is protected: the user cannot edit it, merge it with a neighbour, or delete it. Everything else — other lines, file-top imports, helpers between functions — is freely editable.
The TypeScript language service sees the whole file as one document, so IntelliSense, Auto-Import, and domain types resolve exactly as in a normal `.ts` file. Markers are plain line comments, so the stored value is also a valid `.ts` file in any toolchain.
```vue
```
### Why line-based `@locked` (and not open/close tags)
Three real-world benefits:
* **Auto-Import works.** When the user types an unresolved type, TypeScript's quickfix inserts the `import` at the top of the file — a non-locked line, so the edit passes. Locked lines below simply shift down with the rest of the text; Monaco's native text handling does the bookkeeping for us.
* **No pairing errors.** Each `// @locked` marker is self-contained; you can't accidentally leave one open or nest them wrong. Copy-pasting a function across templates "just works".
* **Helpers fit naturally.** The user can declare local types, utility functions, or blank lines between the locked signatures without asking permission.
### The model value is the persistence format
`v-model` always contains the full source *including* the `// @locked` comments. Save and reload the exact same string — nothing to serialize:
```ts
// Save
localStorage.setItem('snippet', code.value);
// Load (later)
code.value = localStorage.getItem('snippet') ?? defaultTemplate;
```
::: tip Free mode
Without any `// @locked` markers the editor behaves like a regular code editor — no guards, no decorations. Adding a marker to any line turns enforcement on for just that line.
:::
### Reacting to rejected edits
Bind `@reject` to surface user feedback when an edit was rolled back:
```vue
```
### Authoring mode (`authoring` prop)
Template authors need to edit the protected parts too. Pass `:authoring="true"` to suspend enforcement: locked lines become editable, markers render at full size in a warm accent colour, and the author can add new markers or remove existing ones. Toggle the prop back to `false` and enforcement resumes with whatever markers are currently in the text.
```vue
{{ editing ? 'Exit authoring mode' : 'Enter authoring mode' }}
```
The demo above exposes the same toggle inline so you can flip between the modes.
### How it works
* **Marker detection**: `/\/\/\s*@locked\b/` — a line is locked if it contains `// @locked` anywhere. Match is case-sensitive; `@lockedx` does not count.
* **Protected range**: every locked line is protected inclusively, including its trailing newline. Backspace at the start of a line below a locked one, Delete at the end of a line above — both blocked.
* **Overlap check**: if any change in the batch intersects any protected range, the entire multi-cursor batch is rolled back via `editor.trigger('undo')`. An internal stack boundary keeps the rejected edit from coalescing with surrounding legal edits.
* **Cursor guard**: cursors that land inside a locked line snap to the nearer free boundary. Silenced when `authoring` is on.
* **Diagnostics filter**: error-severity markers emitted on locked lines are suppressed — an in-progress body that makes TypeScript mark the signature as broken won't surface that to the user, because they can't fix it anyway. Warnings and info remain; the filter also stands down in `authoring` mode so authors see everything.
* **Auto-features policy**: Monaco's `formatOnType`, `formatOnPaste`, and `linkedEditing` are turned off in constrained mode so cross-boundary reformats don't generate confusing rejections. Auto-Import (the lightbulb quickfix) is deliberately left on — its edits go to the file top, which is virtually always outside any lock.
* **Rejections**: `@reject` emits an object `{ reason, range? }` so you can surface a toast, shake animation, or highlight the affected line range. See the Events section below for the full payload.
::: warning Editor-option override
The auto-feature overrides above run on mount when constrained mode activates. If you call `editorRef.value?.getEditor().updateOptions({ formatOnType: true })` from consumer code, the override will re-apply next time constrained mode is set up (e.g. when `modelValue` gains or loses `// @locked` markers). If you need to re-enable these features for a constrained-mode editor, file an issue describing your use case — we may add an opt-out prop.
:::
### Styling
Two CSS variable groups, set per editor root and overridden automatically by the `coar-script-editor--authoring` class:
```css
.coar-script-editor {
--coar-script-editor-marker-scale: 0.6; /* relative to editor font */
--coar-script-editor-marker-opacity: 0.45;
--coar-script-editor-marker-color: var(--coar-text-neutral-tertiary);
--coar-script-editor-locked-line-bg: /* subtle tint */;
}
.coar-script-editor--authoring {
--coar-script-editor-marker-scale: 1;
--coar-script-editor-marker-opacity: 0.85;
--coar-script-editor-marker-color: var(--coar-text-warning);
--coar-script-editor-locked-line-bg: /* warm tint */;
}
```
Override per-editor via inline style or globally via your theme stylesheet.
### Pure helpers
Available without mounting an editor — use them for validation, on the server, or in tests:
```ts
import {
hasLockedMarkers,
scanLockedLines,
computeProtectedRanges,
getEditableSegments,
getSlots,
getSlot,
editIsProtected,
snapOffsetAwayFromLocked,
countLockedLines,
isEverySegmentNonEmpty,
validateSource,
SLOT_MARKER_PATTERN,
} from '@cocoar/vue-script-editor';
// Structural queries
if (hasLockedMarkers(source)) { /* ... constrained ... */ }
const n = countLockedLines(source);
const lines = scanLockedLines(source); // per locked line
const ranges = computeProtectedRanges(lines); // merged blocks for overlap/snap
// Segmentation
const segments = getEditableSegments(source); // stretches between locks
// Named slots (per-region access by name; see the Named slots section)
const allSlots = getSlots(source); // { slotName: bodyContent }
const fn2Body = getSlot(source, 'fn2'); // string | undefined
// Submit-gating
if (isEverySegmentNonEmpty(source)) {
submit(source);
}
// Soft validation (non-throwing, surfaces informational warnings)
const v: SourceValidation = validateSource(source);
// v.ok — no warnings surfaced
// v.lockedLineCount — number of // @locked lines
// v.segmentCount — number of editable stretches between locks
// v.warnings — e.g. "source starts with a locked line" (imports can't be added above)
```
The full type shapes:
```ts
interface SourceValidation {
ok: boolean;
lockedLineCount: number;
segmentCount: number;
warnings: string[];
}
interface LockedLine {
lineIndex: number; // 0-based
lineStart: number; // char offset of first line char
lineEnd: number; // offset of trailing `\n` (or source.length for last line)
protectedStart: number; // inclusive start of the protected range
protectedEnd: number; // inclusive end (covers the `\n`)
snapBefore: number | null; // cursor-snap target before, null for first line
snapAfter: number | null; // cursor-snap target after, null for last line
slotName?: string; // name parsed from @slot:NAME on this locked line
}
interface ProtectedRange {
start: number;
end: number;
snapBefore: number | null;
snapAfter: number | null;
}
```
### Named slots (`@slot:NAME`)
Templates with multiple fillable regions — e.g. an event-handler script with three function bodies the user may or may not fill in — need a way to identify *which* body belongs to *which* function in the persisted source. Line-based locking alone tells you "this stretch is editable" but not "this is the body of `onLoad`".
The `@slot:NAME` attribute solves it. Placed on a `// @locked` line, it **names the editable segment that follows** (up to the next locked line or EOF):
```ts
function fn1(input) { // @locked @slot:fn1
} // @locked
function fn2(input) { // @locked @slot:fn2
} // @locked
function fn3(input) { // @locked @slot:fn3
return input * 2;
} // @locked
```
Because the marker sits on a **locked line**, the user cannot delete or move it — the slot anchor survives whatever edits the user makes to the bodies. Auto-Import inserts at the file top shift all slot markers down with the rest of the text; the scanner re-derives positions on each call.
#### Reading slot content
Two helpers, both pure functions over the source string:
```ts
import { getSlots, getSlot } from '@cocoar/vue-script-editor';
// All slots as a dictionary, slot name → body content
const slots = getSlots(code.value);
// { fn1: '', fn2: '', fn3: ' return input * 2;' }
// A single slot, or `undefined` if the template does not declare it
const fn2Body = getSlot(code.value, 'fn2');
// '' — declared but not filled in
const missing = getSlot(code.value, 'fn4');
// undefined — template does not have this slot
```
* **Empty string** (`''`) = slot exists but body is whitespace-only (user skipped it). Check with `content.trim().length === 0`.
* **`undefined`** = slot not declared in the template. Lets callers distinguish "not part of the template" from "part of the template but empty".
* **First-wins on duplicates** — if two locked lines declare the same slot name, the first one's segment is returned. `validateSource()` warns on duplicates during template authoring.
* **Content trim rule**: leading and trailing blank lines are stripped; indentation of the remaining content is preserved, so multi-line bodies keep their shape.
#### Submit-gating by slot
```ts
const slots = getSlots(code.value);
const filled = Object.entries(slots)
.filter(([, body]) => body.trim().length > 0)
.map(([name]) => name);
if (filled.length === 0) {
// Block save — user hasn't filled in anything.
}
```
#### Symmetric parsing on the server
The slot format is regex-matchable, so consumers running the saved script server-side (e.g. a C# Jint host) can extract the same info without shipping JS. The regex is exported as `SLOT_MARKER_PATTERN`:
```ts
import { SLOT_MARKER_PATTERN } from '@cocoar/vue-script-editor';
// '\/\/\s*@locked\b[^\n]*?@slot:([A-Za-z_][A-Za-z0-9_-]*)'
```
Drop the helper below into your backend project as-is. It matches the JS implementation one-to-one: same regexes, same first-wins rule on duplicates, same CRLF normalization, same blank-line trimming:
```csharp
using System.Text.RegularExpressions;
public static class ScriptSlots
{
private static readonly Regex LockedMarker =
new(@"//\s*@locked\b", RegexOptions.Compiled);
private static readonly Regex SlotMarker =
new(@"//\s*@locked\b[^\n]*?@slot:([A-Za-z_][A-Za-z0-9_-]*)", RegexOptions.Compiled);
///
/// All named slots in the source keyed by slot name.
/// Empty string = slot exists but body is whitespace-only.
/// First-wins on duplicates.
///
public static Dictionary GetSlots(string source)
{
// Normalize CRLF so Windows-saved sources parse identically.
var lines = source.Replace("\r\n", "\n").Split('\n');
var result = new Dictionary(StringComparer.Ordinal);
for (int i = 0; i < lines.Length; i++)
{
var match = SlotMarker.Match(lines[i]);
if (!match.Success) continue;
var name = match.Groups[1].Value;
if (result.ContainsKey(name)) continue; // first-wins
// Find the next locked line (or EOF).
int end = i + 1;
while (end < lines.Length && !LockedMarker.IsMatch(lines[end])) end++;
var bodyLines = lines.Skip(i + 1).Take(end - i - 1);
result[name] = TrimBlankLines(string.Join("\n", bodyLines));
}
return result;
}
///
/// Content of a single slot. Returns null when no locked line declares that name.
/// Returns "" when the slot exists but its body is empty — callers distinguish
/// "not declared" from "declared but empty".
///
public static string? GetSlot(string source, string name)
=> GetSlots(source).TryGetValue(name, out var v) ? v : null;
private static string TrimBlankLines(string raw)
{
var lines = raw.Split('\n');
int start = 0, end = lines.Length;
while (start < end && string.IsNullOrWhiteSpace(lines[start])) start++;
while (end > start && string.IsNullOrWhiteSpace(lines[end - 1])) end--;
return string.Join("\n", lines.Skip(start).Take(end - start));
}
}
```
With this helper, the Jint host can decide per-function whether to invoke it:
```csharp
var source = await dbContext.Scripts
.Where(s => s.Id == id)
.Select(s => s.SourceCode)
.FirstAsync();
var slots = ScriptSlots.GetSlots(source);
var engine = new Engine().Execute(source);
foreach (var (name, body) in slots)
{
if (!string.IsNullOrWhiteSpace(body))
engine.Invoke(name, input);
}
```
Or pull a single handler directly:
```csharp
var onSave = ScriptSlots.GetSlot(source, "onSave");
if (!string.IsNullOrWhiteSpace(onSave))
engine.Invoke("onSave", input);
```
The C# port mirrors the JS behaviour exactly, so you can reuse the 13 slot-related test cases from `LockedLineScanner.test.ts` as a parity check — same input strings must produce the same outputs.
#### Slot name rules
* Must match `[A-Za-z_][A-Za-z0-9_-]*` — starts with a letter or underscore, then letters / digits / underscores / hyphens.
* Names that don't match (e.g. `@slot:1bad`) are ignored silently — the locked line still locks, but no slot is registered.
* Must sit on a `// @locked` line. A lone `// @slot:X` on a free line is not recognised, because the user could delete it.
### Limitations (v1)
* **Languages**: TypeScript, JavaScript, JSON. Other Monaco-supported languages (CSS, HTML, Markdown, SQL, etc.) work too if you register their workers, but the component is only tested against these three.
* **Per-line granularity.** Locking a specific *character range* inside a line is not supported; the whole line is locked.
* **Monaco auto-edits that cross a boundary are blocked.** Format Document over a locked line, Rename Symbol touching both a locked and a free stretch — the whole operation is rolled back. Usually what you want; flip `authoring` on if not.
* **Authoring toggle + stale markers**: when `authoring` flips from `false` to `true`, previously-suppressed error markers on locked lines reappear only after the next TypeScript analysis pass (i.e. the next edit). This is a minor UX quirk of Monaco's marker model and does not affect correctness.
## API
### Props
| Prop | Type | Default | Description |
| -------------- | --------------------------------------- | -------------- | ---------------------------------------------------------------------------------------- |
| `modelValue` | `string` | `''` | Editor source. Any line containing `// @locked` is protected. |
| `authoring` | `boolean` | `false` | Authoring mode — suspends enforcement so template authors can modify locked lines or markers. |
| `language` | `'typescript' \| 'javascript' \| 'json'` | `'typescript'` | Language mode. Changing it switches the model live. |
| `readonly` | `boolean` | `false` | Viewer mode — user cannot edit but selection / copy / navigation still work. |
| `disabled` | `boolean` | `false` | Non-interactive form state. Dimmed, pointer-events suppressed, picked up from `CoarFormField`. |
| `error` | `boolean` | `false` | Error state — red border. Auto-picked up from `CoarFormField.error`. |
| `placeholder` | `string` | `''` | Placeholder shown when the editor is empty and not focused. |
| `required` | `boolean` | `false` | Sets `aria-required="true"`. Does not enforce submission. |
| `autofocus` | `boolean` | `false` | Focus the editor after mount. |
| `id` | `string` | `''` | HTML id. Auto-generated if omitted; `CoarFormField.id` takes precedence. |
| `name` | `string` | `''` | Informational. Emitted as `data-name` (the editor is not a native form control). |
| `height` | `string \| number` | `undefined` | Explicit height — CSS string (`"160px"`, `"40%"`) or pixels as number. |
| `variant` | `'editor' \| 'inline'` | `'editor'` | UI preset. `'editor'` = full IDE chrome. `'inline'` = compact form-field look. |
| `lineNumbers` | `boolean` | `undefined` | Explicit line-numbers toggle. Overrides the variant default. Off-state keeps a small left margin so text doesn't hit the border. |
| `scriptMode` | `boolean` | `false` | Suppresses TS/JS diagnostics for "script body" code. Global side-effect — see Form Integration. |
| `preamble` | `string` | `''` | Hidden + locked prefix providing per-editor type context. Does not round-trip through `modelValue`. |
| `minimap` | `boolean` | `false` | Show the Monaco minimap gutter. |
| `theme` | `'auto' \| 'light' \| 'dark'` | `'auto'` | `auto` tracks `.dark-mode` class on ``/``, `data-theme="dark"`, then OS `prefers-color-scheme` — reactively. See Theming below. |
| `extraLibs` | `CoarScriptEditorExtraLib[]` | `[]` | TypeScript declarations available for IntelliSense. |
### Events
| Event | Payload | Description |
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------ |
| `update:modelValue` | `string` | Full editor text. Markers stay in the value so it round-trips. Preamble is stripped before emit. |
| `reject` | `CoarScriptEditorRejectEvent` | Emitted when an edit was rolled back. See the payload shape below. |
| `focused` | `void` | Fired when the editor widget gains focus (including suggestion popup). |
| `blurred` | `void` | Fired when the editor widget loses focus — use this to trigger form-touched state. |
```ts
interface CoarScriptEditorRejectEvent {
reason: CoarScriptEditorRejectReason;
/** 1-based line range of the rejected edit (from Monaco). */
range?: { startLineNumber: number; endLineNumber: number };
}
// Currently a single value; the type is an open union so consumers pattern-match forward-compatibly.
type CoarScriptEditorRejectReason = 'edit-overlaps-locked-line';
```
### Types
```ts
interface CoarScriptEditorExtraLib {
content: string; // .d.ts source
filePath: string; // e.g. 'file:///types/app-context.d.ts'
}
type CoarScriptEditorLanguage = 'typescript' | 'javascript' | 'json';
type CoarScriptEditorTheme = 'auto' | 'light' | 'dark';
```
### Exposed Methods
```ts
const editorRef = ref | null>(null);
// Standard helper
editorRef.value?.focus();
// Escape-hatch access to the raw Monaco editor instance and its text model
editorRef.value?.getEditor();
editorRef.value?.getModel();
```
Use `getEditor()` / `getModel()` for APIs not covered by the declarative props — markers, custom commands, folding ranges, formatting actions, etc.
## Theming
Two Monaco themes ship with the package — `coar-light` and `coar-dark`. They're registered via `monaco.editor.defineTheme` the first time any editor mounts.
### How `theme="auto"` decides
Monaco's theme is not CSS-driven — it's switched via an imperative `monaco.editor.setTheme()` call. `auto` mode watches the page for common dark-mode signals and calls `setTheme` whenever any of them changes. The resolution order is:
1. `.dark-mode` class on `` or `` → dark (Cocoar convention)
2. `.dark` class on `` or `` → dark
3. `data-theme="dark"` / `data-theme="light"` attribute on `` or ``
4. OS-level `prefers-color-scheme`
All four sources are watched live via `MutationObserver` + `matchMedia` listeners, so toggling your app's theme switcher flips the editor in the same frame.
::: tip Custom theme switchers
If your app uses a different convention (e.g. a Pinia store driving a root attribute), skip `auto` and bind the prop directly:
```vue
```
Monaco's `setTheme` is called whenever the prop changes, so this is the cheapest integration.
:::
The editor container surfaces these CSS custom properties:
```css
.coar-script-editor {
--coar-border-neutral-tertiary: /* editor border */;
--coar-background-neutral-primary: /* editor surface */;
--coar-radius-xs: /* rounded corners */;
}
```
To register your own Monaco theme, call `monaco.editor.defineTheme('my-theme', {...})` anywhere in your app and pass it via the underlying editor instance (`editorRef.value?.getEditor().updateOptions({ theme: 'my-theme' })`).
### Font
The editor renders code in **Cascadia Code** (Microsoft's ligature-enabled coding font) with `Consolas`, `Monaco`, `Courier New` as fallback. Ligatures are enabled by default, so `!=`, `=>`, `===`, and `&&` render as combined glyphs. This matches `CoarCodeBlock` — both components share the same font stack.
Cascadia Code is bundled via `@cocoar/vue-ui/fonts` (weights 400 / 600 / 700). If your app imports that stylesheet — the standard Cocoar setup — the font loads automatically:
```ts
import '@cocoar/vue-ui/fonts'
import '@cocoar/vue-ui/styles'
```
If you don't import `@cocoar/vue-ui/fonts` (e.g. an app that only uses the script editor), Monaco falls back to Consolas/Monaco/Courier New. The editor keeps working; you just don't get the Cascadia Code glyphs or ligatures.
To override the font — e.g. to a custom corporate monospace — use the `getEditor()` escape hatch:
```ts
editorRef.value?.getEditor()?.updateOptions({
fontFamily: "'JetBrains Mono', monospace",
fontLigatures: true,
});
```
---
---
url: /components/data-grid.md
description: >-
CoarDataGrid — AG Grid-based data grid with fluent builder API, locale-aware
column types, wrapper-column decorations and Cocoar theming with dark mode.
---
# Data Grid
A powerful data grid built on AG Grid with Cocoar theming. Configure columns, sorting, selection, and cell renderers through a fluent builder API — no raw AG Grid config needed.
::: info Separate Package
The Data Grid depends on AG Grid. Install it separately:
```bash
pnpm add @cocoar/vue-data-grid ag-grid-community ag-grid-vue3
```
:::
```ts
import { CoarDataGrid, CoarGridBuilder } from '@cocoar/vue-data-grid';
```
## Basic Usage
Define columns with `.field()`, `.header()`, and `.flex()` / `.width()`. Pass row data with `.rowData()`.
## Appearance
Add a border or elevation shadow to the grid. Toggle the checkboxes to see the effect.
### Dark mode
Dark styles ship with the package. The theme maps AG Grid's variables onto the semantic `--coar-*` tokens (with hardcoded fallbacks), so the grid follows your design-system theme in both modes. Dark values activate via the `.dark-mode` class — on `` or any ancestor (the Cocoar convention, same as `@cocoar/vue-ui`), or directly on the grid element. `[data-theme="dark"]` is **not** a trigger here.
## Column Types
Built-in renderers for dates, numbers, currency, tags, and icons — no custom cell components needed. Date, number, and currency columns are locale-aware and update reactively when the locale changes. Try the locale switcher in the nav bar.
| Method | Description |
|--------|-------------|
| `.field(name)` | Plain text column |
| `.date(field, config?)` | Locale-aware date display |
| `.number(field, config?)` | Locale-aware number display |
| `.currency(field, config?)` | Locale-aware currency display |
| `.tag(field, config)` | Renders a `CoarTag` with variant mapping or custom colors |
| `.icon(field, config?)` | Renders a `CoarIcon` |
| `.wrap(inner)` | Wraps any column builder with left/right decoration slots |
## Wrapper Column
Decorate any column with left and/or right slots — perfect for status indicators, action icons, or inline badges. The inner column keeps all its behavior (sort, filter, edit, `valueFormatter`, custom `cellRenderer`, …); only rendering gets an extra frame around it.
Each slot accepts one of three shapes:
```ts
// 1) Icon shorthand
.left({
icon: (row) => row.starred ? 'star' : 'star-outline',
color: (row) => row.starred ? '#f5a623' : '#ccc',
tooltip: (row) => row.starred ? 'Unstar' : 'Star',
onClick: (row, event) => toggleStar(row),
show: (row) => row.visible, // optional v-if gate
})
// 2) Any Vue component
// The component automatically receives `row: TData` as a prop —
// use `params(row)` to add or override props.
.right({
component: CoarBadge,
params: (row) => ({ content: String(row.unread) }),
show: (row) => row.unread > 0,
})
// 3) Plain text
.right({ text: (row) => row.suffix })
```
### Multiple items per slot
Pass an array to stack several items in the same slot — each with its own `show()` gate, `onClick`, and tooltip. Items are rendered in order with a small gap.
```ts
.right([
{ icon: 'circle-alert', color: '#dc2626', show: (r) => r.isCritical },
{ icon: 'message-circle', color: '#3b82f6', show: (r) => r.awaitingFeedback },
{ component: PriorityIndicator }, // receives `row` automatically
])
```
### Row-aware components
Every component slot automatically receives `row: TData` as a prop. This lets a single component decide what to render — icon, tag, or nothing — based on the full row:
```ts
const PriorityIndicator = defineComponent({
props: { row: { type: Object as () => Message, required: true } },
setup(props) {
return () => {
if (props.row.priority === 'high') return h(CoarTag, { variant: 'error' }, () => 'HIGH');
if (props.row.priority === 'low') return h(CoarIcon, { name: 'arrow-down' });
return null;
};
},
});
```
Slot `onClick` handlers automatically call `event.stopPropagation()` so they don't trigger row-click or cell-click events on the grid.
## Row Selection
Toggle between single-click and multi-select with checkboxes.
## Reactive Data
Bind a `ref` with `.rowDataRef()` and the grid updates automatically when your data changes.
## Search (Quick Filter)
Enable the built-in search bar with `show-search`. It wires the search input to the builder's quick filter automatically.
### Custom Layout
Use `CoarDataGridSearch` and `CoarDataGrid` separately for full layout control. Connect them via `builder.quickFilterText(ref)`.
### Per-Column Configuration
Control how each column participates in quick filtering:
```ts
builder.columns([
// Default: searches by String(value)
(col) => col.field('name').header('Name'),
// Custom text extraction (e.g., for arrays or objects)
(col) => col.field('tags').quickFilter((tags) => tags.map(t => t.label).join(' ')),
// Exclude from search
(col) => col.field('id').quickFilter(false),
]);
```
### Custom Filter Function
Override the default per-column matching with a fully custom filter:
```ts
builder.quickFilterFn((searchValue, data) => {
// searchValue is already lowercased and trimmed
return data.name.toLowerCase().includes(searchValue)
|| data.email.toLowerCase().includes(searchValue);
});
```
### Search Highlighting
Enable text highlighting in grid cells using the CSS Custom Highlight API. Matching text is underlined without modifying the DOM.
```ts
builder
.quickFilterText(searchRef)
.searchHighlight()
```
The highlight style can be customized via CSS:
```css
::highlight(coar-search) {
text-decoration: underline;
text-decoration-color: #0066cc;
}
```
## I18n Headers
Column headers support runtime language switching via `@cocoar/vue-localization`. Pass a fallback text and an optional translation key:
```ts
builder.columns([
// Static header
(col) => col.field('name').header('Name'),
// With i18n — falls back to 'Name' if no translation found
(col) => col.field('name').header('Name', 'todo.grid.header.title'),
])
```
If `@cocoar/vue-localization` is not installed, the fallback text is always shown. Headers update automatically when the language changes at runtime.
## Auto Size
Control how columns are sized initially:
```ts
// Columns fill the grid width (most common)
builder.autoSize('fitGridWidth')
// Columns fit their content
builder.autoSize('fitCellContents')
```
## Tree Drag & Drop
Move rows between parents via drag & drop. Use `.rowDrag()` on the tree column, `.rowDragHighlight()` for visual feedback, and `.onRowDragEnd()` to handle the reparenting.
```ts
builder
.treeData({ children: (r) => r.children ?? [], rowId: (r) => r.id })
.openRows(openRows)
.rowDragHighlight()
.onRowDragEnd((event) => {
const dragged = event.node.data;
const target = event.overNode?.data;
if (!dragged || !target) return;
// API call or store mutation to reparent
api.moveInto(dragged.id, target.id);
});
```
## Tree Data
Display hierarchical data with expand/collapse. Use `treeData()` with nested children arrays and `openRows()` to control expansion. The `tree()` column type renders indentation, chevron toggle, and child count.
Search automatically expands matching branches — a parent stays visible when any descendant matches.
```ts
builder
.treeData({
children: (row) => row.children ?? [],
rowId: (row) => row.id,
})
.openRows(openRowsRef)
.columns([
(col) => col.tree('name').header('Name').flex(1), // tree column
(col) => col.field('size').header('Size').width(100),
])
```
## Row Drag & Drop
Reorder rows via drag & drop. Use `.rowDrag()` on a column to show the drag handle, and `.rowDragManaged()` on the builder. Dragging is automatically disabled when a column sort is active.
```ts
builder
.columns([
(col) => col.field('name').rowDrag().flex(1),
])
.rowDragManaged()
.onRowDragEnd(() => {
const newOrder = builder.getDisplayedRowData();
store.updateOrder(newOrder); // persist new order
});
```
## Sorting
Make columns sortable and set a default sort order.
```ts
const builder = CoarGridBuilder.create()
.columns([
(col) => col.field('name').header('Name').flex(1).sortable(),
(col) => col.number('salary').header('Salary').width(120).sortable(),
])
.rowData(data)
.defaultSort('name', 'asc');
```
## Column Persistence
Persist column widths, order, visibility, and sort in IndexedDB with `.persistColumnState(key)`.
**Width buckets:** The grid container width is rounded to buckets (default: 100px). Each bucket gets its own saved column layout, so different container sizes — switching monitors, collapsing a sidebar — each keep their own column widths. When no exact bucket exists, the nearest saved state is applied.
**Live sync:** Multiple grids with the same key synchronize column changes instantly. Resize, reorder, or hide a column in one grid and all others update immediately. Useful for comparison views with different filters on the same data structure.
Try it below — resize a column in Team A and watch Team B follow.
```ts
const builder = CoarGridBuilder.create()
.persistColumnState('my-users-grid')
.columns([...])
// Optional: custom bucket size and debounce
.persistColumnState('my-grid', { bucketSize: 200, debounceMs: 1000 })
// Reset current bucket
builder.resetPersistedState()
// Reset all buckets
builder.resetPersistedStates()
```
### Cleanup
Persisted entries are timestamped on every read and write. Call `cleanupColumnStates()` once at application startup to remove stale entries and prevent unbounded growth:
```ts
// main.ts
import { cleanupColumnStates } from '@cocoar/vue-data-grid';
cleanupColumnStates(180); // Remove entries older than 6 months
```
## API
### CoarDataGrid Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `builder` | `CoarGridBuilder` | — | Grid configuration builder (required) |
| `theme` | `Theme` | `cocoarTheme` | AG Grid theme override |
| `showSearch` | `boolean` | `false` | Show the search bar in the toolbar |
| `searchPlaceholder` | `string` | `'Search...'` | Placeholder for the search input |
| `searchSize` | `'xs' \| 's' \| 'm' \| 'l'` | `'m'` | Search input size |
| `search` | `string` | `''` | Search text (`v-model:search`) |
| `bordered` | `boolean` | `false` | Show a border around the grid |
| `elevated` | `boolean` | `false` | Add elevation shadow |
### CoarDataGrid Slots
| Slot | Description |
|------|-------------|
| `toolbar-left` | Content on the left side of the toolbar (e.g., title, icon) |
| `toolbar-right` | Content on the right side of the toolbar (e.g., buttons, actions) |
The toolbar appears automatically when `showSearch` is enabled or any `toolbar-*` slot is used. The search input fills available space (`flex: 1`). When search is disabled, a spacer pushes `toolbar-right` to the far right.
```vue
Users
Add User
Export
```
### CoarGridBuilder Methods
| Method | Parameters | Description |
|--------|-----------|-------------|
| `.columns(defs)` | `ColumnDefFn[]` | Define column configuration |
| `.rowData(data)` | `T[]` | Set static row data |
| `.rowDataRef(ref)` | `Ref` | Bind reactive row data |
| `.quickFilterText(ref)` | `Ref` | Bind search text for quick filtering |
| `.quickFilterFn(fn)` | `(search, data) => boolean` | Custom filter function override |
| `.searchHighlight()` | — | Highlight matching text via CSS Custom Highlight API |
| `.rowDragManaged()` | — | Enable managed drag & drop reordering |
| `.onRowDragEnd(fn)` | `(event) => void` | Handle drag end, persist new order |
| `.rowDragHighlight(opts?)` | `{ canDrop? }` | Visual drop target feedback with validation |
| `.getDisplayedRowData()` | — | Get row data in current display order |
| `.getTreeMeta(rowId)` | `string` | Get tree node depth, children info |
| `.treeData(config)` | `TreeDataConfig` | Enable tree mode with nested children |
| `.openRows(ref)` | `Ref` | Reactive ref of expanded row IDs |
| `.autoSize(strategy)` | `'fitGridWidth' \| 'fitCellContents'` | Column auto-sizing strategy |
| `.rowSelection(mode, opts?)` | `'single' \| 'multiple'` | Enable row selection |
| `.defaultSort(field, dir)` | `string, 'asc' \| 'desc'` | Set default sort column |
| `.persistColumnState(key, opts?)` | `string, ColumnPersistenceOptions?` | Persist column state in IndexedDB with width-based buckets |
| `.resetPersistedState(bucket?)` | `number?` | Reset persisted state for a specific bucket (defaults to current) |
| `.resetPersistedStates()` | — | Reset all persisted column states (all buckets) |
| `.rowClassRules(rules)` | `RowClassRules` | Conditional row CSS classes |
### Standalone Functions
| Function | Parameters | Description |
|----------|-----------|-------------|
| `cleanupColumnStates(maxAgeDays)` | `number` | Remove persisted column states older than `maxAgeDays`. Call at app startup. |
### CoarGridColumnBuilder Methods
| Method | Parameters | Description |
|--------|-----------|-------------|
| `.field(name)` | `keyof T` | Set column data field |
| `.header(text, i18nKey?)` | `string, string?` | Set header text with optional i18n key |
| `.flex(value)` | `number` | Flexible column width |
| `.width(px)` | `number` | Fixed column width |
| `.fixedWidth(px)` | `number` | Non-resizable fixed width |
| `.sortable()` | — | Enable column sorting |
| `.quickFilter(fn)` | `boolean \| (value, data) => string` | Configure quick filter for column |
| `.date(field, config?)` | `keyof T, DateCellRendererConfig?` | Locale-aware date cell renderer |
| `.number(field, config?)` | `keyof T, NumberCellRendererConfig?` | Locale-aware number cell renderer |
| `.currency(field, config?)` | `keyof T, CurrencyCellRendererConfig?` | Locale-aware currency cell renderer |
| `.tag(field, config)` | `keyof T, TagConfig` | Tag cell renderer |
| `.icon(field, config?)` | `keyof T, IconConfig?` | Icon cell renderer |
| `.tree(field, config?)` | `keyof T, TreeCellRendererConfig?` | Tree column with expand/collapse |
---
---
url: /components/data-grid/editing.md
description: >-
CoarDataGrid in-cell editing — editable() with per-row predicates, custom Vue
editors via cellEditorConfig, and onCellValueChanged for committed edits.
---
# Editing
In-cell editing is exposed through three builder methods that map directly onto AG Grid's editor lifecycle:
| Method | Level | Purpose |
|--------|-------|---------|
| `column.editable(value)` | column | Enable editing — `boolean` or `(row) => boolean` predicate |
| `column.cellEditorConfig(component, config)` | column | Plug in a custom Vue editor component (mirrors `cellRendererConfig`) |
| `gridBuilder.onCellValueChanged(handler)` | grid | React to a committed cell edit |
`editable()` and `cellEditorConfig()` are **orthogonal** — set both, otherwise the editor never opens. If you only set `editable(true)`, the cell uses AG Grid's built-in text editor.
## Default Editor
`.editable(true)` enables the default text editor. Editing starts on **double-click** (or pressing Enter / F2 with a cell focused). Enter commits, Escape cancels.
A row predicate gates editing per-row — locked items in the demo below skip the editor entirely:
```ts
(col) => col.field('name').editable(row => !row.locked)
```
`onCellValueChanged` fires once per committed edit and surfaces both the previous and new value. The demo updates a status line below the grid:
```ts
CoarGridBuilder.create()
.columns([
(col) => col.field('name').editable(row => !row.locked),
(col) => col.number('amount').editable(row => !row.locked),
])
.rowDataRef(data)
.onCellValueChanged((event) => {
saveField(event.data, event.colDef.field, event.newValue);
});
```
## Custom Cell Editor
`cellEditorConfig(component, config)` accepts any Vue component and wraps your `config` object under `params.config` — the exact same convention as `cellRendererConfig`.
The component must follow AG Grid's [editor contract](https://www.ag-grid.com/vue-data-grid/component-cell-editor/): receive a single `params` prop and expose a `getValue()` method via `defineExpose`. Cocoar does not ship editor wrappers — building them is consumer territory, since the appropriate input control (text, select, autocomplete, date picker, custom widget) is application-specific.
The minimal `SelectCellEditor.vue` used above:
```vue
{{ opt }}
```
Wire it into a column:
```ts
(col) =>
col.field('role')
.editable(true)
.cellEditorConfig(SelectCellEditor, {
options: ['Engineer', 'Designer', 'Manager'],
})
```
## Tips
**Single-click edit.** AG Grid's default is double-click. To enter edit on a single click, pass through the native option:
```ts
gridBuilder.option('singleClickEdit', true);
```
**Stop editing on focus loss.** Useful for forms where clicking outside should commit:
```ts
gridBuilder.stopEditingWhenCellsLoseFocus();
```
**Full-row editing.** Edit every cell in a row at once:
```ts
gridBuilder.fullRowEdit();
```
## API
### Column-level
| Method | Parameters | Description |
|--------|-----------|-------------|
| `.editable(value)` | `boolean \| (row: T) => boolean` | Enable editing statically or via row predicate. The predicate receives row data; rows without data (group rows etc.) return `false`. |
| `.cellEditorConfig(component, config)` | `Component, object` | Set custom cell editor. `config` is wrapped under `cellEditorParams.config`. |
### Grid-level
| Method | Parameters | Description |
|--------|-----------|-------------|
| `.onCellValueChanged(handler)` | `(event: CellValueChangedEvent) => void` | Fires once per committed cell edit. `event.oldValue`, `event.newValue`, `event.data`, `event.colDef.field`. |
| `.fullRowEdit(value?)` | `boolean` | Enable full-row editing mode. |
| `.stopEditingWhenCellsLoseFocus(value?)` | `boolean` | Commit the edit when focus leaves the cell. |
---
---
url: /components/data-grid/text.md
description: >-
CoarDataGrid text column — col.text() edits cells with CoarTextInput via
CoarTextCellEditor, with placeholder, maxLength, and per-row editable gating.
---
# Text Column
`col.text(field, configurator?)` declares a text column whose editor is `` — same visual language as forms, fitted into the cell.
```ts
import { CoarGridBuilder } from '@cocoar/vue-data-grid';
CoarGridBuilder.create().columns([
(col) => col.text('name').editable(true),
(col) => col.text('email', t => t.placeholder('user@example.com').maxLength(120)).editable(true),
])
```
Read-only display uses AG Grid's default text rendering — same as plain `.field()`. The shortcut adds:
* A configurable `CoarTextCellEditor` that opens on double-click / Enter / F2
* `sortable: true` by default
## Edit-mode flow
| Action | Result |
|--------|--------|
| Double-click cell (or Enter / F2) | Opens `CoarTextCellEditor` with focus on the input, existing value selected |
| Type a printable key on a focused cell | Opens editor seeded with that key (replace mode) |
| Tab | Commits + moves focus to the next editable cell, opening its editor automatically |
| Enter | Commits + stays |
| Escape | Cancels |
Toggles fire `cellValueChanged` like any other commit, so a single grid-level handler covers all column types.
## Example
```ts
CoarGridBuilder.create().columns([
(col) => col.text('name', t => t.placeholder('Name').maxLength(80)).editable(true),
(col) => col.text('email', t => t.placeholder('user@example.com').maxLength(120)).editable(true),
(col) => col.field('role'), // not editable
])
.stopEditingWhenCellsLoseFocus()
.onCellValueChanged(event => save(event.data, event.colDef.field, event.newValue));
```
## Layered overrides
```ts
// Replace the editor (drops the configurator)
col.text('name').editable(true).cellEditorConfig(MyCustomEditor, { ... })
// Keep the bundled editor, override editable
col.text('name').editable(row => !row.locked)
```
## API
### `col.text(field, configurator?)`
| Configurator method | Type | Description |
|--------|------|-------------|
| `.placeholder(value)` | `string` | Placeholder shown when input is empty |
| `.maxLength(value)` | `number` | Max input length |
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | Input size (default: `'s'`) |
| `.prefix(value)` | `string` | Text shown before the input value |
| `.suffix(value)` | `string` | Text shown after the input value |
Editor commits via `getValue()` per AG Grid's contract — Tab / Enter / Escape are handled by AG Grid's native edit-mode logic. Combine with `gridBuilder.stopEditingWhenCellsLoseFocus()` so clicking outside also commits.
---
---
url: /components/data-grid/number.md
description: >-
CoarDataGrid number column — col.number() formats locale-aware and edits with
CoarNumberCellEditor supporting min/max, decimals, step, and stepper buttons.
---
# Number Column
`col.number(field, …)` is locale-aware in both display and editing. Two forms — pick by need:
| Form | Effect |
|------|--------|
| `col.number('amount')` or `col.number('amount', { decimals: 2 })` | **Renderer only** — locale-formatted number, no editor (legacy / display-only) |
| `col.number('amount', n => n.decimals(2).min(0).max(100))` | **Renderer + editor** — same formatting plus `CoarNumberCellEditor` for in-cell editing |
The callback form bundles `CoarNumberCellEditor` automatically. Adding `.editable(true)` on the outer chain enables it.
## Example
```ts
CoarGridBuilder.create- ().columns([
(col) => col.field('product').flex(1),
(col) =>
col
.number('qty', n => n.min(0).max(9999).step(1).stepperButtons('both'))
.editable(true),
(col) =>
col
.number('price', n => n.decimals(2).min(0).step(0.01))
.editable(true),
])
.stopEditingWhenCellsLoseFocus()
.onCellValueChanged(event => save(event.data, event.colDef.field, event.newValue));
```
The renderer uses `useL10n().fmtNumber()` so the display reactively follows the active locale (try the locale switcher in the docs nav). The editor uses Maskito for locale-aware parsing — `1.234,56` in `de-AT` and `1,234.56` in `en-US` both yield the same numeric value.
## Edit-mode flow
| Action | Result |
|--------|--------|
| Double-click cell (or Enter / F2) | Opens `CoarNumberCellEditor` with focus, existing value selected |
| Type a digit / `.` / `,` / `-` on a focused cell | Opens editor seeded with that key |
| Tab | Commits + moves to the next editable cell |
| Enter | Commits + stays |
| Escape | Cancels |
## Layered overrides
```ts
// Override editor (e.g. add custom validation)
col.number('qty', n => n.min(0)).editable(true).cellEditorConfig(MyOwnEditor, { ... })
// Override editable per-row
col.number('qty', n => n.min(0)).editable(row => !row.archived)
```
## API
### `col.number(field, configOrCallback?)`
**Config-object form** (legacy — renderer only):
| Property | Type | Description |
|----------|------|-------------|
| `decimals` | `number` | Number of decimal places |
**Callback form** (renderer + editor — `NumberColumnConfigurator`):
| Method | Type | Renderer / Editor | Description |
|--------|------|-------------------|-------------|
| `.decimals(value)` | `number` | both | Decimal places |
| `.min(value)` | `number` | editor | Minimum allowed value |
| `.max(value)` | `number` | editor | Maximum allowed value |
| `.step(value)` | `number` | editor | Step increment for arrows / stepper |
| `.stepperButtons(value)` | `'none' \| 'increment' \| 'decrement' \| 'both'` | editor | Stepper button mode |
| `.placeholder(value)` | `string` | editor | Placeholder text |
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | editor | Input size (default: `'s'`) |
Editor commits via `getValue()` returning a `number | null`. Combine with `gridBuilder.stopEditingWhenCellsLoseFocus()` so clicking outside also commits.
---
---
url: /components/data-grid/select.md
description: >-
CoarDataGrid select column — col.select() renders the matched option label and
edits via CoarSelect with auto-commit on pick, clearable, searchable, and
row-aware options.
---
# Select Column
`col.select(field, configurator)` declares a select column whose renderer shows the **label** of the matched option and whose editor is `
` — same visual language as forms, with the dropdown teleported to `` so it can overflow the cell freely.
```ts
import { CoarGridBuilder } from '@cocoar/vue-data-grid';
const ROLES = [
{ value: 'eng', label: 'Engineer' },
{ value: 'des', label: 'Designer' },
{ value: 'mgr', label: 'Manager' },
];
CoarGridBuilder.create().columns([
(col) => col.field('name').flex(1),
(col) => col.select('role', s => s.options(ROLES)).editable(true),
])
```
The configurator's `options` is required — there's no useful "select column without options".
## Edit-mode flow
| Action | Result |
|--------|--------|
| Double-click cell (or Enter / F2) | Opens `CoarSelectCellEditor` and **auto-opens the dropdown** via `afterGuiAttached` |
| Click an option (or Enter on highlighted) | Auto-commits + exits edit mode in one step |
| Up / Down | Highlight previous / next option |
| Type a character (when `searchable: true`) | Filters the option list |
| Escape | Closes dropdown; pressing again cancels edit |
The auto-commit is intentional — for a select, picking an option **is** the edit. Tab-through-edit-mode still works on the surrounding cells; the select cell just commits faster than free-form text would.
## Example
```ts
CoarGridBuilder.create().columns([
(col) => col.field('name').flex(1),
// simple
(col) => col.select('role', s => s.options(ROLES)).editable(true),
// clearable + per-row gating (archived rows are read-only)
(col) =>
col.select('status', s => s.options(STATUSES).clearable())
.editable(row => row.status !== 'archived'),
// searchable for long lists
(col) =>
col.select('country', s => s.options(COUNTRIES).searchable())
.editable(true),
])
.stopEditingWhenCellsLoseFocus();
```
## Row-aware options
Pass a function instead of a static array to compute options per row:
```ts
col.select('parent', s =>
s.options(row => allowedParents(row.type))
).editable(true)
```
Both renderer (label lookup) and editor (dropdown options) call the function with the current row, so display and edit stay consistent.
## Layered overrides
```ts
// Replace the editor entirely (drops the configurator)
col.select('role', s => s.options(ROLES))
.editable(true)
.cellEditorConfig(MyCustomSelect, { … })
// Keep the bundled renderer + editor, override editable
col.select('role', s => s.options(ROLES)).editable(false)
```
## API
### `col.select(field, configurator)`
| Configurator method | Type | Description |
|--------|------|-------------|
| `.options(value)` | `CoarSelectOption[] \| (row) => CoarSelectOption[]` | **Required.** Static array or per-row function. |
| `.clearable(value?)` | `boolean = true` | Show a clear button in the editor |
| `.searchable(value?)` | `boolean = true` | Enable search/filter in the dropdown |
| `.placeholder(value)` | `string` | Placeholder shown when no value is selected |
| `.searchPlaceholder(value)` | `string` | Search-input placeholder (used with `.searchable()`) |
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | Trigger size (default: `'s'`) |
`CoarSelectOption` is `{ value: T, label: string, disabled?: boolean, group?: string, icon?: string }`.
Editor commits via `getValue()` returning the option `value`. The dropdown panel is teleported to `` (Coar overlay-host) so it can extend past the cell / grid boundaries without clipping.
---
---
url: /components/data-grid/multi-select.md
description: >-
CoarDataGrid multi-value columns — col.multiSelect() and col.tagSelect() edit
array cells via CoarMultiSelect or CoarTagSelect, with chips display, search,
and allowCreate.
---
# Multi-Select & Tag-Select Columns
Two column shortcuts for multi-value cells. Both store the cell value as `T[]` and share the same renderer — they differ only in the editor surface and which configurator options are available.
| | Editor | When to use |
|--|--|--|
| **`col.multiSelect()`** | `` — standard trigger, dropdown shows all options with checkboxes | Curated option lists where the user picks N of M known values. Supports search, "Select all". |
| **`col.tagSelect()`** | `` — trigger renders selected values as removable chips inline; dropdown shows only not-yet-selected options | When the visual identity of selected values matters at-a-glance, or when you want `.allowCreate()` to let users add free-form values. |
```ts
import { CoarGridBuilder } from '@cocoar/vue-data-grid';
CoarGridBuilder.create().columns([
(col) => col.field('name').flex(1),
(col) => col.multiSelect('tags', s => s.options(TAGS).searchable().showSelectAll())
.editable(true),
(col) => col.tagSelect('skills', s => s.options(SKILLS).allowCreate().display('chips'))
.editable(true),
])
```
## Edit-mode flow
| Action | Result |
|--------|--------|
| Double-click cell (or Enter / F2) | Opens the editor and **auto-opens the dropdown** via `afterGuiAttached` |
| Toggle a checkbox (multiSelect) / pick an option (tagSelect) | Updates the editor's working array. Dropdown stays open. |
| Click outside the dropdown / Tab / Enter | Commits — AG Grid pulls the final array via `getValue()` |
| Escape | Cancels (no commit) |
Unlike `col.select()` (which auto-commits on every pick because the single-value edit is *one* click), multi-value editors deliberately keep the dropdown open so the user can complete the selection. Focus-preservation prevents AG Grid's `stopEditingWhenCellsLoseFocus` from committing the array prematurely when the user clicks options in the body-teleported dropdown.
## Rendering
Both columns default to a comma-separated label list. Switch to chips via the configurator:
```ts
col.multiSelect('tags', s => s.options(TAGS).display('chips'))
col.tagSelect('skills', s => s.options(SKILLS).display('chips'))
```
The shared renderer (`CoarMultiSelectCellRenderer`) looks up labels from `options` — values that aren't in the option list (only possible via `col.tagSelect().allowCreate()`) fall back to `String(value)`.
## Example
## Row-aware options
Pass a function for per-row option lists — both renderer (label lookup) and editor (dropdown) call it with the current row:
```ts
col.multiSelect('perms', s => s.options(row => permsFor(row.role)))
.editable(true)
```
## API
### `col.multiSelect(field, configurator)`
Cell value type: `T[]`.
| Configurator method | Type | Description |
|---|---|---|
| `.options(value)` | `CoarSelectOption[] \| (row) => CoarSelectOption[]` | **Required.** Static array or per-row function. |
| `.clearable(value?)` | `boolean = true` | Show a clear button in the editor |
| `.searchable(value?)` | `boolean = true` | Enable search/filter in the dropdown |
| `.showSelectAll(value?)` | `boolean = true` | Show a "Select all" row at the top of the dropdown |
| `.placeholder(value)` | `string` | Placeholder shown when no values are selected |
| `.searchPlaceholder(value)` | `string` | Search-input placeholder (used with `.searchable()`) |
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | Trigger size (default: `'s'`) |
| `.display(value)` | `'text' \| 'chips'` | Renderer display mode (default: `'text'`) |
### `col.tagSelect(field, configurator)`
Cell value type: `T[]`. The cell renderer is shared with `col.multiSelect()`; only the editor differs.
| Configurator method | Type | Description |
|---|---|---|
| `.options(value)` | `CoarSelectOption[] \| (row) => CoarSelectOption[]` | **Required.** Static array or per-row function. |
| `.placeholder(value)` | `string` | Placeholder shown when no values are selected |
| `.searchPlaceholder(value)` | `string` | Search-input placeholder |
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | Trigger size (default: `'s'`) |
| `.allowCreate(value?)` | `boolean = true` | Let the user type free-form values not in `options` |
| `.display(value)` | `'text' \| 'chips'` | Renderer display mode (default: `'text'`) |
`CoarSelectOption` is `{ value: T, label: string, disabled?: boolean, group?: string, icon?: string }`.
## Layered overrides
Same escape-hatches as the other column shortcuts — chain `.cellEditorConfig(...)` or `.cellRendererConfig(...)` after the factory call to swap in custom components while keeping the rest of the column setup.
```ts
col.multiSelect('tags', s => s.options(TAGS))
.editable(true)
.cellEditorConfig(MyCustomMultiEditor, { /* ... */ })
```
---
---
url: /components/data-grid/date-columns.md
description: >-
CoarDataGrid date columns — col.plainDate/plainDateTime/zonedDateTime render
Temporal values locale-aware and edit via the matching Cocoar date-time
picker.
---
# Date Columns
Three Temporal-typed column shortcuts for date / date-time / zoned-date-time cells. All three follow the same pattern: a renderer that formats locale-aware via `toLocaleString`, an editor that wraps the matching `` / `` / `` component.
| | Cell value | Renderer format | Editor |
|--|--|--|--|
| **`col.plainDate()`** | `Temporal.PlainDate \| null` | `15. Mai 2026` (date-style: medium) | `CoarPlainDatePicker` |
| **`col.plainDateTime()`** | `Temporal.PlainDateTime \| null` | `15. Mai 2026, 14:30` (date-style: medium + time-style: short) | `CoarPlainDateTimePicker` |
| **`col.zonedDateTime()`** | `Temporal.ZonedDateTime \| null` | `15. Mai 2026, 14:30 GMT+2` (+ short zone-name suffix) | `CoarZonedDateTimePicker` |
```ts
import { CoarGridBuilder } from '@cocoar/vue-data-grid';
import { Temporal } from '@js-temporal/polyfill';
CoarGridBuilder.create().columns([
(col) => col.plainDate('startsOn', d => d.highlightWeekends())
.editable(true),
(col) => col.plainDateTime('reminderAt')
.editable(true),
(col) => col.zonedDateTime('eventAt', d => d.timeZone('Europe/Vienna'))
.editable(true),
])
```
::: info Temporal-only contract
All three column shortcuts require the cell value to be the matching `Temporal` type (or `null`). ISO strings, native `Date`, floating `Temporal.PlainDateTime` in a `zonedDateTime` column — all rejected: the renderer shows empty, the editor falls back to `null`. Convert at the data layer (typically in the row mapper that turns API responses into grid rows). This matches `@cocoar/vue-calendar`'s Temporal-only contract — when a row's date round-trips between the grid and the calendar, both sides agree on the type.
The legacy `col.date(field, config?)` shortcut (display-only, accepts `Date | string`) is unchanged for back-compat with existing consumer columns.
:::
## Edit-mode flow
| Action | Result |
|--------|--------|
| Double-click cell (or Enter / F2) | Opens the editor and focuses the picker's trigger. The picker handles its own open / navigate / select keystrokes. |
| Click outside / Tab / Enter (after selection) | AG Grid commits via `getValue()` — `Temporal.PlainDate` / `PlainDateTime` / `ZonedDateTime` (or `null` if cleared). |
| Escape | Cancels (no commit). |
Focus-preservation (capture-phase `mousedown` listener that `preventDefault`s on `.coar-overlay-host` targets) prevents AG Grid from committing prematurely while the user navigates the body-teleported picker panel.
## `col.plainDate(field, configurator?)`
| Configurator method | Type | Description |
|---|---|---|
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | Trigger size (default: `'s'`) |
| `.clearable(value?)` | `boolean = true` | Show a clear button inside the picker (default: `true`) |
| `.min(value)` | `Temporal.PlainDate \| null` | Minimum selectable date |
| `.max(value)` | `Temporal.PlainDate \| null` | Maximum selectable date |
| `.showWeekNumbers(value?)` | `boolean = true` | Show ISO week numbers in the calendar panel |
| `.highlightWeekends(value?)` | `boolean = true` | Visually highlight Saturday + Sunday |
| `.markers(value)` | `CoarDateMarker[] \| (row) => CoarDateMarker[]` | Date markers (dot / ring / underline) |
| `.locale(value)` | `string` | Locale override (defaults to consumer-app locale via `useL10n()`) |
## `col.plainDateTime(field, configurator?)`
Same configurator surface as `col.plainDate()`, but `min` / `max` accept `Temporal.PlainDateTime`.
Use this when the time-of-day matters but the event has no fixed zone (calendar-local reminders, scheduled-locally tasks). For cross-zone events, use `col.zonedDateTime()`.
## `col.zonedDateTime(field, configurator?)`
| Configurator method | Type | Description |
|---|---|---|
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | Trigger size (default: `'s'`) |
| `.clearable(value?)` | `boolean = true` | Show a clear button inside the picker (default: `true`) |
| `.min(value)` | `Temporal.ZonedDateTime \| null` | Minimum selectable instant |
| `.max(value)` | `Temporal.ZonedDateTime \| null` | Maximum selectable instant |
| `.showWeekNumbers(value?)` | `boolean = true` | Show ISO week numbers |
| `.highlightWeekends(value?)` | `boolean = true` | Highlight Saturday + Sunday |
| `.markers(value)` | `CoarDateMarker[] \| (row) => CoarDateMarker[]` | Date markers |
| `.locale(value)` | `string` | Locale override |
| `.timeZone(value)` | `string` | **Default IANA zone** for newly-created values (cell was empty before the edit). Existing values keep their own zone. |
| `.timezoneFilter(value)` | `string[]` | Wildcard filter patterns for the zone selector (e.g. `['Europe/*', 'America/*']`) |
| `.displayTimeZone(value)` | `string` | **Renderer-only.** Project every row's instant into this zone for display (e.g. `'Europe/Vienna'` to render every event in Vienna time for cross-zone coordination views). When omitted, each row renders in its own value's zone. |
The renderer formats each cell in its own zone — a row whose value lives in `America/New_York` displays the New York wallclock + a `GMT-5` (or `GMT-4` in summer) suffix, regardless of the user's browser zone. Cross-zone columns stay unambiguous at a glance.
## Row-aware markers
`markers` accepts a function for per-row decorations — useful when the calendar should highlight different dates depending on the row:
```ts
col.plainDate('startsOn', d =>
d.markers(row => [
{ date: row.deadline, variant: 'underline', color: 'var(--coar-color-warning-bold)' },
])
).editable(true)
```
## Layered overrides
Same escape-hatches as the other column shortcuts:
```ts
col.plainDate('startsOn', d => d.size('s'))
.editable(true)
.cellEditorConfig(MyCustomDateEditor, { /* ... */ })
```
---
---
url: /components/data-grid/checkbox.md
description: >-
CoarDataGrid checkbox column — col.checkbox() renders a read-only CoarCheckbox
per cell, with opt-in edit-mode toggling, per-row gating, and indeterminate
tri-state.
---
# Checkbox Column
`col.checkbox(field, configurator?)` renders a `` in each cell — same visual language as forms, just sized to fit the row. The renderer is **always read-only**; interactivity comes from edit-mode, exactly like text/number/select columns.
```ts
import { CoarGridBuilder } from '@cocoar/vue-data-grid';
CoarGridBuilder.create().columns([
(col) => col.checkbox('done').editable(true),
(col) => col.field('title').flex(1),
])
```
## Edit-mode flow
Without `.editable()` the checkbox is a read-only indicator. Adding `.editable(true)` (or a row-predicate) opts the column into AG Grid's standard edit-mode flow:
| Action | Result |
|--------|--------|
| Double-click cell (or Enter / F2) | Opens `CoarCheckboxCellEditor` — interactive `` with focus on the input |
| Space | Toggles the checkbox |
| Tab | Commits + moves focus to the next editable cell, **opening its editor automatically** |
| Enter | Commits + stays |
| Escape | Cancels |
The Tab-through-edit-mode pattern is AG Grid's native data-entry workflow — keyboard users can fly through editable cells without ever touching the mouse. Pair with `gridBuilder.stopEditingWhenCellsLoseFocus()` so clicking outside also commits.
Toggles fire `cellValueChanged` like any other editor commit, so a single `gridBuilder.onCellValueChanged()` handler covers all column types — checkbox, text, number, custom editors.
## Editable + per-row gating
Pass a row-predicate to `.editable()` to disable the editor for individual rows. Locked rows render a read-only checkbox and can't be entered.
```ts
CoarGridBuilder.create().columns([
(col) => col.checkbox('done').editable(row => !row.locked),
(col) => col.field('task').flex(1),
(col) => col.checkbox('locked'), // read-only indicator
])
.stopEditingWhenCellsLoseFocus()
.onCellValueChanged((event) => {
if (event.colDef.field === 'done') save(event.data);
});
```
## States — read-only, editable, indeterminate
Three independent states, all using the same `col.checkbox()` shortcut:
* **Read-only:** omit `.editable()` — checkbox is rendered, edit-mode never opens.
* **Editable:** add `.editable(true)` or `.editable(row => …)`.
* **Indeterminate (tri-state):** pass `c.indeterminate(row => …)` in the configurator. Useful for "partial" or "in-progress" states where the row's value isn't a clean true/false. The indeterminate state is shown in both renderer and editor.
```ts
col.checkbox('rolloutComplete', c => c
.indeterminate(row => row.partial && !row.rolloutComplete)
).editable(true)
```
## Layered overrides
The shortcut bundles renderer + editor with the configurator's options. Subsequent calls on the chain override (last-write-wins):
```ts
// Replace the renderer entirely (drops the configurator)
col.checkbox('done').cellRenderer(MyOwnCheckbox)
// Replace just the editor (e.g. a select-style "yes/no/maybe" widget)
col.checkbox('done').editable(true).cellEditorConfig(MyTriStateEditor, { ... })
// Keep the bundled renderer + editor, override editable
col.checkbox('done').editable(false)
```
## API
### `col.checkbox(field, configurator?)`
| Configurator method | Type | Description |
|--------|------|-------------|
| `.label(value)` | `string \| (row) => string` | Optional label rendered next to the checkbox (in both renderer and editor) |
| `.indeterminate(predicate)` | `(row) => boolean` | Tri-state indicator per row |
| `.size(value)` | `'xs' \| 's' \| 'm' \| 'l'` | Checkbox size (default: `'s'`) |
The configurator config is passed identically to both `CoarCheckboxCellRenderer` and `CoarCheckboxCellEditor`, so display and edit look the same — only behavior changes.
Interactive state comes from the outer chain:
| Outer chain | Result |
|-------------|--------|
| no `.editable()` | Read-only — edit-mode never opens |
| `.editable(true)` | Edit-mode opens on double-click / Enter / F2 |
| `.editable(false)` | Read-only |
| `.editable(row => …)` | Per-row predicate — edit-mode opens only when `true` |
Commit behavior: the editor exposes `getValue()` per AG Grid's contract. Tab/Enter/Escape are handled by AG Grid's native edit-mode logic. Combine with `gridBuilder.stopEditingWhenCellsLoseFocus()` so clicking outside the editor also commits.
---
---
url: /components/page-builder.md
description: >-
Overview of @cocoar/vue-page-builder, a headless visual page composition
framework: consumer-defined element registry, portable JSON schemas, shared
PageConfig for builder and renderer.
---
# Page Builder
::: warning Preview
Page Builder is **provisional**. It shipped as GA in 2.17 — that was an
oversight, and 3.0 corrects it: the package is back under the Preview badge
until the authoring model settles. Public API, `PageConfig` and the document
schema may still change in a minor release, and 3.0 itself removed four config
concepts and renamed several more.
Documents are safe across those changes: every schema change ships a migration
that runs on ingest. Pin a version if you depend on the API, and read the
[Authoring contract](./authoring-contract) for what is still open.
:::
`@cocoar/vue-page-builder` is a generic, headless visual page composition framework. Users drag UI primitives onto a canvas, configure them, and the result is a portable JSON schema that a companion renderer turns back into live Cocoar components.
Everything domain-specific — what actions a button can trigger, where images come from, which elements are permitted, and even **which element types exist** — is defined by the **consumer application**, not the library. Built-in elements are pre-registered definitions on an open [element registry](./custom-elements); consumer apps register their own element types on the same contract.
## Two components
| Component | Purpose | Docs |
|-----------|---------|------|
| `` | Visual editor — 3-panel layout, drag-and-drop, props panel | [→ CoarPageBuilder](./coar-page-builder) |
| `` | Runtime renderer — schema → live Cocoar components | [→ CoarPageRenderer](./coar-page-renderer) |
Both share the same `PageConfig`. The builder uses it as UI affordances; the renderer uses it as the security boundary.
## Quick start
Import the stylesheet once (it carries the builder chrome **and** the renderer's
layout styles — without it, stacks lose their flex layout):
```ts
import '@cocoar/vue-page-builder/styles';
```
::: info Peer dependencies
`@cocoar/vue-page-builder` declares `@cocoar/vue-ui` **and** `@cocoar/vue-localization` as peer dependencies. All builder chrome and the renderer's validation messages resolve through `@cocoar/vue-localization` (keys under `coar.pageBuilder.*`), with built-in English fallbacks — English-only apps need no i18n setup.
:::
```vue
```
The **same `config` is passed to both** — the builder uses it to filter UI affordances; the renderer uses it as the security boundary at render time. When `config.assetResolver` is set, the renderer falls back to it automatically, so the `:asset-resolver` prop is only needed as an override.
## Architecture
```
Consumer app
│
├── ← visual editor
│
└── maps JSON nodes → Cocoar components
wires action IDs → real handler functions
```
The JSON schema is the single artifact that flows between builder and renderer. It is plain JSON with no library dependency — any renderer (including a custom one) can interpret it.
## PageConfig — the consumer contract
Everything tenant-facing or domain-specific is declared here. Pass the **same value** to both the builder and the renderer.
```ts
interface PageConfig {
/**
* Element types permitted to appear in the tree — built-in types and
* consumer-registered keys alike. Omit to allow every type.
* `page` (the root marker) is always implicitly allowed.
*/
allowedElements?: (ElementType | (string & {}))[]
/**
* Consumer-registered element types, merged ADDITIVELY over the built-in
* set (shadowing a built-in key warns in DEV). One registration serves
* palette, canvas, inspector and runtime. App-wide defaults can be
* provided under PAGE_ELEMENT_TYPES_KEY instead; this field wins when both
* are present. See the Custom elements guide.
*/
elements?: PageElementRegistry
/**
* The data contract behind the page (DTO fields). When present, the
* builder's Field section offers these instead of a free-text name —
* filtered per element to the compatible value types — the palette
* gains a draggable Fields group, and the builder lint flags unknown
* names, incompatible bindings and missing required fields.
* See the Field contract section below.
*/
fields?: PageFieldSpec[]
/** Allow-listed host context for property bindings, conditions and repeaters. */
contextFields?: PageContextField[]
// interface PageContextField {
// path: string
// type: PageContextValueType
// itemFields?: PageContextItemField[]
// /** Closed set of values — the condition editor offers them as a dropdown
// * instead of a free-text box. This is how a host view state, tier or
// * status becomes authorable without a second mechanism for it. */
// values?: string[]
// }
/** Locales offered by the builder for LocalizedValue props. */
locales?: { id: string; label: string }[]
defaultLocale?: string
documentLimits?: { maxNodes?: number; maxDepth?: number }
/**
* Allow binding names outside `fields`. Defaults to false — with a
* contract, authors pick from it.
*/
allowCustomFields?: boolean
/**
* Hide free value-producing elements from the library and the Inputs
* entries of the outline's add-child menu — exactly what the field
* contract replaces. Containers and content/action elements stay available.
* Pure authoring UI;
* `allowedElements` remains the boundary for what may be used at all.
*/
hideElementPicker?: boolean
/**
* Action IDs that registry elements with `action: true` may reference. When provided, the
* builder's Action input becomes a dropdown of these labeled choices
* instead of free text. The renderer's `actions` map is the actual
* security boundary — `availableActions` is a UX affordance.
*/
availableActions?: { id: string; label: string }[]
/**
* Resolves an assetId to a URL. Used by the builder for thumbnails
* (canvas preview, props panel, Preview tab) and by the runtime
* renderer for ` `. Same contract as the renderer's
* `:asset-resolver` prop — the renderer falls back to this when
* that prop is absent, so passing the same config to both is enough.
*/
assetResolver?: (id: string) => string
/**
* Opens the consumer's own asset picker UI and resolves to the chosen
* `assetId`, or `null` if the user cancelled. The library does NOT
* ship a picker — the IDP owns the entire picker UX (browse, upload,
* search, delete, categorisation, …). When omitted, the image element
* falls back to a free-text Asset ID input.
*/
pickAsset?: (currentId?: string) => Promise
/**
* Resolves an options-source id to the option list of a choice input
* (select / multi-select / radio-group) — the async sibling of
* `assetResolver`, for API-backed lists (countries, users, …). A node
* opts in via its `optionsSourceId` prop; static `options` stay the
* default and the fallback when this callback is absent.
*/
optionsSource?: (sourceId: string) => Promise
}
```
### `allowedElements`
Takes built-in types and consumer-registered keys alike. Enforced at **both** layers:
* *Builder*: hidden from the palette and add-child menu; tenants can't insert disallowed types. Nodes of a disallowed type already present in the schema get a validation **error** and a "skipped at runtime" treatment on the canvas; nodes of an *unregistered* type get a **warning** (they stay in the tree losslessly), so authors learn about both before saving.
* *Renderer*: disallowed nodes are skipped at render time (with one `console.warn` per type) even if they appear in hand-written or tampered JSON. This is the security boundary.
The gate also applies to the renderer's **value model**, not just rendering: disallowed subtrees contribute no default values and their fields never block validation — an invisible `required` field can't permanently veto a validating button.
```ts
allowedElements: [
'stack', 'card', 'section', 'divider',
'heading', 'paragraph',
'text-input', 'checkbox', 'button', 'link', 'image',
'acme-rating', // a consumer-registered key from config.elementTypes
],
```
Drop element types the tenant shouldn't be able to use. `page` is implicitly always allowed.
### `elements` — custom element types
The element set is open: one `definePageElement()` definition registers a component defined entirely in **your app** as a first-class element — it appears in the palette, canvas, inspector, preview and value model exactly like a built-in. Registrations merge **additively** over the built-ins; keys are lowercase kebab-case (`^[a-z][a-z0-9-]*$`) and a vendor prefix is recommended:
```ts
import { definePageElement, type PageConfig } from '@cocoar/vue-page-builder';
import RatingRenderer from './RatingRenderer.vue';
const config: PageConfig = {
elements: {
'acme-rating': definePageElement({
renderer: RatingRenderer, // receives { node }
value: { isEmpty: (v) => !v || Number(v) === 0 }, // participates in the value model
}),
},
allowedElements: ['stack', 'heading', 'text-input', 'button', 'acme-rating'],
};
```
Unregistered types degrade **losslessly**: kept in the tree and in the JSON tab, flagged in the builder, skipped at runtime with one console warning per type, and excluded from the value model so they can never block a submit. The full contract — builder half (palette label, canvas preview, inspector, lint), `usePageElement()` renderer context, app-wide registration via `PAGE_ELEMENT_TYPES_KEY` — is covered in the [Custom elements guide](./custom-elements).
### Field contract
In practice a page is rarely a free-form document — it usually **projects a DTO**: the login request, the profile record, the ticket form. The field names and their types are known up front, and authors should *pick* from them instead of inventing names the backend then has to guess at. `config.dataContract` declares that contract:
```ts
const config: PageConfig = {
fields: [
{ name: 'username', valueType: 'string', label: 'Username', required: true },
{ name: 'password', valueType: 'string', label: 'Password', required: true, defaultElement: 'password-input' },
{ name: 'rememberMe', valueType: 'boolean', label: 'Remember me' },
{ name: 'age', valueType: 'number', label: 'Age' },
{ name: 'dueUntil', valueType: 'date', label: 'Due until' },
],
};
```
Each `PageFieldSpec` is `{ name, valueType, label?, required?, defaultElement? }`: `name` is the `ActionValues` key (the DTO property), `valueType` decides which elements can edit the field, `label` is carried onto the element on binding, `required` sets `validation.required` on binding and keeps a root-level warning alive while the field is missing from the page, and `defaultElement` picks the element the field-first flow creates.
#### Value types and compatibility
Compatibility is an exact token match between the field's `valueType` and the element definition's `ElementValueSpec.types`. `PageValueType` is an **open** token union — the built-in tokens below plus any consumer token (`'geo'`, `'money'`, …):
| `valueType` | Compatible built-in elements |
|-------------|------------------------------|
| `string` | `text-input`, `password-input`, `select`, `radio-group`, `otp-input` |
| `boolean` | `checkbox`, `switch` |
| `number` | `number-input` |
| `string[]` | `multi-select` |
| `date` | `date-input` |
| `datetime` | `datetime-input` |
Consumer elements participate through the same declaration: a rating element whose definition says `value: { types: ['number'] }` becomes a representation for `number` fields — compatibility is registry-driven, not a central table. A value spec **without** `types` is unconstrained (compatible with every field), so consumer elements that don't declare are never falsely blocked. See [Custom elements](./custom-elements#_3-value-model-participation).
#### Two authoring flows
**Element-first** — drop any element, then bind it. With a contract, the Field section's *Field name* control becomes a select over the **compatible** fields only (a `text-input` never offers `rememberMe`). Binding takes the contract label along — never overwriting a label the author already edited — and sets `validation.required` for contract-required fields. Clearing the select unbinds; a bound name outside the contract stays visible as `(custom)`.
**Field-first** — with a contract the palette gains a third group, **Fields**: one draggable card per contract field, with a type icon, the contract label and a `*` for required fields; a card greys out once its name is bound anywhere on the page. Dropping a card creates the field's default element — `field.defaultElement` when registered, else the first compatible value element in registry order — **pre-bound**: name set, contract label carried into the props bag (when the element has a `label` prop at all), `validation.required` applied.
#### Representation switch
Same field, different element: the Field section gains an **Element** select listing the representations that can edit the bound field's value type (unbound: any type the current element declares) — filtered to placeable, allow-listed elements and hidden when fewer than two remain. Switching converts the node **in place**: it keeps `id` (selection follows), `name`, `defaultValue`, `validation`, `style` and the label, while the rest of the props bag restarts from the target element's defaults. One undoable step — username as `text-input` ⇄ `password-input` ⇄ `select` ⇄ `otp-input`, one click each.
#### Contract lint
Three rules join [builder-side validation](./coar-page-builder#builder-side-validation):
* a bound name **outside the contract** is an *error* — unless `allowCustomFields` is set,
* a **type-incompatible** binding (say, a `checkbox` bound to a `string` field) is an *error*,
* a **required contract field missing** from the page is a *warning* on the root node.
#### Strict by default
`allowCustomFields` defaults to `false`: with a contract, binding is select-only, and freshly dropped value elements start **unbound** instead of minting a `field_*` name the lint would immediately flag — the author picks a contract field. Setting `allowCustomFields: true` relaxes all of it: the Field section adds a free-text *Custom name* input, fresh elements mint names again, and the unknown-name lint rule stands down.
#### `allowedElements` governs everything
The allow-list composes with the contract at every seam: a field's default element (field-first drop) and the representation switcher only ever offer **allowed** elements. Drop `password-input` from `allowedElements` and a `string` field can no longer be represented as a password input — the field's `defaultElement` falls back to the first compatible *allowed* element, and a field with no allowed representation greys out in the palette.
#### Fields-only authoring
Set `hideElementPicker: true` to remove free value-producing elements from the right-hand **Elements** library and from the outline's **Inputs** add-child group — exactly the entries the field contract replaces. Fields then come exclusively from dragging contract cards. **Containers** and content/action elements (headings, notes, buttons, links, images) stay available because every form needs structure and chrome. Classification is registry-derived from the value spec, so consumer elements sort themselves. This is pure authoring UI — combine it with `allowedElements` when the *rendering* boundary should shrink too.
#### Typed field lists (opt-in)
For a **static** DTO, `defineFields()` checks the field list at compile time — names must be DTO properties, value types must fit the property types (string properties admit the `date`/`datetime` tokens, since dates travel as ISO strings):
```ts
interface LoginDto { username: string; password: string; rememberMe: boolean }
fields: defineFields([
{ name: 'username', valueType: 'string', required: true },
{ name: 'rememberMe', valueType: 'boolean' },
// { name: 'usernme', valueType: 'string' }, // ✗ compile error — not a DTO property
// { name: 'rememberMe', valueType: 'string' } // ✗ compile error — boolean property
])
```
It is pure opt-in sugar with zero runtime cost: the result is a plain `PageFieldSpec[]`, so dynamically grown DTOs keep working — either skip the helper entirely, or mix: `[...defineFields([...]), ...dynamicExtraFields]`.
#### Authoring-only by design
The contract constrains **authoring only**. Binding is plain `node.name` — persisted schemas stay self-contained, render without the contract, and a document authored under one contract remains a valid document everywhere. The renderer never consults `fields`; `allowedElements` remains the security boundary.
### `availableActions`
When provided, the Action ID input in Button/Link props becomes a dropdown. Stored action IDs that aren't in the list are surfaced as `auth:something (not configured)` so orphans don't silently disappear.
```ts
availableActions: [
{ id: 'auth:login', label: 'Sign in' },
{ id: 'auth:register', label: 'Create account' },
{ id: 'auth:forgot-password', label: 'Forgot password' },
{ id: 'auth:sso-google', label: 'Sign in with Google' },
{ id: 'auth:sso-microsoft', label: 'Sign in with Microsoft' },
],
```
The runtime `actions` map on the renderer is the real boundary — it only invokes handlers that exist there. `availableActions` is purely a UX affordance.
### `assetResolver` + `pickAsset`
The library does **not** ship an asset picker. You build your own — a modal, a drawer, a sidebar, however you want — and wire it in via two simple callbacks.
```ts
const config: PageConfig = {
// ...
/** Resolves an asset id to a URL. The builder uses this for thumbnails;
the renderer falls back to it when its :asset-resolver prop is absent. */
assetResolver: (id) => `https://cdn.example.com/t/${tenantId}/${encodeURIComponent(id)}`,
/** Opens YOUR picker and resolves to the chosen id (or null on cancel). */
async pickAsset(currentId) {
const result = await myAssetModal.open({ initial: currentId });
return result ?? null;
},
};
```
::: warning Validate the asset id
`assetResolver` receives whatever `assetId` string sits in the schema — including hand-edited JSON. Encode it (`encodeURIComponent`) or allowlist it (e.g. `/^[A-Za-z0-9_-]+$/`) before splicing it into a URL, or a crafted id like `../other-tenant/logo` walks out of your tenant prefix.
:::
The image element's props panel renders:
* a **thumbnail** using `assetResolver(node.assetId)`
* a **Choose…/Change…** button that calls `pickAsset(currentId)` and patches the returned id onto the schema
* a **Clear** button when an id is set
When `pickAsset` is omitted, the image element falls back to a free-text Asset ID input — useful for development or scripted authoring.
#### What your picker needs to do
The full contract is just `(currentId?: string) => Promise`. Inside, you do whatever fits your stack:
* list assets from your API
* handle uploads (sign URL, POST file, etc.)
* search, filter, paginate
* delete
* categorise by tag/folder
* show metadata, dimensions, file size
Return the chosen asset's id, or `null` if the user cancelled. The library doesn't care about anything else.
Example skeleton using Cocoar's `useDialog`:
```ts
import { useDialog } from '@cocoar/vue-ui';
import MyAssetPicker from './MyAssetPicker.vue';
const dialog = useDialog();
const config: PageConfig = {
// ...
assetResolver: (id) => assetUrlMap.value.get(id) ?? '',
async pickAsset(currentId) {
const { result } = dialog.open(
MyAssetPicker,
{ title: 'Choose image', size: 'l' },
{ initial: currentId },
);
return (await result) ?? null;
},
};
```
A complete reference implementation lives at `apps/playground/src/components/PlaygroundAssetPicker.vue` — copy it as a starting point.
## Security Model
**Allowed elements** — `config.allowedElements` is enforced at both layers (builder hides and flags; renderer skips, with one `console.warn` per type). The renderer is the hard boundary — even tampered JSON cannot smuggle in disallowed types, and disallowed subtrees are excluded from the value model too (no defaults, no validation veto).
**Actions** — every registry element that declares `action: true` stores the shared optional `ActionProps` contract; built-in buttons and links use it too. The builder supplies one Action + JSON key/value editor with an `fx` switch per value, and the renderer only invokes handlers from the consumer-provided `actions` map — action ids are inert strings. Handler payload precedence is form values < resolved per-key `actionValues` < the legacy bound `actionValue`; only JSON-safe explicit values cross the boundary. Per-key bindings may read controlled context, customer Page State, form fields, named Repeat selections, or the current Repeat item/index. When `config.availableActions` is set, the builder also constrains the Action input to a labeled dropdown. One qualification to "nothing executable lives in the schema": `validation.pattern` is a tenant-authored regular expression that *is* evaluated at render time. It is compiled safely — an invalid pattern becomes an inert rule with a single `console.warn` — and anchored to match the full string, like the HTML `pattern` attribute.
**Images** — `image` nodes store an `assetId` reference, never a raw URL. The renderer calls `assetResolver(id)` at render time. The "tenants cannot reference external domains" guarantee is therefore exactly as strong as **your** `assetResolver` — validate or encode the id before building a URL (see the warning above). Uploads happen entirely inside the consumer-built picker (whatever `pickAsset` opens) — that's where you validate file type, scan for malware, and enforce per-tenant size quotas.
## Complete IDP integration walkthrough
Here's how a tenant-customisable login flow fits together end-to-end.
### 1. Define the tenant config
In a shared file your admin app and your login app both import:
```ts
// tenants/loginConfig.ts
import type { PageConfig } from '@cocoar/vue-page-builder';
export function buildLoginConfig(tenantId: string): PageConfig {
return {
allowedElements: [
'stack', 'card', 'divider',
'heading', 'paragraph',
'text-input', 'checkbox', 'button', 'link', 'image',
],
availableActions: [
{ id: 'auth:login', label: 'Sign in' },
{ id: 'auth:sso-google', label: 'Sign in with Google' },
{ id: 'auth:sso-microsoft', label: 'Sign in with Microsoft' },
{ id: 'auth:forgot-password', label: 'Forgot password' },
{ id: 'auth:register', label: 'Create account' },
{ id: 'nav:login', label: 'Go to login' },
],
// Allowlist the id — it's tenant-authored schema data, not trusted input.
assetResolver: (id) =>
/^[A-Za-z0-9_-]+$/.test(id) ? `https://cdn.example.com/t/${tenantId}/${id}` : '',
async pickAsset(currentId) {
// Open your own asset picker — the library does not ship one.
// Inside MyAssetPickerModal you'd call your /api/tenants/${tenantId}/assets
// endpoint for the list, POST for uploads, etc.
return await openMyAssetPickerModal({ tenantId, initial: currentId });
},
};
}
```
### 2. Admin page — the builder
```vue
Login page editor
{{ saving ? 'Saving…' : 'Save' }}
```
### 3. Runtime — the login page itself
```vue
```
### Notes for the IDP wiring
* **Schema migration** — the builder normalises schemas at **every entry point**: the initial `v-model` value, external `v-model` replacement, and the JSON tab's Apply. Legacy `column`/`row` containers migrate to `stack`, v1 flat documents get their `props` bags, non-`page` roots get wrapped in a `page`, missing or duplicate node ids are repaired, missing `children` arrays / `props` bags and out-of-range heading levels are healed. The runtime renderer additionally runs both migrations on the fly, so old saved schemas keep rendering even without a round-trip through the builder. To run the same migration server-side before persisting, use the exported helpers: `normalizePageSchema(value)` returns `{ schema, issues, changed }`; `migrateLegacyTypes`, `migrateV1PropsBag` and `KNOWN_ELEMENT_TYPES` are exported alongside it.
* **`schemaVersion`** — new roots are stamped with `schemaVersion: 6`. Version 4 keeps the v2 props-bag and v3 runtime-composition grammar and adds a stable page-wide `name` to every element for Element Code (the same name is the form/DTO key for value elements). Version 5 adds builder-only origin metadata for reusable, versioned compositions. Version 6 renames the repeat's `props.source` to `props.contextPath`, so `source` means one thing everywhere. Older documents are normalized deterministically. Persist the version as-is.
* **JSON Apply is gated by severity** — structural **errors** (non-object nodes — data would be dropped) reject the Apply with a message; nothing broken reaches your `v-model`. **Warnings** (healed or lossless findings — including *unknown element types*, which stay in the tree losslessly) apply anyway and are surfaced inline, so documents using newer or unregistered element types remain editable.
* **Validation** — builder validation flags authoring mistakes but never blocks saving: a button/link without an action, or with an action id outside `availableActions`, or an *unregistered* element type, is a *warning*; duplicate field names, missing image asset ids, invalid `validation.pattern`, and disallowed element types are *errors*. If you need hard guarantees, validate server-side before persisting (e.g., reject if any image node has an empty `props.assetId`). At runtime, a `validates: true` button stays **clickable** while the form is invalid — clicking it marks every field touched and reveals all errors instead of running the action; it only disables while an async `onValidate` is in flight. Cross-field or server-side checks (e.g., "email domain not allowed for this tenant") go through the renderer's `:on-validate` prop: it runs at submit time after the declarative rules pass, may return a `Promise` of `{ fieldName: errorMessage }`, a non-empty result blocks the action, and editing a field clears its server error. See [CoarPageRenderer](./coar-page-renderer).
* **CSP** — image URLs come from `assetResolver`, so your CDN domain needs to be in `img-src`. Action IDs and labels are inert strings; the one tenant-authored value evaluated at render time is `validation.pattern`, which is compiled safely and anchored (see [Security Model](#security-model)).
* **Full-screen / centering** — the renderer fills and measures its host width. To center content on a full-height screen, set the `page` node's `minHeight: '100dvh'` + `justify: 'center'` + `align: 'center'`. See [Sizing and alignment](./coar-page-renderer#sizing-and-alignment).
* **Per-tenant theming** — the renderer uses the Cocoar Design System tokens; override CSS variables on a wrapping container for tenant brand colors.
## Implementation Roadmap
| Phase | Scope | Status |
|-------|-------|--------|
| **1 — Foundation** | `schema.ts` types · `CoarPageRenderer` · playground demo | ✅ Done |
| **2 — Builder shell** | Canvas + palette · Outline · Props panel · DnD · Undo/redo · JSON tab | ✅ Done |
| **3 — Config + safety** | `page` root · `stack` (direction toggle) · `:config.allowedElements` · `:config.availableActions` | ✅ Done |
| **4 — Asset callbacks + polish** | `:config.pickAsset` + `:config.assetResolver` · builder validation · responsive preview | ✅ Done |
| **5 — Layout & sizing** | Flex model — `justify` / `align` / `alignSelf` / `size` (fit · fill · fixed) / `minHeight`; guided Style-panel controls; Editor matches Preview | ✅ Done |
| **GA hardening** | Correctness & data-safety fixes (schema normalization at every entry point, gated JSON Apply, `crypto.randomUUID` ids, `schemaVersion` stamp, safe `pattern` compile) · pointer-events DnD (mouse + touch/pen long-press, outline drag-to-reorder) · validation UX (clickable validating buttons, submit-time async `onValidate`) · outline ARIA tree + scoped keyboard shortcuts · duplicate / select-options / default-value editors · i18n (`coar.pageBuilder.*` via `@cocoar/vue-localization`) | ✅ Done |
| **Element registry** | Unified props-bag wire format (introduced in v2, current `schemaVersion: 6`, transparent older-document normalization) · open [consumer-registered element types](./custom-elements) (`config.elementTypes`, `definePageElement`, `usePageElement`) · lossless degradation of unregistered types · severity-gated JSON Apply · renderer `initialValues` | ✅ Done |
| **Submit lifecycle & dynamics** | Async actions (`isSubmitting`, spinner, reentry guards) · [form-level error channel](./coar-page-renderer#async-actions-the-form-level-error-channel) (`_form`, banner, `#form-error` slot) · [Enter-to-submit](./coar-page-renderer#enter-to-submit) · built-in email format check · host form API (`update:values`, `values` / `isDirty` / `reset`) · [`visibleWhen`](./coar-page-renderer#conditional-visibility-visiblewhen) conditional visibility · [`optionsSource`](./coar-page-renderer#dynamic-options-optionssource) dynamic option lists | ✅ Done |
| **5b — Style editor (visual)** | Spacing sliders + colour pickers (rolls into the tenant theming track) | Planned |
| **Runtime composition v4** | Mobile-first responsive overrides · safe context/state/item bindings · Page State and per-element code · key-based localization · generic repeaters and selected-key outputs · feedback zones · document-limit fallback validation | ✅ Done |
| **5+ — Schema versioning** | Formal multi-step migration framework beyond the current deterministic normalization to v5 | Planned |
---
---
url: /components/page-builder/coar-page-builder.md
description: >-
CoarPageBuilder visual editor: outline and properties inspector, drag-and-drop
canvas, searchable element library and reusable compositions, emitting a
PageNode JSON schema via v-model.
---
# ``
The visual-editor half of `@cocoar/vue-page-builder`. It renders a three-panel layout: the vertically split Outline and Properties inspector on the left, the canvas in the centre, and a searchable element library on the right. It emits a `PageNode` JSON tree as `v-model`; the same tree is consumed by [``](./coar-page-renderer) at runtime.
All three panels are resizable via drag handles and collapsible.
::: tip Stylesheet
Import `@cocoar/vue-page-builder/styles` once in your app — it carries the entire builder chrome (panels, canvas, palette). Without it the builder renders unstyled.
:::
## Playground
A live builder with a small starting schema and a restricted `allowedElements` list — note that the palette and the outline's "Add child" menu only offer the permitted types. Drag palette cards onto the canvas, reorder rows in the outline via their grip handles, and try `Ctrl+Z` after an edit. The builder fills its host element, so give it a bounded height.
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `modelValue` / `v-model` | `PageNode` | empty `page` | The page schema. Bound two-way; every edit updates the ref. Every tree entering from **outside** — the initial value, a later external replacement, or an initially-`undefined` ref that is filled once an async load resolves — passes through the same normalization as the JSON tab's Apply: legacy `column`/`row` containers become `stack`, v1 flat documents get their `props` bags, a non-`page` root is wrapped in one, missing/duplicate node ids are repaired with fresh `crypto.randomUUID` ids, missing `children` arrays and `props` bags are added, every element receives a unique name, and documents are stamped `schemaVersion: 6`. Repairs log a DEV-only console warning. |
| `config` | [`PageConfig`](./#pageconfig-the-consumer-contract) | — | Allowed elements, [consumer element registrations](./custom-elements), actions, runtime-context contracts, locales, limits and asset callbacks. Pass the same value to the renderer. |
| `previewContext` | `Record` | — | Safe host data for the embedded preview. Only paths declared by `config.contextFields` are readable. |
| `previewInitialValues` | `ActionValues` | — | Field values the embedded preview starts from, merged **over** the schema's own `defaultValue`s exactly as at runtime. The edit-form case, and the case where the host computes a default the author did not write (a per-tenant "remember me" setting). Replacing the object re-seeds the preview. |
| `previewLocale` | `string` | — | Initial locale for localized schema values in the preview. |
| `previewTheme` | `CoarTheme` | — | Host-resolved brand primitives applied through the same `CoarThemeScope` used at runtime. Scoped to the Preview canvas; builder chrome and dialogs are unaffected. |
| `previewThemeMode` | `'auto' \| 'light' \| 'dark'` | `'auto'` | Colour mode for the preview theme. Auto follows the surrounding application/OS mode. |
| `previewActions` | `Record` | — | Optional live handlers for action testing in the embedded preview. |
| `previewFallbackSchema` | `PageNode` | — | Host-owned fallback used by the preview when the customized document violates its contract. |
| `previewExpressionValues` | `RuntimeExpressionValues` | — | Optional precomputed expression values for the embedded preview. Source code is never evaluated in the builder's main thread. |
| `authoringMode` | `'properties' \| 'code'` | `'properties'` | In `code` mode the inspector stays structural and Page/Element Code is authoritative for configurable properties. |
| `previewRuntimeHost` | `PageRuntimeHost` | no-capability host | Application capability catalogue for the Builder-owned isolated preview runtime. |
| `previewRuntimePageId` | `string` | schema id | Stable page identity used by preview capability grants and diagnostics. |
| `previewRuntimeTenantId` | `string` | — | Optional tenant identity supplied to preview capability grants. |
| `pageCodeExtraLibs` | `CoarScriptEditorExtraLib[]` | `[]` | Host capability declarations added to Monaco IntelliSense for Page and Element Code. |
| `compositionRepository` | `PageCompositionRepository` | — | Optional host-owned repository of reusable, immutable, versioned subtrees. Supplying it enables the Compositions library and management tab. |
| `compositionManagement` | `'inline' \| 'consume'` | `'inline'` | Whether definitions may be created/published inside this page builder or are managed by a separate host-owned composition editor. |
## Events
| Event | Payload | Description |
|-------|---------|-------------|
| `update:modelValue` | `PageNode` | Emitted after every schema edit for `v-model`. |
| `preview-values` | `ActionValues` | Current field-value snapshot from the embedded preview. |
| `preview-runtime` | `{ fields, form }` | Reactive preview scope for host diagnostics; `form` contains valid, dirty, validating and submitting flags. |
| `open-composition` | `PageCompositionReference` | Requests host navigation to the independently managed composition definition and exact pinned version. |
| `validation` | `AuthoringFinding[]` | The authoring findings the builder draws in its own outline and props panel. Fires on mount and whenever the set changes in content, so an edit that leaves the findings alone stays quiet. The payload is a copy — mutating it cannot corrupt the builder. |
## Reaching the validation findings
Two layers answer two different questions, and mixing them up leads to either a
save button that never enables or a document that fails at activation.
| | Question | API |
|---|---|---|
| **Authoring findings** | "What should I tell the author to fix?" | `@findings` / `useAuthoringFindings()` |
| **Activation contract** | "May this document go live?" | [`validatePageDocument()`](./idp-integration) |
The authoring layer is the broader one — it includes hints the runtime does not
care about (an action that is not in `availableActions`, a required contract
field nobody put on the page) and it *contains* the hard failures of the
activation contract, so gating a save on `severity === 'error'` is sound.
```vue
Save
```
Each finding carries the `nodeId` it belongs to and, where the problem is a
specific property, the `field` — enough to build your own "jump to node" list.
Outside a mounted builder — a document dashboard, a bulk migration check — call
the composable directly in a component `setup()`. It resolves the element
registry reactively, honouring `config.elementTypes` and an app-level
`PAGE_ELEMENT_TYPES_KEY` provide alike:
```ts
const { findings, byNodeId } = useAuthoringFindings(schema, config);
```
## Features
* **Searchable element library** — the right panel groups draggable cards under **Fields** (first when a field contract exists), **Containers**, **Elements** and, when a repository is supplied, **Compositions**. Groups are independently collapsible and a single search filters all of them, so repositories with dozens of definitions remain manageable. Registry entries come from built-ins plus [`config.elementTypes`](./custom-elements), filtered by `config.allowedElements`. `hideElementPicker` removes value-producing entries from **Elements** and from the outline's separate Inputs add-child group; containers and content/action elements remain available.
* **Pointer-based drag & drop** — built on pointer events rather than HTML5 drag events, so it works with mouse, touch **and** pen (tablet-first). Mouse drags start after a 5 px movement threshold, so plain clicks keep working; touch/pen drags arm after a 300 ms long-press. A ghost preview follows the pointer, scroll containers auto-scroll near their edges, and `Escape` cancels a drag in flight.
* **Outline tree** — hierarchical node list with selection and real drag-to-reorder: every row except the root carries a grip handle, thin drop bars light up between rows while dragging, and container rows highlight for drop-**into**. Per-row actions: move up/down, duplicate, delete, plus an inline "Add child" menu. Stacks display "Column" or "Row" based on direction. Warning icons mark nodes with validation issues (hover for the full message).
* **Canvas** — per-element preview components with dashed selection borders; each node's type tab doubles as its drag handle. A registered element without its own preview renders as a neutral icon+label chip; unregistered or disallowed element types get a red "skipped at runtime" treatment, so the canvas never pretends the runtime renderer will show them. Switches to live preview in the **Preview** tab and to a paste-and-apply JSON editor in the **JSON** tab.
* **Properties panel** — the lower-left inspector is resolved from the element registry: each definition ships its own inspector component. Value-producing elements additionally get a host-owned **Field** section (field name, Required, default value — with a per-element default-value editor when the definition provides one, e.g. "Checked by default" for checkboxes). With a [field contract](./#field-contract), the field-name input becomes a select over the contract fields the element can edit (clearing it unbinds; binding carries the contract label and required along), an **Element** select switches the node to another representation of the same field, and `allowCustomFields` adds a free-text custom-name input. Every non-page node also gets a host-owned **Visibility** section authoring [`visibleWhen`](./coar-page-renderer#conditional-visibility-visiblewhen): a "Visible when" select over the page's named fields plus a typed `equals` editor (checked/unchecked for boolean controllers, the option list for choice controllers, free text otherwise; a JSON-authored `in` condition is surfaced read-only) — conditional nodes carry an **eye marker** on their canvas tab. Selecting the page root shows a **Page** section with the [Enter-to-submit](./coar-page-renderer#enter-to-submit) checkbox; the button inspector offers the matching "Default button" checkbox. The choice inputs' inspectors take an **Options source ID** for [API-backed option lists](./coar-page-renderer#dynamic-options-optionssource). A linked composition root additionally shows its name, pinned version, version selector, update-to-latest, detach and open actions. Validation issues for the selected node are surfaced at the top with colored banners. Option-based inputs share an **options editor** (add / remove / reorder options; a removed option clears a default that pointed at it).
* **Reusable compositions** — repository-backed definitions appear beside normal elements and materialize through the same drag-and-drop pipeline. No runtime wrapper or persisted composition element type is introduced: the inserted subtree stays ordinary PageBuilder JSON plus builder-only origin metadata. Version changes are explicit; runtime compilation removes repository metadata and needs no repository access.
* **Duplicate** — available as an outline row action and a canvas button. Deep-clones the subtree with fresh ids on every node; colliding field names are flagged by the duplicate-name validation.
* **Stack direction toggle** — change a stack from column to row direction without re-creating it. Children stay put.
* **Layout controls** — every node's Style section exposes the flex model: container `Justify` (main axis) + `Align items` (cross axis), and per-node `Align self`, `Size` (Fit / Fill / Fixed → Width) and `Min height`. Center a single element, distribute a row, or build a full-screen centered page — and the Editor canvas mirrors the result 1:1 with the Preview.
* **Asset picker entry point** — when `config.pickAsset` is set, the image element shows a thumbnail + "Choose…" button that defers to your own picker UI.
* **Responsive authoring and preview** — Mobile-first base styles plus Phone, Tablet and Desktop overrides. The exact Compact 320×568, Phone 390×844, Tablet 768×1024 and Desktop 1280×800 frames use the same resolver as runtime; each override can be reset independently. `hidden` removes the node from rendering, validation and action payloads at that breakpoint. Both the Style section and the code-mode **Quick Properties** show the value that applies at the breakpoint being authored, not the base — a node hidden on Compact and revealed on Desktop reads as visible while you are on Desktop. Where that value came from an override, the Quick Property carries a small breakpoint chip, because editing it writes one assignment that applies everywhere and would flatten the difference.
* **Runtime bindings and localization** — bind supported element props and individual `actionValues.` entries to allow-listed context paths, customer Page State, named form fields/selections, or the current repeater item/index. Safe expressions use the same targets. Properties explicitly registered as `valueKind: 'localized-text'` reference stable keys; the central **Translations** tab edits the page-owned catalogue for every configured locale and flags unused keys. Legacy embedded `LocalizedValue` objects remain readable.
* **Conditions, repeaters and feedback zones** — `visibleWhen` supports field/context/state/item sources and bounded `all`/`any` composition. A generic `repeat` renders a child template for an allow-listed context array and can emit a selected-key array under any configured output name. A `feedback` node places form errors, status, loading or authored messages inside the layout.
* **Host-supplied preview inputs** — the embedded preview and its sandbox session run against `previewContext` and `previewLocale`. Until the host supplies whatever its own `config` declares (`contextFields`, `locales`), the preview stays off and says so rather than rendering against invented data. Swapping `previewContext` restarts the sandbox against the new contract, so a host that wants an “empty / typical / 50 items” picker builds it in its own chrome and simply binds the chosen sample.
* **Page Root Code** — selecting the page root exposes a separate constrained code editor. It can reactively configure only `page.style`, `page.responsive`, and `page.enterSubmits`; structure, children, ids, types and names remain visual-builder owned. Shared mutable data stays in the independent Page State editor.
* **Undo / redo** — `Ctrl+Z` / `Ctrl+Y` (or `Cmd+Z` / `Cmd+Shift+Z`), also via toolbar buttons.
* **Scoped keyboard shortcuts** — undo/redo and `Delete` / `Backspace` (removes the selected node) only act while focus is inside *that builder instance*, and never while focus is in an editable target: the JSON textarea, props-panel inputs and your app's own form fields keep their native undo and delete behavior.
* **Keyboard navigation** — the outline is an ARIA tree (`role="tree"` / `role="treeitem"` with `aria-level`, `aria-selected`, `aria-expanded`) with a roving tabindex: `Arrow Up` / `Arrow Down` / `Home` / `End` move focus, `Enter` / `Space` selects the focused row. Canvas nodes are focusable too; `Enter` / `Space` selects the focused node.
## JSON tab
The JSON tab shows the current schema and lets you paste and **Apply** a replacement. Apply is gated by **issue severity** — the pasted tree runs through the same normalization pass the v-model entry points use:
* **Errors block Apply** — structural damage where data would be dropped: non-object nodes (and non-JSON input). The inline message lists what is wrong; nothing reaches the working tree (or, through `v-model`, your storage).
* **Warnings apply anyway** and are surfaced next to the Apply button: everything healed in place or lossless — legacy `column` / `row` containers (→ `stack`), v1 flat nodes (→ `props` bags), a non-`page` root (wrapped in a `page`), missing or duplicate node ids (fresh ids), missing or non-object `props` bags, out-of-range or non-numeric heading levels, non-array `children` (reset), `children` on a non-container, and **unknown element types**.
Unknown element types are deliberately *not* rejected: a document from a newer library version — or one using [consumer elements](./custom-elements) this instance hasn't registered — stays pasteable and round-trips **losslessly** (the nodes stay in the tree; the runtime renderer skips them with one console warning per type).
A successful Apply lands as a single undoable step; when there were no findings at all, the builder switches back to the Editor tab.
::: info Exported helpers
The same machinery is exported for hosts that persist or migrate schemas themselves: `normalizePageSchema(value)` → `{ schema, issues, changed }` (issues carry `severity: 'error' | 'warning'`), `migrateLegacyTypes(node)`, `migrateV1PropsBag(node)`, and the `KNOWN_ELEMENT_TYPES` set (built-ins only). See [Legacy schemas & normalization](./coar-page-renderer#legacy-schemas-normalization).
:::
## Builder-side findings
The builder derives its authoring findings reactively and surfaces them at two layers:
* **Outline** — a warning icon next to the affected node row (red ⛔ for errors, yellow ⚠ for warnings). Hover the icon for the full message.
* **Props panel** — a colored banner at the top of the selected node's properties listing every finding for that node.
Built-in rules:
| Rule | Severity |
|------|----------|
| Element type is not registered (skipped at runtime, but kept losslessly in the tree) | warning |
| Type not in `config.allowedElements` (skipped at render time) | error |
| Any registry element with `action: true` has no Action | warning |
| Action ID is not in `config.availableActions` (only checked when that list is non-empty) | warning |
| Action values are not JSON-safe, or the dynamic action-value key is empty/reserved | error |
| `validation.pattern` does not compile as a regular expression | error |
| Image has no Asset ID | error |
| Two named inputs share the same `name` | error |
| Bound field name is not in the [field contract](./#field-contract) (`config.dataContract` set, `allowCustomFields` off) | error |
| Element cannot edit its bound contract field's value type | error |
| Required contract field is missing from the page (reported on the root node) | warning |
| `visibleWhen` is malformed (node stays always visible) | warning |
| `visibleWhen` references a field that is not on the page | warning |
| `visibleWhen` chain is circular (incl. self-reference and loops through ancestor containers) — fields can lock each other hidden | warning |
| `visibleWhen.equals` targets a multi-value (`string[]`) field — the condition can never match | warning |
| Multiple buttons claim `default` — Enter fires only the first in tree order | warning |
| Field name is reserved (`__proto__`, `constructor`, `prototype`) — excluded from the value model | error |
| `optionsSourceId` is set but `config.optionsSource` is not configured | warning |
Element definitions can contribute their own findings through the definition's `builder.lint` hook — they are merged into the same outline/props-panel surfaces with their declared severity (see [Custom elements](./custom-elements)).
Validation is a builder UX scaffold — it does **not** affect what the renderer does, and no severity blocks saving. The renderer is governed by `allowedElements` (the hard security boundary) and by which handlers exist in the `actions` map.
## Per-element architecture
Every element type — built-in or consumer-registered — is **one registry definition** (`definePageElement`): a runtime renderer plus optional value spec, canvas preview, inspector, default-value editor and lint hook. Built-ins live one folder per element inside the package and are pre-registered on the same contract consumers use:
```
packages/page-builder/src/elements/
├── registry.ts ← contract types · definePageElement · additive merge
├── builtins.ts ← the pre-registered built-in registry
├── heading/
│ ├── index.ts ← the definition (renderer + builder halves)
│ ├── HeadingRenderer.vue
│ ├── HeadingPreview.vue
│ └── HeadingInspector.vue
├── text-input/
└── …
```
`BuilderPropsPanel.vue` is a thin host shell: it resolves the selected node's definition from the merged registry and renders ` ` between the host-owned **Field** and **Style** sections. The palette, add-child menu, canvas previews and outline icons all derive from the same registry — adding an element type is a single definition, **no central files are touched**. Consumer apps register theirs via `config.elementTypes`; the shared `OptionsEditor` component is exported for reuse in consumer inspectors. See the [Custom elements guide](./custom-elements).
## Pairing with the renderer
The builder's Preview tab uses `` internally with the same `config` — the renderer falls back to `config.assetResolver` on its own, so thumbnails work without extra wiring. For the actual runtime page (outside the builder), you mount the renderer yourself — see [``](./coar-page-renderer) and the [integration walkthrough](./#complete-idp-integration-walkthrough).
## i18n Keys
All builder chrome — and the runtime renderer's validation messages — resolve through [`@cocoar/vue-localization`](/foundations/localization/translations) (a peer dependency) with keys under `coar.pageBuilder.*`. English fallbacks are built in, so apps without a translation setup render English. Values in `{braces}` are interpolation parameters.
### Chrome (tabs, toolbar, panels, preview widths)
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.chrome.outline` | `'Outline'` |
| `coar.pageBuilder.chrome.collapseOutline` | `'Collapse outline'` |
| `coar.pageBuilder.chrome.expandOutline` | `'Expand outline'` |
| `coar.pageBuilder.chrome.collapseProperties` | `'Collapse properties'` |
| `coar.pageBuilder.chrome.expandProperties` | `'Expand properties'` |
| `coar.pageBuilder.chrome.tabEditor` | `'Editor'` |
| `coar.pageBuilder.chrome.tabPreview` | `'Preview'` |
| `coar.pageBuilder.chrome.tabJson` | `'JSON'` |
| `coar.pageBuilder.chrome.undo` | `'Undo (Ctrl+Z)'` |
| `coar.pageBuilder.chrome.redo` | `'Redo (Ctrl+Y)'` |
| `coar.pageBuilder.chrome.jsonHint` | `'Paste or edit JSON, then click Apply'` |
| `coar.pageBuilder.chrome.jsonApply` | `'Apply →'` |
| `coar.pageBuilder.chrome.previewWidth` | `'Preview width'` |
| `coar.pageBuilder.chrome.previewDesktop` | `'Desktop'` |
| `coar.pageBuilder.chrome.previewFullTitle` | `'Full width'` |
| `coar.pageBuilder.chrome.previewTablet` | `'Tablet · 768'` |
| `coar.pageBuilder.chrome.previewTabletTitle` | `'768px'` |
| `coar.pageBuilder.chrome.previewMobile` | `'Mobile · 375'` |
| `coar.pageBuilder.chrome.previewMobileTitle` | `'375px'` |
### Shared row actions
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.common.moveUp` | `'Move up'` |
| `coar.pageBuilder.common.moveDown` | `'Move down'` |
| `coar.pageBuilder.common.duplicate` | `'Duplicate'` |
| `coar.pageBuilder.common.delete` | `'Delete'` |
### Outline
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.outline.treeLabel` | `'Page structure'` |
| `coar.pageBuilder.outline.addChild` | `'Add child'` |
| `coar.pageBuilder.outline.column` | `'Column'` |
| `coar.pageBuilder.outline.row` | `'Row'` |
| `coar.pageBuilder.outline.validationIssues` | `'Validation issues'` |
### Palette
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.palette.containers` | `'Containers'` |
| `coar.pageBuilder.palette.inputs` | `'Inputs'` |
| `coar.pageBuilder.palette.elements` | `'Elements'` |
| `coar.pageBuilder.palette.fields` | `'Fields'` |
| `coar.pageBuilder.palette.dragToAdd` | `'Drag to add {label}'` |
| `coar.pageBuilder.palette.fieldBound` | `'Already on the page'` |
| `coar.pageBuilder.palette.fieldNoElement` | `'No compatible element available'` |
### Canvas
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.canvas.emptyContainer` | `'Empty {type} — drop something here'` |
| `coar.pageBuilder.canvas.unknownType` | `'Unknown type "{type}" — skipped at runtime'` |
| `coar.pageBuilder.canvas.notAllowed` | `'Not in allowedElements — skipped at runtime'` |
| `coar.pageBuilder.canvas.visibleWhen` | `'Shown conditionally — depends on "{field}"'` |
### Element type labels
Used by the palette, the outline's add-child menu and the canvas type tabs. These are the built-in elements' `label` keys; consumer-registered elements carry their own `label: { key, fallback }` in the element definition, so their keys live in the consumer's namespace, not under `coar.pageBuilder.*`.
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.type.page` | `'Page'` |
| `coar.pageBuilder.type.stack` | `'Stack'` |
| `coar.pageBuilder.type.card` | `'Card'` |
| `coar.pageBuilder.type.section` | `'Section'` |
| `coar.pageBuilder.type.divider` | `'Divider'` |
| `coar.pageBuilder.type.spacer` | `'Spacer'` |
| `coar.pageBuilder.type.heading` | `'Heading'` |
| `coar.pageBuilder.type.paragraph` | `'Paragraph'` |
| `coar.pageBuilder.type.note` | `'Note'` |
| `coar.pageBuilder.type.image` | `'Image'` |
| `coar.pageBuilder.type.link` | `'Link'` |
| `coar.pageBuilder.type.button` | `'Button'` |
| `coar.pageBuilder.type.textInput` | `'Text Input'` |
| `coar.pageBuilder.type.passwordInput` | `'Password'` |
| `coar.pageBuilder.type.numberInput` | `'Number Input'` |
| `coar.pageBuilder.type.checkbox` | `'Checkbox'` |
| `coar.pageBuilder.type.switch` | `'Switch'` |
| `coar.pageBuilder.type.select` | `'Select'` |
| `coar.pageBuilder.type.multiSelect` | `'Multi Select'` |
| `coar.pageBuilder.type.radioGroup` | `'Radio Group'` |
| `coar.pageBuilder.type.dateInput` | `'Date'` |
| `coar.pageBuilder.type.dateTimeInput` | `'Date & Time'` |
| `coar.pageBuilder.type.otpInput` | `'OTP Input'` |
### Properties panel
The host-owned **Field** section (name / required / default value, shown for every value-producing element) uses `props.fieldName`, `props.required` and `props.defaultValue`.
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.props.panelTitle` | `'Properties'` |
| `coar.pageBuilder.props.emptyTitle` | `'No node selected'` |
| `coar.pageBuilder.props.emptyHint` | `'Click a node in the outline or canvas to edit it.'` |
| `coar.pageBuilder.props.text` | `'Text'` |
| `coar.pageBuilder.props.title` | `'Title'` |
| `coar.pageBuilder.props.label` | `'Label'` |
| `coar.pageBuilder.props.level` | `'Level'` |
| `coar.pageBuilder.props.fieldName` | `'Field name'` |
| `coar.pageBuilder.props.fieldUnbound` | `'Not bound'` |
| `coar.pageBuilder.props.customFieldName` | `'Custom name'` |
| `coar.pageBuilder.props.elementType` | `'Element'` |
| `coar.pageBuilder.props.placeholder` | `'Placeholder'` |
| `coar.pageBuilder.props.inputType` | `'Input type'` |
| `coar.pageBuilder.props.rows` | `'Rows'` |
| `coar.pageBuilder.props.min` | `'Min'` |
| `coar.pageBuilder.props.max` | `'Max'` |
| `coar.pageBuilder.props.step` | `'Step'` |
| `coar.pageBuilder.props.decimals` | `'Decimals'` |
| `coar.pageBuilder.props.length` | `'Length'` |
| `coar.pageBuilder.props.mask` | `'Mask input'` |
| `coar.pageBuilder.props.otpType` | `'Character set'` |
| `coar.pageBuilder.props.defaultValue` | `'Default value'` |
| `coar.pageBuilder.props.checkedByDefault` | `'Checked by default'` |
| `coar.pageBuilder.props.onByDefault` | `'On by default'` |
| `coar.pageBuilder.props.required` | `'Required'` |
| `coar.pageBuilder.props.disabled` | `'Disabled'` |
| `coar.pageBuilder.props.options` | `'Options'` |
| `coar.pageBuilder.props.addOption` | `'Add option'` |
| `coar.pageBuilder.props.removeOption` | `'Remove option'` |
| `coar.pageBuilder.props.optionLabelPlaceholder` | `'label'` |
| `coar.pageBuilder.props.optionValuePlaceholder` | `'value'` |
| `coar.pageBuilder.props.action` | `'Action'` |
| `coar.pageBuilder.props.actionHint` | `'Matched against the actions map at render time'` |
| `coar.pageBuilder.props.notConfigured` | `'{id} (not configured)'` |
| `coar.pageBuilder.props.validatesForm` | `'Validates form before firing'` |
| `coar.pageBuilder.props.defaultButton` | `'Default button (Enter submits here)'` |
| `coar.pageBuilder.props.enterSubmits` | `'Enter submits (fires the default button)'` |
| `coar.pageBuilder.props.visibleWhenField` | `'Visible when'` |
| `coar.pageBuilder.props.alwaysVisible` | `'Always visible'` |
| `coar.pageBuilder.props.visibleWhenEquals` | `'equals'` |
| `coar.pageBuilder.props.visibleWhenIn` | `'Multi-value condition (in) — edit it in the JSON tab.'` |
| `coar.pageBuilder.props.visibleWhenArray` | `'"equals" cannot match a multi-value field — author this condition in the JSON tab.'` |
| `coar.pageBuilder.props.checked` | `'checked'` |
| `coar.pageBuilder.props.unchecked` | `'unchecked'` |
| `coar.pageBuilder.props.optionsSource` | `'Options source ID'` |
| `coar.pageBuilder.props.optionsSourceHint` | `'Resolved via config.optionsSource at render time — overrides the static options'` |
| `coar.pageBuilder.props.variant` | `'Variant'` |
| `coar.pageBuilder.props.iconLeft` | `'Icon (left)'` |
| `coar.pageBuilder.props.asset` | `'Asset'` |
| `coar.pageBuilder.props.assetId` | `'Asset ID'` |
| `coar.pageBuilder.props.assetIdHint` | `'Resolved via assetResolver at render time'` |
| `coar.pageBuilder.props.altText` | `'Alt text'` |
| `coar.pageBuilder.props.choose` | `'Choose…'` |
| `coar.pageBuilder.props.change` | `'Change…'` |
| `coar.pageBuilder.props.clear` | `'Clear'` |
| `coar.pageBuilder.props.noImage` | `'No image'` |
| `coar.pageBuilder.props.noPreview` | `'No preview'` |
| `coar.pageBuilder.props.stackDirection` | `'Stack direction'` |
| `coar.pageBuilder.props.direction` | `'Direction'` |
| `coar.pageBuilder.props.column` | `'Column'` |
| `coar.pageBuilder.props.row` | `'Row'` |
| `coar.pageBuilder.props.orientation` | `'Orientation'` |
| `coar.pageBuilder.props.horizontal` | `'Horizontal'` |
| `coar.pageBuilder.props.vertical` | `'Vertical'` |
| `coar.pageBuilder.props.wrapChildren` | `'Wrap children'` |
| `coar.pageBuilder.props.gap` | `'Gap'` |
| `coar.pageBuilder.props.padding` | `'Padding'` |
| `coar.pageBuilder.props.justify` | `'Justify (main axis)'` |
| `coar.pageBuilder.props.alignItems` | `'Align items (cross axis)'` |
| `coar.pageBuilder.props.alignSelf` | `'Align self'` |
| `coar.pageBuilder.props.size` | `'Size'` |
| `coar.pageBuilder.props.sizeAuto` | `'Auto'` |
| `coar.pageBuilder.props.sizeFill` | `'Fill'` |
| `coar.pageBuilder.props.sizeFixedWidth` | `'Fixed width'` |
| `coar.pageBuilder.props.sizeCss` | `'Size (CSS)'` |
| `coar.pageBuilder.props.width` | `'Width'` |
| `coar.pageBuilder.props.minHeight` | `'Min height'` |
| `coar.pageBuilder.props.spacerSizeHint` | `'Leave empty to fill available space'` |
| `coar.pageBuilder.props.default` | `'— default'` |
| `coar.pageBuilder.props.inherit` | `'— inherit'` |
| `coar.pageBuilder.props.none` | `'— none'` |
### Inspector section titles
Headings of the collapsible sections in the properties panel. `props.section.field`, `props.section.page`, `props.section.visibility`, `props.section.style` and `props.section.layout` are host-owned; the per-element titles come from each built-in definition's `inspectorTitle`. Consumer-registered elements provide their own `inspectorTitle: { key, fallback }`, so their keys are not under `coar.pageBuilder.*`.
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.props.section.field` | `'Field'` |
| `coar.pageBuilder.props.section.page` | `'Page'` |
| `coar.pageBuilder.props.section.visibility` | `'Visibility'` |
| `coar.pageBuilder.props.section.style` | `'Style'` |
| `coar.pageBuilder.props.section.layout` | `'Layout'` |
| `coar.pageBuilder.props.section.card` | `'Card'` |
| `coar.pageBuilder.props.section.section` | `'Section'` |
| `coar.pageBuilder.props.section.spacer` | `'Spacer'` |
| `coar.pageBuilder.props.section.heading` | `'Heading'` |
| `coar.pageBuilder.props.section.paragraph` | `'Paragraph'` |
| `coar.pageBuilder.props.section.note` | `'Note'` |
| `coar.pageBuilder.props.section.image` | `'Image'` |
| `coar.pageBuilder.props.section.link` | `'Link'` |
| `coar.pageBuilder.props.section.button` | `'Button'` |
| `coar.pageBuilder.props.section.textInput` | `'Text input'` |
| `coar.pageBuilder.props.section.passwordInput` | `'Password input'` |
| `coar.pageBuilder.props.section.numberInput` | `'Number input'` |
| `coar.pageBuilder.props.section.checkbox` | `'Checkbox'` |
| `coar.pageBuilder.props.section.switch` | `'Switch'` |
| `coar.pageBuilder.props.section.select` | `'Select'` |
| `coar.pageBuilder.props.section.multiSelect` | `'Multi select'` |
| `coar.pageBuilder.props.section.radioGroup` | `'Radio group'` |
| `coar.pageBuilder.props.section.dateInput` | `'Date'` |
| `coar.pageBuilder.props.section.dateTimeInput` | `'Date & time'` |
| `coar.pageBuilder.props.section.otpInput` | `'OTP input'` |
### Runtime validation messages
Shown by `` under invalid fields when a validating button is clicked.
| Key | Default (English) |
|-----|-------------------|
| `coar.pageBuilder.validation.required` | `'This field is required'` |
| `coar.pageBuilder.validation.minLength` | `'Minimum {n} characters'` |
| `coar.pageBuilder.validation.maxLength` | `'Maximum {n} characters'` |
| `coar.pageBuilder.validation.pattern` | `'Invalid format'` |
| `coar.pageBuilder.validation.matchField` | `'Does not match'` |
---
---
url: /components/page-builder/coar-page-renderer.md
description: >-
CoarPageRenderer turns a PageNode schema into live Cocoar components at
runtime, enforcing the allowedElements security boundary with actions,
validation and initialValues.
---
# ``
The runtime-renderer half of `@cocoar/vue-page-builder`. Takes a `PageNode` schema (produced by [``](./coar-page-builder) or written by hand) and renders it as live Cocoar components. This is the component you mount on the actual page that end-users see.
The renderer is also the **security boundary** — elements not in `config.allowedElements` are skipped at render time, even if they appear in hand-written or tampered JSON.
::: tip Stylesheet
Import `@cocoar/vue-page-builder/styles` once in your app — the renderer's layout styles (stack flexbox, section/card spacing) live there too, not just the builder chrome.
:::
## Props
| Prop | Type | Description |
|------|------|-------------|
| `schema` | `PageNode` | Required. The page schema to render. Legacy `column`/`row` containers and v1 flat documents are [migrated on the fly](#legacy-schemas-normalization). |
| `config` | [`PageConfig`](./#pageconfig-the-consumer-contract) | Security/allowlist boundary. Elements not in `config.allowedElements` are skipped at render time (with one console warning per type) **and excluded from the value model**. Also supplies the `assetResolver` fallback and the [consumer element registrations](./custom-elements) (`config.elementTypes`). |
| `actions` | `Record void \| Promise>` | Map of action IDs to handler functions. Buttons and links call these. A returned Promise is awaited: buttons disable (the triggering one spins) until it settles, further clicks are ignored, and a rejection surfaces in the [form-level error banner](#async-actions-the-form-level-error-channel). |
| `onValidate` | `(values: ActionValues) => Record \| Promise>` | Developer-only cross-field/server validation. Runs at **submit time** — when a `validates: true` button is clicked and after all declarative rules pass. May be sync or async; returns `{ fieldName: errorMessage }`. A non-empty result blocks the action. The reserved key `_form` addresses the form as a whole (banner instead of a field). Not exposed in builder UI. See [Validation](#validation). |
| `assetResolver` | `(id: string) => string` | Resolves an `assetId` to a URL at render time. Falls back to `config.assetResolver` when not set. Needed when the schema contains `image` nodes. |
| `initialValues` | `ActionValues` | Host-supplied field values for edit-form scenarios, merged **over** the schema's `defaultValue`s on init. Only keys that match a **named** input in the (allowed) tree are taken — stray host data never leaks into the action payload. Replacing the object with **different values** re-initializes the form, like a schema change; a value-identical replacement (e.g. an inline object literal re-created by a parent re-render — nested objects/arrays compare by content) is ignored, so in-progress user input survives. |
| `runtimeContext` | `Record` | Host-owned runtime data. The document can only read paths explicitly declared by `config.contextFields`; undeclared paths resolve to the binding fallback. |
| `locale` | `string` | Active locale used to resolve page translation keys, legacy `LocalizedValue` props and localized templates. Regional locales fall back to their base locale and then `config.defaultLocale`. |
| `viewportWidth` | `number` | Optional deterministic container width. Runtime normally measures its container; previews and tests can provide an exact width. |
| `fallbackSchema` | `PageNode` | Host-owned safe document rendered when the customized document fails allow-list, binding or document-limit validation. `usingFallback` is exposed on the component ref. |
## Usage
```vue
```
`ActionValues` is `Record` — a flat map of all named fields at the time the action fires. Every input element with a `name` property contributes its value — **including untouched ones**: text/otp inputs contribute **strings** (`''` when untouched), `number-input` a **number** (`null`), `checkbox`/`switch` **booleans** (`false`), `multi-select` a **string array** (`[]`), `select`/`radio-group` a **string** (`null`), date inputs an **ISO string** (`null`). Fields in [conditionally hidden](#conditional-visibility-visiblewhen) or disallowed subtrees are excluded. Handlers receive a snapshot, not live state. Hand-written schemas carry no hard type guarantee, so narrow the values in your handler.
To prefill a form (edit scenarios), pass `initialValues` — the values seed **over** the schema defaults, filtered to the named fields that actually exist in the allowed tree. Prefer a stable reference (a `computed` or plain object created once): replacing it with different values re-initializes the form and discards user edits.
```vue
```
## Events & host form API
The renderer is not a black box between init and action click — it emits value changes and exposes a small form API on its component ref:
| Surface | Description |
|---------|-------------|
| `@update:values` (event) | Fires with a snapshot of the current value map on init, on every field edit and on `reset()` — unlocks autosave, drafts and dirty tracking. The snapshot is a copy, safe to keep; it contains the named fields of the allowed **and currently visible** tree. |
| `values` (exposed) | Snapshot of the current value map (same rules as the event). |
| `isDirty` (exposed) | `true` once any field differs from its initial state (schema defaults + `initialValues`). |
| `isFormValid` (exposed) | Quiet validation state — `true` while every declarative rule passes. Shows no errors. |
| `reset()` (exposed) | Back to the initial state: schema defaults + `initialValues`; touched flags, server errors and the form banner are cleared. |
```vue
```
## JSON Schema
One node grammar for every element (wire-format **v4**): the v2 `props`-bag and v3 runtime-composition grammar remain compatible; v4 gives every element a stable page-wide `name` for Element Code and form identity.
```ts
interface ElementNode {
id: string // stable UUID (crypto.randomUUID), assigned by the builder
type: string // element-registry key — a built-in type or a consumer key
props: Record // element-specific props (JSON-safe bag)
style?: NodeStyle
responsive?: Partial>>
bindings?: Record
// Value-model trio — meaningful when the element's definition declares `value`:
name?: string
defaultValue?: unknown
validation?: FieldValidation
visibleWhen?: VisibleWhen // conditional visibility — see below
children?: PageNode[] // containers only
}
interface NodeStyle {
// ── Container: how this node lays out its children ──
gap?: string // CSS gap between children — '8px', '1rem', …
padding?: string // CSS padding inside this node
justify?: 'start' | 'center' | 'end' // justify-content — main-axis
| 'space-between' | 'space-around' | 'space-evenly'
align?: 'start' | 'center' | 'end' | 'stretch' // align-items — cross-axis
// ── Self: how this node sits inside its parent ──
alignSelf?: 'start' | 'center' | 'end' | 'stretch' // align-self — overrides parent `align`
size?: 'fit' | 'fill' | 'fixed' // sizing along the parent's main axis
width?: string // used when size: 'fixed' — '380px', '100%', …
minHeight?: string // 'min-height' — e.g. '100dvh' to make the page fill the viewport
}
```
The root is the one node shape outside the element grammar: `{ id, type: 'page', schemaVersion, enterSubmits?, stateCode?, rootCode?, translations?, style?, responsive?, children }` — a schema-shape marker, not a placeable element, with no props bag. `rootCode` is a constrained reactive presentation binding and can return only root `style`, `responsive`, and `enterSubmits` changes. **`4`** is current. Older documents remain readable and are normalized deterministically. `enterSubmits` opts the page into [Enter-to-submit](#enter-to-submit).
Node `id`s must be unique page-wide — the builder assigns them via `crypto.randomUUID()` and [repairs missing or duplicate ids](#legacy-schemas-normalization) at every entry point.
### Layout behaviour
Containers are flexbox. The `page` root and `card` / `section` bodies are columns; a `stack` is either (`direction: 'column' | 'row'`, default `column`, plus optional `wrap` for rows).
* **page** — the schema root. A vertical stack; the only element allowed at the top of the tree.
* **stack** — generic flex container. Toggle `direction` between `column` and `row`. Row children are **natural-width by default** — opt a child into growing with `size: 'fill'`.
* **card** — `CoarCard` wrapper, optional `title`. Children stacked vertically.
* **section** — semantic `` with optional `title` heading.
#### Sizing and alignment
`NodeStyle` separates *how a container arranges its children* from *how a node sizes and places itself*:
| Field | Applies to | Maps to | Use |
|-------|-----------|---------|-----|
| `justify` | containers | `justify-content` | distribute children on the main axis (e.g. push a button row right with `end`) |
| `align` | containers | `align-items` | align children on the cross axis |
| `alignSelf` | any node | `align-self` | override the parent's `align` for one node — e.g. center a single button in a left-aligned column |
| `size` | any node | flex / width | `fit` (natural) · `fill` (take available space) · `fixed` (+ `width`) |
| `minHeight` | any node | `min-height` | give a node a minimum height (see below) |
`size: 'fill'` is **direction-aware**: in a row it grows along the row; in a column it becomes full-width (so a "fill" Sign-in button spans the whole card).
#### Full-screen / centered pages
The renderer is a width-filling block and measures that container for responsive resolution. To center content on a full-screen page (the classic login card), size the `page` itself:
```json
{ "type": "page", "style": { "minHeight": "100dvh", "justify": "center", "align": "center" } }
```
`minHeight: '100dvh'` makes the page fill the current dynamic viewport; `justify: 'center'` centers vertically (a column's main axis is vertical) and `align: 'center'` centers horizontally — no host CSS required beyond the host having its natural width. Legacy `vh` and the modern `svh`/`lvh` variants are supported as well.
### Example — login page
```json
{
"id": "root",
"type": "page",
"schemaVersion": 3,
"style": { "minHeight": "100dvh", "justify": "center", "align": "center", "padding": "48px" },
"children": [
{
"id": "n1",
"type": "card",
"props": {},
"style": { "size": "fixed", "width": "400px", "gap": "16px" },
"children": [
{ "id": "n2", "type": "image", "props": { "assetId": "logo-primary", "alt": "Acme logo" } },
{ "id": "n3", "type": "heading", "props": { "text": "Welcome back", "level": 1 } },
{ "id": "n4", "type": "text-input", "name": "email",
"props": { "label": "Email", "inputType": "email" },
"validation": { "required": true } },
{ "id": "n5", "type": "password-input", "name": "password",
"props": { "label": "Password" },
"validation": { "required": true, "minLength": 8 } },
{ "id": "n6", "type": "checkbox", "name": "rememberMe", "defaultValue": false,
"props": { "label": "Remember me" } },
{ "id": "n7", "type": "button", "style": { "size": "fill" },
"props": { "label": "Sign in", "action": "auth:login", "validates": true } },
{ "id": "n8", "type": "link", "props": { "label": "Forgot password?", "action": "auth:forgot-password" } }
]
}
]
}
```
Note the split: `name`, `defaultValue`, `validation` and `style` sit at **node level** (host vocabulary, uniform for every element), while `label`, `inputType`, `action`, `assetId`, … sit in **`props`** (each element's own vocabulary).
### Try it live
The same card rendered live (logo omitted). Email is `required` + `inputType: 'email'`, password is `required` + `minLength: 8`, and the Sign-in button `validates`. Click it with empty fields — the click marks every field touched and reveals all errors at once; once the form is valid, the action receives the `ActionValues` and writes them below the card.
## Generic runtime composition (v4)
These features are domain-neutral. Authentication pages are one consumer: names such as `approvedScopes` are ordinary schema configuration, not package concepts.
### Responsive styles
The renderer applies a mobile-first cascade using its measured container width: Compact/base from 320 px, Phone from 390 px, Tablet from 768 px and Desktop from 1280 px. Base values live in `style`; breakpoint differences live in `responsive.phone`, `responsive.tablet` and `responsive.desktop`. The renderer and builder preview share the same resolver. Length values pass through a restrictive CSS-length parser, while colors, typography, radii and elevation use controlled design-token enums.
### Safe bindings, localization and conditions
`bindings` maps an element prop to a controlled runtime source. A target may
be a top-level prop (`disabled`, `label`, …) or one action argument
(`actionValues.approvedScopes`). Supported direct sources are:
| Source | Value |
|--------|-------|
| `context` + `path` | Exact host path declared by `config.contextFields` |
| `state` + `path` | Customer-authored `definePageState(...)` value |
| `field` + `path` | Current named form value |
| `selection` + `path` | Current named Repeat selection (`string[]`) |
| `item` + `path` | Current Repeat item path declared by that Repeat's context contract |
| `index` | Current Repeat index |
| `expression` | Host-sandboxed JavaScript result supplied through `expressionValues` |
Context/item traversal is allow-listed; state/form/selection names come from
the page contract itself. A `RuntimeTemplate` can interpolate several
allow-listed values.
New page documents keep customer-owned messages once on the page root and reference them with a serializable translation binding:
```json
{
"source": "translation",
"key": "page.submit.label",
"params": { "name": "Ada" },
"fallback": "Sign in"
}
```
Element Code creates the same value through `i18n.text(key, params?, fallback?)`. Resolution is page catalogue → host `@cocoar/vue-localization` catalogue → fallback → key. `LocalizedValue` is retained as a compatibility format for existing schemas.
`visibleWhen` uses the same field/context/item sources with `equals`, `notEquals`, `in`, `notIn`, `exists`, `isEmpty` and `isNotEmpty`. Conditions can be combined with bounded `all`/`any` groups. Hidden subtrees do not render, validate or contribute values/action payloads.
### Generic repeaters and selections
`repeat` renders its child template for an allow-listed context array. Item bindings and conditions can only read declared `itemFields`; `maxItems` is capped at 500. Its optional selection contract is also generic:
```json
{
"type": "repeat",
"props": {
"source": "catalog.items",
"keyPath": "id",
"selection": {
"name": "chosenItemIds",
"valuePath": "id",
"requiredPath": "mandatory",
"defaultSelection": "all"
}
}
}
```
The result is `ActionValues.chosenItemIds: string[]`. Required items are always
selected and cannot be unchecked. Host `initialValues` may seed the selection;
otherwise `defaultSelection` is `'none'` or `'all'`. Reconciliation retains
the current choice, removes stale and duplicate values, adds required values,
and emits the source-array order. The output name and item paths are freely
configured; the primitive has no knowledge of scopes, products, roles or any
other domain.
### Feedback placement, actions and fallback
`feedback` is an authorable semantic zone. `kind: 'form-error'` places rejected async-action or `_form` validation errors at that exact tree position; other kinds provide error, success, info and loading status with appropriate live-region semantics. Every action-capable element uses the same optional [`ActionProps`](#action-arguments) payload contract.
Hosts can mark nodes as required, lock their visibility/style/placement, and cap node count/depth through `PageConfig`. If a saved customization violates those invariants, `fallbackSchema` provides a safe host-owned render path rather than a partially broken page.
## Built-in Elements
Built-ins are pre-registered [element definitions](./custom-elements) — they ride exactly the same registry contract as consumer-registered elements. The prop names listed below live in each node's **`props` bag**; `name` / `defaultValue` / `validation` / `style` are node-level host fields on every element.
### Containers
| Type | Description |
|------|-------------|
| `page` | Root container. Always column-direction. |
| `stack` | Generic flex container with toggleable `direction` (`column` | `row`). Optional `wrap` for row-direction stacks. |
| `card` | `CoarCard` wrapper with optional `title` |
| `section` | Semantic section with optional `title` heading |
| `divider` | Visual separator (`CoarDivider`) |
| `spacer` | Empty space — `flex: 1` (fills available space) unless `size` is set |
### Typography & Display
| Type | Props | Description |
|------|-------|-------------|
| `heading` | `text`, `level` (1–6) | H1–H6 heading |
| `paragraph` | `text` | Body text block |
| `note` | `text`, `variant` (`neutral` | `info` | `success` | `warning` | `error` | `accent`) | `CoarNote` callout box |
### Inputs
| Type | Key props (in `props`) | Value type | Cocoar component |
|------|-----------|------------|-----------------|
| `text-input` | `label`, `inputType`, `rows`, `placeholder`, `disabled` | `string` | `CoarTextInput` (textarea when `rows > 1`) |
| `password-input` | `label`, `placeholder`, `disabled` | `string` | `CoarPasswordInput` (masked) |
| `number-input` | `label`, `placeholder`, `min`, `max`, `step`, `decimals`, `disabled` | `number` | `CoarNumberInput` |
| `checkbox` | `label`, `disabled` | `boolean` | `CoarCheckbox` |
| `switch` | `label`, `disabled` | `boolean` | `CoarSwitch` |
| `radio-group` | `label`, `options`, `optionsSourceId`, `orientation`, `disabled` | `string` | `CoarRadioGroup` + `CoarRadioButton` |
| `select` | `label`, `options`, `optionsSourceId`, `placeholder`, `disabled` | `string` | `CoarSelect` |
| `multi-select` | `label`, `options`, `optionsSourceId`, `placeholder`, `disabled` | `string[]` | `CoarMultiSelect` |
| `otp-input` | `label`, `length`, `otpType`, `mask`, `disabled` | `string` | `CoarOtpInput` |
| `date-input` | `label`, `placeholder`, `disabled` — `defaultValue` is ISO `YYYY-MM-DD` | ISO `string` | `CoarPlainDatePicker` |
| `datetime-input` | `label`, `placeholder`, `disabled` — `defaultValue` is ISO `YYYY-MM-DDTHH:mm[:ss]` | ISO `string` | `CoarPlainDateTimePicker` |
All inputs support the node-level `name` (wires the value into `ActionValues`), `defaultValue`, and `validation`, plus `props.disabled`. Fields with `validation.required` get the `*` marker via `CoarFormField`. Required semantics adapt to the value shape: a required `multi-select` needs **at least one** selection, a required `otp-input` needs a **complete** code (all cells filled), a required `switch`/`checkbox` must be **on**.
::: info Date values are ISO strings
The wire format for `date-input`/`datetime-input` is always the ISO string — in the schema's `defaultValue` **and** in `ActionValues`. The renderer converts to/from `Temporal.PlainDate`/`PlainDateTime` at the picker boundary; an unparsable value renders as an empty picker instead of crashing. Zoned (time-zone-aware) date-times are deliberately not part of the element set yet.
:::
#### `inputType`
`text-input` maps its `inputType` onto the right control and autocomplete hints:
| `inputType` | Renders | Autocomplete |
|-------------|---------|--------------|
| `'text'` (default) | `CoarTextInput` with `type="text"` | — |
| `'email'` | `CoarTextInput` with `type="email"` | `autocomplete="email"` |
| `'url'` | `CoarTextInput` with `type="url"` | `autocomplete="url"` |
Masked passwords are their own element: `password-input` (renders `CoarPasswordInput`). Legacy `text-input` nodes with `inputType: 'password'` migrate to it transparently on load.
`inputType: 'email'` also opts the field into the built-in **email format check** — see [Email format](#email-format).
#### Dynamic options (`optionsSource`)
The choice inputs (`select`, `multi-select`, `radio-group`) take their options from the static `options` array by default. For API-backed lists (countries, users, …), set the node's `optionsSourceId` and provide the resolver in the config — the async sibling of `assetResolver`:
```ts
const config: PageConfig = {
optionsSource: async (sourceId) => {
if (sourceId === 'countries') return api.countries(); // Promise
return [];
},
};
```
A set `optionsSourceId` wins over the static `options`; without a configured `optionsSource` the static options are used (and the builder lint warns). While a load is in flight the list is empty; a failed load stays empty and warns once. The resolver is called once per element instance — memoize consumer-side when several elements share a source. Consumer elements get the same behavior via the exported `useResolvedOptions` composable.
#### Declarative rules
Rules live on the node-level `validation` property (host vocabulary, uniform for every valued element):
```ts
interface FieldValidation {
required?: boolean // any valued element — emptiness comes from the element's definition
minLength?: number // string-rule elements (text-input, password-input, …)
maxLength?: number // string-rule elements
pattern?: string // string-rule elements; regex source applied as full-string match
matchField?: string // any valued element — value must equal this other named field's value
message?: string // custom error message — overrides defaults
}
```
`required` and `matchField` are host-enforced on every valued element; the string rules (`minLength` / `maxLength` / `pattern`) are host-enforced on elements whose definition opts in via `value.textRules` (built-in: `text-input` and `password-input`). Other elements express extra rules through their definition's `validate` hook (run crash-guarded, after the host rules) — see [Custom elements](./custom-elements). How and when errors surface is described under [Validation](#validation).
### Actions
| Type | Key props | Description |
|------|-----------|-------------|
| `button` | `label`, `action`, `validates`, `default`, `variant`, `size`, `icon` | `CoarButton` — calls the matching `actions` handler. Content-width by default; use `style.size: 'fill'` for a full-width button. `default: true` marks it as the [Enter-to-submit](#enter-to-submit) target. |
| `link` | `label`, `action` | Inline text link. Content-width by default. |
When `validates: true` on a button, clicking it validates all named fields before the action fires. The button **stays clickable while the form is invalid** — the click reveals the errors instead of firing the action. While a trigger is in flight (an async `onValidate` **or** an async action), the triggering button spins and every other action button and link disables; further clicks are ignored. See [Validation](#validation).
#### Action arguments
Buttons, links and consumer elements with `action: true` share one contract:
```ts
interface ActionProps {
action?: string
actionValues?: Record
actionValueField?: string
actionValue?: unknown
}
```
All four fields are optional. `actionValues` is a JSON-safe key/value map. The
common Properties-panel editor accepts values such as `"de"`, `42`, `true`,
`null`, arrays and objects; every key has its own **fx** switch. A nested
binding such as `bindings["actionValues.language"]` replaces only that entry.
`actionValue` supplies the older single additional value under
`actionValueField` and remains supported.
The renderer builds a detached handler payload in this explicit order:
1. current named form values,
2. resolved `actionValues` (static defaults plus per-key bindings), overwriting colliding form keys,
3. the dynamic `actionValue`, overwriting a static entry with the same `actionValueField`.
This order is identical for click, link activation, Enter-to-submit and consumer action elements. Invalid non-JSON values never reach a handler; builder and activation validation report them as errors.
### Media
| Type | Props | Description |
|------|-------|-------------|
| `image` | `assetId`, `alt` | Resolved via `assetResolver` at render time. Raw URLs are not accepted. |
### Consumer elements
The element set is **open**: register your own element types via `config.elementTypes` (or app-wide via `PAGE_ELEMENT_TYPES_KEY`) and they render, join the value model and validate exactly like built-ins. Element renderers wire themselves through the `usePageElement()` context (`getValue` / `setValue` / `getError` / `markTouched` / `triggerAction` / `isValidating` / `isSubmitting` / `pendingAction` / `formError` / `resolveAsset` / `config`). See the [Custom elements guide](./custom-elements).
## Validation
Named fields validate against their declarative `validation` rules reactively, but errors only *show* once a field is **touched**:
* **text inputs** are touched on blur,
* **checkbox / select** are touched on change — choosing a value *is* the interaction, there is no meaningful blur moment,
* **clicking a `validates: true` button marks every named field touched at once.**
### Click reveals errors
A validating button is **not** disabled while the form is invalid. Clicking it with an invalid form marks all fields touched, reveals every error — including checkbox and select errors that have no blur moment — focuses and scrolls the **first invalid control** into view (off-screen errors must not make the click look dead), and does **not** run the action. A disabled button can't explain itself; a click can.
Buttons disable only while a trigger is genuinely in flight — an async `onValidate` or an async action. The triggering button shows a spinner; every other action button and link disables; repeated clicks are ignored (double-submit guard for validating and non-validating buttons alike).
### `pattern` semantics
`validation.pattern` is applied as a **full-string match** — the source is compiled as `^(?:pattern)$`, the same semantics as the HTML `pattern` attribute. An invalid pattern never crashes the page: it becomes an **inert rule** (the field passes) and the renderer logs one `console.warn` per distinct pattern.
### Email format
A `text-input` with `inputType: 'email'` validates the entered value against the WHATWG email pattern (`input[type=email]` constraint semantics, full string) **by default** — no hand-written `pattern` needed. The check skips empty values (`required` decides those) and uses the localized `coar.pageBuilder.validation.email` message. Since submission is JS-driven (there is no `