react-modal-sheet 4.3.0 → 5.0.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/snap.ts ADDED
@@ -0,0 +1,226 @@
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 (sheetHeight <= 0) {
47
+ console.warn(
48
+ `Sheet height is ${sheetHeight}, cannot compute snap points. ` +
49
+ 'Make sure the sheet is mounted and has a valid height.'
50
+ );
51
+ return [];
52
+ }
53
+
54
+ const snapPointValues = snapPointsProp.map((point) => {
55
+ // Percentage values e.g. between 0.0 and 1.0
56
+ if (point > 0 && point <= 1) {
57
+ return Math.round(point * sheetHeight);
58
+ }
59
+
60
+ return point < 0 ? sheetHeight + point : point; // negative values
61
+ });
62
+
63
+ console.assert(
64
+ isAscendingOrder(snapPointValues),
65
+ `Snap points need to be in ascending order got: [${snapPointsProp.join(', ')}]`
66
+ );
67
+
68
+ // Make sure all snap points are within the sheet height
69
+ snapPointValues.forEach((snap) => {
70
+ if (snap < 0 || snap > sheetHeight) {
71
+ console.warn(
72
+ `Snap point ${snap} is outside of the sheet height ${sheetHeight}. ` +
73
+ 'This can cause unexpected behavior. Consider adjusting your snap points.'
74
+ );
75
+ }
76
+ });
77
+
78
+ if (!snapPointValues.includes(sheetHeight)) {
79
+ console.warn(
80
+ 'Snap points do not include the sheet height.' +
81
+ 'Please include `1` as the last snap point or it will be included automatically.' +
82
+ 'This is to ensure the sheet can be fully opened.'
83
+ );
84
+ snapPointValues.push(sheetHeight);
85
+ }
86
+
87
+ return snapPointValues.map((snap, index) => ({
88
+ snapIndex: index,
89
+ snapValue: snap, // Absolute value from the bottom of the sheet
90
+ snapValueY: sheetHeight - snap, // Y value is inverted as `y = 0` means sheet is at the top
91
+ }));
92
+ }
93
+
94
+ function findClosestSnapPoint({
95
+ snapPoints,
96
+ currentY,
97
+ }: {
98
+ snapPoints: SheetSnapPoint[];
99
+ currentY: number;
100
+ }) {
101
+ return snapPoints.reduce((closest, snap) =>
102
+ Math.abs(snap.snapValueY - currentY) <
103
+ Math.abs(closest.snapValueY - currentY)
104
+ ? snap
105
+ : closest
106
+ );
107
+ }
108
+
109
+ function findNextSnapPointInDirection({
110
+ y,
111
+ snapPoints,
112
+ dragDirection,
113
+ }: {
114
+ y: number;
115
+ snapPoints: SheetSnapPoint[];
116
+ dragDirection: 'up' | 'down';
117
+ }) {
118
+ // NOTE: lower Y means higher in the sheet position!
119
+ if (dragDirection === 'down') {
120
+ /**
121
+ * Example:
122
+ *
123
+ * [
124
+ * { snapIndex: 0, snapValueY: 810 },
125
+ * { snapIndex: 1, snapValueY: 640 },
126
+ * { snapIndex: 2, snapValueY: 405 }, <-- next down
127
+ * ------------- Y = 60 ------------
128
+ * { snapIndex: 3, snapValueY: 50 },
129
+ * { snapIndex: 4, snapValueY: 0 },
130
+ * ]
131
+ */
132
+ return snapPoints
133
+ .slice()
134
+ .reverse()
135
+ .find((s) => s.snapValueY > y);
136
+ } else {
137
+ /**
138
+ * Example:
139
+ * [
140
+ * { snapIndex: 0, snapValueY: 810 },
141
+ * { snapIndex: 1, snapValueY: 640 },
142
+ * { snapIndex: 2, snapValueY: 405 },
143
+ * ------------- Y = 60 ------------
144
+ * { snapIndex: 3, snapValueY: 50 }, <-- next up
145
+ * { snapIndex: 4, snapValueY: 0 },
146
+ * ]
147
+ */
148
+ return snapPoints.find((s) => s.snapValueY < y);
149
+ }
150
+ }
151
+ export function handleHighVelocityDrag({
152
+ dragDirection,
153
+ snapPoints,
154
+ }: {
155
+ dragDirection: 'up' | 'down';
156
+ snapPoints: SheetSnapPoint[];
157
+ }) {
158
+ // Go to either the last or the first snap point depending on the direction
159
+ const bottomSnapPoint = snapPoints[0];
160
+ const topSnapPoint = snapPoints[snapPoints.length - 1];
161
+
162
+ if (dragDirection === 'down') {
163
+ return {
164
+ yTo: bottomSnapPoint.snapValueY,
165
+ snapIndex: bottomSnapPoint.snapIndex,
166
+ };
167
+ }
168
+ return {
169
+ yTo: topSnapPoint.snapValueY,
170
+ snapIndex: topSnapPoint.snapIndex,
171
+ };
172
+ }
173
+
174
+ export function handleLowVelocityDrag({
175
+ currentSnapPoint,
176
+ currentY,
177
+ dragDirection,
178
+ snapPoints,
179
+ velocity,
180
+ }: {
181
+ currentSnapPoint: SheetSnapPoint;
182
+ currentY: number;
183
+ dragDirection: 'up' | 'down';
184
+ snapPoints: SheetSnapPoint[];
185
+ velocity: number;
186
+ }) {
187
+ const closestSnapRelativeToCurrentY = findClosestSnapPoint({
188
+ snapPoints,
189
+ currentY,
190
+ });
191
+
192
+ /**
193
+ * If velocity is very low the user has stopped the sheet to a specific
194
+ * position and we should snap to the closest snap point as there is no
195
+ * "momentum" that would push the sheet further to the given direction
196
+ */
197
+ if (Math.abs(velocity) < 20) {
198
+ return {
199
+ yTo: closestSnapRelativeToCurrentY.snapValueY,
200
+ snapIndex: closestSnapRelativeToCurrentY.snapIndex,
201
+ };
202
+ }
203
+
204
+ /**
205
+ * If the dragging has a bit more velocity, we instead want to go to
206
+ * the next snap point in the given direction if it exists
207
+ */
208
+ const nextSnapInDirectionRelativeToCurrentY = findNextSnapPointInDirection({
209
+ y: currentY,
210
+ snapPoints,
211
+ dragDirection,
212
+ });
213
+
214
+ if (nextSnapInDirectionRelativeToCurrentY) {
215
+ return {
216
+ yTo: nextSnapInDirectionRelativeToCurrentY.snapValueY,
217
+ snapIndex: nextSnapInDirectionRelativeToCurrentY.snapIndex,
218
+ };
219
+ }
220
+
221
+ // No snap point down, stay at current
222
+ return {
223
+ yTo: currentSnapPoint.snapValueY,
224
+ snapIndex: currentSnapPoint.snapIndex,
225
+ };
226
+ }
package/src/styles.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type Properties } from 'csstype';
2
2
 
3
- export const styles: Record<string, Properties> = {
4
- wrapper: {
3
+ export const styles = {
4
+ root: {
5
5
  position: 'fixed',
6
6
  top: 0,
7
7
  bottom: 0,
@@ -66,4 +66,4 @@ export const styles: Record<string, Properties> = {
66
66
  overflowY: 'auto',
67
67
  overscrollBehaviorY: 'none',
68
68
  },
69
- };
69
+ } satisfies Record<string, Properties>;
package/src/types.tsx CHANGED
@@ -1,61 +1,58 @@
1
1
  import {
2
2
  type ComponentPropsWithoutRef,
3
3
  type ForwardRefExoticComponent,
4
- type HTMLAttributes,
5
- type MutableRefObject,
6
4
  type ReactNode,
7
5
  type RefAttributes,
8
6
  type RefObject,
9
7
  } from 'react';
10
8
 
11
9
  import {
12
- type DragHandlers,
13
- type MotionValue,
14
- type MotionProps,
15
- type motion,
10
+ type DragHandler,
16
11
  type EasingDefinition,
12
+ type MotionProps,
13
+ type MotionValue,
17
14
  type Transition,
15
+ type motion,
18
16
  } from 'motion/react';
17
+ import type { RectReadOnly } from 'react-use-measure';
19
18
 
20
- export interface SheetEvents {
21
- onOpenStart?: () => void;
22
- onOpenEnd?: () => void;
23
- onCloseStart?: () => void;
24
- onCloseEnd?: () => void;
25
- onSnap?: (index: number) => void;
26
- }
27
-
28
- export type SheetDetent = 'full-height' | 'content-height';
19
+ export type SheetDetent = 'default' | 'full' | 'content';
29
20
 
30
21
  type CommonProps = MotionProps & {
31
22
  className?: string;
32
23
  };
33
24
 
25
+ type MotionDivProps = ComponentPropsWithoutRef<typeof motion.div>;
26
+
34
27
  export interface SheetTweenConfig {
35
28
  ease: EasingDefinition;
36
29
  duration: number;
37
30
  }
38
31
 
39
32
  export type SheetProps = {
40
- isOpen: boolean;
33
+ avoidKeyboard?: boolean;
41
34
  children: ReactNode;
42
- onClose: () => void;
43
- /** @deprecated Use `modalEffectRootId` instead! */
44
- rootId?: string;
45
- modalEffectRootId?: string;
46
- mountPoint?: Element;
47
- snapPoints?: number[];
48
35
  detent?: SheetDetent;
49
- initialSnap?: number; // index of snap points array
50
- tweenConfig?: SheetTweenConfig;
36
+ disableDismiss?: boolean;
51
37
  disableDrag?: boolean;
52
38
  disableScrollLocking?: boolean;
53
- prefersReducedMotion?: boolean;
54
- dragVelocityThreshold?: number;
55
39
  dragCloseThreshold?: number;
40
+ dragVelocityThreshold?: number;
41
+ initialSnap?: number; // index of snap points array
42
+ isOpen: boolean;
43
+ modalEffectRootId?: string;
56
44
  modalEffectThreshold?: number;
57
- } & SheetEvents &
58
- ComponentPropsWithoutRef<typeof motion.div>;
45
+ mountPoint?: Element;
46
+ prefersReducedMotion?: boolean;
47
+ snapPoints?: number[];
48
+ tweenConfig?: SheetTweenConfig;
49
+ onClose: () => void;
50
+ onCloseEnd?: () => void;
51
+ onCloseStart?: () => void;
52
+ onOpenEnd?: () => void;
53
+ onOpenStart?: () => void;
54
+ onSnap?: (index: number) => void;
55
+ } & MotionDivProps;
59
56
 
60
57
  export type SheetContainerProps = Omit<
61
58
  CommonProps,
@@ -64,16 +61,7 @@ export type SheetContainerProps = Omit<
64
61
  children: ReactNode;
65
62
  };
66
63
 
67
- export type SheetDraggableProps = Omit<
68
- CommonProps,
69
- | 'drag'
70
- | 'dragElastic'
71
- | 'dragConstraints'
72
- | 'dragMomentum'
73
- | 'onDrag'
74
- | 'onDragStart'
75
- | 'onDragEnd'
76
- > & {
64
+ export type SheetDraggableProps = MotionDivProps & {
77
65
  children?: ReactNode;
78
66
  disableDrag?: boolean;
79
67
  };
@@ -83,34 +71,43 @@ export type SheetBackdropProps = Omit<
83
71
  'initial' | 'animate' | 'exit'
84
72
  >;
85
73
 
86
- export type SheetScrollerProps = HTMLAttributes<HTMLDivElement> & {
87
- draggableAt?: 'top' | 'bottom' | 'both';
88
- disableScroll?: boolean;
89
- };
90
-
91
74
  export interface SheetDragProps {
92
75
  drag: 'y';
93
76
  dragElastic: number;
94
77
  dragMomentum: boolean;
95
78
  dragPropagation: boolean;
96
- onDrag: DragHandlers['onDrag'];
97
- onDragStart: DragHandlers['onDragStart'];
98
- onDragEnd: DragHandlers['onDragEnd'];
79
+ onDrag: DragHandler;
80
+ onDragStart: DragHandler;
81
+ onDragEnd: DragHandler;
99
82
  }
100
83
 
84
+ export type SheetStateInfo = {
85
+ scrollPosition?: 'top' | 'bottom' | 'middle';
86
+ currentSnap?: number;
87
+ };
88
+
89
+ export type SheetContentScrollableProps = MotionDivProps & {
90
+ disableDrag?: boolean | ((args: SheetStateInfo) => boolean);
91
+ disableScroll?: boolean | ((args: SheetStateInfo) => boolean);
92
+ scrollRef?: RefObject<HTMLDivElement | null>;
93
+ };
94
+
95
+ export type SheetSnapPoint = {
96
+ snapIndex: number;
97
+ snapValue: number; // Absolute value from the bottom of the sheet
98
+ snapValueY: number; // Y value is inverted as `y = 0` means sheet is at the top
99
+ };
100
+
101
101
  export interface SheetContextType {
102
- y: MotionValue<any>;
103
- sheetRef: RefObject<any>;
104
- isOpen: boolean;
102
+ currentSnap?: number;
105
103
  detent: SheetDetent;
106
- initialSnap: SheetProps['initialSnap'];
107
- indicatorRotation: MotionValue<number>;
108
- callbacks: RefObject<SheetEvents>;
109
- dragProps?: SheetDragProps;
110
- windowHeight: number;
111
- animationOptions: Transition;
112
- reduceMotion: boolean;
113
104
  disableDrag: boolean;
105
+ dragProps?: SheetDragProps;
106
+ indicatorRotation: MotionValue<number>;
107
+ avoidKeyboard: boolean;
108
+ sheetBoundsRef: (node: HTMLDivElement | null) => void;
109
+ sheetRef: RefObject<any>;
110
+ y: MotionValue<any>;
114
111
  }
115
112
 
116
113
  export interface SheetScrollerContextType {
@@ -135,16 +132,15 @@ type SheetComponent = ForwardRefExoticComponent<
135
132
  SheetProps & RefAttributes<any>
136
133
  >;
137
134
 
138
- type ScrollerComponent = ForwardRefExoticComponent<
139
- SheetScrollerProps & RefAttributes<any>
135
+ type ContentScrollableComponent = ForwardRefExoticComponent<
136
+ SheetContentScrollableProps & RefAttributes<any>
140
137
  >;
141
138
 
142
139
  interface SheetCompoundComponent {
143
140
  Container: ContainerComponent;
144
141
  Header: DraggableComponent;
145
- Content: DraggableComponent;
142
+ Content: ContentScrollableComponent;
146
143
  Backdrop: BackdropComponent;
147
- Scroller: ScrollerComponent;
148
144
  }
149
145
 
150
146
  export type SheetCompound = SheetComponent & SheetCompoundComponent;
package/src/utils.ts CHANGED
@@ -1,161 +1,13 @@
1
1
  import { type ForwardedRef, type RefCallback, type RefObject } from 'react';
2
2
  import { IS_SSR } from './constants';
3
- import type { SheetDetent } from './types';
4
3
 
5
- /**
6
- * Get the rounded height of the sheet element and log a warning if the
7
- * element is not mounted yet but the height is requested.
8
- */
9
- export function getSheetHeight(sheetRef: RefObject<HTMLDivElement | null>) {
10
- const sheetEl = sheetRef.current;
11
-
12
- if (!sheetEl) {
13
- console.warn(
14
- 'Sheet height is not available because the sheet element is not mounted yet.'
15
- );
16
- return 0;
17
- }
18
-
19
- return Math.round(sheetEl.getBoundingClientRect().height);
20
- }
21
-
22
- /**
23
- * Convert negative / percentage snap points to absolute values
24
- */
25
- export function getSnapPoints({
26
- snapPointsProp,
27
- sheetHeight,
28
- }: {
29
- snapPointsProp: number[];
30
- sheetHeight: number;
31
- }) {
32
- const snapPointValues = snapPointsProp.map((point) => {
33
- // Percentage values e.g. between 0.0 and 1.0
34
- if (point > 0 && point <= 1) {
35
- return Math.round(point * sheetHeight);
36
- }
37
-
38
- return point < 0 ? sheetHeight + point : point; // negative values
39
- });
40
-
41
- console.assert(
42
- inDescendingOrder(snapPointValues) || sheetHeight === 0,
43
- `Snap points need to be in descending order got: [${snapPointsProp.join(', ')}]`
44
- );
45
-
46
- return snapPointValues;
47
- }
48
-
49
- export function getClosestSnapPoint({
50
- snapPoints,
51
- currentY,
52
- sheetHeight,
53
- detent,
54
- }: {
55
- snapPoints: number[];
56
- currentY: number;
57
- sheetHeight: number;
58
- detent?: SheetDetent;
59
- }) {
60
- // Inverse values are the values that can be passed to `animate`
61
- const snapInverse = snapPoints.map(
62
- (p) => sheetHeight - Math.min(p, sheetHeight)
63
- );
64
-
65
- // Allow snapping to the top of the sheet if detent is set to `content-height`
66
- if (detent === 'content-height' && !snapInverse.includes(0)) {
67
- snapInverse.unshift(0);
68
- }
69
-
70
- // Get the closest snap point
71
- const snapTo = getClosest(snapInverse, currentY);
72
- const snapIndex = snapInverse.indexOf(snapTo);
73
-
74
- const snapY = validateSnapTo({
75
- snapTo: getClosest(snapInverse, currentY),
76
- sheetHeight,
77
- });
78
-
79
- return { snapY, snapIndex };
80
- }
81
-
82
- /**
83
- * Get the closest number to the goal from the array of numbers.
84
- */
85
- export function getClosest(nums: number[], goal: number) {
86
- let closest = nums[0];
87
- let minDifference = Math.abs(nums[0] - goal);
88
-
89
- for (let i = 1; i < nums.length; i++) {
90
- const difference = Math.abs(nums[i] - goal);
91
- if (difference < minDifference) {
92
- closest = nums[i];
93
- minDifference = difference;
94
- }
95
- }
96
-
97
- return closest;
98
- }
99
-
100
- /**
101
- * Check if the array is in descending order.
102
- * This is used to validate the snap points.
103
- */
104
- export function inDescendingOrder(arr: number[]) {
4
+ export function isAscendingOrder(arr: number[]) {
105
5
  for (let i = 0; i < arr.length; i++) {
106
- if (arr[i + 1] > arr[i]) return false;
6
+ if (arr[i + 1] < arr[i]) return false;
107
7
  }
108
-
109
8
  return true;
110
9
  }
111
10
 
112
- /**
113
- * Get the Y value of the given snap point. This can be passed to the `animate`
114
- * function to animate the sheet to the given snap point.
115
- */
116
- export function getSnapY({
117
- sheetHeight,
118
- snapPoints,
119
- snapIndex,
120
- }: {
121
- sheetHeight: number;
122
- snapPoints: number[];
123
- snapIndex: number;
124
- }) {
125
- const snapPoint = snapPoints[snapIndex];
126
-
127
- if (snapPoint === undefined) {
128
- console.warn(
129
- `Invalid snap index ${snapIndex}. Snap points are: [${snapPoints.join(', ')}]`
130
- );
131
- return null;
132
- }
133
-
134
- const y = validateSnapTo({ snapTo: sheetHeight - snapPoint, sheetHeight });
135
-
136
- return y;
137
- }
138
-
139
- /**
140
- * Make sure the snap point is not out of bounds.
141
- * Snap points cannot be negative - `0` means the sheet is fully open.
142
- */
143
- export function validateSnapTo({
144
- snapTo,
145
- sheetHeight,
146
- }: {
147
- snapTo: number;
148
- sheetHeight: number;
149
- }) {
150
- if (snapTo < 0) {
151
- console.warn(
152
- `Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${sheetHeight + Math.abs(snapTo)}.`
153
- );
154
- }
155
-
156
- return Math.max(Math.round(snapTo), 0);
157
- }
158
-
159
11
  export function mergeRefs<T = any>(refs: ForwardedRef<T>[]): RefCallback<T> {
160
12
  return (value: any) => {
161
13
  refs.forEach((ref: any) => {
@@ -1,84 +0,0 @@
1
- import React, { type TouchEvent, type UIEvent, forwardRef } from 'react';
2
-
3
- import { useSheetScrollerContext } from './context';
4
- import { type SheetScrollerProps } from './types';
5
- import { isTouchDevice } from './utils';
6
- import { styles } from './styles';
7
-
8
- export const SheetScroller = forwardRef<any, SheetScrollerProps>(
9
- (
10
- {
11
- disableScroll = false,
12
- draggableAt = 'top',
13
- children,
14
- style,
15
- className = '',
16
- ...rest
17
- },
18
- ref
19
- ) => {
20
- const { setDragEnabled, setDragDisabled } = useSheetScrollerContext();
21
-
22
- function determineDragState(element: HTMLDivElement) {
23
- /**
24
- * If the caller is managing the scrollability of the element,
25
- * enable the dragging of the sheet content.
26
- */
27
- if (disableScroll) {
28
- setDragEnabled();
29
- return;
30
- }
31
-
32
- const { scrollTop, scrollHeight, clientHeight } = element;
33
- const isScrollable = scrollHeight > clientHeight;
34
-
35
- if (!isScrollable) return;
36
-
37
- const isAtTop = scrollTop <= 0;
38
- const isAtBottom = scrollHeight - scrollTop === clientHeight;
39
-
40
- const shouldEnable =
41
- (draggableAt === 'top' && isAtTop) ||
42
- (draggableAt === 'bottom' && isAtBottom) ||
43
- (draggableAt === 'both' && (isAtTop || isAtBottom));
44
-
45
- if (shouldEnable) {
46
- setDragEnabled();
47
- } else {
48
- setDragDisabled();
49
- }
50
- }
51
-
52
- function onScroll(event: UIEvent<HTMLDivElement>) {
53
- determineDragState(event.currentTarget);
54
- if (rest.onScroll) rest.onScroll(event);
55
- }
56
-
57
- function onTouchStart(event: TouchEvent<HTMLDivElement>) {
58
- determineDragState(event.currentTarget);
59
- if (rest.onTouchStart) rest.onTouchStart(event);
60
- }
61
-
62
- const scrollProps = isTouchDevice()
63
- ? { onScroll, onTouchStart }
64
- : undefined;
65
-
66
- return (
67
- <div
68
- {...rest}
69
- ref={ref}
70
- className={`react-modal-sheet-scroller ${className}`}
71
- style={{
72
- ...styles.scroller,
73
- ...style,
74
- overflowY: disableScroll ? 'hidden' : 'auto',
75
- }}
76
- {...scrollProps}
77
- >
78
- {children}
79
- </div>
80
- );
81
- }
82
- );
83
-
84
- SheetScroller.displayName = 'SheetScroller';