@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/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
+ }