` / `` / `` 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, ` `. |
## Restricting the Toolbar
Pass a `tools` array to limit which buttons the toolbar exposes. When omitted, all tools are shown. Order does not matter — the canonical order is preserved.
```vue
```
### 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` | `CoarMarkdownEditorTool[]` | *all* | Whitelist of toolbar tools. See [Restricting the Toolbar](#restricting-the-toolbar) |
| `flavor` | `'commonmark' \| 'gfm' \| 'cocoar' \| { gfm?, textColor? }` | `'cocoar'` | Portability contract — hard-enforces which features can be authored. See [Flavors](#flavors-portability) |
| `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/script-editor.md
---
# 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
---
# 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.
## 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
---
# 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
---
# 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
---
# 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
---
# 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
---
# 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
---
# 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
---
# 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
---
# Page Builder
`@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 — is defined by the **consumer application**, not the library.
## 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
```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.
## 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. Omit to allow every type.
* `page` (the root marker) is always implicitly allowed.
*/
allowedElements?: ElementType[]
/**
* Action IDs that buttons and links 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.
*/
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
}
```
### `allowedElements`
Enforced at **both** layers:
* *Builder*: hidden from the palette and add-child menu; tenants can't insert disallowed types.
* *Renderer*: disallowed nodes are skipped at render time even if they appear in hand-written or tampered JSON. This is the security boundary.
```ts
allowedElements: [
'stack', 'card', 'section', 'divider',
'heading', 'paragraph',
'text-input', 'checkbox', 'button', 'link', 'image',
],
```
Drop element types the tenant shouldn't be able to use. `page` is implicitly always allowed.
### `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;
pass the same function (or one equivalent) to the renderer's :asset-resolver. */
assetResolver: (id) => `https://cdn.example.com/t/${tenantId}/${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;
},
};
```
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; renderer skips). The renderer is the hard boundary — even tampered JSON cannot smuggle in disallowed types.
**Actions** — buttons and links store an action `id`. The renderer only invokes handlers from the consumer-provided `actions` map. Arbitrary JavaScript is never stored in the schema. When `config.availableActions` is set, the builder also constrains the Action input to a labeled dropdown.
**Images** — `image` nodes store an `assetId` reference, never a raw URL. The renderer calls `assetResolver(id)` at render time. Tenants cannot reference external domains. 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' },
],
assetResolver: (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** — JSON pasted into the builder is normalised (`column`/`row` → `stack`, non-`page` roots get wrapped in a `page`). Schemas loaded from your backend that pre-date these types still work — the renderer accepts them implicitly because the builder migrates on paste, but if you have old saved schemas you should run the same migration server-side before storing the new shape.
* **Validation** — the builder warns about missing actions, duplicate field names, and missing asset IDs, but lets the tenant save anyway. If you want hard server-side validation before persisting, walk the tree and reject (e.g., reject if any `image.assetId === ''`).
* **CSP** — image URLs come from `assetResolver`, so your CDN domain needs to be in `img-src`. Action IDs and labels are plain strings — nothing executable lives in the schema.
* **Full-screen / centering** — the renderer is `display: contents`, so the `page` fills the host element's width. To center a login card on a full-height screen, set the `page` node's `minHeight: '100vh'` + `justify: 'center'` + `align: 'center'` — no host CSS needed. 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 |
| **5b — Style editor (visual)** | Spacing sliders + colour pickers (rolls into the tenant theming track) | Planned |
| **6+ — Schema versioning** | Formal `version` field + migration framework | Planned |
---
---
url: /components/page-builder/coar-page-builder.md
---
# ``
The visual-editor half of `@cocoar/vue-page-builder`. Renders a three-panel layout — outline tree on the left, canvas with palette in the centre, properties panel on the right — and 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.
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `modelValue` / `v-model` | `PageNode` | empty `page` | The page schema. Bound two-way; every edit updates the ref. |
| `config` | [`PageConfig`](./#pageconfig-the-consumer-contract) | — | Allowed elements, available actions, asset callbacks. The same value must be passed to the renderer. |
## Features
* **Palette toolbar** — drag containers (Stack, Card, Section) and elements onto the canvas. Hidden types per `config.allowedElements`.
* **Outline tree** — hierarchical node list with selection, drag-to-reorder, inline add/delete. Stacks display "Column" or "Row" based on direction. Warning icons (⚠ / ⛔) mark nodes with validation issues.
* **Canvas** — real component previews with dashed selection borders. Switches to live preview in the **Preview** tab and to a paste-and-apply JSON editor in the **JSON** tab.
* **Properties panel** — per-element configuration. Each element type ships its own props component. Validation issues for the selected node are surfaced at the top with colored banners.
* **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 preview** — Desktop · Tablet · 768 · Mobile · 375 segmented toggle in the Preview tab. The render area is capped and centered so you can verify the design at common breakpoints.
* **Undo / redo** — `Ctrl+Z` / `Ctrl+Y` (or `Cmd+Z` / `Cmd+Shift+Z`), also via toolbar buttons.
* **Keyboard** — `Delete` / `Backspace` removes the selected node (when focus is not in an input).
* **JSON paste migration** — legacy `column` / `row` types are auto-coerced to `stack` on import; other non-`page` root types are auto-wrapped in a `page`.
## Builder-side validation
The builder runs schema-level validation reactively and surfaces issues 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 issue for that node.
Built-in rules:
| Rule | Severity |
|------|----------|
| Button / link has no Action | warning |
| Action ID is not in `config.availableActions` | warning |
| Image has no Asset ID | error |
| Two named inputs share the same `name` | error |
Validation is a builder UX scaffold — it does **not** affect what the renderer does. The renderer is governed by `allowedElements` (the hard security boundary) and by which handlers exist in the `actions` map.
## Per-element architecture
Each element type brings its own props component, registered in a single map:
```
packages/page-builder/src/builder/props/
├── registry.ts ← ElementType → { component, sectionTitle }
├── StackProps.vue
├── CardProps.vue
├── HeadingProps.vue
├── ButtonProps.vue
├── ImageProps.vue
├── …
└── StyleProps.vue ← universal Gap/Justify/Align/Align-self/Size/Min-height/Padding
```
The main `BuilderPropsPanel.vue` is a thin shell that resolves the registry entry and renders ` `. Adding a new element type requires creating one new `Props.vue` file and adding one line to the registry — no central files are touched.
## Pairing with the renderer
The builder's Preview tab uses `` internally, passing through `config.assetResolver` 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).
---
---
url: /components/page-builder/coar-page-renderer.md
---
# ``
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.
## Props
| Prop | Type | Description |
|------|------|-------------|
| `schema` | `PageNode` | Required. The page schema to render. |
| `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). |
| `actions` | `Record void>` | Map of action IDs to handler functions. Buttons and links call these. |
| `onValidate` | `(values: ActionValues) => Promise>` | Developer-only async/cross-field validation. Returns `{ fieldName: errorMessage }`. Not exposed in builder UI. |
| `assetResolver` | `(id: string) => string` | Resolves an `assetId` to a URL at render time. Required when the schema contains `image` nodes. |
## Usage
```vue
```
`ActionValues` is `Record` — a flat map of all named fields at the time the action fires. Fields are collected from `text-input`, `checkbox`, and `select` nodes that have a `name` property.
## JSON Schema
Every node shares a common base:
```ts
interface PageNode {
id: string // stable UUID, assigned by the builder
type: ElementType
style?: NodeStyle
children?: PageNode[] // containers only
// ...element-specific props
}
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. '100vh' to make the page fill the viewport
}
```
### 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 adds no box of its own (`display: contents`), so the `page` node sits directly inside whatever element you mount `` in — that **host provides the width**. To center content on a full-screen page (the classic login card), size the `page` itself:
```json
{ "type": "page", "style": { "minHeight": "100vh", "justify": "center", "align": "center" } }
```
`minHeight: '100vh'` makes the page fill the 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.
### Example — login page
```json
{
"id": "root",
"type": "page",
"style": { "minHeight": "100vh", "justify": "center", "align": "center", "padding": "48px" },
"children": [
{
"id": "n1",
"type": "card",
"style": { "size": "fixed", "width": "400px", "gap": "16px" },
"children": [
{ "id": "n2", "type": "image", "assetId": "logo-primary", "alt": "Acme logo" },
{ "id": "n3", "type": "heading", "text": "Welcome back", "level": 1 },
{ "id": "n4", "type": "text-input", "label": "Email", "name": "email", "inputType": "email" },
{ "id": "n5", "type": "text-input", "label": "Password", "name": "password", "inputType": "password" },
{ "id": "n6", "type": "checkbox", "label": "Remember me", "name": "rememberMe" },
{ "id": "n7", "type": "button", "label": "Sign in", "action": "auth:login", "validates": true, "style": { "size": "fill" } },
{ "id": "n8", "type": "link", "label": "Forgot password?", "action": "auth:forgot-password" }
]
}
]
}
```
## Built-in Elements
### 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
| Type | Props | Description |
|------|-------|-------------|
| `heading` | `text`, `level` (1–6) | H1–H6 heading |
| `paragraph` | `text` | Body text block |
### Inputs
| Type | Key props | Cocoar component |
|------|-----------|-----------------|
| `text-input` | `label`, `name`, `inputType`, `placeholder`, `validation` | `CoarTextInput` / `CoarPasswordInput` |
| `checkbox` | `label`, `name`, `validation` | `CoarCheckbox` |
| `select` | `label`, `name`, `options`, `placeholder` | `CoarSelect` |
All three support `name` (wires the value into `ActionValues`), `disabled`, and `validation`.
```ts
interface FieldValidation {
required?: boolean
minLength?: number // text-input only
maxLength?: number // text-input only
pattern?: string // text-input only; regex source applied as full-string match
matchField?: string // value must equal this other named field's value (text-input only)
message?: string // custom error message — overrides defaults
}
```
### Actions
| Type | Key props | Description |
|------|-----------|-------------|
| `button` | `label`, `action`, `validates`, `variant`, `size`, `icon` | `CoarButton` — calls the matching `actions` handler. Content-width by default; use `style.size: 'fill'` for a full-width button. |
| `link` | `label`, `action` | Inline text link. Content-width by default. |
When `validates: true` on a button, all named fields are validated before the action fires. The button is disabled while any field is invalid.
### Media
| Type | Props | Description |
|------|-------|-------------|
| `image` | `assetId`, `alt` | Resolved via `assetResolver` at render time. Raw URLs are not accepted. |
## Security boundary
The renderer enforces three rules **regardless of what the schema contains**:
1. **Allowed elements** — `config.allowedElements` is the hard boundary. Disallowed types are skipped silently (with one console warning per type).
2. **Actions** — buttons and links store an action `id`. Only handlers present in the `actions` prop fire — any other action ID is a silent no-op. Arbitrary JavaScript is never stored in the schema.
3. **Images** — `image` nodes store an `assetId` reference, never a raw URL. The renderer always goes through `assetResolver`. Tenants cannot reference external domains.
See the [Security Model](./#security-model) section on the overview page for the full discussion.
## Pairing with the builder
The same `config` should be passed to both `` and ``. The builder uses it as UI affordance (palette filter, action dropdown, picker hook); the renderer uses it as the security boundary. Putting the SAME object in both keeps "what tenants can author" and "what gets rendered" in sync.
See the [integration walkthrough](./#complete-idp-integration-walkthrough) for the full builder + renderer wiring example.
---
---
url: /components/document-viewer.md
---
# Document Viewer
`@cocoar/vue-document-viewer` is a generic, source-agnostic document viewer for Vue 3. One component — [``](./coar-document-viewer) — renders **PDFs**, **single images**, and **multi-page image galleries**, with shared toolbar chrome, side panels, and an annotation layer.
You pick the source kind with a small factory (`pdfSource(...)`, `imageSource(...)`, `imageGallerySource(...)`); the viewer dispatches internally. The toolbar greys out tools the active source doesn't support — e.g. search and outline are PDF-only — rather than hiding them, so users don't see UI moving around when they switch documents.
```ts
import {
CoarDocumentViewer,
imageSource,
imageGallerySource,
} from '@cocoar/vue-document-viewer';
import { pdfSource } from '@cocoar/vue-document-viewer/pdf';
import '@cocoar/vue-document-viewer/styles';
```
`pdfjs-dist` is an **optional peer dependency**. PDF consumers import `pdfSource` from the `/pdf` subpath; image-only consumers never pay the pdfjs bundle cost.
## Three source factories
```ts
// PDF — pdfjs subpath
pdfSource({ url, headers?, withCredentials? })
// Single-page raster / vector image (JPG / PNG / SVG / WebP / AVIF / GIF / blob: / data:)
imageSource({ url })
// Multi-page image document — pages may mix orientations
imageGallerySource({ urls })
```
Each factory returns a frozen `DocumentSource`. Build it inside a `computed` so the viewer rebinds only when something actually changes — switching sources keeps the toolbar, panels, and viewport mounted; only the inner page renderer rebinds.
## Single image
Toolbar tools the source doesn't support stay visible but disabled — e.g. **Search**, **Previous/Next page**, and **Outline** are off for a single-page image. Their tooltips append the `notAvailableForSource` label so the user understands the state.
## Image gallery
Pages can mix orientations (landscape page 1, portrait page 2, wide page 3 above). Every page's intrinsic dimensions are read from its own image, so the viewer never letter-boxes or stretches. Sidebar thumbnails track the active page.
## PDF source
The PDF demo runs in the playground at [`localhost:5188/pdf-viewer`](http://localhost:5188/pdf-viewer) — it's omitted here because the pdfjs worker has to be configured by the consumer (one-line setup, below).
```vue
```
### Worker setup (PDF consumers only)
pdfjs needs a worker to parse the binary off the main thread. Wire it once at app bootstrap — Vite/webpack/Rollup all support the `?worker` query:
```ts
// main.ts
import * as pdfjs from 'pdfjs-dist';
import PdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker';
pdfjs.GlobalWorkerOptions.workerPort = new PdfWorker();
```
If you skip this step, pdfjs will try to fetch the worker over the network and fall back to a slow inline mode — both viable, but not what you want in production.
## Source capabilities
Every source advertises what it can do. The toolbar reads these flags to enable/disable individual tools; consumers can inspect them too:
```ts
interface DocumentSourceCapabilities {
multiPage: boolean; // Prev / Next / page input
textLayer: boolean; // Text selection, Ctrl+C copy
search: boolean; // Search button
outline: boolean; // Outline (TOC) sidebar tab
print: boolean; // Print button
}
```
The factories pre-populate the right values: PDFs get all-true, images get a single-page no-text profile, galleries get the same with `multiPage: true`. Adding a future source kind (e.g. OCR'd images) is a matter of providing a new factory that flips the relevant flags.
::: tip Why disabled, not hidden?
Tools that vanish when the source changes make the toolbar layout shift — buttons jump positions, muscle memory breaks. Greying out keeps positions stable and surfaces the capability constraint to the user via tooltip.
:::
## Info panel
When the right-side annotations panel is open, a collapsible **Info** section at the top surfaces source metadata:
* Format string (`"PDF · v1.7"`, `"Image · PNG"`, `"Image gallery · SVG"`)
* Total page count
* Current page dimensions (live — updates as the user flips pages)
* PDF-only fields: title / author / subject / keywords / creator / producer / created / modified / PDF version (empty fields are skipped)
* File size in bytes (PDFs only — pdfjs exposes `contentLength`)
Disable with `:show-info-section="false"` if you want a minimal annotations-only panel.
## Architecture
```
useDocumentLoader(sourceRef) ← internal dispatcher, watches source.kind
usePdfDocumentAdapter(pdfSourceRef)
useImageDocumentAdapter(imgSourceRef)
useImageGalleryAdapter(gallerySourceRef)
each publishes { status, pageProviders, info, error, retry, destroy }
usePageRenderer({ pageProviders, … }) ← source-agnostic, owns canvas + textLayer DOM
DocumentToolbar, DocumentSidebar, DocumentAnnotationPanel, DocumentSearchBar
```
The seam between formats is `PageProvider` — every page (PDF page proxy, image element, future kinds) materializes through the same interface (`render(canvas, opts)`, `cancel()`, optional `getTextLayer()`). The renderer never imports `pdfjs-dist`.
## What's next
| Page | Covers |
|------|--------|
| [CoarDocumentViewer](./coar-document-viewer) | Full props / emits / slots reference, labels, position memory |
| [Toolbar customization](./toolbar) | Order-driven `tools` array, separator pseudo-tool, section toggles |
| [Annotations](./annotations) | Modes, types, lifecycle events, schema, color picker |
---
---
url: /components/document-viewer/coar-document-viewer.md
---
# CoarDocumentViewer
The all-in-one viewer component. One required prop — `source` — plus a handful of toggles for chrome, panels, and the annotation surface.
```ts
import { CoarDocumentViewer } from '@cocoar/vue-document-viewer';
import '@cocoar/vue-document-viewer/styles';
```
For PDFs, also pull `pdfSource` from the [`/pdf` subpath](./#worker-setup-pdf-consumers-only) and wire the worker once at app bootstrap.
## Minimal usage
```vue
```
That's it. Toolbar on, sidebars and annotations panel off, default 'view' annotation mode. Wrap the viewer in a sized container — the component fills 100% of its parent.
## Props
### Source
| Prop | Type | Default | Notes |
|---|---|---|---|
| `source` | `DocumentSource` | *required* | Build via `pdfSource()`, `imageSource()`, or `imageGallerySource()`. Returns a frozen object — build it inside `computed` to avoid unnecessary rebinds. |
Switching the `source` keeps the surrounding chrome mounted; only the inner page renderer rebinds, so users see toolbar / panels stay still across document changes.
### Chrome toggles
| Prop | Type | Default | Notes |
|---|---|---|---|
| `showToolbar` | `boolean` | `true` | Top/side/bottom toolbar — see `toolbarPosition`. |
| `toolbarPosition` | `'top' \| 'right' \| 'bottom' \| 'left'` | `'top'` | Where the toolbar sits. Horizontal at top/bottom, vertical at sides. |
| `showThumbnails` | `boolean` | `false` | Left-rail Thumbnails tab. Works for every source (per-page mini canvas). |
| `showOutline` | `boolean` | `false` | Left-rail Outline (TOC) tab. PDF-only; auto-hides when the doc has no outline. |
| `showAnnotationsPanel` | `boolean` | `false` | Right-rail panel: Info section + annotation list with filter/sort/search. |
| `showInfoSection` | `boolean` | `true` | Top section of the annotations panel surfacing source metadata. Only active when the panel is open. |
| `showSearch` | `boolean` | `true` | Search input (button + bar). Disabled when the source can't search (e.g. images). |
| `showPrintDownload` | `boolean` | `false` | Print + Download buttons in the toolbar. |
| `showAnnotationModes` | `boolean` | `true` | The drawing-mode button group (marker / note / draw / text). |
Two convenience props — `showThumbnails || showOutline` controls the left-rail toggle button; `showAnnotationsPanel` controls the right-rail toggle. The user can collapse either rail from inside the viewer; these props gate whether the toggle is even present in the toolbar.
#### Panel open state (`v-model`)
By default, the left and right rails start closed and their open/closed state lives inside the component — fine for stand-alone viewers. If you embed the viewer inside a parent that mounts/unmounts it (e.g. a tab bar in a file explorer where the user switches between a Markdown file and a PDF and back), use `v-model` to hold the state on the parent so it **persists across remounts**.
| Prop / Event | Type | Notes |
|---|---|---|
| `:sidebar-open` + `@update:sidebar-open` (`v-model:sidebar-open`) | `boolean` | Left rail (thumbnails / outline). Default `false`. |
| `:annotations-panel-open` + `@update:annotations-panel-open` (`v-model:annotations-panel-open`) | `boolean` | Right rail (info section + annotations list). Default `false`. |
```vue
```
Without `v-model`, the state is purely internal — same behavior as before, no breaking change.
### Position memory
```ts
interface CoarDocumentViewerPosition {
page: number; // 0-based page index in view
pageOffset: number; // fractional scroll inside the page, 0..1
zoom: number; // 1 = 100%
rotation: 0 | 90 | 180 | 270;
}
```
Two compatible mechanisms:
| Prop / Event | Use when |
|---|---|
| `storageKey: string` | You want the viewer to persist position in `localStorage` automatically. Different keys per document (e.g. include the file ID) so each document remembers its own view. |
| `:position` + `@update:position` (`v-model:position`) | You own persistence (server, IndexedDB, your existing state manager). Two-way binding — the viewer reads on mount, writes on every change. |
Both mechanisms can coexist; when both are present, the bound `position` wins on mount.
### Toolbar layout
| Prop | Type | Default | Notes |
|---|---|---|---|
| `tools` | `CoarDocumentViewerTool[]` | `undefined` (= `COAR_DOCUMENT_VIEWER_ALL_TOOLS`) | Array drives BOTH the visible set AND the order. See [Toolbar customization](./toolbar). |
### Annotations
| Prop / Event | Type | Notes |
|---|---|---|
| `annotations` | `CoarPdfAnnotation[]` | Consumer-owned. The viewer never mutates this array. |
| `:annotation-mode` + `@update:annotationMode` (`v-model:annotation-mode`) | `'view' \| 'select' \| 'eraser' \| 'marker' \| 'comment' \| 'ink' \| 'freetext'` | The active pointer mode. `'view'` is read-only (existing annotations clickable, no new ones created). |
| `annotationColors` | `string[]` | Palette for the color picker. Defaults to a 7-color pastel + neon set. |
| `@annotation:create` | `(payload: CoarPdfAnnotationCreatePayload) => void` | Consumer assigns `id` + `createdAt` (+ optionally `createdBy`), pushes to `annotations`. |
| `@annotation:update` | `(payload: { id: string; patch: Partial }) => void` | Consumer merges the patch into the matching annotation. |
| `@annotation:delete` | `(id: string) => void` | Consumer removes by id. |
See [Annotations](./annotations) for the full lifecycle, schema, and a worked example.
### Labels
```ts
interface CoarDocumentViewerLabels {
// Loading / error overlays
loading?: string;
errorTitle?: string;
errorRetry?: string;
// Page navigation
pageOf?: string; // "Page {current} of {total}"
pageJumpAria?: string;
prevPage?: string;
nextPage?: string;
// Zoom
zoomIn?: string;
zoomOut?: string;
resetZoom?: string;
zoomLevel?: string;
fitWidth?: string;
fitPage?: string;
resetView?: string;
pan?: string;
// Rotation
rotateCw?: string;
rotateCcw?: string;
// Search
search?: string;
searchNext?: string;
searchPrev?: string;
searchMatchOf?: string; // "{current} of {total}"
// Panels + sidebar tabs
thumbnails?: string;
outline?: string;
annotationsPanel?: string;
// Document actions
print?: string;
download?: string;
// Annotation modes
modeView?: string;
modeSelect?: string;
modeEraser?: string;
modeMarker?: string;
modeNote?: string;
modeInk?: string;
modeFreetext?: string;
strokeWidth?: string;
// Annotation panel
noAnnotations?: string;
noMatchingAnnotations?: string;
searchAnnotations?: string;
filterBy?: string;
sortBy?: string;
sortByPage?: string;
sortChronological?: string;
pagePrefix?: string;
justNow?: string;
moreActions?: string;
annotationDelete?: string;
annotationEditComment?: string;
annotationColor?: string;
// Capability tooltip suffix — appended when a tool isn't supported by the source.
notAvailableForSource?: string;
// Info section
infoSection?: string; // "Info"
infoFormat?: string;
infoPages?: string;
infoPage?: string; // "Page {n}"
infoSize?: string;
infoTitle?: string;
infoAuthor?: string;
infoSubject?: string;
infoKeywords?: string;
infoCreator?: string;
infoProducer?: string;
infoCreated?: string;
infoModified?: string;
infoPdfVersion?: string;
}
```
English defaults are baked in. Pass any subset to override individual strings — keys you omit fall back to the default. `{current}`, `{total}`, `{n}` placeholders are substituted at render time.
## Events
| Event | Payload | When |
|---|---|---|
| `update:position` | `CoarDocumentViewerPosition` | Page / scroll / zoom / rotation change. Used for `v-model:position`. |
| `update:annotationMode` | `CoarPdfAnnotationMode` | User picks a different mode in the toolbar. Used for `v-model:annotation-mode`. |
| `update:sidebarOpen` | `boolean` | User toggles the left rail (thumbnails / outline). Used for `v-model:sidebar-open`. |
| `update:annotationsPanelOpen` | `boolean` | User toggles the right rail (annotations panel). Used for `v-model:annotations-panel-open`. |
| `annotation:create` | `CoarPdfAnnotationCreatePayload` | New annotation drawn. Consumer assigns id + timestamp. |
| `annotation:update` | `{ id, patch }` | Existing annotation edited (color / comment / position). |
| `annotation:delete` | `string` (id) | Existing annotation removed (via panel menu or eraser tool). |
| `error` | `CoarDocumentViewerErrorEvent` | Source failed to load. `{ error: unknown, src?: string }`. |
## Slots
| Slot | Props | When |
|---|---|---|
| `loading` | *none* | Replaces the default `"Loading…"` text shown while the source is fetching/parsing. |
| `error` | `{ error: unknown; retry: () => void }` | Replaces the default error overlay. Call `retry()` to re-trigger the load. |
```vue
Fetching document…
{{ String(error) }}
Try again
```
## Sizing
The viewer fills 100% of its parent and uses internal flexbox to allocate space across toolbar, panels, and the page area. Put it inside a sized container:
```vue
```
Splitters between the columns are draggable — users can resize the left rail (thumbnails) and right rail (annotations panel) on the fly.
## Position memory example
```vue
```
Use one OR the other in practice — both is fine, just pick the priority. When both are present, the bound `position` wins on mount; afterwards both stay in sync.
## Common configurations
### Read-only PDF viewer with thumbnails
```vue
```
### Annotation tool with persistence
```vue
```
### Minimal embedded preview (no toolbar)
```vue
```
---
---
url: /components/document-viewer/toolbar.md
---
# Toolbar customization
The toolbar is **order-driven**: the `tools` prop is an array of tool identifiers in the order you want them to render. Want zoom buttons on the left and page navigation on the right? Just reorder the array. Want a custom layout with only the four buttons you actually need? Pass that subset.
When `tools` is omitted, the viewer falls back to `COAR_DOCUMENT_VIEWER_ALL_TOOLS` — the canonical 8-group layout with separators at the original group boundaries.
## Minimal toolbar
The canonical "nav + zoom" layout — page navigation, separator, zoom controls:
```ts
import type { CoarDocumentViewerTool } from '@cocoar/vue-document-viewer';
const MINIMAL_TOOLS: CoarDocumentViewerTool[] = [
'prev-page',
'page-input',
'next-page',
'separator',
'zoom-out',
'zoom-reset',
'zoom-in',
];
```
```vue
```
## The `'separator'` pseudo-tool
`'separator'` doesn't render an action — it renders a `CoarSidebarDivider` between groups. Place it anywhere in the array to visually break up clusters.
Three convenience behaviors save you from edge-case handling:
| Input | Output |
|---|---|
| Leading `'separator'` | Trimmed |
| Trailing `'separator'` | Trimmed |
| Consecutive `'separator'`s | Collapsed to one |
This matters because **section toggles** (e.g. `showSearch: false`) filter tools *before* the trim/collapse. So a `tools` array like `['prev-page', 'separator', 'search', 'separator', 'next-page']` with `showSearch: false` doesn't leave you two adjacent orphan separators — the collapse step turns it into `['prev-page', 'separator', 'next-page']` automatically.
## All available tools
The full `CoarDocumentViewerTool` union:
| Identifier | What it does | Default group |
|---|---|---|
| `sidebar-toggle` | Toggle the left rail (thumbnails / outline) | Panels |
| `annotations-panel` | Toggle the right rail | Panels |
| `prev-page` | Previous page | Navigation |
| `page-input` | "Page N of M" number input | Navigation |
| `next-page` | Next page | Navigation |
| `zoom-out` | Zoom out one step | Zoom |
| `zoom-reset` | Editable zoom-percent readout (click to reset to 100%) | Zoom |
| `zoom-in` | Zoom in one step | Zoom |
| `fit-width` | Fit page width to viewport | View |
| `fit-page` | Fit whole page to viewport | View |
| `reset-view` | Reset zoom + rotation | View |
| `rotate-ccw` | Rotate -90° | Rotation |
| `rotate-cw` | Rotate +90° | Rotation |
| `pan` | Hand tool — drag the page | Pointer |
| `select` | Select / move existing annotations | Pointer |
| `eraser` | Erase strokes from marker / ink annotations | Pointer |
| `marker` | Draw highlighter strokes | Drawing |
| `note` | Place comment pin | Drawing |
| `ink` | Free-hand ink strokes | Drawing |
| `freetext` | Place free-text box | Drawing |
| `search` | Open search bar | Actions |
| `print` | Print document | Actions |
| `download` | Download source file | Actions |
| `separator` | Visual divider between groups | *pseudo-tool* |
## Default layout
`COAR_DOCUMENT_VIEWER_ALL_TOOLS` — the canonical 8-group layout, importable so you can derive variants by filtering:
```ts
import { COAR_DOCUMENT_VIEWER_ALL_TOOLS } from '@cocoar/vue-document-viewer';
// Hide just the print + download buttons
const tools = COAR_DOCUMENT_VIEWER_ALL_TOOLS.filter(
(t) => t !== 'print' && t !== 'download',
);
```
## Filtering layers
Three independent layers decide which buttons render and how:
```
user's `tools` array (or COAR_DOCUMENT_VIEWER_ALL_TOOLS)
│
▼
┌─────────────────────────────────────────────────────┐
│ 1. Section toggles strip whole categories │
│ showSearch:false → drop 'search' │
│ showPrintDownload:false → drop 'print','download' │
│ showAnnotationModes:false → drop drawing tools │
│ (no Thumbnails/Outline?) → drop 'sidebar-toggle'│
│ (no AnnotationsPanel?) → drop 'annotations-panel' │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 2. Separator normalization │
│ trim leading / trailing → collapse consecutive │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ 3. Capability gating (per-tool, runtime) │
│ source.capabilities.search:false → 'search' disabled │
│ source.capabilities.multiPage:false → page-nav disabled │
│ source.capabilities.outline:false → outline tab hidden │
│ (disabled tools STAY VISIBLE with a tooltip suffix) │
└─────────────────────────────────────────────────────┘
│
▼
Final rendered toolbar
```
Layers 1 and 2 happen in pure-function form (`computeEffectiveTools` in `internal/effective-tools.ts`) and remove items from the array. Layer 3 happens per-button at render time — it never removes anything, just toggles the `disabled` state and appends `notAvailableForSource` to the tooltip.
This is the **stable-position rule**: switching sources never makes buttons jump around, because layer 3 doesn't touch positions.
## Section toggles vs `tools` array
If you want to drop a whole category, either path works:
```ts
// A) Shorthand — section toggle
// B) Explicit — omit from tools array
```
Use the section toggle when you want to drop a category cleanly. Use `tools` when you need precise positional control — e.g. moving search to the start, or placing the page-input between zoom and rotation.
## Toolbar position
`toolbarPosition` controls where the toolbar sits relative to the page area:
```vue
```
| Value | Layout |
|---|---|
| `'top'` (default) | Horizontal bar above the page area |
| `'bottom'` | Horizontal bar below the page area |
| `'left'` | Vertical rail to the left of the page area |
| `'right'` | Vertical rail to the right of the page area |
Vertical rails ('left' / 'right') still use the same `tools` array — buttons just stack vertically and the `'separator'` pseudo-tool renders as a horizontal divider instead of a vertical one.
---
---
url: /components/document-viewer/annotations.md
---
# Annotations
`CoarDocumentViewer` ships with a built-in annotation layer that supports four types — **marker** highlights, comment **notes**, free-hand **ink**, and **freetext** boxes. Annotations are **controlled**: the consumer owns the data, the viewer emits events, the consumer applies changes.
```ts
import {
CoarDocumentViewer,
type CoarPdfAnnotation,
type CoarPdfAnnotationMode,
type CoarPdfAnnotationCreatePayload,
type CoarPdfAnnotationUpdatePayload,
} from '@cocoar/vue-document-viewer';
```
## Live demo
Pick a mode in the toolbar (marker / note / draw / text), then interact with the image. The consumer assigns `id` + `createdAt` on creation, merges patches on update, and removes on delete. Try **switch to Select**, then drag an existing annotation, or click the 3-dot menu in the right panel.
## Annotation types
Four discriminated-union types, all sharing a `BaseAnnotation` shape:
```ts
interface BaseAnnotation {
id: string;
type: 'marker' | 'comment' | 'ink' | 'freetext';
pageIndex: number; // 0-based
color: string; // CSS color
createdAt: string; // ISO timestamp
createdBy?: string; // Consumer-provided display string
comment?: string; // Every type may carry a side comment
}
```
### Marker (highlighter)
```ts
interface CoarPdfMarkerAnnotation extends BaseAnnotation {
type: 'marker';
strokes: CoarPdfPoint[][]; // SVG-style polyline list
width: number; // Stroke width in CSS px at zoom=1
}
```
Drawn like a felt-tip marker, rendered with `mix-blend-mode: multiply` so the underlying text reads through.
### Comment pin
```ts
interface CoarPdfCommentAnnotation extends BaseAnnotation {
type: 'comment';
anchor: CoarPdfPoint; // Pin location in normalized page coords
comment: string; // REQUIRED for comment annotations
}
```
Drops a pin at the click point and opens a popover for the comment body. The comment text is mandatory — empty pins are never created.
### Ink (freehand)
```ts
interface CoarPdfInkAnnotation extends BaseAnnotation {
type: 'ink';
strokes: CoarPdfPoint[][];
width: number;
}
```
Same wire shape as marker but rendered as opaque strokes (no blend mode, thinner default width).
### Freetext
```ts
interface CoarPdfFreetextAnnotation extends BaseAnnotation {
type: 'freetext';
rect: CoarPdfRect; // Box geometry in normalized page coords
text: string; // The visible label
fontSize: number; // CSS px at zoom=1
}
```
User clicks to drop a text box; the textarea writes to `text` (not `comment`). Optional `comment` still works as a side note.
## Coordinate system
All coordinates are **page-relative and normalized to `[0..1]`**:
```ts
interface CoarPdfPoint { x: number; y: number; }
interface CoarPdfRect { x: number; y: number; w: number; h: number; }
```
This means the same annotation renders correctly at any zoom level and rotation — the viewer multiplies by the current viewport at render time. Storage stays portable: an annotation drawn at 100 % zoom plays back identically at 250 %, on a different screen, or after a rotation.
## Lifecycle
The viewer never mutates the `annotations` array directly. Instead it emits three events that the consumer applies:
### Create
```ts
import { v4 as uuid } from 'uuid';
function onCreate(payload: CoarPdfAnnotationCreatePayload) {
annotations.value = [
...annotations.value,
{
...payload,
id: uuid(),
createdAt: new Date().toISOString(),
createdBy: currentUser.displayName,
} as CoarPdfAnnotation,
];
}
```
`CoarPdfAnnotationCreatePayload` is `CoarPdfAnnotation` with the consumer-owned fields stripped (`'id' | 'createdAt' | 'createdBy'`). The viewer fills in everything else — type, color, geometry, stroke data — based on the active mode and pointer input.
### Update
```ts
function onUpdate({ id, patch }: CoarPdfAnnotationUpdatePayload) {
annotations.value = annotations.value.map((a) =>
a.id === id ? ({ ...a, ...patch } as CoarPdfAnnotation) : a,
);
}
```
Fired when the user edits an annotation through its popover (color, comment, freetext body) or drags it to a new position (anchor / rect / strokes). `patch` is a partial of the relevant annotation shape — apply with a plain spread.
### Delete
```ts
function onDelete(id: string) {
annotations.value = annotations.value.filter((a) => a.id !== id);
}
```
Fired when the user picks "Delete" from the panel's 3-dot menu, or when the eraser tool removes the last stroke from a marker / ink annotation.
## Modes
```ts
type CoarPdfAnnotationMode =
| 'view' // Read-only — existing annotations clickable, no new ones
| 'select' // Existing annotations clickable AND draggable
| 'eraser' // Click a stroke on marker/ink to remove it
| 'marker' // Drawing
| 'comment' // Drawing — drops a pin
| 'ink' // Drawing
| 'freetext'; // Drawing — drops a text box
```
Mode is two-way bound — the toolbar's mode buttons update it, but the consumer can also drive it from outside (e.g. a keyboard shortcut, a parent's "Start commenting" CTA):
```vue
```
```ts
// Keyboard shortcut from anywhere
useEventListener(window, 'keydown', (e) => {
if (e.key === 'm' && (e.ctrlKey || e.metaKey)) mode.value = 'marker';
});
```
## Color palette
The mode picker shows a color selector with seven defaults:
| Color | Hex |
|---|---|
| Yellow | `#fde68a` |
| Pink | `#fca5a5` |
| Green | `#86efac` |
| Blue | `#93c5fd` |
| Purple | `#c4b5fd` |
| Saturated yellow | `#facc15` |
| Hot pink | `#ec4899` |
Pass `:annotation-colors="[...]"` to override the entire palette. Each color is a CSS string — anything `rgb()`, `hsl()`, `#xxx`, `oklch()` etc. that the browser accepts works.
## Panel + filters
`:show-annotations-panel="true"` opens the right rail with:
* **Info section** at the top (source metadata — see [Overview](./)).
* **Annotation list** — every annotation, grouped by page or chronologically. Click an entry to select + scroll-to. 3-dot menu per entry: edit comment, change color, delete.
* **Filter chips** — toggle each type on/off (marker / note / ink / freetext).
* **Search input** — substring search over comments and freetext bodies.
* **Sort toggle** — By page / Chronological.
The list is read-only — every interaction goes through the same `annotation:update` / `annotation:delete` event pipeline as the in-page surface, so the consumer-owned state stays canonical.
## Worked example — collaborative review
```vue
```
The controlled pattern makes optimistic updates + rollback trivial: snapshot `annotations.value`, apply locally, and revert on API failure.
## Persistence shape
Annotations are plain JSON — no `Date` objects, no class instances, no internal references. Store as-is:
```ts
// PostgreSQL JSONB column
CREATE TABLE annotations (
id UUID PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents,
data JSONB NOT NULL -- one CoarPdfAnnotation
);
```
```ts
// MongoDB / Firestore — embed directly
{ documentId: "...", annotations: CoarPdfAnnotation[] }
```
Because coordinates are normalized, annotations migrate across documents only when the page geometry matches; they're fully portable across rendering environments (different screens, zoom levels, rotation).
## Tips
* **Always wrap `annotations` mutations in a fresh array** (`[...]`, `.map`, `.filter`) so Vue's reactivity sees the change.
* **Generate the id on create**, not before — the create event fires only after the user finishes the drawing gesture (releases the mouse / taps "Save"). Pre-creating ids causes orphan entries when the gesture is cancelled.
* **Touch interactions work the same as mouse** — drawing modes accept both. The viewer normalizes pointer events internally.
* **Eraser is destructive** — clicking a stroke on a marker/ink annotation deletes that stroke; deleting the last stroke fires `annotation:delete` for the whole annotation. There's no per-stroke undo built in; consumers wanting one should keep a history snapshot ring around their `annotations` array.
---
---
url: /components/file-explorer.md
---
# File Explorer
`@cocoar/vue-file-explorer-core` is the **headless engine** for a VSCode-style file/asset explorer in Vue 3 — the **data + coordination**, not a finished UI. A single composable, `useFileExplorer({store})`, drives a pluggable `AssetStore` backend and returns every ref + op a file explorer needs (tree + tab state machine, selection, async loading, blob-URL leases, dirty tracking, conflict resolution). It ships **no layout** — you compose the chrome with [`@cocoar/vue-ui`](/components/panel-layout) (`CoarPanelLayout`, `CoarSplitPane`, `CoarTree`, …). A batteries-included, layouted `` component — under the bare `@cocoar/vue-file-explorer` name — is planned on top.
::: tip Mental model — engine, not UI
`useFileExplorer` is **headless**: it renders no editors, tabs, breadcrumbs, or layout — it returns reactive state + ops, and your view binds to `fe.*`. The composable IS the bus that wires the panels together: select in the tree → a tab opens → the editor and details panels react, all through one shared `fe.*` instance. For the **layout**, reach for the [panel-layout](/components/panel-layout) primitives; the demos below are worked examples to copy. A batteries-included `` component is planned — it'll sit on exactly these pieces.
:::
```ts
import {
useFileExplorer,
createInMemoryAssetStore,
type Asset,
} from '@cocoar/vue-file-explorer-core';
```
The required peer is [`@cocoar/vue-ui`](/components/tree) for `CoarTree` + `CoarTreeNodeLabel`. `@cocoar/vue-script-editor` is **optional** — only pulled in if you want the Monaco-typed `language` field on the file-meta resolver.
## Three building blocks
| Piece | Role | Doc |
|---|---|---|
| [`useFileExplorer({store})`](./use-file-explorer) | The composable. Tree state + tab state machine + async bookkeeping + every imperative op. | reference |
| [`AssetStore`](./asset-store) | The data-plane contract a backend implements (HTTP, IndexedDB, in-memory, …). | contract |
| [`createInMemoryAssetStore`](./in-memory-store) | Reference implementation with reactive latency / failure / lazy / conflict knobs for demos and tests. | knobs |
## Full dispatch demo
A realistic shell — tree, tab bar (with preview/pinned), breadcrumb, and a dispatched editor area that swaps between `CoarScriptEditor` (Monaco) for code, `CoarMarkdownEditor` (Milkdown) for markdown, and `CoarDocumentViewer` for images. Click around, edit anything, **Ctrl+S** saves.
Click a file to **preview** it (italic title) — clicking another file replaces the preview tab. **Double-click** to pin (italic clears). Editing a preview tab auto-pins it the moment it goes dirty — the impossible "italic + unsaved" state never exists.
::: tip Persistent viewer config across file swaps
Open `logo.svg`, click the thumbnails toggle to open the left rail, then switch to `README.md` and back. The rail stays open. Same for any tool config you pass.
That's because in the demo, `viewerSidebarOpen` / `viewerAnnotationsPanelOpen` / `viewerTools` live as refs **outside** the editor `v-if` branch — they're consumer-owned state, just passed into `CoarDocumentViewer` via `v-model` (for the panel toggles) and `:tools` (for toolbar config). When you switch from `.md` (Milkdown) back to `.svg` (DocumentViewer), the viewer is freshly mounted but the props it sees on mount are the persisted values — so the panel comes up open, the same tools are configured. No additional API needed on `useFileExplorer` — the shell owns it.
:::
## Minimal shell
Same composable, no heavy editors — useful as a starting point or for plain-text use cases:
The composable returns refs (`rootNodes`, `selectedId`, `expanded`, `openTabs`, `activeTab`, `loadingNodes`, `savingNodes`, `breadcrumbPath`) and ops (`openFile`, `saveTab`, `closeTab`, `addFolder`, `addFiles`, `deleteNode`, `moveNode`, `rename`, `reorderTab`, …). Wire whichever you need into your shell.
::: tip Where's the `` component?
Planned — and reserved under the bare `@cocoar/vue-file-explorer` name. It'll be built on these exact pieces: `useFileExplorer` + the [panel-layout](/components/panel-layout) primitives + `CoarTree`. The engine ships first because the **layout** is the part consumers most want to own (sidebar arrangement, tab bar styling, editor dispatch, context-menu shape); the hard-to-get-right bits (placeholder-then-fill open, optimistic rollback, blob-URL lifecycle, beforeunload warning, drag-to-reorder tabs) already live in the engine.
:::
## Details panel
The explorer hands you the **data** for a details / info panel; **where** it renders is your layout's call. `useFileExplorer` exposes **`selectedAsset`** (the selected node, resolved reactively from `selectedId`) and **`describeAsset(asset)`** (its framework-known property rows). Drop the panel below the tree, into a [`CoarPanelLayout`](/components/panel-layout) region — wherever.
`describeAsset` returns only what the framework can know from the `Asset` shape + resolved file-meta — **Name, Type, Language** (script files), **Extension**, and **Path**:
```ts
const fe = useFileExplorer({ store });
// fe.selectedAsset: Readonly[ | null>>
// fe.describeAsset(asset) → [{ key, label, value }, …]
```
```vue
]
{{ p.label }} {{ p.value }}
```
Domain fields (size, modified date, author, …) live in your generic `payload` — the framework can't know them, so **append your own rows**: `[...fe.describeAsset(asset), ...myPayloadRows(asset)]`. Need full control? Skip `describeAsset` and build the panel straight off `selectedAsset`.
::: tip Resizable tree-over-details sidebar
Want the tree stacked above the details panel with a draggable divider (VS-Code style)? Nest a [`CoarSplitPane`](/components/panel-layout) in your sidebar — tree in `#first`, the `selectedAsset` / `describeAsset` panel in `#second`. See the [panel layout](/components/panel-layout) docs.
:::
## Lazy mode
Stores that implement `loadChildren(parentId)` opt into lazy loading. The composable detects the capability automatically — no flag needed.
`hasChildren` hints control which folders show a chevron before their kids load. `loadingNodes` exposes the per-row spinner state so consumers can swap icon → spinner during a fetch.
## Conflict policies
Uploads and creates run through a per-store conflict policy. Default `'rename'` mirrors Finder / VSCode auto-suffixing (`foo.txt` → `foo (2).txt`).
`move` and `rename` deliberately **bypass** the policy — those are explicit user intent, not file additions, so silently changing the requested name would be surprising.
## Sort modes
Sibling ordering lives on the composable (not the store), because filesystem backends can't persist per-entry order. Pick one of three built-in strategies or pass a comparator.
| Mode | Behavior |
|---|---|
| `'folders-first'` (default) | Folders alphabetical, then files alphabetical — VSCode / Windows Explorer pattern. |
| `'alphabetical'` | All entries mixed alphabetical — Finder pattern. |
| `'manual'` | Array order = visual order. Drop-between-siblings positions persist via `store.move(id, parentId, position)`. |
| `(a, b) => number` | Custom comparator (e.g. by extension, by mtime). |
In any non-manual mode the composable silently drops the `position` argument when forwarding to `store.move()` — the comparator decides the final position after the parent change. `api.reorderable: Ref` is reactive, so a toolbar can flip drag modes at runtime.
## Architecture
```
useFileExplorer({store})
├── AssetStore ← your backend
│ loadTree / loadChildren / loadContent
│ createFolder / createFile / uploadFile
│ save / rename / delete / move
├── tree state ← rootNodes, selectedId, expanded
├── tab state machine ← openTabs, activeTab, dirty, pin/preview
├── async state ← loadingNodes, savingNodes
├── blob-URL leases ← revoked on delete + unmount
├── beforeunload warning ← active while any tab is dirty
└── 3-stage file-meta fallback ← asset.editor → getFileMeta → ext heuristic
```
The composable knows nothing about HTTP / IndexedDB / multipart — the `AssetStore` contract is the seam. Swap implementations without touching the view.
## What's next
| Page | Covers |
|------|--------|
| [`useFileExplorer`](./use-file-explorer) | Options, return surface, tab state machine, navigation helpers, lifecycle |
| [`AssetStore` contract](./asset-store) | Every method's signature + semantics, conflict pipeline, lazy opt-in, error funnel |
| [In-memory store](./in-memory-store) | `createInMemoryAssetStore` knobs — latency / failure / sort / lazy / conflict |
---
---
url: /components/file-explorer/use-file-explorer.md
---
# useFileExplorer
`useFileExplorer({store, ...})` is the composable. It wires a configured [`AssetStore`](./asset-store) into reactive tree + tab state, owns the async / dirty / blob-URL bookkeeping, and returns every ref and op a file-explorer shell needs.
```ts
import {
useFileExplorer,
type UseFileExplorerOptions,
type UseFileExplorerReturn,
type OpenTab,
} from '@cocoar/vue-file-explorer-core';
```
## Options
```ts
interface UseFileExplorerOptions {
store: AssetStore;
onError?: (op: AssetOp, err: unknown, ctx: AssetOpContext) => void;
getFileMeta?: (asset: Asset) => FileMeta | null;
confirm?: (message: string) => boolean;
initialExpandedIds?: readonly string[];
sortMode?: MaybeRefOrGetter>;
}
```
| Option | Default | Notes |
|---|---|---|
| `store` | *required* | Anything implementing [`AssetStore`](./asset-store). The single seam between view and backend. |
| `onError` | *no-op* | Single funnel for every store rejection. The composable has already rolled back by the time this fires. |
| `getFileMeta` | *none* | Stage-2 of the file-meta fallback chain. Returns `null` to fall through to the extension heuristic. |
| `confirm` | `window.confirm` | Used by `closeTab` / `closeOthers` / `closeToRight` / `closeAll` to confirm discarding dirty tabs. Override for custom dialogs. |
| `initialExpandedIds` | top-level folders (eager) / `[]` (lazy) | Folder ids to seed `expanded` with. Defaults to root folders in eager mode so the user sees content immediately; defaults to empty in lazy mode so the canonical click-to-expand UX appears. |
| `sortMode` | `'folders-first'` | Sibling ordering strategy. `MaybeRefOrGetter` so a toolbar can flip it live. See [sort modes](./#sort-modes). |
## Return surface
The return is organized into four groups: tree state, tab state, ops, navigation.
### Tree state
```ts
readonly assets: Readonly[[]>>;
readonly rootNodes: Readonly][[]>>;
selectedId: Ref];
readonly selectedAsset: Readonly[ | null>>;
expanded: Ref]>;
readonly loading: Readonly[>;
readonly loadingNodes: Readonly][>>;
readonly savingNodes: Readonly][>>;
readonly reorderable: Readonly][>;
```
| Ref | Notes |
|---|---|
| `assets` | Flat reactive list — the store's underlying projection. Read-only from the consumer's perspective. |
| `rootNodes` | Already filtered + sorted children of `null`. Pass directly to `]`. |
| `selectedId` | Two-way. Single-click on a row sets this; a watcher then opens the file as a preview tab. |
| `selectedAsset` | Read-only. `selectedId` resolved to the `Asset` (or `null`). Pair with `describeAsset` for a [details panel](./#details-panel). |
| `expanded` | Two-way `Set` of expanded folder ids. Lazy loading is driven by CoarTree's `loadChildren` hook (bind `:load-children="fe.loadChildren"`) — the tree fires the fetch on first expand. |
| `loading` | `true` during the **initial** `store.loadTree()` call only. Per-file content loads live on `loadingNodes`; per-folder lazy loads are owned by the tree (`isLoading` slot prop). Stays `false` for stores that surface their own reactive `_assets`. |
| `loadingNodes` | Per-id Set of files being `loadContent`-fetched. (Folder lazy-loads moved to CoarTree's per-row `isLoading` slot prop.) Bind to row-icon → spinner swap. |
| `savingNodes` | Per-id Set: any in-flight save / rename / delete / move. Same spinner channel as `loadingNodes`. |
| `reorderable` | `true` when `sortMode === 'manual'`. Reactive — read it in your CoarTree wiring to gate drop-between-siblings. |
### CoarTree wiring helpers
```ts
getId: (a: Asset) => string;
getChildren: (a: Asset) => readonly Asset[] | undefined;
getLabel: (a: Asset) => string;
isExpandable: (a: Asset) => boolean;
loadChildren?: (node: Asset) => Promise; // lazy stores only — bind to
```
These mirror ``'s prop signatures so you can pass them straight through:
```vue
fe.rename(node.id, newName)"
@node-move="fe.moveNode"
/>
```
`isExpandable` returns `false` for folders the store reports as `hasChildren: false` — keeps the chevron off known-empty folders in lazy mode.
For **lazy stores**, bind `:load-children="fe.loadChildren"` (`undefined` for eager stores, so the binding is a no-op there). CoarTree calls it on first expand of an unloaded folder and owns the loading state (`isLoading` slot prop), the error state (`hasError` + `@load-error`) and retry (`api.reloadChildren`); the composable just supplies the fetch body and caches loaded folders so re-expand never re-fetches. Render the spinner where you like from `isLoading` (e.g. swap the row icon) and pass `hide-loading-spinner` to drop the tree's built-in chevron spinner. See the [tree lazy-loading docs](/components/tree#lazy-loading-async-children). Lazy-load failures reach **both** `onError('loadChildren', …)` (log/toast) and the tree's `@load-error` / `hasError` (row UX) — pick one channel for user-facing messaging to avoid duplicates.
### Tab state
```ts
readonly openTabs: Readonly[>;
activeId: Ref];
readonly activeTab: Readonly[>;
readonly anyDirty: Readonly][>;
isDirty: (tab: OpenTab) => boolean;
setContent: (id: string, content: string) => void;
interface OpenTab {
id: string;
name: string;
editor: FileEditor;
language?: CoarScriptEditorLanguage;
content: string; // current editor buffer
savedContent: string; // last persisted — content !== savedContent ⇒ dirty
pinned: boolean; // false = preview (italic, replaced on next preview)
}
```
The tab state machine implements the VSCode pattern:
| Trigger | Result |
|---|---|
| Single-click on a file row | Push a **preview** tab (italic title). At most one preview exists at a time — opening another preview replaces it. |
| Double-click / Enter / "Open" menu | Open as **pinned**. Existing previews are upgraded in place. |
| User edits → `setContent` differs from `savedContent` | Auto-pin the tab. Eliminates the impossible "italic + dirty" state. |
| `closeTab` while dirty | Calls `options.confirm` ("Discard unsaved changes to '{name}'?"). |
| `closeTab` while `savingNodes.has(id)` | Bails — closing would orphan the in-flight save. |
### Imperative ops
```ts
// CRUD — optimistic; resolve when the backend confirms
addFolder(parentId: string | null, name: string): Promise] | null>;
addFiles(parentId: string | null, files: FileList | readonly File[]): Promise;
deleteNode(asset: Asset): Promise;
move(id: string, newParentId: string | null, position?: number): Promise;
moveNode(e: CoarTreeNodeMoveEvent>): Promise;
rename(id: string, newName: string): Promise;
refresh(folderId?: string | null): Promise;
// Tab ops
openFile(asset: Asset, opts?: { pinned: boolean }): Promise;
activateNode(asset: Asset): void; // dblclick / Enter — opens pinned
saveTab(id: string): Promise;
saveActive(): Promise;
closeTab(id: string): void;
closeOthers(keepId: string): void;
closeToRight(anchorId: string): void;
closeAll(): void;
pinTab(id: string): void;
unpinTab(id: string): void;
reorderTab(sourceId: string, targetId: string, position: 'before' | 'after'): void;
```
Notes on the trickier ones:
* **`addFolder`** is **optimistic**: a temp node is inserted immediately, then reconciled to the backend's real id on resolve (rolled back on error). Pairs with [``'s `startCreate`](../tree#inline-create) so the draft → real-node handoff has no flicker. (Stores that surface their own reactive `_assets` skip the temp node — their own mutation is the source.)
* **`addFiles`** is the OS-drop entry point. The composable derives content per file (text or `URL.createObjectURL` for PDF / image), calls `store.uploadFile()`, then `store.save(id, content)` **if the store has `save`**. The merged node is stamped with the target `parentId`, so a folder-filtered grid shows it immediately even if the store's returned asset omitted it. Blob URLs are tracked and revoked on delete + unmount.
* **`move`** is the plain programmatic move (optimistic + rollback) for sources that aren't a tree drag — a "move to folder" ``, a grid card dropped on a folder row, an undo command. `newParentId: null` moves to the root; `position` (an index within the new parent's children) is honored only in `'manual'` sort mode. **`moveNode`** delegates to it.
* **`moveNode`** consumes ``'s `CoarTreeNodeMoveEvent`. For `position: 'inside'`, it auto-expands the target folder. For `'before' / 'after'`, it forwards a computed index only when `reorderable.value`.
* **`openFile`** is the placeholder-then-fill flow. The placeholder tab is pushed + activated immediately; on `loadContent` rejection the placeholder rolls back so the user isn't stranded. **Browse-only stores** (no `loadContent`) make this a no-op — no editor tabs open, and the single-click-preview watcher stays quiet.
* **`activateNode`** is meant for ``. Files open pinned; folders are a no-op (CoarTree itself toggles expansion).
* **`reorderTab`** is for drag-to-reorder. No-op on self-drop or unknown ids; pinned status is preserved on the moved tab.
* **`refresh()`** re-runs `store.loadTree()` (or `store.loadChildren(folderId)` in lazy mode when given a folder id). Use it when upstream state can change out-of-band — a SignalR push from the backend, another tab uploading a file, a server-side retention sweep. No-op for stores that surface a reactive `_assets` directly: those are already live.
### Navigation
```ts
revealInTree(id: string, focusNode?: (id: string) => void): void;
readonly breadcrumbPath: Readonly[>;
pathOf(id: string): string[];
fileMeta(asset: Asset]): FileMeta | null;
describeAsset(asset: Asset): AssetProperty[]; // { key, label, value }
```
| Helper | Notes |
|---|---|
| `revealInTree` | Walks the parentId chain, expands every ancestor in one batch, sets `selectedId`, then calls `focusNode?.(id)` after Vue flushes. Pass the tree's `api.focusNode` if you have it. |
| `breadcrumbPath` | Name-path of the **active tab**. Drives the editor-area breadcrumb. |
| `pathOf(id)` | Name-path of any asset. Used for quick-open / recent-files matchers. |
| `fileMeta(asset)` | Runs the 3-stage fallback. Returns `null` for unrecognised binary types — caller skips with a warning. |
| `describeAsset(asset)` | Framework-known property rows (Name, Type, Language, Extension, Path) for a [details panel](./#details-panel). Append your own `payload`-derived rows. Pure helper also exported as `buildAssetProperties`. |
## Persistent viewer config across file swaps
When the consumer's shell mounts different editors via `v-if` based on `activeTab.editor`, each editor component is freshly mounted on every editor-type swap (e.g. `.md` → `.ts` → `.md` unmounts + remounts Monaco the second time). Any internal editor state — Monaco's scroll position, `CoarDocumentViewer`'s sidebar open / closed, Milkdown's toolbar collapse — **resets on remount** unless the consumer's shell holds that state as refs and passes them in.
The cleanest pattern: hold whatever you want to persist as refs **outside the `v-if` branch**, bind them via `v-model` or pass them via props.
```vue
```
::: tip This is shell-level state, not composable-level
The composable deliberately knows nothing about editors — `useFileExplorer` doesn't render them or hold their config. That keeps it independent of any specific editor package, and lets every consumer's shell define its own dispatch logic (which editor for which file type, fall-through to plain text, custom editors for proprietary asset payload types, …). The trade-off: editor persistence is a shell concern. The pattern above is the canonical way to handle it.
:::
This works for every editor: hold a Monaco view-state ref outside the `v-if` and restore it in `onMounted`; hold a Milkdown collapsed-toolbar boolean outside and pass it via prop; hold the position memory ref outside and bind it to `v-model:position`. The composable doesn't care — its job is the tree + tab state, not the editor's internals.
## Lifecycle
* **Mount — the tree loads itself.** `useFileExplorer` calls `store.loadTree()` automatically on mount (it kicks off the fetch synchronously during setup; `loading` is your "populating" signal). **You don't need a manual `fe.refresh()` in `onMounted`** — that would be a redundant second fetch. Reserve `refresh()` for out-of-band changes (server push, another tab mutating, retention sweep). Stores that surface their own reactive `_assets` (the in-memory impl) are live from creation, so the auto-load is a no-op for them.
* **Lazy children** — Lazy loading is driven by CoarTree's `loadChildren` hook (bind `:load-children="fe.loadChildren"`): the tree fires the fetch on first expand and on mount for any seeded `initialExpandedIds` (cascading as parents publish). The composable supplies only the fetch body and caches loaded folders so re-expand never re-fetches.
* **Eager open** — Single-click `selectedId` change is watched; file selections fire `openFile(file, { pinned: false })`.
* **Unmount** — `onScopeDispose` removes the `beforeunload` listener and revokes every blob URL the composable owns. Consumer doesn't need to clean up.
* **`beforeunload`** — Active while `anyDirty.value` is true. Browser shows its native "leave site?" prompt.
## Example consumer shell
A minimal shell — tree + breadcrumb + a single-tab editor area — to show what the composable's surface looks like in practice:
```vue
fe.rename(node.id, newName)"
>