Skip to content
ilinxa/pro-ui

Card Tree Node

alphav0.5.1

Card-tree renderer for flow canvas nodes — read-only viewer, a consumer-owned edit dialog pattern, and a typed port editor strip.

Category: Data DisplayUpdated: 2026-08-18Created: 2026-05-16Author: ilinxa

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 + block chips + 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. Pass the same customPredefinedKeys array to createCardTreeViewerRenderer() and to <CardTree> so the node and the dialog agree about which keys are blocks.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/card-tree-node

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/card-tree-node-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

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 is not implemented). The chips on each node are card-tree blocks (v0.4.0); “Response” also carries a host-rendered body block.

Demo source

demo.tsxtsx
"use client"; import { useEffect, useMemo, useRef, useState } from "react";import {  Dialog,  DialogContent,  DialogDescription,  DialogHeader,  DialogTitle,} from "@/components/ui/dialog";import {  FlowCanvas,  type NodeRenderer,  updateNodeData,} from "@/registry/components/data/flow-canvas";import {  CardTree,  type CardTreeHandle,  type CardTreeJsonNode,  type CustomPredefinedKey,} from "@/registry/components/data/card-tree";import { PortEditorStrip } from "./parts/port-editor-strip";import { createCardTreeViewerRenderer } from "./parts/card-tree-viewer";import { cardTreeNodeFixture } from "./dummy-data"; // A host-registered custom block (v0.4.0). The SAME registration array feeds// the canvas renderer and `<CardTree>` in the dialog — that is the point: the// node and the editor must agree about what exists on a card. Module scope,// because an inline literal re-allocates every render (card-tree v0.6.0's// unbounded-render-loop hazard).const CUSTOM_KEYS: CustomPredefinedKey[] = [  {    key: "body",    description: "Rich-text blocks (Plate/editor.js shape)",    validate: (value) => ({ ok: Array.isArray(value) }),    defaultValue: () => [],    searchableText: (value) =>      Array.isArray(value)        ? value.map((b) => (b as { text?: string })?.text ?? "").filter(Boolean)        : [],    render: (value) => {      const blocks = Array.isArray(value) ? value : [];      return (        <span className="flex flex-col gap-0.5">          {blocks.map((b, i) => {            const block = b as { type?: string; text?: string };            return (              <span                key={i}                className={                  block.type === "heading"                    ? "font-semibold text-foreground"                    : "text-muted-foreground"                }              >                {block.text}              </span>            );          })}        </span>      );    },  },]; // Module-scope renderers (per xyflow-react-pro skill: recreating renderer// arrays in render triggers teardown + remount on every render). The renderer// factory must likewise be called ONCE — it resolves options into the stable// object that keeps the viewer's memo effective.const RENDERERS: NodeRenderer[] = [  createCardTreeViewerRenderer({    customPredefinedKeys: CUSTOM_KEYS,    // Built-in blocks still show summary chips; `body` is painted by the host    // renderer above, so both v0.4.0 paths are visible on one canvas.    renderCustomBlocks: true,  }),]; // Reserved keys that belong to flow-canvas's NodeData shape (ports + the// `__type` discriminator) but not to card-tree's open-shape data model.// card-tree v0.1 logs warnings when it sees `ports: Port[]` arrays as// children (its parser only supports object-keyed children + the `list`// predefined key for scalar arrays). Strip these before handing to// `<CardTree>`; merge back on save so flow-canvas keeps its routing data.const FLOW_CANVAS_RESERVED_KEYS = new Set(["ports", "__type"]); function stripFlowCanvasFields(data: CardTreeJsonNode): CardTreeJsonNode {  const out: CardTreeJsonNode = {};  for (const [key, value] of Object.entries(data)) {    if (FLOW_CANVAS_RESERVED_KEYS.has(key)) continue;    if (value && typeof value === "object" && !Array.isArray(value)) {      out[key] = stripFlowCanvasFields(value as CardTreeJsonNode);    } else {      out[key] = value;    }  }  return out;} function mergeFlowCanvasFields(  edited: CardTreeJsonNode,  original: CardTreeJsonNode,): CardTreeJsonNode {  const out: CardTreeJsonNode = { ...edited };  // Restore the reserved keys from the original at this level.  for (const key of FLOW_CANVAS_RESERVED_KEYS) {    if (original[key] !== undefined) {      out[key] = original[key];    }  }  // Recurse into nested object children (subcards) by key match. Subcards  // not present in `edited` are dropped (user deleted them via card-tree);  // new subcards in `edited` not in `original` are preserved as-is (they  // have no flow-canvas data to merge).  for (const [key, value] of Object.entries(edited)) {    if (FLOW_CANVAS_RESERVED_KEYS.has(key)) continue;    if (value && typeof value === "object" && !Array.isArray(value)) {      const origChild = original[key];      if (        origChild &&        typeof origChild === "object" &&        !Array.isArray(origChild)      ) {        out[key] = mergeFlowCanvasFields(          value as CardTreeJsonNode,          origChild as CardTreeJsonNode,        );      }    }  }  return out;} export default function CardTreeNodeDemo() {  // Controlled canvas state. onChange flows from flow-canvas (after drag /  // connect / delete) and from card-tree's onChange (live-save per Q2).  const [canvas, setCanvas] = useState(cardTreeNodeFixture);  const [editing, setEditing] = useState<    { nodeId: string; subPath?: string } | null  >(null);   // F-02 lock: imperative `CardTreeHandle.focusCard(subPath)` via ref. There's  // no `initialFocusCardId` prop on CardTree today, so this is how subcard-  // level edit targeting reaches the editor.  const cardTreeRef = useRef<CardTreeHandle>(null);   // Read the editing node's data once per open so CardTree's defaultValue is  // stable across keystrokes (avoids the "value resets on every keystroke"  // anti-pattern). Strips flow-canvas reserved keys before handing to CardTree.  // Re-derived only when editing.nodeId changes (dep array intentional —  // `canvas` is read at memo time but doesn't trigger re-derivation; CardTree  // is uncontrolled via defaultValue + key= remount).  const editingTree: CardTreeJsonNode | null = useMemo(() => {    if (!editing) return null;    const node = canvas.nodes.find((n) => n.id === editing.nodeId);    if (!node) return null;    return stripFlowCanvasFields(node.data as CardTreeJsonNode);    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [editing?.nodeId]);   // F-02 lock continued: focus the targeted subcard once CardTree has mounted.  // Runs after the dialog opens. If subPath is undefined (root-level edit),  // skip — card-tree opens at root by default.  useEffect(() => {    if (!editing?.subPath) return;    const handle = cardTreeRef.current;    if (!handle) return;    handle.focusCard(editing.subPath);  }, [editing?.subPath, editing?.nodeId]);   return (    <div className="flex h-150 flex-col gap-2">      <p className="text-xs text-muted-foreground">        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 is not implemented). The chips on each node are        card-tree blocks (v0.4.0); &ldquo;Response&rdquo; also carries a        host-rendered <code>body</code> block.      </p>       <div className="relative flex-1 overflow-hidden rounded-lg border border-border bg-card/40">        <FlowCanvas          data={canvas}          onChange={setCanvas}          renderers={RENDERERS}          onEditRequest={(nodeId, subPath) => setEditing({ nodeId, subPath })}        />      </div>       <Dialog        open={editing !== null}        onOpenChange={(open) => {          if (!open) setEditing(null);        }}      >        {/* shadcn DialogContent defaults to `sm:max-w-sm` (384px) at the sm            breakpoint — must use the responsive variant to override it.            Plain `max-w-N` would be capped to 384px on sm+. */}        <DialogContent className="sm:max-w-4xl">          <DialogHeader>            <DialogTitle>Edit rich card</DialogTitle>            <DialogDescription>              Edits live-save back into the canvas. Close the dialog or click              another node to switch.            </DialogDescription>          </DialogHeader>           {editing && editingTree && (            // key={editing.nodeId} forces a clean remount on CardTree when            // switching nodes — Plate re-initializes per open (plan §9 G3).            // PortEditorStrip is uncontrolled-by-design (Q9 lock); no key=            // remount needed — it re-reads ports on canvas-prop change.            <div className="max-h-[60vh] space-y-3 overflow-auto">              {/* v0.2 — port editor strip above the card-tree editor per Q1 lock */}              <PortEditorStrip                nodeId={editing.nodeId}                subPath={editing.subPath}                canvas={canvas}                onChange={setCanvas}                editable={true}                // v0.5 (FU-A) — the strip walks the same tree the renderer                // paints, so it takes the same registrations. Without them                // `body` reads as a child card here while the canvas draws it                // as a block, and a port edit can land inside a block payload.                customPredefinedKeys={CUSTOM_KEYS}              />              <CardTree                key={editing.nodeId}                ref={cardTreeRef}                defaultValue={editingTree}                editable={true}                // Same registrations as the canvas renderer — without this the                // dialog would reinterpret `body` as a child card and the two                // surfaces would disagree.                customPredefinedKeys={CUSTOM_KEYS}                onChange={(next) => {                  setCanvas((prev) => {                    const original = prev.nodes.find(                      (n) => n.id === editing.nodeId,                    )?.data as CardTreeJsonNode | undefined;                    if (!original) return prev;                    // Merge card-tree's edited tree with the prior data shape                    // so flow-canvas-only reserved keys (ports + __type) round-                    // trip through the edit cleanly.                    const merged = mergeFlowCanvasFields(next, original);                    return updateNodeData(                      prev,                      editing.nodeId,                      merged as CardTreeJsonNode & { __type: string },                    );                  });                }}              />            </div>          )}        </DialogContent>      </Dialog>    </div>  );} 

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 + block chips + 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.

Blocks on a node (v0.4.0)

A card-tree card can carry blocks — the five built-in predefined keys (codearea, image, table, quote, list) and any key the host registers through customPredefinedKeys. Through v0.3 the canvas viewer rendered none of them: it recognised scalars and __rcid-tagged objects, and a block is neither, so blocks vanished silently. v0.4.0 paints each one as a compact chip — table  2 x 3, body  2 items — sized for node zoom, with the full payload still living in the edit dialog.

Pass the same customPredefinedKeys array to the renderer and to <CardTree>. If the node knows about a registration and the dialog does not (or the reverse), the two surfaces disagree about what exists on the card.

import { CardTree, type CustomPredefinedKey } from "@ilinxa/card-tree";
import { createCardTreeViewerRenderer } from "@ilinxa/card-tree-node";

// Module scope — an inline literal re-allocates every render.
const CUSTOM_KEYS: CustomPredefinedKey[] = [
  {
    key: "body",
    validate: (v) => ({ ok: Array.isArray(v) }),
    defaultValue: () => [],
    render: (v) => <MyRichText value={v} />,
  },
];

// Call the factory ONCE. It resolves options into the stable object that
// keeps the viewer's memo effective.
const RENDERERS = [
  createCardTreeViewerRenderer({
    customPredefinedKeys: CUSTOM_KEYS,
    renderCustomBlocks: true,  // default false = summary chips
    maxBlocks: 3,              // also: maxFlatFields, maxSubcards
  }),
];

// ...and the dialog gets the same array:
<CardTree defaultValue={tree} editable customPredefinedKeys={CUSTOM_KEYS} />
  • renderCustomBlocks is off by default. A canvas node is a summary surface, and host render code sized for a full-width editor rarely fits a 240px node. Turn it on when your renderer is compact.
  • Host render() output is wrapped in an error boundary either way — a renderer that throws degrades to its summary chip and never blanks the canvas.
  • Built-in blocks always use the chip; a host cannot capture table by registering that name (card-tree drops such collisions at mount, and the viewer matches).
  • Target a block from CSS with [data-block-kind="table"] or [data-block-key="body"].

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 not implemented.

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 (by Object.entries order). Numbers right-aligned, dates formatted via Intl.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}
      // v0.5 — the THIRD surface that needs your registrations. Same array
      // you pass to createCardTreeViewerRenderer() and to <CardTree>.
      customPredefinedKeys={CUSTOM_KEYS}
      // 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={...} customPredefinedKeys={CUSTOM_KEYS} />
  </>
)}

v0.5 (FU-A) — pass your registrations here too.The strip walks the node's data tree to find the card named by subPath, and through v0.4 it decided what counted as a card by inspecting each value for __rcid or a portsarray. That disagreed with the renderer in both directions: a child card you hadn't yet round-tripped through <CardTree> was drawn on the canvas but unreachable here, and a registered block whose payload happened to carry ports was walked into as if it were a card — so a port edit could land inside a block payload. It now classifies keys with the same router the viewer uses, which is why it needs the same customPredefinedKeys (and disabledPredefinedKeys) you give the renderer. Both types are imported from @ilinxa/card-tree. Omitting them is safe only if you register nothing.

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 not implemented — it needs 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: relative is load-bearing at every DOM level (NodeShell, CardTreeViewer outer, SubcardBlock) — xyflow's <Handle> is position: absolute and anchors to the nearest positioned ancestor. If you fork the viewer and drop relative, 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-subobject pattern 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 is not implemented)
  • 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.
  • v0.4 BlockStrip — card-tree blocks finally render on the canvas (FU-2). The five built-in predefined keys and every host-registered custom key paint as compact summary chips (`table 2 x 3`, `body 2 items`); through v0.3 all of them rendered as nothing and `quote` leaked into the flat-field strip as an ordinary string.
  • v0.4 createCardTreeViewerRenderer() — configurable renderer factory: customPredefinedKeys, opt-in host `render()` for custom blocks (error-boundaried), disabledPredefinedKeys, and the maxFlatFields / maxBlocks / maxSubcards caps Q6 kept hardcoded through v0.3. `cardTreeViewerRenderer` stays as the zero-config default.
  • v0.4 key-first classification — one router (classifyNodeKey) mirroring card-tree's own precedence (reserved → built-in → custom → scalar → object/array) replaces two independent value-shape heuristics that disagreed with the editor the dialog mounts.
  • v0.5 PortEditorStrip takes customPredefinedKeys / disabledPredefinedKeys (FU-A) — the port walker now classifies keys through the same router as the viewer instead of its own private isCardLike copy. Closes the last surface where the port editor and the canvas disagreed about what a card is: a child card with no __rcid was drawn but unreachable, and a block whose payload carried `ports` could be written into.

Tags

card-tree-nodeflow-canvascard-treepopup-editrendererjson-canvasagent-workflowconfig-canvas

Dependencies

shadcn primitives: popover, select, checkbox, input, label, button
npm peer deps: lucide-react@^1.11.0
internal: card-tree, flow-canvas