@startupjs-ui/draggable 0.1.11 → 0.1.19

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/CHANGELOG.md CHANGED
@@ -3,6 +3,33 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [0.1.19](https://github.com/startupjs/startupjs-ui/compare/v0.1.18...v0.1.19) (2026-03-17)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * **draggable:** placeholder position from ghost (element), not cursor ([#18](https://github.com/startupjs/startupjs-ui/issues/18)) ([fbb8d9a](https://github.com/startupjs/startupjs-ui/commit/fbb8d9a012e94f40fd93c2cc07b407f78399106e))
12
+
13
+
14
+
15
+
16
+
17
+ ## Unreleased
18
+
19
+ ### Fixes
20
+
21
+ * **draggable:** placeholder position based on ghost (element) instead of cursor — same behavior regardless of grab point; equal slots + direction-aware index for reorder ([SortableJS-style](https://github.com/SortableJS/Sortable))
22
+ * **draggable:** multi-drop 2D hit-test — ghost center (refX/refY) determines which Droppable is hovered; correct cross-list drag and dropHoverId
23
+ * **droppable:** show placeholder when empty list is the active drop target (drag into empty zone)
24
+
25
+ ## [0.1.16](https://github.com/startupjs/startupjs-ui/compare/v0.1.15...v0.1.16) (2026-02-10)
26
+
27
+ **Note:** Version bump only for package @startupjs-ui/draggable
28
+
29
+
30
+
31
+
32
+
6
33
  ## [0.1.11](https://github.com/startupjs/startupjs-ui/compare/v0.1.10...v0.1.11) (2026-01-20)
7
34
 
8
35
  **Note:** Version bump only for package @startupjs-ui/draggable
@@ -1,5 +1,5 @@
1
1
  import React, { useContext, useEffect, useRef, type ReactNode } from 'react'
2
- import { Animated, View, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
2
+ import { View, StyleSheet, Platform, type StyleProp, type ViewStyle } from 'react-native'
3
3
  import { State, PanGestureHandler } from 'react-native-gesture-handler'
4
4
  import { pug, observer } from 'startupjs'
5
5
  import { themed } from '@startupjs-ui/core'
@@ -51,81 +51,117 @@ function Draggable ({
51
51
  const ref = useRef<any>(null)
52
52
  const $dndContext = useContext(DragDropContext)
53
53
 
54
- const animateStates = {
55
- left: new Animated.Value(0),
56
- top: new Animated.Value(0)
57
- }
58
-
59
- // init drags.dragId
60
54
  useEffect(() => {
61
- $dndContext.drags[dragId].set({ ref, style: {} })
62
- }, [ // eslint-disable-line react-hooks/exhaustive-deps
63
- dragId,
64
- _dropId,
65
- _index,
66
- $dndContext.drags[dragId].ref.current.get() // eslint-disable-line react-hooks/exhaustive-deps
67
- ])
68
-
69
- if (_dropId == null || _index == null) {
70
- return pug`
71
- View(style=style)= children
72
- `
55
+ if (!$dndContext) return
56
+ if ($dndContext.drags[dragId]) {
57
+ $dndContext.drags[dragId].set({ ref, style: {} })
58
+ }
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [dragId, _dropId, _index])
61
+
62
+ if (_dropId == null || _index == null || !$dndContext) {
63
+ return pug`View(style=style)= children`
73
64
  }
74
65
 
75
66
  const dropId = _dropId
76
67
  const index = _index
77
68
 
78
- function onHandlerStateChange ({ nativeEvent }: any) {
79
- const data = {
80
- type,
81
- dragId,
82
- dropId,
83
- dragStyle: { ...StyleSheet.flatten(style) },
84
- startPosition: {
85
- x: nativeEvent.x,
86
- y: nativeEvent.y
87
- }
88
- }
69
+ async function onHandlerStateChange ({ nativeEvent }: any) {
70
+ const startAbsoluteX = nativeEvent.absoluteX ?? 0
71
+ const startAbsoluteY = nativeEvent.absoluteY ?? 0
89
72
 
90
73
  if (nativeEvent.state === State.BEGAN) {
91
- ref.current.measure((dragX: any, dragY: any, dragWidth: any, dragHeight: any) => {
92
- data.dragStyle.height = dragHeight
93
-
94
- $dndContext.drops[dropId].ref.current.get().measure((dx: any, dy: any, dw: any, dropHeight: any) => {
95
- // init states
96
- $dndContext.drags[dragId].style.set({ display: 'none' })
97
- $dndContext.assign({
98
- activeData: data,
99
- dropHoverId: dropId,
100
- dragHoverIndex: index
101
- })
74
+ const offsetX = nativeEvent.x ?? 0
75
+ const offsetY = nativeEvent.y ?? 0
76
+ const ghostLeft = startAbsoluteX - offsetX
77
+ const ghostTop = startAbsoluteY - offsetY
78
+
79
+ const flatStyle = StyleSheet.flatten(style) || {} as any
80
+ const data: Record<string, any> = {
81
+ type,
82
+ dragId,
83
+ dropId,
84
+ dragStyle: {
85
+ ...flatStyle,
86
+ height: flatStyle.height ?? 0,
87
+ width: flatStyle.width ?? 0
88
+ },
89
+ startPosition: { x: offsetX, y: offsetY },
90
+ startGhostTop: ghostTop,
91
+ x: startAbsoluteX,
92
+ y: startAbsoluteY,
93
+ ghostLeft,
94
+ ghostTop
95
+ }
96
+
97
+ const applyDragStart = (measuredHeight: number | null, measuredWidth: number | null) => {
98
+ if (measuredHeight != null || measuredWidth != null) {
99
+ data.dragStyle = {
100
+ ...data.dragStyle,
101
+ ...(typeof measuredHeight === 'number' && { height: measuredHeight }),
102
+ ...(typeof measuredWidth === 'number' && { width: measuredWidth })
103
+ }
104
+ }
105
+ $dndContext.drags[dragId].style.set({ display: 'none' })
106
+ $dndContext.assign({
107
+ activeData: data,
108
+ dropHoverId: dropId,
109
+ dragHoverIndex: index
110
+ })
102
111
 
103
- onDragBegin && onDragBegin({
112
+ if (onDragBegin) {
113
+ onDragBegin({
104
114
  dragId: data.dragId,
105
115
  dropId: data.dropId,
106
116
  dropHoverId: dropId,
107
117
  hoverIndex: index
108
118
  })
119
+ }
120
+ }
121
+
122
+ if (ref.current?.measure) {
123
+ ref.current.measure((_x: number, _y: number, dragWidth: number, dragHeight: number) => {
124
+ applyDragStart(dragHeight, dragWidth)
109
125
  })
110
- })
126
+ } else {
127
+ applyDragStart(null, null)
128
+ }
111
129
  }
112
130
 
113
131
  if (nativeEvent.state === State.END) {
114
- animateStates.left.setValue(0)
115
- animateStates.top.setValue(0)
116
-
117
- onDragEnd && onDragEnd({
118
- dragId: $dndContext.activeData.dragId.get(),
119
- dropId: $dndContext.activeData.dropId.get(),
120
- dropHoverId: $dndContext.dropHoverId.get(),
121
- hoverIndex: $dndContext.dragHoverIndex.get()
122
- })
132
+ const clearDragState = () => {
133
+ $dndContext.assign({
134
+ drags: { [dragId]: { style: {} } },
135
+ activeData: {},
136
+ dropHoverId: '',
137
+ dragHoverIndex: null
138
+ })
139
+ }
140
+ try {
141
+ const finalY = nativeEvent.absoluteY ?? $dndContext.activeData.y.get()
142
+ const finalX = nativeEvent.absoluteX ?? $dndContext.activeData.x.get()
143
+ $dndContext.activeData.x.set(finalX)
144
+ $dndContext.activeData.y.set(finalY)
145
+ const activeDataSnap = { ...$dndContext.activeData.get(), y: finalY, x: finalX }
146
+ const finalHoverIndex = await checkPosition(activeDataSnap)
147
+ const hoverIndex = finalHoverIndex ?? $dndContext.dragHoverIndex.get()
148
+
149
+ if (onDragEnd) {
150
+ onDragEnd({
151
+ dragId: $dndContext.activeData.dragId.get(),
152
+ dropId: $dndContext.activeData.dropId.get(),
153
+ dropHoverId: $dndContext.dropHoverId.get(),
154
+ hoverIndex
155
+ })
156
+ }
157
+ } finally {
158
+ clearDragState()
159
+ }
160
+ }
123
161
 
124
- // reset states
162
+ if (nativeEvent.state === State.CANCELLED || nativeEvent.state === State.FAILED) {
125
163
  $dndContext.assign({
126
- drags: {
127
- [dragId]: { style: {} }
128
- },
164
+ drags: { [dragId]: { style: {} } },
129
165
  activeData: {},
130
166
  dropHoverId: '',
131
167
  dragHoverIndex: null
@@ -136,116 +172,173 @@ function Draggable ({
136
172
  function onGestureEvent ({ nativeEvent }: any) {
137
173
  if (!$dndContext.dropHoverId.get()) return
138
174
 
139
- animateStates.left.setValue(
140
- nativeEvent.absoluteX - $dndContext.activeData.startPosition.x.get()
141
- )
142
- animateStates.top.setValue(
143
- nativeEvent.absoluteY - $dndContext.activeData.startPosition.y.get()
144
- )
145
-
175
+ const left = nativeEvent.absoluteX - $dndContext.activeData.startPosition.x.get()
176
+ const top = nativeEvent.absoluteY - $dndContext.activeData.startPosition.y.get()
177
+ $dndContext.activeData.ghostLeft.set(left)
178
+ $dndContext.activeData.ghostTop.set(top)
146
179
  $dndContext.activeData.x.set(nativeEvent.absoluteX)
147
180
  $dndContext.activeData.y.set(nativeEvent.absoluteY)
148
181
  checkPosition($dndContext.activeData.get())
149
182
  }
150
183
 
151
- function checkPosition (activeData: any) {
152
- $dndContext.drops[dropId].ref.current.get().measure(async (dX: any, dY: any, dWidth: any, dHeight: any, dPageX: any, dPageY: any) => {
153
- const positions: any[] = []
154
- let startPosition = dPageY
155
- let endPosition = dPageY
156
-
157
- const dragsLength = $dndContext.drops[$dndContext.dropHoverId.get()].items.get()?.length || 0
158
-
159
- for (let index = 0; index < dragsLength; index++) {
160
- if (!$dndContext.dropHoverId.get()) break
184
+ async function checkPosition (activeData: any): Promise<number | null> {
185
+ const ghostTopNow = activeData.ghostTop
186
+ const ghostLeftNow = activeData.ghostLeft
187
+ const ghostHeight = activeData.dragStyle?.height ?? 0
188
+ const ghostWidth = activeData.dragStyle?.width ?? 0
189
+ const refY = ghostTopNow != null ? ghostTopNow + ghostHeight / 2 : activeData.y
190
+ const refX = ghostLeftNow != null ? ghostLeftNow + ghostWidth / 2 : activeData.x
191
+ if (refY == null) return await Promise.resolve(null)
192
+
193
+ const dropIds = Object.keys($dndContext.drops.get() ?? {})
194
+ if (dropIds.length === 0) return await Promise.resolve(null)
195
+
196
+ const measureDrop = async (id: string) => {
197
+ const dref = $dndContext.drops[id].ref.current?.get()
198
+ if (!dref?.measureInWindow && !dref?.measure) return await Promise.resolve(null)
199
+ return await new Promise<{ id: string, left: number, right: number, top: number, bottom: number } | null>((resolve) => {
200
+ if (typeof dref.measureInWindow === 'function') {
201
+ dref.measureInWindow((x: number, y: number, w: number, h: number) => { resolve({ id, left: x, right: x + (w ?? 0), top: y, bottom: y + (h ?? 0) }) })
202
+ } else {
203
+ dref.measure((_x: number, _y: number, _w: number, dH: number, px: number, py: number) => {
204
+ const top = py ?? _y
205
+ const left = px ?? _x
206
+ resolve({ id, left, right: left + (_w ?? 0), top, bottom: top + (dH ?? 0) })
207
+ })
208
+ }
209
+ })
210
+ }
161
211
 
162
- const iterDragId = $dndContext.drops[$dndContext.dropHoverId.get()].items[index].get()
212
+ return await Promise.all(dropIds.map(measureDrop)).then((results) => {
213
+ const hit = results.find((r) => r && refY >= r.top && refY < r.bottom && (refX == null || (refX >= r.left && refX < r.right)))
214
+ const targetDropId = hit ? hit.id : dropIds[0]
215
+ const fallback = results.find((r) => r && r.id === targetDropId)
216
+ const dropTop = (hit ?? fallback)?.top ?? 0
217
+ const dropBottom = (hit ?? fallback)?.bottom ?? 0
218
+ $dndContext.dropHoverId.set(targetDropId)
219
+
220
+ const items = $dndContext.drops[targetDropId].items.get() ?? []
221
+ if (items.length === 0) {
222
+ $dndContext.dragHoverIndex.set(0)
223
+ return 0
224
+ }
163
225
 
164
- await new Promise<void>(resolve => {
165
- const currentElement = $dndContext.drags[iterDragId].ref.current.get()
166
- if (!currentElement) {
167
- positions.push(null)
168
- resolve()
169
- return
170
- }
171
- currentElement.measure((x: any, y: any, width: any, height: any, pageX: any, pageY: any) => {
172
- if (index === 0) {
173
- startPosition = dPageY
174
- endPosition = dPageY + y + (height / 2)
175
- } else {
176
- startPosition = endPosition
177
- endPosition = pageY + (height / 2)
178
- }
179
-
180
- if (iterDragId === dragId) {
181
- positions.push(null)
182
- } else {
183
- positions.push({ start: startPosition, end: endPosition })
184
- }
185
-
186
- resolve()
187
- })
226
+ const n = items.length
227
+ const dropHeight = dropBottom - dropTop
228
+ const slotHeight = dropHeight / n
229
+ const rects: Array<{ top: number, bottom: number, height: number }> = []
230
+ for (let i = 0; i < n; i++) {
231
+ rects.push({
232
+ top: dropTop + i * slotHeight,
233
+ bottom: dropTop + (i + 1) * slotHeight,
234
+ height: slotHeight
188
235
  })
189
236
  }
190
237
 
191
- positions.push({ start: endPosition, end: dPageY + dHeight })
192
-
193
- for (let index = 0; index < positions.length; index++) {
194
- const position = positions[index]
195
- if (!position) continue
238
+ if (refY < rects[0].top) {
239
+ $dndContext.dragHoverIndex.set(0)
240
+ return 0
241
+ }
242
+ if (refY >= rects[n - 1].bottom) {
243
+ $dndContext.dragHoverIndex.set(n)
244
+ return n
245
+ }
196
246
 
197
- if (activeData.y > position.start && activeData.y < position.end) {
198
- $dndContext.dragHoverIndex.set(index)
247
+ const startGhostTop = activeData.startGhostTop ?? activeData.ghostTop
248
+ const movingDown = ghostTopNow >= startGhostTop
249
+ const upThreshold = 0.001
250
+ let hoverIndex = 0
251
+ for (let i = 0; i < n; i++) {
252
+ const r = rects[i]
253
+ if (refY >= r.top && refY < r.bottom) {
254
+ const relY = r.height > 0 ? (refY - r.top) / r.height : 0
255
+ if (movingDown) {
256
+ hoverIndex = Math.min(i + 1, n)
257
+ } else {
258
+ hoverIndex = relY < upThreshold && i > 0 ? i - 1 : i
259
+ }
199
260
  break
200
261
  }
262
+ if (refY < r.top) {
263
+ hoverIndex = i
264
+ break
265
+ }
266
+ hoverIndex = i + 1
201
267
  }
202
- })
268
+
269
+ $dndContext.dragHoverIndex.set(hoverIndex)
270
+ return hoverIndex
271
+ }).then(async (hoverIndex) => await Promise.resolve(hoverIndex))
203
272
  }
204
273
 
205
- const contextStyle = $dndContext.drags[dragId].style.get() || {}
206
- const _style: any = StyleSheet.flatten([style, animateStates])
274
+ const contextStyle = $dndContext.drags[dragId] ? ($dndContext.drags[dragId].style.get() ?? {}) : {}
275
+ const flatStyle = StyleSheet.flatten(style) || {} as any
276
+
277
+ const dragStyleRaw = $dndContext.activeData.dragStyle?.get()
278
+ const dragStyle = dragStyleRaw && typeof dragStyleRaw === 'object' ? dragStyleRaw : null
279
+ const getNum = (v: any): number | undefined =>
280
+ v == null
281
+ ? undefined
282
+ : typeof v?.get === 'function'
283
+ ? v.get()
284
+ : typeof v === 'number'
285
+ ? v
286
+ : undefined
287
+ const phHeight = dragStyle ? getNum(dragStyle.height) : undefined
288
+ const phWidth = dragStyle ? getNum(dragStyle.width) : undefined
289
+
290
+ const isActiveDrag = $dndContext.activeData.dragId.get() === dragId
291
+ const ghostLeft = isActiveDrag ? ($dndContext.activeData.ghostLeft.get() ?? 0) : 0
292
+ const ghostTop = isActiveDrag ? ($dndContext.activeData.ghostTop.get() ?? 0) : 0
293
+ const ghostStyle: ViewStyle = {
294
+ ...flatStyle,
295
+ position: Platform.OS === 'web' ? ('fixed' as any) : 'absolute',
296
+ left: ghostLeft,
297
+ top: ghostTop,
298
+ ...(typeof phWidth === 'number' && phWidth > 0 && { width: phWidth }),
299
+ ...(typeof phHeight === 'number' && phHeight > 0 && { height: phHeight })
300
+ }
301
+ if (Platform.OS === 'web') {
302
+ (ghostStyle as Record<string, unknown>).cursor = 'default'
303
+ }
207
304
 
208
- const isShowPlaceholder = $dndContext.activeData.get() &&
305
+ const isShowPlaceholder =
306
+ $dndContext.activeData.get() &&
209
307
  $dndContext.dropHoverId.get() === dropId &&
210
308
  $dndContext.dragHoverIndex.get() === index
211
309
 
212
- const isShowLastPlaceholder = $dndContext.activeData.get() &&
310
+ const isShowLastPlaceholder =
311
+ $dndContext.activeData.get() &&
213
312
  $dndContext.dropHoverId.get() === dropId &&
214
- $dndContext.drops[dropId].items.get().length - 1 === index &&
313
+ ($dndContext.drops[dropId].items.get() ?? []).length - 1 === index &&
215
314
  $dndContext.dragHoverIndex.get() === index + 1
216
315
 
217
- const placeholder = pug`
218
- View.placeholder(
219
- style={
220
- height: $dndContext.activeData.get() && $dndContext.activeData.dragStyle.get() && $dndContext.activeData.dragStyle.height.get(),
221
- marginTop: $dndContext.activeData.get() && $dndContext.activeData.dragStyle.get() && $dndContext.activeData.dragStyle.marginTop.get(),
222
- marginBottom: $dndContext.activeData.get() && $dndContext.activeData.dragStyle.get() && $dndContext.activeData.dragStyle.marginBottom.get()
223
- }
224
- )
225
- `
316
+ const placeholderStyle: ViewStyle = {
317
+ ...flatStyle,
318
+ backgroundColor: 'var(--color-bg-main-subtle-alt, #e5e7eb)' as any,
319
+ minHeight: 32,
320
+ borderRadius: flatStyle.borderRadius ?? 4,
321
+ ...(typeof phHeight === 'number' && phHeight > 0 && { height: phHeight }),
322
+ ...(typeof phWidth === 'number' && phWidth > 0 && { width: phWidth })
323
+ }
226
324
 
227
325
  return pug`
228
326
  if isShowPlaceholder
229
- = placeholder
327
+ View(style=placeholderStyle)
230
328
 
231
329
  Portal
232
- if $dndContext.activeData.dragId.get() === dragId
233
- Animated.View(style=[
234
- _style,
235
- { position: 'absolute', cursor: 'default' }
236
- ])= children
330
+ if isActiveDrag
331
+ View(style=ghostStyle)= children
237
332
 
238
333
  PanGestureHandler(
239
334
  onHandlerStateChange=onHandlerStateChange
240
335
  onGestureEvent=onGestureEvent
241
336
  )
242
- Animated.View(
243
- ref=ref
244
- style=[style, contextStyle]
245
- )= children
337
+ View(ref=ref style=[style, contextStyle])
338
+ = children
246
339
 
247
340
  if isShowLastPlaceholder
248
- = placeholder
341
+ View(style=placeholderStyle)
249
342
  `
250
343
  }
251
344
 
@@ -1,6 +1,6 @@
1
1
  import React, { useRef, useEffect, useContext, type ReactNode } from 'react'
2
- import { View, StatusBar, type StyleProp, type ViewStyle } from 'react-native'
3
- import { pug, observer, $ } from 'startupjs'
2
+ import { View, type StyleProp, type ViewStyle } from 'react-native'
3
+ import { pug, observer } from 'startupjs'
4
4
  import { themed } from '@startupjs-ui/core'
5
5
  import { DragDropContext } from '../DragDropProvider'
6
6
 
@@ -15,6 +15,8 @@ export interface DroppableProps {
15
15
  type?: string
16
16
  /** Unique droppable container id */
17
17
  dropId: string
18
+ /** Optional: explicit list of item ids (e.g. when list is controlled and may be empty) */
19
+ items?: string[]
18
20
  /** Called when active drag leaves this droppable */
19
21
  onLeave?: () => void
20
22
  /** Called when active drag enters this droppable */
@@ -26,72 +28,43 @@ function Droppable ({
26
28
  style,
27
29
  type,
28
30
  dropId,
31
+ items: itemsProp,
29
32
  onLeave,
30
33
  onHover
31
34
  }: DroppableProps): ReactNode {
32
35
  const ref = useRef<any>(null)
33
36
  const $dndContext = useContext(DragDropContext)
34
- const $isHover = $(false)
35
37
 
36
38
  useEffect(() => {
39
+ if (!$dndContext) return
40
+ const fromProp = Array.isArray(itemsProp) && itemsProp.length > 0
41
+ const items = fromProp
42
+ ? itemsProp
43
+ : React.Children.map(children as any, (child: any) => child?.props?.dragId)
37
44
  $dndContext.drops[dropId].set({
38
45
  ref,
39
- items: React.Children.map(children as any, (child: any) => {
40
- return child?.props?.dragId
41
- })
46
+ items: items ?? []
42
47
  })
43
- }, [children, dropId, $dndContext])
48
+ }, [children, dropId, itemsProp, $dndContext])
44
49
 
45
- useEffect(() => {
46
- ref.current.measure((x: any, y: any, width: any, height: any, pageX: any, pageY: any) => {
47
- if (!$dndContext.activeData.dragId.get() || !$dndContext.dropHoverId.get()) {
48
- $isHover.set(false)
49
- return
50
- }
51
-
52
- const leftBorder = pageX
53
- const rightBorder = pageX + width
54
- const topBorder = pageY
55
- const bottomBorder = pageY + height
56
-
57
- const isHoverUpdate = (
58
- $dndContext.activeData.x.get() > leftBorder &&
59
- $dndContext.activeData.x.get() < rightBorder &&
60
- $dndContext.activeData.y.get() - (StatusBar.currentHeight ?? 0) > topBorder &&
61
- $dndContext.activeData.y.get() - (StatusBar.currentHeight ?? 0) < bottomBorder
62
- )
63
-
64
- if (isHoverUpdate && !$isHover.get()) {
65
- $dndContext.dropHoverId.set(dropId)
66
- onHover && onHover() // TODO
67
- }
50
+ if (!$dndContext) return children as ReactNode
68
51
 
69
- if (!isHoverUpdate && $isHover.get()) {
70
- onLeave && onLeave() // TODO
71
- }
72
-
73
- $isHover.set(isHoverUpdate)
74
- })
75
- // eslint-disable-next-line react-hooks/exhaustive-deps
76
- }, [JSON.stringify($dndContext.activeData.get())])
77
-
78
- const modChildren = React.Children.toArray(children).map((child: any, index) => {
79
- return React.cloneElement(child, {
80
- ...child.props,
81
- _dropId: dropId,
82
- _index: index
83
- })
84
- })
52
+ const modChildren = React.Children.toArray(children).map((child: any, index) =>
53
+ React.cloneElement(child, { ...child.props, _dropId: dropId, _index: index })
54
+ )
85
55
 
86
- const hasActiveDrag = $dndContext.drops[dropId].items.get()?.includes($dndContext.activeData.dragId.get())
56
+ const hasActiveDrag = ($dndContext.drops[dropId].items.get() ?? []).includes($dndContext.activeData.dragId?.get())
87
57
  const activeStyle = hasActiveDrag ? { zIndex: 9999 } : {}
88
- const contextStyle = $dndContext.drops[dropId].style.get() || {}
58
+ const contextStyle = $dndContext.drops[dropId].style?.get() ?? {}
59
+ const items = $dndContext.drops[dropId].items.get() ?? []
60
+ const isHoverTargetEmpty = $dndContext.activeData.get() && $dndContext.dropHoverId.get() === dropId && items.length === 0
61
+ const emptyPlaceholderStyle: ViewStyle = { backgroundColor: 'var(--color-bg-main-subtle-alt, #e5e7eb)' as any, minHeight: 32, borderRadius: 4 }
89
62
 
90
63
  return pug`
91
- View(
92
- ref=ref
93
- style=[style, activeStyle, contextStyle]
94
- )= modChildren
64
+ View(ref=ref style=[style, activeStyle, contextStyle])
65
+ if isHoverTargetEmpty
66
+ View(style=emptyPlaceholderStyle)
67
+ = modChildren
95
68
  `
96
69
  }
97
70
 
package/README.mdx CHANGED
@@ -21,11 +21,11 @@ import './index.mdx.cssx.styl'
21
21
 
22
22
  # Draggable
23
23
 
24
- `Draggable` provides simple building blocks for drag & drop on React Native / RN Web:
24
+ `Draggable` provides simple building blocks for drag and drop on React Native and React Native Web:
25
25
 
26
- - `DragDropProvider` stores drag/drop state and exposes it via context
27
- - `Droppable` marks a drop target and tracks the order of its children
28
- - `Draggable` handles gestures and renders the dragged item into a `Portal`
26
+ - **DragDropProvider** -- stores drag/drop state and exposes it via context
27
+ - **Droppable** -- marks a drop target and tracks the order of its children
28
+ - **Draggable** -- handles gestures and renders the dragged item into a Portal
29
29
 
30
30
  ```jsx
31
31
  import { DragDropProvider, Droppable, Draggable } from 'startupjs-ui'
@@ -34,9 +34,37 @@ import { DragDropProvider, Droppable, Draggable } from 'startupjs-ui'
34
34
  ## How it works
35
35
 
36
36
  1. Wrap the area with `DragDropProvider`.
37
- 2. Inside it render one or more `Droppable`s with unique `dropId`.
38
- 3. Render `Draggable`s inside each `Droppable`, each with a unique `dragId`.
39
- 4. Update your own data in `onDragEnd` using `{ dragId, dropId, dropHoverId, hoverIndex }`.
37
+ 2. Inside it, render one or more `Droppable` containers, each with a unique `dropId`.
38
+ 3. Render `Draggable` items inside each `Droppable`, each with a unique `dragId`.
39
+ 4. Update your data in the `onDragEnd` callback using `{ dragId, dropId, dropHoverId, hoverIndex }`.
40
+
41
+ ## Props
42
+
43
+ ### DragDropProvider
44
+
45
+ - **children** -- the content to render inside the provider
46
+
47
+ ### Droppable
48
+
49
+ - **dropId** (required) -- a unique identifier for this drop target
50
+ - **type** -- an optional type string for filtering which draggables can be dropped here
51
+ - **style** -- custom styles for the droppable container
52
+ - **onHover** -- called when an active drag enters this droppable area
53
+ - **onLeave** -- called when an active drag leaves this droppable area
54
+
55
+ ### Draggable
56
+
57
+ - **dragId** (required) -- a unique identifier for this draggable item
58
+ - **type** -- an optional type string for filtering compatible drop targets
59
+ - **style** -- custom styles for the draggable item
60
+ - **onDragBegin** -- called when the drag gesture starts; receives `{ dragId, dropId, dropHoverId, hoverIndex }`
61
+ - **onDragEnd** -- called when the drag gesture ends; receives `{ dragId, dropId, dropHoverId, hoverIndex }`
62
+
63
+ The callback options contain:
64
+ - `dragId` -- the id of the dragged item
65
+ - `dropId` -- the id of the droppable the item originated from
66
+ - `dropHoverId` -- the id of the droppable the item is currently hovering over
67
+ - `hoverIndex` -- the index position within the hovered droppable
40
68
 
41
69
  ## Simple example (reorder in a single list)
42
70
 
package/index.cssx.styl CHANGED
@@ -1,3 +1,3 @@
1
1
  .placeholder
2
- background-color var(--color-bg-main-subtle-alt)
2
+ background-color var(--color-bg-main-subtle-alt, #e5e7eb)
3
3
  border-radius .5u
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startupjs-ui/draggable",
3
- "version": "0.1.11",
3
+ "version": "0.1.19",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,8 +8,8 @@
8
8
  "types": "index.d.ts",
9
9
  "type": "module",
10
10
  "dependencies": {
11
- "@startupjs-ui/core": "^0.1.11",
12
- "@startupjs-ui/portal": "^0.1.11"
11
+ "@startupjs-ui/core": "^0.1.19",
12
+ "@startupjs-ui/portal": "^0.1.19"
13
13
  },
14
14
  "peerDependencies": {
15
15
  "react": "*",
@@ -17,5 +17,5 @@
17
17
  "react-native-gesture-handler": "*",
18
18
  "startupjs": "*"
19
19
  },
20
- "gitHead": "b21659a9d8408cd921560196db22a18fd8eda82d"
20
+ "gitHead": "bfcca786dc363f42a09b6eef4feb7ca8139302fc"
21
21
  }