A powerful SolidJS library for building hierarchical block-based UIs with drag-and-drop, multi-selection, and smooth animations.
Care has been taken to ensure everything "just works" with minimal configuration or intervention.
Demo:https://alexanderrafferty.com/projects/solid-nest/
- Tiny bundle size - Only ~6kB minified + gzipped!
- Drag-and-drop - Intuitive block reordering with visual feedback
- Unlimited nesting - Create deeply nested hierarchies
- Multi-selection - Select and move multiple blocks at once
- Copy/paste - Supports copy, cut and paste callbacks
- Smooth animations - Performant transitions with no jank
- (Nearly) Headless UI - Bring your own styles and components*
- Tag-based constraints - Control which blocks can be nested where
- Mobile support - Supports both mouse and touch events
*Some minor styling is provided for convenience, but it's easy to override
Some features I plan to add in the future include:
- Rectangular selection
npm i solid-nest
# or
yarn add solid-nest
# or
pnpm add solid-nestimport{BlockTree,createBlockTree}from'solid-nest'typeMyBlock={id: stringtext: stringchildren?: MyBlock[]}functionApp(){// Define your block structureconstroot: MyBlock={id: 'root',text: 'Root',children: [{id: 'a',text: 'First block'},{id: 'b',text: 'Second block'},{id: 'c',text: 'Third block'},],}return(<BlockTreeroot={root}getKey={block=>block.id}getChildren={block=>block.children}>{/* Defines how each block in the tree should be rendered */}{props=>(<divclass="border rounded p-4">{/* Add data-drag-handle to elements that should initiate drag */}<divdata-drag-handleclass="cursor-grab"><p>{props.block.text}</p></div><divclass="mt-4">{props.children}</div></div>)}</BlockTree>)}The BlockTree is the primary component exposed by this library, and is used as follows:
<BlockTree// The root block of the treeroot={root}// Functions to extract data from blocksgetKey={block=>block.id}getChildren={block=>block.children}getOptions={block=>({spacing: 16,tag: 'item'})}// The currently selected blocksselection={{blocks: ['key1','key2']}}// Various event handlers; because this is a controlled component,// the state of the tree won't update unless these are handledonSelectionChange={event=>{}}onInsert={event=>{}}onReorder={event=>{}}onRemove={event=>{}}// ...various additional events and configuration props, documented below>{/* A function to render each block in the tree */}{props=><YourBlockComponent{...props}/>}</BlockTree>Blocks are the fundamental building units of your tree. BlockTree doesn't require your blocks to have a specific shape. Instead, you provide functions to extract the necessary information:
getKey- Function that returns a unique identifier for each blockgetChildren- Function that returns the child blocks (optional)getOptions- Function that returns configuration options (optional)
Your block type can be any shape you want:
typeMyBlock={id: stringtext: stringchildren?: MyBlock[]}<BlockTreeroot={root}getKey={block=>block.id}getChildren={block=>block.children}>{/* ... */}</BlockTree>Notably, child blocks don't have to be physically nested inside their parent blocks. For example, blocks might simply store the keys of its children,
but instead rely on the getChildren function to fetch the child blocks from somewhere else.
The getOptions function can return configuration for each block:
typeBlockOptions={spacing?: number// Spacing between children (in pixels)tag?: string// Tag for drag-and-drop constraintsaccepts?: string[]// Array of tags this block accepts as children}Example:
<BlockTreeroot={root}getKey={block=>block.id}getChildren={block=>block.children}getOptions={block=>({spacing: block.type==='container' ? 20 : 12,tag: block.type,accepts: block.type==='container' ? ['item'] : []})}>{/* ... */}</BlockTree>The BlockTree component accepts various props to configure its behaviour.
The only required props are root and children, though you'll almost certainly want to implement
most if not all of the event handlers too, otherwise the block tree won't be editable.
| Prop | Type | Default | Description |
|---|---|---|---|
root | R | required | The root block of the tree |
children | Component<BlockProps<K, T>> | required | Render function for blocks |
getKey | (block: T | R) => K | required | Function to get a block's unique key |
getChildren | (block: T | R) => T[] | null | undefined | Function to get a block's children | |
getOptions | (block: T | R) => BlockOptions | null | undefined | Function to get a block's options | |
selection | Selection<K> | Current selection | |
onSelectionChange | (event: SelectionEvent<K>) => void | Called when selection changes | |
onInsert | EventHandler<InsertEvent<K, T>> | Called when blocks are inserted | |
onReorder | EventHandler<ReorderEvent<K>> | Called when blocks are reordered | |
onRemove | EventHandler<RemoveEvent<K>> | Called when blocks are removed | |
onCopy | EventHandler<CopyEvent<T>> | Called when blocks are copied | |
onCut | EventHandler<CutEvent<T>> | Called when blocks are cut | |
onPaste | EventHandler<PasteEvent<K>> | Called when blocks are pasted | |
transitionDuration | number | 200 | Animation duration (ms) |
dragThreshold | number | 10 | Distance cursor must move (px) to start drag |
fixedHeightWhileDragging | boolean | false | Fix container height during drag operations |
multiselect | boolean | true | Enable multi-selection |
dropzone | Component<{}> | Custom dropzone component | |
placeholder | Component<{ parent: K }> | Custom placeholder component | |
dragContainer | Component<DragContainerProps<T>> | Custom drag container component |
A Selection can either be:
- A set of blocks (when
blockshas a value) - A place between blocks, like an insertion cursor (when
placehas a value) - Empty (when neither property has a value)
It is not valid for both properties to have a value at the same time.
typeSelection<K>={blocks?: K[]place?: Place<K>}The BlockTree render function receives these props for each block:
| Prop | Type | Description |
|---|---|---|
key | K | Block's unique key |
block | T | The block data |
selected | boolean | Whether block is currently selected |
dragging | boolean | Whether block is being dragged |
children | JSX.Element | Rendered child blocks |
It's perfectly fine not to render the children in the render function, or only conditionally render it.
This will prevent the user from inserting any new child blocks via drag-and-drop, though any existing children
will remain unless programmatically removed.
To make an element draggable, add the data-drag-handle attribute to it. When a user clicks and drags an element with this attribute, it will initiate a drag operation for the block. The entire block can be made draggable by adding this attribute to the root element.
If there are any elements inside the block that should be able to take focus, like input elements, ensure you add an event handler for the onPointerDown event that calls event.stopPropagation, otherwise focus will be immediately lost and the block itself will become selected.
{props=>(<divdata-drag-handle><p>{props.block.text}</p><inputonPointerDown={ev=>ev.stopPropagation()}/></div>)}The BlockTree component emits various kinds of events in response to drag-and-drop and other interactions.
Keep in mind that the state of the block tree won't actually update unless these events are listened to,
and the state is updated accordingly. In other words, BlockTree is a controlled component and state management
is left up to the consumer.
The EventHandler type referenced in the BlockTree props table is simply a callback function:
exporttypeEventHandler<E>=(event: E)=>voidFired when blocks are selected or deselected. This is a discriminated union with three possible variants:
typeSelectionEvent<K>=|{kind: 'blocks'// A block was clickedkey: K// The block that was clickedmode: SelectionMode// The selection mode (explained below)blocks: K[]// The new set of selected blocks}|{kind: 'place'// A gap between blocks was clickedplace: Place<K>// The insertion point that was clicked}|{kind: 'deselect'// Focus was lost from the block tree}The mode property indicates how the selection was modified when a block was clicked:
| Mode | Value | Trigger | Behavior |
|---|---|---|---|
| Set | 'set' | Click (no modifiers) | Selects the clicked block, deselects all others |
| Toggle | 'toggle' | Cmd/Ctrl + Click | Toggles the clicked block's selection state |
| Range | 'range' | Shift + Click | Selects all blocks between the first selected block and the clicked block (at the same nesting level) |
Fired when new blocks are inserted.
typeInsertEvent<K,T>={blocks: T[]// Blocks being insertedplace: {parent: K// Parent block keybefore: K|null// Insert before this key, or `null` for end}}Fired when blocks are reordered via drag-and-drop:
typeReorderEvent={keys: K[]// Keys of blocks being movedplace: {parent: K// Parent block keybefore: K|null// Insert before this key, or `null` for end}}Fired when blocks are removed (e.g., via the Delete key):
typeRemoveEvent={keys: K[]// Keys of blocks being removed}Fired when blocks are copied (Cmd/Ctrl + C):
typeCopyEvent<T>={blocks: T[]// Blocks being copieddata: DataTransfer// Clipboard data transfer object}You can use the data object to set clipboard data in any format you need:
onCopy={(event)=>{constjson=JSON.stringify(event.blocks)event.data.setData('application/json',json)event.data.setData('text/plain',`Copied ${event.blocks.length} blocks`)}}Fired when blocks are cut (Cmd/Ctrl + X):
typeCutEvent<T>={blocks: T[]// Blocks being cutdata: DataTransfer// Clipboard data transfer object}Similar to CopyEvent, but typically you'll also want to remove the blocks after cutting.
Fired when data is pasted (Cmd/Ctrl + V):
typePasteEvent<K>={place: Place<K>// Where the data should be pasteddata: DataTransfer// Clipboard data transfer object}You'll need to parse the clipboard data and insert the blocks:
onPaste={(event)=>{constjson=event.data.getData('application/json')if(json){constblocks=JSON.parse(json)// Insert blocks at `event.place`}}}BlockTree is a controlled component, meaning you're responsible for managing the state of your blocks. The component provides event handlers that tell you when changes occur, but you need to update your state accordingly.
Here's a basic example of managing state manually:
import{createSignal}from'solid-js'import{BlockTree}from'solid-nest'typeMyBlock={id: stringtext: stringchildren?: MyBlock[]}functionApp(){const[root,setRoot]=createSignal<MyBlock>({id: 'root',text: 'Root',children: []})const[selection,setSelection]=createSignal<{blocks?: string[]}>({})consthandleReorder=(event: ReorderEvent<string>)=>{// Update your state to reflect the reordering// Implementation depends on your state structure}return(<BlockTreeroot={root()}getKey={block=>block.id}getChildren={block=>block.children}selection={selection()}onSelectionChange={event=>{if(event.kind==='blocks'){setSelection({blocks: event.blocks})}elseif(event.kind==='deselect'){setSelection({})}}}onReorder={handleReorder}>{props=>(<divdata-drag-handle>{props.block.text}<div>{props.children}</div></div>)}</BlockTree>)}For more complex state management needs, you may want to use SolidJS stores or integrate with your existing state management solution. See the examples in the repository for more detailed implementations.
Control which blocks can be nested where using tags. Tags are configured via the getOptions function.
Note that blocks without a tag will be accepted by any parent block.
typeMyBlock={id: stringtype: 'container'|'item'text: stringchildren?: MyBlock[]}constroot: MyBlock={id: 'root',type: 'container',text: 'Root',children: [{id: 'container',type: 'container',text: 'Container',children: [],},{id: 'item1',type: 'item',text: 'Item',},],}<BlockTreeroot={root}getKey={block=>block.id}getChildren={block=>block.children}getOptions={block=>({tag: block.type,accepts: block.type==='container' ? ['item'] : []})}>{/* ... */}</BlockTree>To further customise the look and feel of a BlockTree, the following components can be replaced with a custom implementation:
Placeholder- Shown when a block has no childrenDropzone- Shows where the dragged block(s) will be moved to when the mouse is releasedDragContainer- Wraps the dragged component
A placeholder is shown when a block has no children. By default, it just an empty <div> which takes up no space, but it can be changed to a custom component.
The component receives the key of the block it belongs to, allowing you to use different UIs for different blocks.
constPlaceholder=({ parent })=>(<divclass="empty-state">
No items in {parent}</div>)<BlockTreeroot={root()}placeholder={Placeholder}>{/* ... */}</BlockTree>The dropzone visually shows where the currently dragged block(s) will be placed when the mouse is released. By default, it is a semi-transparent black rectangle, but this too can be customised by providing a custom component.
Note: You'll probably want to give this component a height of 100% to ensure it fills the available space.
constDropzone=()=>(<divclass="custom-dropzone"style={{height: '100%'}}>
Drop here
</div>)<BlockTreeroot={root()}dropzone={Dropzone}>{/* ... */}</BlockTree>The drag container wraps the dragged block(s) during a drag operation. By default, it creates a stacked visual effect when multiple blocks are selected, showing up to 3 blocks with a slight offset to indicate multiple items are being dragged. The component receives the blocks being dragged and the rendered children.
constDragContainer=(props: DragContainerProps<MyBlock>)=>(<divclass="custom-drag-container">{props.children}<Showwhen={props.blocks.length>1}><spanclass="badge">{props.blocks.length} items</span></Show></div>)<BlockTreeroot={root()}dragContainer={DragContainer}>{/* ... */}</BlockTree>The BlockTree component has built-in support for the following keyboard shortcuts:
- Delete - Remove selected blocks
- Cmd/Ctrl + C - Copy selected blocks
- Cmd/Ctrl + X - Cut selected blocks
- Cmd/Ctrl + V - Paste blocks
MIT
Contributions are welcome! Please feel free to submit a Pull Request.