@duro-app/ui 3.1.0 → 3.2.1

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,499 @@
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
+ // `target` is the resolved drop target (null cancels). The consumer's
192
+ // onDrop runs here, in the event handler, never inside a state updater:
193
+ // React may call an updater more than once and forbids updating another
194
+ // component from it.
195
+ const finish = useCallback(
196
+ (target: DragDropTarget | null) => {
197
+ const s = session.current
198
+ if (!s) return
199
+ if (s.holdTimer) clearTimeout(s.holdTimer)
200
+ if (frame.current !== null) cancelAnimationFrame(frame.current)
201
+ frame.current = null
202
+ session.current = null
203
+ try {
204
+ if (s.source.hasPointerCapture(s.pointerId)) s.source.releasePointerCapture(s.pointerId)
205
+ } catch {
206
+ // already released
207
+ }
208
+ if (!s.active) return
209
+ const item = items.current.get(s.id)
210
+ setDrag(null)
211
+ if (target && item) {
212
+ onDrop({item: {id: s.id, zone: item.zone, data: item.data as T}, target})
213
+ say('drop', s.id, target.zone)
214
+ } else {
215
+ say('cancel', s.id)
216
+ }
217
+ },
218
+ [onDrop, say],
219
+ )
220
+
221
+ const activate = useCallback(
222
+ (s: DragSession, x: number, y: number) => {
223
+ s.active = true
224
+ const item = items.current.get(s.id)
225
+ setDrag({
226
+ id: s.id,
227
+ preview: item?.preview ?? null,
228
+ width: s.width,
229
+ height: s.height,
230
+ x: x - s.offsetX,
231
+ y: y - s.offsetY,
232
+ over: locate(s.id, x, y),
233
+ })
234
+ say('pick', s.id)
235
+ },
236
+ [locate, say],
237
+ )
238
+
239
+ const begin = useCallback(
240
+ (id: string, e: React.PointerEvent) => {
241
+ if (session.current || e.button !== 0) return
242
+ const source = e.currentTarget as HTMLElement
243
+ const rect = source.getBoundingClientRect()
244
+ const s: DragSession = {
245
+ id,
246
+ pointerId: e.pointerId,
247
+ downAt: e.timeStamp,
248
+ startX: e.clientX,
249
+ startY: e.clientY,
250
+ offsetX: e.clientX - rect.left,
251
+ offsetY: e.clientY - rect.top,
252
+ width: rect.width,
253
+ height: rect.height,
254
+ source,
255
+ active: false,
256
+ holdTimer: null,
257
+ }
258
+ session.current = s
259
+ // Capture keeps the stream on the item if the pointer leaves the window;
260
+ // the document listeners below carry the drag either way, so a browser
261
+ // (or a synthetic event) without an active pointer is not an error.
262
+ try {
263
+ source.setPointerCapture(e.pointerId)
264
+ } catch {
265
+ // no active pointer to capture
266
+ }
267
+ if (e.pointerType === 'touch') {
268
+ s.holdTimer = setTimeout(() => {
269
+ s.holdTimer = null
270
+ if (session.current === s && !s.active) activate(s, s.startX, s.startY)
271
+ }, TOUCH_HOLD_MS)
272
+ }
273
+ },
274
+ [activate],
275
+ )
276
+
277
+ // Move/up/cancel listen on the document: pointer capture keeps the events
278
+ // flowing to the source element, but a single document listener survives
279
+ // the item re-rendering under the pointer.
280
+ useEffect(() => {
281
+ const onMove = (e: PointerEvent) => {
282
+ const s = session.current
283
+ if (!s || e.pointerId !== s.pointerId) return
284
+ if (!s.active) {
285
+ const moved = Math.hypot(e.clientX - s.startX, e.clientY - s.startY)
286
+ if (e.pointerType === 'touch') {
287
+ // The hold is judged by event time, not by the timer having fired:
288
+ // a busy main thread (seen on iOS Safari) can withhold the timer
289
+ // AND every pointermove until the finger lifts, then deliver them
290
+ // in one burst. Since touch-action:none keeps the browser from
291
+ // panning on the item, a move stream that arrives at all is ours;
292
+ // the hold only separates a drag from a quick flick.
293
+ if (e.timeStamp - s.downAt >= TOUCH_HOLD_MS) {
294
+ if (s.holdTimer) clearTimeout(s.holdTimer)
295
+ s.holdTimer = null
296
+ activate(s, s.startX, s.startY)
297
+ } else {
298
+ // Moved before the hold elapsed: a flick, not a drag.
299
+ if (moved > MOVE_THRESHOLD_PX) finish(null)
300
+ return
301
+ }
302
+ } else {
303
+ if (moved < MOVE_THRESHOLD_PX) return
304
+ activate(s, e.clientX, e.clientY)
305
+ }
306
+ }
307
+ if (frame.current !== null) return
308
+ frame.current = requestAnimationFrame(() => {
309
+ frame.current = null
310
+ const x = e.clientX
311
+ const y = e.clientY
312
+ setDrag((current) =>
313
+ current
314
+ ? {...current, x: x - s.offsetX, y: y - s.offsetY, over: locate(s.id, x, y)}
315
+ : current,
316
+ )
317
+ })
318
+ }
319
+ const onUp = (e: PointerEvent) => {
320
+ const s = session.current
321
+ if (!s || e.pointerId !== s.pointerId) return
322
+ // Resolve the target from the release point, not the last frame.
323
+ finish(s.active ? locate(s.id, e.clientX, e.clientY) : null)
324
+ }
325
+ const onCancel = (e: PointerEvent) => {
326
+ const s = session.current
327
+ if (!s || e.pointerId !== s.pointerId) return
328
+ finish(null)
329
+ }
330
+ const onKey = (e: KeyboardEvent) => {
331
+ if (e.key === 'Escape' && session.current) finish(null)
332
+ }
333
+ document.addEventListener('pointermove', onMove)
334
+ document.addEventListener('pointerup', onUp)
335
+ document.addEventListener('pointercancel', onCancel)
336
+ document.addEventListener('keydown', onKey)
337
+ return () => {
338
+ document.removeEventListener('pointermove', onMove)
339
+ document.removeEventListener('pointerup', onUp)
340
+ document.removeEventListener('pointercancel', onCancel)
341
+ document.removeEventListener('keydown', onKey)
342
+ }
343
+ }, [activate, finish, locate])
344
+
345
+ const value = useMemo<RootContextValue>(
346
+ () => ({registerZone, registerItem, begin, drag}),
347
+ [registerZone, registerItem, begin, drag],
348
+ )
349
+
350
+ return (
351
+ <RootContext.Provider value={value}>
352
+ {children}
353
+ {drag && (
354
+ <html.div
355
+ aria-hidden
356
+ style={[styles.ghost, styles.ghostAt(drag.x, drag.y, drag.width, drag.height)]}
357
+ >
358
+ {drag.preview}
359
+ </html.div>
360
+ )}
361
+ <html.div role="status" aria-live="polite" aria-atomic style={styles.liveRegion}>
362
+ {announcement}
363
+ </html.div>
364
+ </RootContext.Provider>
365
+ )
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Zone
370
+ // ---------------------------------------------------------------------------
371
+
372
+ export interface DragDropZoneProps<T = unknown> {
373
+ id: string
374
+ /** Read to screen readers when an item is dropped here. */
375
+ label: string
376
+ /** Which axis items flow along; decides how the insertion index is read
377
+ * from the pointer. */
378
+ orientation?: 'horizontal' | 'vertical'
379
+ /** Refuse an item; the zone then never lights up for it. */
380
+ accepts?: (item: DragDropItemData<T>) => boolean
381
+ children: ReactNode
382
+ }
383
+
384
+ function Zone<T = unknown>({
385
+ id,
386
+ label,
387
+ orientation = 'horizontal',
388
+ accepts,
389
+ children,
390
+ }: DragDropZoneProps<T>) {
391
+ const {registerZone, drag} = useRoot('Zone')
392
+ const ref = useRef<HTMLDivElement>(null)
393
+
394
+ useEffect(() => {
395
+ const el = ref.current
396
+ if (!el || isNative) return
397
+ return registerZone(id, {
398
+ el,
399
+ orientation,
400
+ label,
401
+ accepts: accepts as ZoneRecord['accepts'],
402
+ })
403
+ }, [id, label, orientation, accepts, registerZone])
404
+
405
+ const over = drag?.over?.zone === id
406
+ const dragging = drag !== null
407
+
408
+ return (
409
+ <html.div
410
+ ref={ref}
411
+ style={[styles.zone, dragging && styles.zoneReady, over && styles.zoneOver]}
412
+ >
413
+ {children}
414
+ </html.div>
415
+ )
416
+ }
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Item
420
+ // ---------------------------------------------------------------------------
421
+
422
+ export interface DragDropItemProps<T = unknown> {
423
+ id: string
424
+ /** The zone this item currently lives in. */
425
+ zone: string
426
+ /** Read to screen readers when picked up or dropped. */
427
+ label: string
428
+ /** Carried to onDrop untouched. */
429
+ data?: T
430
+ /** What follows the pointer; defaults to the children. */
431
+ preview?: ReactNode
432
+ disabled?: boolean
433
+ children: ReactNode
434
+ }
435
+
436
+ function Item<T = unknown>({
437
+ id,
438
+ zone,
439
+ label,
440
+ data,
441
+ preview,
442
+ disabled = false,
443
+ children,
444
+ }: DragDropItemProps<T>) {
445
+ const {registerItem, begin, drag} = useRoot('Item')
446
+ const ref = useRef<HTMLDivElement>(null)
447
+
448
+ useEffect(() => {
449
+ const el = ref.current
450
+ if (!el || isNative) return
451
+ return registerItem(id, {el, zone, data, label, preview: preview ?? children})
452
+ }, [id, zone, data, label, preview, children, registerItem])
453
+
454
+ // WebKit only honours the prefixed user-select, and a touch held on
455
+ // selectable text becomes iOS's selection gesture (loupe and handles)
456
+ // before our hold can turn it into a drag; the callout is the same race
457
+ // for links and images. StyleX emits neither prefixed property and
458
+ // react-strict-dom's types have no vendor keys, so they are set on the
459
+ // element directly. Both inherit into the item's children.
460
+ useEffect(() => {
461
+ const el = ref.current
462
+ if (!el || isNative) return
463
+ const style = el.style as CSSStyleDeclaration & {webkitTouchCallout?: string}
464
+ style.webkitUserSelect = 'none'
465
+ style.webkitTouchCallout = 'none'
466
+ }, [])
467
+
468
+ const onPointerDown = useCallback(
469
+ (e: React.PointerEvent) => {
470
+ if (disabled) return
471
+ begin(id, e)
472
+ },
473
+ [begin, disabled, id],
474
+ )
475
+
476
+ if (isNative) return <>{children}</>
477
+
478
+ const lifted = drag?.id === id
479
+ return (
480
+ <html.div
481
+ ref={ref}
482
+ onPointerDown={onPointerDown}
483
+ style={[styles.item, disabled && styles.itemDisabled, lifted && styles.itemLifted]}
484
+ >
485
+ {children}
486
+ </html.div>
487
+ )
488
+ }
489
+
490
+ /** Current drag, for consumers that render their own placeholder or ghost. */
491
+ export function useDragDrop(): {
492
+ readonly dragging: string | null
493
+ readonly over: DragDropTarget | null
494
+ } {
495
+ const ctx = useContext(RootContext)
496
+ return {dragging: ctx?.drag?.id ?? null, over: ctx?.drag?.over ?? null}
497
+ }
498
+
499
+ 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'