Skip to content
ilinxa/pro-ui

Card Tree Node

alphav0.3.0

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-11Created: 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 + 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

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
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
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

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 deferred to v0.2).

Demo source

demo.tsxtsx

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

  • Titledata.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}
      // 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: 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 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.

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