@pyreon/dnd 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
2
+ import { onCleanup, signal } from "@pyreon/reactivity"
3
+ import type { DragData } from "./types"
4
+
5
+ export interface UseDragMonitorOptions {
6
+ /** Called on any drag start in the page. */
7
+ onDragStart?: (data: DragData) => void
8
+ /** Called on any drop in the page. */
9
+ onDrop?: (sourceData: DragData, targetData: DragData) => void
10
+ /** Filter which drags to monitor. */
11
+ canMonitor?: (data: DragData) => boolean
12
+ }
13
+
14
+ export interface UseDragMonitorResult {
15
+ /** Whether any element is currently being dragged. */
16
+ isDragging: () => boolean
17
+ /** Data of the currently dragging element (null if not dragging). */
18
+ dragData: () => DragData | null
19
+ }
20
+
21
+ /**
22
+ * Monitor all drag operations on the page.
23
+ * Useful for global drag indicators, analytics, or coordination between
24
+ * multiple drag-and-drop areas.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * const { isDragging, dragData } = useDragMonitor({
29
+ * canMonitor: (data) => data.type === "card",
30
+ * onDrop: (source, target) => logDrop(source, target),
31
+ * })
32
+ *
33
+ * <Show when={isDragging()}>
34
+ * <div class="global-drag-overlay">
35
+ * Dragging: {() => dragData()?.name}
36
+ * </div>
37
+ * </Show>
38
+ * ```
39
+ */
40
+ export function useDragMonitor(options?: UseDragMonitorOptions): UseDragMonitorResult {
41
+ const isDragging = signal(false)
42
+ const dragData = signal<DragData | null>(null)
43
+
44
+ const canMonitorFn = options?.canMonitor
45
+ ? ({ source }: { source: { data: Record<string, unknown> } }) =>
46
+ options.canMonitor?.(source.data as DragData) ?? true
47
+ : null
48
+
49
+ const cleanup = monitorForElements({
50
+ ...(canMonitorFn ? { canMonitor: canMonitorFn } : {}),
51
+ onDragStart: ({ source }) => {
52
+ isDragging.set(true)
53
+ dragData.set(source.data as DragData)
54
+ options?.onDragStart?.(source.data as DragData)
55
+ },
56
+ onDrop: ({ source, location }) => {
57
+ isDragging.set(false)
58
+ dragData.set(null)
59
+ const targetData = location.current.dropTargets[0]?.data ?? {}
60
+ options?.onDrop?.(source.data as DragData, targetData as DragData)
61
+ },
62
+ })
63
+
64
+ onCleanup(cleanup)
65
+
66
+ return { isDragging, dragData }
67
+ }
@@ -0,0 +1,66 @@
1
+ import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
2
+ import { onCleanup, signal } from "@pyreon/reactivity"
3
+ import type { DragData, UseDraggableOptions, UseDraggableResult } from "./types"
4
+
5
+ /**
6
+ * Make an element draggable with signal-driven state.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * let cardEl: HTMLElement | null = null
11
+ *
12
+ * const { isDragging } = useDraggable({
13
+ * element: () => cardEl,
14
+ * data: { id: card.id, type: "card" },
15
+ * })
16
+ *
17
+ * <div ref={(el) => cardEl = el} class={isDragging() ? "opacity-50" : ""}>
18
+ * {card.title}
19
+ * </div>
20
+ * ```
21
+ */
22
+ export function useDraggable<T extends DragData = DragData>(
23
+ options: UseDraggableOptions<T>,
24
+ ): UseDraggableResult {
25
+ const isDragging = signal(false)
26
+ let cleanup: (() => void) | undefined
27
+
28
+ function setup() {
29
+ if (cleanup) cleanup()
30
+
31
+ const el = options.element()
32
+ if (!el) return
33
+
34
+ const resolveData = () =>
35
+ typeof options.data === "function" ? (options.data as () => T)() : options.data
36
+
37
+ const handle = options.handle?.()
38
+ cleanup = draggable({
39
+ element: el,
40
+ ...(handle ? { dragHandle: handle } : {}),
41
+ getInitialData: resolveData,
42
+ canDrag: () => {
43
+ const disabled = options.disabled
44
+ if (typeof disabled === "function") return !disabled()
45
+ return !disabled
46
+ },
47
+ onDragStart: () => {
48
+ isDragging.set(true)
49
+ options.onDragStart?.()
50
+ },
51
+ onDrop: () => {
52
+ isDragging.set(false)
53
+ options.onDragEnd?.()
54
+ },
55
+ })
56
+ }
57
+
58
+ // Defer setup to next microtask so refs are populated
59
+ queueMicrotask(setup)
60
+
61
+ onCleanup(() => {
62
+ if (cleanup) cleanup()
63
+ })
64
+
65
+ return { isDragging }
66
+ }
@@ -0,0 +1,67 @@
1
+ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
2
+ import { onCleanup, signal } from "@pyreon/reactivity"
3
+ import type { DragData, UseDroppableOptions, UseDroppableResult } from "./types"
4
+
5
+ /**
6
+ * Make an element a drop target with signal-driven state.
7
+ *
8
+ * @example
9
+ * ```tsx
10
+ * let zoneEl: HTMLElement | null = null
11
+ *
12
+ * const { isOver } = useDroppable({
13
+ * element: () => zoneEl,
14
+ * onDrop: (data) => handleDrop(data),
15
+ * canDrop: (data) => data.type === "card",
16
+ * })
17
+ *
18
+ * <div ref={(el) => zoneEl = el} class={isOver() ? "bg-blue-50" : ""}>
19
+ * Drop here
20
+ * </div>
21
+ * ```
22
+ */
23
+ export function useDroppable<T extends DragData = DragData>(
24
+ options: UseDroppableOptions<T>,
25
+ ): UseDroppableResult {
26
+ const isOver = signal(false)
27
+ let cleanup: (() => void) | undefined
28
+
29
+ function setup() {
30
+ if (cleanup) cleanup()
31
+
32
+ const el = options.element()
33
+ if (!el) return
34
+
35
+ cleanup = dropTargetForElements({
36
+ element: el,
37
+ getData: () => {
38
+ if (!options.data) return {}
39
+ return typeof options.data === "function" ? (options.data as () => T)() : options.data
40
+ },
41
+ canDrop: ({ source }) => {
42
+ if (!options.canDrop) return true
43
+ return options.canDrop(source.data as DragData)
44
+ },
45
+ onDragEnter: ({ source }) => {
46
+ isOver.set(true)
47
+ options.onDragEnter?.(source.data as DragData)
48
+ },
49
+ onDragLeave: () => {
50
+ isOver.set(false)
51
+ options.onDragLeave?.()
52
+ },
53
+ onDrop: ({ source }) => {
54
+ isOver.set(false)
55
+ options.onDrop?.(source.data as DragData)
56
+ },
57
+ })
58
+ }
59
+
60
+ queueMicrotask(setup)
61
+
62
+ onCleanup(() => {
63
+ if (cleanup) cleanup()
64
+ })
65
+
66
+ return { isOver }
67
+ }
@@ -0,0 +1,131 @@
1
+ import {
2
+ dropTargetForExternal,
3
+ monitorForExternal,
4
+ } from "@atlaskit/pragmatic-drag-and-drop/external/adapter"
5
+ import { containsFiles, getFiles } from "@atlaskit/pragmatic-drag-and-drop/external/file"
6
+ import { onCleanup, signal } from "@pyreon/reactivity"
7
+
8
+ export interface UseFileDropOptions {
9
+ /** Element getter for the drop zone. */
10
+ element: () => HTMLElement | null
11
+ /** Called when files are dropped. */
12
+ onDrop: (files: File[]) => void
13
+ /** Filter accepted file types (e.g. ["image/*", ".pdf"]). */
14
+ accept?: string[]
15
+ /** Maximum number of files. */
16
+ maxFiles?: number
17
+ /** Whether drop is disabled. */
18
+ disabled?: boolean | (() => boolean)
19
+ }
20
+
21
+ export interface UseFileDropResult {
22
+ /** Whether files are being dragged over the drop zone. */
23
+ isOver: () => boolean
24
+ /** Whether files are being dragged anywhere on the page. */
25
+ isDraggingFiles: () => boolean
26
+ }
27
+
28
+ /**
29
+ * File drop zone with signal-driven state.
30
+ * Uses the native file drag events via pragmatic-drag-and-drop.
31
+ *
32
+ * @example
33
+ * ```tsx
34
+ * let dropZone: HTMLElement | null = null
35
+ *
36
+ * const { isOver, isDraggingFiles } = useFileDrop({
37
+ * element: () => dropZone,
38
+ * accept: ["image/*", ".pdf"],
39
+ * maxFiles: 5,
40
+ * onDrop: (files) => upload(files),
41
+ * })
42
+ *
43
+ * <div
44
+ * ref={(el) => dropZone = el}
45
+ * class={isOver() ? "drop-active" : isDraggingFiles() ? "drop-ready" : ""}
46
+ * >
47
+ * Drop files here
48
+ * </div>
49
+ * ```
50
+ */
51
+ export function useFileDrop(options: UseFileDropOptions): UseFileDropResult {
52
+ const isOver = signal(false)
53
+ const isDraggingFiles = signal(false)
54
+ let cleanup: (() => void) | undefined
55
+
56
+ function matchesAccept(file: File, accept: string[]): boolean {
57
+ return accept.some((pattern) => {
58
+ if (pattern.startsWith(".")) {
59
+ return file.name.toLowerCase().endsWith(pattern.toLowerCase())
60
+ }
61
+ if (pattern.endsWith("/*")) {
62
+ return file.type.startsWith(pattern.slice(0, -1))
63
+ }
64
+ return file.type === pattern
65
+ })
66
+ }
67
+
68
+ function setup() {
69
+ if (cleanup) cleanup()
70
+
71
+ const el = options.element()
72
+ if (!el) return
73
+
74
+ const cleanups: (() => void)[] = []
75
+
76
+ // Monitor for file drags anywhere on the page
77
+ cleanups.push(
78
+ monitorForExternal({
79
+ canMonitor: ({ source }) => containsFiles({ source }),
80
+ onDragStart: () => isDraggingFiles.set(true),
81
+ onDrop: () => isDraggingFiles.set(false),
82
+ }),
83
+ )
84
+
85
+ // Drop target on the specific element
86
+ cleanups.push(
87
+ dropTargetForExternal({
88
+ element: el,
89
+ canDrop: ({ source }) => {
90
+ const disabled = options.disabled
91
+ if (typeof disabled === "function" ? disabled() : disabled) return false
92
+ return containsFiles({ source })
93
+ },
94
+ onDragEnter: () => isOver.set(true),
95
+ onDragLeave: () => isOver.set(false),
96
+ onDrop: ({ source }) => {
97
+ isOver.set(false)
98
+ isDraggingFiles.set(false)
99
+
100
+ let files = getFiles({ source })
101
+
102
+ // Filter by accept
103
+ if (options.accept && options.accept.length > 0) {
104
+ files = files.filter((f) => matchesAccept(f, options.accept as string[]))
105
+ }
106
+
107
+ // Limit count
108
+ if (options.maxFiles && files.length > options.maxFiles) {
109
+ files = files.slice(0, options.maxFiles)
110
+ }
111
+
112
+ if (files.length > 0) {
113
+ options.onDrop(files)
114
+ }
115
+ },
116
+ }),
117
+ )
118
+
119
+ cleanup = () => {
120
+ for (const fn of cleanups) fn()
121
+ }
122
+ }
123
+
124
+ queueMicrotask(setup)
125
+
126
+ onCleanup(() => {
127
+ if (cleanup) cleanup()
128
+ })
129
+
130
+ return { isOver, isDraggingFiles }
131
+ }
@@ -0,0 +1,224 @@
1
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"
2
+ import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"
3
+ import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"
4
+ import {
5
+ attachClosestEdge,
6
+ type Edge,
7
+ extractClosestEdge,
8
+ } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"
9
+ import { onCleanup, signal } from "@pyreon/reactivity"
10
+ import type { DropEdge, UseSortableOptions, UseSortableResult } from "./types"
11
+
12
+ const SORT_KEY = "__pyreon_sortable_key"
13
+ const SORT_ID = "__pyreon_sortable_id"
14
+
15
+ let _sortableCounter = 0
16
+
17
+ /**
18
+ * Sortable list with signal-driven state, auto-scroll, and edge detection.
19
+ *
20
+ * Features:
21
+ * - Keyed drag items matching `<For by={...}>` pattern
22
+ * - Auto-scroll when dragging near container edges
23
+ * - Closest-edge detection (drop above/below or left/right)
24
+ * - Axis constraint (vertical/horizontal)
25
+ * - Keyboard reordering (Alt+Arrow keys)
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * const items = signal([
30
+ * { id: "1", name: "Alice" },
31
+ * { id: "2", name: "Bob" },
32
+ * { id: "3", name: "Charlie" },
33
+ * ])
34
+ *
35
+ * const { containerRef, itemRef, activeId, overId, overEdge } = useSortable({
36
+ * items,
37
+ * by: (item) => item.id,
38
+ * onReorder: (newItems) => items.set(newItems),
39
+ * })
40
+ *
41
+ * <ul ref={containerRef}>
42
+ * <For each={items()} by={item => item.id}>
43
+ * {(item) => (
44
+ * <li
45
+ * ref={itemRef(item.id)}
46
+ * class={activeId() === item.id ? "dragging" : ""}
47
+ * style={overId() === item.id ? `border-${overEdge()}: 2px solid blue` : ""}
48
+ * >
49
+ * {item.name}
50
+ * </li>
51
+ * )}
52
+ * </For>
53
+ * </ul>
54
+ * ```
55
+ */
56
+ export function useSortable<T>(options: UseSortableOptions<T>): UseSortableResult {
57
+ const sortableId = `sortable-${++_sortableCounter}`
58
+ const activeId = signal<string | number | null>(null)
59
+ const overId = signal<string | number | null>(null)
60
+ const overEdge = signal<DropEdge | null>(null)
61
+ const axis = options.axis ?? "vertical"
62
+
63
+ const cleanups: (() => void)[] = []
64
+
65
+ /** Perform the reorder based on current active/over/edge state. */
66
+ function performReorder() {
67
+ const dragId = activeId.peek()
68
+ const dropId = overId.peek()
69
+ const edge = overEdge.peek()
70
+ if (dragId == null || dropId == null || dragId === dropId) return
71
+
72
+ const currentItems = options.items()
73
+ const dragIndex = currentItems.findIndex((item) => options.by(item) === dragId)
74
+ const dropIndex = currentItems.findIndex((item) => options.by(item) === dropId)
75
+ if (dragIndex === -1 || dropIndex === -1) return
76
+
77
+ const reordered = [...currentItems]
78
+ const [moved] = reordered.splice(dragIndex, 1)
79
+ if (!moved) return
80
+
81
+ // Determine insert position based on closest edge
82
+ const rawInsert =
83
+ edge === "bottom" || edge === "right"
84
+ ? dropIndex >= dragIndex
85
+ ? dropIndex
86
+ : dropIndex + 1
87
+ : dropIndex <= dragIndex
88
+ ? dropIndex
89
+ : dropIndex - 1
90
+ const insertAt = Math.max(0, Math.min(rawInsert, reordered.length))
91
+
92
+ reordered.splice(insertAt, 0, moved)
93
+ options.onReorder(reordered)
94
+ }
95
+
96
+ function containerRef(el: HTMLElement) {
97
+ // Auto-scroll when dragging near container edges
98
+ cleanups.push(
99
+ autoScrollForElements({
100
+ element: el,
101
+ canScroll: ({ source }) => source.data[SORT_ID] === sortableId,
102
+ }),
103
+ )
104
+
105
+ // Container is a drop target for reorder finalization
106
+ cleanups.push(
107
+ dropTargetForElements({
108
+ element: el,
109
+ getData: () => ({ [SORT_ID]: sortableId }),
110
+ canDrop: ({ source }) => source.data[SORT_ID] === sortableId,
111
+ onDrop: () => {
112
+ performReorder()
113
+ activeId.set(null)
114
+ overId.set(null)
115
+ overEdge.set(null)
116
+ },
117
+ }),
118
+ )
119
+
120
+ // Keyboard reordering: Alt+Arrow keys
121
+ const keyHandler = (e: KeyboardEvent) => {
122
+ if (!e.altKey) return
123
+
124
+ const isUp = axis === "vertical" ? e.key === "ArrowUp" : e.key === "ArrowLeft"
125
+ const isDown = axis === "vertical" ? e.key === "ArrowDown" : e.key === "ArrowRight"
126
+ if (!isUp && !isDown) return
127
+
128
+ const focused = document.activeElement as HTMLElement | null
129
+ if (!focused || !el.contains(focused)) return
130
+
131
+ const focusedKey = focused.dataset.pyreonSortKey
132
+ if (!focusedKey) return
133
+
134
+ e.preventDefault()
135
+
136
+ const currentItems = options.items()
137
+ const currentIndex = currentItems.findIndex((item) => String(options.by(item)) === focusedKey)
138
+ if (currentIndex === -1) return
139
+
140
+ const targetIndex = isUp ? currentIndex - 1 : currentIndex + 1
141
+ if (targetIndex < 0 || targetIndex >= currentItems.length) return
142
+
143
+ const reordered = [...currentItems]
144
+ const temp = reordered[currentIndex]
145
+ reordered[currentIndex] = reordered[targetIndex] as T
146
+ reordered[targetIndex] = temp as T
147
+ options.onReorder(reordered)
148
+
149
+ // Restore focus after DOM update
150
+ requestAnimationFrame(() => {
151
+ const items = el.querySelectorAll("[data-pyreon-sort-key]")
152
+ for (const item of items) {
153
+ if ((item as HTMLElement).dataset.pyreonSortKey === focusedKey) {
154
+ ;(item as HTMLElement).focus()
155
+ break
156
+ }
157
+ }
158
+ })
159
+ }
160
+
161
+ el.addEventListener("keydown", keyHandler)
162
+ cleanups.push(() => el.removeEventListener("keydown", keyHandler))
163
+ }
164
+
165
+ function itemRef(key: string | number): (el: HTMLElement) => void {
166
+ return (el: HTMLElement) => {
167
+ el.dataset.pyreonSortKey = String(key)
168
+ if (!el.hasAttribute("tabindex")) el.setAttribute("tabindex", "0")
169
+ el.setAttribute("role", "listitem")
170
+ el.setAttribute("aria-roledescription", "sortable item")
171
+
172
+ const allowedEdges: Edge[] = axis === "vertical" ? ["top", "bottom"] : ["left", "right"]
173
+
174
+ const cleanup = combine(
175
+ draggable({
176
+ element: el,
177
+ getInitialData: () => ({
178
+ [SORT_KEY]: key,
179
+ [SORT_ID]: sortableId,
180
+ }),
181
+ onDragStart: () => activeId.set(key),
182
+ onDrop: () => {
183
+ queueMicrotask(() => {
184
+ activeId.set(null)
185
+ overId.set(null)
186
+ overEdge.set(null)
187
+ })
188
+ },
189
+ }),
190
+ dropTargetForElements({
191
+ element: el,
192
+ getData: ({ input, element }) =>
193
+ attachClosestEdge(
194
+ { [SORT_KEY]: key, [SORT_ID]: sortableId },
195
+ { input, element, allowedEdges },
196
+ ),
197
+ canDrop: ({ source }) => source.data[SORT_ID] === sortableId,
198
+ onDragEnter: ({ self }) => {
199
+ overId.set(key)
200
+ overEdge.set(extractClosestEdge(self.data) as DropEdge | null)
201
+ },
202
+ onDrag: ({ self }) => {
203
+ overEdge.set(extractClosestEdge(self.data) as DropEdge | null)
204
+ },
205
+ onDragLeave: () => {
206
+ if (overId.peek() === key) {
207
+ overId.set(null)
208
+ overEdge.set(null)
209
+ }
210
+ },
211
+ }),
212
+ )
213
+
214
+ cleanups.push(cleanup)
215
+ }
216
+ }
217
+
218
+ onCleanup(() => {
219
+ for (const cleanup of cleanups) cleanup()
220
+ cleanups.length = 0
221
+ })
222
+
223
+ return { containerRef, itemRef, activeId, overId, overEdge }
224
+ }