react-modal-sheet 1.6.3 → 1.8.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
@@ -4,53 +4,93 @@ import { usePreventScroll } from '@react-aria/overlays';
4
4
 
5
5
  import {
6
6
  animate,
7
- useMotionValue,
8
- PanInfo,
9
7
  AnimatePresence,
8
+ AnimationOptions,
9
+ PanInfo,
10
+ useMotionValue,
11
+ useReducedMotion,
10
12
  } from 'framer-motion';
11
13
 
14
+ import {
15
+ useModalEffect,
16
+ useWindowHeight,
17
+ useIsomorphicLayoutEffect,
18
+ useEvent,
19
+ } from './hooks';
20
+
21
+ import {
22
+ DEFAULT_SPRING_CONFIG,
23
+ DRAG_CLOSE_THRESHOLD,
24
+ DRAG_VELOCITY_THRESHOLD,
25
+ IS_SSR,
26
+ } from './constants';
27
+
12
28
  import { SheetContextType, SheetProps } from './types';
13
- import { getClosest, inDescendingOrder, isSSR, useWindowHeight } from './utils';
14
29
  import { SheetContext } from './context';
15
- import { useModalEffect } from './hooks';
30
+ import { getClosest, inDescendingOrder, validateSnapTo } from './utils';
16
31
  import styles from './styles';
17
32
 
18
33
  const Sheet = React.forwardRef<any, SheetProps>(
19
34
  (
20
35
  {
21
- children,
22
- isOpen,
23
- onClose,
24
36
  onOpenStart,
25
37
  onOpenEnd,
38
+ onClose,
26
39
  onCloseStart,
27
40
  onCloseEnd,
28
41
  onSnap,
42
+ children,
43
+ isOpen,
29
44
  snapPoints,
30
- initialSnap = 0,
31
45
  rootId,
32
46
  mountPoint,
33
- springConfig = { stiffness: 300, damping: 30, mass: 0.2 },
34
- disableDrag = false,
35
47
  style,
48
+ detent = 'full-height',
49
+ initialSnap = 0,
50
+ springConfig = DEFAULT_SPRING_CONFIG,
51
+ disableDrag = false,
52
+ prefersReducedMotion = false,
36
53
  ...rest
37
54
  },
38
55
  ref
39
56
  ) => {
40
57
  const sheetRef = React.useRef<any>(null);
41
- const callbacks = React.useRef({ onOpenStart, onOpenEnd, onCloseStart, onCloseEnd }); // prettier-ignore
42
58
  const indicatorRotation = useMotionValue(0);
43
59
  const windowHeight = useWindowHeight();
60
+ const shouldReduceMotion = useReducedMotion();
61
+ const reduceMotion = Boolean(prefersReducedMotion || shouldReduceMotion);
62
+ const animationOptions: AnimationOptions<number> = reduceMotion
63
+ ? { type: 'tween', duration: 0.01 }
64
+ : { type: 'spring', ...springConfig };
44
65
 
45
66
  // NOTE: the inital value for `y` doesn't matter since it is overwritten by
46
67
  // the value driven by the `AnimatePresence` component when the sheet is opened
47
68
  // and after that it is driven by the gestures and/or snapping
48
69
  const y = useMotionValue(0);
49
70
 
71
+ // Keep the callback fns up-to-date so that they can be accessed inside
72
+ // the effect without including them to the dependencies array
73
+ const callbacks = React.useRef({
74
+ onOpenStart,
75
+ onOpenEnd,
76
+ onCloseStart,
77
+ onCloseEnd,
78
+ });
79
+
80
+ useIsomorphicLayoutEffect(() => {
81
+ callbacks.current = {
82
+ onOpenStart,
83
+ onOpenEnd,
84
+ onCloseStart,
85
+ onCloseEnd,
86
+ };
87
+ });
88
+
50
89
  if (snapPoints) {
51
90
  // Convert negative / percentage snap points to absolute values
52
91
  snapPoints = snapPoints.map(point => {
53
- if (point > 0 && point <= 1) return Math.round(point * windowHeight); // percentage values e.g. between 0.0 and 1.0
92
+ // Percentage values e.g. between 0.0 and 1.0
93
+ if (point > 0 && point <= 1) return Math.round(point * windowHeight);
54
94
  return point < 0 ? windowHeight + point : point; // negative values
55
95
  });
56
96
 
@@ -60,54 +100,61 @@ const Sheet = React.forwardRef<any, SheetProps>(
60
100
  );
61
101
  }
62
102
 
63
- const handleDrag = React.useCallback((_, { delta }: PanInfo) => {
103
+ const onDrag = useEvent((_, { delta }: PanInfo) => {
64
104
  // Update drag indicator rotation based on drag velocity
65
105
  const velocity = y.getVelocity();
106
+
66
107
  if (velocity > 0) indicatorRotation.set(10);
67
108
  if (velocity < 0) indicatorRotation.set(-10);
68
109
 
69
110
  // Make sure user cannot drag beyond the top of the sheet
70
111
  y.set(Math.max(y.get() + delta.y, 0));
71
- }, []); // eslint-disable-line
72
-
73
- const handleDragEnd = React.useCallback(
74
- (_, { velocity }: PanInfo) => {
75
- if (velocity.y > 500) {
76
- // User flicked the sheet down
77
- onClose();
78
- } else {
79
- const sheetEl = sheetRef.current as HTMLDivElement;
80
- const contentHeight = sheetEl.getBoundingClientRect().height;
81
- const snapTo = Math.round(
82
- snapPoints
83
- ? getClosest(snapPoints.map(p => contentHeight - p), y.get()) // prettier-ignore
84
- : y.get() / contentHeight > 0.6 // Close if dragged over 60%
85
- ? contentHeight
86
- : 0
87
- );
88
-
89
- // Update the spring value so that the sheet is animated to the snap point
90
- animate(y, snapTo, { type: 'spring', ...springConfig });
91
-
92
- if (snapPoints && onSnap) {
93
- const snapValue = Math.abs(Math.round(snapPoints[0] - snapTo));
94
- const snapIndex = snapPoints.indexOf(getClosest(snapPoints, snapValue)); // prettier-ignore
95
- onSnap(snapIndex);
112
+ });
113
+
114
+ const onDragEnd = useEvent((_, { velocity }: PanInfo) => {
115
+ if (velocity.y > DRAG_VELOCITY_THRESHOLD) {
116
+ // User flicked the sheet down
117
+ onClose();
118
+ } else {
119
+ const sheetEl = sheetRef.current as HTMLDivElement;
120
+ const sheetHeight = sheetEl.getBoundingClientRect().height;
121
+ const currentY = y.get();
122
+
123
+ let snapTo = 0;
124
+
125
+ if (snapPoints) {
126
+ const snapToValues = snapPoints
127
+ .map(p => sheetHeight - p)
128
+ .filter(p => p >= 0); // negative values can occur with `content-height` detent
129
+
130
+ // Allow snapping to the top of the sheet if detent is set to `content-height`
131
+ if (detent === 'content-height' && !snapToValues.includes(0)) {
132
+ snapToValues.unshift(0);
96
133
  }
97
134
 
98
- if (snapTo >= contentHeight) onClose();
135
+ // Get the closest snap point
136
+ snapTo = getClosest(snapToValues, currentY);
137
+ } else if (currentY / sheetHeight > DRAG_CLOSE_THRESHOLD) {
138
+ // Close if dragged over enough far
139
+ snapTo = sheetHeight;
99
140
  }
100
141
 
101
- // Reset indicator rotation after dragging
102
- indicatorRotation.set(0);
103
- },
104
- [onClose, onSnap] // eslint-disable-line
105
- );
142
+ snapTo = validateSnapTo({ snapTo, sheetHeight });
106
143
 
107
- // Keep the callback fns up-to-date so that they can be accessed inside
108
- // the effect without including them to the dependencies array
109
- React.useEffect(() => {
110
- callbacks.current = { onOpenStart, onOpenEnd, onCloseStart, onCloseEnd };
144
+ // Update the spring value so that the sheet is animated to the snap point
145
+ animate(y, snapTo, animationOptions);
146
+
147
+ if (snapPoints && onSnap) {
148
+ const snapValue = Math.abs(Math.round(snapPoints[0] - snapTo));
149
+ const snapIndex = snapPoints.indexOf(getClosest(snapPoints, snapValue)); // prettier-ignore
150
+ onSnap(snapIndex);
151
+ }
152
+
153
+ if (snapTo >= sheetHeight) onClose();
154
+ }
155
+
156
+ // Reset indicator rotation after dragging
157
+ indicatorRotation.set(0);
111
158
  });
112
159
 
113
160
  // Trigger onSnap callback when sheet is opened or closed
@@ -121,16 +168,22 @@ const Sheet = React.forwardRef<any, SheetProps>(
121
168
  y,
122
169
  snapTo: (snapIndex: number) => {
123
170
  const sheetEl = sheetRef.current as HTMLDivElement | null;
171
+
124
172
  if (
125
173
  snapPoints &&
126
174
  snapPoints[snapIndex] !== undefined &&
127
175
  sheetEl !== null
128
176
  ) {
129
- const contentHeight = sheetEl.getBoundingClientRect().height;
130
- const snapTo = contentHeight - snapPoints[snapIndex];
131
- animate(y, snapTo, { type: 'spring', ...springConfig });
177
+ const sheetHeight = sheetEl.getBoundingClientRect().height;
178
+ const snapPoint = snapPoints[snapIndex];
179
+ const snapTo = validateSnapTo({
180
+ snapTo: sheetHeight - snapPoint,
181
+ sheetHeight,
182
+ });
183
+
184
+ animate(y, snapTo, animationOptions);
132
185
  if (onSnap) onSnap(snapIndex);
133
- if (snapTo >= contentHeight) onClose();
186
+ if (snapTo >= sheetHeight) onClose();
134
187
  }
135
188
  },
136
189
  }));
@@ -141,16 +194,18 @@ const Sheet = React.forwardRef<any, SheetProps>(
141
194
  // properly on iOS. Scroll locking from React Aria seems to work much better.
142
195
  usePreventScroll({ isDisabled: !isOpen });
143
196
 
144
- const dragProps = disableDrag
145
- ? ({} as any)
146
- : {
147
- drag: 'y' as const,
148
- dragElastic: 0,
149
- dragConstraints: { top: 0, bottom: 0 },
150
- dragMomentum: false,
151
- onDrag: handleDrag,
152
- onDragEnd: handleDragEnd,
153
- };
197
+ const dragProps = React.useMemo(() => {
198
+ const dragProps: SheetContextType['dragProps'] = {
199
+ drag: 'y',
200
+ dragElastic: 0,
201
+ dragConstraints: { top: 0, bottom: 0 },
202
+ dragMomentum: false,
203
+ onDrag,
204
+ onDragEnd,
205
+ };
206
+
207
+ return disableDrag ? undefined : dragProps;
208
+ }, [disableDrag]); // eslint-disable-line
154
209
 
155
210
  const context: SheetContextType = {
156
211
  y,
@@ -158,22 +213,18 @@ const Sheet = React.forwardRef<any, SheetProps>(
158
213
  isOpen,
159
214
  initialSnap,
160
215
  snapPoints,
216
+ detent,
161
217
  indicatorRotation,
162
218
  callbacks,
163
219
  dragProps,
164
220
  windowHeight,
165
- springConfig,
166
- };
167
-
168
- const wrapperProps = {
169
- ...rest,
170
- ref,
171
- style: { ...styles.wrapper, ...style },
221
+ animationOptions,
222
+ reduceMotion,
172
223
  };
173
224
 
174
225
  const sheet = (
175
226
  <SheetContext.Provider value={context}>
176
- <div {...wrapperProps}>
227
+ <div {...rest} ref={ref} style={{ ...styles.wrapper, ...style }}>
177
228
  <AnimatePresence>
178
229
  {/* NOTE: AnimatePresence requires us to set keys to children */}
179
230
  {isOpen
@@ -186,7 +237,7 @@ const Sheet = React.forwardRef<any, SheetProps>(
186
237
  </SheetContext.Provider>
187
238
  );
188
239
 
189
- if (isSSR) return sheet;
240
+ if (IS_SSR) return sheet;
190
241
 
191
242
  return ReactDOM.createPortal(sheet, mountPoint ?? document.body);
192
243
  }
package/src/types.tsx CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  MotionValue,
4
4
  useSpring,
5
5
  MotionProps,
6
+ AnimationOptions,
6
7
  } from 'framer-motion';
7
8
 
8
9
  export type SheetEvents = {
@@ -13,6 +14,8 @@ export type SheetEvents = {
13
14
  onSnap?: (index: number) => void;
14
15
  };
15
16
 
17
+ export type SheetDetent = 'full-height' | 'content-height';
18
+
16
19
  export type SheetProps = {
17
20
  isOpen: boolean;
18
21
  children: React.ReactNode;
@@ -20,9 +23,11 @@ export type SheetProps = {
20
23
  rootId?: string;
21
24
  mountPoint?: Element;
22
25
  snapPoints?: number[];
26
+ detent?: SheetDetent;
23
27
  initialSnap?: number; // index of snap points array
24
28
  springConfig?: Parameters<typeof useSpring>[1];
25
29
  disableDrag?: boolean;
30
+ prefersReducedMotion?: boolean;
26
31
  } & SheetEvents &
27
32
  React.HTMLAttributes<HTMLDivElement>;
28
33
 
@@ -58,7 +63,6 @@ export type SheetDragProps = {
58
63
  dragConstraints: any;
59
64
  dragMomentum: boolean;
60
65
  onDrag: DragHandlers['onDrag'];
61
- onDragStart: DragHandlers['onDragStart'];
62
66
  onDragEnd: DragHandlers['onDragEnd'];
63
67
  };
64
68
 
@@ -67,12 +71,14 @@ export type SheetContextType = {
67
71
  sheetRef: React.MutableRefObject<any>;
68
72
  isOpen: boolean;
69
73
  snapPoints: SheetProps['snapPoints'];
74
+ detent: SheetDetent;
70
75
  initialSnap: SheetProps['initialSnap'];
71
76
  indicatorRotation: MotionValue<number>;
72
77
  callbacks: React.MutableRefObject<SheetEvents>;
73
- dragProps: SheetDragProps;
78
+ dragProps?: SheetDragProps;
74
79
  windowHeight: number;
75
- springConfig: Parameters<typeof useSpring>[1];
80
+ animationOptions: AnimationOptions<number>;
81
+ reduceMotion: boolean;
76
82
  };
77
83
 
78
84
  type ContainerComponent = React.ForwardRefExoticComponent<
package/src/utils.ts CHANGED
@@ -1,12 +1,8 @@
1
- import { useEffect, useLayoutEffect, useState } from 'react';
2
-
3
- export const isSSR = typeof window === 'undefined';
4
-
5
- export const getClosest = (nums: number[], goal: number) => {
1
+ export function getClosest(nums: number[], goal: number) {
6
2
  return nums.reduce((prev, curr) => {
7
3
  return Math.abs(curr - goal) < Math.abs(prev - goal) ? curr : prev;
8
4
  });
9
- };
5
+ }
10
6
 
11
7
  const highlightId = 'react-modal-sheet-highlight';
12
8
 
@@ -70,25 +66,27 @@ export function cleanupRootStyles(rootId: string) {
70
66
  }
71
67
  }
72
68
 
73
- const useIsomorphicLayoutEffect = isSSR ? useEffect : useLayoutEffect;
74
-
75
- export function useWindowHeight() {
76
- const [windowHeight, setWindowHeight] = useState(0);
77
-
78
- useIsomorphicLayoutEffect(() => {
79
- const updateHeight = () => setWindowHeight(window.innerHeight);
80
- window.addEventListener('resize', updateHeight);
81
- updateHeight();
82
- return () => window.removeEventListener('resize', updateHeight);
83
- }, []);
84
-
85
- return windowHeight;
86
- }
87
-
88
- export const inDescendingOrder = (arr: number[]) => {
69
+ export function inDescendingOrder(arr: number[]) {
89
70
  for (let i = 0; i < arr.length; i++) {
90
71
  if (arr[i + 1] > arr[i]) return false;
91
72
  }
92
73
 
93
74
  return true;
94
- };
75
+ }
76
+
77
+ export function validateSnapTo({
78
+ snapTo,
79
+ sheetHeight,
80
+ }: {
81
+ snapTo: number;
82
+ sheetHeight: number;
83
+ }) {
84
+ if (snapTo < 0) {
85
+ console.warn(
86
+ `Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${sheetHeight +
87
+ Math.abs(snapTo)}.`
88
+ );
89
+ }
90
+
91
+ return Math.max(Math.round(snapTo), 0);
92
+ }