Card Tree
betav0.6.2JSON-driven recursive card tree with a full structural editor — drag and drop, multi-select, permissions, search, validation, and undo.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/card-treeAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/card-tree-fixturesCLI 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 keysThesis 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 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-class static-shape adaptive-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
metric baseline card-tree delta dev-time-days 21 2 -90% a11y-score 76 98 +29% bundle-kb 45 18 -60% round-trip-fidelity — ✓ — impact
- pages
- 3
94% task successWhy 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.
23 lines · valid
Nothing rendered yet
Edit the JSON on the left, then press Submit to render the live result on the right.
Demo source
"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's structural management foundation, plus the "Impact" card'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 stringlist— 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-IDsAccessibility
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