Card Tree Node
alphav0.3.0Card-tree renderer for flow canvas nodes — read-only viewer, a consumer-owned edit dialog pattern, and a typed port editor strip.
Context
Use card-tree-node when each flow-canvas node should carry a card-tree tree as its data (agent workflow editor, schema/config canvas, decision/runbook map). The viewer paints a read-only summary (title + first 3 flat fields + nested-card outlines with their own ports + selectability); clicking fires ctx.onEditRequest(subPath?) which the consumer routes to a dialog mounting <CardTree editable> with the same JSON. At most one card-tree editor instance is mounted at any time regardless of node count.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/card-tree-nodeAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/card-tree-node-fixturesPreview
Click any node to edit. Click a nested subcard to open the editor pre-focused on it. Marquee-select or shift-click for multi-select (canvas-level — bulk edit deferred to v0.2).
Demo source
Usage
When to use
Reach for @ilinxa/card-tree-node when each flow-canvas node should carry a card-tree JSON tree as its data — agent workflow editors, schema/config canvases, decision or runbook maps. The viewer paints a read-only summary (title + first 3 flat fields + nested-card outlines with their own ports); clicking opens a consumer-owned dialog with the full CardTree editor. At most ONE card-tree editor instance is mounted at any moment regardless of node count.
Canonical wiring
import { useRef, useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { FlowCanvas, updateNodeData } from "@ilinxa/flow-canvas";
import { CardTree, type CardTreeHandle } from "@ilinxa/card-tree";
import { cardTreeViewerRenderer } from "@ilinxa/card-tree-node";
const RENDERERS = [cardTreeViewerRenderer]; // module-scope!
export function MyCanvas() {
const [canvas, setCanvas] = useState(initialData);
const [editing, setEditing] = useState<
{ nodeId: string; subPath?: string } | null
>(null);
const cardTreeRef = useRef<CardTreeHandle>(null);
return (
<>
<FlowCanvas
data={canvas}
onChange={setCanvas}
renderers={RENDERERS}
onEditRequest={(nodeId, subPath) => setEditing({ nodeId, subPath })}
/>
<Dialog open={editing !== null} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent aria-describedby={undefined}>
{editing && (
<CardTree
key={editing.nodeId} // clean remount
ref={cardTreeRef}
defaultValue={canvas.nodes.find(n => n.id === editing.nodeId)!.data}
editable={true}
onChange={(next) =>
setCanvas((prev) => updateNodeData(prev, editing.nodeId, next))
}
/>
)}
</DialogContent>
</Dialog>
</>
);
}Subcard-level edit targeting (the subPath model)
The renderer fires ctx.onEditRequest?.(subPath?) wheresubPath is the clicked subcard's __rcid (card-tree's canonical card identifier). The host bubbles to FlowCanvasProps.onEditRequest?.(nodeId, subPath) verbatim — pass it through to your dialog. Inside the dialog, focus the targeted subcard via the imperative ref:
// Run after the dialog opens + CardTree has mounted.
useEffect(() => {
if (editing?.subPath) cardTreeRef.current?.focusCard(editing.subPath);
}, [editing?.subPath, editing?.nodeId]);F-02 lock — CardTreedoesn't expose aninitialFocusCardId prop today; the imperativeCardTreeHandle.focusCard(id) via ref is the only way to pre-focus a specific subcard. A v0.2 polish on card-tree may add a prop if consumers signal friction.
When __rcid is missing on a subcard
Rich-card auto-attaches __rcidon parse — so a consumer-defined fixture that hasn't been through <CardTree> once may carry subcards without IDs. The viewer renders them normally but disables click-to-focus on those subcards; clicking falls through to the root edit. Dev mode logs a console.warn pointing at the affected subcard. Pass the canvas data through <CardTree>once at boot OR attach IDs manually with card-tree's ID helper.
Multi-select on the canvas
n8n-style multi-select (marquee + shift-click) works out of the box — flow-canvas hosts the selection. Clicking-to-edit on the clicked node opens the dialog on THAT node; the other selected nodes stay selected for canvas-level operations (move, delete, duplicate). Bulk EDIT (single op applied across selection) is deferred to v0.2.
v0.1 viewer limits
- Title —
data.titleif non-empty string, else first non-reserved string flat field, else "Untitled card-tree". - Flat fields — first 3 entries that are
boolean/number/ ISO-8601 date string / plain string (byObject.entriesorder). Numbers right-aligned, dates formatted viaIntl.DateTimeFormat, booleans as ✓ / —. - Nested cards — up to 4 subcards painted as outlines with their own port handles; one level deep. v0.2 may make these configurable via
CardTreeViewerOptions. - Edit trigger — single click. v0.2 escape hatch (
editTrigger?: "click" | "doubleClick") if a consumer surfaces real conflict.
Port editing (v0.2 — <PortEditorStrip>)
v0.2 ships an opt-in PortEditorStrip for editing theports[] array of a card or subcard inline. Mount it alongside <CardTree editable> inside your dialog — the strip is uncontrolled (operates on the canvas prop) and live-saves on every mutation.
import {
PortEditorStrip,
type PortEditorPermissions,
} from "@ilinxa/card-tree-node";
// inside the dialog body — strip above CardTree per Q1 lock
{editing && (
<>
<PortEditorStrip
nodeId={editing.nodeId}
subPath={editing.subPath} // targets root if undefined; subcard by __rcid
canvas={canvas}
onChange={setCanvas}
editable={true}
// optional — gates affordances when supplied
permissions={{
canAddPort: (cardId) => true,
canRemovePort: (cardId, portId) => portId !== "p-locked-port",
canEditPortField: (cardId, portId, field) =>
field !== "id" || portId.startsWith("p-user-"),
}}
/>
<CardTree editable defaultValue={...} onChange={...} />
</>
)}Direction multi-select on add: check [✓in],[✓out], or both. Both creates two atomic ports sharing type / side / multi / label with -in /-out id suffixes. After save, the two ports are independent rows in the editor — no auto-grouping (Q3 lock).
Doc-port type (v0.2.5 of flow-canvas — new built-in "doc" port type): forced to side: "bottom"in the editor picker. Targets (doc files) don't exist yet — doc-typed ports are orphan slots until a future doc-file procomp ships.
Live-save model: every change calls onChange(updatedCanvas). No commit/cancel button. Selects + checkbox commit on change; id and label inputs commit on blur (id renames have edge implications). Renaming a port with live edges surfaces a tooltip warning; the rename still commits but the consumer must update edge references manually.
Custom port-type registration in the strip's picker is deferred to v0.3 with proper shared-context plumbing — v0.2 uses the 6 defaults only. Per-field ports(a flat field IS a port) is also a v0.3 lift; in v0.2 you add ports via the strip's "+ add port" affordance independently from any field.
Footguns
- Port IDs must be unique within the node— including across subcards. flow-canvas's port-walker returns the first match for a given ID; duplicates silently mis-route edges. Dev mode warns when a duplicate is detected on viewer mount.
position: relativeis load-bearing at every DOM level (NodeShell, CardTreeViewer outer, SubcardBlock) — xyflow's<Handle>isposition: absoluteand anchors to the nearest positioned ancestor. If you fork the viewer and droprelative, subcard handles silently fly to the wrong parent.- Don't use Radix
<Dialog.Portal forceMount>— it defeats the "at most one editor mounted" property. shadcn's default<Dialog>unmounts content on close, which is what makes the perf claim hold. - Subcards aren't drag-extractable in v0.1— they're part of one card-tree tree per node by design (Q1 lock); flow-canvas's
data-draggable-subobjectpattern won't work on subcards. v0.2 candidate if a consumer asks.
Full reference: docs/procomps/card-tree-node-procomp/ (description, plan, guide). Companion components: @ilinxa/flow-canvas@^0.2.1 (host; requires v0.2.1+ for onEditRequest) and @ilinxa/card-tree@^0.4.0 (editor).
Features
- CardTreeViewer NodeRenderer<CardTreeCanvasNode> — drop-in for flow-canvas's renderer registry
- Subcard-level click-to-focus — clicking a nested card pre-focuses the dialog on that subcard via CardTreeHandle.focusCard
- Subcard ports + selectability — subcards carry their own port handles + visual selection state
- Graceful degradation when __rcid is missing — subcard click bubbles to root + dev-mode warning
- n8n-style multi-select supported via flow-canvas's marquee + shift-click (bulk-edit-via-dialog deferred to v0.3)
- Consumer-owned dialog pattern (no shipped dialog chrome) — documented in procomp guide
- v0.2 PortEditorStrip — opt-in port editor (id / type / side / dir / multi / label) per card or subcard; live-save; [✓in][✓out] create-flow splits to atomic rows; doc-type forces bottom side editor-side; orphan-doc-target tooltip until doc files ship
- v0.2.1 — F-cross-13 path-b sweep: PortEditorAddPopover trigger drops `asChild`; PortEditorRow id-field Tooltip (an <Input> can't nest inside the trigger <button> either backend renders) replaced with a native `title` hint; dead TooltipProvider + `tooltip` dep dropped. Zero public-API change.