@octane-xplat/dnd-kit 0.0.0-stage → 0.10.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 +307 -2
- package/dist/native/components.js +275 -0
- package/dist/native/controller.js +236 -0
- package/dist/native/geometry.js +18 -0
- package/dist/native/index.js +4 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/native/sort.js +11 -0
- package/dist/web/components.js +215 -0
- package/dist/web/controller.js +236 -0
- package/dist/web/geometry.web.js +16 -0
- package/dist/web/index.js +4 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/sort.js +11 -0
- package/examples/sortable.macos.tsx +128 -0
- package/examples/sortable.tsx +22 -0
- package/package.json +88 -3
- package/src/abort.macos.ts +7 -0
- package/src/components.tsx +262 -0
- package/src/components.web.test.tsx +118 -0
- package/src/controller.mobile.test.ts +176 -0
- package/src/controller.ts +301 -0
- package/src/geometry.macos.test.ts +34 -0
- package/src/geometry.macos.ts +32 -0
- package/src/geometry.ts +18 -0
- package/src/geometry.web.ts +14 -0
- package/src/index.macos.ts +23 -0
- package/src/index.ts +21 -0
- package/src/index.web.ts +21 -0
- package/src/props.ts +83 -0
- package/src/sort.ts +19 -0
- package/types/generated/.tsrx-typegen-manifest.json +11 -0
- package/types/generated/components.d.ts +13 -0
- package/types/generated/controller.d.ts +32 -0
- package/types/generated/geometry.web.d.ts +3 -0
- package/types/generated/index.d.ts +5 -0
- package/types/generated/props.d.ts +84 -0
- package/types/generated/sort.d.ts +2 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
useEffect,
|
|
6
|
+
useCallback,
|
|
7
|
+
useSyncExternalStore,
|
|
8
|
+
} from 'octane'
|
|
9
|
+
|
|
10
|
+
import { View, useMeasure } from '@octane-xplat/ui'
|
|
11
|
+
import { DndController } from './controller'
|
|
12
|
+
import { dragStyle } from './geometry'
|
|
13
|
+
import { arrayMove } from './sort'
|
|
14
|
+
import type {
|
|
15
|
+
DndId,
|
|
16
|
+
DndContextProps,
|
|
17
|
+
DndSnapshot,
|
|
18
|
+
DraggableOptions,
|
|
19
|
+
DraggableResult,
|
|
20
|
+
DroppableOptions,
|
|
21
|
+
DroppableResult,
|
|
22
|
+
SortableContextProps,
|
|
23
|
+
SortableResult,
|
|
24
|
+
SortableListProps,
|
|
25
|
+
} from './props'
|
|
26
|
+
|
|
27
|
+
// The universal native context uses this callable as its provider, but the
|
|
28
|
+
// DOM TypeScript signature requires a renderer scope parameter for JSX.
|
|
29
|
+
const Context = createContext<DndController | null>(null)
|
|
30
|
+
const ContextProvider: any = Context
|
|
31
|
+
const SortContext = createContext<readonly (string | number)[] | null>(null)
|
|
32
|
+
const SortContextProvider: any = SortContext
|
|
33
|
+
|
|
34
|
+
/** Scope registrations to one renderer root. Nested roots need their own DndContext. */
|
|
35
|
+
export function DndContext(props: DndContextProps) {
|
|
36
|
+
const ref = useRef<DndController | null>(null)
|
|
37
|
+
if (!ref.current) {
|
|
38
|
+
ref.current = new DndController()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const controller = ref.current
|
|
42
|
+
controller.options = props
|
|
43
|
+
useEffect(() => () => controller.disposeController(), [controller])
|
|
44
|
+
return <ContextProvider value={controller}>{props.children}</ContextProvider>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function useController(): DndController {
|
|
48
|
+
const controller = useContext(Context)
|
|
49
|
+
if (!controller) {
|
|
50
|
+
throw new Error('Drag and drop hooks require DndContext')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return controller
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Subscribes independently so retained native children observe drag state. */
|
|
57
|
+
export function useDndContext(): DndSnapshot {
|
|
58
|
+
const controller = useController()
|
|
59
|
+
return useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function useDraggable(options: DraggableOptions): DraggableResult {
|
|
63
|
+
const controller = useController()
|
|
64
|
+
const snapshot = useDndContext()
|
|
65
|
+
const entity = useRef<ReturnType<DndController['registerDraggable']> | null>(null)
|
|
66
|
+
const node = useRef<any>(null)
|
|
67
|
+
const latest = useRef(options)
|
|
68
|
+
latest.current = options
|
|
69
|
+
const measurement = useMeasure()
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const registered = controller.registerDraggable(latest.current)
|
|
72
|
+
entity.current = registered
|
|
73
|
+
if (node.current) {
|
|
74
|
+
controller.draggableNodes.set(options.id, node.current)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
if (controller.getSnapshot().active?.id === options.id) {
|
|
79
|
+
controller.cancel()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
registered.destroy()
|
|
83
|
+
entity.current = null
|
|
84
|
+
controller.draggableNodes.delete(options.id)
|
|
85
|
+
}
|
|
86
|
+
}, [controller, options.id])
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!entity.current) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
entity.current.data = options.data ?? {}
|
|
94
|
+
entity.current.disabled = options.disabled ?? false
|
|
95
|
+
if (options.disabled && controller.getSnapshot().active?.id === options.id) {
|
|
96
|
+
controller.cancel()
|
|
97
|
+
}
|
|
98
|
+
}, [controller, options.id, options.data, options.disabled])
|
|
99
|
+
|
|
100
|
+
const ref = useCallback(
|
|
101
|
+
(element: any) => {
|
|
102
|
+
node.current = element
|
|
103
|
+
measurement.ref(element)
|
|
104
|
+
if (element) {
|
|
105
|
+
controller.draggableNodes.set(options.id, element)
|
|
106
|
+
} else {
|
|
107
|
+
controller.draggableNodes.delete(options.id)
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
[controller, options.id, measurement.ref],
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const onPan = useCallback(
|
|
114
|
+
(event: Parameters<DraggableResult['onPan']>[0]) => entity.current?.onPan?.(event),
|
|
115
|
+
[],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const isDragging = snapshot.isDragging && snapshot.active?.id === options.id
|
|
119
|
+
const transform = isDragging ? snapshot.transform : { x: 0, y: 0 }
|
|
120
|
+
return { ref, onPan, isDragging, transform, style: dragStyle(transform.x, transform.y) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function useDroppable(options: DroppableOptions): DroppableResult {
|
|
124
|
+
const controller = useController()
|
|
125
|
+
const snapshot = useDndContext()
|
|
126
|
+
const entity = useRef<ReturnType<DndController['registerDroppable']> | null>(null)
|
|
127
|
+
const node = useRef<any>(null)
|
|
128
|
+
const latest = useRef(options)
|
|
129
|
+
latest.current = options
|
|
130
|
+
const measurement = useMeasure()
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
const registered = controller.registerDroppable(latest.current)
|
|
133
|
+
entity.current = registered
|
|
134
|
+
if (node.current) {
|
|
135
|
+
controller.droppableNodes.set(options.id, node.current)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
controller.refresh()
|
|
139
|
+
return () => {
|
|
140
|
+
registered.destroy()
|
|
141
|
+
entity.current = null
|
|
142
|
+
controller.droppableNodes.delete(options.id)
|
|
143
|
+
controller.refresh()
|
|
144
|
+
}
|
|
145
|
+
}, [controller, options.id])
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!entity.current) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
entity.current.data = options.data ?? {}
|
|
153
|
+
entity.current.disabled = options.disabled ?? false
|
|
154
|
+
entity.current.accept = options.accept
|
|
155
|
+
? (source) => options.accept!({ id: source.id, data: source.data })
|
|
156
|
+
: undefined
|
|
157
|
+
|
|
158
|
+
controller.refresh()
|
|
159
|
+
}, [
|
|
160
|
+
controller,
|
|
161
|
+
options.data,
|
|
162
|
+
options.disabled,
|
|
163
|
+
options.accept,
|
|
164
|
+
controller.options.collisionDetection,
|
|
165
|
+
measurement.bounds,
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
const ref = useCallback(
|
|
169
|
+
(element: any) => {
|
|
170
|
+
node.current = element
|
|
171
|
+
measurement.ref(element)
|
|
172
|
+
if (element) {
|
|
173
|
+
controller.droppableNodes.set(options.id, element)
|
|
174
|
+
} else {
|
|
175
|
+
controller.droppableNodes.delete(options.id)
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
[controller, options.id, measurement.ref],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return { ref, isOver: snapshot.isDragging && snapshot.over?.id === options.id }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Declares list membership for useSortable; it does not own item order. */
|
|
185
|
+
export function SortableContext(props: SortableContextProps) {
|
|
186
|
+
if (new Set(props.items).size !== props.items.length) {
|
|
187
|
+
throw new Error('SortableContext requires unique item ids')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return <SortContextProvider value={props.items}>{props.children}</SortContextProvider>
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Bind and spread onPan/style onto one View. Order changes only when the owner commits a drop. */
|
|
194
|
+
export function useSortable(options: DraggableOptions): SortableResult {
|
|
195
|
+
const items = useContext(SortContext)
|
|
196
|
+
if (!items?.includes(options.id)) {
|
|
197
|
+
throw new Error('useSortable requires membership in SortableContext')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const drag = useDraggable(options)
|
|
201
|
+
const drop = useDroppable(options)
|
|
202
|
+
const ref = useCallback(
|
|
203
|
+
(element: any) => {
|
|
204
|
+
drag.ref(element)
|
|
205
|
+
drop.ref(element)
|
|
206
|
+
},
|
|
207
|
+
[drag.ref, drop.ref],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return { ...drag, ref, isOver: drop.isOver }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function SortableRow<T extends DndId>(props: {
|
|
214
|
+
key?: T
|
|
215
|
+
id: T
|
|
216
|
+
index: number
|
|
217
|
+
list: SortableListProps<T>
|
|
218
|
+
}) {
|
|
219
|
+
const sortable = useSortable({ id: props.id, disabled: props.list.disabled })
|
|
220
|
+
return (
|
|
221
|
+
<View ref={sortable.ref} onPan={sortable.onPan} style={sortable.style}>
|
|
222
|
+
{props.list.renderItem(props.id, props.index)}
|
|
223
|
+
</View>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A controlled sortable list. Provide stable unique ids and save the order in onReorder. */
|
|
228
|
+
export function SortableList<T extends DndId>(props: SortableListProps<T>) {
|
|
229
|
+
const {
|
|
230
|
+
items,
|
|
231
|
+
renderItem: _renderItem,
|
|
232
|
+
onReorder,
|
|
233
|
+
disabled: _disabled,
|
|
234
|
+
dnd,
|
|
235
|
+
...viewProps
|
|
236
|
+
} = props
|
|
237
|
+
|
|
238
|
+
return (
|
|
239
|
+
<DndContext
|
|
240
|
+
{...dnd}
|
|
241
|
+
onDragEnd={(event) => {
|
|
242
|
+
if (event.active && event.over) {
|
|
243
|
+
const from = items.findIndex((id) => id === event.active!.id)
|
|
244
|
+
const to = items.findIndex((id) => id === event.over!.id)
|
|
245
|
+
if (from >= 0 && to >= 0 && from !== to) {
|
|
246
|
+
onReorder(arrayMove(items, from, to), event)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
dnd?.onDragEnd?.(event)
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<SortableContext items={items}>
|
|
254
|
+
<View {...viewProps}>
|
|
255
|
+
{items.map((id, index) => (
|
|
256
|
+
<SortableRow key={id} id={id} index={index} list={props} />
|
|
257
|
+
))}
|
|
258
|
+
</View>
|
|
259
|
+
</SortableContext>
|
|
260
|
+
</DndContext>
|
|
261
|
+
)
|
|
262
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, createRoot } from 'octane'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { View } from '@octane-xplat/ui'
|
|
5
|
+
import { DndContext, useDraggable, useDroppable } from './index.web'
|
|
6
|
+
|
|
7
|
+
const roots: ReturnType<typeof createRoot>[] = []
|
|
8
|
+
|
|
9
|
+
function mount(jsx: any) {
|
|
10
|
+
const element = document.createElement('div')
|
|
11
|
+
document.body.append(element)
|
|
12
|
+
const root = createRoot(element)
|
|
13
|
+
roots.push(root)
|
|
14
|
+
act(() => root.render(jsx))
|
|
15
|
+
return { element }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pointer(type: string, clientX: number, clientY: number): PointerEvent {
|
|
19
|
+
const event = new Event(type, { bubbles: true, cancelable: true })
|
|
20
|
+
Object.assign(event, { pointerId: 1, clientX, clientY })
|
|
21
|
+
return event as PointerEvent
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setBounds(element: Element, top: number) {
|
|
25
|
+
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
|
|
26
|
+
x: 0,
|
|
27
|
+
y: top,
|
|
28
|
+
left: 0,
|
|
29
|
+
top,
|
|
30
|
+
right: 40,
|
|
31
|
+
bottom: top + 40,
|
|
32
|
+
width: 40,
|
|
33
|
+
height: 40,
|
|
34
|
+
toJSON: () => ({}),
|
|
35
|
+
} as DOMRect)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function flush() {
|
|
39
|
+
for (let i = 0; i < 40; i++) {
|
|
40
|
+
await Promise.resolve()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function DragSource() {
|
|
45
|
+
const drag = useDraggable({ id: 'source' })
|
|
46
|
+
return <View id="drag-source" ref={drag.ref} onPan={drag.onPan} style={drag.style} />
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function DropTarget() {
|
|
50
|
+
const drop = useDroppable({ id: 'target' })
|
|
51
|
+
return <View id="drop-target" ref={drop.ref} style={{ height: 40 }} />
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
for (const root of roots.splice(0)) {
|
|
56
|
+
act(() => root.unmount())
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
document.body.innerHTML = ''
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('@octane-xplat/dnd-kit (web)', () => {
|
|
63
|
+
it('starts and completes a drop from pointer events on ref-attached Views', async () => {
|
|
64
|
+
const onDragStart = vi.fn()
|
|
65
|
+
const onDragEnd = vi.fn()
|
|
66
|
+
const { element } = mount(
|
|
67
|
+
<DndContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
|
68
|
+
<DragSource />
|
|
69
|
+
<DropTarget />
|
|
70
|
+
</DndContext>,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
await flush()
|
|
74
|
+
|
|
75
|
+
const source = element.querySelector('#drag-source')!
|
|
76
|
+
const target = element.querySelector('#drop-target')!
|
|
77
|
+
setBounds(source, 0)
|
|
78
|
+
setBounds(target, 80)
|
|
79
|
+
|
|
80
|
+
act(() => source.dispatchEvent(pointer('pointerdown', 20, 20)))
|
|
81
|
+
await flush()
|
|
82
|
+
act(() => source.dispatchEvent(pointer('pointermove', 20, 100)))
|
|
83
|
+
await flush()
|
|
84
|
+
act(() => source.dispatchEvent(pointer('pointerup', 20, 100)))
|
|
85
|
+
await flush()
|
|
86
|
+
|
|
87
|
+
expect(onDragStart).toHaveBeenCalledTimes(1)
|
|
88
|
+
expect(onDragEnd).toHaveBeenCalledTimes(1)
|
|
89
|
+
expect(onDragEnd.mock.calls[0]?.[0]).toMatchObject({
|
|
90
|
+
active: { id: 'source' },
|
|
91
|
+
over: { id: 'target' },
|
|
92
|
+
canceled: false,
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('reports pointer cancellation without a successful drop', async () => {
|
|
97
|
+
const onDragEnd = vi.fn()
|
|
98
|
+
const onDragCancel = vi.fn()
|
|
99
|
+
const { element } = mount(
|
|
100
|
+
<DndContext onDragEnd={onDragEnd} onDragCancel={onDragCancel}>
|
|
101
|
+
<DragSource />
|
|
102
|
+
<DropTarget />
|
|
103
|
+
</DndContext>,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
await flush()
|
|
107
|
+
|
|
108
|
+
const source = element.querySelector('#drag-source')!
|
|
109
|
+
setBounds(source, 0)
|
|
110
|
+
act(() => source.dispatchEvent(pointer('pointerdown', 20, 20)))
|
|
111
|
+
await flush()
|
|
112
|
+
act(() => source.dispatchEvent(pointer('pointercancel', 20, 20)))
|
|
113
|
+
await flush()
|
|
114
|
+
|
|
115
|
+
expect(onDragCancel).toHaveBeenCalledTimes(1)
|
|
116
|
+
expect(onDragEnd).not.toHaveBeenCalled()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { DndController } from './controller'
|
|
3
|
+
import { arrayMove } from './sort'
|
|
4
|
+
|
|
5
|
+
const node = (x: number, y: number, width = 100, height = 40) => ({
|
|
6
|
+
isLoaded: true,
|
|
7
|
+
getLocationOnScreen: () => ({ x, y }),
|
|
8
|
+
getActualSize: () => ({ width, height }),
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const flush = async () => {
|
|
12
|
+
for (let i = 0; i < 30; i++) {
|
|
13
|
+
await Promise.resolve()
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pan = (source: ReturnType<DndController['registerDraggable']>, state: string, dy = 0) =>
|
|
18
|
+
source.onPan?.({ state, dx: 0, dy, x: 0, y: dy, vx: 0, vy: 0, target: null })
|
|
19
|
+
|
|
20
|
+
function setup() {
|
|
21
|
+
const manager = new DndController()
|
|
22
|
+
manager.draggableNodes.set('a', node(0, 0))
|
|
23
|
+
manager.droppableNodes.set('a', node(0, 0))
|
|
24
|
+
manager.droppableNodes.set('b', node(0, 80))
|
|
25
|
+
const source = manager.registerDraggable({ id: 'a', data: { container: 'first' } })
|
|
26
|
+
manager.registerDroppable({ id: 'a' })
|
|
27
|
+
manager.registerDroppable({ id: 'b', data: { container: 'second' } })
|
|
28
|
+
return { manager, source }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('native pan substrate and abstract core', () => {
|
|
32
|
+
it('delivers committed transforms and drops over another container', async () => {
|
|
33
|
+
const { manager, source } = setup()
|
|
34
|
+
const start = vi.fn(),
|
|
35
|
+
move = vi.fn(),
|
|
36
|
+
end = vi.fn()
|
|
37
|
+
|
|
38
|
+
manager.options = { onDragStart: start, onDragMove: move, onDragEnd: end }
|
|
39
|
+
pan(source, 'began')
|
|
40
|
+
await flush()
|
|
41
|
+
expect(manager.getSnapshot().isDragging).toBe(true)
|
|
42
|
+
pan(source, 'moved', 80)
|
|
43
|
+
await flush()
|
|
44
|
+
expect(manager.getSnapshot().over?.id).toBe('b')
|
|
45
|
+
expect(manager.getSnapshot().transform.y).toBe(80)
|
|
46
|
+
pan(source, 'ended', 80)
|
|
47
|
+
await flush()
|
|
48
|
+
expect(start).toHaveBeenCalledTimes(1)
|
|
49
|
+
expect(move).toHaveBeenCalled()
|
|
50
|
+
expect(end.mock.calls[0]?.[0]).toMatchObject({
|
|
51
|
+
active: { id: 'a' },
|
|
52
|
+
over: { id: 'b' },
|
|
53
|
+
canceled: false,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
expect(manager.getSnapshot().active).toBeNull()
|
|
57
|
+
manager.disposeController()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('serializes a quick begin/move/release and permits a second drag', async () => {
|
|
61
|
+
const { manager, source } = setup()
|
|
62
|
+
const end = vi.fn()
|
|
63
|
+
manager.options = { onDragEnd: end }
|
|
64
|
+
pan(source, 'began')
|
|
65
|
+
pan(source, 'moved', 80)
|
|
66
|
+
pan(source, 'ended', 80)
|
|
67
|
+
await flush()
|
|
68
|
+
expect(end.mock.calls[0]?.[0].over?.id).toBe('b')
|
|
69
|
+
pan(source, 'began')
|
|
70
|
+
await flush()
|
|
71
|
+
pan(source, 'ended')
|
|
72
|
+
await flush()
|
|
73
|
+
expect(end).toHaveBeenCalledTimes(2)
|
|
74
|
+
manager.disposeController()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('cancels without reporting a successful drop', async () => {
|
|
78
|
+
const { manager, source } = setup()
|
|
79
|
+
const end = vi.fn(),
|
|
80
|
+
cancel = vi.fn()
|
|
81
|
+
|
|
82
|
+
manager.options = { onDragEnd: end, onDragCancel: cancel }
|
|
83
|
+
pan(source, 'began')
|
|
84
|
+
await flush()
|
|
85
|
+
pan(source, 'cancelled', 80)
|
|
86
|
+
await flush()
|
|
87
|
+
expect(cancel).toHaveBeenCalledTimes(1)
|
|
88
|
+
expect(end).not.toHaveBeenCalled()
|
|
89
|
+
expect(manager.getSnapshot().active).toBeNull()
|
|
90
|
+
manager.disposeController()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('excludes disabled and rejected drop targets', async () => {
|
|
94
|
+
const { manager, source } = setup()
|
|
95
|
+
manager.registry.droppables.get('b')!.disabled = true
|
|
96
|
+
pan(source, 'began')
|
|
97
|
+
await flush()
|
|
98
|
+
pan(source, 'moved', 80)
|
|
99
|
+
await flush()
|
|
100
|
+
expect(manager.getSnapshot().over).toBeNull()
|
|
101
|
+
manager.registry.droppables.get('b')!.disabled = false
|
|
102
|
+
manager.registry.droppables.get('b')!.accept = () => false
|
|
103
|
+
manager.refresh()
|
|
104
|
+
await flush()
|
|
105
|
+
expect(manager.getSnapshot().over).toBeNull()
|
|
106
|
+
manager.disposeController()
|
|
107
|
+
await flush()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not start disabled or unmeasurable sources and rejects duplicate ids', async () => {
|
|
111
|
+
const { manager, source } = setup()
|
|
112
|
+
source.disabled = true
|
|
113
|
+
pan(source, 'began')
|
|
114
|
+
await flush()
|
|
115
|
+
expect(manager.getSnapshot().active).toBeNull()
|
|
116
|
+
source.disabled = false
|
|
117
|
+
manager.draggableNodes.delete('a')
|
|
118
|
+
pan(source, 'began')
|
|
119
|
+
await flush()
|
|
120
|
+
expect(manager.getSnapshot().active).toBeNull()
|
|
121
|
+
expect(() => manager.registerDraggable({ id: 'a' })).toThrow('Duplicate')
|
|
122
|
+
manager.disposeController()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('scrolls while held near an edge, clamps offsets, and stops on cancel', async () => {
|
|
126
|
+
vi.useFakeTimers()
|
|
127
|
+
const { manager, source } = setup()
|
|
128
|
+
const offsetRef = { current: 0 }
|
|
129
|
+
const scrollTo = vi.fn((offset: number) => {
|
|
130
|
+
offsetRef.current = offset
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
manager.options = {
|
|
134
|
+
autoScroll: {
|
|
135
|
+
bounds: () => ({ x: 0, y: 0, width: 100, height: 100 }),
|
|
136
|
+
offsetRef,
|
|
137
|
+
maxOffset: () => 20,
|
|
138
|
+
scrollTo,
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
pan(source, 'began')
|
|
143
|
+
await flush()
|
|
144
|
+
pan(source, 'moved', 70)
|
|
145
|
+
await flush()
|
|
146
|
+
await vi.advanceTimersByTimeAsync(160)
|
|
147
|
+
expect(offsetRef.current).toBe(20)
|
|
148
|
+
expect(manager.getSnapshot().transform.y).toBe(90)
|
|
149
|
+
manager.cancel()
|
|
150
|
+
await flush()
|
|
151
|
+
expect(manager.getSnapshot().transform).toEqual({ x: 0, y: 0 })
|
|
152
|
+
const calls = scrollTo.mock.calls.length
|
|
153
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
154
|
+
expect(scrollTo).toHaveBeenCalledTimes(calls)
|
|
155
|
+
manager.disposeController()
|
|
156
|
+
vi.useRealTimers()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('stops queued input and unregisters sensor bindings on destruction', async () => {
|
|
160
|
+
const { manager, source } = setup()
|
|
161
|
+
pan(source, 'began')
|
|
162
|
+
await flush()
|
|
163
|
+
manager.disposeController()
|
|
164
|
+
pan(source, 'moved', 80)
|
|
165
|
+
await flush()
|
|
166
|
+
expect(source.onPan).toBeUndefined()
|
|
167
|
+
expect(manager.registry.draggables.has('a')).toBe(false)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('arrayMove preserves input and ignores invalid indices', () => {
|
|
172
|
+
const items = ['a', 'b', 'c']
|
|
173
|
+
expect(arrayMove(items, 0, 2)).toEqual(['b', 'c', 'a'])
|
|
174
|
+
expect(items).toEqual(['a', 'b', 'c'])
|
|
175
|
+
expect(arrayMove(items, -1, 2)).toEqual(items)
|
|
176
|
+
})
|