Skip to content

Instantly share code, notes, and snippets.

@Blankeos
Created October 2, 2025 03:32
Show Gist options
  • Select an option

  • Save Blankeos/bb6ffb7d3eede94ed20d790857faf249 to your computer and use it in GitHub Desktop.

Select an option

Save Blankeos/bb6ffb7d3eede94ed20d790857faf249 to your computer and use it in GitHub Desktop.
SoldiJS + Pragmatic Drag and Drop
import {
Accessor,
createContext,
createEffect,
createMemo,
createSignal,
FlowProps,
JSX,
onCleanup,
useContext,
} from 'solid-js';
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import {
draggable,
dropTargetForElements,
monitorForElements,
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
/* ---------- 1. Context ---------- */
export type OnDropEvent = {
sourceId: string | number;
targetId: string | number;
/** Never typesafe, there's no way to infer from internal items. Make sure to cast. */
sourceData: any;
/** Never typesafe, there's no way to infer from internal items. Make sure to cast. */
targetData: any;
/** Mostly no point, it's the same anyway as instanceId anyway. Just for debugging. */
sourceInstanceId: string | null;
/** Mostly no point, it's the same anyway as instanceId anyway. Just for debugging. */
targetInstanceId: string | null;
};
export type OnDropHandler = (event: OnDropEvent) => void;
type DragAndDropContextValue = {
/** Unique identifier for this drag-and-drop instance. */
instanceId: string;
/**
* In-memory lookup registry for all draggable and droppable components in this provider.
* The registry serves as a central reference that maps component IDs to their full data payloads.
* During drag operations, only minimal source data (like IDs) is transferred for performance.
* When a drop occurs, the registry is used to retrieve the complete data objects associated
* with both the source (dragged item) and target (drop location) using their respective IDs.
* This ensures the consumer's `onDrop` callback receives the complete contextual information
* needed to properly handle the drag-and-drop operation.
*/
registry: Map<string | number, { id: string | number; data: any }>;
};
const DragAndDropContext = createContext<DragAndDropContextValue>();
export const useDragAndDropContext = () => {
const ctx = useContext(DragAndDropContext);
if (!ctx) throw new Error('useDragAndDropContext must be used within <DragAndDropProvider>');
return ctx;
};
type DragAndDropProviderProps = {
/** Unique identifier for this drag-and-drop instance. */
instanceId?: string;
onDrop?: OnDropHandler;
onDropTargetChange?: OnDropHandler;
};
export const DragAndDropProvider = (props: FlowProps<DragAndDropProviderProps>) => {
const instanceId = createMemo(
() => props.instanceId ?? `draginstance-${Math.random().toString(36).substring(2, 9)}`
);
let registry = new Map<string | number, { id: string | number; data: any }>();
createEffect(() => {
onCleanup(
monitorForElements({
canMonitor: ({ source }) => {
const s = source.data as { instanceId: string };
return s.instanceId === instanceId();
},
onDropTargetChange: ({ source, location }) => {
if (!props.onDropTargetChange) return;
const target = location.current.dropTargets[0];
if (!target) return;
const sourceId = (source.data as { id: string | number }).id;
const targetId = (target.data as { id: string | number }).id;
if (sourceId === undefined || targetId === undefined) return;
const sourceEntry = registry.get(sourceId);
const targetEntry = registry.get(targetId);
props.onDropTargetChange({
sourceId,
targetId,
sourceData: sourceEntry?.data,
targetData: targetEntry?.data,
sourceInstanceId: (source.data as { instanceId: string }).instanceId,
targetInstanceId: instanceId(),
});
},
onDrop: ({ source, location }) => {
const target = location.current.dropTargets[0];
if (!target) return;
const sourceId = (source.data as { id: string | number }).id;
const targetId = (target.data as { id: string | number }).id;
if (sourceId === undefined || targetId === undefined) return;
const sourceEntry = registry.get(sourceId);
const targetEntry = registry.get(targetId);
props.onDrop?.({
sourceId,
targetId,
sourceData: sourceEntry?.data,
targetData: targetEntry?.data,
sourceInstanceId: (source.data as { instanceId: string }).instanceId,
targetInstanceId: instanceId(),
});
},
})
);
});
const contextValue: DragAndDropContextValue = {
// Does not need to change on mount.
// eslint-disable-next-line solid/reactivity
instanceId: instanceId(),
registry: registry,
};
return (
<DragAndDropContext.Provider value={contextValue}>{props.children}</DragAndDropContext.Provider>
);
};
/* ---------- 2. Building Blocks ---------- */
export type DragState = 'idle' | 'dragging' | 'over';
/**
* DraggableItem is both `draggable()` and `dropTargetForElements()`, just for simplicity
* so we don't need to neste Draggable and Droppable at once.
* dropTargetForElements is enabled by default, but can be disabled.
*/
export const DraggableItem = (props: {
id: string | number;
type?: string;
data?: any;
dropTargetType?: string | string[];
dropTargetCanDrop?: (sourceData: any) => boolean;
/** @defaultValue true */
enableDropTarget?: boolean;
children: (state: Accessor<DragState>, ref: (el: HTMLElement) => void) => JSX.Element;
}) => {
const [state, setState] = createSignal<DragState>('idle');
let ref!: HTMLElement;
const { instanceId, registry } = useDragAndDropContext();
createEffect(() => {
registry.set(props.id, { id: props.id, data: props.data });
onCleanup(() => registry.delete(props.id));
});
createEffect(() => {
onCleanup(
combine(
draggable({
element: ref,
getInitialData: () => ({
id: props.id,
type: props.type || 'item',
instanceId,
data: props.data,
}),
onDragStart: () => setState('dragging'),
onDrag: () => setState('dragging'),
onDrop: () => setState('idle'),
}),
// Exactly like Droppable.
...(props.enableDropTarget !== false
? [
dropTargetForElements({
element: ref,
getData: () => ({ id: props.id }),
getIsSticky: () => true,
canDrop: ({ source }) => {
const s = source.data as {
id: string | number;
type: string;
instanceId: string;
data: any;
};
if (s.instanceId !== instanceId) return false;
const types = Array.isArray(props.dropTargetType)
? props.dropTargetType
: [props.dropTargetType || 'item'];
if (!types.includes(s.type)) return false;
if (props.dropTargetCanDrop) return props.dropTargetCanDrop(s.data);
return true;
},
onDragEnter: () => setState('over'),
onDragLeave: () => setState('idle'),
onDrop: () => setState('idle'),
}),
]
: [])
)
);
});
// eslint-disable-next-line solid/reactivity
return props.children(state, (el) => (ref = el));
};
/** Droppable is only `dropTargetForElements()`, useful for areas where an item can be dragged into. */
export const Droppable = (props: {
id: string | number;
type?: string | string[];
data?: any;
canDrop?: (sourceData: any) => boolean;
children: (state: Accessor<DragState>, ref: (el: HTMLElement) => void) => JSX.Element;
}) => {
const [state, setState] = createSignal<DragState>('idle');
let ref!: HTMLElement;
const { instanceId, registry } = useDragAndDropContext();
createEffect(() => {
registry.set(props.id, { id: props.id, data: props.data });
onCleanup(() => registry.delete(props.id));
});
createEffect(() => {
onCleanup(
combine(
dropTargetForElements({
element: ref,
getData: () => ({ id: props.id }),
getIsSticky: () => true,
canDrop: ({ source }) => {
const s = source.data as {
id: string | number;
type: string;
instanceId: string;
data: any;
};
if (s.instanceId !== instanceId) return false;
const types = Array.isArray(props.type) ? props.type : [props.type || 'item'];
if (!types.includes(s.type)) return false;
if (props.canDrop) return props.canDrop(s.data);
return true;
},
onDragEnter: () => setState('over'),
onDragLeave: () => setState('idle'),
onDrop: () => setState('idle'),
})
)
);
});
// eslint-disable-next-line solid/reactivity
return props.children(state, (el) => (ref = el));
};
/* ---------- 3. Auto-scroll helper ---------- */
import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element';
type AutoScrollOptions = {
canScroll?: (args: { source: any }) => boolean;
};
export const useAutoScroll = (opts?: AutoScrollOptions) => {
let ref: HTMLElement;
createEffect(() => {
const { canScroll = () => true } = opts ?? { canScroll: () => true };
if (!ref) return;
onCleanup(
autoScrollForElements({
element: ref,
canScroll: canScroll,
})
);
});
return (el: HTMLElement) => (ref = el);
};
@Blankeos
Copy link
Author

Blankeos commented Oct 2, 2025

Examples

import {
  DragAndDropProvider,
  DraggableItem,
  Droppable,
  useAutoScroll,
} from '@/components/drag-and-drop/drag-and-drop';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { For, Index } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { TransitionGroup } from 'solid-transition-group';

const arrayMoveImmutable = <T,>(arr: T[], from: number, to: number): T[] => {
  const newArr = [...arr];
  const [item] = newArr.splice(from, 1);
  newArr.splice(to, 0, item);
  return newArr;
};

const PokemonListExample = () => {
  const [list, setList] = createStore([
    {
      id: '001',
      name: 'Bulbasaur',
      img: 'https://oyster.ignimgs.com/mediawiki/apis.ign.com/pokemon-quest/9/9b/001pq.jpg',
    },
    {
      id: '004',
      name: 'Charmander',
      img: 'https://img.rankedboost.com/wp-content/uploads/2018/08/Pokemon-Quest-Charmander.png',
    },
    {
      id: '007',
      name: 'Squirtle',
      img: 'https://img.rankedboost.com/wp-content/uploads/2018/08/Pokemon-Quest-Squirtle.png',
    },
  ]);

  return (
    <DragAndDropProvider
      onDrop={({ sourceId, targetId }) => {
        const sourceIndex = list.findIndex((item) => item.id === sourceId);
        const targetIndex = list.findIndex((item) => item.id === targetId);
        if (sourceIndex === -1 || targetIndex === -1) return;

        const reorderedItems = arrayMoveImmutable(list, sourceIndex, targetIndex);
        setList(reconcile(reorderedItems));
      }}
    >
      <span class="text-xs">List</span>
      <div class="flex flex-col gap-y-2">
        <TransitionGroup name="group-item">
          <For each={list}>
            {(item) => (
              <DraggableItem id={item.id} data={item}>
                {(dragState, dragRef) => (
                  <div
                    ref={dragRef}
                    class={cn(
                      'bg-card flex cursor-grab items-center gap-3 rounded-lg border p-3 shadow-sm transition-all active:cursor-grabbing',
                      dragState() === 'dragging' && 'opacity-50'
                    )}
                  >
                    <img
                      src={item.img}
                      class="h-10 w-10 rounded-full bg-white object-cover object-center"
                      alt={item.name}
                    />
                    <div class="flex-1">
                      <h4 class="text-foreground font-medium">{item.name}</h4>
                      <p class="text-muted-foreground text-xs">ID: {item.id}</p>
                    </div>
                    <div
                      class={`text-xs font-medium ${
                        dragState() === 'dragging'
                          ? 'text-destructive'
                          : dragState() === 'over'
                            ? 'text-primary'
                            : 'text-muted-foreground'
                      }`}
                    >
                      {dragState() === 'dragging'
                        ? 'dragging'
                        : dragState() === 'over'
                          ? 'over'
                          : 'idle'}
                    </div>
                  </div>
                )}
              </DraggableItem>
            )}
          </For>
        </TransitionGroup>
      </div>
    </DragAndDropProvider>
  );
};

const SortAsYouDragExample = () => {
  const [list, setList] = createStore([
    { id: '1', name: 'Alpha', emoji: 'πŸš€' },
    { id: '2', name: 'Beta', emoji: '⭐' },
    { id: '3', name: 'Gamma', emoji: 'πŸ”₯' },
    { id: '4', name: 'Delta', emoji: 'πŸ’Ž' },
  ]);

  return (
    <DragAndDropProvider
      instanceId="sort-list"
      onDropTargetChange={({ sourceId, targetId }) => {
        const sourceIndex = list.findIndex((item) => item.id === sourceId);
        const targetIndex = list.findIndex((item) => item.id === targetId);
        if (sourceIndex === -1 || targetIndex === -1) return;

        const reorderedItems = arrayMoveImmutable(list, sourceIndex, targetIndex);
        setList(reconcile(reorderedItems));
      }}
    >
      <span class="text-xs">List (but sort-as-you-drag, idk it looks cooler)</span>
      <div class="flex flex-col gap-y-2">
        <TransitionGroup name="group-item">
          <For each={list}>
            {(item) => (
              <DraggableItem id={item.id} data={item}>
                {(dragState, dragRef) => (
                  <div
                    ref={dragRef}
                    class={cn(
                      'bg-card cursor-grab rounded-lg border p-3 shadow-sm transition-all active:cursor-grabbing',
                      dragState() === 'over' && 'bg-primary/20 rotate-2',
                      dragState() === 'dragging' && 'opacity-50'
                    )}
                  >
                    <span class="font-medium">
                      {item.emoji} {item.name}
                    </span>
                  </div>
                )}
              </DraggableItem>
            )}
          </For>
        </TransitionGroup>
      </div>
    </DragAndDropProvider>
  );
};

const FruitGridExample = () => {
  const [grid, setGrid] = createStore([
    { id: '🍎', name: 'Apple' },
    { id: '🍊', name: 'Orange' },
    { id: 'πŸ‡', name: 'Grape' },
    { id: '🍌', name: 'Banana' },
    { id: 'πŸ“', name: 'Strawberry' },
    { id: 'πŸ₯', name: 'Kiwi' },
  ]);

  return (
    <DragAndDropProvider
      instanceId="fruit-grid"
      onDrop={({ sourceId, targetId }) => {
        const sourceIndex = grid.findIndex((item) => item.id === sourceId);
        const targetIndex = grid.findIndex((item) => item.id === targetId);
        if (sourceIndex === -1 || targetIndex === -1) return;

        const reorderedItems = arrayMoveImmutable(grid, sourceIndex, targetIndex);
        setGrid(reconcile(reorderedItems));
      }}
    >
      <span class="text-xs">Grid</span>
      <div class="flex flex-col gap-y-2">
        <div class="grid grid-cols-3 gap-2">
          <TransitionGroup name="group-item">
            <For each={grid}>
              {(item) => (
                <DraggableItem id={item.id} data={item}>
                  {(dragState, dragRef) => (
                    <div
                      ref={dragRef}
                      class={cn(
                        'bg-card flex cursor-grab flex-col items-center gap-2 rounded-lg border p-3 shadow-sm transition-all active:cursor-grabbing',
                        dragState() === 'dragging' && 'opacity-20',
                        dragState() === 'over' && 'rotate-3'
                      )}
                    >
                      <div class="text-2xl">{item.id}</div>
                      <div class="text-xs font-medium">{item.name}</div>
                    </div>
                  )}
                </DraggableItem>
              )}
            </For>
          </TransitionGroup>
        </div>

        <Button
          size="sm"
          variant="outline"
          onClick={() => {
            setGrid((items) => [...items].sort(() => Math.random() - 0.5));
          }}
        >
          πŸ”€ Shuffle Fruits
        </Button>
      </div>
    </DragAndDropProvider>
  );
};

const TrelloBoardExample = () => {
  type Task = { id: string; title: string; assignee: string };
  type Column = { columnId: string; title: string; items: Task[] };

  const [board, setBoard] = createStore<Column[]>([
    {
      title: 'Todo',
      columnId: 'todo',
      items: [
        { id: 't1', title: 'Design mockups', assignee: 'Alice' },
        { id: 't2', title: 'Write API docs', assignee: 'Bob' },
        { id: 't3', title: 'Review PR #42', assignee: 'You' },
        { id: 't4', title: 'Update dependencies', assignee: 'Carol' },
        { id: 't5', title: 'Fix login bug', assignee: 'Dave' },
      ],
    },
    {
      title: 'Doing',
      columnId: 'doing',
      items: [
        { id: 'd1', title: 'Setup CI pipeline', assignee: 'Alice' },
        { id: 'd2', title: 'Deploy to staging', assignee: 'Bob' },
      ],
    },
    {
      title: 'Done',
      columnId: 'done',
      items: [],
    },
  ]);
  const [columnSorting, setColumnSorting] = createStore([
    { id: 'todo' },
    { id: 'doing' },
    { id: 'done' },
  ]);

  function moveTask(
    fromColumnIndex: number,
    fromIndex: number,
    toColumnIndex: number,
    toIndex: number
  ) {
    if (fromColumnIndex === toColumnIndex) {
      // Move within the same column
      const column = { ...board[fromColumnIndex] };
      const newItems = arrayMoveImmutable(column.items, fromIndex, toIndex);
      setBoard(fromColumnIndex, 'items', reconcile(newItems));
    } else {
      // Move between columns
      const task = board[fromColumnIndex].items[fromIndex];
      setBoard(
        fromColumnIndex,
        'items',
        reconcile(board[fromColumnIndex].items.filter((_, i) => i !== fromIndex))
      );
      setBoard(
        toColumnIndex,
        'items',
        reconcile(
          (() => {
            const next = [...board[toColumnIndex].items];
            next.splice(toIndex, 0, task);
            return next;
          })()
        )
      );
    }
  }

  function moveColumn(fromIndex: number, toIndex: number) {
    const reordered = arrayMoveImmutable(columnSorting, fromIndex, toIndex);
    setColumnSorting(reconcile(reordered));
  }

  return (
    <DragAndDropProvider
      instanceId="trello-board"
      onDrop={({ sourceId, targetId, sourceData, targetData }) => {
        // COLUMN MOVE
        const isColumn = sourceData && 'column' in sourceData;
        if (isColumn) {
          const fromIndex = columnSorting.findIndex((col) => col.id === sourceId);
          const toIndex = columnSorting.findIndex((col) => col.id === targetId);
          if (fromIndex === -1 || toIndex === -1) return;
          moveColumn(fromIndex, toIndex);
          return;
        }

        // TASK MOVE
        const fromColumnIndex = board.findIndex((col) => col.items.some((t) => t.id === sourceId));
        const toColumnIndex = board.findIndex((col) => col.columnId === targetData?.columnId);
        if (fromColumnIndex === -1 || toColumnIndex === -1) return;

        const fromIndex = board[fromColumnIndex].items.findIndex((t) => t.id === sourceId);
        const toIndex = board[toColumnIndex].items.findIndex((t) => t.id === targetId);
        if (fromIndex === -1) return;

        const insertIndex = toIndex === -1 ? board[toColumnIndex].items.length : toIndex;
        moveTask(fromColumnIndex, fromIndex, toColumnIndex, insertIndex);
      }}
    >
      <span class="text-xs">Trello Board (Cross-list drag & sortable columns)</span>
      <div class="grid grid-cols-3 gap-4">
        <For each={columnSorting}>
          {(column, _columnIndex) => {
            const columnId = column.id;
            const col = board.find((_b) => _b.columnId === columnId)!;

            return (
              <DraggableItem
                id={columnId}
                data={{ column: true, columnId: columnId }}
                dropTargetCanDrop={(_s) => {
                  return true;
                }}
              >
                {(columnState, columnRef) => {
                  const scrollRef = useAutoScroll({
                    canScroll: ({ source }) => {
                      return !('column' in source.data.data);
                    },
                  });

                  return (
                    <div
                      ref={columnRef}
                      class={cn(
                        'bg-background flex cursor-grab flex-col gap-2 rounded-lg border border-dashed transition active:cursor-grabbing',
                        columnState() === 'over' && 'bg-primary/30',
                        columnState() === 'dragging' && 'opacity-50'
                      )}
                    >
                      <h4 class="px-3 pt-3 text-xs font-semibold">{col.title}</h4>
                      <div
                        ref={scrollRef}
                        class="scrollbar-thin flex h-full max-h-[190px] flex-col gap-2 overflow-auto px-3 pb-3"
                      >
                        <For each={col.items}>
                          {(task) => (
                            <DraggableItem id={task.id} data={{ ...task, columnId: columnId }}>
                              {(taskState, taskRef) => (
                                <div
                                  ref={taskRef}
                                  class={cn(
                                    'bg-card relative flex cursor-grab flex-col gap-1 rounded-lg border p-3 shadow-sm transition-all active:cursor-grabbing',
                                    taskState() === 'over' && 'rotate-2',
                                    taskState() === 'dragging' && 'opacity-50'
                                  )}
                                >
                                  <span class="text-sm font-medium">{task.title}</span>
                                  <span class="text-muted-foreground text-xs">
                                    @{task.assignee}
                                  </span>
                                </div>
                              )}
                            </DraggableItem>
                          )}
                        </For>
                      </div>
                    </div>
                  );
                }}
              </DraggableItem>
            );
          }}
        </For>
      </div>
    </DragAndDropProvider>
  );
};

const TrophyDropExample = () => {
  const [slots, setSlots] = createStore<{ left: string | null; right: string | null }>({
    left: null,
    right: 'πŸ†',
  });

  return (
    <DragAndDropProvider
      instanceId="trophy-drop"
      onDrop={({ sourceId, targetId }) => {
        if (sourceId === 'left-slot' && targetId === 'right-slot') {
          setSlots('left', null);
          setSlots('right', 'πŸ†');
        } else if (sourceId === 'right-slot' && targetId === 'left-slot') {
          setSlots('left', 'πŸ†');
          setSlots('right', null);
        }
      }}
    >
      <span class="text-xs">Drag to swap between the two containers</span>
      <div class="flex items-center gap-10">
        <Index each={['left', 'right']}>
          {(side) => (
            <Droppable id={`${side()}-slot`}>
              {(state, ref) => (
                <div
                  ref={ref}
                  class={cn(
                    'bg-background flex h-40 w-40 items-center justify-center rounded-xl border-2 border-dashed transition',
                    state() === 'over' ? 'border-primary bg-primary/50' : ''
                  )}
                >
                  {slots[side() as 'left' | 'right'] ? (
                    <DraggableItem
                      id={`${side()}-slot`}
                      data={{ type: 'item', emoji: slots[side() as 'left' | 'right'] }}
                    >
                      {(dragState, dragRef) => (
                        <div
                          ref={dragRef}
                          class={cn(
                            'animate-fadeIn cursor-grab rounded-lg bg-yellow-200 p-4 text-5xl shadow select-none active:cursor-grabbing',
                            dragState() === 'dragging' && 'invisible'
                          )}
                        >
                          {slots[side() as 'left' | 'right']}
                        </div>
                      )}
                    </DraggableItem>
                  ) : (
                    <span class="text-gray-400">Drop here</span>
                  )}
                </div>
              )}
            </Droppable>
          )}
        </Index>
      </div>
    </DragAndDropProvider>
  );
};

export function DragExample() {
  return (
    <>
      <PokemonListExample />
      <SortAsYouDragExample />
      <FruitGridExample />
      <TrelloBoardExample />
      <TrophyDropExample />
    </>
  );
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment