@octane-xplat/dnd-kit 0.0.1 → 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,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
+ })