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