Created
October 2, 2025 03:32
-
-
Save Blankeos/bb6ffb7d3eede94ed20d790857faf249 to your computer and use it in GitHub Desktop.
SoldiJS + Pragmatic Drag and Drop
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| }; |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Examples