@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.
- package/LICENSE +21 -0
- package/README.md +133 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +423 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +236 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +51 -0
- package/src/index.ts +20 -0
- package/src/tests/dnd.test.ts +281 -0
- package/src/types.ts +83 -0
- package/src/use-drag-monitor.ts +67 -0
- package/src/use-draggable.ts +66 -0
- package/src/use-droppable.ts +67 -0
- package/src/use-file-drop.ts +131 -0
- package/src/use-sortable.ts +224 -0
|
@@ -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
|
+
}
|