@simoneggert/react-modal-sheet 5.4.3
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/LICENSE +21 -0
- package/README.md +994 -0
- package/dist/index.d.mts +188 -0
- package/dist/index.d.ts +188 -0
- package/dist/index.js +1511 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1502 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +87 -0
- package/src/SheetBackdrop.tsx +46 -0
- package/src/SheetContainer.tsx +53 -0
- package/src/SheetContent.tsx +117 -0
- package/src/SheetDragIndicator.tsx +52 -0
- package/src/SheetHeader.tsx +52 -0
- package/src/constants.ts +19 -0
- package/src/context.tsx +12 -0
- package/src/debug.ts +38 -0
- package/src/hooks/use-dimensions.ts +30 -0
- package/src/hooks/use-drag-constraints.ts +14 -0
- package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
- package/src/hooks/use-keyboard-avoidance.ts +75 -0
- package/src/hooks/use-modal-effect.ts +161 -0
- package/src/hooks/use-prevent-scroll.ts +357 -0
- package/src/hooks/use-resize-observer.ts +31 -0
- package/src/hooks/use-safe-area-insets.ts +40 -0
- package/src/hooks/use-scroll-position.ts +142 -0
- package/src/hooks/use-sheet-state.ts +63 -0
- package/src/hooks/use-stable-callback.ts +18 -0
- package/src/hooks/use-virtual-keyboard.ts +269 -0
- package/src/index.tsx +41 -0
- package/src/sheet.tsx +414 -0
- package/src/snap.ts +242 -0
- package/src/styles.ts +110 -0
- package/src/types.tsx +154 -0
- package/src/utils.ts +116 -0
package/src/sheet.tsx
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import {
|
|
2
|
+
animate,
|
|
3
|
+
type DragHandler,
|
|
4
|
+
motion,
|
|
5
|
+
type Transition,
|
|
6
|
+
useMotionValue,
|
|
7
|
+
useReducedMotion,
|
|
8
|
+
useTransform,
|
|
9
|
+
} from 'motion/react';
|
|
10
|
+
import React, {
|
|
11
|
+
forwardRef,
|
|
12
|
+
useImperativeHandle,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
} from 'react';
|
|
16
|
+
import { createPortal } from 'react-dom';
|
|
17
|
+
import useMeasure from 'react-use-measure';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_DRAG_CLOSE_THRESHOLD,
|
|
21
|
+
DEFAULT_DRAG_VELOCITY_THRESHOLD,
|
|
22
|
+
DEFAULT_TWEEN_CONFIG,
|
|
23
|
+
IS_SSR,
|
|
24
|
+
REDUCED_MOTION_TWEEN_CONFIG,
|
|
25
|
+
} from './constants';
|
|
26
|
+
import { SheetContext } from './context';
|
|
27
|
+
import { useDimensions } from './hooks/use-dimensions';
|
|
28
|
+
import { useKeyboardAvoidance } from './hooks/use-keyboard-avoidance';
|
|
29
|
+
import { useModalEffect } from './hooks/use-modal-effect';
|
|
30
|
+
import { usePreventScroll } from './hooks/use-prevent-scroll';
|
|
31
|
+
import { useSheetState } from './hooks/use-sheet-state';
|
|
32
|
+
import { useStableCallback } from './hooks/use-stable-callback';
|
|
33
|
+
import {
|
|
34
|
+
computeSnapPoints,
|
|
35
|
+
handleHighVelocityDrag,
|
|
36
|
+
handleLowVelocityDrag,
|
|
37
|
+
} from './snap';
|
|
38
|
+
import { styles } from './styles';
|
|
39
|
+
import { type SheetContextType, type SheetProps } from './types';
|
|
40
|
+
import { applyStyles, waitForElement, willOpenKeyboard } from './utils';
|
|
41
|
+
|
|
42
|
+
export const Sheet = forwardRef<any, SheetProps>(
|
|
43
|
+
(
|
|
44
|
+
{
|
|
45
|
+
avoidKeyboard = true,
|
|
46
|
+
children,
|
|
47
|
+
className = '',
|
|
48
|
+
detent = 'default',
|
|
49
|
+
disableDismiss = false,
|
|
50
|
+
disableDrag: disableDragProp = false,
|
|
51
|
+
disableScrollLocking = false,
|
|
52
|
+
dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
|
|
53
|
+
dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
|
|
54
|
+
initialSnap,
|
|
55
|
+
isOpen,
|
|
56
|
+
modalEffectRootId,
|
|
57
|
+
modalEffectThreshold,
|
|
58
|
+
mountPoint,
|
|
59
|
+
prefersReducedMotion = false,
|
|
60
|
+
snapPoints: snapPointsProp,
|
|
61
|
+
style,
|
|
62
|
+
tweenConfig = DEFAULT_TWEEN_CONFIG,
|
|
63
|
+
unstyled = false,
|
|
64
|
+
onOpenStart,
|
|
65
|
+
onOpenEnd,
|
|
66
|
+
onClose,
|
|
67
|
+
onCloseStart,
|
|
68
|
+
onCloseEnd,
|
|
69
|
+
onSnap,
|
|
70
|
+
onDrag: onDragProp,
|
|
71
|
+
onDragStart: onDragStartProp,
|
|
72
|
+
onDragEnd: onDragEndProp,
|
|
73
|
+
...rest
|
|
74
|
+
},
|
|
75
|
+
ref
|
|
76
|
+
) => {
|
|
77
|
+
const [sheetBoundsRef, sheetBounds] = useMeasure();
|
|
78
|
+
const sheetRef = useRef<HTMLDivElement>(null);
|
|
79
|
+
const sheetHeight = Math.round(sheetBounds.height);
|
|
80
|
+
const [currentSnap, setCurrentSnap] = useState(initialSnap);
|
|
81
|
+
const snapPoints =
|
|
82
|
+
snapPointsProp && sheetHeight > 0
|
|
83
|
+
? computeSnapPoints({ sheetHeight, snapPointsProp })
|
|
84
|
+
: [];
|
|
85
|
+
|
|
86
|
+
const { windowHeight } = useDimensions();
|
|
87
|
+
const closedY = sheetHeight > 0 ? sheetHeight : windowHeight;
|
|
88
|
+
const y = useMotionValue(closedY);
|
|
89
|
+
const yInverted = useTransform(y, (val) => Math.max(sheetHeight - val, 0));
|
|
90
|
+
const indicatorRotation = useMotionValue(0);
|
|
91
|
+
|
|
92
|
+
const shouldReduceMotion = useReducedMotion();
|
|
93
|
+
const reduceMotion = Boolean(prefersReducedMotion || shouldReduceMotion);
|
|
94
|
+
const animationOptions: Transition = {
|
|
95
|
+
type: 'tween',
|
|
96
|
+
...(reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig),
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// +2 for tolerance in case the animated value is slightly off
|
|
100
|
+
const zIndex = useTransform(y, (val) =>
|
|
101
|
+
val + 2 >= closedY ? -1 : (style?.zIndex ?? 9999)
|
|
102
|
+
);
|
|
103
|
+
const visibility = useTransform(y, (val) =>
|
|
104
|
+
val + 2 >= closedY ? 'hidden' : 'visible'
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const updateSnap = useStableCallback((snapIndex: number) => {
|
|
108
|
+
setCurrentSnap(snapIndex);
|
|
109
|
+
onSnap?.(snapIndex);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const getSnapPoint = useStableCallback((snapIndex: number) => {
|
|
113
|
+
if (snapPointsProp && snapPoints) {
|
|
114
|
+
if (snapIndex < 0 || snapIndex >= snapPoints.length) {
|
|
115
|
+
console.warn(
|
|
116
|
+
`Invalid snap index ${snapIndex}. Snap points are: [${snapPointsProp.join(', ')}] and their computed values are: [${snapPoints
|
|
117
|
+
.map((point) => point.snapValue)
|
|
118
|
+
.join(', ')}]`
|
|
119
|
+
);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return snapPoints[snapIndex];
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const snapTo = useStableCallback(async (snapIndex: number) => {
|
|
128
|
+
if (!snapPointsProp) {
|
|
129
|
+
console.warn('Snapping is not possible without `snapPoints` prop.');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const snapPoint = getSnapPoint(snapIndex);
|
|
134
|
+
|
|
135
|
+
if (snapPoint === null) {
|
|
136
|
+
console.warn(`Invalid snap index ${snapIndex}.`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (snapIndex === 0) {
|
|
141
|
+
onClose();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await animate(y, snapPoint.snapValueY, {
|
|
146
|
+
...animationOptions,
|
|
147
|
+
onComplete: () => updateSnap(snapIndex),
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const keyboard = useKeyboardAvoidance({
|
|
152
|
+
isEnabled: isOpen && avoidKeyboard,
|
|
153
|
+
containerRef: sheetRef,
|
|
154
|
+
onWillOpenKeyboard: async () => {
|
|
155
|
+
const lastSnapPoint = snapPoints[snapPoints.length - 1];
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* If there are snap points and the sheet is not already at the last snap point,
|
|
159
|
+
* move it there to make sure the focused input is not covered by the keyboard
|
|
160
|
+
*/
|
|
161
|
+
if (lastSnapPoint && lastSnapPoint.snapIndex !== currentSnap) {
|
|
162
|
+
await animate(y, lastSnapPoint.snapValueY, animationOptions);
|
|
163
|
+
updateSnap(lastSnapPoint.snapIndex);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
onDidOpenKeyboard: (focusedElement) => {
|
|
167
|
+
const sheetElement = sheetRef.current;
|
|
168
|
+
if (!sheetElement) return;
|
|
169
|
+
|
|
170
|
+
const inputRect = focusedElement.getBoundingClientRect();
|
|
171
|
+
const containerRect = sheetElement.getBoundingClientRect();
|
|
172
|
+
const scroller = sheetElement.querySelector(
|
|
173
|
+
'.react-modal-sheet-content-scroller'
|
|
174
|
+
) as HTMLElement;
|
|
175
|
+
|
|
176
|
+
const scrollTarget = Math.max(
|
|
177
|
+
inputRect.top -
|
|
178
|
+
containerRect.top +
|
|
179
|
+
scroller.scrollTop -
|
|
180
|
+
inputRect.height,
|
|
181
|
+
0
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
requestAnimationFrame(() => {
|
|
185
|
+
scroller.scrollTo({ top: scrollTarget, behavior: 'smooth' });
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// Disable drag if the keyboard is open to avoid weird behavior
|
|
191
|
+
const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
|
|
192
|
+
|
|
193
|
+
const blurActiveInput = useStableCallback(() => {
|
|
194
|
+
/**
|
|
195
|
+
* Find focused input inside the sheet and blur it when dragging starts
|
|
196
|
+
* to prevent a weird ghost caret "bug" on mobile
|
|
197
|
+
*/
|
|
198
|
+
const focusedElement = document.activeElement as HTMLElement | null;
|
|
199
|
+
|
|
200
|
+
// Only blur the focused element if it's inside the sheet
|
|
201
|
+
if (
|
|
202
|
+
focusedElement &&
|
|
203
|
+
willOpenKeyboard(focusedElement) &&
|
|
204
|
+
sheetRef.current?.contains(focusedElement)
|
|
205
|
+
) {
|
|
206
|
+
focusedElement.blur();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const onDragStart = useStableCallback<DragHandler>((event, info) => {
|
|
211
|
+
blurActiveInput();
|
|
212
|
+
onDragStartProp?.(event, info);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const onDrag = useStableCallback<DragHandler>((event, info) => {
|
|
216
|
+
onDragProp?.(event, info);
|
|
217
|
+
|
|
218
|
+
const currentY = y.get();
|
|
219
|
+
|
|
220
|
+
// Update drag indicator rotation based on drag velocity
|
|
221
|
+
const velocity = y.getVelocity();
|
|
222
|
+
if (velocity > 0) indicatorRotation.set(10);
|
|
223
|
+
if (velocity < 0) indicatorRotation.set(-10);
|
|
224
|
+
|
|
225
|
+
// Make sure user cannot drag beyond the top of the sheet
|
|
226
|
+
y.set(Math.max(currentY + info.delta.y, 0));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const onDragEnd = useStableCallback<DragHandler>((event, info) => {
|
|
230
|
+
blurActiveInput();
|
|
231
|
+
onDragEndProp?.(event, info);
|
|
232
|
+
|
|
233
|
+
const currentY = y.get();
|
|
234
|
+
|
|
235
|
+
let yTo = 0;
|
|
236
|
+
|
|
237
|
+
const currentSnapPoint =
|
|
238
|
+
currentSnap !== undefined ? getSnapPoint(currentSnap) : null;
|
|
239
|
+
|
|
240
|
+
if (currentSnapPoint) {
|
|
241
|
+
const dragOffsetDirection = info.offset.y > 0 ? 'down' : 'up';
|
|
242
|
+
const dragVelocityDirection = info.velocity.y > 0 ? 'down' : 'up';
|
|
243
|
+
const isHighVelocity =
|
|
244
|
+
Math.abs(info.velocity.y) > dragVelocityThreshold;
|
|
245
|
+
|
|
246
|
+
let result: { yTo: number; snapIndex: number | undefined };
|
|
247
|
+
|
|
248
|
+
if (isHighVelocity) {
|
|
249
|
+
result = handleHighVelocityDrag({
|
|
250
|
+
snapPoints,
|
|
251
|
+
dragDirection: dragVelocityDirection,
|
|
252
|
+
});
|
|
253
|
+
} else {
|
|
254
|
+
result = handleLowVelocityDrag({
|
|
255
|
+
currentSnapPoint,
|
|
256
|
+
currentY,
|
|
257
|
+
dragDirection: dragOffsetDirection,
|
|
258
|
+
snapPoints,
|
|
259
|
+
velocity: info.velocity.y,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
yTo = result.yTo;
|
|
264
|
+
|
|
265
|
+
// If disableDismiss is true, prevent closing via gesture
|
|
266
|
+
if (disableDismiss && yTo + 1 >= sheetHeight) {
|
|
267
|
+
// Use the bottom-most open snap point
|
|
268
|
+
const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
|
|
269
|
+
|
|
270
|
+
if (bottomSnapPoint) {
|
|
271
|
+
yTo = bottomSnapPoint.snapValueY;
|
|
272
|
+
updateSnap(bottomSnapPoint.snapIndex);
|
|
273
|
+
} else {
|
|
274
|
+
// If no open snap points available, stay at current position
|
|
275
|
+
yTo = currentY;
|
|
276
|
+
}
|
|
277
|
+
} else if (result.snapIndex !== undefined) {
|
|
278
|
+
updateSnap(result.snapIndex);
|
|
279
|
+
}
|
|
280
|
+
} else if (
|
|
281
|
+
info.velocity.y > dragVelocityThreshold ||
|
|
282
|
+
currentY > sheetHeight * dragCloseThreshold
|
|
283
|
+
) {
|
|
284
|
+
// Close the sheet if dragged past the threshold or if the velocity is high enough
|
|
285
|
+
// But only if disableDismiss is false
|
|
286
|
+
if (disableDismiss) {
|
|
287
|
+
// If disableDismiss, snap back to the open position
|
|
288
|
+
yTo = 0;
|
|
289
|
+
} else {
|
|
290
|
+
yTo = closedY;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Update the spring value so that the sheet is animated to the snap point
|
|
295
|
+
animate(y, yTo, animationOptions);
|
|
296
|
+
|
|
297
|
+
// +1px for imprecision tolerance
|
|
298
|
+
// Only call onClose if disableDismiss is false or if we're actually closing
|
|
299
|
+
if (yTo + 1 >= sheetHeight && !disableDismiss) {
|
|
300
|
+
onClose();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Reset indicator rotation after dragging
|
|
304
|
+
indicatorRotation.set(0);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
useImperativeHandle(ref, () => ({
|
|
308
|
+
y,
|
|
309
|
+
yInverted,
|
|
310
|
+
height: sheetHeight,
|
|
311
|
+
snapTo,
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
useModalEffect({
|
|
315
|
+
y,
|
|
316
|
+
detent,
|
|
317
|
+
sheetHeight,
|
|
318
|
+
snapPoints,
|
|
319
|
+
rootId: modalEffectRootId,
|
|
320
|
+
startThreshold: modalEffectThreshold,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Motion should handle body scroll locking but it's not working properly on iOS.
|
|
325
|
+
* Scroll locking from React Aria seems to work much better 🤷♂️
|
|
326
|
+
*/
|
|
327
|
+
usePreventScroll({
|
|
328
|
+
isDisabled: disableScrollLocking || !isOpen,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const state = useSheetState({
|
|
332
|
+
isOpen,
|
|
333
|
+
onOpen: async () => {
|
|
334
|
+
onOpenStart?.();
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* This is not very React-y but we need to wait for the sheet
|
|
338
|
+
* but we need to wait for the sheet to be rendered and visible
|
|
339
|
+
* before we can measure it and animate it to the initial snap point.
|
|
340
|
+
*/
|
|
341
|
+
await waitForElement('react-modal-sheet-container');
|
|
342
|
+
|
|
343
|
+
const initialSnapPoint =
|
|
344
|
+
initialSnap !== undefined ? getSnapPoint(initialSnap) : null;
|
|
345
|
+
|
|
346
|
+
const yTo = initialSnapPoint?.snapValueY ?? 0;
|
|
347
|
+
|
|
348
|
+
await animate(y, yTo, animationOptions);
|
|
349
|
+
|
|
350
|
+
if (initialSnap !== undefined) {
|
|
351
|
+
updateSnap(initialSnap);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
onOpenEnd?.();
|
|
355
|
+
},
|
|
356
|
+
onClosing: async () => {
|
|
357
|
+
onCloseStart?.();
|
|
358
|
+
|
|
359
|
+
await animate(y, closedY, animationOptions);
|
|
360
|
+
|
|
361
|
+
onCloseEnd?.();
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const dragProps: SheetContextType['dragProps'] = {
|
|
366
|
+
drag: 'y',
|
|
367
|
+
dragElastic: 0,
|
|
368
|
+
dragMomentum: false,
|
|
369
|
+
dragPropagation: false,
|
|
370
|
+
onDrag,
|
|
371
|
+
onDragStart,
|
|
372
|
+
onDragEnd,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const context: SheetContextType = {
|
|
376
|
+
currentSnap,
|
|
377
|
+
detent,
|
|
378
|
+
disableDrag,
|
|
379
|
+
dragProps,
|
|
380
|
+
indicatorRotation,
|
|
381
|
+
avoidKeyboard,
|
|
382
|
+
prefersReducedMotion,
|
|
383
|
+
sheetBoundsRef,
|
|
384
|
+
sheetRef,
|
|
385
|
+
unstyled,
|
|
386
|
+
y,
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const sheet = (
|
|
390
|
+
<SheetContext.Provider value={context}>
|
|
391
|
+
<motion.div
|
|
392
|
+
{...rest}
|
|
393
|
+
ref={ref}
|
|
394
|
+
data-sheet-state={state}
|
|
395
|
+
className={`react-modal-sheet-root ${className}`}
|
|
396
|
+
style={{
|
|
397
|
+
...applyStyles(styles.root, unstyled),
|
|
398
|
+
zIndex,
|
|
399
|
+
visibility,
|
|
400
|
+
...style,
|
|
401
|
+
}}
|
|
402
|
+
>
|
|
403
|
+
{state !== 'closed' ? children : null}
|
|
404
|
+
</motion.div>
|
|
405
|
+
</SheetContext.Provider>
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
if (IS_SSR) return sheet;
|
|
409
|
+
|
|
410
|
+
return createPortal(sheet, mountPoint ?? document.body);
|
|
411
|
+
}
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
Sheet.displayName = 'Sheet';
|
package/src/snap.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import type { SheetSnapPoint } from './types';
|
|
2
|
+
import { isAscendingOrder } from './utils';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Convert negative / percentage snap points to absolute values
|
|
6
|
+
*
|
|
7
|
+
* Example output:
|
|
8
|
+
*
|
|
9
|
+
* ```json
|
|
10
|
+
* [
|
|
11
|
+
* {
|
|
12
|
+
* "snapIndex": 0, // <-- bottom snap point
|
|
13
|
+
* "snapValue": 0,
|
|
14
|
+
* "snapValueY": 810
|
|
15
|
+
* },
|
|
16
|
+
* {
|
|
17
|
+
* "snapIndex": 1,
|
|
18
|
+
* "snapValue": 170,
|
|
19
|
+
* "snapValueY": 640
|
|
20
|
+
* },
|
|
21
|
+
* {
|
|
22
|
+
* "snapIndex": 2,
|
|
23
|
+
* "snapValue": 405,
|
|
24
|
+
* "snapValueY": 405
|
|
25
|
+
* },
|
|
26
|
+
* {
|
|
27
|
+
* "snapIndex": 3,
|
|
28
|
+
* "snapValue": 760,
|
|
29
|
+
* "snapValueY": 50
|
|
30
|
+
* },
|
|
31
|
+
* {
|
|
32
|
+
* "snapIndex": 4, // <-- top snap point
|
|
33
|
+
* "snapValue": 810,
|
|
34
|
+
* "snapValueY": 0
|
|
35
|
+
* }
|
|
36
|
+
* ]
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export function computeSnapPoints({
|
|
40
|
+
snapPointsProp,
|
|
41
|
+
sheetHeight,
|
|
42
|
+
}: {
|
|
43
|
+
snapPointsProp: number[];
|
|
44
|
+
sheetHeight: number;
|
|
45
|
+
}): SheetSnapPoint[] {
|
|
46
|
+
if (snapPointsProp[0] !== 0) {
|
|
47
|
+
console.error(
|
|
48
|
+
'First snap point should be 0 to ensure the sheet can be fully closed. ' +
|
|
49
|
+
`Got: [${snapPointsProp.join(', ')}]`
|
|
50
|
+
);
|
|
51
|
+
snapPointsProp.unshift(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (snapPointsProp[snapPointsProp.length - 1] !== 1) {
|
|
55
|
+
console.error(
|
|
56
|
+
'Last snap point should be 1 to ensure the sheet can be fully opened. ' +
|
|
57
|
+
`Got: [${snapPointsProp.join(', ')}]`
|
|
58
|
+
);
|
|
59
|
+
snapPointsProp.push(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (sheetHeight <= 0) {
|
|
63
|
+
console.error(
|
|
64
|
+
`Sheet height is ${sheetHeight}, cannot compute snap points. ` +
|
|
65
|
+
'Make sure the sheet is mounted and has a valid height.'
|
|
66
|
+
);
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const snapPointValues = snapPointsProp.map((point) => {
|
|
71
|
+
// Percentage values e.g. between 0.0 and 1.0
|
|
72
|
+
if (point > 0 && point <= 1) {
|
|
73
|
+
return Math.round(point * sheetHeight);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return point < 0 ? sheetHeight + point : point; // negative values
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
console.assert(
|
|
80
|
+
isAscendingOrder(snapPointValues),
|
|
81
|
+
`Snap points need to be in ascending order got: [${snapPointsProp.join(', ')}]`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Make sure all snap points are within the sheet height
|
|
85
|
+
snapPointValues.forEach((snap) => {
|
|
86
|
+
if (snap < 0 || snap > sheetHeight) {
|
|
87
|
+
console.warn(
|
|
88
|
+
`Snap point ${snap} is outside of the sheet height ${sheetHeight}. ` +
|
|
89
|
+
'This can cause unexpected behavior. Consider adjusting your snap points.'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!snapPointValues.includes(sheetHeight)) {
|
|
95
|
+
console.warn(
|
|
96
|
+
'Snap points do not include the sheet height.' +
|
|
97
|
+
'Please include `1` as the last snap point or it will be included automatically.' +
|
|
98
|
+
'This is to ensure the sheet can be fully opened.'
|
|
99
|
+
);
|
|
100
|
+
snapPointValues.push(sheetHeight);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return snapPointValues.map((snap, index) => ({
|
|
104
|
+
snapIndex: index,
|
|
105
|
+
snapValue: snap, // Absolute value from the bottom of the sheet
|
|
106
|
+
snapValueY: sheetHeight - snap, // Y value is inverted as `y = 0` means sheet is at the top
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function findClosestSnapPoint({
|
|
111
|
+
snapPoints,
|
|
112
|
+
currentY,
|
|
113
|
+
}: {
|
|
114
|
+
snapPoints: SheetSnapPoint[];
|
|
115
|
+
currentY: number;
|
|
116
|
+
}) {
|
|
117
|
+
return snapPoints.reduce((closest, snap) =>
|
|
118
|
+
Math.abs(snap.snapValueY - currentY) <
|
|
119
|
+
Math.abs(closest.snapValueY - currentY)
|
|
120
|
+
? snap
|
|
121
|
+
: closest
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findNextSnapPointInDirection({
|
|
126
|
+
y,
|
|
127
|
+
snapPoints,
|
|
128
|
+
dragDirection,
|
|
129
|
+
}: {
|
|
130
|
+
y: number;
|
|
131
|
+
snapPoints: SheetSnapPoint[];
|
|
132
|
+
dragDirection: 'up' | 'down';
|
|
133
|
+
}) {
|
|
134
|
+
// NOTE: lower Y means higher in the sheet position!
|
|
135
|
+
if (dragDirection === 'down') {
|
|
136
|
+
/**
|
|
137
|
+
* Example:
|
|
138
|
+
*
|
|
139
|
+
* [
|
|
140
|
+
* { snapIndex: 0, snapValueY: 810 },
|
|
141
|
+
* { snapIndex: 1, snapValueY: 640 },
|
|
142
|
+
* { snapIndex: 2, snapValueY: 405 }, <-- next down
|
|
143
|
+
* ------------- Y = 60 ------------
|
|
144
|
+
* { snapIndex: 3, snapValueY: 50 },
|
|
145
|
+
* { snapIndex: 4, snapValueY: 0 },
|
|
146
|
+
* ]
|
|
147
|
+
*/
|
|
148
|
+
return snapPoints
|
|
149
|
+
.slice()
|
|
150
|
+
.reverse()
|
|
151
|
+
.find((s) => s.snapValueY > y);
|
|
152
|
+
} else {
|
|
153
|
+
/**
|
|
154
|
+
* Example:
|
|
155
|
+
* [
|
|
156
|
+
* { snapIndex: 0, snapValueY: 810 },
|
|
157
|
+
* { snapIndex: 1, snapValueY: 640 },
|
|
158
|
+
* { snapIndex: 2, snapValueY: 405 },
|
|
159
|
+
* ------------- Y = 60 ------------
|
|
160
|
+
* { snapIndex: 3, snapValueY: 50 }, <-- next up
|
|
161
|
+
* { snapIndex: 4, snapValueY: 0 },
|
|
162
|
+
* ]
|
|
163
|
+
*/
|
|
164
|
+
return snapPoints.find((s) => s.snapValueY < y);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function handleHighVelocityDrag({
|
|
168
|
+
dragDirection,
|
|
169
|
+
snapPoints,
|
|
170
|
+
}: {
|
|
171
|
+
dragDirection: 'up' | 'down';
|
|
172
|
+
snapPoints: SheetSnapPoint[];
|
|
173
|
+
}) {
|
|
174
|
+
// Go to either the last or the first snap point depending on the direction
|
|
175
|
+
const bottomSnapPoint = snapPoints[0];
|
|
176
|
+
const topSnapPoint = snapPoints[snapPoints.length - 1];
|
|
177
|
+
|
|
178
|
+
if (dragDirection === 'down') {
|
|
179
|
+
return {
|
|
180
|
+
yTo: bottomSnapPoint.snapValueY,
|
|
181
|
+
snapIndex: bottomSnapPoint.snapIndex,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
yTo: topSnapPoint.snapValueY,
|
|
186
|
+
snapIndex: topSnapPoint.snapIndex,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function handleLowVelocityDrag({
|
|
191
|
+
currentSnapPoint,
|
|
192
|
+
currentY,
|
|
193
|
+
dragDirection,
|
|
194
|
+
snapPoints,
|
|
195
|
+
velocity,
|
|
196
|
+
}: {
|
|
197
|
+
currentSnapPoint: SheetSnapPoint;
|
|
198
|
+
currentY: number;
|
|
199
|
+
dragDirection: 'up' | 'down';
|
|
200
|
+
snapPoints: SheetSnapPoint[];
|
|
201
|
+
velocity: number;
|
|
202
|
+
}) {
|
|
203
|
+
const closestSnapRelativeToCurrentY = findClosestSnapPoint({
|
|
204
|
+
snapPoints,
|
|
205
|
+
currentY,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* If velocity is very low the user has stopped the sheet to a specific
|
|
210
|
+
* position and we should snap to the closest snap point as there is no
|
|
211
|
+
* "momentum" that would push the sheet further to the given direction
|
|
212
|
+
*/
|
|
213
|
+
if (Math.abs(velocity) < 20) {
|
|
214
|
+
return {
|
|
215
|
+
yTo: closestSnapRelativeToCurrentY.snapValueY,
|
|
216
|
+
snapIndex: closestSnapRelativeToCurrentY.snapIndex,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* If the dragging has a bit more velocity, we instead want to go to
|
|
222
|
+
* the next snap point in the given direction if it exists
|
|
223
|
+
*/
|
|
224
|
+
const nextSnapInDirectionRelativeToCurrentY = findNextSnapPointInDirection({
|
|
225
|
+
y: currentY,
|
|
226
|
+
snapPoints,
|
|
227
|
+
dragDirection,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (nextSnapInDirectionRelativeToCurrentY) {
|
|
231
|
+
return {
|
|
232
|
+
yTo: nextSnapInDirectionRelativeToCurrentY.snapValueY,
|
|
233
|
+
snapIndex: nextSnapInDirectionRelativeToCurrentY.snapIndex,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// No snap point down, stay at current
|
|
238
|
+
return {
|
|
239
|
+
yTo: currentSnapPoint.snapValueY,
|
|
240
|
+
snapIndex: currentSnapPoint.snapIndex,
|
|
241
|
+
};
|
|
242
|
+
}
|