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,1266 @@
1
+ /**
2
+ * Sortable — a list whose rows can be dragged into a different order.
3
+ *
4
+ * The one list interaction nothing else in the library covers. `Swipe` acts on
5
+ * a row, `Tree` opens one, `Table` sorts every row at once by a column — none
6
+ * of them let a person say *this one goes above that one*, which is how a
7
+ * playlist, a task list, a set of form fields or a run of dashboard tiles is
8
+ * actually arranged.
9
+ *
10
+ * ```tsx
11
+ * const [tasks, setTasks] = useState(TASKS);
12
+ *
13
+ * <Sortable
14
+ * value={tasks.map((task) => task.id)}
15
+ * onReorder={(_, { from, to }) => setTasks((t) => reorderItems(t, from, to))}
16
+ * gap={8}
17
+ * >
18
+ * {tasks.map((task) => (
19
+ * <Sortable.Item key={task.id} id={task.id}>
20
+ * <Item variant="outline">
21
+ * <Item.Content>
22
+ * <Item.Title>{task.title}</Item.Title>
23
+ * </Item.Content>
24
+ * <Sortable.Handle />
25
+ * </Item>
26
+ * </Sortable.Item>
27
+ * ))}
28
+ * </Sortable>
29
+ * ```
30
+ *
31
+ * ## Nothing is ever moved in the tree
32
+ *
33
+ * The rows stay exactly where they were laid out and are pushed around with
34
+ * transforms. A row's offset is the difference between where its slot sits in
35
+ * the order being dragged and where it sits in the order that was rendered —
36
+ * one subtraction, on the UI thread, per row per frame. Reordering the
37
+ * children instead would mean React reconciling the whole list on every slot
38
+ * the finger crosses, which is the one thing a drag cannot afford.
39
+ *
40
+ * It also means the component never owns the order. It reports where a row was
41
+ * dropped and the list is the caller's to rearrange, because the caller is the
42
+ * only one who knows what the ids stand for — a `value` that disagreed with
43
+ * the children would put rows in places their content had not moved to.
44
+ *
45
+ * ## Heights are measured, not assumed
46
+ *
47
+ * Every row reports its own height, so a list of rows of different sizes lands
48
+ * in the right slots. A fixed row height would be one number to get wrong in
49
+ * every list that has a two-line row in it, and the measurement costs one
50
+ * layout pass on mount.
51
+ *
52
+ * ## Where the drop is reported
53
+ *
54
+ * `onReorder` fires when the row has finished settling, not when the finger
55
+ * lifts. Between those two moments the row is animating into a slot that the
56
+ * layout does not know about yet; re-rendering the list in the middle of that
57
+ * would relayout every row underneath it and the settling row would jump. By
58
+ * the time the callback runs the rows are already where the new order puts
59
+ * them, so the re-render that follows changes nothing on screen.
60
+ */
61
+ import {
62
+ createContext,
63
+ useCallback,
64
+ useContext,
65
+ useEffect,
66
+ useMemo,
67
+ useRef,
68
+ useState,
69
+ type ReactNode,
70
+ } from 'react';
71
+ import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
72
+ import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';
73
+ import Animated, {
74
+ measure,
75
+ runOnJS,
76
+ scrollTo,
77
+ useAnimatedRef,
78
+ useAnimatedStyle,
79
+ useDerivedValue,
80
+ useFrameCallback,
81
+ useReducedMotion,
82
+ useScrollViewOffset,
83
+ useSharedValue,
84
+ withSpring,
85
+ type AnimatedRef,
86
+ type SharedValue,
87
+ } from 'react-native-reanimated';
88
+ import { useCSSVariable } from 'uniwind';
89
+ import { GripVerticalIcon, IconColorProvider } from '../../icons';
90
+ import { cn } from '../../utils/cn';
91
+ import { impactKnock, selectionTick } from '../../utils/haptics';
92
+
93
+ /**
94
+ * Rows getting out of the way of the one being carried. Quick, because they
95
+ * are answering a finger that has already moved — a neighbour that ambles into
96
+ * its new slot reads as the list struggling to keep up with the drag.
97
+ *
98
+ * Critically damped, and stiff. Both were wrong before: the spring overshot its
99
+ * slot and spent the rest of a third of a second coming back, so a row the
100
+ * finger had already passed was still visibly moving. A row getting out of the
101
+ * way has nothing to express by bouncing — it is not the thing being carried,
102
+ * and the fastest way to say "your place is free" is to be out of it.
103
+ */
104
+ const DISPLACE = { damping: 28, stiffness: 400, mass: 0.5 } as const;
105
+
106
+ /**
107
+ * Settles a row into its slot. Stiffer and less bouncy than the library's
108
+ * overlay springs: a row that overshoots its slot reads as having landed in
109
+ * the wrong one and then corrected itself.
110
+ *
111
+ * The rest thresholds are loosened from the defaults on purpose. The drop is
112
+ * reported from this spring's completion, so the tolerance it finishes at is
113
+ * also how long the caller waits to hear about it — and the last hundredth of
114
+ * a point of travel is not something anybody can see.
115
+ */
116
+ const LAND = {
117
+ damping: 26,
118
+ stiffness: 260,
119
+ mass: 0.7,
120
+ restDisplacementThreshold: 0.5,
121
+ restSpeedThreshold: 2,
122
+ } as const;
123
+
124
+ /**
125
+ * Coming loose, and settling back. Deliberately faster than `LAND`: the row
126
+ * has to be back at its own size by the time it arrives, or it finishes the
127
+ * drop full-sized and then shrinks, which reads as two separate movements.
128
+ */
129
+ const LIFT = { damping: 20, stiffness: 400, mass: 0.5 } as const;
130
+
131
+ /**
132
+ * How much a lifted row grows. Enough to read as picked up over a full-width
133
+ * row, where a larger jump would push the row past the edges of the list it
134
+ * came out of.
135
+ */
136
+ const LIFT_SCALE = 1.05;
137
+
138
+ /**
139
+ * How far the finger must travel on a handle before the drag takes over. In
140
+ * line with the rest of the library's pans: below about this, a list inside a
141
+ * scroller cannot be scrolled by a finger that happened to land on a grip.
142
+ */
143
+ const HANDLE_SLOP = 10;
144
+
145
+ /** What starts a drag. */
146
+ export type SortableActivation = 'handle' | 'longPress';
147
+
148
+ /** Where a row ended up, alongside the order it produced. */
149
+ export interface SortableReorderDetails {
150
+ /** The row that was dragged. */
151
+ id: string;
152
+ /** Its index before the drag. */
153
+ from: number;
154
+ /** Its index after it. */
155
+ to: number;
156
+ }
157
+
158
+ /**
159
+ * The same move applied to a list of your own.
160
+ *
161
+ * `onReorder` hands back the new order of the ids, but a list is rarely a list
162
+ * of ids — it is a list of the things they name. This does the move on that
163
+ * list, and returns a new array rather than sorting in place, because the one
164
+ * that mutated would be the one React had already decided was unchanged.
165
+ *
166
+ * ```tsx
167
+ * onReorder={(_, { from, to }) => setTasks((tasks) => reorderItems(tasks, from, to))}
168
+ * ```
169
+ *
170
+ * Indices outside the list are returned unchanged rather than throwing: a
171
+ * reorder is not worth crashing a screen over, and a list that did not move is
172
+ * the honest result of a move that had nowhere to go.
173
+ */
174
+ export function reorderItems<T>(items: readonly T[], from: number, to: number): T[] {
175
+ if (from === to) return [...items];
176
+ if (from < 0 || from >= items.length) return [...items];
177
+ if (to < 0 || to >= items.length) return [...items];
178
+
179
+ const next = [...items];
180
+ const moved = next.splice(from, 1);
181
+ next.splice(to, 0, ...moved);
182
+ return next;
183
+ }
184
+
185
+ /* -------------------------------------------------------------------------- */
186
+ /* Slot maths */
187
+ /* -------------------------------------------------------------------------- */
188
+
189
+ /**
190
+ * How far down the list a row's slot begins, given an order and the measured
191
+ * heights — the sum of everything above it plus one gap per row above it.
192
+ *
193
+ * A worklet, and deliberately a loop rather than a cached prefix sum: the
194
+ * lists this component is for are the ones a person is willing to drag through
195
+ * by hand, and a cache would be another thing to invalidate every time a row
196
+ * changed height.
197
+ */
198
+ function slotOffset(
199
+ order: readonly string[],
200
+ id: string,
201
+ heights: Record<string, number>,
202
+ gap: number
203
+ ): number {
204
+ 'worklet';
205
+ let offset = 0;
206
+ for (let i = 0; i < order.length; i += 1) {
207
+ const at = order[i];
208
+ if (at === id) return offset;
209
+ offset += (at === undefined ? 0 : (heights[at] ?? 0)) + gap;
210
+ }
211
+ return offset;
212
+ }
213
+
214
+ /** The middle of the slot at `index`, in the same coordinates. */
215
+ function slotCenter(
216
+ order: readonly string[],
217
+ index: number,
218
+ heights: Record<string, number>,
219
+ gap: number
220
+ ): number {
221
+ 'worklet';
222
+ let offset = 0;
223
+ for (let i = 0; i < index; i += 1) {
224
+ const at = order[i];
225
+ offset += (at === undefined ? 0 : (heights[at] ?? 0)) + gap;
226
+ }
227
+ const self = order[index];
228
+ return offset + (self === undefined ? 0 : (heights[self] ?? 0)) / 2;
229
+ }
230
+
231
+ /**
232
+ * The order after a row has moved, with pinned rows left where they were.
233
+ *
234
+ * The move is applied first, to the whole list, so a carried row can be dragged
235
+ * *past* a pinned one — refusing the move instead would make a pinned row a
236
+ * wall, and a row that holds its place is not the same as a row nothing may
237
+ * cross. The pinned ids are then put back at the indices they occupy in the
238
+ * laid-out order, and everything else falls into the slots that are left, in
239
+ * the order the move produced.
240
+ *
241
+ * `laid` rather than `list` is what the fixed indices are read from, because
242
+ * that is the one order a pinned row is guaranteed to be correctly placed in:
243
+ * it is where it was rendered, and holding its slot is the whole point.
244
+ */
245
+ function moveWithPinned(
246
+ list: readonly string[],
247
+ laid: readonly string[],
248
+ pinned: Record<string, boolean>,
249
+ id: string,
250
+ from: number,
251
+ to: number
252
+ ): string[] {
253
+ 'worklet';
254
+ const moved = [...list];
255
+ moved.splice(from, 1);
256
+ moved.splice(to, 0, id);
257
+
258
+ const next: (string | undefined)[] = [];
259
+ let anyPinned = false;
260
+ for (let i = 0; i < moved.length; i += 1) next.push(undefined);
261
+ for (let i = 0; i < laid.length && i < next.length; i += 1) {
262
+ const at = laid[i];
263
+ if (at !== undefined && pinned[at]) {
264
+ next[i] = at;
265
+ anyPinned = true;
266
+ }
267
+ }
268
+ if (!anyPinned) return moved;
269
+
270
+ const free: string[] = [];
271
+ for (let i = 0; i < moved.length; i += 1) {
272
+ const at = moved[i];
273
+ if (at !== undefined && !pinned[at]) free.push(at);
274
+ }
275
+
276
+ const result: string[] = [];
277
+ let f = 0;
278
+ for (let i = 0; i < next.length; i += 1) {
279
+ const held = next[i];
280
+ if (held !== undefined) {
281
+ result.push(held);
282
+ continue;
283
+ }
284
+ const take = free[f];
285
+ f += 1;
286
+ if (take !== undefined) result.push(take);
287
+ }
288
+ return result;
289
+ }
290
+
291
+ /**
292
+ * Where the dragged row belongs now, given where its edges have reached.
293
+ *
294
+ * It walks outwards from the row's current slot and stops at the first
295
+ * neighbour it has not reached, rather than scanning the whole list for the
296
+ * nearest slot. The difference shows up with rows of unequal height: scanning
297
+ * can hand back a slot two places away that happens to be closer, which reads
298
+ * as the row skipping one.
299
+ *
300
+ * What counts as reaching a neighbour is the *leading edge* of the carried row
301
+ * against that neighbour's middle — its bottom edge going down, its top edge
302
+ * going up. Comparing middle against middle, as this used to, means the finger
303
+ * has to travel a whole row before anything happens, because a row's middle
304
+ * starts a whole row away from its neighbour's: the list sat still through the
305
+ * first row of every drag and then moved all at once. Leading edge against
306
+ * middle halves that, and it is also the more natural reading — the rows get
307
+ * out of the way once the row being carried is over them, not once it is past
308
+ * them.
309
+ */
310
+ function targetIndex(
311
+ order: readonly string[],
312
+ current: number,
313
+ top: number,
314
+ height: number,
315
+ heights: Record<string, number>,
316
+ gap: number
317
+ ): number {
318
+ 'worklet';
319
+ // Where the carried row's own slot begins, so the direction of travel is
320
+ // read from the row rather than from the sign of a gesture that may have
321
+ // changed its mind since.
322
+ const self = order[current];
323
+ const restingTop =
324
+ slotCenter(order, current, heights, gap) -
325
+ (self === undefined ? 0 : (heights[self] ?? 0)) / 2;
326
+
327
+ if (top < restingTop) {
328
+ let target = current;
329
+ for (let i = current - 1; i >= 0; i -= 1) {
330
+ if (top >= slotCenter(order, i, heights, gap)) break;
331
+ target = i;
332
+ }
333
+ return target;
334
+ }
335
+
336
+ const bottom = top + height;
337
+ let target = current;
338
+ for (let i = current + 1; i < order.length; i += 1) {
339
+ if (bottom <= slotCenter(order, i, heights, gap)) break;
340
+ target = i;
341
+ }
342
+ return target;
343
+ }
344
+
345
+ /* -------------------------------------------------------------------------- */
346
+ /* Context */
347
+ /* -------------------------------------------------------------------------- */
348
+
349
+ interface SortableContextValue {
350
+ /** The order being dragged. Diverges from `rendered` mid-drag, never after. */
351
+ order: SharedValue<string[]>;
352
+ /** The order the children were laid out in — what the transforms subtract. */
353
+ rendered: SharedValue<string[]>;
354
+ /** Measured row heights, keyed by id. */
355
+ heights: SharedValue<Record<string, number>>;
356
+ /** Which ids hold their slot. Read on the UI thread while a drag resolves. */
357
+ pinned: SharedValue<Record<string, boolean>>;
358
+ /**
359
+ * How far each row is from where it was laid out, keyed by id.
360
+ *
361
+ * Derived once per rearrangement rather than worked out by each row for
362
+ * itself. Every row's style worklet re-runs on every frame of a drag — it
363
+ * closes over the value the carried row is riding on — so a row summing the
364
+ * heights above it twice per frame made the list cost the square of its
365
+ * length to drag, which is felt exactly when a list is long enough to be
366
+ * worth reordering by hand. This is invalidated by the same shared values it
367
+ * is built from, so a row changing height still puts it right.
368
+ */
369
+ offsets: SharedValue<Record<string, number>>;
370
+ /** The row under the finger, or `null`. Also the settling row, until it lands. */
371
+ activeId: SharedValue<string | null>;
372
+ /** The active row's offset from where it was laid out. */
373
+ translate: SharedValue<number>;
374
+ /**
375
+ * How far the active row has come loose: 0 sitting in the list, 1 carried.
376
+ * Driven from the gesture rather than derived from `activeId`, so the row
377
+ * starts settling back the moment the finger lifts instead of waiting for
378
+ * the drop to finish first.
379
+ */
380
+ lift: SharedValue<number>;
381
+ /**
382
+ * Which drag is in flight. Bumped on every lift and every drop, so a landing
383
+ * spring that is still running when the next drag begins can tell that the
384
+ * row is no longer its to put down.
385
+ */
386
+ dragSeq: SharedValue<number>;
387
+ /** The finger's position on the screen, for the scroller to read. */
388
+ fingerY: SharedValue<number>;
389
+ /** The enclosing scroller's offset, or a constant 0 when there is none. */
390
+ scrollOffset: SharedValue<number>;
391
+ /** What that offset was when the drag began. */
392
+ scrollAtStart: SharedValue<number>;
393
+ gap: number;
394
+ disabled: boolean;
395
+ activation: SortableActivation;
396
+ longPressDelay: number;
397
+ haptics: boolean;
398
+ reducedMotion: boolean;
399
+ /** JS-side mirror of `activeId`, written twice a drag rather than per frame. */
400
+ activeItem: string | null;
401
+ /** Index of each id in the rendered order. */
402
+ indexOf: (id: string) => number;
403
+ measured: (id: string, height: number) => void;
404
+ /** Register or clear a row's hold on its slot. */
405
+ setPinned: (id: string, value: boolean) => void;
406
+ begin: (id: string) => void;
407
+ settled: (id: string) => void;
408
+ /** Move a row by whole slots — the path that is not a gesture. */
409
+ step: (id: string, delta: number) => void;
410
+ setAutoscroll: (active: boolean) => void;
411
+ }
412
+
413
+ const SortableRootContext = createContext<SortableContextValue | null>(null);
414
+
415
+ function useSortableRoot(part: string): SortableContextValue {
416
+ const context = useContext(SortableRootContext);
417
+ if (!context) throw new Error(`${part} must be used inside <Sortable>.`);
418
+ return context;
419
+ }
420
+
421
+ interface SortableItemContextValue {
422
+ id: string;
423
+ index: number;
424
+ isActive: boolean;
425
+ disabled: boolean;
426
+ /** The pan, when it belongs to a handle rather than to the whole row. */
427
+ handleGesture: GestureType | null;
428
+ }
429
+
430
+ const SortableItemContext = createContext<SortableItemContextValue | null>(null);
431
+
432
+ /**
433
+ * What the row being rendered knows about itself.
434
+ *
435
+ * For a handle of your own, or for a row that looks different while it is
436
+ * being carried — dimmed neighbours, a border, a shadow. `isActive` is a plain
437
+ * boolean and changes twice in a drag rather than sixty times a second: it is
438
+ * set when the row is lifted and cleared when it lands, and nothing between
439
+ * those two moments touches React at all.
440
+ */
441
+ export function useSortableItem(): {
442
+ id: string;
443
+ index: number;
444
+ isActive: boolean;
445
+ disabled: boolean;
446
+ } {
447
+ const context = useContext(SortableItemContext);
448
+ if (!context) throw new Error('useSortableItem must be used inside <Sortable.Item>.');
449
+ const { id, index, isActive, disabled } = context;
450
+ return { id, index, isActive, disabled };
451
+ }
452
+
453
+ /* -------------------------------------------------------------------------- */
454
+ /* Root */
455
+ /* -------------------------------------------------------------------------- */
456
+
457
+ export interface SortableProps extends Omit<ViewProps, 'children'> {
458
+ className?: string;
459
+ /**
460
+ * The ids of the rows, in the order they are rendered below. It is the
461
+ * caller's array rather than the component's, because only the caller knows
462
+ * what an id stands for — an order held here that disagreed with the
463
+ * children would put rows in places their content had not moved to.
464
+ */
465
+ value: string[];
466
+ /**
467
+ * The rows, one `Sortable.Item` per id in `value` and in the same order.
468
+ * They may be wrapped in anything; each row registers itself.
469
+ */
470
+ children?: ReactNode;
471
+ /**
472
+ * Told the new order once the dropped row has settled, and where it came
473
+ * from and went. Rearrange your own list from `details` — `reorderItems`
474
+ * does exactly this move.
475
+ */
476
+ onReorder?: (order: string[], details: SortableReorderDetails) => void;
477
+ /**
478
+ * Space between rows, in points. A prop rather than a `gap` class because
479
+ * the drag has to know it: the slot a row lands in is measured, and a gap
480
+ * the component cannot read is a gap it drops rows into the middle of.
481
+ */
482
+ gap?: number;
483
+ /**
484
+ * What lifts a row. `handle` is the default and the safer one — the rest of
485
+ * the row stays free to be pressed, and a list of rows with buttons on them
486
+ * still works. `longPress` gives the whole row to the drag.
487
+ */
488
+ activation?: SortableActivation;
489
+ /** How long `longPress` activation waits, in milliseconds. */
490
+ longPressDelay?: number;
491
+ /**
492
+ * Knock when a row is lifted, tick as it passes each slot. On by default:
493
+ * a drag with no feedback under the finger is the interaction people give up
494
+ * on halfway through, unsure whether anything is happening.
495
+ */
496
+ haptics?: boolean;
497
+ /** Turn every row's drag off and leave the list static. */
498
+ disabled?: boolean;
499
+ /**
500
+ * The scroller the list sits in, from `useAnimatedRef`. Given one, a drag
501
+ * carried to the top or bottom edge scrolls it, so a list longer than the
502
+ * screen can be reordered end to end. Without it a drag stops at the edge,
503
+ * which is correct for a list that fits.
504
+ */
505
+ scrollRef?: AnimatedRef<Animated.ScrollView>;
506
+ /** Points from the scroller's edge at which the scrolling begins. */
507
+ autoscrollThreshold?: number;
508
+ /** Points per frame at the very edge, tapering to nothing at the threshold. */
509
+ autoscrollSpeed?: number;
510
+ /** Told which row was lifted, the moment it is. */
511
+ onDragStart?: (id: string) => void;
512
+ /** Told when it lands, whether or not the order changed. */
513
+ onDragEnd?: (id: string) => void;
514
+ }
515
+
516
+ /**
517
+ * The list, and everything the rows share.
518
+ *
519
+ * It renders one plain `View` in a column and nothing else. The rows are the
520
+ * caller's, laid out by flexbox in the order they were written; the component
521
+ * only ever adds a transform to them.
522
+ */
523
+ function SortableRoot({
524
+ className,
525
+ value,
526
+ children,
527
+ onReorder,
528
+ gap = 0,
529
+ activation = 'handle',
530
+ longPressDelay = 220,
531
+ haptics = true,
532
+ disabled = false,
533
+ scrollRef,
534
+ autoscrollThreshold = 72,
535
+ autoscrollSpeed = 8,
536
+ onDragStart,
537
+ onDragEnd,
538
+ ...props
539
+ }: SortableProps) {
540
+ const order = useSharedValue<string[]>(value);
541
+ const rendered = useSharedValue<string[]>(value);
542
+ const heights = useSharedValue<Record<string, number>>({});
543
+ const pinned = useSharedValue<Record<string, boolean>>({});
544
+ const activeId = useSharedValue<string | null>(null);
545
+ const translate = useSharedValue(0);
546
+ const lift = useSharedValue(0);
547
+ const dragSeq = useSharedValue(0);
548
+ const fingerY = useSharedValue(0);
549
+
550
+ const [activeItem, setActiveItem] = useState<string | null>(null);
551
+ const reducedMotion = useReducedMotion();
552
+
553
+ /*
554
+ * A joined key rather than the array: `value` is almost always a fresh
555
+ * `.map()` on every render, so depending on it directly would reset the
556
+ * order on every keystroke elsewhere on the screen. A null separator
557
+ * cannot appear in an id that came from anywhere real.
558
+ */
559
+ const key = value.join('\u0000');
560
+
561
+ /*
562
+ * The caller has applied the drop, so the rendered order is the new one and
563
+ * the transforms that were holding rows in their new slots are no longer
564
+ * needed — the layout puts them there now. Both orders are reset together,
565
+ * which is what makes the re-render after a drop invisible.
566
+ */
567
+ useEffect(() => {
568
+ order.value = value;
569
+ rendered.value = value;
570
+ translate.value = 0;
571
+ lift.value = 0;
572
+ // `value` is covered by `key`; depending on the array itself would fire
573
+ // this on every render of the screen around it.
574
+ // eslint-disable-next-line react-hooks/exhaustive-deps
575
+ }, [key, order, rendered, translate, lift]);
576
+
577
+ const indices = useMemo(() => {
578
+ const map = new Map<string, number>();
579
+ value.forEach((id, index) => map.set(id, index));
580
+ return map;
581
+ // eslint-disable-next-line react-hooks/exhaustive-deps
582
+ }, [key]);
583
+
584
+ const indexOf = useCallback((id: string) => indices.get(id) ?? -1, [indices]);
585
+
586
+ const measured = useCallback(
587
+ (id: string, height: number) => {
588
+ if (heights.value[id] === height) return;
589
+ heights.value = { ...heights.value, [id]: height };
590
+ },
591
+ [heights]
592
+ );
593
+
594
+ const setPinned = useCallback(
595
+ (id: string, next: boolean) => {
596
+ if (Boolean(pinned.value[id]) === next) return;
597
+ pinned.value = { ...pinned.value, [id]: next };
598
+ },
599
+ [pinned]
600
+ );
601
+
602
+ /*
603
+ * Every row's distance from where it was laid out, in one pass.
604
+ *
605
+ * Two prefix sums — one over the order being dragged, one over the order the
606
+ * children are actually in — and the difference between them per id. Rebuilt
607
+ * when a swap changes `order`, when a drop resets both, or when a row reports
608
+ * a new height, and at no other time; a drag that is only moving the carried
609
+ * row does not touch it at all.
610
+ */
611
+ const offsets = useDerivedValue<Record<string, number>>(() => {
612
+ const map = heights.value;
613
+ const target: Record<string, number> = {};
614
+ const list = order.value;
615
+ let at = 0;
616
+ for (let i = 0; i < list.length; i += 1) {
617
+ const id = list[i];
618
+ if (id === undefined) continue;
619
+ target[id] = at;
620
+ at += (map[id] ?? 0) + gap;
621
+ }
622
+
623
+ const result: Record<string, number> = {};
624
+ const laid = rendered.value;
625
+ at = 0;
626
+ for (let i = 0; i < laid.length; i += 1) {
627
+ const id = laid[i];
628
+ if (id === undefined) continue;
629
+ result[id] = (target[id] ?? at) - at;
630
+ at += (map[id] ?? 0) + gap;
631
+ }
632
+ return result;
633
+ }, [gap]);
634
+
635
+ /* ---------------------------------------------------------------------- */
636
+ /* Autoscroll */
637
+ /* ---------------------------------------------------------------------- */
638
+
639
+ /*
640
+ * A ref of its own so `useScrollViewOffset` always has one to hold. It is
641
+ * never attached to anything, and the hook is happy with a ref that resolves
642
+ * to nothing — which is exactly the case where there is no scrolling to do.
643
+ */
644
+ const fallbackRef = useAnimatedRef<Animated.ScrollView>();
645
+ const scrollOffset = useScrollViewOffset(scrollRef ?? fallbackRef);
646
+
647
+ /**
648
+ * Where the scroller stood when the drag began. The row follows the finger,
649
+ * and the finger is measured against the screen — so every point the list
650
+ * scrolls underneath it has to be added back, or a row would slide out from
651
+ * under the finger the moment the list started moving.
652
+ */
653
+ const scrollAtStart = useSharedValue(0);
654
+
655
+ /*
656
+ * A frame callback rather than something driven by the pan, because the
657
+ * finger held still at the edge is exactly when the scrolling has to keep
658
+ * happening — and a pan that is not moving sends no updates.
659
+ */
660
+ const autoscroll = useFrameCallback(() => {
661
+ 'worklet';
662
+ if (!scrollRef || activeId.value === null) return;
663
+
664
+ const view = measure(scrollRef);
665
+ if (!view) return;
666
+
667
+ const top = view.pageY + autoscrollThreshold;
668
+ const bottom = view.pageY + view.height - autoscrollThreshold;
669
+ const y = fingerY.value;
670
+
671
+ let intensity = 0;
672
+ if (y < top) intensity = (y - top) / autoscrollThreshold;
673
+ else if (y > bottom) intensity = (y - bottom) / autoscrollThreshold;
674
+ if (intensity === 0) return;
675
+
676
+ const clamped = Math.max(-1, Math.min(1, intensity));
677
+ scrollTo(scrollRef, 0, scrollOffset.value + clamped * autoscrollSpeed, false);
678
+ }, false);
679
+
680
+ /*
681
+ * `setActive` is a JS-thread call, so the gesture reaches it through
682
+ * `runOnJS` rather than flipping a flag the callback would have to poll.
683
+ */
684
+ const setAutoscroll = useCallback(
685
+ (active: boolean) => {
686
+ if (!scrollRef) return;
687
+ autoscroll.setActive(active);
688
+ },
689
+ [autoscroll, scrollRef]
690
+ );
691
+
692
+ /* ---------------------------------------------------------------------- */
693
+ /* Lifting and landing */
694
+ /* ---------------------------------------------------------------------- */
695
+
696
+ const begin = useCallback(
697
+ (id: string) => {
698
+ setActiveItem(id);
699
+ setAutoscroll(true);
700
+ if (haptics) impactKnock();
701
+ onDragStart?.(id);
702
+ },
703
+ [haptics, onDragStart, setAutoscroll]
704
+ );
705
+
706
+ /**
707
+ * The row has landed. Only now is the drop reported: until the spring
708
+ * finished, the row was in a slot the layout knew nothing about, and a
709
+ * re-render in the middle of that would have relaid out every row under it
710
+ * while one of them was still moving.
711
+ */
712
+ const settled = useCallback(
713
+ (id: string) => {
714
+ setActiveItem(null);
715
+ setAutoscroll(false);
716
+ onDragEnd?.(id);
717
+
718
+ const next = order.value;
719
+ const from = rendered.value.indexOf(id);
720
+ const to = next.indexOf(id);
721
+ if (from === to || from < 0 || to < 0) return;
722
+
723
+ onReorder?.([...next], { id, from, to });
724
+ },
725
+ [onDragEnd, onReorder, order, rendered, setAutoscroll]
726
+ );
727
+
728
+ /**
729
+ * A move of whole slots, with no gesture behind it — the path a screen
730
+ * reader takes, and the one a keyboard would take if a phone had one. It
731
+ * reports the drop straight away: nothing is mid-flight, so there is no
732
+ * settling to wait for.
733
+ */
734
+ const step = useCallback(
735
+ (id: string, delta: number) => {
736
+ const current = rendered.value.indexOf(id);
737
+ if (current < 0) return;
738
+
739
+ const to = current + delta;
740
+ if (to < 0 || to >= rendered.value.length) return;
741
+
742
+ const next = reorderItems(rendered.value, current, to);
743
+ order.value = next;
744
+ if (haptics) selectionTick();
745
+ onReorder?.(next, { id, from: current, to });
746
+ },
747
+ [haptics, onReorder, order, rendered]
748
+ );
749
+
750
+ const context = useMemo<SortableContextValue>(
751
+ () => ({
752
+ order,
753
+ rendered,
754
+ heights,
755
+ pinned,
756
+ offsets,
757
+ activeId,
758
+ translate,
759
+ lift,
760
+ dragSeq,
761
+ fingerY,
762
+ scrollOffset,
763
+ scrollAtStart,
764
+ gap,
765
+ disabled,
766
+ activation,
767
+ longPressDelay,
768
+ haptics,
769
+ reducedMotion,
770
+ activeItem,
771
+ indexOf,
772
+ measured,
773
+ setPinned,
774
+ begin,
775
+ settled,
776
+ step,
777
+ setAutoscroll,
778
+ }),
779
+ [
780
+ order,
781
+ rendered,
782
+ heights,
783
+ pinned,
784
+ offsets,
785
+ activeId,
786
+ translate,
787
+ lift,
788
+ dragSeq,
789
+ fingerY,
790
+ scrollOffset,
791
+ scrollAtStart,
792
+ gap,
793
+ disabled,
794
+ activation,
795
+ longPressDelay,
796
+ haptics,
797
+ reducedMotion,
798
+ activeItem,
799
+ indexOf,
800
+ measured,
801
+ setPinned,
802
+ begin,
803
+ settled,
804
+ step,
805
+ setAutoscroll,
806
+ ]
807
+ );
808
+
809
+ return (
810
+ <SortableRootContext.Provider value={context}>
811
+ <View
812
+ accessibilityRole="list"
813
+ className={cn('w-full', className)}
814
+ style={gap > 0 ? { gap } : undefined}
815
+ {...props}
816
+ >
817
+ {children}
818
+ </View>
819
+ </SortableRootContext.Provider>
820
+ );
821
+ }
822
+ SortableRoot.displayName = 'Sortable';
823
+
824
+ /* -------------------------------------------------------------------------- */
825
+ /* Item */
826
+ /* -------------------------------------------------------------------------- */
827
+
828
+ export interface SortableItemProps extends Omit<ViewProps, 'children'> {
829
+ className?: string;
830
+ /**
831
+ * What this row is, and the id that appears in `value` and in the order
832
+ * handed back. Stable across renders — an id derived from the index changes
833
+ * the moment the list is reordered, and the rows lose track of themselves.
834
+ */
835
+ id: string;
836
+ children?: ReactNode;
837
+ /**
838
+ * Stop this row being picked up. The others still move past it, because a
839
+ * row that cannot be dragged is not the same as a row that cannot be
840
+ * displaced — that is what `pinned` is for, and conflating the two would mean
841
+ * silently refusing drops that look like they worked.
842
+ */
843
+ disabled?: boolean;
844
+ /**
845
+ * Hold this row's place in the list. It cannot be picked up, and — unlike a
846
+ * `disabled` row — nothing else can take its slot either: the rows being
847
+ * dragged reorder among the places left over, and one carried past this row
848
+ * goes around it rather than through it.
849
+ *
850
+ * For the row that means something by being where it is. A header, a total, a
851
+ * step that has to come first.
852
+ */
853
+ pinned?: boolean;
854
+ /**
855
+ * Extra classes for the row while it is being carried, applied last. A
856
+ * lifted row is given an opaque surface and a shadow so it is never drawn
857
+ * see-through over the rows it is passing; this is what overrides that.
858
+ */
859
+ activeClassName?: string;
860
+ }
861
+
862
+ /**
863
+ * One row.
864
+ *
865
+ * It measures itself, carries its own transform, and holds the pan — which is
866
+ * either wrapped around the whole row or handed to a `Sortable.Handle` inside
867
+ * it, depending on what lifts a row in this list.
868
+ */
869
+ function SortableItem({
870
+ className,
871
+ activeClassName,
872
+ id,
873
+ children,
874
+ disabled = false,
875
+ pinned = false,
876
+ ...props
877
+ }: SortableItemProps) {
878
+ const root = useSortableRoot('Sortable.Item');
879
+ const {
880
+ order,
881
+ rendered,
882
+ heights,
883
+ pinned: pinnedIds,
884
+ offsets,
885
+ activeId,
886
+ translate,
887
+ lift,
888
+ dragSeq,
889
+ fingerY,
890
+ scrollOffset,
891
+ scrollAtStart,
892
+ gap,
893
+ activation,
894
+ longPressDelay,
895
+ haptics,
896
+ reducedMotion,
897
+ activeItem,
898
+ indexOf,
899
+ measured,
900
+ setPinned,
901
+ begin,
902
+ settled,
903
+ step,
904
+ } = root;
905
+
906
+ /** A row is undraggable if it, the whole list, or its own pin says so. */
907
+ const locked = root.disabled || disabled || pinned;
908
+
909
+ /*
910
+ * Published to the root so the drag can read it on the UI thread. A pin is
911
+ * resolved while a finger is moving, where the props of a row two places away
912
+ * are not reachable.
913
+ */
914
+ useEffect(() => {
915
+ setPinned(id, pinned);
916
+ }, [id, pinned, setPinned]);
917
+ const index = indexOf(id);
918
+ const isActive = activeItem === id;
919
+
920
+ const tick = useCallback(() => selectionTick(), []);
921
+
922
+ /*
923
+ * Read off a ref inside the worklets rather than captured by them: `begin`
924
+ * and `settled` change identity whenever the caller's callbacks do, and a
925
+ * gesture rebuilt mid-drag is a gesture that drops the drag.
926
+ */
927
+ const handlers = useRef({ begin, settled, tick });
928
+ handlers.current = { begin, settled, tick };
929
+
930
+ const notifyBegin = useCallback((rowId: string) => {
931
+ handlers.current.begin(rowId);
932
+ }, []);
933
+ const notifySettled = useCallback((rowId: string) => {
934
+ handlers.current.settled(rowId);
935
+ }, []);
936
+ const notifyTick = useCallback(() => {
937
+ handlers.current.tick();
938
+ }, []);
939
+
940
+ const pan = useMemo(() => {
941
+ const gesture = Gesture.Pan()
942
+ .enabled(!locked)
943
+ .onStart((event) => {
944
+ activeId.value = id;
945
+ translate.value = 0;
946
+ order.value = [...rendered.value];
947
+ fingerY.value = event.absoluteY;
948
+ /*
949
+ * Claims the row: a landing spring left over from the previous drop
950
+ * finds this number changed and leaves the row alone rather than
951
+ * putting down one that has just been picked back up.
952
+ */
953
+ dragSeq.value += 1;
954
+ /*
955
+ * Left at rest when motion is turned down, so the row does not grow.
956
+ * The surface and the shadow it gets in the same moment are not
957
+ * motion and stay either way — they are what stop the lifted row
958
+ * being see-through, which is not a preference.
959
+ */
960
+ lift.value = reducedMotion ? 0 : withSpring(1, LIFT);
961
+ /*
962
+ * Taken here rather than on the JS side: `notifyBegin` lands a frame
963
+ * or more later, and by then a fast flick may already have moved the
964
+ * scroller — which would be read as the finger having moved.
965
+ */
966
+ scrollAtStart.value = scrollOffset.value;
967
+ runOnJS(notifyBegin)(id);
968
+ })
969
+ .onUpdate((event) => {
970
+ if (activeId.value !== id) return;
971
+
972
+ /*
973
+ * The pan reports the finger against the screen, so every point the
974
+ * list has scrolled underneath it since the lift has to be added
975
+ * back — otherwise a row slides out from under the finger the moment
976
+ * a drag at the edge starts scrolling.
977
+ */
978
+ translate.value =
979
+ event.translationY + (scrollOffset.value - scrollAtStart.value);
980
+ fingerY.value = event.absoluteY;
981
+
982
+ const list = order.value;
983
+ const map = heights.value;
984
+ const current = list.indexOf(id);
985
+ if (current < 0) return;
986
+
987
+ const top = slotOffset(rendered.value, id, map, gap) + translate.value;
988
+ const to = targetIndex(list, current, top, map[id] ?? 0, map, gap);
989
+ if (to === current) return;
990
+
991
+ const next = moveWithPinned(
992
+ list,
993
+ rendered.value,
994
+ pinnedIds.value,
995
+ id,
996
+ current,
997
+ to
998
+ );
999
+ // A move that only pinned rows could have absorbed leaves the order
1000
+ // exactly as it was, and there is nothing to feel or to redraw.
1001
+ if (next[current] === id) return;
1002
+ order.value = next;
1003
+
1004
+ if (haptics) runOnJS(notifyTick)();
1005
+ })
1006
+ .onFinalize(() => {
1007
+ if (activeId.value !== id) return;
1008
+
1009
+ /*
1010
+ * The row springs to where its new slot sits *in the old layout* —
1011
+ * the layout has not changed yet and will not until the drop is
1012
+ * reported, which is what the callback below does once the row has
1013
+ * stopped moving.
1014
+ */
1015
+ const landing =
1016
+ slotOffset(order.value, id, heights.value, gap) -
1017
+ slotOffset(rendered.value, id, heights.value, gap);
1018
+
1019
+ /*
1020
+ * Released here rather than once the row has landed, so the shrink and
1021
+ * the drop are one movement. `LIFT` is the faster spring of the two,
1022
+ * so the row is back at its own size a little before it arrives.
1023
+ */
1024
+ dragSeq.value += 1;
1025
+ const seq = dragSeq.value;
1026
+ lift.value = reducedMotion ? 0 : withSpring(0, LIFT);
1027
+
1028
+ /*
1029
+ * Runs whether or not the spring finished. An interrupted spring used
1030
+ * to leave `activeId` set, which left the row lifted for good and the
1031
+ * drop unreported; `seq` is what tells the two cases apart, because
1032
+ * the only interruption that should be ignored is the row being picked
1033
+ * up again.
1034
+ */
1035
+ const land = () => {
1036
+ 'worklet';
1037
+ if (dragSeq.value !== seq) return;
1038
+ activeId.value = null;
1039
+ runOnJS(notifySettled)(id);
1040
+ };
1041
+
1042
+ if (reducedMotion) {
1043
+ translate.value = landing;
1044
+ land();
1045
+ return;
1046
+ }
1047
+
1048
+ translate.value = withSpring(landing, LAND, land);
1049
+ });
1050
+
1051
+ if (activation === 'longPress') return gesture.activateAfterLongPress(longPressDelay);
1052
+
1053
+ /*
1054
+ * On a handle the drag starts on movement rather than on a hold, but not
1055
+ * on the first pixel: a list inside a scroller has to be able to scroll
1056
+ * from a finger that happened to land on a grip.
1057
+ */
1058
+ return gesture.activeOffsetY([-HANDLE_SLOP, HANDLE_SLOP]);
1059
+ }, [
1060
+ activation,
1061
+ activeId,
1062
+ locked,
1063
+ dragSeq,
1064
+ fingerY,
1065
+ gap,
1066
+ haptics,
1067
+ heights,
1068
+ id,
1069
+ lift,
1070
+ longPressDelay,
1071
+ notifyBegin,
1072
+ notifySettled,
1073
+ notifyTick,
1074
+ order,
1075
+ reducedMotion,
1076
+ rendered,
1077
+ scrollAtStart,
1078
+ scrollOffset,
1079
+ translate,
1080
+ ]);
1081
+
1082
+ const style = useAnimatedStyle(() => {
1083
+ /*
1084
+ * Read rather than animated here. The springs live on `translate` and
1085
+ * `lift`, which the gesture drives; starting one from inside the style
1086
+ * instead would re-enter it on every frame of the drag, because this
1087
+ * worklet re-runs every time the row it is carrying moves.
1088
+ */
1089
+ if (activeId.value === id) {
1090
+ return {
1091
+ transform: [
1092
+ { translateY: translate.value },
1093
+ { scale: 1 + lift.value * (LIFT_SCALE - 1) },
1094
+ ],
1095
+ };
1096
+ }
1097
+
1098
+ /*
1099
+ * Read, not worked out. This worklet re-runs on every frame of a drag —
1100
+ * it closes over the value the carried row rides on — so summing the
1101
+ * heights above this row here, twice, made a list cost the square of its
1102
+ * length to drag. The root derives every row's offset in one pass instead,
1103
+ * and only when the arrangement actually changes.
1104
+ */
1105
+ const offset = offsets.value[id] ?? 0;
1106
+
1107
+ /*
1108
+ * Only animated while a drag is in flight, and the difference is the whole
1109
+ * end of the drop. When the caller applies the reorder the rows move in
1110
+ * the tree and every offset falls to zero on the same commit — springing
1111
+ * to it would send the row that was just dropped sliding back across the
1112
+ * distance it had travelled, in a slot it was already sitting in.
1113
+ */
1114
+ const settling = activeId.value === null;
1115
+
1116
+ return {
1117
+ transform: [
1118
+ { translateY: settling || reducedMotion ? offset : withSpring(offset, DISPLACE) },
1119
+ { scale: 1 },
1120
+ ],
1121
+ };
1122
+ });
1123
+
1124
+ const onLayout = useCallback(
1125
+ (event: LayoutChangeEvent) => measured(id, event.nativeEvent.layout.height),
1126
+ [id, measured]
1127
+ );
1128
+
1129
+ const itemContext = useMemo<SortableItemContextValue>(
1130
+ () => ({
1131
+ id,
1132
+ index,
1133
+ isActive,
1134
+ disabled: locked,
1135
+ handleGesture: activation === 'handle' ? pan : null,
1136
+ }),
1137
+ [id, index, isActive, locked, activation, pan]
1138
+ );
1139
+
1140
+ /**
1141
+ * A drag is invisible to a screen reader — there is nothing to announce and
1142
+ * no way to discover it from the row. Moving by whole slots is published as
1143
+ * an accessibility action instead, which is the only path to reordering for
1144
+ * someone who is not dragging anything.
1145
+ */
1146
+ const a11y = locked
1147
+ ? undefined
1148
+ : [
1149
+ { name: 'moveUp', label: 'Move up' },
1150
+ { name: 'moveDown', label: 'Move down' },
1151
+ ];
1152
+
1153
+ const row = (
1154
+ <Animated.View
1155
+ onLayout={onLayout}
1156
+ accessibilityActions={a11y}
1157
+ onAccessibilityAction={(event) => {
1158
+ if (event.nativeEvent.actionName === 'moveUp') step(id, -1);
1159
+ if (event.nativeEvent.actionName === 'moveDown') step(id, 1);
1160
+ }}
1161
+ style={style}
1162
+ /*
1163
+ * A carried row is drawn over the ones it is passing, so it has to have
1164
+ * a surface of its own — the row itself is only a box around whatever
1165
+ * the caller put inside it, and a good deal of what people put there
1166
+ * (an outlined `Item`, a bare `View`) has no background at all. Without
1167
+ * this the lifted row is see-through and the list can be read straight
1168
+ * through the middle of it.
1169
+ *
1170
+ * After `className` so the surface holds whatever else the row is
1171
+ * wearing, and before `activeClassName`, which is the way out.
1172
+ */
1173
+ className={cn(
1174
+ 'w-full',
1175
+ className,
1176
+ isActive && 'z-10 rounded-xl bg-card shadow-lg',
1177
+ isActive && activeClassName
1178
+ )}
1179
+ {...props}
1180
+ >
1181
+ {children}
1182
+ </Animated.View>
1183
+ );
1184
+
1185
+ return (
1186
+ <SortableItemContext.Provider value={itemContext}>
1187
+ {activation === 'longPress' ? (
1188
+ <GestureDetector gesture={pan}>{row}</GestureDetector>
1189
+ ) : (
1190
+ row
1191
+ )}
1192
+ </SortableItemContext.Provider>
1193
+ );
1194
+ }
1195
+ SortableItem.displayName = 'Sortable.Item';
1196
+
1197
+ /* -------------------------------------------------------------------------- */
1198
+ /* Handle */
1199
+ /* -------------------------------------------------------------------------- */
1200
+
1201
+ export interface SortableHandleProps extends Omit<ViewProps, 'children'> {
1202
+ className?: string;
1203
+ /** Replaces the grip glyph. Anything at all — the drag is on the box. */
1204
+ children?: ReactNode;
1205
+ /** What a screen reader calls the grip. */
1206
+ accessibilityLabel?: string;
1207
+ }
1208
+
1209
+ /**
1210
+ * The part of a row that lifts it.
1211
+ *
1212
+ * A grip exists so the rest of the row does not have to be given up to the
1213
+ * drag: a row with a button, a checkbox or a link on it needs those to stay
1214
+ * pressable, and a long press on the whole row takes all of them. It is sized
1215
+ * to be hit rather than to be seen, which is why the box around the glyph is
1216
+ * larger than the glyph.
1217
+ *
1218
+ * Inert in a list that lifts on a long press — the whole row already carries
1219
+ * the gesture there, and a second one inside it would fight the first.
1220
+ */
1221
+ function SortableHandle({
1222
+ className,
1223
+ children,
1224
+ accessibilityLabel = 'Drag to reorder',
1225
+ ...props
1226
+ }: SortableHandleProps) {
1227
+ const { activation, disabled: rootDisabled } = useSortableRoot('Sortable.Handle');
1228
+ const item = useContext(SortableItemContext);
1229
+
1230
+ /*
1231
+ * The grip is furniture rather than content, so it takes the muted
1232
+ * foreground — read from the theme rather than written down, because a hex
1233
+ * here stops being right the moment the theme inverts.
1234
+ */
1235
+ const muted = useCSSVariable('--color-muted-foreground');
1236
+ const tint = typeof muted === 'string' ? muted : undefined;
1237
+
1238
+ const glyph = (
1239
+ <View
1240
+ accessible
1241
+ accessibilityRole="adjustable"
1242
+ accessibilityLabel={accessibilityLabel}
1243
+ accessibilityState={{ disabled: rootDisabled || item?.disabled }}
1244
+ className={cn(
1245
+ 'items-center justify-center px-2 py-1.5',
1246
+ (rootDisabled || item?.disabled) && 'opacity-40',
1247
+ className
1248
+ )}
1249
+ {...props}
1250
+ >
1251
+ <IconColorProvider color={tint}>
1252
+ {children ?? <GripVerticalIcon size={18} />}
1253
+ </IconColorProvider>
1254
+ </View>
1255
+ );
1256
+
1257
+ if (activation !== 'handle' || !item?.handleGesture) return glyph;
1258
+
1259
+ return <GestureDetector gesture={item.handleGesture}>{glyph}</GestureDetector>;
1260
+ }
1261
+ SortableHandle.displayName = 'Sortable.Handle';
1262
+
1263
+ export const Sortable = Object.assign(SortableRoot, {
1264
+ Item: SortableItem,
1265
+ Handle: SortableHandle,
1266
+ });