Tree-view mixin for Arc CMS projects. Adds collapsible, drag-and-drop hierarchy to any CMS entity (pages, groups, or custom models) using a materialized path strategy — one SQL index, O(log n + k) subtree reads, no recursive CTEs at runtime.
/admin/pages → Tree view List view
▼ About Title Slug Status
▶ Team Home /p/home live
└ Alice About /p/about live
▶ Services Services /p/… draft
─ Contact Contact /p/… live
- Collapsible tree — expand/collapse subtrees; state is local, zero server round-trips
- Drag and drop — move any node to a new parent; cycle detection prevents invalid moves
- Keyboard navigation — Arrow keys move focus; Space/Enter toggle expand; fully accessible
- ARIA treegrid —
role="treegrid",aria-level,aria-expandedon every node - Breadcrumb — edit pages show the full ancestor path derived from the stored
pathcolumn (zero extra queries) - Parent picker — indented
<select>with descendant exclusion to prevent cycles - Path auto-sync — changing a page's parent via the edit form cascades
path/depthto all descendants - Zero runtime dependencies — vanilla JS, no external libraries
npm install arc-tree
arc cms add tree # updates arc.config.json + runs DB migrationThe arc cms add tree command:
- Adds
"arc-tree"topackagesinarc.config.json - Adds
path TEXT,depth INTEGERcolumns to each configured model - Creates B-tree indexes on
pathandparentId - Backfills existing rows using a single recursive CTE
// arc.config.json
{
"packages": ["arc-cms", "arc-tree"],
"tree": {
"models": ["pages", "groups"] // tables to receive path + depth columns
}
}arc-tree uses adjacency list + materialized path — the best balance of read performance, write safety, and debuggability for SQLite CMS workloads:
| Field | Type | Description |
|---|---|---|
parentId |
INTEGER nullable | Direct parent FK (already exists on pages) |
path |
TEXT | "1/5/12" — ancestor IDs root → self |
depth |
INTEGER | 0 = root, 1 = first child, etc. |
SQLite silently ignores REFERENCES constraints added via ALTER TABLE ADD COLUMN. The parentId FK is enforced at the application layer, not the DB layer. When deleting a node, your edit handler must re-home its children before the delete, or they will retain a stale parentId and appear as orphans until the next arc cms add tree run (which cleans them via the orphan-cleanup pass in migrate.sql).
| Criterion | Materialized path | MPTT (lft/rght) |
|---|---|---|
| Read subtree | O(log n + k) ✓ | O(log n + k) ✓ |
| Move node | O(k) ✓ | O(n) ✗ |
| Concurrent writes | Safe ✓ | Table lock ✗ |
| Human-readable | Yes ✓ | No ✗ |
| SQLite friendly | Yes ✓ | Partial ✗ |
The package registers two routes under /admin (requires admin or editor role):
Returns all rows ordered by path ASC. Feed directly into CmsTreeTable.
[
{ "id": 1, "parentId": null, "path": "1", "depth": 0, "title": "Home", ... },
{ "id": 3, "parentId": 1, "path": "1/3", "depth": 1, "title": "About", ... }
]Body: { "newParentId": "3" } — pass null to promote to root.
Validates:
- Node exists
newParentIdis not the node itselfnewParentIdis not a descendant of the node (cycle detection)
Returns { "ok": true, "path": "1/3/5" } or { "error": "..." } with 4xx status.
Drop-in for any CMS list page. Import and replace the inline <table>.
import CmsTreeTable from "@arc-tree/widgets/CmsTreeTable.arc"
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
rows |
Any | — | Flat row array with id, parentId, depth, label |
entityUrl |
String | — | Base URL for Edit buttons (e.g. /admin/pages) |
moveUrl |
String | "" |
POST endpoint for drag-drop; "" = read-only |
col2Header |
String | "" |
Column 2 header; omit to show only the name column |
draggable |
Bool | true |
Set false to force read-only regardless of moveUrl |
Row shape:
{
id: number | string,
parentId: number | string | null,
depth: number, // 0-based
label: string, // primary display text
secondary: string, // optional: shown as muted text in col 2
badge: string, // optional: shown as a tag chip in col 2
}Example — pages list:
@server fn listPagesTree() -> Any
const rows = db.pages.findMany({ orderBy: { path: "asc" } })
return rows.map(p => ({
id: p.id,
parentId: p.parentId,
depth: p.depth ?? 0,
label: p.title,
secondary: "/p/" + p.slug,
badge: p.published ? "live" : "draft"
}))
@live const treeRows = listPagesTree()
CmsTreeTable
rows=treeRows
entityUrl="/admin/pages"
moveUrl="/admin/tree/pages"
col2Header="Slug / Status"
Indented <select> for the parent field on edit pages. Automatically excludes the current node and its descendants.
import CmsParentPicker from "@arc-tree/widgets/CmsParentPicker.arc"
Props:
| Prop | Type | Default | Description |
|---|---|---|---|
name |
String | — | @state variable name to bind (use bind:value="{name}") |
options |
Any | — | Flat array with { id, depth, label } |
excludeIds |
Any | [] |
String array of IDs to hide (self + descendants) |
Server-side options builder:
const parentOptions = allPages
.filter(p => p.id != parseInt(pageId))
.map(p => ({
id: p.id,
depth: p.depth ?? 0,
label: "—".repeat(p.depth ?? 0) + " " + p.title
}))
Usage:
@state let parentId = data.page.parentId ? String(data.page.parentId) : ""
CmsParentPicker
name="parentId"
options=data.parentOptions
excludeIds=data.descendantIds
- Add the model name to
"tree.models"inarc.config.json - Run
arc cms add treeagain to migrate the new table - Add
GET /admin/tree/mymodelandPOST /admin/tree/mymodel/:id/movetotree.arc(follow the existingpagespattern) - Use
CmsTreeTable+CmsParentPickerin your list and edit pages
| Key | Action |
|---|---|
↑ / ↓ |
Move focus to previous / next row |
→ |
Expand focused node |
← |
Collapse focused node (or jump to parent) |
Space/Enter |
Toggle expand/collapse |
| Operation | Complexity | Notes |
|---|---|---|
GET /tree/:model |
O(n) | Single SELECT * ORDER BY path scan |
POST .../move |
O(k) | k = subtree size; one DB call per affected row |
| Expand / collapse | O(1) | Pure client-side Set toggle, no re-fetch |
| Ancestor breadcrumb | O(d) | Split stored path string — zero DB queries |
| Migration backfill | O(n) | One recursive CTE pass at install time |
Path strings stay compact: ≤ 70 chars at depth 10 with 6-digit IDs.
The widgets use CSS custom properties from the Arc CMS design system:
--brand-from /* accent colour (focus rings, drop targets) */
--ui-fg /* primary text */
--ui-fg-2 /* secondary text */
--ui-fg-3 /* muted / placeholder text */
--ui-bg-2 /* card background */
--ui-bg-3 /* hover background */
--ui-border /* border colour */Override any of these on :root or a scoped selector to theme the tree.
MIT © arc-tree contributors