ilinxa
blockcard-treesource: ok · hit
Card Tree
JSON-driven recursive card tree with a full structural editor — drag and drop, multi-select, permissions, search, validation, and undo.
npx shadcn add "https://ui.ilinxa.com/r/card-tree.json"free
Interaction
dragdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
popover
Files53 · source cached 10/14/2026, 3:08:31 AM
src/registry/components/data/card-tree/card-tree.tsx
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useReducer,
useRef,
useState,
} from "react";
import {
DndContext,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { cn } from "@/lib/utils";
import type {
CardAddedEvent,
CardDuplicatedEvent,
CardMovedEvent,
CardRemovedEvent,
CardRenamedEvent,
EffectivePermissions,
FieldAddedEvent,
FieldEditedEvent,
FieldRemovedEvent,
LevelStyle,
MetaAddedEvent,
MetaChangedEvent,
MetaRemovedEvent,
PredefinedAddedEvent,
PredefinedEditedEvent,
PredefinedRemovedEvent,
CardTreeHandle,
CardTreeJsonNode,
CardTreeProps,
SearchMatch,
} from "./types";
import {
isCustomEntry,
parseInput,
type CardTreePredefinedEntry,
type ParseError,
type ParsedCardTree,
} from "./lib/parse";
import { findCustomKey, resolveCustomKeys } from "./lib/custom-keys";
import { serializeTree, treeToJsonNode } from "./lib/serialize";
import {
createInitialState,
findAncestorIds,
findCard,
findParentId,
reducer,
visibleIdsInOrder,
type DefaultCollapsed,
type CardTreeAction,
type CardTreeState,
} from "./lib/reducer";
import {
validateCardRename,
validateCardRemove,
validateFieldAdd,
validateFieldEditKey,
validateFieldEditValue,
validateMetaAdd,
validateMetaEdit,
validatePredefinedShape,
} from "./lib/validate-edit";
import {
runHostValidators,
type AnyChangeEvent,
type ValidatorKey,
} from "./lib/validators";
import { useTreeKeyboard } from "./hooks/use-tree-keyboard";
import { useEditMode } from "./hooks/use-edit-mode";
import { isDirty as isDirtySelector } from "./hooks/use-dirty";
import { usePermissions } from "./hooks/use-permissions";
import { useDndSensors, collisionStrategy } from "./hooks/use-dnd-config";
import { useSearch } from "./hooks/use-search";
import { useUndo } from "./hooks/use-undo";
import { Card, type CardConfig, type EditDispatchers, type EditValidators } from "./parts/card";
import { EmptyTreePlaceholder } from "./parts/empty-tree";
/* ───────── default level styles ───────── */
const DEFAULT_LEVEL_STYLES: LevelStyle[] = [
{ containerClassName: "rounded-xl border border-border bg-card px-3 py-3 shadow-sm" },
{ containerClassName: "rounded-lg border border-border bg-muted/30 px-3 py-2.5" },
{ containerClassName: "rounded-md border border-border/70 bg-muted/20 px-2.5 py-2" },
{ containerClassName: "rounded-md border border-border/50 bg-muted/15 px-2 py-2" },
{ containerClassName: "rounded-sm border border-border/40 bg-muted/10 px-2 py-1.5" },
{ containerClassName: "rounded-sm border border-dashed border-border/30 px-2 py-1.5" },
];
function clampIndex(level: number, len: number): number {
return Math.max(0, Math.min(level - 1, len - 1));
}
function makeErrorTree(message: string): ParsedCardTree {
return {
id: "rcc-error-root",
order: 0,
level: 1,
fields: [{ key: "error", value: message, type: "string" }],
predefined: [],
children: [],
};
}
function generateId(): string {
if (
typeof globalThis.crypto !== "undefined" &&
typeof globalThis.crypto.randomUUID === "function"
) {
return globalThis.crypto.randomUUID();
}
return `rc-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
function logParseErrors(errors: ParseError[]): void {
if (errors.length === 0) return;
for (const err of errors) {
console.warn(
`[card-tree] ${err.path ? `at ${err.path}: ` : ""}${err.message}`,
);
}
}
/* ───────── component ───────── */
export const CardTree = forwardRef<CardTreeHandle, CardTreeProps>(
function CardTree(props, ref) {
const {
defaultValue,
levelStyles,
getLevelStyle,
predefinedKeyStyles,
defaultCollapsed = "none",
metaPresentation = "hidden",
disabledPredefinedKeys,
dateDetection = "auto",
editable = false,
onChange,
onFieldEdited,
onFieldAdded,
onFieldRemoved,
onCardAdded,
onCardRemoved,
onCardRenamed,
onPredefinedAdded,
onPredefinedEdited,
onPredefinedRemoved,
onSelectionChange,
// v0.3
dndScopes,
onCardMoved,
onCardDuplicated,
permissions,
canEditField,
canAddField,
canRemoveField,
canEditCard,
canAddCard,
canRemoveCard,
canEditPredefined,
canAddPredefined,
canRemovePredefined,
canDragCard,
canDropCard,
onPermissionDenied,
customPredefinedKeys,
allowRootRemoval = false,
onRootRemoved,
defaultDeletePolicy = "cascade",
promoteCollisionStrategy = "suffix",
emptyTreeRenderer,
metaRenderers,
auditTrail,
onMetaChanged,
onMetaAdded,
onMetaRemoved,
search,
onSearchResults,
validators: hostValidators,
validate,
onValidationFailed,
maxUndoDepth = 50,
disableUndoShortcuts = false,
onUndo,
onRedo,
className,
"aria-label": ariaLabel = "Card tree",
} = props;
/* ───────── custom predefined-keys (v0.6) ─────────
* Registration is mount-only: resolve once, reject collisions/duplicates
* loudly, then thread the SAME resolved list through parse, the key
* validators, search and render. Every parse call must see it — a parse that
* doesn't know the registry re-classifies a custom block as a child card
* (or, for arrays, drops it), which is precisely the 0.5.0 failure.
*
* Identity is keyed on the registered NAMES, not on the array reference.
* That is not an optimization — it is required. Registration is mount-only
* by contract, and the documented usage passes an inline literal
* (`customPredefinedKeys={[metricKey]}`), which is a NEW array every render.
* Keying on the array would recompute this every render, flow into
* useSearch's result memo, and make `onSearchResults` fire every render —
* a host that setStates in that callback (its only purpose) would get an
* unbounded render loop.
*/
// NOTE: the NUL/SOH sentinels below MUST stay as \u escapes, never raw
// bytes — raw control characters make git classify this file as binary.
const customKeySignature = (customPredefinedKeys ?? [])
.map((k) => k?.key ?? "\u0001invalid")
.join("\u0000");
const customKeys = useMemo(
() => resolveCustomKeys(customPredefinedKeys),
// Signature, not array identity — see the note above. Registrations are
// mount-only, so a changed name is the only change that can matter.
// eslint-disable-next-line react-hooks/exhaustive-deps
[customKeySignature],
);
useEffect(() => {
// Loud by design. The v0.3–0.5.0 bug cost an integrator a day *because*
// a bad registration was indistinguishable from a good one.
for (const d of customKeys.diagnostics) {
console.error(`[card-tree] ${d.message}`);
}
}, [customKeys]);
const initialState = useMemo(() => {
const { tree, errors } = parseInput(defaultValue, {
disabledPredefinedKeys: disabledPredefinedKeys ?? [],
dateDetection,
customKeys: customKeys.keys,
});
logParseErrors(errors);
const root =
tree ?? makeErrorTree("Invalid input — defaultValue must be a JSON object.");
return createInitialState(root, defaultCollapsed as DefaultCollapsed);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [state, dispatch] = useReducer(reducer, initialState);
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const { mode: editMode, setMode: setEditMode, clear: clearEditMode } =
useEditMode();
const [tentativeCardId, setTentativeCardId] = useState<string | null>(null);
const [emptyRoot, setEmptyRoot] = useState(false);
const disabledKeys = useMemo(
() => disabledPredefinedKeys ?? [],
[disabledPredefinedKeys],
);
/* ───────── permissions ───────── */
const perms = usePermissions(
state.tree,
editable,
permissions,
{
canEditField,
canAddField,
canRemoveField,
canEditCard,
canAddCard,
canRemoveCard,
canEditPredefined,
canAddPredefined,
canRemovePredefined,
canDragCard,
canDropCard,
},
onPermissionDenied,
);
/* ───────── search ───────── */
const searchResult = useSearch(
state,
dispatch,
search,
// Resolved list, not the raw prop — a dropped registration must not be
// searchable either.
customKeys.keys,
onSearchResults,
);
/**
* Shape-gate a predefined entry before commit.
*
* Built-ins go through the internal shape validator; custom blocks go
* through the HOST's own `validate`. Routing a custom key into
* `validatePredefinedShape` is not merely wrong — its switch is exhaustive
* over the five built-in keys with no `default`, so it returns `undefined`
* and the caller's `!shape.ok` throws a TypeError. Adding or editing any
* custom block would crash.
*/
const validateEntryShape = useCallback(
(entry: CardTreePredefinedEntry): boolean => {
if (isCustomEntry(entry)) {
const registration = findCustomKey(customKeys.keys, entry.key);
if (!registration) return false;
try {
return registration.validate(entry.value)?.ok === true;
} catch {
return false; // host code — a throwing validator rejects, never crashes
}
}
return validatePredefinedShape(entry.key, entry.value).ok;
},
[customKeys.keys],
);
/* ───────── validation gate (v0.4) ───────── */
const gateValidation = useCallback(
(
validatorKey: ValidatorKey,
event: AnyChangeEvent,
actionType: string,
cardId: string | undefined,
): boolean => {
if (!hostValidators && !validate) return true;
const treeJson = treeToJsonNode(stateRef.current.tree) as CardTreeJsonNode;
const result = runHostValidators(
validatorKey,
event,
actionType,
cardId,
treeJson,
hostValidators,
validate,
);
if (!result.ok) {
onValidationFailed?.({
action: actionType,
cardId,
errors: result.errors,
layer: result.layer,
});
return false;
}
return true;
},
[hostValidators, validate, onValidationFailed],
);
/* ───────── undo/redo (v0.4) ───────── */
// Sync maxUndoDepth prop into reducer state
useEffect(() => {
dispatch({ type: "set-max-undo-depth", depth: maxUndoDepth });
}, [maxUndoDepth]);
/* ───────── event firing ───────── */
const eventQueueRef = useRef<
Array<{ action: CardTreeAction; prevState: CardTreeState }>
>([]);
const queueCommit = useCallback((action: CardTreeAction) => {
eventQueueRef.current.push({ action, prevState: stateRef.current });
dispatch(action);
}, []);
const lastFiredVersionRef = useRef(state.version);
useEffect(() => {
if (state.version === lastFiredVersionRef.current) {
eventQueueRef.current = [];
return;
}
lastFiredVersionRef.current = state.version;
const queued = eventQueueRef.current;
eventQueueRef.current = [];
for (const { action, prevState } of queued) {
fireGranularEvent(action, prevState, state, {
onFieldEdited,
onFieldAdded,
onFieldRemoved,
onCardAdded,
onCardRemoved,
onCardRenamed,
onPredefinedAdded,
onPredefinedEdited,
onPredefinedRemoved,
onCardMoved,
onCardDuplicated,
onMetaChanged,
onMetaAdded,
onMetaRemoved,
});
}
if (onChange) onChange(treeToJsonNode(state.tree));
}, [
state.version,
state.tree,
state,
onChange,
onFieldEdited,
onFieldAdded,
onFieldRemoved,
onCardAdded,
onCardRemoved,
onCardRenamed,
onPredefinedAdded,
onPredefinedEdited,
onPredefinedRemoved,
onCardMoved,
onCardDuplicated,
onMetaChanged,
onMetaAdded,
onMetaRemoved,
]);
// Multi-selection change effect
const lastSelectedIdsRef = useRef(state.selectedIds);
useEffect(() => {
if (state.selectedIds !== lastSelectedIdsRef.current) {
lastSelectedIdsRef.current = state.selectedIds;
onSelectionChange?.(Array.from(state.selectedIds));
}
}, [state.selectedIds, onSelectionChange]);
/* ───────── styles ───────── */
const resolveLevelStyle = useMemo(() => {
if (getLevelStyle) return getLevelStyle;
const arr =
levelStyles && levelStyles.length > 0 ? levelStyles : DEFAULT_LEVEL_STYLES;
return (level: number) => arr[clampIndex(level, arr.length)];
}, [getLevelStyle, levelStyles]);
/* ───────── dispatchers ───────── */
const dispatchers: EditDispatchers = useMemo(
() => ({
fieldEditValue: (cardId, key, value, type) => {
if (!perms.canEditField(cardId, key)) return;
const r = validateFieldEditValue(stateRef.current, cardId, key, value, type);
if (!r.ok) return;
const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(
(f) => f.key === key,
);
if (!oldField) return;
if (
!gateValidation(
"fieldEdit",
{
cardId,
key,
oldValue: oldField.value,
oldType: oldField.type,
newValue: value,
newType: type,
},
"field-edit-value",
cardId,
)
)
return;
queueCommit({
type: "field-edit-value",
cardId,
key,
value,
valueType: type,
});
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
clearEditMode();
},
fieldEditKey: (cardId, oldKey, newKey) => {
if (!perms.canEditField(cardId, oldKey)) return;
const r = validateFieldEditKey(
stateRef.current,
cardId,
oldKey,
newKey,
disabledKeys,
customKeys.names,
);
if (!r.ok) return;
const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(
(f) => f.key === oldKey,
);
if (!oldField) return;
if (
!gateValidation(
"fieldEdit",
{
cardId,
key: newKey,
oldValue: oldField.value,
oldType: oldField.type,
newValue: oldField.value,
newType: oldField.type,
},
"field-edit-key",
cardId,
)
)
return;
queueCommit({ type: "field-edit-key", cardId, oldKey, newKey });
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
clearEditMode();
},
fieldAdd: (cardId, key, value, type) => {
if (!perms.canAddField(cardId)) return;
const r = validateFieldAdd(
stateRef.current,
cardId,
key,
value,
type,
disabledKeys,
customKeys.names,
);
if (!r.ok) return;
if (
!gateValidation(
"fieldAdd",
{ cardId, key, value, type },
"field-add",
cardId,
)
)
return;
queueCommit({ type: "field-add", cardId, key, value, valueType: type });
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
clearEditMode();
},
fieldRemove: (cardId, key) => {
if (!perms.canRemoveField(cardId, key)) return;
const oldField = findCard(stateRef.current.tree, cardId)?.fields.find(
(f) => f.key === key,
);
if (!oldField) return;
if (
!gateValidation(
"fieldRemove",
{ cardId, key, oldValue: oldField.value, oldType: oldField.type },
"field-remove",
cardId,
)
)
return;
queueCommit({ type: "field-remove", cardId, key });
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
},
cardAdd: (parentId) => {
if (!perms.canAddCard(parentId)) return;
const newId = generateId();
const parent = findCard(stateRef.current.tree, parentId);
const order = parent
? parent.children.reduce((m, c) => Math.max(m, c.order), -1) + 1
: 0;
const newCard: ParsedCardTree = {
id: newId,
order,
level: (parent?.level ?? 0) + 1,
parentKey: "untitled",
fields: [],
predefined: [],
children: [],
};
if (
!gateValidation(
"cardAdd",
{
parentId,
card: treeToJsonNode(newCard) as CardTreeJsonNode,
},
"card-add",
parentId,
)
)
return;
queueCommit({ type: "card-add", parentId, card: newCard });
setTentativeCardId(newId);
setEditMode({ kind: "card-title", cardId: newId });
},
cardRemove: (cardId, policy) => {
if (!perms.canRemoveCard(cardId)) return;
const isRoot = stateRef.current.tree.id === cardId;
if (isRoot) {
if (!allowRootRemoval) return;
const currentJson = treeToJsonNode(stateRef.current.tree);
const newRoot = onRootRemoved
? onRootRemoved(currentJson)
: null;
if (newRoot === null) {
setEmptyRoot(true);
// We don't actually mutate state here — empty-tree placeholder takes over rendering
} else {
const { tree } = parseInput(newRoot, {
disabledPredefinedKeys: disabledKeys,
dateDetection,
customKeys: customKeys.keys,
});
if (tree) {
queueCommit({ type: "replace-tree", tree });
setEmptyRoot(false);
}
}
return;
}
const r = validateCardRemove(stateRef.current, cardId);
if (!r.ok) return;
const removingCard = findCard(stateRef.current.tree, cardId);
if (!removingCard) return;
const parentIdForEvent = findParentId(stateRef.current.tree, cardId);
if (
!gateValidation(
"cardRemove",
{
cardId,
removed: treeToJsonNode(removingCard) as CardTreeJsonNode,
parentId: parentIdForEvent,
},
"card-remove",
cardId,
)
)
return;
queueCommit({
type: "card-remove",
cardId,
policy: policy ?? defaultDeletePolicy,
collisionStrategy: promoteCollisionStrategy,
});
clearEditMode();
},
cardRename: (cardId, newKey) => {
if (!perms.canEditCard(cardId)) return;
const r = validateCardRename(stateRef.current, cardId, newKey, disabledKeys, customKeys.names);
if (!r.ok) return;
const oldKey = findCard(stateRef.current.tree, cardId)?.parentKey;
if (
!gateValidation(
"cardRename",
{ cardId, oldKey, newKey },
"card-rename",
cardId,
)
)
return;
queueCommit({ type: "card-rename", cardId, newParentKey: newKey });
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
if (tentativeCardId === cardId) setTentativeCardId(null);
clearEditMode();
},
cardCancelTentative: (cardId) => {
queueCommit({ type: "card-remove", cardId, policy: "cascade" });
if (tentativeCardId === cardId) setTentativeCardId(null);
clearEditMode();
},
cardDuplicate: (cardId) => {
if (!perms.canEditCard(cardId)) return;
const newCardId = generateId();
const parentForEvent = findParentId(stateRef.current.tree, cardId);
if (
!gateValidation(
"cardDuplicate",
{
sourceCardId: cardId,
newCardId,
parentId: parentForEvent ?? "",
},
"card-duplicate",
cardId,
)
)
return;
queueCommit({ type: "card-duplicate", cardId, newCardId });
},
predefinedAdd: (cardId, entry) => {
if (!perms.canAddPredefined(cardId, String(entry.key))) return;
if (!validateEntryShape(entry)) return;
if (
!gateValidation(
"predefinedAdd",
{ cardId, key: entry.key, value: entry.value },
"predefined-add",
cardId,
)
)
return;
queueCommit({ type: "predefined-add", cardId, entry });
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
setEditMode({ kind: "predefined", cardId, key: entry.key });
},
predefinedEdit: (cardId, key, entry) => {
if (!perms.canEditPredefined(cardId, String(key))) return;
if (!validateEntryShape(entry)) return;
const oldEntry = findCard(stateRef.current.tree, cardId)?.predefined.find(
(p) => p.key === key,
);
if (
!gateValidation(
"predefinedEdit",
{
cardId,
key,
oldValue: oldEntry?.value ?? null,
newValue: entry.value,
},
"predefined-edit",
cardId,
)
)
return;
queueCommit({ type: "predefined-edit", cardId, key, entry });
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
clearEditMode();
},
predefinedRemove: (cardId, key) => {
if (!perms.canRemovePredefined(cardId, String(key))) return;
const oldEntry = findCard(stateRef.current.tree, cardId)?.predefined.find(
(p) => p.key === key,
);
if (
!gateValidation(
"predefinedRemove",
{ cardId, key, oldValue: oldEntry?.value ?? null },
"predefined-remove",
cardId,
)
)
return;
queueCommit({ type: "predefined-remove", cardId, key });
stampAuditTrail(queueCommit, stateRef.current, cardId, auditTrail);
},
metaEdit: (cardId, key, value) => {
const r = validateMetaEdit(stateRef.current, cardId, key, value);
if (!r.ok) return;
const oldValue =
findCard(stateRef.current.tree, cardId)?.meta?.[key] ?? null;
if (
!gateValidation(
"metaEdit",
{ cardId, key, oldValue, newValue: value },
"meta-edit",
cardId,
)
)
return;
queueCommit({ type: "meta-edit", cardId, key, value });
},
metaAdd: (cardId, key, value) => {
const r = validateMetaAdd(stateRef.current, cardId, key, value);
if (!r.ok) return;
if (
!gateValidation(
"metaAdd",
{ cardId, key, value },
"meta-add",
cardId,
)
)
return;
queueCommit({ type: "meta-add", cardId, key, value });
},
metaRemove: (cardId, key) => {
const oldValue =
findCard(stateRef.current.tree, cardId)?.meta?.[key] ?? null;
if (
!gateValidation(
"metaRemove",
{ cardId, key, oldValue },
"meta-remove",
cardId,
)
)
return;
queueCommit({ type: "meta-remove", cardId, key });
},
selectCard: (cardId, event) => {
if (event.shiftKey) {
dispatch({ type: "extend-selection-to", id: cardId });
} else if (event.metaKey || event.ctrlKey) {
dispatch({ type: "toggle-selection", id: cardId });
} else {
dispatch({ type: "set-multi-selection", ids: [cardId], anchor: cardId });
}
},
}),
[
queueCommit,
clearEditMode,
setEditMode,
tentativeCardId,
perms,
disabledKeys,
defaultDeletePolicy,
promoteCollisionStrategy,
allowRootRemoval,
onRootRemoved,
dateDetection,
auditTrail,
gateValidation,
customKeys.keys,
customKeys.names,
validateEntryShape,
],
);
const validators: EditValidators = useMemo(
() => ({
fieldEditValue: (cardId, key, value, type) =>
validateFieldEditValue(stateRef.current, cardId, key, value, type),
fieldEditKey: (cardId, oldKey, newKey) =>
validateFieldEditKey(stateRef.current, cardId, oldKey, newKey, disabledKeys, customKeys.names),
fieldAdd: (cardId, key, value, type) =>
validateFieldAdd(stateRef.current, cardId, key, value, type, disabledKeys, customKeys.names),
cardRename: (cardId, newKey) =>
validateCardRename(stateRef.current, cardId, newKey, disabledKeys, customKeys.names),
metaEdit: (cardId, key, value) =>
validateMetaEdit(stateRef.current, cardId, key, value),
metaAdd: (cardId, key, value) =>
validateMetaAdd(stateRef.current, cardId, key, value),
}),
[disabledKeys, customKeys.names],
);
/* ───────── DnD handlers ───────── */
const dndEnabled =
editable &&
((dndScopes?.sameLevel ?? true) || (dndScopes?.crossLevel ?? true));
const sortableIds = useMemo(
() => visibleIdsInOrder(state.tree, state.collapsed).filter((id) => id !== state.tree.id),
[state.tree, state.collapsed],
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const id = String(event.active.id);
dispatch({ type: "drag-start", cardId: id });
},
[],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
dispatch({ type: "drag-end" });
const { active, over } = event;
if (!over) return;
const activeId = String(active.id);
const overId = String(over.id);
if (activeId === overId) return;
// Determine drop semantics: if over is the same parent's sibling → same-level reorder;
// otherwise → cross-level reparent.
const tree = stateRef.current.tree;
const sourceParent = findParentId(tree, activeId);
const overParent = findParentId(tree, overId);
if (sourceParent === null) return; // can't drag root
// Check permissions
if (!perms.canDragCard(activeId)) return;
let newParentId: string;
let newOrder: number;
if (sourceParent === overParent) {
// Same-level reorder
if (!(dndScopes?.sameLevel ?? true)) return;
const overCard = findCard(tree, overId);
if (!overCard) return;
newParentId = overParent;
newOrder = overCard.order + 0.5;
} else {
// Cross-level reparent (drop into target as last child)
if (!(dndScopes?.crossLevel ?? true)) return;
if (!perms.canDropCard(activeId, overId)) return;
// Cycle check
const targetAncestors = findAncestorIds(tree, overId);
if (targetAncestors.includes(activeId) || activeId === overId) return;
newParentId = overId;
const overCard = findCard(tree, overId);
if (!overCard) return;
newOrder =
overCard.children.reduce((m, c) => Math.max(m, c.order), -1) + 1;
}
queueCommit({
type: "card-move",
cardId: activeId,
newParentId,
newOrder,
});
},
[perms, dndScopes, queueCommit],
);
/* ───────── config ───────── */
const config: CardConfig = useMemo(
() => ({
resolveLevelStyle,
predefinedKeyStyles,
metaPresentation,
metaRenderers,
rootTitle: ariaLabel,
disabledPredefinedKeys: disabledKeys,
// Resolved registrations — parts look renderers/editors up by name.
customKeys: customKeys.keys,
editable,
editMode,
setEditMode,
clearEditMode,
tentativeCardId,
// eslint-disable-next-line react-hooks/refs
dispatchers,
// eslint-disable-next-line react-hooks/refs
validators,
perms,
allowRootRemoval,
defaultDeletePolicy,
searchMatches: searchResult.matches,
searchQuery: search?.query ?? "",
searchCaseSensitive: search?.caseSensitive ?? false,
dndEnabled,
}),
[
resolveLevelStyle,
predefinedKeyStyles,
metaPresentation,
metaRenderers,
ariaLabel,
disabledKeys,
customKeys.keys,
editable,
editMode,
setEditMode,
clearEditMode,
tentativeCardId,
dispatchers,
validators,
perms,
allowRootRemoval,
defaultDeletePolicy,
searchResult.matches,
search?.query,
search?.caseSensitive,
dndEnabled,
],
);
const onKeyDown = useTreeKeyboard(state, dispatch, editMode);
/* ───────── undo/redo (v0.4) ───────── */
const rootContainerRef = useRef<HTMLDivElement | null>(null);
const undoApi = useUndo(state, dispatch, {
enableShortcuts: !disableUndoShortcuts,
rootRef: rootContainerRef,
onUndo,
onRedo,
});
/* ───────── imperative handle ───────── */
useImperativeHandle(
ref,
(): CardTreeHandle => ({
getValue: () => serializeTree(state.tree),
getTree: () => treeToJsonNode(state.tree) as CardTreeJsonNode,
isDirty: () => isDirtySelector(state),
markClean: () => dispatch({ type: "mark-clean" }),
getSelectedId: () =>
state.selectedIds.size > 0 ? Array.from(state.selectedIds)[0] ?? null : null,
getSelectedIds: () => Array.from(state.selectedIds),
setSelection: (ids) => {
if (ids === null) {
dispatch({ type: "clear-selection" });
return;
}
const arr = typeof ids === "string" ? [ids] : Array.from(ids);
dispatch({
type: "set-multi-selection",
ids: arr,
anchor: arr[arr.length - 1] ?? null,
});
},
focusCard: (id) => {
dispatch({ type: "set-focus", id });
requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`[data-rcid="${id}"]`)?.focus();
});
},
addCardAt: (parentId) => {
const newId = generateId();
const parent = findCard(state.tree, parentId);
const order = parent
? parent.children.reduce((m, c) => Math.max(m, c.order), -1) + 1
: 0;
const newCard: ParsedCardTree = {
id: newId,
order,
level: (parent?.level ?? 0) + 1,
parentKey: "untitled",
fields: [],
predefined: [],
children: [],
};
queueCommit({ type: "card-add", parentId, card: newCard });
return newId;
},
removeCard: (id) => {
queueCommit({ type: "card-remove", cardId: id, policy: "cascade" });
},
replaceRoot: (newRoot) => {
if (newRoot === null) {
setEmptyRoot(true);
return;
}
const { tree } = parseInput(newRoot, {
disabledPredefinedKeys: disabledKeys,
dateDetection,
customKeys: customKeys.keys,
});
if (tree) {
queueCommit({ type: "replace-tree", tree });
setEmptyRoot(false);
}
},
getEffectivePermissions: (cardId, target) => {
const card = findCard(state.tree, cardId);
if (!card) {
return {
edit: false,
add: false,
remove: false,
reorder: false,
reparent: false,
} as EffectivePermissions;
}
return perms.getEffective({
cardId,
level: card.level,
kind: target?.kind ?? "card",
key: target?.key,
});
},
findNext: () => findNextMatch(state, searchResult, dispatch),
findPrevious: () => findPrevMatch(state, searchResult, dispatch),
scrollToMatch: (match) => {
dispatch({ type: "set-active-match-index", index: searchResult.matches.indexOf(match) });
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(`[data-rcid="${match.cardId}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
},
clearSearch: () => {
dispatch({ type: "clear-search" });
},
// v0.4
undo: () => undoApi.undo(),
redo: () => undoApi.redo(),
canUndo: () => undoApi.canUndo,
canRedo: () => undoApi.canRedo,
clearHistory: () => undoApi.clearHistory(),
}),
[
state,
perms,
searchResult,
queueCommit,
disabledKeys,
dateDetection,
undoApi,
customKeys.keys,
],
);
/* ───────── render ───────── */
if (emptyRoot) {
return (
<div role="region" aria-label={ariaLabel} className={cn("w-full", className)}>
{emptyTreeRenderer ? (
emptyTreeRenderer()
) : (
<EmptyTreePlaceholder
onAddRoot={
editable
? () => {
// Build a minimal new root and replace
const newRoot: CardTreeJsonNode = {
title: "untitled",
};
const { tree } = parseInput(newRoot, {
disabledPredefinedKeys: disabledKeys,
dateDetection,
customKeys: customKeys.keys,
});
if (tree) {
dispatch({ type: "replace-tree", tree });
setEmptyRoot(false);
}
}
: undefined
}
/>
)}
</div>
);
}
const treeContent = (
<ul
role="tree"
aria-label={ariaLabel}
aria-multiselectable={editable ? "true" : undefined}
onKeyDown={onKeyDown}
className="block list-none p-0 m-0"
>
<Card tree={state.tree} config={config} state={state} dispatch={dispatch} />
</ul>
);
return (
<div
ref={rootContainerRef}
role="region"
aria-label={ariaLabel}
tabIndex={-1}
className={cn("w-full focus:outline-none", className)}
onClick={(e) => {
if (e.target === e.currentTarget) {
dispatch({ type: "clear-selection" });
}
}}
>
{dndEnabled ? (
<DndContextWrapper
sortableIds={sortableIds}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{treeContent}
</DndContextWrapper>
) : (
treeContent
)}
</div>
);
},
);
/* ───────── DnD wrapper (only mounts sensors when needed) ───────── */
function DndContextWrapper({
sortableIds,
onDragStart,
onDragEnd,
children,
}: {
sortableIds: string[];
onDragStart: (event: DragStartEvent) => void;
onDragEnd: (event: DragEndEvent) => void;
children: React.ReactNode;
}) {
const sensors = useDndSensors();
return (
<DndContext
sensors={sensors}
collisionDetection={collisionStrategy}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{children}
</SortableContext>
</DndContext>
);
}
/* ───────── audit trail helper ───────── */
function stampAuditTrail(
queueCommit: (action: CardTreeAction) => void,
state: CardTreeState,
cardId: string,
auditTrail: { editor?: string; lastEditedKey?: string; lastEditorKey?: string } | undefined,
): void {
if (!auditTrail) return;
const lastEditedKey = auditTrail.lastEditedKey ?? "_lastEdited";
const lastEditorKey = auditTrail.lastEditorKey ?? "_lastEditor";
const card = findCard(state.tree, cardId);
if (!card) return;
const now = new Date().toISOString();
if (card.meta?.[lastEditedKey] !== now) {
queueCommit({ type: "meta-edit", cardId, key: lastEditedKey, value: now });
}
if (auditTrail.editor && card.meta?.[lastEditorKey] !== auditTrail.editor) {
queueCommit({
type: "meta-edit",
cardId,
key: lastEditorKey,
value: auditTrail.editor,
});
}
}
/* ───────── search navigation ───────── */
function findNextMatch(
state: CardTreeState,
result: { matches: SearchMatch[]; activeIndex: number | null },
dispatch: (action: CardTreeAction) => void,
): SearchMatch | null {
if (result.matches.length === 0) return null;
const next = ((result.activeIndex ?? -1) + 1) % result.matches.length;
dispatch({ type: "set-active-match-index", index: next });
const match = result.matches[next];
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(`[data-rcid="${match.cardId}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
return match;
}
function findPrevMatch(
state: CardTreeState,
result: { matches: SearchMatch[]; activeIndex: number | null },
dispatch: (action: CardTreeAction) => void,
): SearchMatch | null {
if (result.matches.length === 0) return null;
const prev =
(result.activeIndex ?? 0) - 1 < 0
? result.matches.length - 1
: (result.activeIndex ?? 0) - 1;
dispatch({ type: "set-active-match-index", index: prev });
const match = result.matches[prev];
requestAnimationFrame(() => {
document
.querySelector<HTMLElement>(`[data-rcid="${match.cardId}"]`)
?.scrollIntoView({ block: "center", behavior: "smooth" });
});
return match;
}
/* ───────── granular event firing ───────── */
type EventHandlers = {
onFieldEdited?: (e: FieldEditedEvent) => void;
onFieldAdded?: (e: FieldAddedEvent) => void;
onFieldRemoved?: (e: FieldRemovedEvent) => void;
onCardAdded?: (e: CardAddedEvent) => void;
onCardRemoved?: (e: CardRemovedEvent) => void;
onCardRenamed?: (e: CardRenamedEvent) => void;
onPredefinedAdded?: (e: PredefinedAddedEvent) => void;
onPredefinedEdited?: (e: PredefinedEditedEvent) => void;
onPredefinedRemoved?: (e: PredefinedRemovedEvent) => void;
onCardMoved?: (e: CardMovedEvent) => void;
onCardDuplicated?: (e: CardDuplicatedEvent) => void;
onMetaChanged?: (e: MetaChangedEvent) => void;
onMetaAdded?: (e: MetaAddedEvent) => void;
onMetaRemoved?: (e: MetaRemovedEvent) => void;
};
function fireGranularEvent(
action: CardTreeAction,
prev: CardTreeState,
next: CardTreeState,
h: EventHandlers,
) {
switch (action.type) {
case "field-edit-value": {
const prevCard = findCard(prev.tree, action.cardId);
const prevField = prevCard?.fields.find((f) => f.key === action.key);
if (!prevField) return;
h.onFieldEdited?.({
cardId: action.cardId,
key: action.key,
oldValue: prevField.value,
oldType: prevField.type,
newValue: action.value,
newType: action.valueType,
});
return;
}
case "field-edit-key": {
const prevCard = findCard(prev.tree, action.cardId);
const prevField = prevCard?.fields.find((f) => f.key === action.oldKey);
if (!prevField) return;
h.onFieldEdited?.({
cardId: action.cardId,
key: action.newKey,
oldValue: prevField.value,
oldType: prevField.type,
newValue: prevField.value,
newType: prevField.type,
});
return;
}
case "field-add":
h.onFieldAdded?.({
cardId: action.cardId,
key: action.key,
value: action.value,
type: action.valueType,
});
return;
case "field-remove": {
const prevCard = findCard(prev.tree, action.cardId);
const prevField = prevCard?.fields.find((f) => f.key === action.key);
if (!prevField) return;
h.onFieldRemoved?.({
cardId: action.cardId,
key: action.key,
oldValue: prevField.value,
oldType: prevField.type,
});
return;
}
case "card-add":
h.onCardAdded?.({
parentId: action.parentId,
card: treeToJsonNode(action.card) as CardTreeJsonNode,
});
return;
case "card-remove": {
const prevSubtree = findCard(prev.tree, action.cardId);
if (!prevSubtree) return;
const parentId = findParentId(prev.tree, action.cardId);
h.onCardRemoved?.({
cardId: action.cardId,
removed: treeToJsonNode(prevSubtree) as CardTreeJsonNode,
parentId,
});
return;
}
case "card-rename": {
const prevCard = findCard(prev.tree, action.cardId);
h.onCardRenamed?.({
cardId: action.cardId,
oldKey: prevCard?.parentKey,
newKey: action.newParentKey,
});
return;
}
case "card-move": {
const prevCard = findCard(prev.tree, action.cardId);
const prevParentId = findParentId(prev.tree, action.cardId);
h.onCardMoved?.({
cardId: action.cardId,
oldParentId: prevParentId ?? "",
newParentId: action.newParentId,
oldOrder: prevCard?.order ?? 0,
newOrder: action.newOrder,
});
return;
}
case "card-duplicate": {
const sourceParentId = findParentId(prev.tree, action.cardId);
h.onCardDuplicated?.({
sourceCardId: action.cardId,
newCardId: action.newCardId,
parentId: sourceParentId ?? "",
});
return;
}
case "predefined-add":
h.onPredefinedAdded?.({
cardId: action.cardId,
key: action.entry.key,
value: action.entry.value,
});
return;
case "predefined-edit": {
const prevCard = findCard(prev.tree, action.cardId);
const prevEntry = prevCard?.predefined.find((p) => p.key === action.key);
h.onPredefinedEdited?.({
cardId: action.cardId,
key: action.key,
oldValue: prevEntry?.value ?? null,
newValue: action.entry.value,
});
return;
}
case "predefined-remove": {
const prevCard = findCard(prev.tree, action.cardId);
const prevEntry = prevCard?.predefined.find((p) => p.key === action.key);
h.onPredefinedRemoved?.({
cardId: action.cardId,
key: action.key,
oldValue: prevEntry?.value ?? null,
});
return;
}
case "meta-edit": {
const prevCard = findCard(prev.tree, action.cardId);
const oldValue = prevCard?.meta?.[action.key] ?? null;
h.onMetaChanged?.({
cardId: action.cardId,
key: action.key,
oldValue,
newValue: action.value,
});
return;
}
case "meta-add":
h.onMetaAdded?.({
cardId: action.cardId,
key: action.key,
value: action.value,
});
return;
case "meta-remove": {
const prevCard = findCard(prev.tree, action.cardId);
const oldValue = prevCard?.meta?.[action.key] ?? null;
h.onMetaRemoved?.({
cardId: action.cardId,
key: action.key,
oldValue,
});
return;
}
default:
return;
}
}
src/registry/components/data/card-tree/index.ts
export { CardTree } from "./card-tree";
// v0.1 types
export type {
CodeAreaValue,
FlatFieldType,
FlatFieldValue,
ImageValue,
LevelStyle,
ListValue,
PredefinedKey,
QuoteValue,
CardTreeHandle,
CardTreeJsonNode,
CardTreeProps,
TableValue,
} from "./types";
// v0.2 event types
export type {
CardAddedEvent,
CardRemovedEvent,
CardRenamedEvent,
FieldAddedEvent,
FieldEditedEvent,
FieldRemovedEvent,
PredefinedAddedEvent,
PredefinedEditedEvent,
PredefinedRemovedEvent,
} from "./types";
// v0.3 types — permissions, search, dnd, custom keys, meta renderers, audit trail.
// These shipped into types.ts in v0.3 but were never re-exported here, so every
// one of them was unreachable from the package root until v0.6 (guide §7.1 and
// §meta-renderers both showed root imports that could not compile).
export type {
AuditTrailConfig,
CardTreePermissions,
CustomKeyContext,
CustomPredefinedKey,
DndScopes,
EffectivePermissions,
MetaRenderer,
MetaRendererContext,
PermissionDenialReason,
PermissionRule,
ReservedKey,
SearchMatch,
SearchMatchType,
SearchOptions,
SearchResult,
} from "./types";
// v0.3 event types
export type {
CardDuplicatedEvent,
CardMovedEvent,
MetaAddedEvent,
MetaChangedEvent,
MetaRemovedEvent,
} from "./types";
// v0.4 validator types
export type {
CardTreeMasterValidator,
CardTreeValidationError,
CardTreeValidationResponse,
CardTreeValidators,
ValidationFailedEvent,
} from "./types";
// constants
export { PREDEFINED_KEYS, RESERVED_KEYS } from "./types";
// optional sibling exports (v0.3 search bar + v0.4 undo toolbar)
export { CardTreeSearchBar } from "./parts/search-bar";
export { CardTreeUndoToolbar } from "./parts/undo-toolbar";
// component metadata
src/registry/components/data/card-tree/types.ts
/**
* Public types for the card-tree pro-component.
*
* v0.1: viewer (typed scalar fields, predefined-key blocks, per-level styling, ARIA tree).
* v0.2: inline editor (click-to-edit, granular events, dirty tracking, single-select).
* v0.3: structural management — drag-drop, bulk multi-select, permission matrix,
* virtualization, native search, meta editing, root-removal, promote-on-delete.
* v0.4: validation hooks + per-commit undo/redo.
* v0.6 (current): **custom predefined-keys actually work.** The prop, the types and the
* docs shipped in v0.3, but nothing between `parse` and the renderers ever read
* them — registration was inert through 0.5.0. v0.6 wires the whole path
* (classify → parse/validate → render/edit → serialize verbatim), which also
* makes array-valued blocks (Plate Value, editor.js) registrable.
*
* See:
* - docs/procomps/card-tree-procomp/card-tree-procomp-plan.md (v0.1)
* - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.2.md
* - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.3.md (§9 = the custom-key spec)
* - docs/procomps/card-tree-procomp/card-tree-procomp-plan-v0.4.md
* - docs/procomps/card-tree-procomp/card-tree-loop.md (v0.6 U-loop)
*/
import type { ReactNode } from "react";
/* ───────── reserved + predefined keys ───────── */
export const RESERVED_KEYS = ["__rcid", "__rcorder", "__rcmeta"] as const;
export type ReservedKey = (typeof RESERVED_KEYS)[number];
export const PREDEFINED_KEYS = [
"codearea",
"image",
"table",
"quote",
"list",
] as const;
export type PredefinedKey = (typeof PREDEFINED_KEYS)[number];
/* ───────── flat-field scalar values ───────── */
export type FlatFieldValue = string | number | boolean | null;
/** Re-exported from lib/infer-type so it's part of the public surface (referenced by event types). */
export type { FlatFieldType } from "./lib/infer-type";
/* ───────── predefined-key payload shapes ───────── */
export type CodeAreaValue = { format: string; content: string };
export type ImageValue = { src: string; alt?: string };
export type TableValue = { headers: string[]; rows: FlatFieldValue[][] };
export type QuoteValue = string;
export type ListValue = FlatFieldValue[];
/* ───────── input node shape ───────── */
export type CardTreeJsonNode = {
__rcid?: string;
__rcorder?: number;
__rcmeta?: Record<string, FlatFieldValue>;
[key: string]: unknown;
};
/* ───────── styling ───────── */
export type LevelStyle = {
containerClassName?: string;
headerClassName?: string;
fieldsClassName?: string;
childrenClassName?: string;
};
/* ───────── v0.2 event types ───────── */
import type { FlatFieldType as _FlatFieldType } from "./lib/infer-type";
export type FieldEditedEvent = {
cardId: string;
key: string;
oldValue: FlatFieldValue;
oldType: _FlatFieldType;
newValue: FlatFieldValue;
newType: _FlatFieldType;
};
export type FieldAddedEvent = {
cardId: string;
key: string;
value: FlatFieldValue;
type: _FlatFieldType;
};
export type FieldRemovedEvent = {
cardId: string;
key: string;
oldValue: FlatFieldValue;
oldType: _FlatFieldType;
};
export type CardAddedEvent = {
parentId: string;
card: CardTreeJsonNode;
};
export type CardRemovedEvent = {
cardId: string;
removed: CardTreeJsonNode;
parentId: string | null;
};
export type CardRenamedEvent = {
cardId: string;
oldKey: string | undefined;
newKey: string;
};
export type PredefinedAddedEvent = {
cardId: string;
key: PredefinedKey | string;
value: unknown;
};
export type PredefinedEditedEvent = {
cardId: string;
key: PredefinedKey | string;
oldValue: unknown;
newValue: unknown;
};
export type PredefinedRemovedEvent = {
cardId: string;
key: PredefinedKey | string;
oldValue: unknown;
};
/* ───────── v0.3 event types ───────── */
export type CardMovedEvent = {
cardId: string;
oldParentId: string;
newParentId: string;
oldOrder: number;
newOrder: number;
};
export type CardDuplicatedEvent = {
sourceCardId: string;
newCardId: string;
parentId: string;
};
export type MetaChangedEvent = {
cardId: string;
key: string;
oldValue: FlatFieldValue;
newValue: FlatFieldValue;
};
export type MetaAddedEvent = {
cardId: string;
key: string;
value: FlatFieldValue;
};
export type MetaRemovedEvent = {
cardId: string;
key: string;
oldValue: FlatFieldValue;
};
/* ───────── v0.3 search ───────── */
export type SearchOptions = {
query: string;
caseSensitive?: boolean;
matchTitles?: boolean;
matchKeys?: boolean;
matchValues?: boolean;
matchPredefined?: boolean;
matchMeta?: boolean;
};
export type SearchMatchType =
| "title"
| "field-key"
| "field-value"
| "predefined"
| "meta-key"
| "meta-value";
export type SearchMatch = {
cardId: string;
matchType: SearchMatchType;
fieldKey?: string;
excerpt: string;
start: number;
length: number;
};
export type SearchResult = {
matches: SearchMatch[];
matchedCardIds: ReadonlySet<string>;
activeIndex: number | null;
};
/* ───────── v0.3 permissions ───────── */
export type PermissionRule = {
edit?: boolean;
add?: boolean;
remove?: boolean;
reorder?: boolean;
reparent?: boolean;
};
export type CardTreePermissions = {
default?: PermissionRule;
byLevel?: Record<number, PermissionRule>;
byCard?: Record<string, PermissionRule>;
/**
* v0.6: accepts registered custom-key names too, not just the five built-ins.
* The runtime lookup in `lib/permissions.ts` always resolved this by string;
* the narrower type was the only thing preventing a custom block from being
* permission-controlled. `string & {}` keeps built-in autocomplete alive.
*/
byPredefinedKey?: Partial<Record<PredefinedKey | (string & {}), PermissionRule>>;
byFieldType?: Partial<Record<_FlatFieldType, PermissionRule>>;
inherit?: boolean;
};
export type PermissionDenialReason =
| "global-editable-false"
| "meta-locked"
| "by-card"
| "by-predefined-key"
| "by-field-type"
| "by-level"
| "default"
| "predicate";
export type EffectivePermissions = PermissionRule & {
reason?: PermissionDenialReason;
};
/* ───────── v0.3 DnD ───────── */
export type DndScopes = {
sameLevel?: boolean;
crossLevel?: boolean;
};
/* ───────── v0.3 custom predefined keys ───────── */
export type CustomKeyContext = {
cardId: string;
level: number;
isEditing: boolean;
className?: string;
};
export type CustomPredefinedKey = {
key: string;
description?: string;
icon?: ReactNode;
category?: string;
validate: (value: unknown) => { ok: boolean; errors?: { code: string; message: string }[] };
render: (value: unknown, ctx: CustomKeyContext) => ReactNode;
edit?: (
value: unknown,
onSave: (next: unknown) => void,
onCancel: () => void,
) => ReactNode;
defaultValue: () => unknown;
/**
* Makes this block searchable by the native data-model search. Optional —
* omit it and the block is simply skipped by search (never an error).
*
* Q-P15 deferred custom-key search out of v0.3; `lib/search.ts` shipped the
* consumption early and it is live as of v0.6.
*/
searchableText?: (value: unknown) => string[];
};
/* ───────── v0.3 meta renderers ───────── */
export type MetaRendererContext = {
cardId: string;
metaKey: string;
className?: string;
};
export type MetaRenderer = (
value: FlatFieldValue,
ctx: MetaRendererContext,
) => ReactNode;
/* ───────── v0.3 audit trail ───────── */
export type AuditTrailConfig = {
/** Author identifier stamped on `_lastEditor` meta entry. */
editor?: string;
/** Reserved meta key names for the audit trail. */
lastEditedKey?: string; // default '_lastEdited'
lastEditorKey?: string; // default '_lastEditor'
};
/* ───────── v0.4 validation hooks ───────── */
export type CardTreeValidationError = { code: string; message: string };
export type CardTreeValidationResponse =
| { ok: true }
| { ok: false; errors: CardTreeValidationError[] };
export type CardTreeValidators = {
fieldEdit?: (
event: FieldEditedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
fieldAdd?: (
event: FieldAddedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
fieldRemove?: (
event: FieldRemovedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
cardAdd?: (
event: CardAddedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
cardRemove?: (
event: CardRemovedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
cardRename?: (
event: CardRenamedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
cardMove?: (
event: CardMovedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
cardDuplicate?: (
event: CardDuplicatedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
predefinedAdd?: (
event: PredefinedAddedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
predefinedEdit?: (
event: PredefinedEditedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
predefinedRemove?: (
event: PredefinedRemovedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
metaEdit?: (
event: MetaChangedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
metaAdd?: (
event: MetaAddedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
metaRemove?: (
event: MetaRemovedEvent,
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
};
export type CardTreeMasterValidator = (
action: { type: string; cardId?: string },
tree: CardTreeJsonNode,
) => CardTreeValidationResponse;
export type ValidationFailedEvent = {
action: string;
cardId?: string;
errors: CardTreeValidationError[];
layer: "per-action" | "master";
};
/* ───────── component props ───────── */
export type CardTreeProps = {
defaultValue: CardTreeJsonNode;
// styling (v0.1)
levelStyles?: LevelStyle[];
getLevelStyle?: (level: number) => LevelStyle;
predefinedKeyStyles?: Partial<Record<PredefinedKey | string, string | LevelStyle>>;
// viewer behavior (v0.1)
defaultCollapsed?: "all" | "none" | ((level: number) => boolean);
metaPresentation?: "hidden" | "inline" | "popover";
disabledPredefinedKeys?: PredefinedKey[];
dateDetection?: "auto" | "never" | ((value: string) => boolean);
// editor behavior (v0.2)
editable?: boolean;
onChange?: (tree: CardTreeJsonNode) => void;
onFieldEdited?: (event: FieldEditedEvent) => void;
onFieldAdded?: (event: FieldAddedEvent) => void;
onFieldRemoved?: (event: FieldRemovedEvent) => void;
onCardAdded?: (event: CardAddedEvent) => void;
onCardRemoved?: (event: CardRemovedEvent) => void;
onCardRenamed?: (event: CardRenamedEvent) => void;
onPredefinedAdded?: (event: PredefinedAddedEvent) => void;
onPredefinedEdited?: (event: PredefinedEditedEvent) => void;
onPredefinedRemoved?: (event: PredefinedRemovedEvent) => void;
// v0.3 — DnD
dndScopes?: DndScopes;
onCardMoved?: (event: CardMovedEvent) => void;
onCardDuplicated?: (event: CardDuplicatedEvent) => void;
// v0.3 — permissions (declarative + predicate escape hatches)
permissions?: CardTreePermissions;
canEditField?: (cardId: string, key: string) => boolean;
canAddField?: (cardId: string) => boolean;
canRemoveField?: (cardId: string, key: string) => boolean;
canEditCard?: (cardId: string) => boolean;
canAddCard?: (parentId: string) => boolean;
canRemoveCard?: (cardId: string) => boolean;
canEditPredefined?: (cardId: string, key: string) => boolean;
canAddPredefined?: (cardId: string, key: string) => boolean;
canRemovePredefined?: (cardId: string, key: string) => boolean;
canDragCard?: (cardId: string) => boolean;
canDropCard?: (cardId: string, targetParentId: string) => boolean;
/** Fired (analytics-only) when an action would have been blocked. */
onPermissionDenied?: (
action: keyof PermissionRule,
cardId: string,
target: string | undefined,
reason: PermissionDenialReason,
) => void;
/**
* Register additional content blocks at mount (v0.3 API — **functional since v0.6**).
*
* Precedence: reserved (`__rc*`) → built-in predefined → **custom** → scalar
* field → child card. Matching happens on the key NAME before the value is
* inspected, so a custom block may hold any JSON shape, arrays included —
* unlike ordinary children, which still reject arrays per Q-P4.
*
* Mount-only. A name that collides with a built-in or reserved key, or that is
* registered twice, is dropped with a `console.error`.
*
* ⚠️ Registering a name **claims that name everywhere in the tree**. If existing
* documents already use it for something else — a child card, say — those
* values are now routed to your `validate`, and a rejection drops the entry
* (same rule the five built-ins have always followed: a malformed `quote` is
* dropped too). Pick names you own, and treat adding a registration to a
* populated corpus as a migration. Parse diagnostics are emitted via
* `console.warn`.
*/
customPredefinedKeys?: CustomPredefinedKey[];
// v0.3 — root-removal + delete policy
allowRootRemoval?: boolean;
onRootRemoved?: (current: CardTreeJsonNode) => CardTreeJsonNode | null;
defaultDeletePolicy?: "cascade" | "promote";
promoteCollisionStrategy?: "suffix" | "qualify" | "reject";
emptyTreeRenderer?: () => ReactNode;
// v0.3 — meta editing + audit
metaRenderers?: Record<string, MetaRenderer>;
auditTrail?: AuditTrailConfig;
onMetaChanged?: (event: MetaChangedEvent) => void;
onMetaAdded?: (event: MetaAddedEvent) => void;
onMetaRemoved?: (event: MetaRemovedEvent) => void;
// v0.3 — search (controlled by host)
search?: SearchOptions;
onSearchResults?: (result: SearchResult) => void;
// v0.3 — multi-select handler (BREAKING change from v0.2's `id: string | null`)
onSelectionChange?: (ids: readonly string[]) => void;
// v0.4 — validation hooks
validators?: CardTreeValidators;
validate?: CardTreeMasterValidator;
onValidationFailed?: (event: ValidationFailedEvent) => void;
// v0.4 — undo/redo
maxUndoDepth?: number;
disableUndoShortcuts?: boolean;
onUndo?: () => void;
onRedo?: () => void;
// container
className?: string;
"aria-label"?: string;
};
/* ───────── imperative ref handle ───────── */
export type CardTreeHandle = {
// v0.1
/** Canonical pretty-printed JSON string with auto-attached IDs. */
getValue(): string;
/** Object form of the parsed tree, with auto-attached IDs preserved. */
getTree(): CardTreeJsonNode;
// v0.2
isDirty(): boolean;
markClean(): void;
/** Returns the first selected card id, or null. v0.3 retains for backward compat with single-select consumers. */
getSelectedId(): string | null;
// v0.3
getSelectedIds(): readonly string[];
setSelection(ids: readonly string[] | string | null): void;
focusCard(id: string): void;
addCardAt(parentId: string, position?: number): string;
removeCard(id: string): void;
/** Programmatic root replacement when allowRootRemoval is enabled. */
replaceRoot(newRoot: CardTreeJsonNode | null): void;
/** Returns effective permission resolution for a card (debug / host-side gating). */
getEffectivePermissions(
cardId: string,
target?: { kind: "field" | "predefined" | "card"; key?: string },
): EffectivePermissions;
// v0.3 — search
findNext(): SearchMatch | null;
findPrevious(): SearchMatch | null;
scrollToMatch(match: SearchMatch): void;
clearSearch(): void;
// v0.4 — undo/redo
undo(): boolean;
redo(): boolean;
canUndo(): boolean;
canRedo(): boolean;
clearHistory(): void;
};
src/registry/components/data/card-tree/hooks/use-bulk-selection.ts
import { useCallback, type Dispatch } from "react";
import type {
CardTreeAction,
CardTreeState,
} from "../lib/reducer";
/**
* v0.3 multi-select helpers. Click semantics:
* - plain click → replace selection with single id
* - shift-click → range select from anchor to id
* - cmd/ctrl-click → toggle id in/out of selection
*/
export function useBulkSelection(
state: CardTreeState,
dispatch: Dispatch<CardTreeAction>,
) {
const select = useCallback(
(id: string) => {
dispatch({ type: "set-multi-selection", ids: [id], anchor: id });
},
[dispatch],
);
const toggle = useCallback(
(id: string) => {
dispatch({ type: "toggle-selection", id });
},
[dispatch],
);
const extend = useCallback(
(id: string) => {
dispatch({ type: "extend-selection-to", id });
},
[dispatch],
);
const clear = useCallback(() => {
dispatch({ type: "clear-selection" });
}, [dispatch]);
const isSelected = useCallback(
(id: string) => state.selectedIds.has(id),
[state.selectedIds],
);
const handleClick = useCallback(
(id: string, event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
if (event.shiftKey) extend(id);
else if (event.metaKey || event.ctrlKey) toggle(id);
else select(id);
},
[extend, toggle, select],
);
return {
selectedIds: state.selectedIds,
anchorId: state.selectionAnchorId,
isSelected,
select,
toggle,
extend,
clear,
handleClick,
};
}
src/registry/components/data/card-tree/hooks/use-collapse-state.ts
import { useCallback, useMemo, type Dispatch } from "react";
import type { CardTreeAction, CardTreeState } from "../lib/reducer";
/**
* Memoized selector + dispatcher for collapse state.
* The Card component reads state.collapsed directly for render; this hook is
* for callers that need a stable closure over `isCollapsed` / `toggle`.
*/
export function useCollapseState(
state: CardTreeState,
dispatch: Dispatch<CardTreeAction>,
) {
const isCollapsed = useCallback(
(id: string) => state.collapsed.has(id),
[state.collapsed],
);
const toggle = useCallback(
(id: string) => dispatch({ type: "toggle-collapse", id }),
[dispatch],
);
return useMemo(() => ({ isCollapsed, toggle }), [isCollapsed, toggle]);
}
src/registry/components/data/card-tree/hooks/use-dirty.ts
import type { CardTreeState } from "../lib/reducer";
/**
* Pure selector for dirty state. Counter-based (every commit-action increments
* `version`; mark-clean snapshots `cleanVersion`). Cheap and predictable.
*
* v0.4 will upgrade to structural-diff dirty tracking once undo/redo lands.
*/
export function isDirty(state: CardTreeState): boolean {
return state.version !== state.cleanVersion;
}
src/registry/components/data/card-tree/hooks/use-dnd-config.ts
"use client";
import {
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type CollisionDetection,
} from "@dnd-kit/core";
const DRAG_DISTANCE_THRESHOLD = 6; // px before drag activates
/**
* Configures @dnd-kit sensors with a small drag-distance threshold so that
* single-clicks (used for entering edit mode) don't accidentally trigger drag.
*/
export function useDndSensors() {
return useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: DRAG_DISTANCE_THRESHOLD },
}),
useSensor(KeyboardSensor),
);
}
/**
* Default collision detection: closest-center against all draggable rectangles.
* Refinement (between-siblings vs into-card) happens at the drop-event handler.
*/
export const collisionStrategy: CollisionDetection = closestCenter;
src/registry/components/data/card-tree/hooks/use-edit-mode.ts
import { useCallback, useState } from "react";
import type { PredefinedKey } from "../types";
/**
* Transient UI state: what's currently being edited.
*
* Lives outside the reducer so undo/redo (v0.4) won't replay edit-mode
* focus. Each card / field / block owns its own input state internally
* via local useState; this hook just flags WHICH thing is in edit mode.
*/
export type EditMode =
| { kind: "field-value"; cardId: string; key: string }
| { kind: "field-key"; cardId: string; key: string }
| { kind: "card-title"; cardId: string }
// `key` is widened from PredefinedKey in v0.6: a custom block's key is an
// arbitrary registered name. It was already being written here via an
// `as PredefinedKey` assertion, which compiled but was a lie the moment
// custom keys became real.
| { kind: "predefined"; cardId: string; key: PredefinedKey | string }
| { kind: "field-add"; cardId: string }
| { kind: "predefined-add"; cardId: string };
export function useEditMode() {
const [mode, setMode] = useState<EditMode | null>(null);
const clear = useCallback(() => setMode(null), []);
const isEditing = useCallback(
(target: EditMode): boolean => {
if (!mode) return false;
if (mode.kind !== target.kind) return false;
if (mode.cardId !== target.cardId) return false;
if ("key" in mode && "key" in target && mode.key !== target.key)
return false;
return true;
},
[mode],
);
return { mode, setMode, clear, isEditing };
}
src/registry/components/data/card-tree/hooks/use-permissions.ts
import { useCallback, useMemo } from "react";
import type {
EffectivePermissions,
PermissionDenialReason,
PermissionRule,
CardTreePermissions,
} from "../types";
import {
computeAncestorMap,
computeLockedIds,
resolveEffectivePermissions,
type PermissionAction,
type PermissionContext,
type PermissionTarget,
} from "../lib/permissions";
import type { ParsedCardTree } from "../lib/parse";
export type PermissionPredicates = {
canEditField?: (cardId: string, key: string) => boolean;
canAddField?: (cardId: string) => boolean;
canRemoveField?: (cardId: string, key: string) => boolean;
canEditCard?: (cardId: string) => boolean;
canAddCard?: (parentId: string) => boolean;
canRemoveCard?: (cardId: string) => boolean;
canEditPredefined?: (cardId: string, key: string) => boolean;
canAddPredefined?: (cardId: string, key: string) => boolean;
canRemovePredefined?: (cardId: string, key: string) => boolean;
canDragCard?: (cardId: string) => boolean;
canDropCard?: (cardId: string, targetParentId: string) => boolean;
};
/**
* Returns memoized helpers that resolve effective permissions for any (action, target).
* Predicate predicates take precedence over declarative permissions.
*/
export function usePermissions(
tree: ParsedCardTree,
editable: boolean,
permissions: CardTreePermissions | undefined,
predicates: PermissionPredicates,
onDenied:
| ((
action: keyof PermissionRule,
cardId: string,
target: string | undefined,
reason: PermissionDenialReason,
) => void)
| undefined,
) {
const metaLockedIds = useMemo(() => computeLockedIds(tree), [tree]);
const ancestorMap = useMemo(() => computeAncestorMap(tree), [tree]);
const ctx: PermissionContext = useMemo(
() => ({ editable, permissions, metaLockedIds, ancestorMap }),
[editable, permissions, metaLockedIds, ancestorMap],
);
const getEffective = useCallback(
(target: PermissionTarget): EffectivePermissions => {
return resolveEffectivePermissions(ctx, target);
},
[ctx],
);
const check = useCallback(
(action: PermissionAction, target: PermissionTarget): boolean => {
const eff = resolveEffectivePermissions(ctx, target);
const allowed = eff[action] !== false;
if (!allowed && onDenied) {
onDenied(action, target.cardId, target.key, eff.reason ?? "default");
}
return allowed;
},
[ctx, onDenied],
);
// Public convenience wrappers
const canEditField = useCallback(
(cardId: string, key: string) => {
if (predicates.canEditField) {
const r = predicates.canEditField(cardId, key);
if (!r && onDenied) onDenied("edit", cardId, key, "predicate");
return r;
}
return check("edit", { cardId, level: 0, kind: "field", key });
},
[check, predicates, onDenied],
);
const canAddField = useCallback(
(cardId: string) => {
if (predicates.canAddField) {
const r = predicates.canAddField(cardId);
if (!r && onDenied) onDenied("add", cardId, undefined, "predicate");
return r;
}
return check("add", { cardId, level: 0, kind: "field" });
},
[check, predicates, onDenied],
);
const canRemoveField = useCallback(
(cardId: string, key: string) => {
if (predicates.canRemoveField) {
const r = predicates.canRemoveField(cardId, key);
if (!r && onDenied) onDenied("remove", cardId, key, "predicate");
return r;
}
return check("remove", { cardId, level: 0, kind: "field", key });
},
[check, predicates, onDenied],
);
const canEditCard = useCallback(
(cardId: string) => {
if (predicates.canEditCard) {
const r = predicates.canEditCard(cardId);
if (!r && onDenied) onDenied("edit", cardId, undefined, "predicate");
return r;
}
return check("edit", { cardId, level: 0, kind: "card" });
},
[check, predicates, onDenied],
);
const canAddCard = useCallback(
(parentId: string) => {
if (predicates.canAddCard) {
const r = predicates.canAddCard(parentId);
if (!r && onDenied) onDenied("add", parentId, undefined, "predicate");
return r;
}
return check("add", { cardId: parentId, level: 0, kind: "card" });
},
[check, predicates, onDenied],
);
const canRemoveCard = useCallback(
(cardId: string) => {
if (predicates.canRemoveCard) {
const r = predicates.canRemoveCard(cardId);
if (!r && onDenied) onDenied("remove", cardId, undefined, "predicate");
return r;
}
return check("remove", { cardId, level: 0, kind: "card" });
},
[check, predicates, onDenied],
);
const canEditPredefined = useCallback(
(cardId: string, key: string) => {
if (predicates.canEditPredefined) {
const r = predicates.canEditPredefined(cardId, key);
if (!r && onDenied) onDenied("edit", cardId, key, "predicate");
return r;
}
return check("edit", { cardId, level: 0, kind: "predefined", key });
},
[check, predicates, onDenied],
);
const canAddPredefined = useCallback(
(cardId: string, key: string) => {
if (predicates.canAddPredefined) return predicates.canAddPredefined(cardId, key);
return check("add", { cardId, level: 0, kind: "predefined", key });
},
[check, predicates],
);
const canRemovePredefined = useCallback(
(cardId: string, key: string) => {
if (predicates.canRemovePredefined) return predicates.canRemovePredefined(cardId, key);
return check("remove", { cardId, level: 0, kind: "predefined", key });
},
[check, predicates],
);
const canDragCard = useCallback(
(cardId: string) => {
if (predicates.canDragCard) return predicates.canDragCard(cardId);
return check("reorder", { cardId, level: 0, kind: "card" });
},
[check, predicates],
);
const canDropCard = useCallback(
(cardId: string, targetParentId: string) => {
if (predicates.canDropCard) return predicates.canDropCard(cardId, targetParentId);
return check("reparent", { cardId, level: 0, kind: "card" });
},
[check, predicates],
);
const isLocked = useCallback(
(cardId: string) => metaLockedIds.has(cardId),
[metaLockedIds],
);
return {
canEditField,
canAddField,
canRemoveField,
canEditCard,
canAddCard,
canRemoveCard,
canEditPredefined,
canAddPredefined,
canRemovePredefined,
canDragCard,
canDropCard,
isLocked,
getEffective,
};
}
export type UsePermissionsReturn = ReturnType<typeof usePermissions>;
src/registry/components/data/card-tree/hooks/use-search.ts
import { useEffect, useMemo, useRef } from "react";
import type { Dispatch } from "react";
import type {
CustomPredefinedKey,
SearchOptions,
SearchResult,
} from "../types";
import { searchTree } from "../lib/search";
import { findAncestorIds, type CardTreeAction, type CardTreeState } from "../lib/reducer";
/**
* Wires the controlled `search` prop through the reducer:
* - On query change → search the tree, dispatch expand-path-to-matches
* - Returns the SearchResult for downstream consumers
* - Also tracks the active match index in reducer state
*/
export function useSearch(
state: CardTreeState,
dispatch: Dispatch<CardTreeAction>,
options: SearchOptions | undefined,
customKeys: readonly CustomPredefinedKey[] | undefined,
onSearchResults: ((result: SearchResult) => void) | undefined,
): SearchResult {
const result = useMemo(() => {
if (!options || !options.query) {
return {
matches: [],
matchedCardIds: new Set<string>(),
activeIndex: null,
};
}
return searchTree(state.tree, options, {
customKeys: customKeys as CustomPredefinedKey[] | undefined,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
state.tree,
options?.query,
options?.caseSensitive,
options?.matchTitles,
options?.matchKeys,
options?.matchValues,
options?.matchPredefined,
options?.matchMeta,
customKeys,
]);
// Sync query into reducer (so collapse-state preservation works)
const lastQueryRef = useRef("");
useEffect(() => {
const query = options?.query ?? "";
if (query !== lastQueryRef.current) {
lastQueryRef.current = query;
dispatch({ type: "set-search-query", query });
}
}, [options?.query, dispatch]);
// Sync expand-path-to-matches when results change
useEffect(() => {
if (result.matchedCardIds.size === 0) return;
const ancestorIds = new Set<string>();
for (const cardId of result.matchedCardIds) {
const ancestors = findAncestorIds(state.tree, cardId);
for (const a of ancestors) ancestorIds.add(a);
}
if (ancestorIds.size > 0) {
dispatch({
type: "expand-path-to-matches",
ancestorIds: Array.from(ancestorIds),
});
}
}, [result.matchedCardIds, state.tree, dispatch]);
// Active index lives in reducer state (so it survives unrelated re-renders)
// Sync the result.activeIndex with state.searchActiveIndex
const enrichedResult = useMemo(
() => ({
...result,
activeIndex:
state.searchActiveIndex !== null && state.searchActiveIndex < result.matches.length
? state.searchActiveIndex
: result.matches.length > 0
? 0
: null,
}),
[result, state.searchActiveIndex],
);
// Fire onSearchResults callback when the result identity changes
const lastResultRef = useRef<SearchResult | null>(null);
useEffect(() => {
if (lastResultRef.current !== enrichedResult && onSearchResults) {
onSearchResults(enrichedResult);
}
lastResultRef.current = enrichedResult;
}, [enrichedResult, onSearchResults]);
return enrichedResult;
}
src/registry/components/data/card-tree/hooks/use-selection.ts
import { useCallback, type Dispatch } from "react";
import type {
CardTreeAction,
CardTreeState,
} from "../lib/reducer";
/**
* Stable selector + dispatcher for selection state.
*
* Selection is click-driven, distinct from focus (which is keyboard-driven).
* Single-select only in v0.2. Multi-select arrives in v0.3.
*/
export function useSelection(
state: CardTreeState,
dispatch: Dispatch<CardTreeAction>,
) {
const select = useCallback(
(id: string | null) => {
dispatch({ type: "set-selection", id });
},
[dispatch],
);
const isSelected = useCallback(
(id: string) => state.selectedId === id,
[state.selectedId],
);
return {
selectedId: state.selectedId,
select,
isSelected,
};
}
src/registry/components/data/card-tree/hooks/use-tree-focus.ts
import { useMemo } from "react";
import type { CardTreeState } from "../lib/reducer";
import { visibleIdsInOrder } from "../lib/reducer";
/**
* Returns the ordered list of card ids currently visible in the tree, plus
* helpers for prev/next/parent traversal. Recomputes only when the tree or
* collapse set changes.
*/
export function useTreeFocus(state: CardTreeState) {
return useMemo(() => {
const visible = visibleIdsInOrder(state.tree, state.collapsed);
const indexById = new Map<string, number>();
visible.forEach((id, i) => indexById.set(id, i));
return {
visible,
first: visible[0] ?? null,
last: visible[visible.length - 1] ?? null,
next: (id: string | null): string | null => {
if (id === null) return visible[0] ?? null;
const i = indexById.get(id);
if (i === undefined) return visible[0] ?? null;
return visible[i + 1] ?? null;
},
prev: (id: string | null): string | null => {
if (id === null) return visible[visible.length - 1] ?? null;
const i = indexById.get(id);
if (i === undefined) return visible[0] ?? null;
return visible[i - 1] ?? null;
},
};
}, [state.tree, state.collapsed]);
}
src/registry/components/data/card-tree/hooks/use-tree-keyboard.ts
import { useCallback, type Dispatch, type KeyboardEvent } from "react";
import type {
CardTreeAction,
CardTreeState,
} from "../lib/reducer";
import { findCard, findParentId } from "../lib/reducer";
import { useTreeFocus } from "./use-tree-focus";
import type { EditMode } from "./use-edit-mode";
/**
* Returns an `onKeyDown` handler for the tree root.
*
* Keyboard contract (per plan §11):
* - ↑ / ↓ prev / next visible card
* - → collapsed → expand; expanded with kids → focus first child
* - ← expanded → collapse; collapsed/leaf → focus parent
* - Home / End first / last visible
* - Enter / Space toggle collapse on focused card
*/
export function useTreeKeyboard(
state: CardTreeState,
dispatch: Dispatch<CardTreeAction>,
editMode: EditMode | null,
) {
const focus = useTreeFocus(state);
const focusId = useCallback(
(id: string | null, root: HTMLElement | null) => {
if (!id) return;
dispatch({ type: "set-focus", id });
// After state commit, move DOM focus.
requestAnimationFrame(() => {
const el = root?.querySelector<HTMLElement>(`[data-rcid="${id}"]`);
el?.focus();
});
},
[dispatch],
);
return useCallback(
(event: KeyboardEvent<HTMLElement>) => {
// When an editor is active, don't process tree-keyboard nav — keystrokes
// belong to the active input.
if (editMode) return;
const root = event.currentTarget;
const focused = state.focusedId;
switch (event.key) {
case "ArrowDown": {
event.preventDefault();
focusId(focus.next(focused), root);
return;
}
case "ArrowUp": {
event.preventDefault();
focusId(focus.prev(focused), root);
return;
}
case "Home": {
event.preventDefault();
focusId(focus.first, root);
return;
}
case "End": {
event.preventDefault();
focusId(focus.last, root);
return;
}
case "ArrowRight": {
if (!focused) return;
event.preventDefault();
const card = findCard(state.tree, focused);
if (!card) return;
const isCollapsed = state.collapsed.has(focused);
const hasChildren = card.children.length > 0;
const hasBody =
card.fields.length > 0 || card.predefined.length > 0;
const canCollapse = hasBody || hasChildren;
if (canCollapse && isCollapsed) {
// Expand body and/or children.
dispatch({ type: "toggle-collapse", id: focused });
} else if (hasChildren && !isCollapsed) {
// Already expanded with children — descend.
focusId(card.children[0]?.id ?? null, root);
}
return;
}
case "ArrowLeft": {
if (!focused) return;
event.preventDefault();
const card = findCard(state.tree, focused);
if (!card) return;
const isCollapsed = state.collapsed.has(focused);
const hasChildren = card.children.length > 0;
const hasBody =
card.fields.length > 0 || card.predefined.length > 0;
const canCollapse = hasBody || hasChildren;
if (canCollapse && !isCollapsed) {
// Expanded — collapse it (body + children).
dispatch({ type: "toggle-collapse", id: focused });
} else {
// Collapsed or empty — ascend.
const parentId = findParentId(state.tree, focused);
if (parentId) focusId(parentId, root);
}
return;
}
case "Enter":
case " ": {
if (!focused) return;
const card = findCard(state.tree, focused);
if (!card) return;
const canCollapse =
card.fields.length > 0 ||
card.predefined.length > 0 ||
card.children.length > 0;
if (canCollapse) {
event.preventDefault();
dispatch({ type: "toggle-collapse", id: focused });
}
return;
}
}
},
[state.focusedId, state.tree, state.collapsed, dispatch, focus, focusId, editMode],
);
}
src/registry/components/data/card-tree/hooks/use-undo.ts
"use client";
import { useCallback, useEffect, type Dispatch } from "react";
import type { CardTreeAction, CardTreeState } from "../lib/reducer";
export function useUndo(
state: CardTreeState,
dispatch: Dispatch<CardTreeAction>,
options: {
enableShortcuts?: boolean;
rootRef?: { current: HTMLElement | null };
onUndo?: () => void;
onRedo?: () => void;
} = {},
) {
const canUndo = state.undoStack.length > 0;
const canRedo = state.redoStack.length > 0;
const undo = useCallback((): boolean => {
if (state.undoStack.length === 0) return false;
dispatch({ type: "undo" });
options.onUndo?.();
return true;
}, [state.undoStack.length, dispatch, options]);
const redo = useCallback((): boolean => {
if (state.redoStack.length === 0) return false;
dispatch({ type: "redo" });
options.onRedo?.();
return true;
}, [state.redoStack.length, dispatch, options]);
const clearHistory = useCallback(() => {
dispatch({ type: "clear-history" });
}, [dispatch]);
// Keyboard shortcuts: Cmd/Ctrl+Z (undo), Cmd/Ctrl+Shift+Z (redo), Cmd/Ctrl+Y (redo)
useEffect(() => {
if (options.enableShortcuts === false) return;
const root = options.rootRef?.current;
if (!root) return;
const handler = (e: KeyboardEvent) => {
// Don't intercept if focus is in an input / textarea / contenteditable
const t = e.target as HTMLElement | null;
if (
t &&
(t.tagName === "INPUT" ||
t.tagName === "TEXTAREA" ||
t.isContentEditable)
) {
return;
}
const isMod = e.metaKey || e.ctrlKey;
if (!isMod) return;
const key = e.key.toLowerCase();
if (key === "z" && !e.shiftKey) {
e.preventDefault();
undo();
} else if ((key === "z" && e.shiftKey) || key === "y") {
e.preventDefault();
redo();
}
};
root.addEventListener("keydown", handler);
return () => root.removeEventListener("keydown", handler);
}, [options.enableShortcuts, options.rootRef, undo, redo]);
return { canUndo, canRedo, undo, redo, clearHistory };
}
src/registry/components/data/card-tree/lib/bulk-actions.ts
/**
* Pure helpers for bulk operations: deep-clone with fresh ids, deepest-first
* sort for safe bulk-removal, parent-key generation for moved cards.
*/
import type { FlatFieldValue } from "../types";
import type { ParsedCardTree } from "./parse";
/**
* Returns a depth map (id → traversal-order index) so callers can sort
* card-ids deepest-first before bulk-removal.
*/
export function buildDepthMap(tree: ParsedCardTree): Map<string, number> {
const out = new Map<string, number>();
walk(tree, 0, out);
return out;
}
function walk(node: ParsedCardTree, depth: number, out: Map<string, number>): void {
out.set(node.id, depth);
for (const c of node.children) walk(c, depth + 1, out);
}
/**
* Returns true if any of `ids` is an ancestor of any other id in the same set.
* Used to deduplicate selection: when an ancestor is selected, descendants
* shouldn't be acted on independently.
*/
export function deduplicateSelection(
tree: ParsedCardTree,
ids: readonly string[],
): string[] {
const set = new Set(ids);
const out: string[] = [];
for (const id of ids) {
let hasAncestorSelected = false;
const ancestorIds = collectAncestors(tree, id);
for (const a of ancestorIds) {
if (set.has(a)) {
hasAncestorSelected = true;
break;
}
}
if (!hasAncestorSelected) out.push(id);
}
return out;
}
function collectAncestors(tree: ParsedCardTree, id: string): string[] {
const out: string[] = [];
walkAncestors(tree, id, [], out);
return out;
}
function walkAncestors(
node: ParsedCardTree,
targetId: string,
path: string[],
out: string[],
): boolean {
if (node.id === targetId) {
out.push(...path);
return true;
}
for (const c of node.children) {
if (walkAncestors(c, targetId, [...path, node.id], out)) return true;
}
return false;
}
/* ───────── selection stats (for bulk-toolbar display) ───────── */
export type SelectionStats = {
count: number;
levelMin: number;
levelMax: number;
totalFields: number;
totalPredefined: number;
totalChildren: number;
lockedCount: number;
};
export function computeSelectionStats(
tree: ParsedCardTree,
ids: ReadonlySet<string>,
): SelectionStats {
const stats: SelectionStats = {
count: 0,
levelMin: Infinity,
levelMax: 0,
totalFields: 0,
totalPredefined: 0,
totalChildren: 0,
lockedCount: 0,
};
walkStats(tree, ids, stats);
if (stats.levelMin === Infinity) stats.levelMin = 0;
return stats;
}
function walkStats(
node: ParsedCardTree,
ids: ReadonlySet<string>,
stats: SelectionStats,
): void {
if (ids.has(node.id)) {
stats.count += 1;
stats.levelMin = Math.min(stats.levelMin, node.level);
stats.levelMax = Math.max(stats.levelMax, node.level);
stats.totalFields += node.fields.length;
stats.totalPredefined += node.predefined.length;
stats.totalChildren += node.children.length;
if (node.meta?.locked === true) stats.lockedCount += 1;
}
for (const c of node.children) walkStats(c, ids, stats);
}
/* ───────── bulk-edit field application ───────── */
export type BulkEditFieldArgs = {
cardIds: readonly string[];
key: string;
value: FlatFieldValue;
/**
* 'set' — set the field on every card (creates if missing)
* 'unset' — remove the field from every card if present
*/
mode: "set" | "unset";
};
src/registry/components/data/card-tree/lib/classify-key.ts
/**
* Pure key router. Decides which bucket a JSON property belongs in:
* - 'reserved' — a __rc* key handled out-of-band (skipped before this is called)
* - 'predefined' — a known predefined-key (codearea/image/table/quote/list) not opted-out
* - 'custom' — a host-registered custom predefined-key (v0.6, `customPredefinedKeys`)
* - 'field' — a flat scalar field (or a predefined-key the developer opted out of)
* - 'child' — a child card (parser will reject arrays here, per Q-P4)
*
* PRECEDENCE (v0.6 — load-bearing, see plan-v0.3 §9 + the 0.5.0 "custom keys are
* inert" report): reserved → built-in → **custom** → scalar → object/array.
*
* Custom keys are matched by NAME, before the value is inspected at all. That is
* what makes an array-valued custom block possible: the `child` route rejects
* arrays (Q-P4, correct for ordinary children), so a value-shape-first router
* could never reach a registered array key. Q-P4 is untouched for non-registered
* keys.
*
* Built-ins win over custom registrations; `resolveCustomKeys` drops colliding
* names at mount, so by the time a name reaches here the two sets are disjoint.
*/
import {
PREDEFINED_KEYS,
RESERVED_KEYS,
type PredefinedKey,
} from "../types";
export type KeyClassification =
| "reserved"
| "predefined"
| "custom"
| "field"
| "child";
export function classifyKey(
key: string,
value: unknown,
disabledPredefinedKeys: readonly PredefinedKey[],
/** Resolved custom-key names (see `resolveCustomKeys`). Empty = v0.5 behavior. */
customKeyNames: readonly string[] = [],
): KeyClassification {
if ((RESERVED_KEYS as readonly string[]).includes(key)) return "reserved";
if ((PREDEFINED_KEYS as readonly string[]).includes(key)) {
const predefined = key as PredefinedKey;
if (disabledPredefinedKeys.includes(predefined)) return "field";
return "predefined";
}
// Registered custom blocks are matched by NAME, before any value inspection —
// this is the branch that unblocks array-valued blocks (Plate Value, editor.js).
if (customKeyNames.includes(key)) return "custom";
// Scalars are flat fields.
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return "field";
}
// Objects + arrays are children. The parser rejects arrays
// (per Q-P4: arrays of objects break round-trip; use object-keyed children).
if (typeof value === "object") return "child";
// undefined, function, symbol, bigint — treat as field; type-inference will null out.
return "field";
}
src/registry/components/data/card-tree/lib/custom-keys.ts
/**
* Mount-time resolution of `customPredefinedKeys` (v0.6).
*
* Registration is mount-only and last-write-loses is NOT the policy: a name that
* collides with a built-in or reserved key is rejected outright, and duplicate
* registrations keep the first. Both cases emit a diagnostic — the 0.5.0 failure
* mode this closes was silence, so every rejection must be *loud*.
*
* Pure: returns diagnostics rather than logging, so parse/classify stay
* side-effect free and the caller (card-tree.tsx) owns the single console.error.
*/
import {
PREDEFINED_KEYS,
RESERVED_KEYS,
type CustomPredefinedKey,
} from "../types";
export type CustomKeyDiagnostic = {
key: string;
code: "collides-with-predefined" | "collides-with-reserved" | "duplicate" | "empty-key";
message: string;
};
export type ResolvedCustomKeys = {
/** Accepted registrations, in registration order. */
keys: readonly CustomPredefinedKey[];
/** Accepted key names — the cheap lookup `classifyKey` takes. */
names: readonly string[];
diagnostics: readonly CustomKeyDiagnostic[];
};
/**
* Shared no-registrations result. Frozen (arrays included) because it is handed
* out by reference to every caller — an in-place push by future code would leak
* one tree's registrations into every other CardTree on the page.
*/
const EMPTY: ResolvedCustomKeys = Object.freeze({
keys: Object.freeze([]),
names: Object.freeze([]),
diagnostics: Object.freeze([]),
});
export function resolveCustomKeys(
registered: readonly CustomPredefinedKey[] | undefined,
): ResolvedCustomKeys {
if (!registered || registered.length === 0) return EMPTY;
const keys: CustomPredefinedKey[] = [];
const names: string[] = [];
const diagnostics: CustomKeyDiagnostic[] = [];
const seen = new Set<string>();
for (const entry of registered) {
const key = entry?.key;
if (typeof key !== "string" || key.trim().length === 0) {
diagnostics.push({
key: String(key),
code: "empty-key",
message: "custom predefined-key registered with an empty name; dropped",
});
continue;
}
if ((RESERVED_KEYS as readonly string[]).includes(key)) {
diagnostics.push({
key,
code: "collides-with-reserved",
message: `custom predefined-key "${key}" collides with a reserved key (${RESERVED_KEYS.join(", ")}); dropped`,
});
continue;
}
if ((PREDEFINED_KEYS as readonly string[]).includes(key)) {
diagnostics.push({
key,
code: "collides-with-predefined",
message: `custom predefined-key "${key}" collides with a built-in predefined key (${PREDEFINED_KEYS.join(", ")}); dropped — the built-in wins`,
});
continue;
}
if (seen.has(key)) {
diagnostics.push({
key,
code: "duplicate",
message: `custom predefined-key "${key}" registered more than once; keeping the first registration`,
});
continue;
}
seen.add(key);
keys.push(entry);
names.push(key);
}
return { keys, names, diagnostics };
}
/** Find a resolved registration by name. Linear scan — registries are tiny. */
export function findCustomKey(
keys: readonly CustomPredefinedKey[],
name: string,
): CustomPredefinedKey | undefined {
return keys.find((k) => k.key === name);
}
src/registry/components/data/card-tree/lib/dnd-helpers.ts
/**
* Pure helpers for drag-drop math.
* Auto-scroll utilities also live here.
*/
import { findAncestorIds, type ParsedCardTree } from "./reducer";
export type DropPosition = {
parentId: string;
order: number;
/** Whether the drop is "into" the card (last child) or "between" siblings (specific order). */
kind: "into" | "between";
};
/**
* Determine if dragging `sourceId` onto `targetId` would create a cycle.
* (Target is the source itself OR a descendant of source.)
*/
export function wouldCreateCycle(
tree: ParsedCardTree,
sourceId: string,
targetId: string,
): boolean {
if (sourceId === targetId) return true;
const ancestorsOfTarget = findAncestorIds(tree, targetId);
// If source is an ancestor of target, dropping target into source's subtree (or onto source) is fine;
// but dropping source INTO target's subtree (where target is source's descendant) creates a cycle.
// So we need to check: is target a descendant of source?
return ancestorsOfTarget.includes(sourceId);
}
/**
* Resolve drop intent based on pointer position within a target card's bounding rectangle.
* Same-level reorder when in upper/lower 25%; cross-level reparent when in middle 50%.
*/
export function resolveDropIntent(args: {
pointerY: number;
rectTop: number;
rectHeight: number;
upperZoneRatio?: number;
lowerZoneRatio?: number;
}): "before" | "after" | "into" {
const upper = args.upperZoneRatio ?? 0.25;
const lower = args.lowerZoneRatio ?? 0.75;
const relative = (args.pointerY - args.rectTop) / args.rectHeight;
if (relative < upper) return "before";
if (relative > lower) return "after";
return "into";
}
/**
* Returns the auto-scroll velocity for a pointer near the edges of a scrollable container.
* Returns 0 when not in the auto-scroll zone.
*
* Velocity scales linearly within the trigger zone (top/bottom 60px by default).
*/
export function autoScrollVelocity(args: {
pointerY: number;
containerTop: number;
containerBottom: number;
triggerZone?: number;
maxVelocity?: number;
}): number {
const zone = args.triggerZone ?? 60;
const maxV = args.maxVelocity ?? 12;
const distFromTop = args.pointerY - args.containerTop;
const distFromBottom = args.containerBottom - args.pointerY;
if (distFromTop < zone && distFromTop >= 0) {
// Negative velocity = scroll up
return -maxV * (1 - distFromTop / zone);
}
if (distFromBottom < zone && distFromBottom >= 0) {
return maxV * (1 - distFromBottom / zone);
}
return 0;
}
/**
* Animation-frame-driven scroll loop. Call to start; call the returned cleanup to stop.
*/
export function startAutoScroll(
el: HTMLElement,
getVelocity: () => number,
): () => void {
let raf = 0;
const tick = () => {
const v = getVelocity();
if (v !== 0) {
el.scrollBy({ top: v });
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}
src/registry/components/data/card-tree/lib/infer-type.ts
/**
* Pure scalar type inference for flat fields.
*
* v0.1 supports five field types:
* string | number | boolean | date | null
*
* `date` is a string-subtype: an ISO-8601 string that ALSO parses to a real Date.
* The dual check (regex + Date.parse) rules out false positives like "2026-12-99".
*/
export type FlatFieldType = "string" | "number" | "boolean" | "date" | "null";
export type DateDetection =
| "auto"
| "never"
| ((value: string) => boolean);
const ISO_8601 =
/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/;
/**
* Returns the resolved `FlatFieldType` for a JSON-scalar value, or `null`
* if the value isn't a supported scalar (caller should error).
*/
export function inferFlatFieldType(
value: unknown,
dateDetection: DateDetection,
): FlatFieldType | null {
if (value === null) return "null";
if (typeof value === "boolean") return "boolean";
if (typeof value === "number") return Number.isFinite(value) ? "number" : "string";
if (typeof value === "string") return inferStringType(value, dateDetection);
return null;
}
function inferStringType(
value: string,
dateDetection: DateDetection,
): FlatFieldType {
if (dateDetection === "never") return "string";
if (typeof dateDetection === "function") {
return dateDetection(value) ? "date" : "string";
}
// 'auto': pattern match + real-date validation
if (ISO_8601.test(value) && !Number.isNaN(Date.parse(value))) return "date";
return "string";
}
src/registry/components/data/card-tree/lib/parse.ts
/**
* Pure parser: input `CardTreeJsonNode` → normalized internal `ParsedCardTree`.
*
* Errors are collected into an array (not thrown), so the caller can render
* a best-effort tree alongside diagnostics.
*
* See plan §5.1 for the full algorithm.
*/
import type {
CodeAreaValue,
CustomPredefinedKey,
FlatFieldValue,
ImageValue,
ListValue,
PredefinedKey,
QuoteValue,
CardTreeJsonNode,
TableValue,
} from "../types";
import { classifyKey } from "./classify-key";
import { findCustomKey } from "./custom-keys";
import {
inferFlatFieldType,
type DateDetection,
type FlatFieldType,
} from "./infer-type";
/* ───────── internal tree types ───────── */
export type CardTreeField = {
key: string;
value: FlatFieldValue;
type: FlatFieldType;
};
/**
* A host-registered custom block (v0.6). `value` is deliberately `unknown` and is
* written back by `serialize` VERBATIM — a custom block's shape is the
* integrator's contract, not card-tree's.
*
* NARROWING CONTRACT: `key` is an open `string`, so a bare `switch (entry.key)`
* would keep this variant alive in every `case`. Always test `isCustomEntry(entry)`
* (or `"custom" in entry`) FIRST; the remaining union is then the five built-ins
* and `switch` narrows as it always did.
*/
export type CardTreeCustomEntry = {
key: string;
custom: true;
value: unknown;
};
export type CardTreePredefinedEntry =
| { key: "codearea"; value: CodeAreaValue }
| { key: "image"; value: ImageValue }
| { key: "table"; value: TableValue }
| { key: "quote"; value: QuoteValue }
| { key: "list"; value: ListValue }
| CardTreeCustomEntry;
/** Narrowing guard for {@link CardTreeCustomEntry} — see its narrowing contract. */
export function isCustomEntry(
entry: CardTreePredefinedEntry,
): entry is CardTreeCustomEntry {
return "custom" in entry && entry.custom === true;
}
export type ParsedCardTree = {
id: string;
order: number;
level: number;
/** Property name on the parent that pointed to this card. Undefined only on root. */
parentKey?: string;
meta?: Record<string, FlatFieldValue>;
fields: CardTreeField[];
predefined: CardTreePredefinedEntry[];
children: ParsedCardTree[];
};
export type ParseError = {
message: string;
path: string;
};
export type ParseOptions = {
disabledPredefinedKeys: readonly PredefinedKey[];
dateDetection: DateDetection;
/**
* Resolved custom-key registrations (v0.6) — pass the `keys` from
* `resolveCustomKeys`, never the raw prop: colliding + duplicate names must
* already be dropped. Omit for v0.5 behavior.
*/
customKeys?: readonly CustomPredefinedKey[];
};
export type ParseResult = {
tree: ParsedCardTree | null;
errors: ParseError[];
};
/* ───────── helpers ───────── */
/**
* Deterministic auto-id from a card's tree path, for cards that don't supply an
* `__rcid`. MUST be deterministic (not a random UUID): parse runs at render-time
* state-init, so an SSR pass and the client hydration pass both parse the same
* input tree in the same order — a random id would differ between them and
* trip a hydration mismatch (`data-rcid` / `aria-labelledby`). Path is unique
* per node, so these ids are collision-free within a tree. Runtime edits mint
* random ids elsewhere (client-only, post-hydration — safe).
*/
function autoId(path: string): string {
const safe = path.replace(/[^a-zA-Z0-9_-]/g, "-");
return `rc-auto-${safe || "root"}`;
}
function isPlainObject(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function pathJoin(parent: string, segment: string | number): string {
return parent === "" ? String(segment) : `${parent}.${segment}`;
}
/* ───────── meta parsing ───────── */
function parseMeta(
raw: unknown,
errors: ParseError[],
path: string,
): Record<string, FlatFieldValue> | undefined {
if (raw === undefined) return undefined;
if (!isPlainObject(raw)) {
errors.push({
message: "__rcmeta must be an object of scalar values; ignored",
path,
});
return undefined;
}
const out: Record<string, FlatFieldValue> = {};
for (const k of Object.keys(raw)) {
const v = raw[k];
if (
v === null ||
typeof v === "string" ||
typeof v === "number" ||
typeof v === "boolean"
) {
out[k] = v as FlatFieldValue;
} else {
errors.push({
message: `__rcmeta.${k} must be a scalar; dropped`,
path,
});
}
}
return out;
}
/* ───────── predefined-key shape validation ───────── */
function validatePredefinedShape(
key: PredefinedKey,
value: unknown,
errors: ParseError[],
path: string,
): CardTreePredefinedEntry | null {
switch (key) {
case "codearea": {
if (
isPlainObject(value) &&
typeof value.format === "string" &&
typeof value.content === "string"
) {
return {
key: "codearea",
value: { format: value.format, content: value.content },
};
}
errors.push({
message:
"codearea must be { format: string, content: string }; entry dropped",
path,
});
return null;
}
case "image": {
if (
isPlainObject(value) &&
typeof value.src === "string" &&
(value.alt === undefined || typeof value.alt === "string")
) {
return {
key: "image",
value: { src: value.src, alt: value.alt as string | undefined },
};
}
errors.push({
message: "image must be { src: string, alt?: string }; entry dropped",
path,
});
return null;
}
case "table": {
if (
isPlainObject(value) &&
Array.isArray(value.headers) &&
value.headers.every((h) => typeof h === "string") &&
Array.isArray(value.rows) &&
value.rows.every(
(row) =>
Array.isArray(row) &&
row.length === (value.headers as string[]).length &&
row.every(
(c) =>
c === null ||
typeof c === "string" ||
typeof c === "number" ||
typeof c === "boolean",
),
)
) {
return {
key: "table",
value: {
headers: value.headers as string[],
rows: value.rows as FlatFieldValue[][],
},
};
}
errors.push({
message:
"table must be { headers: string[], rows: FlatFieldValue[][] } with consistent row widths; entry dropped",
path,
});
return null;
}
case "quote": {
if (typeof value === "string") return { key: "quote", value };
errors.push({ message: "quote must be a string; entry dropped", path });
return null;
}
case "list": {
if (
Array.isArray(value) &&
value.every(
(v) =>
v === null ||
typeof v === "string" ||
typeof v === "number" ||
typeof v === "boolean",
)
) {
return { key: "list", value: value as FlatFieldValue[] };
}
errors.push({
message: "list must be an array of scalar values; entry dropped",
path,
});
return null;
}
}
}
/* ───────── core recursion ───────── */
/**
* `ParseOptions` plus the custom-key NAME list, derived once per `parseInput`
* rather than per node — `classifyKey` runs on every property of every card.
*/
type InternalParseOptions = ParseOptions & {
customNames: readonly string[];
};
function parseNode(
node: unknown,
parentKey: string | undefined,
level: number,
opts: InternalParseOptions,
errors: ParseError[],
path: string,
seenIds: Set<string>,
): ParsedCardTree | null {
if (!isPlainObject(node)) {
errors.push({
message: "expected an object; found " + (Array.isArray(node) ? "array" : typeof node),
path,
});
return null;
}
// identity
let id =
typeof node.__rcid === "string" && node.__rcid.length > 0
? node.__rcid
: autoId(path);
if (seenIds.has(id)) {
errors.push({
message: `duplicate __rcid "${id}"; regenerated`,
path,
});
// Path is unique, so the path-derived id deterministically resolves the
// collision (and stays SSR-stable).
id = autoId(path);
}
seenIds.add(id);
let order = 0;
if (node.__rcorder !== undefined) {
if (typeof node.__rcorder === "number" && Number.isFinite(node.__rcorder)) {
order = node.__rcorder;
} else {
errors.push({
message: `non-numeric __rcorder coerced to 0`,
path,
});
}
}
const meta = parseMeta(node.__rcmeta, errors, path);
const fields: CardTreeField[] = [];
const predefined: CardTreePredefinedEntry[] = [];
const children: ParsedCardTree[] = [];
const seenSiblingKeys = new Set<string>();
for (const key of Object.keys(node)) {
if ((["__rcid", "__rcorder", "__rcmeta"] as const).includes(key as never))
continue;
if (seenSiblingKeys.has(key)) {
errors.push({ message: `duplicate sibling key "${key}"; ignored`, path });
continue;
}
seenSiblingKeys.add(key);
const value = node[key];
const childPath = pathJoin(path, key);
const classification = classifyKey(
key,
value,
opts.disabledPredefinedKeys,
opts.customNames,
);
switch (classification) {
case "reserved":
// unreachable in this loop (reserved keys filtered above)
break;
case "predefined": {
const validated = validatePredefinedShape(
key as PredefinedKey,
value,
errors,
childPath,
);
if (validated) predefined.push(validated);
break;
}
case "custom": {
const registration = opts.customKeys
? findCustomKey(opts.customKeys, key)
: undefined;
if (!registration) {
// classifyKey said "custom" but the registration is gone — only
// reachable if customNames and customKeys were built from different
// sources. Treat as a field rather than losing the value silently.
errors.push({
message: `custom predefined-key "${key}" is classified but has no registration; entry dropped`,
path: childPath,
});
break;
}
// Host code runs here. plan-v0.3 §L881: a throwing validator must
// degrade (drop + warn), never take the tree down.
let result: { ok: boolean; errors?: { code: string; message: string }[] };
try {
result = registration.validate(value);
} catch {
errors.push({
message: `custom predefined-key "${key}" validator threw; entry dropped`,
path: childPath,
});
break;
}
if (!result || result.ok !== true) {
const detail = (result?.errors ?? [])
.map((e) => `${e.code}: ${e.message}`)
.join("; ");
errors.push({
message: `custom predefined-key "${key}" failed validation${detail ? ` (${detail})` : ""}; entry dropped`,
path: childPath,
});
break;
}
// Stored verbatim — serialize writes `entry.value` straight back out,
// so the round-trip is byte-identical for any JSON shape (I2).
predefined.push({ key, custom: true, value });
break;
}
case "field": {
const type = inferFlatFieldType(value, opts.dateDetection);
if (type === null) {
errors.push({
message: `unsupported flat-field value type for key "${key}" (got ${typeof value}); ignored`,
path: childPath,
});
} else {
fields.push({ key, value: value as FlatFieldValue, type });
}
break;
}
case "child": {
if (Array.isArray(value)) {
errors.push({
message: `array values are not supported as children in v0.1 (key "${key}"). Use object-keyed children, or the \`list\` predefined key for scalar arrays.`,
path: childPath,
});
break;
}
const child = parseNode(
value,
key,
level + 1,
opts,
errors,
childPath,
seenIds,
);
if (child) children.push(child);
break;
}
}
}
// Stable sort children by order; tie-break by input order (Array.prototype.sort
// is stable in all modern engines, including V8 since 7.0).
children.sort((a, b) => a.order - b.order);
return {
id,
order,
level,
parentKey,
meta,
fields,
predefined,
children,
};
}
/* ───────── public entry point ───────── */
export function parseInput(
input: CardTreeJsonNode,
opts: ParseOptions,
): ParseResult {
const errors: ParseError[] = [];
const seenIds = new Set<string>();
const internal: InternalParseOptions = {
...opts,
customNames: opts.customKeys?.map((k) => k.key) ?? [],
};
const tree = parseNode(input, undefined, 1, internal, errors, "", seenIds);
return { tree, errors };
}
src/registry/components/data/card-tree/lib/permissions.ts
/**
* Pure permission resolver.
*
* Resolution order (most → least specific):
* 1. Predicate (canEditField, canAddCard, etc.) — overrides everything
* 2. Per-card meta lock (`__rcmeta.locked === true`) — read-only cascade
* 3. Per-card override (permissions.byCard[id])
* 4. Per-predefined-key override (permissions.byPredefinedKey[key])
* 5. Per-field-type override (permissions.byFieldType[type])
* 6. Per-level override (permissions.byLevel[level])
* 7. Default (permissions.default)
* 8. Global `editable` prop — false denies everything
*/
import type {
EffectivePermissions,
PermissionDenialReason,
PermissionRule,
CardTreePermissions,
} from "../types";
import type { FlatFieldType } from "./infer-type";
import type { ParsedCardTree } from "./reducer";
export type PermissionAction = "edit" | "add" | "remove" | "reorder" | "reparent";
export type PermissionTarget = {
cardId: string;
level: number;
/** "card" → the card itself; "field" → flat field with optional `key`; "predefined" → predefined entry with `key`. */
kind: "card" | "field" | "predefined";
key?: string;
/** For field-targeted actions, the field's inferred type. */
fieldType?: FlatFieldType;
};
export type PermissionContext = {
editable: boolean;
permissions?: CardTreePermissions;
metaLockedIds: ReadonlySet<string>;
ancestorMap: Map<string, readonly string[]>;
};
/* ───────── locked-cards computation (cascading) ───────── */
export function computeLockedIds(tree: ParsedCardTree): Set<string> {
const out = new Set<string>();
walkLocked(tree, false, out);
return out;
}
function walkLocked(node: ParsedCardTree, ancestorLocked: boolean, out: Set<string>): void {
const selfLocked = node.meta?.locked === true;
const isLocked = ancestorLocked || selfLocked;
if (isLocked) out.add(node.id);
for (const child of node.children) walkLocked(child, isLocked, out);
}
/* ───────── ancestor map (for permission inheritance) ───────── */
export function computeAncestorMap(tree: ParsedCardTree): Map<string, readonly string[]> {
const out = new Map<string, readonly string[]>();
walkAncestor(tree, [], out);
return out;
}
function walkAncestor(
node: ParsedCardTree,
path: string[],
out: Map<string, readonly string[]>,
): void {
out.set(node.id, path);
for (const c of node.children) walkAncestor(c, [...path, node.id], out);
}
/* ───────── effective permissions resolver ───────── */
const ALL_DENIED: EffectivePermissions = {
edit: false,
add: false,
remove: false,
reorder: false,
reparent: false,
};
const ALL_ALLOWED_DEFAULTS: PermissionRule = {
edit: true,
add: true,
remove: true,
reorder: true,
reparent: true,
};
export function resolveEffectivePermissions(
ctx: PermissionContext,
target: PermissionTarget,
): EffectivePermissions {
// 1. Global editable=false → deny all
if (!ctx.editable) {
return { ...ALL_DENIED, reason: "global-editable-false" };
}
// 2. Meta-lock cascade
if (ctx.metaLockedIds.has(target.cardId)) {
return { ...ALL_DENIED, reason: "meta-locked" };
}
// Build resolved rule by layering, most-specific overrides win
let rule: PermissionRule = { ...ALL_ALLOWED_DEFAULTS };
let reason: PermissionDenialReason | undefined;
// 7. Default
if (ctx.permissions?.default) {
rule = { ...rule, ...ctx.permissions.default };
reason = "default";
}
// 6. Per-level
if (ctx.permissions?.byLevel) {
const levelRule = ctx.permissions.byLevel[target.level];
if (levelRule) {
rule = { ...rule, ...levelRule };
reason = "by-level";
}
}
// 6.5 Inheritance — when permissions.inherit is true, walk ancestors and OR-combine "denied" flags
if (ctx.permissions?.inherit && ctx.permissions.byCard) {
const ancestors = ctx.ancestorMap.get(target.cardId) ?? [];
for (const ancestorId of ancestors) {
const ancestorRule = ctx.permissions.byCard[ancestorId];
if (ancestorRule) {
rule = { ...rule, ...maskDenials(rule, ancestorRule) };
}
}
}
// 5. Per-field-type (only applicable for field-targeted actions)
if (
ctx.permissions?.byFieldType &&
target.kind === "field" &&
target.fieldType
) {
const fieldTypeRule = ctx.permissions.byFieldType[target.fieldType];
if (fieldTypeRule) {
rule = { ...rule, ...fieldTypeRule };
reason = "by-field-type";
}
}
// 4. Per-predefined-key
if (
ctx.permissions?.byPredefinedKey &&
target.kind === "predefined" &&
target.key
) {
const keyRule = (
ctx.permissions.byPredefinedKey as Record<string, PermissionRule>
)[target.key];
if (keyRule) {
rule = { ...rule, ...keyRule };
reason = "by-predefined-key";
}
}
// 3. Per-card
if (ctx.permissions?.byCard) {
const cardRule = ctx.permissions.byCard[target.cardId];
if (cardRule) {
rule = { ...rule, ...cardRule };
reason = "by-card";
}
}
return { ...rule, reason };
}
/** OR-combine denials: if either rule denies, denied. Used for inheritance. */
function maskDenials(base: PermissionRule, ancestor: PermissionRule): PermissionRule {
const out: PermissionRule = {};
for (const key of Object.keys(ancestor) as (keyof PermissionRule)[]) {
if (ancestor[key] === false) out[key] = false;
else if (base[key] !== undefined) out[key] = base[key];
}
return out;
}
/* ───────── per-action helpers (used by predicates) ───────── */
export function canDoAction(
ctx: PermissionContext,
action: PermissionAction,
target: PermissionTarget,
): { allowed: boolean; reason?: PermissionDenialReason } {
const eff = resolveEffectivePermissions(ctx, target);
const allowed = eff[action] !== false;
return { allowed, reason: allowed ? undefined : eff.reason };
}
src/registry/components/data/card-tree/lib/reducer.ts
/**
* Pure reducer for card-tree state.
*
* v0.1 actions: toggle-collapse, set-focus, replace-tree
* v0.2 actions: 12 (selection, mark-clean, field/card/predefined edit/add/remove)
* v0.3 actions: 12 (multi-select, drag-transient, card move/duplicate, meta edit/add/remove,
* search query/active-match/clear, expand-path-to-matches)
*
* Validation runs OUTSIDE the reducer; the dispatch wrapper validates before dispatch.
*/
import type { FlatFieldValue, PredefinedKey } from "../types";
import type { FlatFieldType } from "./infer-type";
import type { CardTreePredefinedEntry, ParsedCardTree } from "./parse";
export type { CardTreeField, CardTreePredefinedEntry, ParsedCardTree } from "./parse";
/* ───────── state ───────── */
/** v0.4 — undo snapshot. Captures tree + UI state that "undo" should restore. */
export type UndoSnapshot = {
tree: ParsedCardTree;
collapsed: ReadonlySet<string>;
selectedIds: ReadonlySet<string>;
focusedId: string | null;
};
export type CardTreeState = {
tree: ParsedCardTree;
collapsed: ReadonlySet<string>;
/** v0.3 — preserves user-driven collapse separately from search-auto-expand. */
userCollapsed: ReadonlySet<string>;
focusedId: string | null;
/** v0.2 — single id retained for backward compat; in v0.3 derived from selectedIds[0]. */
selectedId: string | null;
/** v0.3 — multi-select set. */
selectedIds: ReadonlySet<string>;
/** v0.3 — anchor for shift-click range selection. */
selectionAnchorId: string | null;
/** v0.3 — id being dragged (transient). */
draggingId: string | null;
/** v0.3 — current search query (empty string = inactive). */
searchQuery: string;
/** v0.3 — index into search-result.matches for the currently active match. */
searchActiveIndex: number | null;
/** v0.4 — undo / redo stacks (most recent at end). */
undoStack: ReadonlyArray<UndoSnapshot>;
redoStack: ReadonlyArray<UndoSnapshot>;
/** v0.4 — configurable history depth. */
maxUndoDepth: number;
version: number;
cleanVersion: number;
};
/* ───────── actions ───────── */
export type CardTreeAction =
// v0.1
| { type: "toggle-collapse"; id: string }
| { type: "set-focus"; id: string | null }
| { type: "replace-tree"; tree: ParsedCardTree }
// v0.2 selection + dirty
| { type: "set-selection"; id: string | null }
| { type: "mark-clean" }
// v0.2 flat fields
| {
type: "field-edit-value";
cardId: string;
key: string;
value: FlatFieldValue;
valueType: FlatFieldType;
}
| { type: "field-edit-key"; cardId: string; oldKey: string; newKey: string }
| {
type: "field-add";
cardId: string;
key: string;
value: FlatFieldValue;
valueType: FlatFieldType;
}
| { type: "field-remove"; cardId: string; key: string }
// v0.2 cards
| { type: "card-add"; parentId: string; card: ParsedCardTree }
| {
type: "card-remove";
cardId: string;
policy?: "cascade" | "promote";
collisionStrategy?: "suffix" | "qualify" | "reject";
}
| { type: "card-rename"; cardId: string; newParentKey: string }
// v0.2 predefined
| { type: "predefined-add"; cardId: string; entry: CardTreePredefinedEntry }
| {
type: "predefined-edit";
cardId: string;
key: PredefinedKey | string;
entry: CardTreePredefinedEntry;
}
| { type: "predefined-remove"; cardId: string; key: PredefinedKey | string }
// v0.3 — multi-select
| { type: "set-multi-selection"; ids: readonly string[]; anchor?: string | null }
| { type: "toggle-selection"; id: string }
| { type: "extend-selection-to"; id: string }
| { type: "clear-selection" }
// v0.3 — drag-transient
| { type: "drag-start"; cardId: string }
| { type: "drag-end" }
// v0.3 — card movement + duplicate
| {
type: "card-move";
cardId: string;
newParentId: string;
newOrder: number;
}
| { type: "card-duplicate"; cardId: string; newCardId: string }
// v0.3 — meta
| {
type: "meta-edit";
cardId: string;
key: string;
value: FlatFieldValue;
}
| {
type: "meta-add";
cardId: string;
key: string;
value: FlatFieldValue;
}
| { type: "meta-remove"; cardId: string; key: string }
// v0.3 — bulk
| { type: "bulk-remove"; cardIds: readonly string[] }
// v0.3 — search
| { type: "set-search-query"; query: string }
| { type: "set-active-match-index"; index: number | null }
| { type: "clear-search" }
| { type: "expand-path-to-matches"; ancestorIds: readonly string[] }
// v0.4 — undo/redo
| { type: "undo" }
| { type: "redo" }
| { type: "clear-history" }
| { type: "set-max-undo-depth"; depth: number };
/* ───────── initial-state factory ───────── */
export type DefaultCollapsed = "all" | "none" | ((level: number) => boolean);
function collectCollapsedIds(
tree: ParsedCardTree,
predicate: DefaultCollapsed,
out: Set<string>,
): void {
const shouldCollapse =
predicate === "all"
? true
: predicate === "none"
? false
: predicate(tree.level);
if (shouldCollapse) out.add(tree.id);
for (const child of tree.children) {
collectCollapsedIds(child, predicate, out);
}
}
export function createInitialState(
tree: ParsedCardTree,
defaultCollapsed: DefaultCollapsed,
maxUndoDepth = 50,
): CardTreeState {
const collapsed = new Set<string>();
collectCollapsedIds(tree, defaultCollapsed, collapsed);
return {
tree,
collapsed,
userCollapsed: collapsed,
focusedId: null,
selectedId: null,
selectedIds: new Set<string>(),
selectionAnchorId: null,
draggingId: null,
searchQuery: "",
searchActiveIndex: null,
undoStack: [],
redoStack: [],
maxUndoDepth,
version: 0,
cleanVersion: 0,
};
}
/* ───────── immutable tree helpers ───────── */
function updateNode(
tree: ParsedCardTree,
id: string,
fn: (node: ParsedCardTree) => ParsedCardTree,
): ParsedCardTree {
if (tree.id === id) return fn(tree);
if (tree.children.length === 0) return tree;
let changed = false;
const next = tree.children.map((c) => {
const n = updateNode(c, id, fn);
if (n !== c) changed = true;
return n;
});
return changed ? { ...tree, children: next } : tree;
}
function removeNodeById(
tree: ParsedCardTree,
id: string,
): { tree: ParsedCardTree; removed: ParsedCardTree | null; parentId: string | null } {
const directIdx = tree.children.findIndex((c) => c.id === id);
if (directIdx >= 0) {
const removed = tree.children[directIdx];
return {
tree: {
...tree,
children: tree.children.filter((_, i) => i !== directIdx),
},
removed,
parentId: tree.id,
};
}
for (let i = 0; i < tree.children.length; i++) {
const sub = removeNodeById(tree.children[i], id);
if (sub.removed) {
const newChildren = [...tree.children];
newChildren[i] = sub.tree;
return {
tree: { ...tree, children: newChildren },
removed: sub.removed,
parentId: sub.parentId,
};
}
}
return { tree, removed: null, parentId: null };
}
function setLevelDeep(node: ParsedCardTree, level: number): ParsedCardTree {
const next: ParsedCardTree = { ...node, level };
if (node.children.length === 0) return next;
next.children = node.children.map((c) => setLevelDeep(c, level + 1));
return next;
}
/**
* Duplicating a card must not leave the copy sharing a mutable value with the
* original. A spread only copies the entry wrapper, so `value` stayed shared by
* reference — latent for `table.rows`, and much likelier from v0.6 on, where a
* custom block's value is arbitrary host-owned JSON (a Plate Value array, say)
* and some editors mutate in place before calling `onSave`. That would corrupt
* the source card while editing its duplicate.
*
* Values are JSON by contract, so a structural clone is valid; anything
* non-cloneable (a stray function on a host value) falls back to the old
* shallow behavior rather than throwing.
*/
function cloneEntryValue<T>(value: T): T {
try {
return typeof structuredClone === "function"
? structuredClone(value)
: (JSON.parse(JSON.stringify(value)) as T);
} catch {
return value;
}
}
function deepCloneWithFreshIds(node: ParsedCardTree, freshId: () => string): ParsedCardTree {
return {
...node,
id: freshId(),
fields: node.fields.map((f) => ({ ...f })),
predefined: node.predefined.map((p) => ({
...p,
value: cloneEntryValue(p.value),
})) as ParsedCardTree["predefined"],
meta: node.meta ? { ...node.meta } : undefined,
children: node.children.map((c) => deepCloneWithFreshIds(c, freshId)),
};
}
function freshIdGen(): () => string {
if (
typeof globalThis.crypto !== "undefined" &&
typeof globalThis.crypto.randomUUID === "function"
) {
return () => globalThis.crypto.randomUUID();
}
let i = 0;
return () => `rc-${Date.now().toString(36)}-${(i++).toString(36)}`;
}
/* ───────── promote-on-delete ───────── */
function resolveCollision(
candidateKey: string,
existingKeys: Set<string>,
strategy: "suffix" | "qualify" | "reject",
qualifyPrefix: string,
): string | null {
if (!existingKeys.has(candidateKey)) return candidateKey;
if (strategy === "reject") return null;
if (strategy === "qualify") {
let qualified = `${qualifyPrefix}_${candidateKey}`;
let i = 2;
while (existingKeys.has(qualified)) {
qualified = `${qualifyPrefix}_${candidateKey}_${i++}`;
}
return qualified;
}
// suffix _2 / _3 / ...
let i = 2;
while (existingKeys.has(`${candidateKey}_${i}`)) i++;
return `${candidateKey}_${i}`;
}
/* ───────── reducer ───────── */
/** Take a snapshot of state slots that undo restores. */
function snapshot(state: CardTreeState): UndoSnapshot {
return {
tree: state.tree,
collapsed: state.collapsed,
selectedIds: state.selectedIds,
focusedId: state.focusedId,
};
}
/** After a commit-action, push undo snapshot + clear redo + trim. */
function withUndoBookkeeping(
prev: CardTreeState,
next: CardTreeState,
): CardTreeState {
if (next.version === prev.version) return next; // not a commit
const undoStack = [...prev.undoStack, snapshot(prev)];
while (undoStack.length > prev.maxUndoDepth) {
undoStack.shift();
}
return { ...next, undoStack, redoStack: [] };
}
export function reducer(
state: CardTreeState,
action: CardTreeAction,
): CardTreeState {
// v0.4 actions handled before delegating to innerReducer
if (action.type === "undo") {
if (state.undoStack.length === 0) return state;
const snap = state.undoStack[state.undoStack.length - 1];
const newUndoStack = state.undoStack.slice(0, -1);
const newRedoStack = [...state.redoStack, snapshot(state)];
return {
...state,
tree: snap.tree,
collapsed: snap.collapsed,
selectedIds: snap.selectedIds,
selectedId:
snap.selectedIds.size > 0
? Array.from(snap.selectedIds)[0] ?? null
: null,
focusedId: snap.focusedId,
undoStack: newUndoStack,
redoStack: newRedoStack,
};
}
if (action.type === "redo") {
if (state.redoStack.length === 0) return state;
const snap = state.redoStack[state.redoStack.length - 1];
const newRedoStack = state.redoStack.slice(0, -1);
const newUndoStack = [...state.undoStack, snapshot(state)];
return {
...state,
tree: snap.tree,
collapsed: snap.collapsed,
selectedIds: snap.selectedIds,
selectedId:
snap.selectedIds.size > 0
? Array.from(snap.selectedIds)[0] ?? null
: null,
focusedId: snap.focusedId,
undoStack: newUndoStack,
redoStack: newRedoStack,
};
}
if (action.type === "clear-history") {
if (state.undoStack.length === 0 && state.redoStack.length === 0) return state;
return { ...state, undoStack: [], redoStack: [] };
}
if (action.type === "set-max-undo-depth") {
if (state.maxUndoDepth === action.depth) return state;
const undoStack =
state.undoStack.length > action.depth
? state.undoStack.slice(state.undoStack.length - action.depth)
: state.undoStack;
return { ...state, maxUndoDepth: action.depth, undoStack };
}
// All other actions → run through innerReducer + bookkeeping
const next = innerReducer(state, action);
return withUndoBookkeeping(state, next);
}
/** v0.1–v0.3 reducer logic, unchanged. */
function innerReducer(
state: CardTreeState,
action: CardTreeAction,
): CardTreeState {
switch (action.type) {
/* v0.1 */
case "toggle-collapse": {
const next = new Set(state.collapsed);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
// Mirror to userCollapsed when search is inactive (so user-driven collapse persists across search clear)
const userNext = state.searchQuery === "" ? next : state.userCollapsed;
return { ...state, collapsed: next, userCollapsed: userNext };
}
case "set-focus": {
if (state.focusedId === action.id) return state;
return { ...state, focusedId: action.id };
}
case "replace-tree": {
return createInitialState(action.tree, "none");
}
/* v0.2 selection + dirty */
case "set-selection": {
if (state.selectedId === action.id) return state;
const ids = action.id ? new Set<string>([action.id]) : new Set<string>();
return {
...state,
selectedId: action.id,
selectedIds: ids,
selectionAnchorId: action.id,
};
}
case "mark-clean": {
if (state.cleanVersion === state.version) return state;
return { ...state, cleanVersion: state.version };
}
/* v0.2 flat-field actions */
case "field-edit-value": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
fields: card.fields.map((f) =>
f.key === action.key
? { ...f, value: action.value, type: action.valueType }
: f,
),
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
case "field-edit-key": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
fields: card.fields.map((f) =>
f.key === action.oldKey ? { ...f, key: action.newKey } : f,
),
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
case "field-add": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
fields: [
...card.fields,
{ key: action.key, value: action.value, type: action.valueType },
],
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
case "field-remove": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
fields: card.fields.filter((f) => f.key !== action.key),
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
/* v0.2 card actions */
case "card-add": {
const tree = updateNode(state.tree, action.parentId, (parent) => {
const child = setLevelDeep(action.card, parent.level + 1);
const next = [...parent.children, child].sort(
(a, b) => a.order - b.order,
);
return { ...parent, children: next };
});
if (tree === state.tree) return state;
const collapsed = new Set(state.collapsed);
collapsed.delete(action.parentId);
return { ...state, tree, collapsed, version: state.version + 1 };
}
case "card-remove": {
const policy = action.policy ?? "cascade";
const collisionStrategy = action.collisionStrategy ?? "suffix";
// Find the card and its parent BEFORE removal
const target = findCard(state.tree, action.cardId);
if (!target) return state;
// Promote first if requested
let workingTree = state.tree;
if (policy === "promote" && target.children.length > 0) {
const parentId = findParentId(state.tree, action.cardId);
if (parentId === null) {
// Root; can't promote — fall back to cascade
} else {
// Compute promoted children with collision resolution
const parent = findCard(state.tree, parentId)!;
const existingKeys = new Set<string>([
...parent.children
.filter((c) => c.id !== action.cardId)
.map((c) => c.parentKey ?? "")
.filter((k) => k.length > 0),
...parent.fields.map((f) => f.key),
]);
const promoted: ParsedCardTree[] = [];
for (const child of target.children) {
const candidate = child.parentKey ?? `child_${child.id.slice(0, 8)}`;
const resolved = resolveCollision(
candidate,
existingKeys,
collisionStrategy,
target.parentKey ?? "promoted",
);
if (resolved === null) {
// reject mode: refuse this promotion
return state;
}
existingKeys.add(resolved);
promoted.push({ ...child, parentKey: resolved, level: parent.level + 1 });
}
workingTree = updateNode(state.tree, parentId, (p) => ({
...p,
children: [
...p.children.filter((c) => c.id !== action.cardId),
...promoted.map((c) => setLevelDeep(c, p.level + 1)),
].sort((a, b) => a.order - b.order),
}));
const removedIds = new Set<string>([action.cardId]);
// Note: descendants' IDs are preserved; only the removed card's id is dirty.
const collapsed = new Set(state.collapsed);
removedIds.forEach((id) => collapsed.delete(id));
const focusedId = removedIds.has(state.focusedId ?? "")
? null
: state.focusedId;
const selectedIds = new Set(
[...state.selectedIds].filter((id) => !removedIds.has(id)),
);
return {
...state,
tree: workingTree,
collapsed,
focusedId,
selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,
selectedIds,
version: state.version + 1,
};
}
}
// Cascade
const result = removeNodeById(state.tree, action.cardId);
if (!result.removed) return state;
const removedIds = new Set<string>();
collectIds(result.removed, removedIds);
const collapsed = new Set(state.collapsed);
removedIds.forEach((id) => collapsed.delete(id));
const focusedId = removedIds.has(state.focusedId ?? "")
? null
: state.focusedId;
const selectedIds = new Set(
[...state.selectedIds].filter((id) => !removedIds.has(id)),
);
return {
...state,
tree: result.tree,
collapsed,
focusedId,
selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,
selectedIds,
version: state.version + 1,
};
}
case "card-rename": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
parentKey: action.newParentKey,
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
/* v0.2 predefined */
case "predefined-add": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
predefined: [...card.predefined, action.entry],
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
case "predefined-edit": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
predefined: card.predefined.map((p) =>
p.key === action.key ? action.entry : p,
),
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
case "predefined-remove": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
predefined: card.predefined.filter((p) => p.key !== action.key),
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
/* v0.3 — multi-select */
case "set-multi-selection": {
const ids = new Set(action.ids);
const anchor =
action.anchor !== undefined
? action.anchor
: action.ids.length > 0
? action.ids[action.ids.length - 1]
: null;
return {
...state,
selectedIds: ids,
selectedId: ids.size > 0 ? [...ids][0] ?? null : null,
selectionAnchorId: anchor,
};
}
case "toggle-selection": {
const next = new Set(state.selectedIds);
if (next.has(action.id)) next.delete(action.id);
else next.add(action.id);
return {
...state,
selectedIds: next,
selectedId: next.size > 0 ? [...next][0] ?? null : null,
selectionAnchorId: action.id,
};
}
case "extend-selection-to": {
// Range-select from anchor to action.id along visible-traversal order.
const anchor = state.selectionAnchorId ?? action.id;
const order = visibleIdsInOrder(state.tree, state.collapsed);
const a = order.indexOf(anchor);
const b = order.indexOf(action.id);
if (a < 0 || b < 0) return state;
const [lo, hi] = a <= b ? [a, b] : [b, a];
const range = order.slice(lo, hi + 1);
const next = new Set([...state.selectedIds, ...range]);
return {
...state,
selectedIds: next,
selectedId: next.size > 0 ? [...next][0] ?? null : null,
};
}
case "clear-selection": {
if (state.selectedIds.size === 0) return state;
return {
...state,
selectedIds: new Set<string>(),
selectedId: null,
selectionAnchorId: null,
};
}
/* v0.3 — drag transient */
case "drag-start": {
return { ...state, draggingId: action.cardId };
}
case "drag-end": {
if (state.draggingId === null) return state;
return { ...state, draggingId: null };
}
/* v0.3 — card movement + duplicate */
case "card-move": {
// Remove the card from its current parent, then insert under newParentId at newOrder.
const result = removeNodeById(state.tree, action.cardId);
if (!result.removed) return state;
const detached = result.removed;
const inserted = updateNode(result.tree, action.newParentId, (parent) => ({
...parent,
children: [
...parent.children,
setLevelDeep({ ...detached, order: action.newOrder }, parent.level + 1),
].sort((a, b) => a.order - b.order),
}));
return { ...state, tree: inserted, version: state.version + 1 };
}
case "card-duplicate": {
const source = findCard(state.tree, action.cardId);
if (!source) return state;
const parentId = findParentId(state.tree, action.cardId);
if (parentId === null) return state; // can't duplicate root
const fresh = freshIdGen();
const clone = deepCloneWithFreshIds(source, fresh);
// Override the top-level clone id with the supplied newCardId
const cloneWithId: ParsedCardTree = {
...clone,
id: action.newCardId,
order: source.order + 0.5,
parentKey: source.parentKey ? `${source.parentKey}_copy` : "copy",
};
const tree = updateNode(state.tree, parentId, (parent) => ({
...parent,
children: [
...parent.children,
setLevelDeep(cloneWithId, parent.level + 1),
].sort((a, b) => a.order - b.order),
}));
return { ...state, tree, version: state.version + 1 };
}
/* v0.3 — meta */
case "meta-edit": {
const tree = updateNode(state.tree, action.cardId, (card) => ({
...card,
meta: { ...(card.meta ?? {}), [action.key]: action.value },
}));
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
case "meta-add": {
const tree = updateNode(state.tree, action.cardId, (card) => {
if (card.meta && action.key in card.meta) return card;
return {
...card,
meta: { ...(card.meta ?? {}), [action.key]: action.value },
};
});
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
case "meta-remove": {
const tree = updateNode(state.tree, action.cardId, (card) => {
if (!card.meta || !(action.key in card.meta)) return card;
const next = { ...card.meta };
delete next[action.key];
return { ...card, meta: Object.keys(next).length > 0 ? next : undefined };
});
if (tree === state.tree) return state;
return { ...state, tree, version: state.version + 1 };
}
/* v0.3 — bulk */
case "bulk-remove": {
// Order ids deepest-first to avoid removing an ancestor before its descendants.
const order = depthFirstDepthMap(state.tree);
const sorted = [...action.cardIds]
.filter((id) => id !== state.tree.id) // root protected
.sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0));
let workingTree = state.tree;
const allRemoved = new Set<string>();
for (const id of sorted) {
const result = removeNodeById(workingTree, id);
if (result.removed) {
collectIds(result.removed, allRemoved);
workingTree = result.tree;
}
}
if (allRemoved.size === 0) return state;
const collapsed = new Set(state.collapsed);
allRemoved.forEach((id) => collapsed.delete(id));
const focusedId = allRemoved.has(state.focusedId ?? "") ? null : state.focusedId;
const selectedIds = new Set(
[...state.selectedIds].filter((id) => !allRemoved.has(id)),
);
return {
...state,
tree: workingTree,
collapsed,
focusedId,
selectedIds,
selectedId: selectedIds.size > 0 ? [...selectedIds][0] ?? null : null,
version: state.version + 1,
};
}
/* v0.3 — search */
case "set-search-query": {
if (state.searchQuery === action.query) return state;
const wasInactive = state.searchQuery === "";
const isInactive = action.query === "";
// When transitioning to active, snapshot user-collapsed state (if not already snapshotted).
// When transitioning to inactive, restore user-collapsed state.
if (wasInactive && !isInactive) {
return {
...state,
searchQuery: action.query,
searchActiveIndex: 0,
userCollapsed: state.collapsed,
};
}
if (!wasInactive && isInactive) {
return {
...state,
searchQuery: "",
searchActiveIndex: null,
collapsed: state.userCollapsed,
};
}
return { ...state, searchQuery: action.query };
}
case "set-active-match-index": {
if (state.searchActiveIndex === action.index) return state;
return { ...state, searchActiveIndex: action.index };
}
case "clear-search": {
if (state.searchQuery === "") return state;
return {
...state,
searchQuery: "",
searchActiveIndex: null,
collapsed: state.userCollapsed,
};
}
case "expand-path-to-matches": {
// Remove these ancestor ids from collapsed (additively).
if (action.ancestorIds.length === 0) return state;
const next = new Set(state.collapsed);
let changed = false;
for (const id of action.ancestorIds) {
if (next.delete(id)) changed = true;
}
return changed ? { ...state, collapsed: next } : state;
}
// v0.4 actions are handled by the outer reducer wrapper, so they should
// never reach innerReducer. Listed here for exhaustiveness.
case "undo":
case "redo":
case "clear-history":
case "set-max-undo-depth":
return state;
}
}
/* ───────── traversal helpers ───────── */
function collectIds(node: ParsedCardTree, out: Set<string>): void {
out.add(node.id);
for (const c of node.children) collectIds(c, out);
}
/** Depth-first-traversal order map (id → traversal index). Used for deepest-first sort. */
function depthFirstDepthMap(tree: ParsedCardTree): Map<string, number> {
const out = new Map<string, number>();
walkDepth(tree, 0, out);
return out;
}
function walkDepth(node: ParsedCardTree, depth: number, out: Map<string, number>): void {
out.set(node.id, depth);
for (const c of node.children) walkDepth(c, depth + 1, out);
}
export function visibleIdsInOrder(
tree: ParsedCardTree,
collapsed: ReadonlySet<string>,
): string[] {
const out: string[] = [];
walkVisible(tree, collapsed, out);
return out;
}
function walkVisible(
tree: ParsedCardTree,
collapsed: ReadonlySet<string>,
out: string[],
): void {
out.push(tree.id);
if (collapsed.has(tree.id)) return;
for (const child of tree.children) walkVisible(child, collapsed, out);
}
export function findParentId(tree: ParsedCardTree, id: string): string | null {
if (tree.id === id) return null;
for (const child of tree.children) {
if (child.id === id) return tree.id;
const inSubtree = findParentId(child, id);
if (inSubtree) return inSubtree;
}
return null;
}
export function findCard(tree: ParsedCardTree, id: string): ParsedCardTree | null {
if (tree.id === id) return tree;
for (const child of tree.children) {
const found = findCard(child, id);
if (found) return found;
}
return null;
}
/** Returns the chain of ancestor ids from root → parent (excluding the card itself). */
export function findAncestorIds(tree: ParsedCardTree, id: string): string[] {
const out: string[] = [];
walkAncestors(tree, id, [], out);
return out;
}
function walkAncestors(
node: ParsedCardTree,
targetId: string,
path: string[],
out: string[],
): boolean {
if (node.id === targetId) {
out.push(...path);
return true;
}
for (const c of node.children) {
if (walkAncestors(c, targetId, [...path, node.id], out)) return true;
}
return false;
}
src/registry/components/data/card-tree/lib/search.ts
/**
* Pure data-model search.
*
* Walks `ParsedCardTree` directly — finds matches in collapsed subtrees and
* meta entries. No DOM dependence.
*/
import type {
CustomPredefinedKey,
FlatFieldValue,
SearchMatch,
SearchOptions,
SearchResult,
} from "../types";
import { isCustomEntry } from "./parse";
import type { CardTreePredefinedEntry, ParsedCardTree } from "./parse";
const EXCERPT_RADIUS = 32; // chars on each side of the match
export type SearchContext = {
customKeys?: CustomPredefinedKey[];
};
export function searchTree(
tree: ParsedCardTree,
options: SearchOptions,
ctx: SearchContext = {},
): SearchResult {
const matches: SearchMatch[] = [];
if (!options.query || options.query.length === 0) {
return { matches, matchedCardIds: new Set(), activeIndex: null };
}
const flags = {
matchTitles: options.matchTitles ?? true,
matchKeys: options.matchKeys ?? true,
matchValues: options.matchValues ?? true,
matchPredefined: options.matchPredefined ?? true,
matchMeta: options.matchMeta ?? true,
};
walk(tree, options.query, options.caseSensitive ?? false, flags, ctx, matches);
const matchedCardIds = new Set<string>();
for (const m of matches) matchedCardIds.add(m.cardId);
return {
matches,
matchedCardIds,
activeIndex: matches.length > 0 ? 0 : null,
};
}
type Flags = {
matchTitles: boolean;
matchKeys: boolean;
matchValues: boolean;
matchPredefined: boolean;
matchMeta: boolean;
};
function walk(
node: ParsedCardTree,
query: string,
caseSensitive: boolean,
flags: Flags,
ctx: SearchContext,
out: SearchMatch[],
): void {
if (flags.matchTitles && node.parentKey) {
addOccurrences(out, node.id, "title", undefined, node.parentKey, query, caseSensitive);
}
for (const field of node.fields) {
if (flags.matchKeys) {
addOccurrences(out, node.id, "field-key", field.key, field.key, query, caseSensitive);
}
if (flags.matchValues) {
addOccurrences(
out,
node.id,
"field-value",
field.key,
stringifyScalar(field.value),
query,
caseSensitive,
);
}
}
if (flags.matchPredefined) {
for (const entry of node.predefined) {
const text = stringifyPredefined(entry, ctx);
if (text) {
addOccurrences(out, node.id, "predefined", String(entry.key), text, query, caseSensitive);
}
}
}
if (flags.matchMeta && node.meta) {
for (const [key, value] of Object.entries(node.meta)) {
addOccurrences(out, node.id, "meta-key", key, key, query, caseSensitive);
addOccurrences(
out,
node.id,
"meta-value",
key,
stringifyScalar(value),
query,
caseSensitive,
);
}
}
for (const child of node.children) {
walk(child, query, caseSensitive, flags, ctx, out);
}
}
function stringifyScalar(v: FlatFieldValue): string {
if (v === null) return "null";
return String(v);
}
function stringifyPredefined(
entry: CardTreePredefinedEntry,
ctx: SearchContext,
): string {
// Custom entries carry an open `string` key, so they must be peeled off BEFORE
// the switch — otherwise they stay a candidate in every `case` and break the
// built-ins' narrowing (see CardTreeCustomEntry's narrowing contract).
if (isCustomEntry(entry)) {
const customKey = ctx.customKeys?.find((k) => k.key === entry.key);
if (!customKey?.searchableText) return "";
try {
return customKey.searchableText(entry.value).join(" ");
} catch {
// Host code — a throwing searchableText must not take the search down.
return "";
}
}
switch (entry.key) {
case "codearea":
return `${entry.value.format} ${entry.value.content}`;
case "image":
return `${entry.value.src} ${entry.value.alt ?? ""}`;
case "table": {
const t = entry.value;
return [
...t.headers,
...t.rows.flat().map(stringifyScalar),
].join(" ");
}
case "quote":
return entry.value;
case "list":
return entry.value.map(stringifyScalar).join(" ");
}
}
function addOccurrences(
out: SearchMatch[],
cardId: string,
matchType: SearchMatch["matchType"],
fieldKey: string | undefined,
haystack: string,
needle: string,
caseSensitive: boolean,
): void {
if (!haystack || !needle) return;
const h = caseSensitive ? haystack : haystack.toLowerCase();
const n = caseSensitive ? needle : needle.toLowerCase();
let from = 0;
while (from <= h.length) {
const idx = h.indexOf(n, from);
if (idx < 0) break;
const excerptStart = Math.max(0, idx - EXCERPT_RADIUS);
const excerptEnd = Math.min(haystack.length, idx + needle.length + EXCERPT_RADIUS);
const excerpt =
(excerptStart > 0 ? "…" : "") +
haystack.slice(excerptStart, excerptEnd) +
(excerptEnd < haystack.length ? "…" : "");
out.push({
cardId,
matchType,
fieldKey,
excerpt,
start: idx - excerptStart + (excerptStart > 0 ? 1 : 0),
length: needle.length,
});
from = idx + needle.length;
}
}
/**
* Returns the ranges within a given string where matches should be highlighted,
* filtered to a specific (cardId, matchType, fieldKey) context.
*
* Used by <MatchHighlight> to wrap matched text in <mark> elements.
*/
export function rangesFor(
matches: readonly SearchMatch[],
haystack: string,
cardId: string,
matchType: SearchMatch["matchType"],
fieldKey: string | undefined,
query: string,
caseSensitive: boolean,
): Array<{ start: number; length: number }> {
if (!query) return [];
const relevant = matches.filter(
(m) =>
m.cardId === cardId &&
m.matchType === matchType &&
m.fieldKey === fieldKey,
);
if (relevant.length === 0) return [];
// Re-run the indexOf locally to get exact positions in the haystack
// (excerpt-based positions are relative to excerpt, not haystack).
const ranges: Array<{ start: number; length: number }> = [];
const h = caseSensitive ? haystack : haystack.toLowerCase();
const n = caseSensitive ? query : query.toLowerCase();
let from = 0;
while (from <= h.length) {
const idx = h.indexOf(n, from);
if (idx < 0) break;
ranges.push({ start: idx, length: query.length });
from = idx + query.length;
}
return ranges;
}
src/registry/components/data/card-tree/lib/serialize.ts
/**
* Pure serializer: `ParsedCardTree` → canonical `CardTreeJsonNode` and JSON string.
*
* Property emission order per plan §3.1:
* 1. __rcid
* 2. __rcorder
* 3. __rcmeta
* 4. flat fields (insertion order from parse)
* 5. predefined-key elements (insertion order)
* 6. children — emitted under their stored `parentKey` in `order` ascending
*
* Round-trip property: parse(serialize(parse(x))) === parse(x). Fixed-point
* on the second round trip.
*/
import type { CardTreeJsonNode } from "../types";
import type { ParsedCardTree } from "./parse";
export function treeToJsonNode(tree: ParsedCardTree): CardTreeJsonNode {
const out: CardTreeJsonNode = {
__rcid: tree.id,
__rcorder: tree.order,
};
if (tree.meta && Object.keys(tree.meta).length > 0) {
out.__rcmeta = { ...tree.meta };
}
for (const field of tree.fields) {
out[field.key] = field.value;
}
for (const entry of tree.predefined) {
out[entry.key] = entry.value as unknown;
}
for (const child of tree.children) {
if (!child.parentKey) {
// Root has no parentKey; non-root with no parentKey is a parser bug — skip.
continue;
}
out[child.parentKey] = treeToJsonNode(child);
}
return out;
}
export function serializeTree(tree: ParsedCardTree): string {
return JSON.stringify(treeToJsonNode(tree), null, 2);
}
src/registry/components/data/card-tree/lib/validate-edit.ts
/**
* Pure synchronous validators for v0.2 edit operations.
*
* Each validator receives the current state + action params, returns either
* { ok: true } or { ok: false, errors: ValidationError[] }. The reducer calls
* these before any commit; on failure, state is unchanged and the calling
* editor surfaces errors via <InlineError>.
*
* No async hooks here — those land in v0.4.
*/
import {
PREDEFINED_KEYS,
RESERVED_KEYS,
type FlatFieldValue,
type PredefinedKey,
} from "../types";
import {
findAncestorIds,
findCard,
findParentId,
type CardTreeState,
} from "./reducer";
import type { FlatFieldType } from "./infer-type";
/* ───────── result + error types ───────── */
export type ValidationError = { code: string; message: string };
export type ValidationResult =
| { ok: true }
| { ok: false; errors: ValidationError[] };
const ok: ValidationResult = { ok: true };
const fail = (...errors: ValidationError[]): ValidationResult => ({
ok: false,
errors,
});
/* ───────── shared key checks ───────── */
function isReservedKey(key: string): boolean {
return (RESERVED_KEYS as readonly string[]).includes(key);
}
function isPredefinedKey(key: string, disabled: readonly PredefinedKey[]): boolean {
return (
(PREDEFINED_KEYS as readonly string[]).includes(key) &&
!(disabled as readonly string[]).includes(key)
);
}
/**
* v0.6 — a registered custom-key name is no more available as a flat-field or
* card name than a built-in is. Without this check the edit commits, but the
* next parse re-classifies the key as `custom`, runs the host validator against
* a scalar, and drops the value: silent data loss across one round-trip.
*/
function isCustomKeyName(key: string, customKeyNames: readonly string[]): boolean {
return customKeyNames.includes(key);
}
function checkKeyAvailable(
newKey: string,
cardId: string,
state: CardTreeState,
disabled: readonly PredefinedKey[],
excludeKey?: string,
customKeyNames: readonly string[] = [],
): ValidationError[] {
const errors: ValidationError[] = [];
if (!newKey || newKey.trim().length === 0) {
errors.push({ code: "empty-key", message: "Key cannot be empty." });
return errors;
}
if (isReservedKey(newKey)) {
errors.push({
code: "reserved-key",
message: `"${newKey}" is reserved (${RESERVED_KEYS.join(", ")}).`,
});
}
if (isPredefinedKey(newKey, disabled)) {
errors.push({
code: "predefined-key",
message: `"${newKey}" is a predefined-key name. Add it to disabledPredefinedKeys to use as a flat field.`,
});
}
if (isCustomKeyName(newKey, customKeyNames)) {
errors.push({
code: "custom-key",
message: `"${newKey}" is a registered custom predefined-key name; pick a different key.`,
});
}
const card = findCard(state.tree, cardId);
if (card) {
const fieldCollision = card.fields.some(
(f) => f.key === newKey && f.key !== excludeKey,
);
if (fieldCollision) {
errors.push({
code: "sibling-key-collision",
message: `Field "${newKey}" already exists on this card.`,
});
}
}
return errors;
}
/* ───────── flat-field validators ───────── */
export function validateFieldEditValue(
state: CardTreeState,
cardId: string,
key: string,
value: FlatFieldValue,
expectedType: FlatFieldType,
): ValidationResult {
const card = findCard(state.tree, cardId);
if (!card) return fail({ code: "no-card", message: "Card not found." });
const field = card.fields.find((f) => f.key === key);
if (!field) return fail({ code: "no-field", message: `Field "${key}" not found.` });
// Type preservation: v0.2 doesn't allow type-changing on edit (Q-P2).
// The editor passes through the inferred type; we just sanity-check it.
if (expectedType === "number" && typeof value !== "number") {
return fail({
code: "type-mismatch",
message: "Value must be a number.",
});
}
if (expectedType === "boolean" && typeof value !== "boolean") {
return fail({
code: "type-mismatch",
message: "Value must be a boolean.",
});
}
if (
(expectedType === "string" || expectedType === "date") &&
typeof value !== "string"
) {
return fail({
code: "type-mismatch",
message: "Value must be a string.",
});
}
if (expectedType === "date" && typeof value === "string") {
if (Number.isNaN(Date.parse(value))) {
return fail({
code: "invalid-date",
message: "Must be a valid ISO-8601 date string.",
});
}
}
if (expectedType === "null" && value !== null) {
return fail({
code: "type-mismatch",
message: "Null fields are read-only in v0.2.",
});
}
return ok;
}
export function validateFieldEditKey(
state: CardTreeState,
cardId: string,
oldKey: string,
newKey: string,
disabledPredefinedKeys: readonly PredefinedKey[],
customKeyNames: readonly string[] = [],
): ValidationResult {
if (oldKey === newKey) return ok;
const errors = checkKeyAvailable(
newKey,
cardId,
state,
disabledPredefinedKeys,
oldKey,
customKeyNames,
);
return errors.length === 0 ? ok : fail(...errors);
}
export function validateFieldAdd(
state: CardTreeState,
cardId: string,
key: string,
value: FlatFieldValue,
type: FlatFieldType,
disabledPredefinedKeys: readonly PredefinedKey[],
customKeyNames: readonly string[] = [],
): ValidationResult {
const keyErrors = checkKeyAvailable(
key,
cardId,
state,
disabledPredefinedKeys,
undefined,
customKeyNames,
);
if (keyErrors.length > 0) return fail(...keyErrors);
if (type === "null") {
return fail({
code: "no-null-add",
message: "Adding null fields is not supported in v0.2.",
});
}
// delegate the value/type sanity check to validateFieldEditValue logic minus card/field lookup
if (type === "number" && typeof value !== "number")
return fail({ code: "type-mismatch", message: "Value must be a number." });
if (type === "boolean" && typeof value !== "boolean")
return fail({ code: "type-mismatch", message: "Value must be a boolean." });
if ((type === "string" || type === "date") && typeof value !== "string")
return fail({ code: "type-mismatch", message: "Value must be a string." });
if (type === "date" && typeof value === "string" && Number.isNaN(Date.parse(value)))
return fail({
code: "invalid-date",
message: "Must be a valid ISO-8601 date string.",
});
return ok;
}
/* ───────── card validators ───────── */
export function validateCardRename(
state: CardTreeState,
cardId: string,
newKey: string,
disabledPredefinedKeys: readonly PredefinedKey[],
customKeyNames: readonly string[] = [],
): ValidationResult {
if (!newKey || newKey.trim().length === 0) {
return fail({ code: "empty-key", message: "Card name cannot be empty." });
}
if (isReservedKey(newKey)) {
return fail({
code: "reserved-key",
message: `"${newKey}" is a reserved key.`,
});
}
if (isPredefinedKey(newKey, disabledPredefinedKeys)) {
return fail({
code: "predefined-key",
message: `"${newKey}" is a predefined-key name; pick a different card name.`,
});
}
if (isCustomKeyName(newKey, customKeyNames)) {
return fail({
code: "custom-key",
message: `"${newKey}" is a registered custom predefined-key name; pick a different card name.`,
});
}
// Sibling-key collision: find this card's parent + check siblings (excluding self)
const parentId = findParentIdLocal(state.tree, cardId);
if (parentId === null) return ok; // root has no siblings
const parent = findCard(state.tree, parentId);
if (!parent) return ok;
const collision = parent.children.some(
(c) => c.id !== cardId && c.parentKey === newKey,
);
if (collision) {
return fail({
code: "sibling-collision",
message: `Sibling "${newKey}" already exists on the parent.`,
});
}
// Also collide with any flat field on the parent (since on serialize they share namespace)
const fieldCollision = parent.fields.some((f) => f.key === newKey);
if (fieldCollision) {
return fail({
code: "sibling-collision",
message: `Parent already has a field named "${newKey}".`,
});
}
return ok;
}
function findParentIdLocal(
tree: { id: string; children: { id: string }[] } & {
[key: string]: unknown;
},
childId: string,
): string | null {
for (const c of tree.children) {
if (c.id === childId) return tree.id;
}
for (const c of tree.children) {
// @ts-expect-error recurse into ParsedCardTree shape
const inner = findParentIdLocal(c, childId);
if (inner) return inner;
}
return null;
}
export function validateCardRemove(
state: CardTreeState,
cardId: string,
): ValidationResult {
if (state.tree.id === cardId) {
return fail({
code: "no-root-remove",
message: "Removing the root card is not supported in v0.2.",
});
}
if (!findCard(state.tree, cardId)) {
return fail({ code: "no-card", message: "Card not found." });
}
return ok;
}
/* ───────── predefined-key validators ───────── */
export function validatePredefinedShape(
key: PredefinedKey,
value: unknown,
): ValidationResult {
switch (key) {
case "codearea": {
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
typeof (value as Record<string, unknown>).format === "string" &&
typeof (value as Record<string, unknown>).content === "string"
)
return ok;
return fail({
code: "shape-mismatch",
message: "codearea must be { format: string, content: string }.",
});
}
case "image": {
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
typeof (value as Record<string, unknown>).src === "string"
) {
const alt = (value as Record<string, unknown>).alt;
if (alt === undefined || typeof alt === "string") return ok;
}
return fail({
code: "shape-mismatch",
message: "image must be { src: string, alt?: string }.",
});
}
case "table": {
if (
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
const v = value as Record<string, unknown>;
if (
Array.isArray(v.headers) &&
v.headers.every((h) => typeof h === "string") &&
Array.isArray(v.rows) &&
v.rows.every(
(row) =>
Array.isArray(row) &&
row.length === (v.headers as string[]).length &&
row.every(
(c) =>
c === null ||
typeof c === "string" ||
typeof c === "number" ||
typeof c === "boolean",
),
)
)
return ok;
}
return fail({
code: "shape-mismatch",
message:
"table must be { headers: string[], rows: scalar[][] } with consistent row widths.",
});
}
case "quote": {
if (typeof value === "string") return ok;
return fail({
code: "shape-mismatch",
message: "quote must be a string.",
});
}
case "list": {
if (
Array.isArray(value) &&
value.every(
(v) =>
v === null ||
typeof v === "string" ||
typeof v === "number" ||
typeof v === "boolean",
)
)
return ok;
return fail({
code: "shape-mismatch",
message: "list must be an array of scalar values.",
});
}
}
}
/* ───────── v0.3: meta validators ───────── */
export function validateMetaEdit(
state: CardTreeState,
cardId: string,
key: string,
value: FlatFieldValue,
): ValidationResult {
const card = findCard(state.tree, cardId);
if (!card) return fail({ code: "no-card", message: "Card not found." });
if (!card.meta || !(key in card.meta)) {
return fail({ code: "no-meta-key", message: `Meta key "${key}" not found.` });
}
if (
value !== null &&
typeof value !== "string" &&
typeof value !== "number" &&
typeof value !== "boolean"
) {
return fail({
code: "invalid-meta-value",
message: "Meta values must be scalars (string / number / boolean / null).",
});
}
return ok;
}
export function validateMetaAdd(
state: CardTreeState,
cardId: string,
key: string,
value: FlatFieldValue,
): ValidationResult {
if (!key || key.trim().length === 0) {
return fail({ code: "empty-key", message: "Meta key cannot be empty." });
}
if (isReservedKey(key)) {
return fail({
code: "reserved-key",
message: `"${key}" is a reserved key.`,
});
}
const card = findCard(state.tree, cardId);
if (!card) return fail({ code: "no-card", message: "Card not found." });
if (card.meta && key in card.meta) {
return fail({
code: "meta-key-collision",
message: `Meta key "${key}" already exists on this card.`,
});
}
if (
value !== null &&
typeof value !== "string" &&
typeof value !== "number" &&
typeof value !== "boolean"
) {
return fail({
code: "invalid-meta-value",
message: "Meta values must be scalars.",
});
}
return ok;
}
/* ───────── v0.3: card-move validator ───────── */
export function validateCardMove(
state: CardTreeState,
cardId: string,
newParentId: string,
): ValidationResult {
if (cardId === newParentId) {
return fail({
code: "self-parent",
message: "Cannot drop a card onto itself.",
});
}
if (!findCard(state.tree, cardId)) {
return fail({ code: "no-card", message: "Card not found." });
}
if (!findCard(state.tree, newParentId)) {
return fail({ code: "no-parent", message: "Target parent not found." });
}
// Cycle detection: target cannot be a descendant of source
const targetAncestors = findAncestorIds(state.tree, newParentId);
if (targetAncestors.includes(cardId)) {
return fail({
code: "cycle",
message: "Cannot drop a card into its own descendant (would create a cycle).",
});
}
// Prevent moving root
if (state.tree.id === cardId) {
return fail({
code: "no-root-move",
message: "Cannot move the root card.",
});
}
// Suppress unused parent-id helper warning
void findParentId;
return ok;
}
src/registry/components/data/card-tree/lib/validators.ts
/**
* Pure validation pipeline runner (v0.4).
*
* Three layers, each must pass before commit:
* 1. Built-in validators — already run by individual dispatchers (v0.2)
* 2. Per-action validators — typed payloads, host-supplied
* 3. Master validator — catch-all, host-supplied
*
* Async hooks are deferred indefinitely (Q14).
*/
import type {
CardAddedEvent,
CardDuplicatedEvent,
CardMovedEvent,
CardRemovedEvent,
CardRenamedEvent,
FieldAddedEvent,
FieldEditedEvent,
FieldRemovedEvent,
MetaAddedEvent,
MetaChangedEvent,
MetaRemovedEvent,
PredefinedAddedEvent,
PredefinedEditedEvent,
PredefinedRemovedEvent,
CardTreeJsonNode,
CardTreeMasterValidator,
CardTreeValidationError,
CardTreeValidationResponse,
CardTreeValidators,
ValidationFailedEvent,
} from "../types";
/** All event payloads passed to per-action validators. */
export type AnyChangeEvent =
| FieldEditedEvent
| FieldAddedEvent
| FieldRemovedEvent
| CardAddedEvent
| CardRemovedEvent
| CardRenamedEvent
| CardMovedEvent
| CardDuplicatedEvent
| PredefinedAddedEvent
| PredefinedEditedEvent
| PredefinedRemovedEvent
| MetaChangedEvent
| MetaAddedEvent
| MetaRemovedEvent;
export type ValidatorKey = keyof CardTreeValidators;
/**
* Run host-supplied validators (per-action + master) against the proposed event.
* Returns ok or the first failing layer's errors.
*/
export function runHostValidators(
validatorKey: ValidatorKey,
event: AnyChangeEvent,
actionType: string,
cardId: string | undefined,
tree: CardTreeJsonNode,
validators: CardTreeValidators | undefined,
master: CardTreeMasterValidator | undefined,
):
| { ok: true }
| {
ok: false;
errors: CardTreeValidationError[];
layer: "per-action" | "master";
} {
// Per-action layer
if (validators) {
const fn = validators[validatorKey];
if (fn) {
const result = safeCall(() =>
(fn as (e: AnyChangeEvent, t: CardTreeJsonNode) => CardTreeValidationResponse)(
event,
tree,
),
);
if (!result.ok) {
return { ok: false, errors: result.errors, layer: "per-action" };
}
}
}
// Master layer
if (master) {
const result = safeCall(() => master({ type: actionType, cardId }, tree));
if (!result.ok) {
return { ok: false, errors: result.errors, layer: "master" };
}
}
return { ok: true };
}
function safeCall(
fn: () => CardTreeValidationResponse,
): CardTreeValidationResponse {
try {
const r = fn();
if (!r || (r.ok !== true && r.ok !== false)) {
return {
ok: false,
errors: [
{
code: "validator-bad-return",
message: "Validator must return { ok: true } or { ok: false, errors: [...] }.",
},
],
};
}
return r;
} catch (err) {
return {
ok: false,
errors: [
{
code: "validator-error",
message: err instanceof Error ? err.message : String(err),
},
],
};
}
}
/** Build the typed event for `onValidationFailed` consumers. */
export function buildValidationFailedEvent(
actionType: string,
cardId: string | undefined,
errors: CardTreeValidationError[],
layer: "per-action" | "master",
): ValidationFailedEvent {
return { action: actionType, cardId, errors, layer };
}
src/registry/components/data/card-tree/parts/bulk-toolbar.tsx
"use client";
import { useState } from "react";
import { Copy, Edit3, Lock, Trash2, X } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { FlatFieldValue } from "../types";
import type { FlatFieldType } from "../lib/infer-type";
/**
* Floating toolbar visible when ≥2 cards are selected. Shows count + bulk actions.
*/
export function BulkToolbar({
count,
levelMin,
levelMax,
totalFields,
lockedCount,
onDelete,
onDuplicate,
onClear,
onBulkSetField,
onToggleLock,
className,
}: {
count: number;
levelMin: number;
levelMax: number;
totalFields: number;
lockedCount: number;
onDelete: () => void;
onDuplicate: () => void;
onClear: () => void;
onBulkSetField: (key: string, value: FlatFieldValue, type: FlatFieldType) => void;
onToggleLock: () => void;
className?: string;
}) {
if (count < 2) return null;
return (
<div
role="toolbar"
aria-label="Bulk actions"
className={cn(
"sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-md",
className,
)}
>
<span className="font-mono text-xs text-foreground/80">
<strong>{count}</strong> cards selected
</span>
<span className="font-mono text-[10px] text-muted-foreground">
· L{levelMin}–L{levelMax} · {totalFields} fields{lockedCount > 0 ? ` · ${lockedCount} locked` : ""}
</span>
<div className="ml-auto flex flex-wrap items-center gap-1">
<BulkSetFieldPopover onCommit={onBulkSetField} />
<button
type="button"
onClick={onToggleLock}
className="inline-flex items-center gap-1 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"
aria-label="Toggle lock on selected cards"
>
<Lock className="size-3" aria-hidden="true" />
toggle lock
</button>
<button
type="button"
onClick={onDuplicate}
className="inline-flex items-center gap-1 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" />
duplicate
</button>
<button
type="button"
onClick={onDelete}
className="inline-flex items-center gap-1 rounded-md border border-destructive/40 bg-destructive/5 px-2 py-1 font-mono text-[11px] text-destructive transition-colors hover:bg-destructive/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Trash2 className="size-3" aria-hidden="true" />
delete
</button>
<button
type="button"
onClick={onClear}
aria-label="Clear selection"
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3.5" aria-hidden="true" />
</button>
</div>
</div>
);
}
function BulkSetFieldPopover({
onCommit,
}: {
onCommit: (key: string, value: FlatFieldValue, type: FlatFieldType) => void;
}) {
const [open, setOpen] = useState(false);
const [key, setKey] = useState("");
const [type, setType] = useState<FlatFieldType>("string");
const [value, setValue] = useState<string>("");
const submit = () => {
if (!key.trim()) return;
let parsedValue: FlatFieldValue;
switch (type) {
case "number":
parsedValue = Number(value) || 0;
break;
case "boolean":
parsedValue = value === "true";
break;
case "null":
parsedValue = null;
break;
case "string":
case "date":
default:
parsedValue = value;
}
onCommit(key, parsedValue, type);
setKey("");
setValue("");
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className="inline-flex items-center gap-1 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"
aria-label="Bulk set field across selection"
>
<Edit3 className="size-3" aria-hidden="true" />
set field
</PopoverTrigger>
<PopoverContent align="end" sideOffset={4} className="w-72 p-3 space-y-2">
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
Set field on all selected
</p>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="key"
className="block w-full rounded-sm border border-border bg-background px-2 py-1 font-mono text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<select
value={type}
onChange={(e) => setType(e.target.value as FlatFieldType)}
className="block w-full rounded-sm border border-border bg-background px-2 py-1 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="date">date</option>
</select>
<input
type={type === "boolean" ? "text" : type === "number" ? "number" : "text"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={type === "boolean" ? "true / false" : "value"}
className="block w-full rounded-sm border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="button"
onClick={submit}
disabled={!key.trim()}
className="block w-full rounded-md bg-primary px-2 py-1 font-mono text-[11px] font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
apply to selected
</button>
</PopoverContent>
</Popover>
);
}
src/registry/components/data/card-tree/parts/card-actions.tsx
"use client";
import { Copy, MoreHorizontal, Plus, Trash2, TreePine } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
/**
* Compact card-level actions menu for edit mode (... button in header).
* v0.3 actions: remove (cascade or promote), duplicate. Root-only when allowed.
*/
export function CardActionsMenu({
onRemoveCascade,
onRemovePromote,
onDuplicate,
canRemove,
canDuplicate,
isRoot,
allowRootRemoval,
hasChildren,
className,
}: {
onRemoveCascade: () => void;
onRemovePromote?: () => void;
onDuplicate?: () => void;
canRemove: boolean;
canDuplicate: boolean;
isRoot: boolean;
allowRootRemoval: boolean;
hasChildren: boolean;
className?: string;
}) {
const removeAllowed = canRemove && (!isRoot || allowRootRemoval);
return (
<Popover>
<PopoverTrigger
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
aria-label="Card actions"
>
<MoreHorizontal className="size-3.5" />
</PopoverTrigger>
<PopoverContent align="end" sideOffset={4} className="w-52 p-1">
{onDuplicate ? (
<button
type="button"
onClick={onDuplicate}
disabled={!canDuplicate || isRoot}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:bg-muted"
>
<Copy className="size-3.5" aria-hidden="true" />
Duplicate
</button>
) : null}
{hasChildren && onRemovePromote ? (
<button
type="button"
onClick={onRemovePromote}
disabled={!removeAllowed}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:bg-muted"
>
<TreePine className="size-3.5" aria-hidden="true" />
Remove (promote children)
</button>
) : null}
<button
type="button"
onClick={onRemoveCascade}
disabled={!removeAllowed}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-40 focus-visible:outline-none focus-visible:bg-destructive/10"
>
<Trash2 className="size-3.5" aria-hidden="true" />
Remove{hasChildren ? " (cascade)" : ""}
</button>
{!removeAllowed ? (
<p className="px-2 py-1 text-[11px] text-muted-foreground">
{isRoot && !allowRootRemoval
? "Root removal disabled (allowRootRemoval=false)."
: "Removal denied by permissions."}
</p>
) : null}
</PopoverContent>
</Popover>
);
}
export function AddChildButton({
onClick,
disabled,
}: {
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex items-center gap-1 rounded-md border border-dashed border-border/70 bg-transparent px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "cursor-not-allowed opacity-40",
)}
>
<Plus className="size-3" aria-hidden="true" />
child
</button>
);
}
src/registry/components/data/card-tree/parts/card-header.tsx
import { Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer, SearchMatch } from "../types";
import type { ParsedCardTree } from "../lib/parse";
import type { EditMode } from "../hooks/use-edit-mode";
import { rangesFor } from "../lib/search";
import { ChevronRight } from "lucide-react";
import { MetaInline } from "./meta-inline";
import { MetaPopover } from "./meta-popover";
import { CardTitleEdit } from "./card-title-edit";
import { CardActionsMenu } from "./card-actions";
import { DragHandle } from "./drag-handle";
import { MatchHighlight } from "./match-highlight";
import type { EditDispatchers, EditValidators } from "./card";
export function CardHeader({
tree,
title,
titleId,
collapsed,
canCollapse,
meta,
metaPresentation,
metaRenderers,
onToggleCollapse,
onSelect,
editable,
canEdit,
canRemove,
canDuplicate,
canDrag,
isLocked,
isRoot,
allowRootRemoval,
hasChildren,
defaultDeletePolicy,
editMode,
setEditMode,
validators,
dispatchers,
tentativeCardId,
dragHandleListeners,
dragHandleAttributes,
searchMatches,
searchQuery,
searchCaseSensitive,
className,
}: {
tree: ParsedCardTree;
title: string | undefined;
titleId: string;
collapsed: boolean;
canCollapse: boolean;
meta?: Record<string, FlatFieldValue>;
metaPresentation: "hidden" | "inline" | "popover";
metaRenderers?: Record<string, MetaRenderer>;
onToggleCollapse: () => void;
onSelect: (event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => void;
editable: boolean;
canEdit: boolean;
canRemove: boolean;
canDuplicate: boolean;
canDrag: boolean;
isLocked: boolean;
isRoot: boolean;
allowRootRemoval: boolean;
hasChildren: boolean;
defaultDeletePolicy: "cascade" | "promote";
editMode: EditMode | null;
setEditMode: (m: EditMode | null) => void;
validators: EditValidators;
dispatchers: EditDispatchers;
tentativeCardId: string | null;
dragHandleListeners?: Record<string, (event: unknown) => void>;
dragHandleAttributes?: Record<string, unknown>;
searchMatches: readonly SearchMatch[];
searchQuery: string;
searchCaseSensitive: boolean;
className?: string;
}) {
const showInlineMeta =
metaPresentation === "inline" && meta && Object.keys(meta).length > 0;
const hasMeta = meta && Object.keys(meta).length > 0;
const isEditingTitle =
editable &&
canEdit &&
editMode?.kind === "card-title" &&
editMode.cardId === tree.id;
const canRenameTitle = editable && canEdit && !isRoot;
const titleRanges = title
? rangesFor(searchMatches, title, tree.id, "title", undefined, searchQuery, searchCaseSensitive)
: [];
return (
<div
className={cn("flex items-center gap-1.5", className)}
onClick={(e) => {
if (e.target === e.currentTarget) {
onSelect({ shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey });
}
}}
>
{/* Drag handle (edit mode only) */}
{editable && canDrag ? (
<DragHandle
listeners={dragHandleListeners}
attributes={dragHandleAttributes}
disabled={!canDrag}
/>
) : null}
{canCollapse ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleCollapse();
}}
aria-label={collapsed ? "Expand card" : "Collapse card"}
aria-expanded={!collapsed}
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight
className={cn(
"size-3.5 transition-transform duration-150",
!collapsed && "rotate-90",
)}
/>
</button>
) : (
<span aria-hidden="true" className="inline-block size-5 shrink-0" />
)}
{isLocked ? (
<Lock
className="size-3 shrink-0 text-muted-foreground/70"
aria-label="Locked"
/>
) : null}
{isEditingTitle ? (
<CardTitleEdit
initialTitle={title ?? ""}
isTentative={tentativeCardId === tree.id}
validate={(newKey) => validators.cardRename(tree.id, newKey)}
onCommit={(newKey) => dispatchers.cardRename(tree.id, newKey)}
onCancel={() => setEditMode(null)}
onCancelTentative={() => dispatchers.cardCancelTentative(tree.id)}
/>
) : (
<h3
id={titleId}
className={cn(
"min-w-0 flex-1 truncate text-sm font-semibold tracking-tight",
canRenameTitle && "cursor-text rounded-sm hover:bg-muted px-1 -mx-1",
)}
onClick={(e) => {
e.stopPropagation();
onSelect({ shiftKey: e.shiftKey, metaKey: e.metaKey, ctrlKey: e.ctrlKey });
if (canRenameTitle) {
setEditMode({ kind: "card-title", cardId: tree.id });
}
}}
>
{title ? (
<MatchHighlight text={title} ranges={titleRanges} />
) : (
<span className="text-muted-foreground italic font-normal">
Untitled
</span>
)}
</h3>
)}
{showInlineMeta && meta ? (
<MetaInline meta={meta} metaRenderers={metaRenderers} />
) : null}
{metaPresentation === "popover" && hasMeta && meta ? (
<MetaPopover
meta={meta}
cardId={tree.id}
editable={editable && canEdit}
metaRenderers={metaRenderers}
dispatchers={dispatchers}
validators={validators}
/>
) : null}
{editable ? (
<CardActionsMenu
onRemoveCascade={() => dispatchers.cardRemove(tree.id, "cascade")}
onRemovePromote={
defaultDeletePolicy === "promote" || hasChildren
? () => dispatchers.cardRemove(tree.id, "promote")
: undefined
}
onDuplicate={
!isRoot ? () => dispatchers.cardDuplicate(tree.id) : undefined
}
canRemove={canRemove}
canDuplicate={canDuplicate}
isRoot={isRoot}
allowRootRemoval={allowRootRemoval}
hasChildren={hasChildren}
/>
) : null}
</div>
);
}
src/registry/components/data/card-tree/parts/card-title-edit.tsx
import { useEffect, useRef, useState } from "react";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";
/**
* Inline editor for a card's parentKey (its visible title).
* Replaces the <h3> while in edit mode.
*/
export function CardTitleEdit({
initialTitle,
validate,
onCommit,
onCancel,
onCancelTentative,
isTentative,
}: {
initialTitle: string;
validate: (newTitle: string) => ValidationResult;
onCommit: (newTitle: string) => void;
/** Called on Escape when the card is "real" (not tentative). */
onCancel: () => void;
/** Called on Escape when the card was just-added and should be removed. */
onCancelTentative: () => void;
isTentative: boolean;
}) {
const [title, setTitle] = useState(initialTitle);
const validation = validate(title);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
const submit = () => {
if (validation.ok && title !== initialTitle) onCommit(title);
else if (title === initialTitle && !isTentative) onCancel();
};
const cancel = () => {
if (isTentative) onCancelTentative();
else onCancel();
};
return (
<span className="inline-flex flex-1 flex-wrap items-center gap-1">
<input
ref={ref}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={submit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
className="min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm font-semibold tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Edit card title"
/>
{!validation.ok ? <InlineError errors={validation.errors} /> : null}
</span>
);
}
src/registry/components/data/card-tree/parts/card.tsx
"use client";
import { type Dispatch } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import type {
CustomPredefinedKey,
FlatFieldValue,
LevelStyle,
MetaRenderer,
PredefinedKey,
SearchMatch,
} from "../types";
import {
isCustomEntry,
type CardTreePredefinedEntry,
type ParsedCardTree,
} from "../lib/parse";
import { findCustomKey } from "../lib/custom-keys";
import type {
CardTreeAction,
CardTreeState,
} from "../lib/reducer";
import type { FlatFieldType } from "../lib/infer-type";
import type { ValidationResult } from "../lib/validate-edit";
import type { EditMode } from "../hooks/use-edit-mode";
import type { UsePermissionsReturn } from "../hooks/use-permissions";
import { CardHeader } from "./card-header";
import { FieldRow } from "./field-row";
import { PredefinedCodeArea } from "./predefined-codearea";
import { PredefinedImage } from "./predefined-image";
import { PredefinedTable } from "./predefined-table";
import { PredefinedQuote } from "./predefined-quote";
import { PredefinedList } from "./predefined-list";
import { PredefinedCustom } from "./predefined-custom";
import { PredefinedEdit } from "./predefined-edit";
import { FieldAddButton, FieldAddForm } from "./field-add";
import { PredefinedAddMenu } from "./predefined-add-menu";
import { AddChildButton } from "./card-actions";
/* ───────── public config (passed from card-tree.tsx) ───────── */
export type EditDispatchers = {
fieldEditValue: (
cardId: string,
key: string,
value: FlatFieldValue,
type: FlatFieldType,
) => void;
fieldEditKey: (cardId: string, oldKey: string, newKey: string) => void;
fieldAdd: (
cardId: string,
key: string,
value: FlatFieldValue,
type: FlatFieldType,
) => void;
fieldRemove: (cardId: string, key: string) => void;
cardAdd: (parentId: string) => void;
cardRemove: (cardId: string, policy?: "cascade" | "promote") => void;
cardRename: (cardId: string, newKey: string) => void;
cardCancelTentative: (cardId: string) => void;
cardDuplicate: (cardId: string) => void;
predefinedAdd: (cardId: string, entry: CardTreePredefinedEntry) => void;
predefinedEdit: (
cardId: string,
key: PredefinedKey | string,
entry: CardTreePredefinedEntry,
) => void;
predefinedRemove: (cardId: string, key: PredefinedKey | string) => void;
metaEdit: (cardId: string, key: string, value: FlatFieldValue) => void;
metaAdd: (cardId: string, key: string, value: FlatFieldValue) => void;
metaRemove: (cardId: string, key: string) => void;
// Selection
selectCard: (
cardId: string,
event: { shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean },
) => void;
};
export type EditValidators = {
fieldEditValue: (
cardId: string,
key: string,
value: FlatFieldValue,
type: FlatFieldType,
) => ValidationResult;
fieldEditKey: (cardId: string, oldKey: string, newKey: string) => ValidationResult;
fieldAdd: (
cardId: string,
key: string,
value: FlatFieldValue,
type: FlatFieldType,
) => ValidationResult;
cardRename: (cardId: string, newKey: string) => ValidationResult;
metaEdit: (cardId: string, key: string, value: FlatFieldValue) => ValidationResult;
metaAdd: (cardId: string, key: string, value: FlatFieldValue) => ValidationResult;
};
export type CardConfig = {
resolveLevelStyle: (level: number) => LevelStyle;
predefinedKeyStyles?: Partial<Record<PredefinedKey | string, string | LevelStyle>>;
metaPresentation: "hidden" | "inline" | "popover";
metaRenderers?: Record<string, MetaRenderer>;
rootTitle?: string;
disabledPredefinedKeys: readonly PredefinedKey[];
// v0.2 editor surface
editable: boolean;
editMode: EditMode | null;
setEditMode: (mode: EditMode | null) => void;
clearEditMode: () => void;
tentativeCardId: string | null;
dispatchers: EditDispatchers;
validators: EditValidators;
// v0.3
perms: UsePermissionsReturn;
allowRootRemoval: boolean;
defaultDeletePolicy: "cascade" | "promote";
searchMatches: readonly SearchMatch[];
searchQuery: string;
searchCaseSensitive: boolean;
dndEnabled: boolean;
// v0.6 — resolved custom predefined-key registrations (see `resolveCustomKeys`).
// Always present; `[]` when the host registered none.
customKeys: readonly CustomPredefinedKey[];
};
/* ───────── helpers ───────── */
function resolvePredefinedClass(
style: string | LevelStyle | undefined,
): string | undefined {
if (!style) return undefined;
if (typeof style === "string") return style;
return style.containerClassName;
}
function PredefinedRenderer({
entry,
styleProp,
editable,
isEditing,
cardId,
level,
customKeys,
onEnterEdit,
onCommit,
onCancel,
onRemove,
}: {
entry: CardTreePredefinedEntry;
styleProp: string | LevelStyle | undefined;
editable: boolean;
isEditing: boolean;
cardId: string;
level: number;
customKeys: readonly CustomPredefinedKey[];
onEnterEdit: () => void;
onCommit: (next: CardTreePredefinedEntry) => void;
onCancel: () => void;
onRemove: () => void;
}) {
if (isEditing) {
return (
<PredefinedEdit
entry={entry}
customKeys={customKeys}
onCommit={onCommit}
onCancel={onCancel}
/>
);
}
const className = resolvePredefinedClass(styleProp);
// NARROWING CONTRACT: peel the custom variant off first — `entry.key` is an
// open `string` on `CardTreeCustomEntry`, so a bare `switch (entry.key)`
// would keep it a live candidate in every case and collapse `entry.value`
// to `unknown`. See lib/parse.ts `isCustomEntry`.
const block = isCustomEntry(entry) ? (
<PredefinedCustom
entry={entry}
registration={findCustomKey(customKeys, entry.key)}
ctx={{ cardId, level, isEditing: false, className }}
/>
) : (
(() => {
switch (entry.key) {
case "codearea":
return <PredefinedCodeArea value={entry.value} className={className} />;
case "image":
return <PredefinedImage value={entry.value} className={className} />;
case "table":
return <PredefinedTable value={entry.value} className={className} />;
case "quote":
return <PredefinedQuote value={entry.value} className={className} />;
case "list":
return <PredefinedList value={entry.value} className={className} />;
}
})()
);
if (!editable) return block;
return (
<div className="group relative">
<button
type="button"
onClick={onEnterEdit}
className="block w-full cursor-text text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-md"
aria-label={`Edit ${entry.key}`}
>
{block}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label={`Remove ${entry.key}`}
className="absolute right-1 top-1 inline-flex size-5 items-center justify-center rounded bg-card/80 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/20 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
×
</button>
</div>
);
}
/* ───────── Card (recursive, sortable) ───────── */
export function Card({
tree,
config,
state,
dispatch,
}: {
tree: ParsedCardTree;
config: CardConfig;
state: CardTreeState;
dispatch: Dispatch<CardTreeAction>;
}) {
const { perms } = config;
const collapsed = state.collapsed.has(tree.id);
const isFocused = state.focusedId === tree.id;
const isSelected = state.selectedIds.has(tree.id);
const hasChildren = tree.children.length > 0;
const hasFields = tree.fields.length > 0;
const hasPredefined = tree.predefined.length > 0;
const hasBody = hasFields || hasPredefined;
const canCollapse = hasBody || hasChildren;
const isLocked = perms.isLocked(tree.id);
const levelStyle = config.resolveLevelStyle(tree.level);
const titleId = `rcc-title-${tree.id}`;
const title =
tree.parentKey ?? (tree.level === 1 ? config.rootTitle : undefined);
const isAddingField =
config.editable &&
perms.canAddField(tree.id) &&
config.editMode?.kind === "field-add" &&
config.editMode.cardId === tree.id;
const presentPredefinedKeys = tree.predefined.map((p) => p.key);
const isRoot = tree.level === 1;
// v0.3: sortable integration
const {
attributes: sortableAttributes,
listeners: sortableListeners,
setNodeRef: sortableSetNodeRef,
transform: sortableTransform,
transition: sortableTransition,
isDragging: sortableIsDragging,
} = useSortable({
id: tree.id,
disabled:
!config.dndEnabled || !config.editable || isRoot || !perms.canDragCard(tree.id),
});
const sortableStyle = {
transform: CSS.Translate.toString(sortableTransform),
transition: sortableTransition,
opacity: sortableIsDragging ? 0.5 : 1,
};
const canEdit = perms.canEditCard(tree.id);
const canAddField = perms.canAddField(tree.id);
const canRemoveCard = perms.canRemoveCard(tree.id);
const canAddCard = perms.canAddCard(tree.id);
const canDuplicateCard = !isRoot && perms.canEditCard(tree.id);
const canDrag = !isRoot && perms.canDragCard(tree.id);
return (
<li
ref={sortableSetNodeRef}
role="treeitem"
aria-level={tree.level}
aria-expanded={canCollapse ? !collapsed : undefined}
aria-selected={isSelected}
aria-labelledby={titleId}
data-readonly={isLocked || undefined}
tabIndex={isFocused ? 0 : -1}
data-rcid={tree.id}
style={sortableStyle}
onFocus={(e) => {
if (e.target === e.currentTarget) {
dispatch({ type: "set-focus", id: tree.id });
}
}}
className={cn(
"block focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
levelStyle.containerClassName,
isSelected &&
"ring-2 ring-primary/40 ring-offset-1 ring-offset-background",
isLocked && "opacity-90",
)}
>
<CardHeader
tree={tree}
title={title}
titleId={titleId}
collapsed={collapsed}
canCollapse={canCollapse}
meta={tree.meta}
metaPresentation={config.metaPresentation}
metaRenderers={config.metaRenderers}
onToggleCollapse={() =>
dispatch({ type: "toggle-collapse", id: tree.id })
}
onSelect={(event) => config.dispatchers.selectCard(tree.id, event)}
editable={config.editable}
canEdit={canEdit}
canRemove={canRemoveCard}
canDuplicate={canDuplicateCard}
canDrag={canDrag}
isLocked={isLocked}
isRoot={isRoot}
allowRootRemoval={config.allowRootRemoval}
hasChildren={hasChildren}
defaultDeletePolicy={config.defaultDeletePolicy}
editMode={config.editMode}
setEditMode={config.setEditMode}
validators={config.validators}
dispatchers={config.dispatchers}
tentativeCardId={config.tentativeCardId}
dragHandleListeners={
sortableListeners as
| Record<string, (event: unknown) => void>
| undefined
}
dragHandleAttributes={
sortableAttributes as unknown as Record<string, unknown> | undefined
}
searchMatches={config.searchMatches}
searchQuery={config.searchQuery}
searchCaseSensitive={config.searchCaseSensitive}
className={levelStyle.headerClassName}
/>
{hasBody && !collapsed ? (
<div className={cn("mt-2 space-y-2", levelStyle.fieldsClassName)}>
{hasFields ? (
<dl className="grid gap-1">
{tree.fields.map((f) => (
<FieldRow
key={f.key}
cardId={tree.id}
fieldKey={f.key}
value={f.value}
type={f.type}
editable={config.editable}
canEdit={perms.canEditField(tree.id, f.key)}
canEditKey={perms.canEditField(tree.id, f.key)}
canRemove={perms.canRemoveField(tree.id, f.key)}
editMode={config.editMode}
setEditMode={config.setEditMode}
validators={config.validators}
dispatchers={config.dispatchers}
searchMatches={config.searchMatches}
searchQuery={config.searchQuery}
searchCaseSensitive={config.searchCaseSensitive}
/>
))}
</dl>
) : null}
{hasPredefined ? (
<div className="space-y-2">
{tree.predefined.map((entry, i) => {
const isEditing =
config.editable &&
perms.canEditPredefined(tree.id, String(entry.key)) &&
config.editMode?.kind === "predefined" &&
config.editMode.cardId === tree.id &&
config.editMode.key === entry.key;
return (
<PredefinedRenderer
key={`${entry.key}-${i}`}
entry={entry}
styleProp={config.predefinedKeyStyles?.[entry.key]}
editable={
config.editable &&
perms.canEditPredefined(tree.id, String(entry.key))
}
isEditing={isEditing}
cardId={tree.id}
level={tree.level}
customKeys={config.customKeys}
onEnterEdit={() =>
config.setEditMode({
kind: "predefined",
cardId: tree.id,
// No cast: EditMode.key is `PredefinedKey | string` as of
// v0.6 precisely so a custom key's name fits honestly.
key: entry.key,
})
}
onCommit={(next) =>
config.dispatchers.predefinedEdit(
tree.id,
entry.key,
next,
)
}
onCancel={() => config.clearEditMode()}
onRemove={() =>
config.dispatchers.predefinedRemove(tree.id, entry.key)
}
/>
);
})}
</div>
) : null}
</div>
) : null}
{/* v0.4.2 — add affordances rendered independently of `hasBody` so an
empty card (zero fields, zero predefined) can still be populated.
Pre-v0.4.2 this block sat inside the body wrapper above, making the
"+ FIELD" / "+ BLOCK" buttons unreachable for a freshly-added child
card (cardAdd creates fields:[]/predefined:[], so hasBody=false). */}
{config.editable && !collapsed ? (
<div className={cn("mt-2 flex flex-wrap items-center gap-1.5", levelStyle.fieldsClassName)}>
{isAddingField ? (
<FieldAddForm
validateAll={(key, value, type) =>
config.validators.fieldAdd(tree.id, key, value, type)
}
onCommit={(key, value, type) =>
config.dispatchers.fieldAdd(tree.id, key, value, type)
}
onCancel={() => config.clearEditMode()}
/>
) : canAddField ? (
<FieldAddButton
onClick={() =>
config.setEditMode({
kind: "field-add",
cardId: tree.id,
})
}
/>
) : null}
<PredefinedAddMenu
presentKeys={presentPredefinedKeys}
disabledKeys={config.disabledPredefinedKeys}
customKeys={config.customKeys}
onAdd={(entry) =>
config.dispatchers.predefinedAdd(tree.id, entry)
}
/>
</div>
) : null}
{hasChildren && !collapsed ? (
<ul
role="group"
className={cn(
"mt-2 space-y-1.5 ps-2 border-s border-border/40",
levelStyle.childrenClassName,
)}
>
{tree.children.map((child) => (
<Card
key={child.id}
tree={child}
config={config}
state={state}
dispatch={dispatch}
/>
))}
</ul>
) : null}
{/* "+ child" button in edit mode */}
{config.editable && canAddCard && !collapsed ? (
<div className={cn("mt-2", hasChildren && "ps-2")}>
<AddChildButton
onClick={() => config.dispatchers.cardAdd(tree.id)}
disabled={!canAddCard}
/>
</div>
) : null}
</li>
);
}
src/registry/components/data/card-tree/parts/drag-handle.tsx
import { GripVertical } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Visible drag affordance — small grip icon. Lives in card header (edit mode).
* Connects to @dnd-kit via the `dragListeners` and `dragAttributes` from useSortable
* applied at the parent (Card).
*/
export function DragHandle({
listeners,
attributes,
disabled,
className,
}: {
listeners?: Record<string, (event: unknown) => void>;
attributes?: Record<string, unknown>;
disabled?: boolean;
className?: string;
}) {
return (
<button
type="button"
{...attributes}
{...listeners}
disabled={disabled}
aria-label="Drag to reorder"
className={cn(
"inline-flex size-5 shrink-0 cursor-grab items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:cursor-grabbing focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
disabled && "cursor-not-allowed opacity-30",
className,
)}
>
<GripVertical className="size-3.5" aria-hidden="true" />
</button>
);
}
src/registry/components/data/card-tree/parts/drop-indicator.tsx
import { cn } from "@/lib/utils";
/**
* Same-level (between-siblings) drop indicator: thin horizontal line at the drop position.
*/
export function BetweenDropIndicator({
active,
className,
}: {
active: boolean;
className?: string;
}) {
return (
<div
aria-hidden="true"
className={cn(
"h-0.5 -my-px rounded-full transition-colors",
active ? "bg-primary" : "bg-transparent",
className,
)}
/>
);
}
/**
* Cross-level (into-card) drop indicator: outline highlight on the target card.
* Renders as a wrapper. The Card component conditionally applies it.
*/
export function IntoDropOutline({
active,
rejected,
className,
}: {
active: boolean;
rejected?: boolean;
className?: string;
}) {
return (
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 rounded-md ring-2 ring-offset-1 ring-offset-background transition-opacity",
active ? "opacity-100" : "opacity-0",
rejected ? "ring-destructive" : "ring-primary",
className,
)}
/>
);
}
src/registry/components/data/card-tree/parts/empty-tree.tsx
import { Plus } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Default empty-tree placeholder shown when the root has been removed
* (allowRootRemoval={true} + onRootRemoved returned null).
*
* Hosts can override this entirely via the emptyTreeRenderer prop.
*/
export function EmptyTreePlaceholder({
onAddRoot,
className,
}: {
onAddRoot?: () => void;
className?: string;
}) {
return (
<div
role="status"
className={cn(
"flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-border/70 bg-muted/20 px-4 py-8 text-center",
className,
)}
>
<p className="text-sm text-muted-foreground">Tree is empty.</p>
{onAddRoot ? (
<button
type="button"
onClick={onAddRoot}
className="inline-flex items-center gap-1.5 rounded-md border border-dashed border-border bg-card px-3 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"
>
<Plus className="size-3" aria-hidden="true" />
add root card
</button>
) : null}
</div>
);
}
src/registry/components/data/card-tree/parts/field-add.tsx
import { useEffect, useRef, useState } from "react";
import { Check, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue } from "../types";
import type { FlatFieldType } from "../lib/infer-type";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";
const ADDABLE_TYPES: FlatFieldType[] = [
"string",
"number",
"boolean",
"date",
];
function defaultValueFor(type: FlatFieldType): FlatFieldValue {
switch (type) {
case "number":
return 0;
case "boolean":
return false;
case "date":
return new Date().toISOString().slice(0, 10);
case "null":
return null;
case "string":
default:
return "";
}
}
/**
* Inline form for adding a flat field. Replaces the "+ field" button
* location while open. Press Enter to add, Escape to cancel.
*/
export function FieldAddForm({
validateAll,
onCommit,
onCancel,
}: {
validateAll: (
key: string,
value: FlatFieldValue,
type: FlatFieldType,
) => ValidationResult;
onCommit: (
key: string,
value: FlatFieldValue,
type: FlatFieldType,
) => void;
onCancel: () => void;
}) {
const [key, setKey] = useState("");
const [type, setType] = useState<FlatFieldType>("string");
const [value, setValue] = useState<FlatFieldValue>(defaultValueFor("string"));
const keyRef = useRef<HTMLInputElement>(null);
useEffect(() => {
keyRef.current?.focus();
}, []);
const validation = validateAll(key, value, type);
const submit = () => {
if (validation.ok) {
onCommit(key, value, type);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
return (
<div
className="space-y-1.5 rounded-md border border-dashed border-border/70 bg-muted/30 p-2"
onKeyDown={handleKeyDown}
>
<div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_minmax(0,auto)] items-center gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
key
</span>
<input
ref={keyRef}
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="field name"
className="rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="New field key"
/>
<select
value={type}
onChange={(e) => {
const t = e.target.value as FlatFieldType;
setType(t);
setValue(defaultValueFor(t));
}}
className="rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="New field type"
>
{ADDABLE_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
value
</span>
<ValueInputForType type={type} value={value} onChange={setValue} />
</div>
<div className="flex items-center justify-between gap-2">
{!validation.ok && key.length > 0 ? (
<InlineError errors={validation.errors} />
) : (
<span />
)}
<div className="flex items-center gap-1">
<button
type="button"
onClick={onCancel}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Cancel add field"
>
<X className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={submit}
disabled={!validation.ok}
className="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 font-mono text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Check className="size-3" aria-hidden="true" />
add
</button>
</div>
</div>
</div>
);
}
function ValueInputForType({
type,
value,
onChange,
}: {
type: FlatFieldType;
value: FlatFieldValue;
onChange: (v: FlatFieldValue) => void;
}) {
const baseClass =
"rounded-sm border border-border bg-background px-1.5 py-0.5 text-[12px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
switch (type) {
case "boolean":
return (
<label className="inline-flex items-center gap-1.5 text-sm">
<input
type="checkbox"
checked={value === true}
onChange={(e) => onChange(e.target.checked)}
className="size-4 rounded-sm"
/>
<span className="font-mono text-[11px] text-muted-foreground">
{value === true ? "true" : "false"}
</span>
</label>
);
case "number":
return (
<input
type="number"
value={typeof value === "number" ? String(value) : ""}
onChange={(e) => {
const n = Number(e.target.value);
onChange(Number.isFinite(n) ? n : 0);
}}
className={cn(baseClass, "w-32 text-right font-mono tabular-nums")}
aria-label="New field number value"
/>
);
case "date":
return (
<input
type="date"
value={typeof value === "string" ? value.slice(0, 10) : ""}
onChange={(e) => onChange(e.target.value)}
className={cn(baseClass, "min-w-32")}
aria-label="New field date value"
/>
);
case "null":
return null;
case "string":
default:
return (
<input
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder="value"
className={cn(baseClass, "min-w-32")}
aria-label="New field string value"
/>
);
}
}
/**
* Lightweight "+ field" button that triggers FieldAddForm.
*/
export function FieldAddButton({
onClick,
}: {
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex items-center gap-1 rounded-md border border-dashed border-border/70 bg-transparent px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Plus className="size-3" aria-hidden="true" />
field
</button>
);
}
src/registry/components/data/card-tree/parts/field-edit.tsx
import {
type ChangeEvent,
type KeyboardEvent,
useEffect,
useRef,
useState,
} from "react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue } from "../types";
import type { FlatFieldType } from "../lib/infer-type";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";
/* ───────── value editor (type-aware) ───────── */
export function FieldValueEdit({
initialValue,
type,
validate,
onCommit,
onCancel,
}: {
initialValue: FlatFieldValue;
type: FlatFieldType;
validate: (value: FlatFieldValue) => ValidationResult;
onCommit: (value: FlatFieldValue) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState<FlatFieldValue>(initialValue);
const validation = validate(value);
const submit = () => {
if (validation.ok) onCommit(value);
};
const handleKey = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === "Enter" && type !== "boolean") {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
return (
<span
className="inline-flex flex-wrap items-center gap-1"
onKeyDown={handleKey}
>
<ValueInput
type={type}
value={value}
onChange={(v) => {
setValue(v);
// For booleans, commit immediately on toggle (Q-P9).
if (type === "boolean" && validation.ok) {
onCommit(v);
}
}}
onBlur={() => {
// Don't commit on blur from boolean — already committed on toggle.
if (type === "boolean") return;
submit();
}}
/>
{type !== "boolean" ? (
<span className="inline-flex items-center gap-0.5">
<button
type="button"
onClick={submit}
disabled={!validation.ok}
aria-label="Save"
className="inline-flex size-5 items-center justify-center rounded text-primary transition-colors hover:bg-muted disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Check className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={onCancel}
aria-label="Cancel"
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3.5" aria-hidden="true" />
</button>
</span>
) : null}
{!validation.ok ? <InlineError errors={validation.errors} /> : null}
</span>
);
}
function ValueInput({
type,
value,
onChange,
onBlur,
}: {
type: FlatFieldType;
value: FlatFieldValue;
onChange: (v: FlatFieldValue) => void;
onBlur: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
if (ref.current && typeof ref.current.select === "function") {
try {
ref.current.select();
} catch {
/* some input types don't support select() */
}
}
}, []);
const baseClass =
"rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
switch (type) {
case "boolean":
return (
<input
ref={ref}
type="checkbox"
checked={value === true}
onChange={(e) => onChange(e.target.checked)}
onBlur={onBlur}
className="size-4 rounded-sm border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Edit boolean value"
/>
);
case "number":
return (
<input
ref={ref}
type="number"
value={value === null || typeof value !== "number" ? "" : String(value)}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value;
if (raw === "") {
onChange(0);
return;
}
const n = Number(raw);
onChange(Number.isFinite(n) ? n : 0);
}}
onBlur={onBlur}
className={cn(baseClass, "w-24 text-right font-mono tabular-nums")}
aria-label="Edit number value"
/>
);
case "date": {
const isoString = typeof value === "string" ? value : "";
const hasTime = /T\d{2}:\d{2}/.test(isoString);
const inputType = hasTime ? "datetime-local" : "date";
// datetime-local doesn't accept Z-suffix; strip for display
const inputValue = hasTime
? isoString.replace(/Z$/, "").slice(0, 16)
: isoString.slice(0, 10);
return (
<input
ref={ref}
type={inputType}
value={inputValue}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
className={cn(baseClass, "min-w-32")}
aria-label="Edit date value"
/>
);
}
case "null":
return (
<span className="text-muted-foreground italic">
(read-only — null fields cannot be edited)
</span>
);
case "string":
default:
return (
<input
ref={ref}
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
className={cn(baseClass, "min-w-32")}
aria-label="Edit string value"
/>
);
}
}
/* ───────── key editor (rename) ───────── */
export function FieldKeyEdit({
initialKey,
validate,
onCommit,
onCancel,
}: {
initialKey: string;
validate: (newKey: string) => ValidationResult;
onCommit: (newKey: string) => void;
onCancel: () => void;
}) {
const [key, setKey] = useState(initialKey);
const validation = validate(key);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
const submit = () => {
if (validation.ok && key !== initialKey) onCommit(key);
else if (key === initialKey) onCancel();
};
return (
<span className="inline-flex flex-wrap items-center gap-1">
<input
ref={ref}
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
onBlur={submit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
className="rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[11px] text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Rename field key"
/>
{!validation.ok ? <InlineError errors={validation.errors} /> : null}
</span>
);
}
src/registry/components/data/card-tree/parts/field-row.tsx
import { Check, Minus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, SearchMatch } from "../types";
import type { FlatFieldType } from "../lib/infer-type";
import type { EditMode } from "../hooks/use-edit-mode";
import type { EditDispatchers, EditValidators } from "./card";
import { FieldKeyEdit, FieldValueEdit } from "./field-edit";
import { MatchHighlight } from "./match-highlight";
import { rangesFor } from "../lib/search";
function formatDate(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const hasTime = /T\d{2}:\d{2}/.test(iso);
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
...(hasTime ? { timeStyle: "short" } : {}),
}).format(d);
}
function renderTypedValue(value: FlatFieldValue, type: FlatFieldType, ranges: Array<{ start: number; length: number }>) {
switch (type) {
case "null":
return <span className="text-muted-foreground">—</span>;
case "boolean":
return (
<span className="inline-flex items-center gap-1.5">
{value === true ? (
<Check className="size-3.5 text-primary" aria-hidden="true" />
) : (
<Minus className="size-3.5 text-muted-foreground" aria-hidden="true" />
)}
<span className="sr-only">{String(value)}</span>
</span>
);
case "number":
return (
<span className="font-mono tabular-nums text-foreground/90">
<MatchHighlight text={String(value)} ranges={ranges} />
</span>
);
case "date":
return (
<span title={String(value)} className="text-foreground/90">
<MatchHighlight text={formatDate(String(value))} ranges={[]} />
</span>
);
case "string":
default:
return (
<span>
<MatchHighlight text={String(value)} ranges={ranges} />
</span>
);
}
}
export function FieldRow({
cardId,
fieldKey,
value,
type,
editable,
canEdit,
canEditKey,
canRemove,
editMode,
setEditMode,
validators,
dispatchers,
searchMatches,
searchQuery,
searchCaseSensitive,
}: {
cardId: string;
fieldKey: string;
value: FlatFieldValue;
type: FlatFieldType;
editable: boolean;
canEdit: boolean;
canEditKey: boolean;
canRemove: boolean;
editMode: EditMode | null;
setEditMode: (m: EditMode | null) => void;
validators: EditValidators;
dispatchers: EditDispatchers;
searchMatches: readonly SearchMatch[];
searchQuery: string;
searchCaseSensitive: boolean;
}) {
const isEditingValue =
editable &&
canEdit &&
editMode?.kind === "field-value" &&
editMode.cardId === cardId &&
editMode.key === fieldKey;
const isEditingKey =
editable &&
canEditKey &&
editMode?.kind === "field-key" &&
editMode.cardId === cardId &&
editMode.key === fieldKey;
const canEditValue = editable && canEdit && type !== "null";
const keyRanges = rangesFor(searchMatches, fieldKey, cardId, "field-key", fieldKey, searchQuery, searchCaseSensitive);
const valueText = value === null ? "" : String(value);
const valueRanges = rangesFor(searchMatches, valueText, cardId, "field-value", fieldKey, searchQuery, searchCaseSensitive);
return (
<div className="group grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] items-baseline gap-x-3 gap-y-0.5">
<dt
className={cn(
"truncate font-mono text-[11px] text-muted-foreground",
editable && canEditKey && "cursor-text rounded-sm hover:bg-muted px-1 -mx-1",
)}
onClick={() => {
if (!editable || !canEditKey) return;
if (isEditingKey) return;
setEditMode({ kind: "field-key", cardId, key: fieldKey });
}}
>
{isEditingKey ? (
<FieldKeyEdit
initialKey={fieldKey}
validate={(newKey) => validators.fieldEditKey(cardId, fieldKey, newKey)}
onCommit={(newKey) => dispatchers.fieldEditKey(cardId, fieldKey, newKey)}
onCancel={() => setEditMode(null)}
/>
) : (
<MatchHighlight text={fieldKey} ranges={keyRanges} />
)}
</dt>
<dd
className="min-w-0 wrap-break-word text-sm text-foreground"
onClick={() => {
if (!canEditValue) return;
if (isEditingValue) return;
setEditMode({ kind: "field-value", cardId, key: fieldKey });
}}
>
{isEditingValue ? (
<FieldValueEdit
initialValue={value}
type={type}
validate={(newValue) =>
validators.fieldEditValue(cardId, fieldKey, newValue, type)
}
onCommit={(newValue) =>
dispatchers.fieldEditValue(cardId, fieldKey, newValue, type)
}
onCancel={() => setEditMode(null)}
/>
) : (
<span
className={cn(
canEditValue && "cursor-text rounded-sm hover:bg-muted px-1 -mx-1",
)}
>
{renderTypedValue(value, type, valueRanges)}
</span>
)}
</dd>
{editable && canRemove ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
dispatchers.fieldRemove(cardId, fieldKey);
}}
aria-label={`Remove field ${fieldKey}`}
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" aria-hidden="true" />
</button>
) : (
<span aria-hidden="true" />
)}
</div>
);
}
src/registry/components/data/card-tree/parts/inline-error.tsx
import { AlertCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ValidationError } from "../lib/validate-edit";
export function InlineError({
errors,
className,
}: {
errors: ValidationError[];
className?: string;
}) {
if (errors.length === 0) return null;
return (
<div
role="alert"
aria-live="polite"
className={cn(
"mt-1 inline-flex items-start gap-1.5 rounded-sm bg-destructive/10 px-1.5 py-0.5 text-[11px] text-destructive",
className,
)}
>
<AlertCircle className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
<span>
{errors.length === 1
? errors[0]?.message
: errors.map((e) => e.message).join(" · ")}
</span>
</div>
);
}
src/registry/components/data/card-tree/parts/match-highlight.tsx
import { Fragment } from "react";
import { cn } from "@/lib/utils";
export type HighlightRange = { start: number; length: number };
/**
* Wraps matched substrings in <mark>. Active ranges get a distinct style.
* Non-matched portions render as plain text.
*
* Used by every text-rendering location in the tree to display search hits.
* When `ranges` is empty, this is a transparent passthrough.
*/
export function MatchHighlight({
text,
ranges,
activeRange,
className,
}: {
text: string;
ranges: readonly HighlightRange[];
activeRange?: HighlightRange;
className?: string;
}) {
if (ranges.length === 0) {
return className ? <span className={className}>{text}</span> : <>{text}</>;
}
// Sort and merge ranges
const sorted = [...ranges].sort((a, b) => a.start - b.start);
const segments: Array<{
start: number;
end: number;
highlight: boolean;
isActive: boolean;
}> = [];
let cursor = 0;
for (const r of sorted) {
if (r.start < cursor) continue; // skip overlapping
if (r.start > cursor) {
segments.push({ start: cursor, end: r.start, highlight: false, isActive: false });
}
const isActive =
activeRange !== undefined &&
activeRange.start === r.start &&
activeRange.length === r.length;
segments.push({
start: r.start,
end: r.start + r.length,
highlight: true,
isActive,
});
cursor = r.start + r.length;
}
if (cursor < text.length) {
segments.push({ start: cursor, end: text.length, highlight: false, isActive: false });
}
return (
<span className={className}>
{segments.map((s, i) => {
const slice = text.slice(s.start, s.end);
if (!s.highlight) return <Fragment key={i}>{slice}</Fragment>;
return (
<mark
key={i}
className={cn(
"rounded-sm bg-amber-200/70 text-foreground dark:bg-amber-500/30",
s.isActive && "bg-amber-400 text-foreground dark:bg-amber-400/80",
)}
>
{slice}
</mark>
);
})}
</span>
);
}
src/registry/components/data/card-tree/parts/meta-edit.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer } from "../types";
import type { ValidationResult } from "../lib/validate-edit";
import { InlineError } from "./inline-error";
/**
* Inline meta editor used inside the popover (and inline-strip when desired).
* Renders meta entries with edit/remove affordances + an "+ meta" button.
*/
export function MetaEditList({
meta,
cardId,
metaRenderers,
onEdit,
onAdd,
onRemove,
validateAdd,
validateEdit,
className,
}: {
meta: Record<string, FlatFieldValue> | undefined;
cardId: string;
metaRenderers?: Record<string, MetaRenderer>;
onEdit: (key: string, value: FlatFieldValue) => void;
onAdd: (key: string, value: FlatFieldValue) => void;
onRemove: (key: string) => void;
validateAdd: (key: string, value: FlatFieldValue) => ValidationResult;
validateEdit: (key: string, value: FlatFieldValue) => ValidationResult;
className?: string;
}) {
const entries = meta ? Object.entries(meta) : [];
const [adding, setAdding] = useState(false);
const [editingKey, setEditingKey] = useState<string | null>(null);
return (
<div className={cn("space-y-1.5", className)}>
<div className="flex items-center justify-between">
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
Meta
</p>
{!adding ? (
<button
type="button"
onClick={() => setAdding(true)}
className="inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Add meta entry"
>
<Plus className="size-2.5" aria-hidden="true" />
add
</button>
) : null}
</div>
<dl className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)_auto] gap-x-2 gap-y-1 text-sm">
{entries.map(([k, v]) => (
<MetaRow
key={k}
metaKey={k}
value={v}
cardId={cardId}
isEditing={editingKey === k}
metaRenderers={metaRenderers}
onEnterEdit={() => setEditingKey(k)}
onCommitEdit={(newValue) => {
onEdit(k, newValue);
setEditingKey(null);
}}
onCancelEdit={() => setEditingKey(null)}
onRemove={() => onRemove(k)}
validate={(newValue) => validateEdit(k, newValue)}
/>
))}
</dl>
{adding ? (
<MetaAddRow
validate={validateAdd}
onCommit={(k, v) => {
onAdd(k, v);
setAdding(false);
}}
onCancel={() => setAdding(false)}
/>
) : null}
</div>
);
}
function MetaRow({
metaKey,
value,
cardId,
isEditing,
metaRenderers,
onEnterEdit,
onCommitEdit,
onCancelEdit,
onRemove,
validate,
}: {
metaKey: string;
value: FlatFieldValue;
cardId: string;
isEditing: boolean;
metaRenderers?: Record<string, MetaRenderer>;
onEnterEdit: () => void;
onCommitEdit: (newValue: FlatFieldValue) => void;
onCancelEdit: () => void;
onRemove: () => void;
validate: (newValue: FlatFieldValue) => ValidationResult;
}) {
const renderer = metaRenderers?.[metaKey];
return (
<div className="contents">
<dt className="truncate font-mono text-[11px] text-muted-foreground">
{metaKey}
</dt>
<dd className="min-w-0">
{isEditing ? (
<MetaValueInput
initial={value}
validate={validate}
onCommit={onCommitEdit}
onCancel={onCancelEdit}
/>
) : renderer ? (
renderer(value, { cardId, metaKey })
) : (
<button
type="button"
onClick={onEnterEdit}
className="cursor-text rounded-sm px-1 -mx-1 text-left hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{value === null ? "—" : String(value)}
</button>
)}
</dd>
<button
type="button"
onClick={onRemove}
aria-label={`Remove meta ${metaKey}`}
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" aria-hidden="true" />
</button>
</div>
);
}
function MetaValueInput({
initial,
validate,
onCommit,
onCancel,
}: {
initial: FlatFieldValue;
validate: (v: FlatFieldValue) => ValidationResult;
onCommit: (v: FlatFieldValue) => void;
onCancel: () => void;
}) {
const [text, setText] = useState(initial === null ? "" : String(initial));
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
const value = parseValue(initial, text);
const validation = validate(value);
return (
<span className="inline-flex flex-wrap items-center gap-1">
<input
ref={ref}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={() => {
if (validation.ok) onCommit(value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (validation.ok) onCommit(value);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
className="rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Edit meta value"
/>
{!validation.ok ? <InlineError errors={validation.errors} /> : null}
</span>
);
}
function parseValue(initial: FlatFieldValue, text: string): FlatFieldValue {
// Preserve type of original
if (typeof initial === "number") {
const n = Number(text);
return Number.isFinite(n) ? n : 0;
}
if (typeof initial === "boolean") {
return text === "true";
}
if (initial === null) {
return text === "" ? null : text;
}
return text;
}
function MetaAddRow({
validate,
onCommit,
onCancel,
}: {
validate: (key: string, value: FlatFieldValue) => ValidationResult;
onCommit: (key: string, value: FlatFieldValue) => void;
onCancel: () => void;
}) {
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const validation = validate(key, value);
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
return (
<div className="space-y-1 rounded-sm border border-dashed border-border/70 p-1.5">
<div className="flex gap-1">
<input
ref={ref}
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="key"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
className="rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="value"
onKeyDown={(e) => {
if (e.key === "Enter" && validation.ok) {
e.preventDefault();
onCommit(key, value);
} else if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
className="flex-1 rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
{!validation.ok && key.length > 0 ? (
<InlineError errors={validation.errors} />
) : null}
</div>
);
}
src/registry/components/data/card-tree/parts/meta-inline.tsx
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer } from "../types";
function format(v: FlatFieldValue) {
if (v === null) return "—";
return String(v);
}
export function MetaInline({
meta,
metaRenderers,
className,
}: {
meta: Record<string, FlatFieldValue>;
metaRenderers?: Record<string, MetaRenderer>;
className?: string;
}) {
const entries = Object.entries(meta);
if (entries.length === 0) return null;
return (
<div
className={cn(
"flex flex-wrap items-center gap-x-1.5 gap-y-0.5 truncate font-mono text-[10px] text-muted-foreground",
className,
)}
>
{entries.map(([k, v], i) => {
const renderer = metaRenderers?.[k];
return (
<span key={k} className="inline-flex items-center gap-1">
{i > 0 ? (
<span aria-hidden="true" className="opacity-60">
·
</span>
) : null}
<span className="text-foreground/50">{k}</span>
{renderer ? renderer(v, { cardId: "", metaKey: k }) : <span>{format(v)}</span>}
</span>
);
})}
</div>
);
}
src/registry/components/data/card-tree/parts/meta-popover.tsx
"use client";
import { Info } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { FlatFieldValue, MetaRenderer } from "../types";
import { MetaEditList } from "./meta-edit";
import type { EditDispatchers, EditValidators } from "./card";
function format(v: FlatFieldValue) {
if (v === null) return "—";
return String(v);
}
export function MetaPopover({
meta,
cardId,
editable,
metaRenderers,
dispatchers,
validators,
className,
}: {
meta: Record<string, FlatFieldValue>;
cardId: string;
editable: boolean;
metaRenderers?: Record<string, MetaRenderer>;
dispatchers?: EditDispatchers;
validators?: EditValidators;
className?: string;
}) {
const entries = Object.entries(meta);
if (entries.length === 0 && !editable) return null;
return (
<Popover>
<PopoverTrigger
className={cn(
"inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
aria-label="Show meta information"
>
<Info className="size-3.5" />
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className="w-72 p-3"
>
{editable && dispatchers && validators ? (
<MetaEditList
meta={meta}
cardId={cardId}
metaRenderers={metaRenderers}
onEdit={(k, v) => dispatchers.metaEdit(cardId, k, v)}
onAdd={(k, v) => dispatchers.metaAdd(cardId, k, v)}
onRemove={(k) => dispatchers.metaRemove(cardId, k)}
validateAdd={(k, v) => validators.metaAdd(cardId, k, v)}
validateEdit={(k, v) => validators.metaEdit(cardId, k, v)}
/>
) : (
<>
<p className="mb-2 text-xs font-mono uppercase tracking-wide text-muted-foreground">
Meta
</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
{entries.map(([k, v]) => {
const renderer = metaRenderers?.[k];
return (
<div key={k} className="contents">
<dt className="truncate font-mono text-xs text-muted-foreground">
{k}
</dt>
<dd className="min-w-0 wrap-break-word">
{renderer ? renderer(v, { cardId, metaKey: k }) : format(v)}
</dd>
</div>
);
})}
</dl>
</>
)}
</PopoverContent>
</Popover>
);
}
src/registry/components/data/card-tree/parts/permission-tooltip.tsx
import type { PermissionDenialReason } from "../types";
import { cn } from "@/lib/utils";
const REASON_MESSAGES: Record<PermissionDenialReason, string> = {
"global-editable-false": "Editing is disabled (editable={false}).",
"meta-locked": "Card is locked via meta (__rcmeta.locked=true).",
"by-card": "Permissions denied for this card specifically.",
"by-predefined-key": "Permissions denied for this content type.",
"by-field-type": "Permissions denied for this field type.",
"by-level": "Permissions denied at this depth.",
default: "Permissions denied (default rule).",
predicate: "Permissions denied (host predicate).",
};
export function PermissionDenialTooltip({
reason,
className,
}: {
reason: PermissionDenialReason;
className?: string;
}) {
return (
<span
role="status"
className={cn(
"inline-block rounded-sm bg-muted/80 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground",
className,
)}
>
{REASON_MESSAGES[reason]}
</span>
);
}
export function permissionReasonText(reason: PermissionDenialReason): string {
return REASON_MESSAGES[reason];
}
src/registry/components/data/card-tree/parts/predefined-add-menu.tsx
import { Plus } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
PREDEFINED_KEYS,
type CustomPredefinedKey,
type PredefinedKey,
} from "../types";
import type { CardTreeCustomEntry, CardTreePredefinedEntry } from "../lib/parse";
import { HostRenderBoundary } from "./predefined-custom";
/**
* Default-shape value for a freshly-added predefined-key entry.
* The user is auto-entered into the predefined editor immediately after add
* so they can fill in real content.
*/
function defaultEntry(key: PredefinedKey): CardTreePredefinedEntry {
switch (key) {
case "codearea":
return { key: "codearea", value: { format: "text", content: "" } };
case "image":
return { key: "image", value: { src: "", alt: "" } };
case "table":
return {
key: "table",
value: { headers: ["col"], rows: [[""]] },
};
case "quote":
return { key: "quote", value: "" };
case "list":
return { key: "list", value: [""] };
}
}
/**
* Default-shape value for a freshly-added CUSTOM predefined-key entry (v0.6).
* `defaultValue()` is host code and may throw — degrade to `null` rather
* than blocking the add.
*/
function defaultCustomEntry(registration: CustomPredefinedKey): CardTreeCustomEntry {
let value: unknown;
try {
value = registration.defaultValue();
} catch {
value = null;
}
return { key: registration.key, custom: true, value };
}
/**
* Hover-revealed "+ block" button → menu listing predefined keys (built-in +
* registered custom, v0.6) not yet present on this card and not in
* `disabledPredefinedKeys` (built-ins only — custom keys have no disable
* switch).
*/
export function PredefinedAddMenu({
presentKeys,
disabledKeys,
customKeys,
onAdd,
}: {
presentKeys: readonly (PredefinedKey | string)[];
disabledKeys: readonly PredefinedKey[];
customKeys: readonly CustomPredefinedKey[];
onAdd: (entry: CardTreePredefinedEntry) => void;
}) {
const available = PREDEFINED_KEYS.filter(
(k) => !presentKeys.includes(k) && !disabledKeys.includes(k),
);
const availableCustom = customKeys.filter(
(reg) => !presentKeys.includes(reg.key),
);
if (available.length === 0 && availableCustom.length === 0) return null;
return (
<Popover>
<PopoverTrigger className="inline-flex items-center gap-1 rounded-md border border-dashed border-border/70 bg-transparent px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Plus className="size-3" aria-hidden="true" />
block
</PopoverTrigger>
<PopoverContent align="start" sideOffset={4} className="w-44 p-1">
{available.length > 0 ? (
<>
<p className="px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
Add block
</p>
{available.map((k) => (
<button
key={k}
type="button"
onClick={() => onAdd(defaultEntry(k))}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left font-mono text-[12px] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:bg-muted"
>
{k}
</button>
))}
</>
) : null}
{availableCustom.length > 0 ? (
<>
<p className="px-2 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
Custom
</p>
{availableCustom.map((reg) => (
<button
key={reg.key}
type="button"
onClick={() => onAdd(defaultCustomEntry(reg))}
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left font-mono text-[12px] transition-colors hover:bg-muted focus-visible:outline-none focus-visible:bg-muted"
>
{reg.icon ? (
<span className="shrink-0 text-muted-foreground" aria-hidden="true">
{/* Host-supplied node — a throwing icon must not take the
add-menu (or the tree) down with it. */}
<HostRenderBoundary fallback={null}>
{reg.icon}
</HostRenderBoundary>
</span>
) : null}
<span className="flex-1 truncate">{reg.description ?? reg.key}</span>
{reg.category ? (
<span className="shrink-0 rounded-sm bg-muted px-1 py-0.5 font-mono text-[9px] uppercase tracking-wider text-muted-foreground">
{reg.category}
</span>
) : null}
</button>
))}
</>
) : null}
</PopoverContent>
</Popover>
);
}
src/registry/components/data/card-tree/parts/predefined-codearea.tsx
import { cn } from "@/lib/utils";
import type { CodeAreaValue } from "../types";
export function PredefinedCodeArea({
value,
className,
}: {
value: CodeAreaValue;
className?: string;
}) {
return (
<figure
className={cn(
"overflow-hidden rounded-md border border-border/70 bg-muted/50",
className,
)}
>
<figcaption className="flex items-center justify-between border-b border-border/70 bg-muted/40 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
<span>{value.format || "code"}</span>
</figcaption>
<pre className="overflow-x-auto px-2.5 py-2 font-mono text-[12.5px] leading-relaxed">
<code>{value.content}</code>
</pre>
</figure>
);
}
src/registry/components/data/card-tree/parts/predefined-custom.tsx
"use client";
import { Component, useEffect, useRef, useState, type ReactNode } from "react";
import { Check, TriangleAlert, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CustomKeyContext, CustomPredefinedKey } from "../types";
import type { CardTreeCustomEntry } from "../lib/parse";
import { InlineError } from "./inline-error";
/**
* Render + edit surface for host-registered custom predefined-keys (v0.6).
* Mirrors the structure of the built-in blocks (predefined-quote.tsx,
* predefined-codearea.tsx): a dumb, prop-driven view component + an editor
* that either delegates to the host's `edit` or falls back to a JSON
* textarea (guide §7.1, plan-v0.3 §9 L881/L846).
*
* Host code (`render` / `edit` / `defaultValue`) is untrusted: it may throw
* synchronously (call-site try/catch) or during its own render (error
* boundary). Either path degrades to a generic JSON `<pre>` fallback rather
* than blanking the tree (I-neg).
*/
function safeStringify(value: unknown): string {
try {
const out = JSON.stringify(value, null, 2);
return out ?? String(value);
} catch {
return String(value);
}
}
function GenericJsonFallback({
value,
className,
}: {
value: unknown;
className?: string;
}) {
return (
<figure
className={cn(
"overflow-hidden rounded-md border border-destructive/40 bg-destructive/5",
className,
)}
>
<figcaption className="flex items-center gap-1.5 border-b border-destructive/30 bg-destructive/10 px-2.5 py-1 font-mono text-[10px] uppercase tracking-wider text-destructive">
<TriangleAlert className="size-3" aria-hidden="true" />
custom renderer error
</figcaption>
<pre className="overflow-x-auto px-2.5 py-2 font-mono text-[12px] leading-relaxed text-foreground/80">
{safeStringify(value)}
</pre>
</figure>
);
}
/**
* Catches throws raised during the host node's own render pass.
*
* Exported because EVERY surface that renders host-supplied ReactNode needs it,
* not just this one — `registration.icon` is rendered by the add-menu and is
* equally hostile. A try/catch cannot substitute here: the throw happens inside
* React's render of the returned element, not at the call site.
*/
export class HostRenderBoundary extends Component<
{ fallback: ReactNode; children: ReactNode },
{ hasError: boolean }
> {
override state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
override componentDidCatch() {
// Swallow — degrading to the generic JSON fallback is the recovery.
}
override render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}
/* ───────── view ───────── */
export function PredefinedCustom({
entry,
registration,
ctx,
}: {
entry: CardTreeCustomEntry;
registration: CustomPredefinedKey | undefined;
ctx: CustomKeyContext;
}) {
const fallback = (
<GenericJsonFallback value={entry.value} className={ctx.className} />
);
if (!registration) return fallback;
let node: ReactNode;
try {
node = registration.render(entry.value, ctx);
} catch {
return fallback;
}
return <HostRenderBoundary fallback={fallback}>{node}</HostRenderBoundary>;
}
/* ───────── edit: JSON-textarea fallback (guide §7.1) ───────── */
// Mirrors predefined-edit.tsx's `inputBase` (the table/JSON-fallback precedent) —
// kept file-local like its sibling rather than sharing an export.
const inputBase =
"rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
function CustomJsonFallbackEdit({
entry,
onCommit,
onCancel,
}: {
entry: CardTreeCustomEntry;
onCommit: (next: CardTreeCustomEntry) => void;
onCancel: () => void;
}) {
const [text, setText] = useState(() => safeStringify(entry.value));
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
let parsed: unknown;
let parseError: string | null = null;
try {
parsed = JSON.parse(text);
} catch (e) {
parseError = e instanceof Error ? e.message : "invalid JSON";
}
return (
<div
className="space-y-1.5 rounded-md border border-dashed border-primary/40 bg-muted/40 p-2"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
custom block "{entry.key}" · no `edit` registered · JSON fallback
</p>
<textarea
ref={ref}
value={text}
onChange={(e) => setText(e.target.value)}
rows={8}
className={cn(inputBase, "block w-full resize-y font-mono text-[12px]")}
aria-label={`Edit ${entry.key} (JSON)`}
spellCheck={false}
/>
<div className="flex items-center justify-between gap-2 pt-1">
{parseError ? (
<InlineError errors={[{ code: "json-parse", message: parseError }]} />
) : (
<span />
)}
<div className="flex items-center gap-1">
<button
type="button"
onClick={onCancel}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Cancel edit"
>
<X className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={() => {
if (!parseError) {
onCommit({ key: entry.key, custom: true, value: parsed });
}
}}
disabled={!!parseError}
className="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 font-mono text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Check className="size-3" aria-hidden="true" />
save
</button>
</div>
</div>
</div>
);
}
/* ───────── edit: dispatcher ───────── */
export function PredefinedCustomEdit({
entry,
registration,
onCommit,
onCancel,
}: {
entry: CardTreeCustomEntry;
registration: CustomPredefinedKey | undefined;
onCommit: (next: CardTreeCustomEntry) => void;
onCancel: () => void;
}) {
const fallback = (
<CustomJsonFallbackEdit entry={entry} onCommit={onCommit} onCancel={onCancel} />
);
if (!registration?.edit) return fallback;
// Host `edit` runs here. It may throw synchronously (caught below) or
// throw during its own render (caught by the boundary, which then swaps
// in the same JSON fallback so editing is never a dead end).
let node: ReactNode;
try {
node = registration.edit(
entry.value,
(next) => onCommit({ key: entry.key, custom: true, value: next }),
onCancel,
);
} catch {
return fallback;
}
return <HostRenderBoundary fallback={fallback}>{node}</HostRenderBoundary>;
}
src/registry/components/data/card-tree/parts/predefined-edit.tsx
import { useEffect, useRef, useState } from "react";
import { Check, X } from "lucide-react";
import { cn } from "@/lib/utils";
import type {
CodeAreaValue,
CustomPredefinedKey,
FlatFieldValue,
ImageValue,
ListValue,
QuoteValue,
TableValue,
} from "../types";
import { isCustomEntry, type CardTreePredefinedEntry } from "../lib/parse";
import { findCustomKey } from "../lib/custom-keys";
import {
validatePredefinedShape,
type ValidationResult,
} from "../lib/validate-edit";
import { InlineError } from "./inline-error";
import { PredefinedCustomEdit } from "./predefined-custom";
/* ───────── shared editor frame ───────── */
function EditorFrame({
children,
validation,
onSave,
onCancel,
}: {
children: React.ReactNode;
validation: ValidationResult;
onSave: () => void;
onCancel: () => void;
}) {
return (
<div
className="space-y-1.5 rounded-md border border-dashed border-primary/40 bg-muted/40 p-2"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
}}
>
{children}
<div className="flex items-center justify-between gap-2 pt-1">
{!validation.ok ? (
<InlineError errors={validation.errors} />
) : (
<span />
)}
<div className="flex items-center gap-1">
<button
type="button"
onClick={onCancel}
className="inline-flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Cancel edit"
>
<X className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={onSave}
disabled={!validation.ok}
className="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 font-mono text-[11px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Check className="size-3" aria-hidden="true" />
save
</button>
</div>
</div>
</div>
);
}
const inputBase =
"rounded-sm border border-border bg-background px-1.5 py-0.5 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
/* ───────── codearea ───────── */
function CodeAreaEdit({
initial,
onSave,
onCancel,
}: {
initial: CodeAreaValue;
onSave: (v: CodeAreaValue) => void;
onCancel: () => void;
}) {
const [format, setFormat] = useState(initial.format);
const [content, setContent] = useState(initial.content);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
const value: CodeAreaValue = { format, content };
const validation = validatePredefinedShape("codearea", value);
return (
<EditorFrame
validation={validation}
onSave={() => validation.ok && onSave(value)}
onCancel={onCancel}
>
<div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
format
</span>
<input
type="text"
value={format}
onChange={(e) => setFormat(e.target.value)}
className={cn(inputBase, "w-32 font-mono text-[12px]")}
aria-label="Code format"
/>
</div>
<textarea
ref={ref}
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
className={cn(
inputBase,
"block w-full resize-y font-mono text-[12.5px] leading-relaxed",
)}
aria-label="Code content"
/>
</EditorFrame>
);
}
/* ───────── image ───────── */
function ImageEdit({
initial,
onSave,
onCancel,
}: {
initial: ImageValue;
onSave: (v: ImageValue) => void;
onCancel: () => void;
}) {
const [src, setSrc] = useState(initial.src);
const [alt, setAlt] = useState(initial.alt ?? "");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
const value: ImageValue = { src, ...(alt ? { alt } : {}) };
const validation = validatePredefinedShape("image", value);
return (
<EditorFrame
validation={validation}
onSave={() => validation.ok && onSave(value)}
onCancel={onCancel}
>
<div className="grid grid-cols-[minmax(0,auto)_minmax(0,1fr)] items-center gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
src
</span>
<input
ref={ref}
type="url"
value={src}
onChange={(e) => setSrc(e.target.value)}
placeholder="https://..."
className={cn(inputBase, "w-full")}
aria-label="Image src"
/>
<span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
alt
</span>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder="description"
className={cn(inputBase, "w-full")}
aria-label="Image alt text"
/>
</div>
{src ? (
<img
src={src}
alt={alt}
className="max-h-32 w-auto rounded-sm border border-border/70"
/>
) : null}
</EditorFrame>
);
}
/* ───────── quote ───────── */
function QuoteEdit({
initial,
onSave,
onCancel,
}: {
initial: QuoteValue;
onSave: (v: QuoteValue) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState(initial);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
ref.current?.select();
}, []);
const validation = validatePredefinedShape("quote", value);
return (
<EditorFrame
validation={validation}
onSave={() => validation.ok && onSave(value)}
onCancel={onCancel}
>
<textarea
ref={ref}
value={value}
onChange={(e) => setValue(e.target.value)}
rows={3}
className={cn(
inputBase,
"block w-full resize-y text-sm italic leading-relaxed",
)}
aria-label="Quote text"
/>
</EditorFrame>
);
}
/* ───────── list (one item per line) ───────── */
function ListEdit({
initial,
onSave,
onCancel,
}: {
initial: ListValue;
onSave: (v: ListValue) => void;
onCancel: () => void;
}) {
const [text, setText] = useState(
initial.map((v) => stringifyScalar(v)).join("\n"),
);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
const value: ListValue = text
.split("\n")
.filter((line, i, a) => line.length > 0 || i < a.length - 1)
.map((line) => parseScalar(line));
const validation = validatePredefinedShape("list", value);
return (
<EditorFrame
validation={validation}
onSave={() => validation.ok && onSave(value)}
onCancel={onCancel}
>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
one item per line · empty lines dropped · `true` / `false` / `null` /
numbers inferred
</p>
<textarea
ref={ref}
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
className={cn(inputBase, "block w-full resize-y font-mono text-[12px]")}
aria-label="List items"
/>
</EditorFrame>
);
}
function stringifyScalar(v: FlatFieldValue): string {
if (v === null) return "null";
return String(v);
}
function parseScalar(line: string): FlatFieldValue {
const trimmed = line.trim();
if (trimmed === "") return "";
if (trimmed === "null") return null;
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
return line;
}
/* ───────── table (JSON-textarea fallback per Q-P4) ───────── */
function TableEdit({
initial,
onSave,
onCancel,
}: {
initial: TableValue;
onSave: (v: TableValue) => void;
onCancel: () => void;
}) {
const [text, setText] = useState(JSON.stringify(initial, null, 2));
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
}, []);
let parsed: TableValue | null = null;
let parseError: string | null = null;
try {
parsed = JSON.parse(text) as TableValue;
} catch (e) {
parseError = e instanceof Error ? e.message : "invalid JSON";
}
const validation: ValidationResult = parsed
? validatePredefinedShape("table", parsed)
: {
ok: false,
errors: [{ code: "json-parse", message: parseError ?? "invalid JSON" }],
};
return (
<EditorFrame
validation={validation}
onSave={() => validation.ok && parsed && onSave(parsed)}
onCancel={onCancel}
>
<p className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
table edit (JSON · cell-editor in v0.3)
</p>
<textarea
ref={ref}
value={text}
onChange={(e) => setText(e.target.value)}
rows={8}
className={cn(inputBase, "block w-full resize-y font-mono text-[12px]")}
aria-label="Table JSON"
spellCheck={false}
/>
</EditorFrame>
);
}
/* ───────── dispatcher ───────── */
export function PredefinedEdit({
entry,
customKeys,
onCommit,
onCancel,
}: {
entry: CardTreePredefinedEntry;
/** Resolved custom-key registrations (v0.6) — see `resolveCustomKeys`. */
customKeys: readonly CustomPredefinedKey[];
onCommit: (next: CardTreePredefinedEntry) => void;
onCancel: () => void;
}) {
if (isCustomEntry(entry)) {
return (
<PredefinedCustomEdit
entry={entry}
registration={findCustomKey(customKeys, entry.key)}
onCommit={onCommit}
onCancel={onCancel}
/>
);
}
switch (entry.key) {
case "codearea":
return (
<CodeAreaEdit
initial={entry.value}
onSave={(v) => onCommit({ key: "codearea", value: v })}
onCancel={onCancel}
/>
);
case "image":
return (
<ImageEdit
initial={entry.value}
onSave={(v) => onCommit({ key: "image", value: v })}
onCancel={onCancel}
/>
);
case "quote":
return (
<QuoteEdit
initial={entry.value}
onSave={(v) => onCommit({ key: "quote", value: v })}
onCancel={onCancel}
/>
);
case "list":
return (
<ListEdit
initial={entry.value}
onSave={(v) => onCommit({ key: "list", value: v })}
onCancel={onCancel}
/>
);
case "table":
return (
<TableEdit
initial={entry.value}
onSave={(v) => onCommit({ key: "table", value: v })}
onCancel={onCancel}
/>
);
}
}
src/registry/components/data/card-tree/parts/predefined-image.tsx
import { cn } from "@/lib/utils";
import type { ImageValue } from "../types";
export function PredefinedImage({
value,
className,
}: {
value: ImageValue;
className?: string;
}) {
return (
<figure
className={cn(
"overflow-hidden rounded-md border border-border/70 bg-muted/30",
className,
)}
>
<img
src={value.src}
alt={value.alt ?? ""}
className="block h-auto w-full max-w-full object-cover"
loading="lazy"
/>
{value.alt ? (
<figcaption className="border-t border-border/70 bg-muted/40 px-2.5 py-1 text-[11px] text-muted-foreground">
{value.alt}
</figcaption>
) : null}
</figure>
);
}
src/registry/components/data/card-tree/parts/predefined-list.tsx
import { cn } from "@/lib/utils";
import type { FlatFieldValue, ListValue } from "../types";
function renderItem(v: FlatFieldValue) {
if (v === null) return <span className="text-muted-foreground">—</span>;
if (typeof v === "boolean")
return (
<span className={v ? "text-primary" : "text-muted-foreground"}>
{v ? "true" : "false"}
</span>
);
if (typeof v === "number")
return <span className="font-mono tabular-nums">{v}</span>;
return String(v);
}
export function PredefinedList({
value,
className,
}: {
value: ListValue;
className?: string;
}) {
return (
<ul
className={cn(
"list-disc space-y-0.5 ps-4 text-[13px] marker:text-muted-foreground/60",
className,
)}
>
{value.map((item, i) => (
<li key={i}>{renderItem(item)}</li>
))}
</ul>
);
}
src/registry/components/data/card-tree/parts/predefined-quote.tsx
import { cn } from "@/lib/utils";
import type { QuoteValue } from "../types";
export function PredefinedQuote({
value,
className,
}: {
value: QuoteValue;
className?: string;
}) {
return (
<blockquote
className={cn(
"rounded-sm border-s-2 border-primary/70 bg-muted/30 ps-3 pe-2.5 py-1.5 text-[13.5px] italic leading-relaxed text-foreground/90",
className,
)}
>
{value}
</blockquote>
);
}
src/registry/components/data/card-tree/parts/predefined-table.tsx
import { cn } from "@/lib/utils";
import type { FlatFieldValue, TableValue } from "../types";
function renderCell(v: FlatFieldValue) {
if (v === null) return <span className="text-muted-foreground">—</span>;
if (typeof v === "boolean")
return (
<span
className={
v ? "text-primary" : "text-muted-foreground"
}
>
{v ? "✓" : "—"}
</span>
);
return String(v);
}
export function PredefinedTable({
value,
className,
}: {
value: TableValue;
className?: string;
}) {
return (
<div
className={cn(
"overflow-x-auto rounded-md border border-border/70 bg-card",
className,
)}
>
<table className="w-full text-[13px]">
<thead className="border-b border-border/70 bg-muted/40">
<tr>
{value.headers.map((h, i) => (
<th
key={`${h}-${i}`}
className="px-2.5 py-1.5 text-left font-mono text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{value.rows.map((row, i) => (
<tr
key={i}
className="border-b border-border/40 last:border-0 hover:bg-muted/20"
>
{row.map((cell, j) => (
<td
key={j}
className={cn(
"px-2.5 py-1.5 align-top",
typeof cell === "number" &&
"text-right font-mono tabular-nums",
)}
>
{renderCell(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
src/registry/components/data/card-tree/parts/search-bar.tsx
"use client";
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Optional default search bar — sibling export. Hosts can skip and wire their own input
* directly into the `search` prop of CardTree.
*
* Bindings:
* F3 / Shift+F3 → next / previous match (handled by host or via the imperative handle)
*/
export function CardTreeSearchBar({
value,
onChange,
matchCount,
activeIndex,
onNext,
onPrevious,
onClear,
placeholder = "Search cards…",
className,
}: {
value: string;
onChange: (next: string) => void;
matchCount: number;
activeIndex: number | null;
onNext: () => void;
onPrevious: () => void;
onClear: () => void;
placeholder?: string;
className?: string;
}) {
const hasMatches = matchCount > 0;
const noMatches = value.length > 0 && matchCount === 0;
const display =
activeIndex !== null && hasMatches
? `${activeIndex + 1} / ${matchCount}`
: hasMatches
? `${matchCount} matches`
: noMatches
? "no matches"
: null;
return (
<div
role="search"
className={cn(
"inline-flex items-center gap-1 rounded-md border border-border bg-card px-2 py-1 shadow-sm",
className,
)}
>
<Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
type="search"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label="Search cards"
onKeyDown={(e) => {
if (e.key === "F3" || (e.key === "g" && (e.metaKey || e.ctrlKey))) {
e.preventDefault();
if (e.shiftKey) onPrevious();
else onNext();
} else if (e.key === "Escape") {
e.preventDefault();
onClear();
}
}}
className="min-w-32 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
/>
{display ? (
<span
aria-live="polite"
className={cn(
"shrink-0 font-mono text-[10px]",
noMatches ? "text-destructive" : "text-muted-foreground",
)}
>
{display}
</span>
) : null}
{hasMatches ? (
<span className="inline-flex shrink-0 items-center gap-0.5 border-l border-border pl-1">
<button
type="button"
onClick={onPrevious}
aria-label="Previous match"
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronUp className="size-3" aria-hidden="true" />
</button>
<button
type="button"
onClick={onNext}
aria-label="Next match"
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDown className="size-3" aria-hidden="true" />
</button>
</span>
) : null}
{value.length > 0 ? (
<button
type="button"
onClick={onClear}
aria-label="Clear search"
className="inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" aria-hidden="true" />
</button>
) : null}
</div>
);
}
src/registry/components/data/card-tree/parts/undo-toolbar.tsx
"use client";
import { Redo2, Undo2 } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Optional sibling export — drop in next to <CardTree /> for default
* undo / redo affordances. Hosts can wire their own buttons via the
* imperative ref handle.
*/
export function CardTreeUndoToolbar({
canUndo,
canRedo,
onUndo,
onRedo,
className,
}: {
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
className?: string;
}) {
return (
<div
role="toolbar"
aria-label="Undo / redo"
className={cn(
"inline-flex items-center gap-0.5 rounded-md border border-border bg-card p-0.5 shadow-sm",
className,
)}
>
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
aria-label="Undo"
title="Undo (Cmd/Ctrl+Z)"
aria-disabled={!canUndo}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Undo2 className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
onClick={onRedo}
disabled={!canRedo}
aria-label="Redo"
title="Redo (Cmd/Ctrl+Shift+Z)"
aria-disabled={!canRedo}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Redo2 className="size-3.5" aria-hidden="true" />
</button>
</div>
);
}