Custom Palette & Panel
Live.Dnd ships a complete editor — a 3-pane Splitter on desktop, a stacked
canvas with Drawers on mobile — and renders it whenever you don't say
otherwise. Everything behind that layout is also reachable as plain data
through three hooks (useDndPalette(), useDndPanel(), useDndLayout()), so
replacing the palette, the property panel, or the arrangement of the whole
thing is a matter of writing an ordinary component and rendering it inside
Live.Dnd. Drag-and-drop and field editing keep working exactly as before.
There are two ways in, and they compose:
- Keep the built-in layout, swap a region. Render
<Live.Dnd.Layout palette={...} panel={...} />and fill only the slot you care about. The Splitter, the mobile FAB, and both Drawers stay. - Replace the layout. Pass your own
childrenand placeLive.Dnd.Canvas(plus whatever you want around it) wherever you like.
Drag in the demo's Hero item and select it to see its "Background
Style" field — an object-shaped binding with no declared type at all —
rendered as one input per key instead of an opaque string, by inferring
structure from the parsed value rather than requiring it upfront. Try
Stats or FAQ too: their items bindings are arrays of objects
whose own children/label fields are further, separately data-bound
JSX elements — each array entry gets its own group of fields instead of
one opaque blob for the whole array.
Custom layout
By default Live.Dnd renders a 3-pane Splitter (palette / canvas / panel)
on desktop, and on mobile a full-width canvas with the palette and property
panel in Drawers. Pass children to design that layout yourself, composing
the three regions wherever you want them:
import { Splitter } from '@jbpark/ui-kit';
<Live.Dnd value={value} onChange={setValue}>
<Splitter withHandle orientation="horizontal">
<Splitter.Panel defaultSize="15%">
<Live.Dnd.Palette />
</Splitter.Panel>
<Splitter.Panel defaultSize="55%">
<Live.Dnd.Canvas />
</Splitter.Panel>
<Splitter.Panel defaultSize="30%">
<Live.Dnd.Panel />
</Splitter.Panel>
</Splitter>
</Live.Dnd>;
Nothing about the layout is prescribed — a vertical stack, tabs, a
collapsible sidebar of your own, panels in a modal, three CSS grid areas —
as long as each region renders somewhere inside Live.Dnd, which owns the
drag context they share.
| Region | Renders |
|---|---|
Live.Dnd.Palette | The built-in palette draggables, in a scroll container. |
Live.Dnd.Canvas | The drop target and the sortable list of sections. |
Live.Dnd.Panel | The built-in property panel, in a scroll container. |
Live.Dnd.Layout | The built-in arrangement, so you can wrap it (a toolbar above it, say) rather than rebuild it, and replace one region through its palette/panel slots. Also how to get the built-in Splitter layout without importing @jbpark/ui-kit yourself. |
Each takes the props of a div (className is merged over the region's own
defaults, so overriding one wins rather than fighting), and each should be
rendered at most once — Canvas in particular carries the scroll container
the section iframes size themselves against, plus fixed drag ids that a
second copy would duplicate.
Two things to know:
Canvasneeds a height-constrained parent. It fills one (h-full), so a parent with no height of its own collapses it.childrenreplaces the mobile chrome too — the floating palette button and both Drawers are part of the default layout, not ofLive.Dnd. TheuseDndLayout()hook returns what drove them (isMobile,selectedId,clearSelection,paletteOpen,setPaletteOpen) so a custom layout can build its own:
import { useDndLayout } from '@jbpark/live-editor/dnd';
const MyLayout = () => {
const { isMobile, selectedId, clearSelection } = useDndLayout();
return isMobile ? <Live.Dnd.Canvas /> : /* ... */;
};
Keeping the built-in layout and swapping one region
Replacing the palette or the panel usually doesn't mean you want a different
arrangement. Live.Dnd.Layout is the built-in one, exported with two slots
so you can hand it your own component and keep everything else:
<Live.Dnd value={value} onChange={setValue}>
<Live.Dnd.Layout panel={<MyPanel />} />
</Live.Dnd>
That keeps the desktop Splitter, the mobile FAB, and both Drawers — your
panel is placed in the right-hand pane on desktop and in the properties
Drawer on mobile. A slot goes in raw, so it owns its own container (the
built-in regions bring theirs); palette works the same way.
Custom palette
useDndPalette() returns what drives the palette:
| Field | Type | Description |
|---|---|---|
items | Section[] | The palette's available sections. |
onAdd | (item: Section) => void | Add a section to the canvas. |
Wrap your own markup in the exported Live.Dnd.DraggableItem so items stay
draggable — it owns the dnd-kit wiring and hands back ref / dragProps /
isDragging. For "tap to add", take isMobile from useDndLayout(): at a
mobile breakpoint a tap can't be read as a failed drag attempt (in the
built-in layout the palette sits in a Drawer with the canvas stacked behind
it, so there's nothing to drag onto) and native dblclick synthesis from
double-tap is unreliable anyway. Treat a single click/tap as "add" there
instead of relying on onDoubleClick, as below.
import { useDndLayout, useDndPalette } from '@jbpark/live-editor/dnd';
const MyPalette = () => {
const { items, onAdd } = useDndPalette();
const { isMobile } = useDndLayout();
return (
<div>
{items.map(item => (
<Live.Dnd.DraggableItem key={item.id} item={item}>
{({ ref, dragProps, isDragging }) => (
<div
ref={ref}
{...dragProps}
onClick={isMobile ? () => onAdd(item) : undefined}
onDoubleClick={() => onAdd(item)}
style={{ opacity: isDragging ? 0.5 : 1 }}
>
{item.name}
</div>
)}
</Live.Dnd.DraggableItem>
))}
</div>
);
};
<Live.Dnd value={value} onChange={setValue}>
<Live.Dnd.Layout palette={<MyPalette />} />
</Live.Dnd>;
Custom panel
useDndPanel() returns what drives the property panel:
| Field | Type | Description |
|---|---|---|
item | Section? | The currently selected section, or undefined if none is selected. |
bindings | PanelBinding[] | The selected section's editable data-binding fields, flattened to one entry per bound property. See the table below. |
onChange | (next: Partial<Section>) => void | Update fields of the selected section directly (not through a binding). |
onDelete | (id: string) => void | Delete a section by id. |
onMoveUp | () => void | Move the selected section up. Alternative to drag-reordering — needed because the canvas sits behind the mobile Drawer this panel renders in, so there's nothing visible to drag onto there. |
onMoveDown | () => void | Move the selected section down, same rationale as onMoveUp. |
canMoveUp | boolean | Whether onMoveUp would do anything right now — disable your move-up control when false. |
canMoveDown | boolean | Whether onMoveDown would do anything right now — disable your move-down control when false. |
onNodeChange | (params: { id: string; label: string; property: string; value: unknown }) => void | Commit an edit to an element addressed by its own data-id, rather than through a binding you were handed. Needed for the nested elements bindings can't reach — pass it to Live.Dnd.Field along with the binding. See below. |
bindings carries each field's type, current value, options (when
present), and an onChange wired straight into the same AST-update pipeline
the built-in panel uses. Switch on binding.type to render your own control:
import { useDndPanel } from '@jbpark/live-editor/dnd';
const MyPanel = () => {
const { item, bindings, onDelete, onMoveUp, onMoveDown, canMoveUp, canMoveDown } =
useDndPanel();
return (
<div>
<header>
{item?.name}
<button disabled={!canMoveUp} onClick={onMoveUp}>
Up
</button>
<button disabled={!canMoveDown} onClick={onMoveDown}>
Down
</button>
{item && <button onClick={() => onDelete(item.id)}>Delete</button>}
</header>
{bindings.map(binding => (
<label key={`${binding.id}-${binding.property}`}>
<span>{binding.label}</span>
{binding.options ? (
<select
value={binding.rawValue}
onChange={e => binding.onChange(e.target.value)}
>
{binding.options.map(o => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : binding.type === 'jsx' || binding.type === 'richtext' ? (
<textarea
defaultValue={binding.rawValue}
onBlur={e => binding.onChange(e.target.value)}
/>
) : (
<input
defaultValue={binding.rawValue}
onBlur={e => binding.onChange(e.target.value)}
/>
)}
</label>
))}
</div>
);
};
Being an ordinary component rather than a callback is the point: a panel can
hold its own state — a validation message, an open/closed group, a
multi-select — without hoisting any of it into the component that renders
Live.Dnd.
Wrapping the built-in panel instead of starting from zero
You don't have to replace the panel to add to it. Live.Dnd.Panel is the
built-in one, so keep it and put your own chrome around it:
const MyPanel = () => (
<div>
<MyCustomHeader />
<Live.Dnd.Panel className="h-auto" />
</div>
);
It reads useDndPanel() itself, so wrapping it can't drop a callback on the
way through — onNodeChange included. (className is merged over the
region's defaults, so h-auto overrides its own h-full and the header above
it doesn't push it out of view.)
onNodeChange matters more than it looks. bindings covers the elements
extract() finds by walking the section's markup, which does not include
JSX nested inside an attribute value — the elements held in an items or
children binding. The built-in array/children editors discover those by
re-extracting that value themselves, and commit them through onNodeChange,
addressing each by its own data-id. No single PanelBinding.onChange can
stand in for it, because each one is bound to a fixed element.
Most shipped sections are affected: in stats, 9 of the section's 10
editable elements are only reachable this way. Drop onNodeChange and those
edits are silent no-ops — the fields still render and accept typing, but
nothing commits.
Reusing the built-in control for one binding
Keeping Live.Dnd.Panel is all-or-nothing: you get the built-in panel for
every field. When you want your own controls for most bindings but not all
of them, Live.Dnd.Field renders the built-in control for a single
binding:
const MyPanel = () => {
const { bindings, onNodeChange } = useDndPanel();
return (
<div>
{bindings.map(binding => (
<label key={`${binding.id}-${binding.property}`}>
<span>{binding.label}</span>
{binding.type === 'array' || binding.property === 'children' ? (
<Live.Dnd.Field binding={binding} onNodeChange={onNodeChange} />
) : (
<MyOwnControl binding={binding} />
)}
</label>
))}
</div>
);
};
Field takes nothing but a PanelBinding out of bindings and the same
onNodeChange — both already part of what useDndPanel() returns, so no
internal wiring is involved.
It renders the control only. The built-in panel's Label (property)
heading comes from the row around it, not from Field, so supply your own
as above.
This is worth the most on items/children/array bindings. Those hold
nested data-bound JSX that bindings can't reach, and reproducing the
editor means re-parsing the value's JSX, walking it for data-id /
data-binding pairs, and translating item positions back to array element
positions before every edit. Field already does that. For a plain string,
number or option binding there's little reason to reach for it — a bare
<input> wired to binding.onChange is the simpler answer.
Your own markup for an array binding: useItemsEditor
Field gives you the built-in control. When you want the same engine but
a different layout, useItemsEditor is that engine on its own:
import { useItemsEditor } from '@jbpark/live-editor/dnd';
const MyItems = ({ binding, onNodeChange }) => {
const { items, selection, actions } = useItemsEditor(binding.rawValue, {
render: binding.render,
onChange: binding.onChange,
onNodeChange,
});
return (
<div>
<button onClick={actions.add}>Add</button>
{items.map(item => (
<details key={item.id}>
<summary>
#{item.index + 1}
<button onClick={() => actions.remove(item.elementIndex)}>x</button>
</summary>
{/* plain properties of an object item */}
{item.properties.map(prop => (
<input
key={prop.label}
defaultValue={prop.rawValue}
onBlur={e => prop.onChange(e.target.value)}
/>
))}
{/* the nested elements `bindings` can't reach */}
{item.nested.map(group =>
group.elements.map(el =>
el.bindings.map(b => (
<Live.Dnd.Field key={b.label} binding={b} onNodeChange={onNodeChange} />
)),
),
)}
</details>
))}
</div>
);
};
Everything it returns is a PanelBinding, so it composes with Field —
render your own layout and still hand individual bindings to the built-in
control where that's good enough, as above.
What you don't have to reimplement:
- Re-parsing each item's JSX to find nested data-bound elements, and
resolving the binding
rendermap onto each property. - Translating a visible item position (
index) to its position in the array's elements (elementIndex), which is what every edit addresses. They differ whenever the array mixes objects and primitives. - Reconciling the selection after a move or delete. Positions shift while the count stays the same, so the selection has to be cleared or replaced or a later bulk action hits the wrong elements.
| Field | Type | Description |
|---|---|---|
kind | 'object' | 'primitive' | Which kind the array is being edited as. An array holding both is treated as objects; the primitives are left untouched rather than dropped. |
items | ItemsEditorItem[] | One entry per item. properties holds an object item's plain fields, nested its JSX-valued ones, value a primitive item's own binding. |
selection | multi-select state | selected, toggle, isSelected, clear, replace. Indices are item index values; the actions translate. |
actions | ItemsEditorActions | add, move, remove, plus duplicateSelected / moveSelected / removeSelected. |
parseError | boolean | The source didn't parse as an array. items is empty. |
The built-in Items panel is written against exactly this hook, so anything
it can do is reachable here.
Array source preservation and limits
Value edits replace only the selected value's source span. Original array
indices are retained: holes and spread entries are omitted from the visible
list, but they remain in the source. For example, changing A in
[, {label:'A'}, {label:'B'}] leaves the leading hole and B untouched.
| Operation | Dense arrays | Arrays with holes or spreads |
|---|---|---|
| Edit a supported literal or object property | Supported | Supported at its original syntax position |
| Move, remove, duplicate, append | Supported | Refused; edit the source code instead |
| Edit a spread entry itself | Not applicable | Refused |
Structural edits preserve raw element text, comments, whitespace and trailing
commas. Gaps between elements stay in their original positions during moves;
deleting an element removes its expression and the necessary separator while
leaving surrounding comments. Copies retain expressions and nested JSX, with
fresh static key and data-id values. Dynamic/ambiguous copy identities,
colliding generated IDs, and parenthesized top-level elements are refused.
The existing last-item guard and empty-array Add limitation remain in place.
A refused operation returns null from the AST helper, leaves source and
selection unchanged, and displays an error through the built-in hook. Public
helper signatures are unchanged. Formatting of returned code now follows the
original source rather than Babel's whole-array formatting.
Your own controls for a children binding: useChildrenEditor
Pass the structured binding.value to useChildrenEditor. It sends structural
commands through the existing binding.onChange callback:
import { useChildrenEditor } from '@jbpark/live-editor/dnd';
const MyChildren = ({ binding }) => {
const { items, selection, actions } = useChildrenEditor(binding.value, {
onChange: binding.onChange,
});
return (
<div>
<button onClick={actions.add}>Add child</button>
<button onClick={actions.duplicateSelected}>Duplicate selected</button>
<button onClick={actions.removeSelected}>Delete selected</button>
{items.map((item, index) => (
<div key={item.id ?? index}>
<input
type="checkbox"
checked={selection.isSelected(index)}
onChange={() => selection.toggle(index)}
/>
Child {index + 1}
<button disabled={index === 0} onClick={() => actions.move(index, index - 1)}>
Move up
</button>
<button onClick={() => actions.remove(index)}>Delete</button>
</div>
))}
</div>
);
};
items contains the extracted DataAttrNode children. selection exposes
selected, toggle, isSelected, clear, and replace. Actions are add,
move(from, to), remove(index), duplicateSelected, moveSelected('up' | 'down'),
and removeSelected. All indices refer to the current extracted list.
For nested field controls, use Live.Dnd.Field with onNodeChange as shown above.
Structural edits preserve each subtree's original source. Text, comments, and
expressions between children stay in their original slots. Copies are appended
before the closing tag, with fresh data-id values on copied JSX elements.
Add copies the first child, or inserts an empty div when the list is empty.
Selection clears when the host supplies changed source, including undo and
external edits; a rejected edit leaves the selection intact.
The AST layer refuses stale commands, invalid indices, unmodeled empty or
expression-only fragments, and copies whose dynamic IDs or trailing spreads
make identity ambiguous. A refused edit returns success: false and keeps the
original code; the built-in panel displays an error. Self-closing parents must
be expanded before adding children.
Existing JSON DataAttrNode[] inputs to update() remain supported for reorder
and removal. Unchanged children reuse their original source. Arbitrary modeled
replacements are accepted only when the old children can be represented without
source loss and the result introduces no duplicate static IDs. Prefer the hook
for structural operations; retain the extracted source metadata for stale-edit
checks. Low-level callers can use ChildrenEdit and getChildrenSignatures from
@jbpark/live-editor/utils/ast.
PanelBinding
| Field | Type | Description |
|---|---|---|
id | string | data-id of the owning element (stable across edits). |
label | string | Human-readable label from the binding definition. |
property | string | The bound prop/attribute name. |
type | BindingType? | The declared data kind — what the value is (string, number, boolean, ...). undefined means a plain string binding. See the full list below. |
widget | string? | The declared presentation — how to render it. An open string, not BindingType: the library can't enumerate controls it doesn't implement, so a custom panel is free to declare and switch on any value it wants (e.g. 'slider'). The built-in panel only recognizes 'icon-picker'/'asset-picker'. |
options | BindingOption[]? | Present when the binding defines a fixed option set (render a <select>). |
render | BindingRenderMap? | Present on object/array bindings whose nested keys/items declare their own types — walk it to type each nested field instead of treating the value as an opaque string. |
min | number? | Minimum value for a number binding. Compared against the real number value delivers — pass it straight to validateBindingValue. |
max | number? | Maximum value for a number binding. |
pattern | string? | A regex source the value must match. |
required | boolean? | Whether an empty value should fail validation. |
meta | Record<string, unknown>? | Any authored key that isn't one of the fields above (a step increment, a unit suffix, a group name, ...) — see below. undefined when nothing extra was authored, not an empty object. |
value | unknown | Current value as its real JS type — a number for a number binding, a boolean, an object/array, a string otherwise. Switch on it without re-parsing. |
rawValue | string | The exact source text behind value, for cases that can't round-trip through a JS value (jsx/richtext, or an expression attribute you want to edit as text). Use it for an <input> defaultValue and for flattenEditableValue/setEditableValue. |
onChange | (value: unknown) => void | Commit an edit through the same AST-update pipeline (including the error toast on a bad edit). Pass the value as its real type — it's serialized once, at the AST boundary, where the declared type is known, so no string quoting on your side. |
BindingType is a closed union of 12 values: array, object, string,
number, boolean, color, jsx, richtext, date, url,
icon-picker, asset-picker. Switching on it alone is enough for the
scalar types; for object/array values, see flattenEditableValue
below rather than treating them as plain text.
An authored type this library doesn't recognize (a typo, or a future
value) doesn't reject the binding — it's dropped, so binding.type is
simply undefined rather than the string you wrote. The same degrade,
not delete, behavior applies one level down: an unrecognized type
inside a render map leaves that leaf's own type undefined instead
of removing the leaf entirely.
Any other key you author on a binding beyond the ones in the table
above — one this library doesn't declare at all, like a step increment or
a unit suffix — isn't dropped either; it survives under binding.meta,
namespaced there instead of spread onto the binding itself so it can't
collide with a future first-class field:
// data-binding={[{ label: 'Spacing', property: 'size', type: 'number', widget: 'slider', min: 0, max: 40, step: 4, unit: 'px' }]}
bindings.map(binding => {
const step = typeof binding.meta?.step === 'number' ? binding.meta.step : 1;
const unit = typeof binding.meta?.unit === 'string' ? binding.meta.unit : '';
return binding.widget === 'slider' ? (
<input
type="range"
min={binding.min}
max={binding.max}
step={step}
defaultValue={binding.rawValue}
onChange={e => binding.onChange(e.target.value)}
/>
) : (
// ...
null
);
});
meta's values are typed unknown — the library can't know what shape a
consumer's own metadata takes — so narrow them, as above, before use.
The shipped Custom Palette & Panel demo's "Content Spacing" field does
exactly this; drag it in the embedded demo above to see it live.
icon-picker and asset-picker are kept in BindingType only so
existing authored content (data-binding={[{ type: 'icon-picker', ... }]})
keeps parsing — they describe a control, not a data kind, so parseBinding
normalizes them into { type: 'string', widget: 'icon-picker' } /
{ type: 'string', widget: 'asset-picker' } rather than passing them
through as-is. A custom panel should switch on binding.widget
for these, not binding.type. The built-in panel's icon set is exported
as ICON_MAP (name -> lucide-react component), and the same
label/value pairs it feeds its own <select> as ICON_OPTIONS, if you
want to reuse either instead of building your own:
import { ICON_MAP, ICON_OPTIONS } from '@jbpark/live-editor';
Author a brand-new field with any other widget the same way — type
still describes the data kind (so validation/coercion keeps working),
widget is free text your own panel switches on:
// data-binding={[{ label: 'Spacing', property: 'size', type: 'number', widget: 'slider', min: 0, max: 40 }]}
bindings.map(binding =>
binding.widget === 'slider' ? (
<input
type="range"
min={binding.min}
max={binding.max}
defaultValue={binding.rawValue}
onChange={e => binding.onChange(e.target.value)}
/>
) : (
// ...fall back to type-based defaults
<input defaultValue={binding.rawValue} onBlur={e => binding.onChange(e.target.value)} />
),
);
Constraints declared on a binding (min/max/pattern/required) aren't
enforced automatically — call the exported validateBindingValue(binding, value) yourself before committing an edit, the same way the built-in panel
does.
Authoring constraints
Two things a data-binding entry has to satisfy for its edits to actually
commit, neither enforced at parse time:
propertymust already exist as an attribute on the element.binding.onChangewrites through the same AST-update pipeline the built-in panel uses, which updates an existing JSX attribute — it doesn't add a new one. A binding whosepropertynames something not already present in the markup (as a prop, or as the element's owninnerText/children/etc.) parses fine and shows up inbindings, but everyonChangecall fails silently on the built-in panel's side (a "Failed to update this field" toast) or returnssuccess: falseif you're callingupdate()yourself.labelmust be unique per element.labelis a display string, not an identifier, but where a binding doesn't declarepropertyit's still how the built-in AST utilities resolve which binding you mean. Two bindings on the same element sharing a label is an authoring mistakeupdate()refuses to silently guess at — it returnssuccess: falseinstead of writing whichever one happened to match first.
Escaping the binding system entirely
data-binding covers declared, per-property edits. For anything else —
restructuring markup, adding a whole new element, editing something with no
binding declared at all — useDndPanel()'s item.code is the section's full
current source, and the exported extract() walks it into the same
DataAttrNode tree the built-in panel reads (including custom, entirely
undeclared data-* attributes, which extract() passes through intact).
Commit any string back with onChange({ code }):
import { extract } from '@jbpark/live-editor/utils/ast';
const RawEditor = () => {
const { item, onChange } = useDndPanel();
if (!item) return null;
const nodes = extract(item.code); // read whatever you need
return (
<button onClick={() => onChange({ code: item.code.replace(/foo/, 'bar') })}>
Edit raw source
</button>
);
};
This is a full escape hatch, not a fallback of last resort — it's the same mechanism Editor mode itself commits through.
Editing object/array values
For an object/array binding, binding.value is already the parsed
object or array, and binding.rawValue is its serialized source text. A
binding's own declared render map (above) is one way to know how to
decompose it, but most content doesn't declare one; e.g. the shipped Hero
item's "Background Style" binding on style has no type or render at
all, since style is just naturally object-shaped.
flattenEditableValue(rawValue) recovers editable structure straight from
the source text, with no declaration required: it parses the string
(the same way parseValue does) and, if the result is an object or array,
recursively walks it, returning one { path, value } entry per primitive
leaf found. path is a list of keys/indices you can hand to
setEditableValue to commit an edit to just that leaf, re-serializing the
whole structure back into a string for binding.onChange. A nested
object/array is walked into further; a string, number, or boolean is
always a leaf — including one that happens to contain JSX. This is what
lets it handle the shipped Stats/FAQ sections' items arrays of
{ key, children }, where children is itself a separately data-bound
JSX element: children comes back as one opaque text leaf rather than
being decomposed further, exactly like parseValue already treats JSX
elsewhere.
const entries = flattenEditableValue(binding.rawValue);
// null if the value isn't an object/array, or is empty
entries?.map(({ path, value }) => (
<input
key={path.join('.')}
defaultValue={String(value)}
onBlur={e =>
binding.onChange(setEditableValue(binding.rawValue, path, e.target.value))
}
/>
));
BindingType, BindingOption, BindingRenderMap, validateBindingValue,
flattenEditableValue, and setEditableValue aren't exported from the
package root — import them from the utils/ast subpath instead:
import {
type BindingOption,
type BindingRenderMap,
type BindingType,
flattenEditableValue,
setEditableValue,
validateBindingValue,
} from '@jbpark/live-editor/utils/ast';