fix(chess/ui): visual-builder drag/nesting/editing UX gaps
Addresses four user-reported gaps in the visual mode authoring surface:
1. × button and expand chevron triggered a drag instead of their own
action. dnd-kit listeners were spread on the outer SortableBlockItem
wrapper, so any pointerdown on a descendant started a sort. Fixed by
routing only `listeners` to a new dedicated grip-handle icon on the
card header; the rest of the card (× / expand / inspector / body)
no longer competes with drag. `attributes` still go on the wrapper
so keyboard drag + screen-reader announcements keep working.
2. No way to add primitives INSIDE a trigger — palette clicks always
appended at the top level. Fixed by computing an addTargetInfo when
the selected block is a container (has childPrimitives + a params
.primitives array): the palette now shows an "Adding inside: {label}"
banner with an "Add at top level instead" escape button, and
handleAddPrimitive routes the new node into the parent params. The
parent auto-expands and selection stays on the parent so repeated
palette clicks stack children under it.
3. Inspector was read-only — ParamField.onChange was a documented
no-op. Fixed by adding onParamsChange through the wire
(VisualBuilderPane.handleParamsChange → BlockList.onParamsChange
→ SortableBlockItem.onParamsChange → BlockCard.onParamsChange →
ParamField.onChange). Editing a number/string/enum field now
immediately updates descriptor.primitives[index].params.
4. Nested children could not be removed — nested BlockList was given
onRemove={() => {}}. Fixed by adding onNestedRemove to BlockListProps;
VisualBuilderPane supplies handleNestedRemove which filters the
matching parent params.primitives[] without mutating siblings.
Additional polish:
- Inspector now opens when a block is selected (previously needed
selected AND expanded), so children and docs show up on first click.
- Child block list renders whenever the parent is expanded OR selected
for the same first-click visibility.
- Expand button gains aria-label="Expand|Collapse" so accessibility
tooling (and Playwright getByRole) can target it by name.
This commit is contained in:
parent
46109d5d23
commit
2d1efb1b3a
3 changed files with 228 additions and 22 deletions
|
|
@ -3,6 +3,14 @@ import type { EffectPrimitiveNode, PrimitiveKind } from '../../modifiers/primiti
|
||||||
import { PRIMITIVE_REGISTRY } from '../../modifiers/primitives/registry.js';
|
import { PRIMITIVE_REGISTRY } from '../../modifiers/primitives/registry.js';
|
||||||
import { ParamField } from '../ParamField.js';
|
import { ParamField } from '../ParamField.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props that a dnd-kit `useSortable` caller hands to the drag-handle
|
||||||
|
* button. Intentionally opaque — we spread them onto the grip icon
|
||||||
|
* without caring about exact shape so dnd-kit's internal event keys
|
||||||
|
* stay encapsulated.
|
||||||
|
*/
|
||||||
|
export type DragHandleProps = Record<string, unknown>;
|
||||||
|
|
||||||
export interface BlockCardProps {
|
export interface BlockCardProps {
|
||||||
node: EffectPrimitiveNode;
|
node: EffectPrimitiveNode;
|
||||||
index: number;
|
index: number;
|
||||||
|
|
@ -11,8 +19,23 @@ export interface BlockCardProps {
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
/**
|
||||||
|
* Called when the inspector edits the primitive's params. When
|
||||||
|
* omitted, the inspector is read-only. The caller (VisualBuilderPane)
|
||||||
|
* is responsible for producing a new descriptor with the updated
|
||||||
|
* params at this block's position in the tree.
|
||||||
|
*/
|
||||||
|
onParamsChange?: (params: unknown) => void;
|
||||||
depth: number; // 0..3 (visually clamped at 3)
|
depth: number; // 0..3 (visually clamped at 3)
|
||||||
childBlocks?: React.ReactNode;
|
childBlocks?: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Props from dnd-kit's `useSortable` that enable drag initiation.
|
||||||
|
* Passed through to the grip-handle button so ONLY that element
|
||||||
|
* starts a drag — clicks on the × / expand / body never do.
|
||||||
|
* Omitted when the card isn't inside a sortable context (e.g. the
|
||||||
|
* DragOverlay ghost render).
|
||||||
|
*/
|
||||||
|
dragHandleProps?: DragHandleProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORIES: Record<string, PrimitiveKind[]> = {
|
const CATEGORIES: Record<string, PrimitiveKind[]> = {
|
||||||
|
|
@ -61,8 +84,10 @@ export default function BlockCard({
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onParamsChange,
|
||||||
depth,
|
depth,
|
||||||
childBlocks
|
childBlocks,
|
||||||
|
dragHandleProps,
|
||||||
}: BlockCardProps) {
|
}: BlockCardProps) {
|
||||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||||
const label = primitive?.label ?? node.kind;
|
const label = primitive?.label ?? node.kind;
|
||||||
|
|
@ -137,6 +162,32 @@ export default function BlockCard({
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div className="flex items-center justify-between p-3">
|
<div className="flex items-center justify-between p-3">
|
||||||
<div className="flex items-center gap-3 overflow-hidden">
|
<div className="flex items-center gap-3 overflow-hidden">
|
||||||
|
{dragHandleProps !== undefined && (
|
||||||
|
<button
|
||||||
|
{...dragHandleProps}
|
||||||
|
type="button"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
title="Drag to reorder"
|
||||||
|
data-testid={`block-drag-handle-${node.kind}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Don't select/expand when the user happens to click the
|
||||||
|
// handle. Drag initiation goes through dnd-kit's
|
||||||
|
// pointerdown listener which we spread above — a plain
|
||||||
|
// click is a no-op.
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="cursor-grab active:cursor-grabbing touch-none select-none p-1 -ml-1 text-neutral-400 hover:text-neutral-600 focus:outline-none focus:ring-2 focus:ring-neutral-400 rounded"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<circle cx="7" cy="5" r="1.5" />
|
||||||
|
<circle cx="13" cy="5" r="1.5" />
|
||||||
|
<circle cx="7" cy="10" r="1.5" />
|
||||||
|
<circle cx="13" cy="10" r="1.5" />
|
||||||
|
<circle cx="7" cy="15" r="1.5" />
|
||||||
|
<circle cx="13" cy="15" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className={`
|
<div className={`
|
||||||
flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0
|
flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0
|
||||||
${badgeStyles}
|
${badgeStyles}
|
||||||
|
|
@ -168,7 +219,9 @@ export default function BlockCard({
|
||||||
: 'text-neutral-500 hover:bg-neutral-100'
|
: 'text-neutral-500 hover:bg-neutral-100'
|
||||||
}`}
|
}`}
|
||||||
title={isExpanded ? "Collapse" : "Expand"}
|
title={isExpanded ? "Collapse" : "Expand"}
|
||||||
|
aria-label={isExpanded ? "Collapse" : "Expand"}
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
|
data-testid={`block-expand-${node.kind}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
|
|
@ -196,29 +249,28 @@ export default function BlockCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inspector Overlay (When selected and expanded) */}
|
{/* Inspector — shown whenever this block is selected so users
|
||||||
{isSelected && isExpanded && primitive && (
|
can always see/edit params of the primitive they picked. */}
|
||||||
|
{isSelected && primitive && (
|
||||||
<div
|
<div
|
||||||
className="px-3 pb-4 pt-1 border-t border-black/5"
|
className="px-3 pb-4 pt-1 border-t border-black/5"
|
||||||
onClick={e => e.stopPropagation()} // Prevent inspector clicks from triggering card select
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ParamField
|
<ParamField
|
||||||
node={node}
|
node={node}
|
||||||
primitive={primitive}
|
primitive={primitive}
|
||||||
allPrimitives={[node]} // We only have the single node context here for now. Good enough for standard fields.
|
allPrimitives={[node]}
|
||||||
onChange={() => {
|
onChange={(params) => {
|
||||||
// The visual builder itself will handle the node updates, BlockCard just renders the inspector
|
if (onParamsChange) onParamsChange(params);
|
||||||
// Since this is read-only from the perspective of BlockCard props, we just provide a no-op
|
|
||||||
// for now until the visual builder state management (T18/T22) handles it.
|
|
||||||
// Note: For now we'll just not support direct editing via ParamField onChange within BlockCard
|
|
||||||
// to keep the API surface exactly as requested (pure presentational).
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nested Children Area */}
|
{/* Nested children — shown when the block is expanded OR selected,
|
||||||
{isExpanded && childBlocks && (
|
so the user sees the inside of the trigger they just picked
|
||||||
|
from the palette without needing an extra click. */}
|
||||||
|
{(isExpanded || isSelected) && childBlocks && (
|
||||||
<div className="p-2 border-t border-black/5 bg-black/5 rounded-b-lg">
|
<div className="p-2 border-t border-black/5 bg-black/5 rounded-b-lg">
|
||||||
{childBlocks}
|
{childBlocks}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,16 @@ export interface BlockListProps {
|
||||||
onSelect: (index: number) => void;
|
onSelect: (index: number) => void;
|
||||||
onToggleExpand: (index: number) => void;
|
onToggleExpand: (index: number) => void;
|
||||||
onRemove: (index: number) => void;
|
onRemove: (index: number) => void;
|
||||||
|
onParamsChange?: (index: number, params: unknown) => void;
|
||||||
onNestedReorder?: (parentIndex: number, fromChildIndex: number, toChildIndex: number) => void;
|
onNestedReorder?: (parentIndex: number, fromChildIndex: number, toChildIndex: number) => void;
|
||||||
|
/**
|
||||||
|
* Called when the user clicks × on a child block inside a trigger.
|
||||||
|
* `parentIndex` is the child's parent in THIS list; `childIndex` is
|
||||||
|
* the child's position within `parent.params.primitives`. When
|
||||||
|
* omitted, the × button on nested blocks is a no-op (present for
|
||||||
|
* backward compat with existing callers).
|
||||||
|
*/
|
||||||
|
onNestedRemove?: (parentIndex: number, childIndex: number) => void;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +53,7 @@ interface SortableBlockItemProps {
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onToggleExpand: () => void;
|
onToggleExpand: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onParamsChange?: (params: unknown) => void;
|
||||||
depth: number;
|
depth: number;
|
||||||
childBlocks?: React.ReactNode;
|
childBlocks?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -66,8 +76,16 @@ function SortableBlockItem(props: SortableBlockItemProps) {
|
||||||
zIndex: isDragging ? 1 : 0,
|
zIndex: isDragging ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pointer-event listeners go ONLY to the grip handle rendered by
|
||||||
|
// BlockCard. `attributes` stay on the wrapper so dnd-kit's keyboard
|
||||||
|
// and screen-reader integration keeps working (role, tabIndex,
|
||||||
|
// aria-describedby, aria-pressed), but pointerdown on the card body,
|
||||||
|
// × button, expand toggle, or inspector fields no longer starts a
|
||||||
|
// drag.
|
||||||
|
const dragHandleProps = { ...listeners };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
<BlockCard
|
<BlockCard
|
||||||
node={props.node}
|
node={props.node}
|
||||||
index={props.index}
|
index={props.index}
|
||||||
|
|
@ -76,8 +94,10 @@ function SortableBlockItem(props: SortableBlockItemProps) {
|
||||||
onSelect={props.onSelect}
|
onSelect={props.onSelect}
|
||||||
onToggleExpand={props.onToggleExpand}
|
onToggleExpand={props.onToggleExpand}
|
||||||
onRemove={props.onRemove}
|
onRemove={props.onRemove}
|
||||||
|
{...(props.onParamsChange ? { onParamsChange: props.onParamsChange } : {})}
|
||||||
depth={props.depth}
|
depth={props.depth}
|
||||||
childBlocks={props.childBlocks}
|
childBlocks={props.childBlocks}
|
||||||
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -91,7 +111,9 @@ export function BlockList({
|
||||||
onSelect,
|
onSelect,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onParamsChange,
|
||||||
onNestedReorder,
|
onNestedReorder,
|
||||||
|
onNestedRemove,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
}: BlockListProps) {
|
}: BlockListProps) {
|
||||||
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||||
|
|
@ -183,12 +205,17 @@ export function BlockList({
|
||||||
|
|
||||||
let childBlocks: React.ReactNode = null;
|
let childBlocks: React.ReactNode = null;
|
||||||
if (hasChildren && isExpanded && typeof node.params === 'object' && node.params !== null && 'primitives' in node.params && Array.isArray((node.params as Record<string, unknown>).primitives)) {
|
if (hasChildren && isExpanded && typeof node.params === 'object' && node.params !== null && 'primitives' in node.params && Array.isArray((node.params as Record<string, unknown>).primitives)) {
|
||||||
// Render nested block list
|
const childNodes = (node.params as Record<string, unknown>).primitives as EffectPrimitiveNode[];
|
||||||
|
// Render nested block list. Selection and expansion
|
||||||
|
// are intentionally scoped to the top level for now —
|
||||||
|
// multi-level selection would need a richer path-based
|
||||||
|
// selector than the current flat number. Removal of
|
||||||
|
// individual children IS supported via onNestedRemove.
|
||||||
childBlocks = (
|
childBlocks = (
|
||||||
<BlockList
|
<BlockList
|
||||||
nodes={(node.params as Record<string, unknown>).primitives as EffectPrimitiveNode[]}
|
nodes={childNodes}
|
||||||
selectedIndex={null} // Nested selection not yet fully scoped, keep null for now or manage differently
|
selectedIndex={null}
|
||||||
expandedIndices={new Set()} // Same for nested expansion
|
expandedIndices={new Set()}
|
||||||
onReorder={(fromIdx, toIdx) => {
|
onReorder={(fromIdx, toIdx) => {
|
||||||
if (onNestedReorder) {
|
if (onNestedReorder) {
|
||||||
onNestedReorder(index, fromIdx, toIdx);
|
onNestedReorder(index, fromIdx, toIdx);
|
||||||
|
|
@ -196,12 +223,19 @@ export function BlockList({
|
||||||
}}
|
}}
|
||||||
onSelect={() => {}}
|
onSelect={() => {}}
|
||||||
onToggleExpand={() => {}}
|
onToggleExpand={() => {}}
|
||||||
onRemove={() => {}}
|
onRemove={(childIdx) => {
|
||||||
|
if (onNestedRemove) {
|
||||||
|
onNestedRemove(index, childIdx);
|
||||||
|
}
|
||||||
|
}}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paramsChangeProp = onParamsChange
|
||||||
|
? { onParamsChange: (params: unknown) => onParamsChange(index, params) }
|
||||||
|
: {};
|
||||||
return (
|
return (
|
||||||
<SortableBlockItem
|
<SortableBlockItem
|
||||||
key={id}
|
key={id}
|
||||||
|
|
@ -213,6 +247,7 @@ export function BlockList({
|
||||||
onSelect={() => onSelect(index)}
|
onSelect={() => onSelect(index)}
|
||||||
onToggleExpand={() => onToggleExpand(index)}
|
onToggleExpand={() => onToggleExpand(index)}
|
||||||
onRemove={() => onRemove(index)}
|
onRemove={() => onRemove(index)}
|
||||||
|
{...paramsChangeProp}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
childBlocks={childBlocks}
|
childBlocks={childBlocks}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,35 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the user has a trigger/container primitive selected (e.g. the
|
||||||
|
* user just clicked "On Turn End"), a new primitive from the palette
|
||||||
|
* lands INSIDE that trigger's `params.primitives` — not at the top
|
||||||
|
* level. Otherwise it's appended to the descriptor root.
|
||||||
|
*
|
||||||
|
* Determined by checking whether the selected primitive's registry
|
||||||
|
* entry exposes `childPrimitives` (all triggers + conditional do).
|
||||||
|
*/
|
||||||
|
const addTargetInfo = (() => {
|
||||||
|
if (selectedIndex === null) return null;
|
||||||
|
const parent = descriptor.primitives[selectedIndex];
|
||||||
|
if (!parent) return null;
|
||||||
|
const registryEntry = PRIMITIVE_REGISTRY.get(parent.kind);
|
||||||
|
if (registryEntry?.childPrimitives === undefined) return null;
|
||||||
|
if (
|
||||||
|
typeof parent.params !== 'object' ||
|
||||||
|
parent.params === null ||
|
||||||
|
!('primitives' in parent.params) ||
|
||||||
|
!Array.isArray((parent.params as Record<string, unknown>).primitives)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
parentIndex: selectedIndex,
|
||||||
|
parentLabel: registryEntry.label ?? parent.kind,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
const handleAddPrimitive = (kind: PrimitiveKind) => {
|
const handleAddPrimitive = (kind: PrimitiveKind) => {
|
||||||
const primitive = PRIMITIVE_REGISTRY.get(kind);
|
const primitive = PRIMITIVE_REGISTRY.get(kind);
|
||||||
if (!primitive) return;
|
if (!primitive) return;
|
||||||
|
|
@ -95,6 +124,38 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
||||||
params: generateDefaultParams(primitive.paramsSchema),
|
params: generateDefaultParams(primitive.paramsSchema),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Nested add: append to the selected container's params.primitives.
|
||||||
|
if (addTargetInfo !== null) {
|
||||||
|
const { parentIndex } = addTargetInfo;
|
||||||
|
const parent = descriptor.primitives[parentIndex];
|
||||||
|
if (!parent) return;
|
||||||
|
const parentParams = parent.params as Record<string, unknown>;
|
||||||
|
const existingChildren =
|
||||||
|
(parentParams.primitives as EffectPrimitiveNode[] | undefined) ?? [];
|
||||||
|
|
||||||
|
const newPrimitives = [...descriptor.primitives];
|
||||||
|
newPrimitives[parentIndex] = {
|
||||||
|
...parent,
|
||||||
|
params: {
|
||||||
|
...parentParams,
|
||||||
|
primitives: [...existingChildren, newNode],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange({ ...descriptor, primitives: newPrimitives });
|
||||||
|
|
||||||
|
// Auto-expand the parent so the new child is visible immediately.
|
||||||
|
setExpandedIndices((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.add(parentIndex);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Keep selection on the parent so successive palette clicks keep
|
||||||
|
// adding children — matches how most block editors behave.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level add.
|
||||||
const newPrimitives = [...descriptor.primitives, newNode];
|
const newPrimitives = [...descriptor.primitives, newNode];
|
||||||
onChange({ ...descriptor, primitives: newPrimitives });
|
onChange({ ...descriptor, primitives: newPrimitives });
|
||||||
setSelectedIndex(newPrimitives.length - 1);
|
setSelectedIndex(newPrimitives.length - 1);
|
||||||
|
|
@ -170,6 +231,40 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
||||||
onChange({ ...descriptor, primitives: newPrimitives });
|
onChange({ ...descriptor, primitives: newPrimitives });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNestedRemove = (parentIdx: number, childIdx: number) => {
|
||||||
|
const parent = descriptor.primitives[parentIdx];
|
||||||
|
if (
|
||||||
|
!parent ||
|
||||||
|
typeof parent.params !== 'object' ||
|
||||||
|
parent.params === null ||
|
||||||
|
!('primitives' in parent.params)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const childPrimitives =
|
||||||
|
((parent.params as Record<string, unknown>).primitives as EffectPrimitiveNode[] | undefined) ?? [];
|
||||||
|
const filtered = childPrimitives.filter((_, i) => i !== childIdx);
|
||||||
|
|
||||||
|
const newPrimitives = [...descriptor.primitives];
|
||||||
|
newPrimitives[parentIdx] = {
|
||||||
|
...parent,
|
||||||
|
params: {
|
||||||
|
...(parent.params as Record<string, unknown>),
|
||||||
|
primitives: filtered,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onChange({ ...descriptor, primitives: newPrimitives });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleParamsChange = (index: number, params: unknown) => {
|
||||||
|
const target = descriptor.primitives[index];
|
||||||
|
if (!target) return;
|
||||||
|
const newPrimitives = [...descriptor.primitives];
|
||||||
|
newPrimitives[index] = { ...target, params };
|
||||||
|
onChange({ ...descriptor, primitives: newPrimitives });
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleExpand = (index: number) => {
|
const handleToggleExpand = (index: number) => {
|
||||||
const newExpanded = new Set(expandedIndices);
|
const newExpanded = new Set(expandedIndices);
|
||||||
if (newExpanded.has(index)) {
|
if (newExpanded.has(index)) {
|
||||||
|
|
@ -239,6 +334,28 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
||||||
<div className="text-sm font-semibold text-neutral-500 uppercase tracking-wider mb-2">
|
<div className="text-sm font-semibold text-neutral-500 uppercase tracking-wider mb-2">
|
||||||
Primitives
|
Primitives
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{addTargetInfo !== null && (
|
||||||
|
<div
|
||||||
|
data-testid="palette-add-target-banner"
|
||||||
|
className="mb-2 rounded-md border border-violet-300 bg-violet-50 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-violet-900">
|
||||||
|
Adding inside:
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-violet-800 truncate">
|
||||||
|
{addTargetInfo.parentLabel}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedIndex(null)}
|
||||||
|
className="mt-1.5 text-violet-700 underline hover:text-violet-900 focus:outline-none focus:ring-2 focus:ring-violet-400 rounded"
|
||||||
|
>
|
||||||
|
Add at top level instead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{allKinds.map(renderPaletteButton)}
|
{allKinds.map(renderPaletteButton)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -258,7 +375,9 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
||||||
onSelect={setSelectedIndex}
|
onSelect={setSelectedIndex}
|
||||||
onToggleExpand={handleToggleExpand}
|
onToggleExpand={handleToggleExpand}
|
||||||
onRemove={handleRemove}
|
onRemove={handleRemove}
|
||||||
|
onParamsChange={handleParamsChange}
|
||||||
onNestedReorder={handleNestedReorder}
|
onNestedReorder={handleNestedReorder}
|
||||||
|
onNestedRemove={handleNestedRemove}
|
||||||
depth={0}
|
depth={0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue