react-modal-sheet 4.4.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sheet.tsx CHANGED
@@ -1,82 +1,94 @@
1
- import { createPortal } from 'react-dom';
2
-
3
- import React, {
4
- Children,
5
- cloneElement,
6
- forwardRef,
7
- useEffect,
8
- useImperativeHandle,
9
- useMemo,
10
- useRef,
11
- } from 'react';
12
-
13
1
  import {
2
+ type DragHandler,
3
+ type Transition,
14
4
  animate,
15
- AnimatePresence,
16
5
  motion,
17
- type PanInfo,
18
- type Transition,
19
6
  useMotionValue,
20
7
  useReducedMotion,
21
8
  useTransform,
22
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';
23
18
 
24
19
  import {
25
- REDUCED_MOTION_TWEEN_CONFIG,
26
- DEFAULT_TWEEN_CONFIG,
27
20
  DEFAULT_DRAG_CLOSE_THRESHOLD,
28
21
  DEFAULT_DRAG_VELOCITY_THRESHOLD,
22
+ DEFAULT_TWEEN_CONFIG,
29
23
  IS_SSR,
24
+ REDUCED_MOTION_TWEEN_CONFIG,
30
25
  } from './constants';
31
-
32
- import {
33
- getSheetHeight,
34
- getSnapY,
35
- getSnapPoints,
36
- getClosestSnapPoint,
37
- } from './utils';
38
-
39
- import { type SheetContextType, type SheetProps } from './types';
40
- import { SheetScrollerContextProvider, SheetContext } from './context';
41
- import { styles } from './styles';
42
- import { useModalEffect } from './hooks/use-modal-effect';
26
+ import { SheetContext } from './context';
43
27
  import { useDimensions } from './hooks/use-dimensions';
44
- import { useIsomorphicLayoutEffect } from './hooks/use-isomorphic-layout-effect';
45
- import { useStableCallback } from './hooks/use-stable-callback';
28
+ import { useModalEffect } from './hooks/use-modal-effect';
46
29
  import { usePreventScroll } from './hooks/use-prevent-scroll';
30
+ import { useSheetState } from './hooks/use-sheet-state';
31
+ import { useStableCallback } from './hooks/use-stable-callback';
32
+ import { useVirtualKeyboard } from './hooks/use-virtual-keyboard';
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 } from './utils';
47
41
 
48
42
  export const Sheet = forwardRef<any, SheetProps>(
49
43
  (
50
44
  {
51
- onOpenStart,
52
- onOpenEnd,
53
- onClose,
54
- onCloseStart,
55
- onCloseEnd,
56
- onSnap,
45
+ avoidKeyboard = true,
57
46
  children,
47
+ className = '',
48
+ detent = 'default',
49
+ disableDismiss = false,
50
+ disableDrag: disableDragProp = false,
58
51
  disableScrollLocking = false,
52
+ dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
53
+ dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
54
+ initialSnap,
59
55
  isOpen,
60
- snapPoints: snapPointsProp,
61
- rootId,
62
56
  modalEffectRootId,
57
+ modalEffectThreshold,
63
58
  mountPoint,
64
- style,
65
- detent = 'full-height',
66
- initialSnap = 0,
67
- disableDrag = false,
68
59
  prefersReducedMotion = false,
60
+ snapPoints: snapPointsProp,
61
+ style,
69
62
  tweenConfig = DEFAULT_TWEEN_CONFIG,
70
- dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD,
71
- dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD,
72
- modalEffectThreshold,
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
73
  ...rest
74
74
  },
75
75
  ref
76
76
  ) => {
77
+ const [sheetBoundsRef, sheetBounds] = useMeasure();
77
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));
78
90
  const indicatorRotation = useMotionValue(0);
79
- const { height: windowHeight } = useDimensions();
91
+
80
92
  const shouldReduceMotion = useReducedMotion();
81
93
  const reduceMotion = Boolean(prefersReducedMotion || shouldReduceMotion);
82
94
  const animationOptions: Transition = {
@@ -84,66 +96,67 @@ export const Sheet = forwardRef<any, SheetProps>(
84
96
  ...(reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig),
85
97
  };
86
98
 
87
- // NOTE: the inital value for `y` doesn't matter since it is overwritten by
88
- // the value driven by the `AnimatePresence` component when the sheet is opened
89
- // and after that it is driven by the gestures and/or snapping
90
- const y = useMotionValue(windowHeight);
99
+ const keyboard = useVirtualKeyboard({
100
+ isEnabled: isOpen && avoidKeyboard,
101
+ containerRef: sheetRef,
102
+ });
103
+
104
+ // Disable drag if the keyboard is open to avoid weird behavior
105
+ const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
91
106
 
92
107
  // +2 for tolerance in case the animated value is slightly off
93
- const zIndex = useTransform(y, (value) =>
94
- value + 2 >= windowHeight ? -1 : (style?.zIndex ?? 9999)
108
+ const zIndex = useTransform(y, (val) =>
109
+ val + 2 >= closedY ? -1 : (style?.zIndex ?? 9999)
95
110
  );
96
- const visibility = useTransform(y, (value) =>
97
- value + 2 >= windowHeight ? 'hidden' : 'visible'
111
+ const visibility = useTransform(y, (val) =>
112
+ val + 2 >= closedY ? 'hidden' : 'visible'
98
113
  );
99
114
 
100
- // Keep the callback fns up-to-date so that they can be accessed inside
101
- // the effect without including them to the dependencies array
102
- const callbacks = useRef({
103
- onOpenStart,
104
- onOpenEnd,
105
- onCloseStart,
106
- onCloseEnd,
115
+ const updateSnap = useStableCallback((snapIndex: number) => {
116
+ setCurrentSnap(snapIndex);
117
+ onSnap?.(snapIndex);
107
118
  });
108
119
 
109
- useIsomorphicLayoutEffect(() => {
110
- callbacks.current = {
111
- onOpenStart,
112
- onOpenEnd,
113
- onCloseStart,
114
- onCloseEnd,
115
- };
120
+ const getSnapPoint = useStableCallback((snapIndex: number) => {
121
+ if (snapPointsProp && snapPoints) {
122
+ if (snapIndex < 0 || snapIndex >= snapPoints.length) {
123
+ console.warn(
124
+ `Invalid snap index ${snapIndex}. Snap points are: [${snapPointsProp.join(', ')}] and their computed values are: [${snapPoints
125
+ .map((point) => point.snapValue)
126
+ .join(', ')}]`
127
+ );
128
+ return null;
129
+ }
130
+ return snapPoints[snapIndex];
131
+ }
132
+ return null;
116
133
  });
117
134
 
118
- const onDrag = useStableCallback((_: PointerEvent, info: PanInfo) => {
119
- const currentY = y.get();
120
-
121
- /**
122
- * Make sure the user cannot drag the sheet beyond the upmost snap point
123
- * if the `snapPoints` prop is provided.
124
- */
125
- if (snapPointsProp) {
126
- const sheetHeight = getSheetHeight(sheetRef);
127
- const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
128
- const topSnapPoint = sheetHeight - snapPoints[0];
129
-
130
- if (info.delta.y < 0 && currentY <= topSnapPoint) {
131
- y.set(topSnapPoint);
132
- return;
133
- }
135
+ const snapTo = useStableCallback(async (snapIndex: number) => {
136
+ if (!snapPointsProp) {
137
+ console.warn('Snapping is not possible without `snapPoints` prop.');
138
+ return;
134
139
  }
135
140
 
136
- // Update drag indicator rotation based on drag velocity
137
- const velocity = y.getVelocity();
141
+ const snapPoint = getSnapPoint(snapIndex);
138
142
 
139
- if (velocity > 0) indicatorRotation.set(10);
140
- if (velocity < 0) indicatorRotation.set(-10);
143
+ if (snapPoint === null) {
144
+ console.warn(`Invalid snap index ${snapIndex}.`);
145
+ return;
146
+ }
141
147
 
142
- // Make sure user cannot drag beyond the top of the sheet
143
- y.set(Math.max(currentY + info.delta.y, 0));
148
+ if (snapIndex === 0) {
149
+ onClose();
150
+ return;
151
+ }
152
+
153
+ await animate(y, snapPoint.snapValueY, {
154
+ ...animationOptions,
155
+ onComplete: () => updateSnap(snapIndex),
156
+ });
144
157
  });
145
158
 
146
- const onDragStart = useStableCallback(() => {
159
+ const blurActiveInput = useStableCallback(() => {
147
160
  // Find focused input inside the sheet and blur it when dragging starts
148
161
  // to prevent a weird ghost caret "bug" on mobile
149
162
  const focusedElement = document.activeElement as HTMLElement | null;
@@ -159,148 +172,175 @@ export const Sheet = forwardRef<any, SheetProps>(
159
172
  }
160
173
  });
161
174
 
162
- const onDragEnd = useStableCallback((_, { velocity }: PanInfo) => {
163
- if (velocity.y > dragVelocityThreshold) {
164
- // User flicked the sheet down
165
- onClose();
166
- } else {
167
- const sheetHeight = getSheetHeight(sheetRef);
168
- const currentY = y.get();
175
+ const onDrag = useStableCallback<DragHandler>((event, info) => {
176
+ onDragProp?.(event, info);
169
177
 
170
- let yTo = 0;
178
+ const currentY = y.get();
171
179
 
172
- const snapPoints = snapPointsProp
173
- ? getSnapPoints({ snapPointsProp, sheetHeight })
174
- : undefined;
180
+ // Update drag indicator rotation based on drag velocity
181
+ const velocity = y.getVelocity();
182
+ if (velocity > 0) indicatorRotation.set(10);
183
+ if (velocity < 0) indicatorRotation.set(-10);
175
184
 
176
- if (snapPoints) {
177
- const { snapY, snapIndex } = getClosestSnapPoint({
178
- snapPoints,
179
- currentY,
180
- sheetHeight,
181
- detent,
182
- });
185
+ // Make sure user cannot drag beyond the top of the sheet
186
+ y.set(Math.max(currentY + info.delta.y, 0));
187
+ });
183
188
 
184
- yTo = snapY;
189
+ const onDragStart = useStableCallback<DragHandler>((event, info) => {
190
+ blurActiveInput();
191
+ onDragStartProp?.(event, info);
192
+ });
185
193
 
186
- // This happens before calling `animate` but it doesn't really matter
187
- onSnap?.(snapIndex);
188
- } else if (currentY / sheetHeight > dragCloseThreshold) {
189
- // Close if dragged over enough far
190
- yTo = sheetHeight;
191
- }
194
+ const onDragEnd = useStableCallback<DragHandler>((event, info) => {
195
+ blurActiveInput();
196
+ onDragEndProp?.(event, info);
192
197
 
193
- // Update the spring value so that the sheet is animated to the snap point
194
- animate(y, yTo, animationOptions);
198
+ const currentY = y.get();
195
199
 
196
- // +1px for imprecision tolerance
197
- if (yTo + 1 >= sheetHeight) {
198
- onClose();
199
- }
200
- }
200
+ let yTo = 0;
201
201
 
202
- // Reset indicator rotation after dragging
203
- indicatorRotation.set(0);
204
- });
202
+ const currentSnapPoint =
203
+ currentSnap !== undefined ? getSnapPoint(currentSnap) : null;
205
204
 
206
- // Trigger onSnap callback when sheet is opened or closed
207
- useEffect(() => {
208
- if (snapPointsProp && onSnap) {
209
- onSnap(isOpen ? initialSnap : snapPointsProp.length - 1);
210
- }
211
- }, [isOpen]);
205
+ if (currentSnapPoint) {
206
+ const dragOffsetDirection = info.offset.y > 0 ? 'down' : 'up';
207
+ const dragVelocityDirection = info.velocity.y > 0 ? 'down' : 'up';
208
+ const isHighVelocity =
209
+ Math.abs(info.velocity.y) > dragVelocityThreshold;
212
210
 
213
- // Animate the sheet open
214
- useEffect(() => {
215
- if (!isOpen) return; // let `AnimatePresence` handle the exit animation
211
+ let result: { yTo: number; snapIndex: number | undefined };
216
212
 
217
- let yTo = 0;
213
+ if (isHighVelocity) {
214
+ result = handleHighVelocityDrag({
215
+ snapPoints,
216
+ dragDirection: dragVelocityDirection,
217
+ });
218
+ } else {
219
+ result = handleLowVelocityDrag({
220
+ currentSnapPoint,
221
+ currentY,
222
+ dragDirection: dragOffsetDirection,
223
+ snapPoints,
224
+ velocity: info.velocity.y,
225
+ });
226
+ }
218
227
 
219
- if (snapPointsProp) {
220
- const snapIndex = initialSnap;
221
- const sheetHeight = getSheetHeight(sheetRef);
222
- const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
223
- const snapY = getSnapY({ sheetHeight, snapPoints, snapIndex });
228
+ yTo = result.yTo;
224
229
 
225
- if (snapY !== null) {
226
- yTo = snapY;
230
+ // If disableDismiss is true, prevent closing via gesture
231
+ if (disableDismiss && yTo + 1 >= sheetHeight) {
232
+ // Use the bottom-most open snap point
233
+ const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
234
+
235
+ if (bottomSnapPoint) {
236
+ yTo = bottomSnapPoint.snapValueY;
237
+ updateSnap(bottomSnapPoint.snapIndex);
238
+ } else {
239
+ // If no open snap points available, stay at current position
240
+ yTo = currentY;
241
+ }
242
+ } else if (result.snapIndex !== undefined) {
243
+ updateSnap(result.snapIndex);
244
+ }
245
+ } else if (
246
+ info.velocity.y > dragVelocityThreshold ||
247
+ currentY > sheetHeight * dragCloseThreshold
248
+ ) {
249
+ // Close the sheet if dragged past the threshold or if the velocity is high enough
250
+ // But only if disableDismiss is false
251
+ if (disableDismiss) {
252
+ // If disableDismiss, snap back to the open position
253
+ yTo = 0;
254
+ } else {
255
+ yTo = closedY;
227
256
  }
228
257
  }
229
258
 
259
+ // Update the spring value so that the sheet is animated to the snap point
230
260
  animate(y, yTo, animationOptions);
231
- }, [isOpen]);
232
-
233
- useImperativeHandle(ref, () => ({
234
- y,
235
- snapTo: (snapIndex: number) => {
236
- if (!snapPointsProp) {
237
- console.warn('Snapping is not possible without `snapPoints` prop.');
238
- return;
239
- }
240
261
 
241
- const sheetHeight = getSheetHeight(sheetRef);
242
- const snapPoints = getSnapPoints({ snapPointsProp, sheetHeight });
243
- const snapY = getSnapY({ sheetHeight, snapPoints, snapIndex });
262
+ // +1px for imprecision tolerance
263
+ // Only call onClose if disableDismiss is false or if we're actually closing
264
+ if (yTo + 1 >= sheetHeight && !disableDismiss) {
265
+ onClose();
266
+ }
244
267
 
245
- if (snapY !== null) {
246
- animate(y, snapY, animationOptions);
247
- onSnap?.(snapIndex);
268
+ // Reset indicator rotation after dragging
269
+ indicatorRotation.set(0);
270
+ });
248
271
 
249
- // +1px for imprecision tolerance
250
- if (snapY + 1 >= sheetHeight) {
251
- onClose();
252
- }
253
- }
254
- },
272
+ useImperativeHandle(ref, () => ({
273
+ y,
274
+ yInverted,
275
+ height: sheetHeight,
276
+ snapTo,
255
277
  }));
256
278
 
257
- if (rootId) {
258
- console.warn(
259
- 'The `rootId` prop is deprecated and will be removed in the next major version. Use `modalEffectRootId` instead.'
260
- );
261
- }
262
-
263
279
  useModalEffect({
264
280
  y,
265
- sheetRef,
266
- snapPointsProp,
267
- rootId: rootId || modalEffectRootId,
281
+ detent,
282
+ sheetHeight,
283
+ snapPoints,
284
+ rootId: modalEffectRootId,
268
285
  startThreshold: modalEffectThreshold,
269
286
  });
270
287
 
271
- // Motion should handle body scroll locking but it's not working
272
- // properly on iOS. Scroll locking from React Aria seems to work much better.
288
+ /**
289
+ * Motion should handle body scroll locking but it's not working properly on iOS.
290
+ * Scroll locking from React Aria seems to work much better 🤷‍♂️
291
+ */
273
292
  usePreventScroll({
274
293
  isDisabled: disableScrollLocking || !isOpen,
275
294
  });
276
295
 
277
- const dragProps = useMemo(() => {
278
- const dragProps: SheetContextType['dragProps'] = {
279
- drag: 'y',
280
- dragElastic: 0,
281
- dragMomentum: false,
282
- dragPropagation: false,
283
- onDrag,
284
- onDragStart,
285
- onDragEnd,
286
- };
296
+ const state = useSheetState({
297
+ isOpen,
298
+ onOpen: async () => {
299
+ onOpenStart?.();
287
300
 
288
- return disableDrag ? undefined : dragProps;
289
- }, [disableDrag, windowHeight]);
301
+ const initialSnapPoint =
302
+ initialSnap !== undefined ? getSnapPoint(initialSnap) : null;
303
+
304
+ const yTo = initialSnapPoint?.snapValueY ?? 0;
305
+
306
+ await animate(y, yTo, animationOptions);
307
+
308
+ if (initialSnap !== undefined) {
309
+ updateSnap(initialSnap);
310
+ }
311
+
312
+ onOpenEnd?.();
313
+ },
314
+ onClosing: async () => {
315
+ onCloseStart?.();
316
+
317
+ await animate(y, closedY, animationOptions);
318
+
319
+ onCloseEnd?.();
320
+ },
321
+ });
322
+
323
+ const dragProps: SheetContextType['dragProps'] = {
324
+ drag: 'y',
325
+ dragElastic: 0,
326
+ dragMomentum: false,
327
+ dragPropagation: false,
328
+ onDrag,
329
+ onDragStart,
330
+ onDragEnd,
331
+ };
290
332
 
291
333
  const context: SheetContextType = {
292
- y,
293
- sheetRef,
294
- isOpen,
295
- initialSnap,
334
+ currentSnap,
296
335
  detent,
297
- indicatorRotation,
298
- callbacks,
299
- dragProps,
300
- windowHeight,
301
- animationOptions,
302
- reduceMotion,
303
336
  disableDrag,
337
+ dragProps,
338
+ indicatorRotation,
339
+ avoidKeyboard,
340
+ sheetBoundsRef,
341
+ sheetRef,
342
+ unstyled,
343
+ y,
304
344
  };
305
345
 
306
346
  const sheet = (
@@ -308,18 +348,16 @@ export const Sheet = forwardRef<any, SheetProps>(
308
348
  <motion.div
309
349
  {...rest}
310
350
  ref={ref}
311
- style={{ ...styles.wrapper, zIndex, visibility, ...style }}
351
+ data-sheet-state={state}
352
+ className={`react-modal-sheet-root ${className}`}
353
+ style={{
354
+ ...applyStyles(styles.root, unstyled),
355
+ zIndex,
356
+ visibility,
357
+ ...style,
358
+ }}
312
359
  >
313
- <AnimatePresence>
314
- {/* NOTE: AnimatePresence requires us to set keys to children */}
315
- {isOpen ? (
316
- <SheetScrollerContextProvider>
317
- {Children.map(children, (child: any, i) =>
318
- cloneElement(child, { key: `sheet-child-${i}` })
319
- )}
320
- </SheetScrollerContextProvider>
321
- ) : null}
322
- </AnimatePresence>
360
+ {state !== 'closed' ? children : null}
323
361
  </motion.div>
324
362
  </SheetContext.Provider>
325
363
  );