panelui-native 0.49.0 → 0.53.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.
Files changed (60) hide show
  1. package/README.md +9 -1
  2. package/lib/module/components/candlestick-chart/index.js +1161 -0
  3. package/lib/module/components/candlestick-chart/index.js.map +1 -0
  4. package/lib/module/components/combobox/index.js +73 -7
  5. package/lib/module/components/combobox/index.js.map +1 -1
  6. package/lib/module/components/context-menu/index.js +529 -0
  7. package/lib/module/components/context-menu/index.js.map +1 -0
  8. package/lib/module/components/menu/index.js +18 -10
  9. package/lib/module/components/menu/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +71 -7
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/sortable/index.js +942 -0
  13. package/lib/module/components/sortable/index.js.map +1 -0
  14. package/lib/module/components/swipe/index.js +140 -5
  15. package/lib/module/components/swipe/index.js.map +1 -1
  16. package/lib/module/components/tabs/index.js +56 -15
  17. package/lib/module/components/tabs/index.js.map +1 -1
  18. package/lib/module/components/time-picker/index.js +295 -31
  19. package/lib/module/components/time-picker/index.js.map +1 -1
  20. package/lib/module/icons/index.js +29 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +5 -2
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/utils/haptics.js +19 -0
  25. package/lib/module/utils/haptics.js.map +1 -1
  26. package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
  27. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
  28. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
  30. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
  31. package/lib/typescript/src/components/menu/index.d.ts +20 -20
  32. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/popover/index.d.ts +51 -1
  34. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  35. package/lib/typescript/src/components/sortable/index.d.ts +248 -0
  36. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/swipe/index.d.ts +35 -0
  38. package/lib/typescript/src/components/swipe/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/tabs/index.d.ts +34 -2
  40. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  42. package/lib/typescript/src/icons/index.d.ts +8 -0
  43. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  44. package/lib/typescript/src/index.d.ts +5 -2
  45. package/lib/typescript/src/index.d.ts.map +1 -1
  46. package/lib/typescript/src/utils/haptics.d.ts +14 -0
  47. package/lib/typescript/src/utils/haptics.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/components/candlestick-chart/index.tsx +1360 -0
  50. package/src/components/combobox/index.tsx +84 -6
  51. package/src/components/context-menu/index.tsx +658 -0
  52. package/src/components/menu/index.tsx +17 -10
  53. package/src/components/popover/index.tsx +94 -6
  54. package/src/components/sortable/index.tsx +1266 -0
  55. package/src/components/swipe/index.tsx +165 -3
  56. package/src/components/tabs/index.tsx +82 -16
  57. package/src/components/time-picker/index.tsx +330 -35
  58. package/src/icons/index.tsx +22 -0
  59. package/src/index.ts +39 -0
  60. package/src/utils/haptics.ts +21 -0
@@ -0,0 +1,942 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Sortable — a list whose rows can be dragged into a different order.
5
+ *
6
+ * The one list interaction nothing else in the library covers. `Swipe` acts on
7
+ * a row, `Tree` opens one, `Table` sorts every row at once by a column — none
8
+ * of them let a person say *this one goes above that one*, which is how a
9
+ * playlist, a task list, a set of form fields or a run of dashboard tiles is
10
+ * actually arranged.
11
+ *
12
+ * ```tsx
13
+ * const [tasks, setTasks] = useState(TASKS);
14
+ *
15
+ * <Sortable
16
+ * value={tasks.map((task) => task.id)}
17
+ * onReorder={(_, { from, to }) => setTasks((t) => reorderItems(t, from, to))}
18
+ * gap={8}
19
+ * >
20
+ * {tasks.map((task) => (
21
+ * <Sortable.Item key={task.id} id={task.id}>
22
+ * <Item variant="outline">
23
+ * <Item.Content>
24
+ * <Item.Title>{task.title}</Item.Title>
25
+ * </Item.Content>
26
+ * <Sortable.Handle />
27
+ * </Item>
28
+ * </Sortable.Item>
29
+ * ))}
30
+ * </Sortable>
31
+ * ```
32
+ *
33
+ * ## Nothing is ever moved in the tree
34
+ *
35
+ * The rows stay exactly where they were laid out and are pushed around with
36
+ * transforms. A row's offset is the difference between where its slot sits in
37
+ * the order being dragged and where it sits in the order that was rendered —
38
+ * one subtraction, on the UI thread, per row per frame. Reordering the
39
+ * children instead would mean React reconciling the whole list on every slot
40
+ * the finger crosses, which is the one thing a drag cannot afford.
41
+ *
42
+ * It also means the component never owns the order. It reports where a row was
43
+ * dropped and the list is the caller's to rearrange, because the caller is the
44
+ * only one who knows what the ids stand for — a `value` that disagreed with
45
+ * the children would put rows in places their content had not moved to.
46
+ *
47
+ * ## Heights are measured, not assumed
48
+ *
49
+ * Every row reports its own height, so a list of rows of different sizes lands
50
+ * in the right slots. A fixed row height would be one number to get wrong in
51
+ * every list that has a two-line row in it, and the measurement costs one
52
+ * layout pass on mount.
53
+ *
54
+ * ## Where the drop is reported
55
+ *
56
+ * `onReorder` fires when the row has finished settling, not when the finger
57
+ * lifts. Between those two moments the row is animating into a slot that the
58
+ * layout does not know about yet; re-rendering the list in the middle of that
59
+ * would relayout every row underneath it and the settling row would jump. By
60
+ * the time the callback runs the rows are already where the new order puts
61
+ * them, so the re-render that follows changes nothing on screen.
62
+ */
63
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
64
+ import { View } from 'react-native';
65
+ import { Gesture, GestureDetector } from 'react-native-gesture-handler';
66
+ import Animated, { measure, runOnJS, scrollTo, useAnimatedRef, useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useScrollViewOffset, useSharedValue, withSpring } from 'react-native-reanimated';
67
+ import { useCSSVariable } from 'uniwind';
68
+ import { GripVerticalIcon, IconColorProvider } from "../../icons/index.js";
69
+ import { cn } from "../../utils/cn.js";
70
+ import { impactKnock, selectionTick } from "../../utils/haptics.js";
71
+
72
+ /**
73
+ * Rows getting out of the way of the one being carried. Quick, because they
74
+ * are answering a finger that has already moved — a neighbour that ambles into
75
+ * its new slot reads as the list struggling to keep up with the drag.
76
+ *
77
+ * Critically damped, and stiff. Both were wrong before: the spring overshot its
78
+ * slot and spent the rest of a third of a second coming back, so a row the
79
+ * finger had already passed was still visibly moving. A row getting out of the
80
+ * way has nothing to express by bouncing — it is not the thing being carried,
81
+ * and the fastest way to say "your place is free" is to be out of it.
82
+ */
83
+ import { jsx as _jsx } from "react/jsx-runtime";
84
+ const DISPLACE = {
85
+ damping: 28,
86
+ stiffness: 400,
87
+ mass: 0.5
88
+ };
89
+
90
+ /**
91
+ * Settles a row into its slot. Stiffer and less bouncy than the library's
92
+ * overlay springs: a row that overshoots its slot reads as having landed in
93
+ * the wrong one and then corrected itself.
94
+ *
95
+ * The rest thresholds are loosened from the defaults on purpose. The drop is
96
+ * reported from this spring's completion, so the tolerance it finishes at is
97
+ * also how long the caller waits to hear about it — and the last hundredth of
98
+ * a point of travel is not something anybody can see.
99
+ */
100
+ const LAND = {
101
+ damping: 26,
102
+ stiffness: 260,
103
+ mass: 0.7,
104
+ restDisplacementThreshold: 0.5,
105
+ restSpeedThreshold: 2
106
+ };
107
+
108
+ /**
109
+ * Coming loose, and settling back. Deliberately faster than `LAND`: the row
110
+ * has to be back at its own size by the time it arrives, or it finishes the
111
+ * drop full-sized and then shrinks, which reads as two separate movements.
112
+ */
113
+ const LIFT = {
114
+ damping: 20,
115
+ stiffness: 400,
116
+ mass: 0.5
117
+ };
118
+
119
+ /**
120
+ * How much a lifted row grows. Enough to read as picked up over a full-width
121
+ * row, where a larger jump would push the row past the edges of the list it
122
+ * came out of.
123
+ */
124
+ const LIFT_SCALE = 1.05;
125
+
126
+ /**
127
+ * How far the finger must travel on a handle before the drag takes over. In
128
+ * line with the rest of the library's pans: below about this, a list inside a
129
+ * scroller cannot be scrolled by a finger that happened to land on a grip.
130
+ */
131
+ const HANDLE_SLOP = 10;
132
+
133
+ /** What starts a drag. */
134
+
135
+ /** Where a row ended up, alongside the order it produced. */
136
+
137
+ /**
138
+ * The same move applied to a list of your own.
139
+ *
140
+ * `onReorder` hands back the new order of the ids, but a list is rarely a list
141
+ * of ids — it is a list of the things they name. This does the move on that
142
+ * list, and returns a new array rather than sorting in place, because the one
143
+ * that mutated would be the one React had already decided was unchanged.
144
+ *
145
+ * ```tsx
146
+ * onReorder={(_, { from, to }) => setTasks((tasks) => reorderItems(tasks, from, to))}
147
+ * ```
148
+ *
149
+ * Indices outside the list are returned unchanged rather than throwing: a
150
+ * reorder is not worth crashing a screen over, and a list that did not move is
151
+ * the honest result of a move that had nowhere to go.
152
+ */
153
+ export function reorderItems(items, from, to) {
154
+ if (from === to) return [...items];
155
+ if (from < 0 || from >= items.length) return [...items];
156
+ if (to < 0 || to >= items.length) return [...items];
157
+ const next = [...items];
158
+ const moved = next.splice(from, 1);
159
+ next.splice(to, 0, ...moved);
160
+ return next;
161
+ }
162
+
163
+ /* -------------------------------------------------------------------------- */
164
+ /* Slot maths */
165
+ /* -------------------------------------------------------------------------- */
166
+
167
+ /**
168
+ * How far down the list a row's slot begins, given an order and the measured
169
+ * heights — the sum of everything above it plus one gap per row above it.
170
+ *
171
+ * A worklet, and deliberately a loop rather than a cached prefix sum: the
172
+ * lists this component is for are the ones a person is willing to drag through
173
+ * by hand, and a cache would be another thing to invalidate every time a row
174
+ * changed height.
175
+ */
176
+ function slotOffset(order, id, heights, gap) {
177
+ 'worklet';
178
+
179
+ let offset = 0;
180
+ for (let i = 0; i < order.length; i += 1) {
181
+ const at = order[i];
182
+ if (at === id) return offset;
183
+ offset += (at === undefined ? 0 : heights[at] ?? 0) + gap;
184
+ }
185
+ return offset;
186
+ }
187
+
188
+ /** The middle of the slot at `index`, in the same coordinates. */
189
+ function slotCenter(order, index, heights, gap) {
190
+ 'worklet';
191
+
192
+ let offset = 0;
193
+ for (let i = 0; i < index; i += 1) {
194
+ const at = order[i];
195
+ offset += (at === undefined ? 0 : heights[at] ?? 0) + gap;
196
+ }
197
+ const self = order[index];
198
+ return offset + (self === undefined ? 0 : heights[self] ?? 0) / 2;
199
+ }
200
+
201
+ /**
202
+ * The order after a row has moved, with pinned rows left where they were.
203
+ *
204
+ * The move is applied first, to the whole list, so a carried row can be dragged
205
+ * *past* a pinned one — refusing the move instead would make a pinned row a
206
+ * wall, and a row that holds its place is not the same as a row nothing may
207
+ * cross. The pinned ids are then put back at the indices they occupy in the
208
+ * laid-out order, and everything else falls into the slots that are left, in
209
+ * the order the move produced.
210
+ *
211
+ * `laid` rather than `list` is what the fixed indices are read from, because
212
+ * that is the one order a pinned row is guaranteed to be correctly placed in:
213
+ * it is where it was rendered, and holding its slot is the whole point.
214
+ */
215
+ function moveWithPinned(list, laid, pinned, id, from, to) {
216
+ 'worklet';
217
+
218
+ const moved = [...list];
219
+ moved.splice(from, 1);
220
+ moved.splice(to, 0, id);
221
+ const next = [];
222
+ let anyPinned = false;
223
+ for (let i = 0; i < moved.length; i += 1) next.push(undefined);
224
+ for (let i = 0; i < laid.length && i < next.length; i += 1) {
225
+ const at = laid[i];
226
+ if (at !== undefined && pinned[at]) {
227
+ next[i] = at;
228
+ anyPinned = true;
229
+ }
230
+ }
231
+ if (!anyPinned) return moved;
232
+ const free = [];
233
+ for (let i = 0; i < moved.length; i += 1) {
234
+ const at = moved[i];
235
+ if (at !== undefined && !pinned[at]) free.push(at);
236
+ }
237
+ const result = [];
238
+ let f = 0;
239
+ for (let i = 0; i < next.length; i += 1) {
240
+ const held = next[i];
241
+ if (held !== undefined) {
242
+ result.push(held);
243
+ continue;
244
+ }
245
+ const take = free[f];
246
+ f += 1;
247
+ if (take !== undefined) result.push(take);
248
+ }
249
+ return result;
250
+ }
251
+
252
+ /**
253
+ * Where the dragged row belongs now, given where its edges have reached.
254
+ *
255
+ * It walks outwards from the row's current slot and stops at the first
256
+ * neighbour it has not reached, rather than scanning the whole list for the
257
+ * nearest slot. The difference shows up with rows of unequal height: scanning
258
+ * can hand back a slot two places away that happens to be closer, which reads
259
+ * as the row skipping one.
260
+ *
261
+ * What counts as reaching a neighbour is the *leading edge* of the carried row
262
+ * against that neighbour's middle — its bottom edge going down, its top edge
263
+ * going up. Comparing middle against middle, as this used to, means the finger
264
+ * has to travel a whole row before anything happens, because a row's middle
265
+ * starts a whole row away from its neighbour's: the list sat still through the
266
+ * first row of every drag and then moved all at once. Leading edge against
267
+ * middle halves that, and it is also the more natural reading — the rows get
268
+ * out of the way once the row being carried is over them, not once it is past
269
+ * them.
270
+ */
271
+ function targetIndex(order, current, top, height, heights, gap) {
272
+ 'worklet';
273
+
274
+ // Where the carried row's own slot begins, so the direction of travel is
275
+ // read from the row rather than from the sign of a gesture that may have
276
+ // changed its mind since.
277
+ const self = order[current];
278
+ const restingTop = slotCenter(order, current, heights, gap) - (self === undefined ? 0 : heights[self] ?? 0) / 2;
279
+ if (top < restingTop) {
280
+ let target = current;
281
+ for (let i = current - 1; i >= 0; i -= 1) {
282
+ if (top >= slotCenter(order, i, heights, gap)) break;
283
+ target = i;
284
+ }
285
+ return target;
286
+ }
287
+ const bottom = top + height;
288
+ let target = current;
289
+ for (let i = current + 1; i < order.length; i += 1) {
290
+ if (bottom <= slotCenter(order, i, heights, gap)) break;
291
+ target = i;
292
+ }
293
+ return target;
294
+ }
295
+
296
+ /* -------------------------------------------------------------------------- */
297
+ /* Context */
298
+ /* -------------------------------------------------------------------------- */
299
+
300
+ const SortableRootContext = /*#__PURE__*/createContext(null);
301
+ function useSortableRoot(part) {
302
+ const context = useContext(SortableRootContext);
303
+ if (!context) throw new Error(`${part} must be used inside <Sortable>.`);
304
+ return context;
305
+ }
306
+ const SortableItemContext = /*#__PURE__*/createContext(null);
307
+
308
+ /**
309
+ * What the row being rendered knows about itself.
310
+ *
311
+ * For a handle of your own, or for a row that looks different while it is
312
+ * being carried — dimmed neighbours, a border, a shadow. `isActive` is a plain
313
+ * boolean and changes twice in a drag rather than sixty times a second: it is
314
+ * set when the row is lifted and cleared when it lands, and nothing between
315
+ * those two moments touches React at all.
316
+ */
317
+ export function useSortableItem() {
318
+ const context = useContext(SortableItemContext);
319
+ if (!context) throw new Error('useSortableItem must be used inside <Sortable.Item>.');
320
+ const {
321
+ id,
322
+ index,
323
+ isActive,
324
+ disabled
325
+ } = context;
326
+ return {
327
+ id,
328
+ index,
329
+ isActive,
330
+ disabled
331
+ };
332
+ }
333
+
334
+ /* -------------------------------------------------------------------------- */
335
+ /* Root */
336
+ /* -------------------------------------------------------------------------- */
337
+
338
+ /**
339
+ * The list, and everything the rows share.
340
+ *
341
+ * It renders one plain `View` in a column and nothing else. The rows are the
342
+ * caller's, laid out by flexbox in the order they were written; the component
343
+ * only ever adds a transform to them.
344
+ */
345
+ function SortableRoot({
346
+ className,
347
+ value,
348
+ children,
349
+ onReorder,
350
+ gap = 0,
351
+ activation = 'handle',
352
+ longPressDelay = 220,
353
+ haptics = true,
354
+ disabled = false,
355
+ scrollRef,
356
+ autoscrollThreshold = 72,
357
+ autoscrollSpeed = 8,
358
+ onDragStart,
359
+ onDragEnd,
360
+ ...props
361
+ }) {
362
+ const order = useSharedValue(value);
363
+ const rendered = useSharedValue(value);
364
+ const heights = useSharedValue({});
365
+ const pinned = useSharedValue({});
366
+ const activeId = useSharedValue(null);
367
+ const translate = useSharedValue(0);
368
+ const lift = useSharedValue(0);
369
+ const dragSeq = useSharedValue(0);
370
+ const fingerY = useSharedValue(0);
371
+ const [activeItem, setActiveItem] = useState(null);
372
+ const reducedMotion = useReducedMotion();
373
+
374
+ /*
375
+ * A joined key rather than the array: `value` is almost always a fresh
376
+ * `.map()` on every render, so depending on it directly would reset the
377
+ * order on every keystroke elsewhere on the screen. A null separator
378
+ * cannot appear in an id that came from anywhere real.
379
+ */
380
+ const key = value.join('\u0000');
381
+
382
+ /*
383
+ * The caller has applied the drop, so the rendered order is the new one and
384
+ * the transforms that were holding rows in their new slots are no longer
385
+ * needed — the layout puts them there now. Both orders are reset together,
386
+ * which is what makes the re-render after a drop invisible.
387
+ */
388
+ useEffect(() => {
389
+ order.value = value;
390
+ rendered.value = value;
391
+ translate.value = 0;
392
+ lift.value = 0;
393
+ // `value` is covered by `key`; depending on the array itself would fire
394
+ // this on every render of the screen around it.
395
+ // eslint-disable-next-line react-hooks/exhaustive-deps
396
+ }, [key, order, rendered, translate, lift]);
397
+ const indices = useMemo(() => {
398
+ const map = new Map();
399
+ value.forEach((id, index) => map.set(id, index));
400
+ return map;
401
+ // eslint-disable-next-line react-hooks/exhaustive-deps
402
+ }, [key]);
403
+ const indexOf = useCallback(id => indices.get(id) ?? -1, [indices]);
404
+ const measured = useCallback((id, height) => {
405
+ if (heights.value[id] === height) return;
406
+ heights.value = {
407
+ ...heights.value,
408
+ [id]: height
409
+ };
410
+ }, [heights]);
411
+ const setPinned = useCallback((id, next) => {
412
+ if (Boolean(pinned.value[id]) === next) return;
413
+ pinned.value = {
414
+ ...pinned.value,
415
+ [id]: next
416
+ };
417
+ }, [pinned]);
418
+
419
+ /*
420
+ * Every row's distance from where it was laid out, in one pass.
421
+ *
422
+ * Two prefix sums — one over the order being dragged, one over the order the
423
+ * children are actually in — and the difference between them per id. Rebuilt
424
+ * when a swap changes `order`, when a drop resets both, or when a row reports
425
+ * a new height, and at no other time; a drag that is only moving the carried
426
+ * row does not touch it at all.
427
+ */
428
+ const offsets = useDerivedValue(() => {
429
+ const map = heights.value;
430
+ const target = {};
431
+ const list = order.value;
432
+ let at = 0;
433
+ for (let i = 0; i < list.length; i += 1) {
434
+ const id = list[i];
435
+ if (id === undefined) continue;
436
+ target[id] = at;
437
+ at += (map[id] ?? 0) + gap;
438
+ }
439
+ const result = {};
440
+ const laid = rendered.value;
441
+ at = 0;
442
+ for (let i = 0; i < laid.length; i += 1) {
443
+ const id = laid[i];
444
+ if (id === undefined) continue;
445
+ result[id] = (target[id] ?? at) - at;
446
+ at += (map[id] ?? 0) + gap;
447
+ }
448
+ return result;
449
+ }, [gap]);
450
+
451
+ /* ---------------------------------------------------------------------- */
452
+ /* Autoscroll */
453
+ /* ---------------------------------------------------------------------- */
454
+
455
+ /*
456
+ * A ref of its own so `useScrollViewOffset` always has one to hold. It is
457
+ * never attached to anything, and the hook is happy with a ref that resolves
458
+ * to nothing — which is exactly the case where there is no scrolling to do.
459
+ */
460
+ const fallbackRef = useAnimatedRef();
461
+ const scrollOffset = useScrollViewOffset(scrollRef ?? fallbackRef);
462
+
463
+ /**
464
+ * Where the scroller stood when the drag began. The row follows the finger,
465
+ * and the finger is measured against the screen — so every point the list
466
+ * scrolls underneath it has to be added back, or a row would slide out from
467
+ * under the finger the moment the list started moving.
468
+ */
469
+ const scrollAtStart = useSharedValue(0);
470
+
471
+ /*
472
+ * A frame callback rather than something driven by the pan, because the
473
+ * finger held still at the edge is exactly when the scrolling has to keep
474
+ * happening — and a pan that is not moving sends no updates.
475
+ */
476
+ const autoscroll = useFrameCallback(() => {
477
+ 'worklet';
478
+
479
+ if (!scrollRef || activeId.value === null) return;
480
+ const view = measure(scrollRef);
481
+ if (!view) return;
482
+ const top = view.pageY + autoscrollThreshold;
483
+ const bottom = view.pageY + view.height - autoscrollThreshold;
484
+ const y = fingerY.value;
485
+ let intensity = 0;
486
+ if (y < top) intensity = (y - top) / autoscrollThreshold;else if (y > bottom) intensity = (y - bottom) / autoscrollThreshold;
487
+ if (intensity === 0) return;
488
+ const clamped = Math.max(-1, Math.min(1, intensity));
489
+ scrollTo(scrollRef, 0, scrollOffset.value + clamped * autoscrollSpeed, false);
490
+ }, false);
491
+
492
+ /*
493
+ * `setActive` is a JS-thread call, so the gesture reaches it through
494
+ * `runOnJS` rather than flipping a flag the callback would have to poll.
495
+ */
496
+ const setAutoscroll = useCallback(active => {
497
+ if (!scrollRef) return;
498
+ autoscroll.setActive(active);
499
+ }, [autoscroll, scrollRef]);
500
+
501
+ /* ---------------------------------------------------------------------- */
502
+ /* Lifting and landing */
503
+ /* ---------------------------------------------------------------------- */
504
+
505
+ const begin = useCallback(id => {
506
+ setActiveItem(id);
507
+ setAutoscroll(true);
508
+ if (haptics) impactKnock();
509
+ onDragStart?.(id);
510
+ }, [haptics, onDragStart, setAutoscroll]);
511
+
512
+ /**
513
+ * The row has landed. Only now is the drop reported: until the spring
514
+ * finished, the row was in a slot the layout knew nothing about, and a
515
+ * re-render in the middle of that would have relaid out every row under it
516
+ * while one of them was still moving.
517
+ */
518
+ const settled = useCallback(id => {
519
+ setActiveItem(null);
520
+ setAutoscroll(false);
521
+ onDragEnd?.(id);
522
+ const next = order.value;
523
+ const from = rendered.value.indexOf(id);
524
+ const to = next.indexOf(id);
525
+ if (from === to || from < 0 || to < 0) return;
526
+ onReorder?.([...next], {
527
+ id,
528
+ from,
529
+ to
530
+ });
531
+ }, [onDragEnd, onReorder, order, rendered, setAutoscroll]);
532
+
533
+ /**
534
+ * A move of whole slots, with no gesture behind it — the path a screen
535
+ * reader takes, and the one a keyboard would take if a phone had one. It
536
+ * reports the drop straight away: nothing is mid-flight, so there is no
537
+ * settling to wait for.
538
+ */
539
+ const step = useCallback((id, delta) => {
540
+ const current = rendered.value.indexOf(id);
541
+ if (current < 0) return;
542
+ const to = current + delta;
543
+ if (to < 0 || to >= rendered.value.length) return;
544
+ const next = reorderItems(rendered.value, current, to);
545
+ order.value = next;
546
+ if (haptics) selectionTick();
547
+ onReorder?.(next, {
548
+ id,
549
+ from: current,
550
+ to
551
+ });
552
+ }, [haptics, onReorder, order, rendered]);
553
+ const context = useMemo(() => ({
554
+ order,
555
+ rendered,
556
+ heights,
557
+ pinned,
558
+ offsets,
559
+ activeId,
560
+ translate,
561
+ lift,
562
+ dragSeq,
563
+ fingerY,
564
+ scrollOffset,
565
+ scrollAtStart,
566
+ gap,
567
+ disabled,
568
+ activation,
569
+ longPressDelay,
570
+ haptics,
571
+ reducedMotion,
572
+ activeItem,
573
+ indexOf,
574
+ measured,
575
+ setPinned,
576
+ begin,
577
+ settled,
578
+ step,
579
+ setAutoscroll
580
+ }), [order, rendered, heights, pinned, offsets, activeId, translate, lift, dragSeq, fingerY, scrollOffset, scrollAtStart, gap, disabled, activation, longPressDelay, haptics, reducedMotion, activeItem, indexOf, measured, setPinned, begin, settled, step, setAutoscroll]);
581
+ return /*#__PURE__*/_jsx(SortableRootContext.Provider, {
582
+ value: context,
583
+ children: /*#__PURE__*/_jsx(View, {
584
+ accessibilityRole: "list",
585
+ className: cn('w-full', className),
586
+ style: gap > 0 ? {
587
+ gap
588
+ } : undefined,
589
+ ...props,
590
+ children: children
591
+ })
592
+ });
593
+ }
594
+ SortableRoot.displayName = 'Sortable';
595
+
596
+ /* -------------------------------------------------------------------------- */
597
+ /* Item */
598
+ /* -------------------------------------------------------------------------- */
599
+
600
+ /**
601
+ * One row.
602
+ *
603
+ * It measures itself, carries its own transform, and holds the pan — which is
604
+ * either wrapped around the whole row or handed to a `Sortable.Handle` inside
605
+ * it, depending on what lifts a row in this list.
606
+ */
607
+ function SortableItem({
608
+ className,
609
+ activeClassName,
610
+ id,
611
+ children,
612
+ disabled = false,
613
+ pinned = false,
614
+ ...props
615
+ }) {
616
+ const root = useSortableRoot('Sortable.Item');
617
+ const {
618
+ order,
619
+ rendered,
620
+ heights,
621
+ pinned: pinnedIds,
622
+ offsets,
623
+ activeId,
624
+ translate,
625
+ lift,
626
+ dragSeq,
627
+ fingerY,
628
+ scrollOffset,
629
+ scrollAtStart,
630
+ gap,
631
+ activation,
632
+ longPressDelay,
633
+ haptics,
634
+ reducedMotion,
635
+ activeItem,
636
+ indexOf,
637
+ measured,
638
+ setPinned,
639
+ begin,
640
+ settled,
641
+ step
642
+ } = root;
643
+
644
+ /** A row is undraggable if it, the whole list, or its own pin says so. */
645
+ const locked = root.disabled || disabled || pinned;
646
+
647
+ /*
648
+ * Published to the root so the drag can read it on the UI thread. A pin is
649
+ * resolved while a finger is moving, where the props of a row two places away
650
+ * are not reachable.
651
+ */
652
+ useEffect(() => {
653
+ setPinned(id, pinned);
654
+ }, [id, pinned, setPinned]);
655
+ const index = indexOf(id);
656
+ const isActive = activeItem === id;
657
+ const tick = useCallback(() => selectionTick(), []);
658
+
659
+ /*
660
+ * Read off a ref inside the worklets rather than captured by them: `begin`
661
+ * and `settled` change identity whenever the caller's callbacks do, and a
662
+ * gesture rebuilt mid-drag is a gesture that drops the drag.
663
+ */
664
+ const handlers = useRef({
665
+ begin,
666
+ settled,
667
+ tick
668
+ });
669
+ handlers.current = {
670
+ begin,
671
+ settled,
672
+ tick
673
+ };
674
+ const notifyBegin = useCallback(rowId => {
675
+ handlers.current.begin(rowId);
676
+ }, []);
677
+ const notifySettled = useCallback(rowId => {
678
+ handlers.current.settled(rowId);
679
+ }, []);
680
+ const notifyTick = useCallback(() => {
681
+ handlers.current.tick();
682
+ }, []);
683
+ const pan = useMemo(() => {
684
+ const gesture = Gesture.Pan().enabled(!locked).onStart(event => {
685
+ activeId.value = id;
686
+ translate.value = 0;
687
+ order.value = [...rendered.value];
688
+ fingerY.value = event.absoluteY;
689
+ /*
690
+ * Claims the row: a landing spring left over from the previous drop
691
+ * finds this number changed and leaves the row alone rather than
692
+ * putting down one that has just been picked back up.
693
+ */
694
+ dragSeq.value += 1;
695
+ /*
696
+ * Left at rest when motion is turned down, so the row does not grow.
697
+ * The surface and the shadow it gets in the same moment are not
698
+ * motion and stay either way — they are what stop the lifted row
699
+ * being see-through, which is not a preference.
700
+ */
701
+ lift.value = reducedMotion ? 0 : withSpring(1, LIFT);
702
+ /*
703
+ * Taken here rather than on the JS side: `notifyBegin` lands a frame
704
+ * or more later, and by then a fast flick may already have moved the
705
+ * scroller — which would be read as the finger having moved.
706
+ */
707
+ scrollAtStart.value = scrollOffset.value;
708
+ runOnJS(notifyBegin)(id);
709
+ }).onUpdate(event => {
710
+ if (activeId.value !== id) return;
711
+
712
+ /*
713
+ * The pan reports the finger against the screen, so every point the
714
+ * list has scrolled underneath it since the lift has to be added
715
+ * back — otherwise a row slides out from under the finger the moment
716
+ * a drag at the edge starts scrolling.
717
+ */
718
+ translate.value = event.translationY + (scrollOffset.value - scrollAtStart.value);
719
+ fingerY.value = event.absoluteY;
720
+ const list = order.value;
721
+ const map = heights.value;
722
+ const current = list.indexOf(id);
723
+ if (current < 0) return;
724
+ const top = slotOffset(rendered.value, id, map, gap) + translate.value;
725
+ const to = targetIndex(list, current, top, map[id] ?? 0, map, gap);
726
+ if (to === current) return;
727
+ const next = moveWithPinned(list, rendered.value, pinnedIds.value, id, current, to);
728
+ // A move that only pinned rows could have absorbed leaves the order
729
+ // exactly as it was, and there is nothing to feel or to redraw.
730
+ if (next[current] === id) return;
731
+ order.value = next;
732
+ if (haptics) runOnJS(notifyTick)();
733
+ }).onFinalize(() => {
734
+ if (activeId.value !== id) return;
735
+
736
+ /*
737
+ * The row springs to where its new slot sits *in the old layout* —
738
+ * the layout has not changed yet and will not until the drop is
739
+ * reported, which is what the callback below does once the row has
740
+ * stopped moving.
741
+ */
742
+ const landing = slotOffset(order.value, id, heights.value, gap) - slotOffset(rendered.value, id, heights.value, gap);
743
+
744
+ /*
745
+ * Released here rather than once the row has landed, so the shrink and
746
+ * the drop are one movement. `LIFT` is the faster spring of the two,
747
+ * so the row is back at its own size a little before it arrives.
748
+ */
749
+ dragSeq.value += 1;
750
+ const seq = dragSeq.value;
751
+ lift.value = reducedMotion ? 0 : withSpring(0, LIFT);
752
+
753
+ /*
754
+ * Runs whether or not the spring finished. An interrupted spring used
755
+ * to leave `activeId` set, which left the row lifted for good and the
756
+ * drop unreported; `seq` is what tells the two cases apart, because
757
+ * the only interruption that should be ignored is the row being picked
758
+ * up again.
759
+ */
760
+ const land = () => {
761
+ 'worklet';
762
+
763
+ if (dragSeq.value !== seq) return;
764
+ activeId.value = null;
765
+ runOnJS(notifySettled)(id);
766
+ };
767
+ if (reducedMotion) {
768
+ translate.value = landing;
769
+ land();
770
+ return;
771
+ }
772
+ translate.value = withSpring(landing, LAND, land);
773
+ });
774
+ if (activation === 'longPress') return gesture.activateAfterLongPress(longPressDelay);
775
+
776
+ /*
777
+ * On a handle the drag starts on movement rather than on a hold, but not
778
+ * on the first pixel: a list inside a scroller has to be able to scroll
779
+ * from a finger that happened to land on a grip.
780
+ */
781
+ return gesture.activeOffsetY([-HANDLE_SLOP, HANDLE_SLOP]);
782
+ }, [activation, activeId, locked, dragSeq, fingerY, gap, haptics, heights, id, lift, longPressDelay, notifyBegin, notifySettled, notifyTick, order, reducedMotion, rendered, scrollAtStart, scrollOffset, translate]);
783
+ const style = useAnimatedStyle(() => {
784
+ /*
785
+ * Read rather than animated here. The springs live on `translate` and
786
+ * `lift`, which the gesture drives; starting one from inside the style
787
+ * instead would re-enter it on every frame of the drag, because this
788
+ * worklet re-runs every time the row it is carrying moves.
789
+ */
790
+ if (activeId.value === id) {
791
+ return {
792
+ transform: [{
793
+ translateY: translate.value
794
+ }, {
795
+ scale: 1 + lift.value * (LIFT_SCALE - 1)
796
+ }]
797
+ };
798
+ }
799
+
800
+ /*
801
+ * Read, not worked out. This worklet re-runs on every frame of a drag —
802
+ * it closes over the value the carried row rides on — so summing the
803
+ * heights above this row here, twice, made a list cost the square of its
804
+ * length to drag. The root derives every row's offset in one pass instead,
805
+ * and only when the arrangement actually changes.
806
+ */
807
+ const offset = offsets.value[id] ?? 0;
808
+
809
+ /*
810
+ * Only animated while a drag is in flight, and the difference is the whole
811
+ * end of the drop. When the caller applies the reorder the rows move in
812
+ * the tree and every offset falls to zero on the same commit — springing
813
+ * to it would send the row that was just dropped sliding back across the
814
+ * distance it had travelled, in a slot it was already sitting in.
815
+ */
816
+ const settling = activeId.value === null;
817
+ return {
818
+ transform: [{
819
+ translateY: settling || reducedMotion ? offset : withSpring(offset, DISPLACE)
820
+ }, {
821
+ scale: 1
822
+ }]
823
+ };
824
+ });
825
+ const onLayout = useCallback(event => measured(id, event.nativeEvent.layout.height), [id, measured]);
826
+ const itemContext = useMemo(() => ({
827
+ id,
828
+ index,
829
+ isActive,
830
+ disabled: locked,
831
+ handleGesture: activation === 'handle' ? pan : null
832
+ }), [id, index, isActive, locked, activation, pan]);
833
+
834
+ /**
835
+ * A drag is invisible to a screen reader — there is nothing to announce and
836
+ * no way to discover it from the row. Moving by whole slots is published as
837
+ * an accessibility action instead, which is the only path to reordering for
838
+ * someone who is not dragging anything.
839
+ */
840
+ const a11y = locked ? undefined : [{
841
+ name: 'moveUp',
842
+ label: 'Move up'
843
+ }, {
844
+ name: 'moveDown',
845
+ label: 'Move down'
846
+ }];
847
+ const row = /*#__PURE__*/_jsx(Animated.View, {
848
+ onLayout: onLayout,
849
+ accessibilityActions: a11y,
850
+ onAccessibilityAction: event => {
851
+ if (event.nativeEvent.actionName === 'moveUp') step(id, -1);
852
+ if (event.nativeEvent.actionName === 'moveDown') step(id, 1);
853
+ },
854
+ style: style
855
+ /*
856
+ * A carried row is drawn over the ones it is passing, so it has to have
857
+ * a surface of its own — the row itself is only a box around whatever
858
+ * the caller put inside it, and a good deal of what people put there
859
+ * (an outlined `Item`, a bare `View`) has no background at all. Without
860
+ * this the lifted row is see-through and the list can be read straight
861
+ * through the middle of it.
862
+ *
863
+ * After `className` so the surface holds whatever else the row is
864
+ * wearing, and before `activeClassName`, which is the way out.
865
+ */,
866
+ className: cn('w-full', className, isActive && 'z-10 rounded-xl bg-card shadow-lg', isActive && activeClassName),
867
+ ...props,
868
+ children: children
869
+ });
870
+ return /*#__PURE__*/_jsx(SortableItemContext.Provider, {
871
+ value: itemContext,
872
+ children: activation === 'longPress' ? /*#__PURE__*/_jsx(GestureDetector, {
873
+ gesture: pan,
874
+ children: row
875
+ }) : row
876
+ });
877
+ }
878
+ SortableItem.displayName = 'Sortable.Item';
879
+
880
+ /* -------------------------------------------------------------------------- */
881
+ /* Handle */
882
+ /* -------------------------------------------------------------------------- */
883
+
884
+ /**
885
+ * The part of a row that lifts it.
886
+ *
887
+ * A grip exists so the rest of the row does not have to be given up to the
888
+ * drag: a row with a button, a checkbox or a link on it needs those to stay
889
+ * pressable, and a long press on the whole row takes all of them. It is sized
890
+ * to be hit rather than to be seen, which is why the box around the glyph is
891
+ * larger than the glyph.
892
+ *
893
+ * Inert in a list that lifts on a long press — the whole row already carries
894
+ * the gesture there, and a second one inside it would fight the first.
895
+ */
896
+ function SortableHandle({
897
+ className,
898
+ children,
899
+ accessibilityLabel = 'Drag to reorder',
900
+ ...props
901
+ }) {
902
+ const {
903
+ activation,
904
+ disabled: rootDisabled
905
+ } = useSortableRoot('Sortable.Handle');
906
+ const item = useContext(SortableItemContext);
907
+
908
+ /*
909
+ * The grip is furniture rather than content, so it takes the muted
910
+ * foreground — read from the theme rather than written down, because a hex
911
+ * here stops being right the moment the theme inverts.
912
+ */
913
+ const muted = useCSSVariable('--color-muted-foreground');
914
+ const tint = typeof muted === 'string' ? muted : undefined;
915
+ const glyph = /*#__PURE__*/_jsx(View, {
916
+ accessible: true,
917
+ accessibilityRole: "adjustable",
918
+ accessibilityLabel: accessibilityLabel,
919
+ accessibilityState: {
920
+ disabled: rootDisabled || item?.disabled
921
+ },
922
+ className: cn('items-center justify-center px-2 py-1.5', (rootDisabled || item?.disabled) && 'opacity-40', className),
923
+ ...props,
924
+ children: /*#__PURE__*/_jsx(IconColorProvider, {
925
+ color: tint,
926
+ children: children ?? /*#__PURE__*/_jsx(GripVerticalIcon, {
927
+ size: 18
928
+ })
929
+ })
930
+ });
931
+ if (activation !== 'handle' || !item?.handleGesture) return glyph;
932
+ return /*#__PURE__*/_jsx(GestureDetector, {
933
+ gesture: item.handleGesture,
934
+ children: glyph
935
+ });
936
+ }
937
+ SortableHandle.displayName = 'Sortable.Handle';
938
+ export const Sortable = Object.assign(SortableRoot, {
939
+ Item: SortableItem,
940
+ Handle: SortableHandle
941
+ });
942
+ //# sourceMappingURL=index.js.map