@duro-app/ui 3.1.0 → 3.2.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/dist/components/DragDrop/DragDrop.d.ts +67 -0
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -0
- package/dist/components/DragDrop/DragDrop.meta.d.ts +3 -0
- package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -0
- package/dist/components/DragDrop/DragDrop.stories.d.ts +21 -0
- package/dist/components/DragDrop/DragDrop.stories.d.ts.map +1 -0
- package/dist/components/DragDrop/styles.css.d.ts +52 -0
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2813 -2455
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/DragDrop/DragDrop.meta.ts +49 -0
- package/src/components/DragDrop/DragDrop.stories.tsx +249 -0
- package/src/components/DragDrop/DragDrop.tsx +502 -0
- package/src/components/DragDrop/styles.css.ts +62 -0
- package/src/index.ts +7 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import {html} from 'react-strict-dom'
|
|
12
|
+
import {styles} from './styles.css'
|
|
13
|
+
import {isNative} from '../../platform'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// DragDrop — move items between zones with one pointer, on mouse, pen and
|
|
17
|
+
// touch alike.
|
|
18
|
+
//
|
|
19
|
+
// Pointer events are the single input path: a press arms a drag, movement
|
|
20
|
+
// past a small threshold (or, on touch, a short hold) starts it, pointer
|
|
21
|
+
// capture keeps the stream on the item, and release resolves the zone under
|
|
22
|
+
// the pointer. The dragged item stays in the DOM (faded) while a ghost copy
|
|
23
|
+
// follows the pointer, so layout never shifts mid-drag and a cancelled drag
|
|
24
|
+
// costs nothing.
|
|
25
|
+
//
|
|
26
|
+
// Dragging is a pointer gesture only. Every consumer MUST keep a non-drag way
|
|
27
|
+
// to do the same thing (a button that adds, a remove affordance) — that is
|
|
28
|
+
// the keyboard and assistive path, and WCAG 2.5.7 requires it. The Root's
|
|
29
|
+
// live region announces picks and drops so screen readers follow along.
|
|
30
|
+
//
|
|
31
|
+
// Native: react-strict-dom does not route pointer capture on React Native,
|
|
32
|
+
// so Item renders its children without drag behaviour there and the consumer's
|
|
33
|
+
// non-drag path is the only path. Not exported from the native barrel.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface DragDropItemData<T = unknown> {
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly zone: string
|
|
39
|
+
readonly data: T
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DragDropTarget {
|
|
43
|
+
readonly zone: string
|
|
44
|
+
/** Insertion index among the target zone's items, from the pointer's
|
|
45
|
+
* position along the zone's axis. Excludes the dragged item itself. */
|
|
46
|
+
readonly index: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DragDropEvent<T = unknown> {
|
|
50
|
+
readonly item: DragDropItemData<T>
|
|
51
|
+
readonly target: DragDropTarget
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ZoneRecord {
|
|
55
|
+
readonly el: HTMLElement
|
|
56
|
+
readonly orientation: 'horizontal' | 'vertical'
|
|
57
|
+
readonly accepts?: (item: DragDropItemData) => boolean
|
|
58
|
+
readonly label: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ItemRecord {
|
|
62
|
+
readonly el: HTMLElement
|
|
63
|
+
readonly zone: string
|
|
64
|
+
readonly data: unknown
|
|
65
|
+
readonly label: string
|
|
66
|
+
readonly preview: ReactNode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface DragSession {
|
|
70
|
+
readonly id: string
|
|
71
|
+
readonly pointerId: number
|
|
72
|
+
/** Event time of the press, for judging the touch hold by timestamps. */
|
|
73
|
+
readonly downAt: number
|
|
74
|
+
readonly startX: number
|
|
75
|
+
readonly startY: number
|
|
76
|
+
readonly offsetX: number
|
|
77
|
+
readonly offsetY: number
|
|
78
|
+
readonly width: number
|
|
79
|
+
readonly height: number
|
|
80
|
+
readonly source: HTMLElement
|
|
81
|
+
active: boolean
|
|
82
|
+
holdTimer: ReturnType<typeof setTimeout> | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface DragState {
|
|
86
|
+
readonly id: string
|
|
87
|
+
readonly preview: ReactNode
|
|
88
|
+
readonly width: number
|
|
89
|
+
readonly height: number
|
|
90
|
+
readonly x: number
|
|
91
|
+
readonly y: number
|
|
92
|
+
readonly over: DragDropTarget | null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface RootContextValue {
|
|
96
|
+
registerZone: (id: string, record: ZoneRecord) => () => void
|
|
97
|
+
registerItem: (id: string, record: ItemRecord) => () => void
|
|
98
|
+
begin: (id: string, e: React.PointerEvent) => void
|
|
99
|
+
drag: DragState | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const RootContext = createContext<RootContextValue | null>(null)
|
|
103
|
+
|
|
104
|
+
const useRoot = (part: string): RootContextValue => {
|
|
105
|
+
const ctx = useContext(RootContext)
|
|
106
|
+
if (!ctx) throw new Error(`DragDrop.${part} must be used within DragDrop.Root`)
|
|
107
|
+
return ctx
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How far the pointer moves before a press becomes a drag (mouse/pen), so a
|
|
111
|
+
* click stays a click. */
|
|
112
|
+
const MOVE_THRESHOLD_PX = 6
|
|
113
|
+
/** How long a touch holds still before it becomes a drag. Below this a touch
|
|
114
|
+
* that moves is a scroll, and the browser gets it back via pointercancel. */
|
|
115
|
+
const TOUCH_HOLD_MS = 180
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Root
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export interface DragDropRootProps<T = unknown> {
|
|
122
|
+
/** Called when an item is released over a zone that accepts it. Reordering
|
|
123
|
+
* inside the item's own zone arrives here too, with the new index. */
|
|
124
|
+
onDrop: (event: DragDropEvent<T>) => void
|
|
125
|
+
/** Overrides the default screen-reader announcements. */
|
|
126
|
+
announce?: (event: {kind: 'pick' | 'drop' | 'cancel'; item: string; zone?: string}) => string
|
|
127
|
+
children: ReactNode
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
|
|
131
|
+
const zones = useRef(new Map<string, ZoneRecord>())
|
|
132
|
+
const items = useRef(new Map<string, ItemRecord>())
|
|
133
|
+
const session = useRef<DragSession | null>(null)
|
|
134
|
+
const frame = useRef<number | null>(null)
|
|
135
|
+
const [drag, setDrag] = useState<DragState | null>(null)
|
|
136
|
+
const [announcement, setAnnouncement] = useState('')
|
|
137
|
+
|
|
138
|
+
const say = useCallback(
|
|
139
|
+
(kind: 'pick' | 'drop' | 'cancel', itemId: string, zoneId?: string) => {
|
|
140
|
+
const item = items.current.get(itemId)?.label ?? itemId
|
|
141
|
+
const zone = zoneId ? (zones.current.get(zoneId)?.label ?? zoneId) : undefined
|
|
142
|
+
const text = announce
|
|
143
|
+
? announce({kind, item, zone})
|
|
144
|
+
: kind === 'pick'
|
|
145
|
+
? `Picked up ${item}.`
|
|
146
|
+
: kind === 'drop'
|
|
147
|
+
? `Dropped ${item} in ${zone ?? 'place'}.`
|
|
148
|
+
: `Cancelled moving ${item}.`
|
|
149
|
+
setAnnouncement(text)
|
|
150
|
+
},
|
|
151
|
+
[announce],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const registerZone = useCallback((id: string, record: ZoneRecord) => {
|
|
155
|
+
zones.current.set(id, record)
|
|
156
|
+
return () => {
|
|
157
|
+
zones.current.delete(id)
|
|
158
|
+
}
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
161
|
+
const registerItem = useCallback((id: string, record: ItemRecord) => {
|
|
162
|
+
items.current.set(id, record)
|
|
163
|
+
return () => {
|
|
164
|
+
items.current.delete(id)
|
|
165
|
+
}
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
/** The zone under the pointer that accepts the dragged item, and where in
|
|
169
|
+
* it the item would land. */
|
|
170
|
+
const locate = useCallback((itemId: string, x: number, y: number): DragDropTarget | null => {
|
|
171
|
+
const item = items.current.get(itemId)
|
|
172
|
+
if (!item) return null
|
|
173
|
+
const payload: DragDropItemData = {id: itemId, zone: item.zone, data: item.data}
|
|
174
|
+
for (const [zoneId, zone] of zones.current) {
|
|
175
|
+
const r = zone.el.getBoundingClientRect()
|
|
176
|
+
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
|
|
177
|
+
if (zone.accepts && !zone.accepts(payload)) return null
|
|
178
|
+
const horizontal = zone.orientation === 'horizontal'
|
|
179
|
+
let index = 0
|
|
180
|
+
for (const [otherId, other] of items.current) {
|
|
181
|
+
if (otherId === itemId || other.zone !== zoneId) continue
|
|
182
|
+
const or = other.el.getBoundingClientRect()
|
|
183
|
+
const mid = horizontal ? (or.left + or.right) / 2 : (or.top + or.bottom) / 2
|
|
184
|
+
if ((horizontal ? x : y) > mid) index += 1
|
|
185
|
+
}
|
|
186
|
+
return {zone: zoneId, index}
|
|
187
|
+
}
|
|
188
|
+
return null
|
|
189
|
+
}, [])
|
|
190
|
+
|
|
191
|
+
const finish = useCallback(
|
|
192
|
+
(dropped: boolean) => {
|
|
193
|
+
const s = session.current
|
|
194
|
+
if (!s) return
|
|
195
|
+
if (s.holdTimer) clearTimeout(s.holdTimer)
|
|
196
|
+
if (frame.current !== null) cancelAnimationFrame(frame.current)
|
|
197
|
+
frame.current = null
|
|
198
|
+
session.current = null
|
|
199
|
+
try {
|
|
200
|
+
if (s.source.hasPointerCapture(s.pointerId)) s.source.releasePointerCapture(s.pointerId)
|
|
201
|
+
} catch {
|
|
202
|
+
// already released
|
|
203
|
+
}
|
|
204
|
+
if (!s.active) return
|
|
205
|
+
setDrag((current) => {
|
|
206
|
+
const target = current?.over ?? null
|
|
207
|
+
const item = items.current.get(s.id)
|
|
208
|
+
if (dropped && target && item) {
|
|
209
|
+
onDrop({item: {id: s.id, zone: item.zone, data: item.data as T}, target})
|
|
210
|
+
say('drop', s.id, target.zone)
|
|
211
|
+
} else {
|
|
212
|
+
say('cancel', s.id)
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
[onDrop, say],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
const activate = useCallback(
|
|
221
|
+
(s: DragSession, x: number, y: number) => {
|
|
222
|
+
s.active = true
|
|
223
|
+
const item = items.current.get(s.id)
|
|
224
|
+
setDrag({
|
|
225
|
+
id: s.id,
|
|
226
|
+
preview: item?.preview ?? null,
|
|
227
|
+
width: s.width,
|
|
228
|
+
height: s.height,
|
|
229
|
+
x: x - s.offsetX,
|
|
230
|
+
y: y - s.offsetY,
|
|
231
|
+
over: locate(s.id, x, y),
|
|
232
|
+
})
|
|
233
|
+
say('pick', s.id)
|
|
234
|
+
},
|
|
235
|
+
[locate, say],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const begin = useCallback(
|
|
239
|
+
(id: string, e: React.PointerEvent) => {
|
|
240
|
+
if (session.current || e.button !== 0) return
|
|
241
|
+
const source = e.currentTarget as HTMLElement
|
|
242
|
+
const rect = source.getBoundingClientRect()
|
|
243
|
+
const s: DragSession = {
|
|
244
|
+
id,
|
|
245
|
+
pointerId: e.pointerId,
|
|
246
|
+
downAt: e.timeStamp,
|
|
247
|
+
startX: e.clientX,
|
|
248
|
+
startY: e.clientY,
|
|
249
|
+
offsetX: e.clientX - rect.left,
|
|
250
|
+
offsetY: e.clientY - rect.top,
|
|
251
|
+
width: rect.width,
|
|
252
|
+
height: rect.height,
|
|
253
|
+
source,
|
|
254
|
+
active: false,
|
|
255
|
+
holdTimer: null,
|
|
256
|
+
}
|
|
257
|
+
session.current = s
|
|
258
|
+
// Capture keeps the stream on the item if the pointer leaves the window;
|
|
259
|
+
// the document listeners below carry the drag either way, so a browser
|
|
260
|
+
// (or a synthetic event) without an active pointer is not an error.
|
|
261
|
+
try {
|
|
262
|
+
source.setPointerCapture(e.pointerId)
|
|
263
|
+
} catch {
|
|
264
|
+
// no active pointer to capture
|
|
265
|
+
}
|
|
266
|
+
if (e.pointerType === 'touch') {
|
|
267
|
+
s.holdTimer = setTimeout(() => {
|
|
268
|
+
s.holdTimer = null
|
|
269
|
+
if (session.current === s && !s.active) activate(s, s.startX, s.startY)
|
|
270
|
+
}, TOUCH_HOLD_MS)
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[activate],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// Move/up/cancel listen on the document: pointer capture keeps the events
|
|
277
|
+
// flowing to the source element, but a single document listener survives
|
|
278
|
+
// the item re-rendering under the pointer.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
const onMove = (e: PointerEvent) => {
|
|
281
|
+
const s = session.current
|
|
282
|
+
if (!s || e.pointerId !== s.pointerId) return
|
|
283
|
+
if (!s.active) {
|
|
284
|
+
const moved = Math.hypot(e.clientX - s.startX, e.clientY - s.startY)
|
|
285
|
+
if (e.pointerType === 'touch') {
|
|
286
|
+
// The hold is judged by event time, not by the timer having fired:
|
|
287
|
+
// a busy main thread (seen on iOS Safari) can withhold the timer
|
|
288
|
+
// AND every pointermove until the finger lifts, then deliver them
|
|
289
|
+
// in one burst. Since touch-action:none keeps the browser from
|
|
290
|
+
// panning on the item, a move stream that arrives at all is ours;
|
|
291
|
+
// the hold only separates a drag from a quick flick.
|
|
292
|
+
if (e.timeStamp - s.downAt >= TOUCH_HOLD_MS) {
|
|
293
|
+
if (s.holdTimer) clearTimeout(s.holdTimer)
|
|
294
|
+
s.holdTimer = null
|
|
295
|
+
activate(s, s.startX, s.startY)
|
|
296
|
+
} else {
|
|
297
|
+
// Moved before the hold elapsed: a flick, not a drag.
|
|
298
|
+
if (moved > MOVE_THRESHOLD_PX) finish(false)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
if (moved < MOVE_THRESHOLD_PX) return
|
|
303
|
+
activate(s, e.clientX, e.clientY)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (frame.current !== null) return
|
|
307
|
+
frame.current = requestAnimationFrame(() => {
|
|
308
|
+
frame.current = null
|
|
309
|
+
const x = e.clientX
|
|
310
|
+
const y = e.clientY
|
|
311
|
+
setDrag((current) =>
|
|
312
|
+
current
|
|
313
|
+
? {...current, x: x - s.offsetX, y: y - s.offsetY, over: locate(s.id, x, y)}
|
|
314
|
+
: current,
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
const onUp = (e: PointerEvent) => {
|
|
319
|
+
const s = session.current
|
|
320
|
+
if (!s || e.pointerId !== s.pointerId) return
|
|
321
|
+
// Resolve the target from the release point, not the last frame.
|
|
322
|
+
if (s.active) {
|
|
323
|
+
const over = locate(s.id, e.clientX, e.clientY)
|
|
324
|
+
setDrag((current) => (current ? {...current, over} : current))
|
|
325
|
+
}
|
|
326
|
+
finish(true)
|
|
327
|
+
}
|
|
328
|
+
const onCancel = (e: PointerEvent) => {
|
|
329
|
+
const s = session.current
|
|
330
|
+
if (!s || e.pointerId !== s.pointerId) return
|
|
331
|
+
finish(false)
|
|
332
|
+
}
|
|
333
|
+
const onKey = (e: KeyboardEvent) => {
|
|
334
|
+
if (e.key === 'Escape' && session.current) finish(false)
|
|
335
|
+
}
|
|
336
|
+
document.addEventListener('pointermove', onMove)
|
|
337
|
+
document.addEventListener('pointerup', onUp)
|
|
338
|
+
document.addEventListener('pointercancel', onCancel)
|
|
339
|
+
document.addEventListener('keydown', onKey)
|
|
340
|
+
return () => {
|
|
341
|
+
document.removeEventListener('pointermove', onMove)
|
|
342
|
+
document.removeEventListener('pointerup', onUp)
|
|
343
|
+
document.removeEventListener('pointercancel', onCancel)
|
|
344
|
+
document.removeEventListener('keydown', onKey)
|
|
345
|
+
}
|
|
346
|
+
}, [activate, finish, locate])
|
|
347
|
+
|
|
348
|
+
const value = useMemo<RootContextValue>(
|
|
349
|
+
() => ({registerZone, registerItem, begin, drag}),
|
|
350
|
+
[registerZone, registerItem, begin, drag],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<RootContext.Provider value={value}>
|
|
355
|
+
{children}
|
|
356
|
+
{drag && (
|
|
357
|
+
<html.div
|
|
358
|
+
aria-hidden
|
|
359
|
+
style={[styles.ghost, styles.ghostAt(drag.x, drag.y, drag.width, drag.height)]}
|
|
360
|
+
>
|
|
361
|
+
{drag.preview}
|
|
362
|
+
</html.div>
|
|
363
|
+
)}
|
|
364
|
+
<html.div role="status" aria-live="polite" aria-atomic style={styles.liveRegion}>
|
|
365
|
+
{announcement}
|
|
366
|
+
</html.div>
|
|
367
|
+
</RootContext.Provider>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Zone
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
export interface DragDropZoneProps<T = unknown> {
|
|
376
|
+
id: string
|
|
377
|
+
/** Read to screen readers when an item is dropped here. */
|
|
378
|
+
label: string
|
|
379
|
+
/** Which axis items flow along; decides how the insertion index is read
|
|
380
|
+
* from the pointer. */
|
|
381
|
+
orientation?: 'horizontal' | 'vertical'
|
|
382
|
+
/** Refuse an item; the zone then never lights up for it. */
|
|
383
|
+
accepts?: (item: DragDropItemData<T>) => boolean
|
|
384
|
+
children: ReactNode
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function Zone<T = unknown>({
|
|
388
|
+
id,
|
|
389
|
+
label,
|
|
390
|
+
orientation = 'horizontal',
|
|
391
|
+
accepts,
|
|
392
|
+
children,
|
|
393
|
+
}: DragDropZoneProps<T>) {
|
|
394
|
+
const {registerZone, drag} = useRoot('Zone')
|
|
395
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const el = ref.current
|
|
399
|
+
if (!el || isNative) return
|
|
400
|
+
return registerZone(id, {
|
|
401
|
+
el,
|
|
402
|
+
orientation,
|
|
403
|
+
label,
|
|
404
|
+
accepts: accepts as ZoneRecord['accepts'],
|
|
405
|
+
})
|
|
406
|
+
}, [id, label, orientation, accepts, registerZone])
|
|
407
|
+
|
|
408
|
+
const over = drag?.over?.zone === id
|
|
409
|
+
const dragging = drag !== null
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<html.div
|
|
413
|
+
ref={ref}
|
|
414
|
+
style={[styles.zone, dragging && styles.zoneReady, over && styles.zoneOver]}
|
|
415
|
+
>
|
|
416
|
+
{children}
|
|
417
|
+
</html.div>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Item
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
export interface DragDropItemProps<T = unknown> {
|
|
426
|
+
id: string
|
|
427
|
+
/** The zone this item currently lives in. */
|
|
428
|
+
zone: string
|
|
429
|
+
/** Read to screen readers when picked up or dropped. */
|
|
430
|
+
label: string
|
|
431
|
+
/** Carried to onDrop untouched. */
|
|
432
|
+
data?: T
|
|
433
|
+
/** What follows the pointer; defaults to the children. */
|
|
434
|
+
preview?: ReactNode
|
|
435
|
+
disabled?: boolean
|
|
436
|
+
children: ReactNode
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function Item<T = unknown>({
|
|
440
|
+
id,
|
|
441
|
+
zone,
|
|
442
|
+
label,
|
|
443
|
+
data,
|
|
444
|
+
preview,
|
|
445
|
+
disabled = false,
|
|
446
|
+
children,
|
|
447
|
+
}: DragDropItemProps<T>) {
|
|
448
|
+
const {registerItem, begin, drag} = useRoot('Item')
|
|
449
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
450
|
+
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
const el = ref.current
|
|
453
|
+
if (!el || isNative) return
|
|
454
|
+
return registerItem(id, {el, zone, data, label, preview: preview ?? children})
|
|
455
|
+
}, [id, zone, data, label, preview, children, registerItem])
|
|
456
|
+
|
|
457
|
+
// WebKit only honours the prefixed user-select, and a touch held on
|
|
458
|
+
// selectable text becomes iOS's selection gesture (loupe and handles)
|
|
459
|
+
// before our hold can turn it into a drag; the callout is the same race
|
|
460
|
+
// for links and images. StyleX emits neither prefixed property and
|
|
461
|
+
// react-strict-dom's types have no vendor keys, so they are set on the
|
|
462
|
+
// element directly. Both inherit into the item's children.
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
const el = ref.current
|
|
465
|
+
if (!el || isNative) return
|
|
466
|
+
const style = el.style as CSSStyleDeclaration & {webkitTouchCallout?: string}
|
|
467
|
+
style.webkitUserSelect = 'none'
|
|
468
|
+
style.webkitTouchCallout = 'none'
|
|
469
|
+
}, [])
|
|
470
|
+
|
|
471
|
+
const onPointerDown = useCallback(
|
|
472
|
+
(e: React.PointerEvent) => {
|
|
473
|
+
if (disabled) return
|
|
474
|
+
begin(id, e)
|
|
475
|
+
},
|
|
476
|
+
[begin, disabled, id],
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if (isNative) return <>{children}</>
|
|
480
|
+
|
|
481
|
+
const lifted = drag?.id === id
|
|
482
|
+
return (
|
|
483
|
+
<html.div
|
|
484
|
+
ref={ref}
|
|
485
|
+
onPointerDown={onPointerDown}
|
|
486
|
+
style={[styles.item, disabled && styles.itemDisabled, lifted && styles.itemLifted]}
|
|
487
|
+
>
|
|
488
|
+
{children}
|
|
489
|
+
</html.div>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Current drag, for consumers that render their own placeholder or ghost. */
|
|
494
|
+
export function useDragDrop(): {
|
|
495
|
+
readonly dragging: string | null
|
|
496
|
+
readonly over: DragDropTarget | null
|
|
497
|
+
} {
|
|
498
|
+
const ctx = useContext(RootContext)
|
|
499
|
+
return {dragging: ctx?.drag?.id ?? null, over: ctx?.drag?.over ?? null}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export const DragDrop = {Root, Zone, Item}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
5
|
+
|
|
6
|
+
export const styles = css.create({
|
|
7
|
+
// The item is the drag handle: no browser panning starts on it, so a touch
|
|
8
|
+
// hold becomes a drag instead of a scroll, and text inside never selects
|
|
9
|
+
// mid-gesture.
|
|
10
|
+
item: {
|
|
11
|
+
touchAction: 'none',
|
|
12
|
+
userSelect: 'none',
|
|
13
|
+
cursor: 'grab',
|
|
14
|
+
transitionProperty: 'opacity',
|
|
15
|
+
transitionDuration: duration.fast,
|
|
16
|
+
transitionTimingFunction: easing.standard,
|
|
17
|
+
},
|
|
18
|
+
itemDisabled: {
|
|
19
|
+
cursor: 'default',
|
|
20
|
+
},
|
|
21
|
+
// The source stays in flow while its ghost travels, so nothing reflows.
|
|
22
|
+
itemLifted: {
|
|
23
|
+
opacity: 0.4,
|
|
24
|
+
cursor: 'grabbing',
|
|
25
|
+
},
|
|
26
|
+
zone: {
|
|
27
|
+
borderRadius: radii.sm,
|
|
28
|
+
transitionProperty: 'box-shadow, background-color',
|
|
29
|
+
transitionDuration: duration.fast,
|
|
30
|
+
transitionTimingFunction: easing.standard,
|
|
31
|
+
},
|
|
32
|
+
// Every zone shows it can receive while something is in the air; the one
|
|
33
|
+
// under the pointer lights up.
|
|
34
|
+
zoneReady: {
|
|
35
|
+
boxShadow: `inset 0 0 0 1px ${colors.border}`,
|
|
36
|
+
},
|
|
37
|
+
zoneOver: {
|
|
38
|
+
boxShadow: `inset 0 0 0 2px ${colors.accent}`,
|
|
39
|
+
backgroundColor: colors.infoBg,
|
|
40
|
+
},
|
|
41
|
+
ghost: {
|
|
42
|
+
position: 'fixed',
|
|
43
|
+
top: 0,
|
|
44
|
+
left: 0,
|
|
45
|
+
pointerEvents: 'none',
|
|
46
|
+
zIndex: 1000,
|
|
47
|
+
opacity: 0.9,
|
|
48
|
+
cursor: 'grabbing',
|
|
49
|
+
},
|
|
50
|
+
ghostAt: (x: number, y: number, width: number, height: number) => ({
|
|
51
|
+
width,
|
|
52
|
+
height,
|
|
53
|
+
transform: `translate3d(${x}px, ${y}px, 0)`,
|
|
54
|
+
}),
|
|
55
|
+
// Same visually-hidden recipe as TagGroup's live region.
|
|
56
|
+
liveRegion: {
|
|
57
|
+
position: 'absolute',
|
|
58
|
+
width: 1,
|
|
59
|
+
height: 1,
|
|
60
|
+
overflow: 'hidden',
|
|
61
|
+
},
|
|
62
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,13 @@ export {Checkbox} from './components/Checkbox/Checkbox'
|
|
|
20
20
|
export {CheckboxGroup} from './components/CheckboxGroup/CheckboxGroup'
|
|
21
21
|
export {ColorInput} from './components/ColorInput/ColorInput'
|
|
22
22
|
export {ConfirmDialog} from './components/ConfirmDialog/ConfirmDialog'
|
|
23
|
+
export {
|
|
24
|
+
DragDrop,
|
|
25
|
+
useDragDrop,
|
|
26
|
+
type DragDropEvent,
|
|
27
|
+
type DragDropItemData,
|
|
28
|
+
type DragDropTarget,
|
|
29
|
+
} from './components/DragDrop/DragDrop'
|
|
23
30
|
export {Dialog, type DialogSize} from './components/Dialog/Dialog'
|
|
24
31
|
export {DetailPanel, type DetailPanelSize} from './components/DetailPanel/DetailPanel'
|
|
25
32
|
export {Drawer, type DrawerAnchor, type DrawerSize} from './components/Drawer/Drawer'
|