Skip to content
ilinxa/pro-ui

Card Tree

betav0.6.2

JSON-driven recursive card tree with a full structural editor — drag and drop, multi-select, permissions, search, validation, and undo.

Category: Data DisplayUpdated: 2026-08-18Created: 2026-04-28Author: ilinxa

Context

Card Tree renders deeply nested structured content — agent transcripts, configuration trees, decision records, runbooks, requirement docs — as a card-tree where each card has typed scalar flat fields (string/number/boolean/null/date), five predefined content blocks (codearea, image, table, quote, list), child cards, and per-card meta. v0.4 completes the safety net: sync validation hooks via 3-layer pipeline (built-in → per-action → master) with `onValidationFailed` event, plus per-commit undo/redo (state-snapshot strategy with structural sharing, default 50-step history, `Cmd+Z` / `Cmd+Shift+Z` / `Cmd+Y` keyboard shortcuts, optional `<CardTreeUndoToolbar>` sibling export). Markdown adapter (v0.5) deferred indefinitely as a separate companion module — card-tree itself is JSON-native. v0.6 makes `customPredefinedKeys` actually work: the prop and its docs shipped in v0.3, but nothing between parse and the renderers ever read it, so registrations were silently inert through 0.5.0. Custom keys now match on name before the value is inspected, which additionally makes array-valued blocks (Plate Value, editor.js) registrable — a shape ordinary child cards still reject by design (Q-P4).

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

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/card-tree-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

v0.6 demo: validators + per-commit undo/redo on v0.3's structural management foundation, plus the "Impact" card's metric and body fields, rendered via two customPredefinedKeys registrations — body is array-valued, the case v0.5.0 could never register. Toggle edit mode to enable inline editing, drag-drop reordering, multi-select (shift-click range, cmd/ctrl-click toggle), the bulk toolbar (≥2 selected), and the undo toolbar (Cmd+Z / Cmd+Shift+Z / Cmd+Y). Use the search bar to find content in collapsed subtrees and meta entries.

Preview

v0.6 · 6 levels · all features + custom keys
  • Thesis outline

    title
    Adaptive UI Components for Data-Heavy Applications
    abstract
    A study of dynamic component patterns for hierarchical structured content in modern web apps.
    word_count
    28400
    status
    in-progress
    approved
    true
    defense_date
    Jun 15, 2026, 2:00 PM
    last_edited
    Apr 28, 2026
    reviewer_count
    —
    • structured-content
    • tree-rendering
    • json-native
    • accessibility
    • round-trip
    Architecture diagram cover
    Architecture diagram cover
    • introduction

      pages
      8
      completed
      true
      The web demands components that adapt to their data, not the other way around.
      • motivation

        summary
        Why hierarchical structured content needs a first-class viewer
        relevance_score
        8.5
        addresses_gap
        true
        component-classstatic-shapeadaptive-shape
        table✓—
        card-tree—✓
        form-builder✓—
        json-tree—✓
        • industry_need

          summary
          Industry surveys 2024-2026
          sample_size
          1240
          confidence
          0.95
          peer_reviewed
          false
          ts
          interface AdaptiveProps<T> {
            data: T;
            schema?: Schema<T>;
            // shape inferred when schema is absent
          }
          • survey_result

            finding
            73% of devs build custom tree-card UIs per project
            methodology
            online survey + 14 interviews
            response_rate
            0.42
            completed
            true
            • shadcn covers primitives, not compositions
            • json-tree libs render as code, not content
            • Notion-likes lock you into a block schema
            • top_excerpt

              attribution
              Senior FE, public-traded SaaS
              interview_id
              7
              recorded
              Jan 22, 2026, 4:30 PM
              We rebuild this same hierarchical view for every product. Six weeks each, accessibility skipped, never reusable.
      • contributions

        summary
        What this thesis adds to the field
        novel_findings
        3
        • JSON-native data model with stable identity keys
        • ARIA tree contract from day one
        • Round-trip-safe serialization at every depth
    • conclusion

      pages
      4
      written
      false
      summary
      Bring it home
    • methodology

      pages
      12
      approach
      design + implementation + evaluation
      Three components, three teams, six weeks each — measured against a single shared baseline.
    • results

      pages
      18
      significant
      true
      p_value
      0.003
      effect_size
      0.81
      metricbaselinecard-treedelta
      dev-time-days212-90%
      a11y-score7698+29%
      bundle-kb4518-60%
      round-trip-fidelity—✓—
    • impact

      pages
      3
      94% task success

      Why array-valued blocks matter

      Plate Value and editor.js documents are arrays of blocks. Through v0.5.0, classifyKey routed any array to the child-card branch and parse rejected it outright — a registered array-shaped key had no way to reach the renderer. v0.6.0 matches custom keys by name before the value is inspected, so this block renders.

Live playground

card-tree renders a recursive card tree from a JSON CardTreeJsonNode (scalar keys are fields, nested objects are subcards, __rcmeta holds metadata). Edit it on the left, press Submit, and the editable card renders on the right.

CardTreeJsonNode · JSON
valid

23 lines · valid

Live preview

Nothing rendered yet

Edit the JSON on the left, then press Submit to render the live result on the right.

Demo source

demo.tsxtsx
"use client"; import { useMemo, useRef, useState } from "react";import {  Blocks,  Copy,  Eye,  Gauge,  Move,  PanelRightClose,  PanelRightOpen,  Pencil,  ShieldCheck,} from "lucide-react";import { cn } from "@/lib/utils";import { CardTree } from "./card-tree";import { CardTreeSearchBar } from "./parts/search-bar";import { CardTreeUndoToolbar } from "./parts/undo-toolbar";import type {  CardTreeHandle,  CardTreeJsonNode,  CardTreeValidators,  CustomPredefinedKey,  SearchMatch,  SearchResult,  ValidationFailedEvent,} from "./types";import { RICH_DEMO } from "./dummy-data"; /** * v0.6 custom predefined-keys — the surface that was declared and documented * since v0.3 but stayed completely inert until v0.6 (see card-tree-loop.md * F1/F7). Two registrations exercise both editor paths: * *   - `metric`  — object-valued, ships a real `edit` implementation, so the *                 custom-editor path is demonstrated end to end. *   - `body`    — ARRAY-valued (Plate Value / editor.js-shaped blocks). This *                 is the case that was structurally impossible pre-v0.6: *                 custom keys match on name before the value is inspected, *                 so an array can be registered even though ordinary *                 children still reject arrays (Q-P4, unchanged). `edit` is *                 intentionally omitted, so this one demonstrates the *                 documented JSON-textarea fallback. */ type MetricValue = { value: number; unit: string }; function isMetricValue(v: unknown): v is MetricValue {  return (    typeof v === "object" &&    v !== null &&    typeof (v as Record<string, unknown>).value === "number" &&    typeof (v as Record<string, unknown>).unit === "string"  );} function MetricBlock({ value, className }: { value: MetricValue; className?: string }) {  return (    <div      className={cn(        "inline-flex items-baseline gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5",        className,      )}    >      <span className="font-mono text-base font-semibold text-foreground">        {value.value}      </span>      <span className="font-mono text-xs text-muted-foreground">{value.unit}</span>    </div>  );} function MetricEditor({  value,  onSave,  onCancel,}: {  value: MetricValue;  onSave: (next: MetricValue) => void;  onCancel: () => void;}) {  const [draft, setDraft] = useState(value);  return (    <div className="flex flex-wrap items-center gap-1.5">      <input        type="number"        value={draft.value}        onChange={(e) => setDraft((d) => ({ ...d, value: Number(e.target.value) }))}        className="w-20 rounded-md border border-border bg-card px-2 py-1 font-mono text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"      />      <input        type="text"        value={draft.unit}        onChange={(e) => setDraft((d) => ({ ...d, unit: e.target.value }))}        className="w-24 rounded-md border border-border bg-card px-2 py-1 font-mono text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"      />      <button        type="button"        onClick={() => onSave(draft)}        className="rounded-md border border-primary bg-primary px-2 py-1 font-mono text-[11px] text-primary-foreground hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"      >        save      </button>      <button        type="button"        onClick={onCancel}        className="rounded-md border border-border bg-card px-2 py-1 font-mono text-[11px] text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"      >        cancel      </button>    </div>  );} const metricKey: CustomPredefinedKey = {  key: "metric",  description: "A KPI value with unit — custom editor",  category: "stats",  icon: <Gauge className="size-3.5" aria-hidden="true" />,  defaultValue: () => ({ value: 0, unit: "pages" }),  validate: (v) =>    isMetricValue(v)      ? { ok: true }      : {          ok: false,          errors: [            {              code: "shape-mismatch",              message: "metric must be { value: number, unit: string }",            },          ],        },  render: (value, ctx) =>    isMetricValue(value) ? (      <MetricBlock value={value} className={ctx.className} />    ) : (      <span className="font-mono text-xs text-destructive">invalid metric</span>    ),  edit: (value, onSave, onCancel) => (    <MetricEditor      value={isMetricValue(value) ? value : { value: 0, unit: "" }}      onSave={onSave}      onCancel={onCancel}    />  ),}; type RichBlock =  | { type: "heading"; level: 1 | 2 | 3; text: string }  | { type: "paragraph"; text: string }; function isRichBlocks(v: unknown): v is RichBlock[] {  return (    Array.isArray(v) &&    v.every(      (b) =>        typeof b === "object" &&        b !== null &&        typeof (b as Record<string, unknown>).type === "string" &&        typeof (b as Record<string, unknown>).text === "string",    )  );} function RichBlocksView({ blocks, className }: { blocks: RichBlock[]; className?: string }) {  return (    <div      className={cn(        "space-y-1.5 rounded-md border border-border bg-muted/20 px-3 py-2",        className,      )}    >      {blocks.map((block, i) =>        block.type === "heading" ? (          <p            key={i}            className={cn(              "font-semibold text-foreground",              block.level === 1 ? "text-base" : block.level === 2 ? "text-sm" : "text-xs",            )}          >            {block.text}          </p>        ) : (          <p key={i} className="text-sm text-muted-foreground">            {block.text}          </p>        ),      )}    </div>  );} const bodyBlocksKey: CustomPredefinedKey = {  key: "body",  description:    "Array-valued rich content (Plate Value / editor.js-shaped blocks) — no custom editor, falls back to the JSON-textarea",  category: "content",  icon: <Blocks className="size-3.5" aria-hidden="true" />,  defaultValue: () => [{ type: "paragraph", text: "" }] satisfies RichBlock[],  validate: (v) =>    isRichBlocks(v)      ? { ok: true }      : {          ok: false,          errors: [            {              code: "shape-mismatch",              message: "body must be an array of { type, text } blocks",            },          ],        },  render: (value, ctx) =>    isRichBlocks(value) ? (      <RichBlocksView blocks={value} className={ctx.className} />    ) : (      <span className="font-mono text-xs text-destructive">invalid body</span>    ),  searchableText: (value) => (isRichBlocks(value) ? value.map((b) => b.text) : []),  // `edit` intentionally omitted — demonstrates the documented JSON-textarea fallback.}; const CUSTOM_PREDEFINED_KEYS: CustomPredefinedKey[] = [metricKey, bodyBlocksKey]; /** * RICH_DEMO plus one card exercising the two registrations above. Kept local * to demo.tsx (not folded into dummy-data.ts) so the fixtures item ships the * v0.1-v0.4 baseline tree unchanged. */const DEMO_TREE: CardTreeJsonNode = {  ...RICH_DEMO,  impact: {    __rcid: "ch4",    __rcorder: 3,    __rcmeta: { drafted: "2026-04-10" },    pages: 3,     metric: { value: 94, unit: "% task success" },     body: [      { type: "heading", level: 2, text: "Why array-valued blocks matter" },      {        type: "paragraph",        text: "Plate Value and editor.js documents are arrays of blocks. Through v0.5.0, classifyKey routed any array to the child-card branch and parse rejected it outright — a registered array-shaped key had no way to reach the renderer. v0.6.0 matches custom keys by name before the value is inspected, so this block renders.",      },    ] satisfies RichBlock[],  },}; const STRICT_VALIDATORS: CardTreeValidators = {  fieldEdit: (event) => {    if (event.key === "priority" && typeof event.newValue === "number") {      if (event.newValue < 1 || event.newValue > 5) {        return {          ok: false,          errors: [            {              code: "host-priority-out-of-range",              message: "Priority must be 1–5.",            },          ],        };      }    }    return { ok: true };  },  cardRemove: (event) => {    if (event.removed.__rcmeta?.locked === true) {      return {        ok: false,        errors: [          {            code: "host-locked-removal",            message: "Cannot remove locked cards.",          },        ],      };    }    return { ok: true };  },}; export default function CardTreeDemo() {  const ref = useRef<CardTreeHandle>(null);  const initialJson = useMemo(() => JSON.stringify(DEMO_TREE, null, 2), []);  const [liveJson, setLiveJson] = useState<string>(initialJson);  const [copied, setCopied] = useState(false);  const [showJson, setShowJson] = useState(true);  const [editable, setEditable] = useState(false);  const [dndEnabled, setDndEnabled] = useState(true);  const [dirty, setDirty] = useState(false);  const [selectedIds, setSelectedIds] = useState<readonly string[]>([]);  const [searchQuery, setSearchQuery] = useState("");  const [searchResult, setSearchResult] = useState<SearchResult | null>(null);  const [validatorsEnabled, setValidatorsEnabled] = useState(false);  const [validationToast, setValidationToast] = useState<string | null>(null);  const [canUndo, setCanUndo] = useState(false);  const [canRedo, setCanRedo] = useState(false);   const handleChange = (tree: CardTreeJsonNode) => {    setLiveJson(JSON.stringify(tree, null, 2));    setDirty(ref.current?.isDirty() ?? false);    setCanUndo(ref.current?.canUndo() ?? false);    setCanRedo(ref.current?.canRedo() ?? false);  };   const handleValidationFailed = (event: ValidationFailedEvent) => {    const message = event.errors.map((e) => e.message).join(" · ");    setValidationToast(`${event.layer}: ${message}`);    setTimeout(() => setValidationToast(null), 3000);  };   const handleUndoOrRedo = () => {    setDirty(ref.current?.isDirty() ?? false);    setLiveJson(JSON.stringify(ref.current?.getTree() ?? DEMO_TREE, null, 2));    setCanUndo(ref.current?.canUndo() ?? false);    setCanRedo(ref.current?.canRedo() ?? false);  };   return (    <div className="space-y-3">      <header className="flex flex-wrap items-start justify-between gap-3">        <p className="max-w-3xl text-sm text-muted-foreground">          v0.6 demo: validators + per-commit undo/redo on v0.3&apos;s structural          management foundation, plus the &quot;Impact&quot; card&apos;s{" "}          <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">metric</code>{" "}          and{" "}          <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">body</code>{" "}          fields, rendered via two <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">customPredefinedKeys</code>{" "}          registrations — <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px]">body</code>{" "}          is array-valued, the case v0.5.0 could never register. Toggle edit          mode to enable inline editing, drag-drop reordering, multi-select          (shift-click range, cmd/ctrl-click toggle), the bulk toolbar (≥2          selected), and the undo toolbar (Cmd+Z / Cmd+Shift+Z / Cmd+Y). Use          the search bar to find content in collapsed subtrees and meta          entries.        </p>        <div className="flex shrink-0 flex-wrap items-center gap-2">          <button            type="button"            onClick={() => setEditable((v) => !v)}            className={cn(              "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",              editable                ? "border-primary bg-primary text-primary-foreground hover:bg-primary/90"                : "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",            )}            aria-pressed={editable}          >            {editable ? <Pencil className="size-3.5" /> : <Eye className="size-3.5" />}            {editable ? "editing" : "view"}          </button>          {editable ? (            <button              type="button"              onClick={() => setDndEnabled((v) => !v)}              className={cn(                "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",                dndEnabled                  ? "border-border bg-muted/40 text-foreground hover:bg-muted"                  : "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",              )}              aria-pressed={dndEnabled}            >              <Move className="size-3.5" aria-hidden="true" />              dnd {dndEnabled ? "on" : "off"}            </button>          ) : null}          {editable ? (            <button              type="button"              onClick={() => setValidatorsEnabled((v) => !v)}              className={cn(                "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 font-mono text-[11px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",                validatorsEnabled                  ? "border-border bg-muted/40 text-foreground hover:bg-muted"                  : "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",              )}              aria-pressed={validatorsEnabled}              title="Toggle host validators (priority must be 1–5; locked cards cannot be removed)"            >              <ShieldCheck className="size-3.5" aria-hidden="true" />              validators {validatorsEnabled ? "on" : "off"}            </button>          ) : null}          {editable ? (            <CardTreeUndoToolbar              canUndo={canUndo}              canRedo={canRedo}              onUndo={() => {                ref.current?.undo();                handleUndoOrRedo();              }}              onRedo={() => {                ref.current?.redo();                handleUndoOrRedo();              }}            />          ) : null}          {dirty ? (            <span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-amber-600 dark:text-amber-400">              <span aria-hidden="true" className="size-1.5 rounded-full bg-amber-500" />              dirty            </span>          ) : null}          <button            type="button"            onClick={() => setShowJson((v) => !v)}            className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"            aria-expanded={showJson}          >            {showJson ? <PanelRightClose className="size-3.5" /> : <PanelRightOpen className="size-3.5" />}            {showJson ? "Hide JSON" : "Show JSON"}          </button>        </div>      </header>       <div className="flex items-center gap-2">        <CardTreeSearchBar          value={searchQuery}          onChange={setSearchQuery}          matchCount={searchResult?.matches.length ?? 0}          activeIndex={searchResult?.activeIndex ?? null}          onNext={() => ref.current?.findNext()}          onPrevious={() => ref.current?.findPrevious()}          onClear={() => {            setSearchQuery("");            ref.current?.clearSearch();          }}        />        {selectedIds.length > 0 ? (          <span className="font-mono text-[11px] text-muted-foreground">            {selectedIds.length} selected          </span>        ) : null}      </div>       <div        className={cn(          "grid gap-4 transition-[grid-template-columns] duration-200 ease-out",          showJson            ? "lg:grid-cols-[minmax(0,1fr)_minmax(0,28rem)]"            : "grid-cols-1",        )}      >        <section className="min-w-0 space-y-2">          <div className="flex items-center justify-between">            <h3 className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">              Preview            </h3>            <span className="font-mono text-[11px] text-muted-foreground">              v0.6 · 6 levels · all features + custom keys            </span>          </div>          <CardTree            ref={ref}            aria-label="Thesis outline"            defaultValue={DEMO_TREE}            metaPresentation="popover"            editable={editable}            dndScopes={dndEnabled ? { sameLevel: true, crossLevel: true } : { sameLevel: false, crossLevel: false }}            search={{ query: searchQuery }}            customPredefinedKeys={CUSTOM_PREDEFINED_KEYS}            validators={validatorsEnabled ? STRICT_VALIDATORS : undefined}            onValidationFailed={handleValidationFailed}            onSearchResults={setSearchResult}            onChange={handleChange}            onSelectionChange={setSelectedIds}          />          {validationToast ? (            <div              role="alert"              aria-live="assertive"              className="mt-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"            >              <strong>Validator rejected:</strong> {validationToast}            </div>          ) : null}        </section>         {showJson ? (          <aside className="min-w-0 space-y-2 lg:sticky lg:top-4 lg:self-start">            <div className="flex items-center justify-between">              <h3 className="font-mono text-[11px] uppercase tracking-wider text-muted-foreground">                {dirty ? "Live JSON (unsaved)" : "Input JSON"}              </h3>              <button                type="button"                onClick={async () => {                  try {                    await navigator.clipboard.writeText(                      ref.current?.getValue() ?? liveJson,                    );                    setCopied(true);                    setTimeout(() => setCopied(false), 1500);                  } catch {                    // ignore                  }                }}                className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"              >                <Copy className="size-3" aria-hidden="true" />                {copied ? "copied" : "copy canonical"}              </button>            </div>            <pre className="max-h-[80vh] overflow-auto rounded-md border bg-muted/40 p-3 text-xs font-mono leading-relaxed">              {liveJson}            </pre>            {dirty ? (              <button                type="button"                onClick={() => {                  ref.current?.markClean();                  setDirty(false);                }}                className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"              >                mark clean              </button>            ) : null}          </aside>        ) : null}      </div>    </div>  );} // Keep the type import alive for editor hover-infovoid ({} as SearchMatch); 

Usage

When to use

Reach for CardTree when you have JSON-shaped, deeply nested, structured content — agent transcripts, configuration trees, decision records, runbooks, requirement docs — and want a card-tree view with typed-scalar fields, predefined content blocks (code, image, table, quote, list), and full keyboard accessibility.

Skip it for prose-only writing (use a markdown editor) or flat lists (use a table). Markdown source is not supported in v0.1 — card-tree is JSON-native.

Basic example

import { CardTree } from "@/components/card-tree";

export function Example() {
  return (
    <CardTree
      defaultValue={{
        title: "ADR-0042",
        status: "accepted",
        priority: 2,
        codearea: { format: "ts", content: "const x = 1;" },
        context: { reason: "..." },
      }}
    />
  );
}

Reserved keys

__rcid, __rcorder, and __rcmeta are reserved. __rcid auto-generates via crypto.randomUUID() if absent. __rcorder controls sibling order (integer, gaps allowed). __rcmeta is a per-card scalar map exposed via the metaPresentation prop.

Predefined keys

Five reserved-name fields render as styled blocks:

  • codearea — { format, content }
  • image — { src, alt? }
  • table — { headers: string[], rows: scalar[][] }
  • quote — a string
  • list — an array of scalars

Add a key to disabledPredefinedKeys to opt out — the parser then treats it as a flat field instead.

Custom predefined keys v0.6

Register additional content blocks at mount via customPredefinedKeys. The prop was declared and documented since v0.3 but had no reader anywhere in the parse pipeline until v0.6 — a tree that passed it saw the registration silently do nothing. As of v0.6 the whole path is wired: classify → parse/validate → render/edit → serialize.

import type { CustomPredefinedKey } from "@/components/card-tree";

const metricKey: CustomPredefinedKey = {
  key: "metric",
  description: "A KPI value with unit",
  defaultValue: () => ({ value: 0, unit: "" }),
  validate: (v) =>
    typeof v === "object" && v !== null
      ? { ok: true }
      : { ok: false, errors: [{ code: "shape-mismatch", message: "..." }] },
  render: (value, ctx) => <MyMetricBlock value={value} cardId={ctx.cardId} />,
  edit: (value, onSave, onCancel) => (
    <MyMetricEditor value={value} onSave={onSave} onCancel={onCancel} />
  ),
};

<CardTree
  defaultValue={data}
  editable
  customPredefinedKeys={[metricKey]}
/>

Precedence: reserved (__rc*) → built-in predefined (codearea / image / table / quote / list) → custom → scalar field → child card. A registered name is matched before its value is inspected, which is what makes an array-valued block registrable — an editor.js document or a Plate Value is an array of nodes, and through v0.5.0 that shape had no way to reach a renderer (the child-card branch rejects arrays per the doc above). Ordinary, unregistered children still reject arrays — this only opens up for names you explicitly register.

If edit is omitted, the editor falls back to a JSON-textarea. validate runs at parse time and at edit commit; a validator that returns { ok: false } or throws drops that entry (with a diagnostic) instead of breaking the rest of the tree. searchableText is optional — supply it to make a custom block participate in native search, or omit it and the block is simply skipped.

Registration is mount-only. A name that collides with a built-in / reserved key, or that is registered twice, is dropped with a console.error — the built-in (or the first registration) wins, and the tree still renders.

Field value typing

Flat-field values are JSON scalars: string, number, boolean, null. Type is inferred at parse time and rendered per type (numbers right-aligned mono; booleans as check / dash icons; ISO-8601 date strings formatted via Intl.DateTimeFormat; null as a muted em-dash).

Pass dateDetection="never" to disable date inference, or a custom predicate function for fine control.

Children

Any non-reserved, non-predefined property whose value is a plain object becomes a child card. Arrays of objects are rejected in v0.1 — convert to object-keyed form (e.g. { items: { item_0: a, item_1: b } }) or use the list predefined key for scalar arrays.

State model

The component is uncontrolled: defaultValue is the seed. To reset, remount via the key prop. Read the current state imperatively via a ref:

const ref = useRef<CardTreeHandle>(null);
// ...
const json = ref.current?.getValue();   // canonical JSON string
const tree = ref.current?.getTree();    // object form with auto-IDs

Accessibility

The tree implements the full ARIA tree contract: role="tree", role="treeitem", aria-level, aria-expanded. Keyboard: arrows navigate visible cards, → expands / descends, ← collapses / ascends, Home / End jump to first / last, Enter / Space toggles collapse on a card with children.

Features

  • JSON-native: accepts any plain object as a card; auto-attaches __rcid + __rcorder
  • Typed flat-field rendering: numbers right-aligned mono, booleans as icons, ISO-8601 dates formatted
  • Five predefined-key content blocks (codearea, image, table, quote, list)
  • v0.6 working custom-key registration: host-defined blocks of ANY JSON shape (arrays included) render, edit, validate, search, and round-trip verbatim
  • Per-level + per-predefined-key slot styling
  • Full ARIA tree contract with keyboard nav (arrows, home/end, expand/collapse, multi-select)
  • Three meta presentation modes (hidden, inline, popover) with custom renderers + audit trail
  • Inline editor: click-to-edit fields, keys, titles, predefined blocks, and meta entries
  • Drag-drop reordering with 2 scopes (same-level + cross-level), keyboard alternative via @dnd-kit
  • Multi-select with shift-click range + cmd-click toggle; bulk delete / duplicate / set-field / toggle-lock
  • Permission matrix with declarative shorthand + 11 predicate escape hatches; meta-locked cascade
  • Native data-model search: finds matches in collapsed cards and meta — auto-expands path
  • Configurable delete policy (cascade / promote) + collision strategy (suffix / qualify / reject)
  • Root-removal opt-in with onRootRemoved callback + emptyTreeRenderer prop
  • v0.4 sync validation hooks (per-action + master); onValidationFailed event for analytics
  • v0.4 per-commit undo/redo with state-snapshot (default 50-step history) + Cmd+Z keyboard binding + optional UndoToolbar sibling export
  • Imperative handle: getValue / getTree / isDirty / markClean / setSelection / focusCard / addCardAt / removeCard / replaceRoot / getEffectivePermissions / findNext / findPrevious / scrollToMatch / clearSearch / undo / redo / canUndo / canRedo / clearHistory

Tags

card-treetreeoutlinejsonviewereditordrag-droppermissionssearchstructured-contentdata

Dependencies

shadcn primitives: popover
npm peer deps: lucide-react@^1.11.0, @dnd-kit/core@^6.3.1, @dnd-kit/sortable@^10.0.0, @dnd-kit/utilities@^3.2.2