react-modal-sheet 4.0.2 → 4.2.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/README.md +46 -26
- package/dist/index.d.mts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +401 -309
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +402 -310
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SheetContainer.tsx +5 -18
- package/src/SheetContent.tsx +1 -1
- package/src/SheetHeader.tsx +1 -1
- package/src/context.tsx +4 -4
- package/src/hooks/use-dimensions.ts +30 -0
- package/src/hooks/use-drag-constraints.ts +14 -0
- package/src/hooks/use-event-callbacks.ts +44 -0
- package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
- package/src/hooks/use-modal-effect.ts +165 -0
- package/src/{use-prevent-scroll.ts → hooks/use-prevent-scroll.ts} +2 -2
- package/src/hooks/use-safe-area-insets.ts +40 -0
- package/src/hooks/use-stable-callback.ts +18 -0
- package/src/sheet.tsx +96 -72
- package/src/styles.ts +1 -1
- package/src/types.tsx +6 -3
- package/src/utils.ts +88 -10
- package/src/hooks.tsx +0 -197
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
|
-
import { type ForwardedRef, type RefCallback } from 'react';
|
|
1
|
+
import { type ForwardedRef, type RefCallback, type RefObject } from 'react';
|
|
2
|
+
import { IS_SSR } from './constants';
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Get the rounded height of the sheet element and log a warning if the
|
|
6
|
+
* element is not mounted yet but the height is requested.
|
|
7
|
+
*/
|
|
8
|
+
export function getSheetHeight(sheetRef: RefObject<HTMLDivElement | null>) {
|
|
9
|
+
const sheetEl = sheetRef.current;
|
|
10
|
+
|
|
11
|
+
if (!sheetEl) {
|
|
12
|
+
console.warn(
|
|
13
|
+
'Sheet height is not available because the sheet element is not mounted yet.'
|
|
14
|
+
);
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return Math.round(sheetEl.getBoundingClientRect().height);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert negative / percentage snap points to absolute values
|
|
23
|
+
*/
|
|
24
|
+
export function getSnapPoints({
|
|
25
|
+
snapPointsProp,
|
|
26
|
+
sheetHeight,
|
|
27
|
+
}: {
|
|
28
|
+
snapPointsProp: number[];
|
|
29
|
+
sheetHeight: number;
|
|
30
|
+
}) {
|
|
31
|
+
const snapPointValues = snapPointsProp.map((point) => {
|
|
32
|
+
// Percentage values e.g. between 0.0 and 1.0
|
|
33
|
+
if (point > 0 && point <= 1) {
|
|
34
|
+
return Math.round(point * sheetHeight);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return point < 0 ? sheetHeight + point : point; // negative values
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
console.assert(
|
|
41
|
+
inDescendingOrder(snapPointValues) || sheetHeight === 0,
|
|
42
|
+
`Snap points need to be in descending order got: [${snapPointsProp.join(', ')}]`
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return snapPointValues;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get the closest number to the goal from the array of numbers.
|
|
50
|
+
*/
|
|
3
51
|
export function getClosest(nums: number[], goal: number) {
|
|
4
52
|
let closest = nums[0];
|
|
5
53
|
let minDifference = Math.abs(nums[0] - goal);
|
|
@@ -15,6 +63,10 @@ export function getClosest(nums: number[], goal: number) {
|
|
|
15
63
|
return closest;
|
|
16
64
|
}
|
|
17
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Check if the array is in descending order.
|
|
68
|
+
* This is used to validate the snap points.
|
|
69
|
+
*/
|
|
18
70
|
export function inDescendingOrder(arr: number[]) {
|
|
19
71
|
for (let i = 0; i < arr.length; i++) {
|
|
20
72
|
if (arr[i + 1] > arr[i]) return false;
|
|
@@ -23,6 +75,37 @@ export function inDescendingOrder(arr: number[]) {
|
|
|
23
75
|
return true;
|
|
24
76
|
}
|
|
25
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Get the Y value of the given snap point. This can be passed to the `animate`
|
|
80
|
+
* function to animate the sheet to the given snap point.
|
|
81
|
+
*/
|
|
82
|
+
export function getSnapY({
|
|
83
|
+
sheetHeight,
|
|
84
|
+
snapPoints,
|
|
85
|
+
snapIndex,
|
|
86
|
+
}: {
|
|
87
|
+
sheetHeight: number;
|
|
88
|
+
snapPoints: number[];
|
|
89
|
+
snapIndex: number;
|
|
90
|
+
}) {
|
|
91
|
+
const snapPoint = snapPoints[snapIndex];
|
|
92
|
+
|
|
93
|
+
if (snapPoint === undefined) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`Invalid snap index ${snapIndex}. Snap points are: [${snapPoints.join(', ')}]`
|
|
96
|
+
);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const y = validateSnapTo({ snapTo: sheetHeight - snapPoint, sheetHeight });
|
|
101
|
+
|
|
102
|
+
return y;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Make sure the snap point is not out of bounds.
|
|
107
|
+
* Snap points cannot be negative - `0` means the sheet is fully open.
|
|
108
|
+
*/
|
|
26
109
|
export function validateSnapTo({
|
|
27
110
|
snapTo,
|
|
28
111
|
sheetHeight,
|
|
@@ -32,9 +115,7 @@ export function validateSnapTo({
|
|
|
32
115
|
}) {
|
|
33
116
|
if (snapTo < 0) {
|
|
34
117
|
console.warn(
|
|
35
|
-
`Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${
|
|
36
|
-
sheetHeight + Math.abs(snapTo)
|
|
37
|
-
}.`
|
|
118
|
+
`Snap point is out of bounds. Sheet height is ${sheetHeight} but snap point is ${sheetHeight + Math.abs(snapTo)}.`
|
|
38
119
|
);
|
|
39
120
|
}
|
|
40
121
|
|
|
@@ -54,7 +135,7 @@ export function mergeRefs<T = any>(refs: ForwardedRef<T>[]): RefCallback<T> {
|
|
|
54
135
|
}
|
|
55
136
|
|
|
56
137
|
export function isTouchDevice() {
|
|
57
|
-
if (
|
|
138
|
+
if (IS_SSR) return false;
|
|
58
139
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
59
140
|
}
|
|
60
141
|
|
|
@@ -86,11 +167,8 @@ const isIPhone = cached(function () {
|
|
|
86
167
|
});
|
|
87
168
|
|
|
88
169
|
const isIPad = cached(function () {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
|
|
92
|
-
(isMac() && navigator.maxTouchPoints > 1)
|
|
93
|
-
);
|
|
170
|
+
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
|
|
171
|
+
return testPlatform(/^iPad/i) || (isMac() && navigator.maxTouchPoints > 1);
|
|
94
172
|
});
|
|
95
173
|
|
|
96
174
|
export const isIOS = cached(function () {
|
package/src/hooks.tsx
DELETED
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
type RefObject,
|
|
3
|
-
useCallback,
|
|
4
|
-
useEffect,
|
|
5
|
-
useLayoutEffect,
|
|
6
|
-
useRef,
|
|
7
|
-
useState,
|
|
8
|
-
} from 'react';
|
|
9
|
-
|
|
10
|
-
import { type MotionValue, type BoundingBox, transform } from 'motion/react';
|
|
11
|
-
|
|
12
|
-
import { IS_SSR } from './constants';
|
|
13
|
-
import { type SheetEvents } from './types';
|
|
14
|
-
|
|
15
|
-
export const useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect;
|
|
16
|
-
|
|
17
|
-
export function useModalEffect({
|
|
18
|
-
y,
|
|
19
|
-
rootId,
|
|
20
|
-
sheetRef,
|
|
21
|
-
}: {
|
|
22
|
-
y: MotionValue<number>;
|
|
23
|
-
rootId?: string;
|
|
24
|
-
sheetRef: RefObject<HTMLDivElement | null>;
|
|
25
|
-
}) {
|
|
26
|
-
const heightRef = useRef(IS_SSR ? 0 : window.innerHeight);
|
|
27
|
-
|
|
28
|
-
function setup() {
|
|
29
|
-
const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
|
|
30
|
-
const body = document.querySelector('body') as HTMLBodyElement;
|
|
31
|
-
if (!root) return;
|
|
32
|
-
|
|
33
|
-
body.style.backgroundColor = '#000';
|
|
34
|
-
root.style.overflow = 'hidden';
|
|
35
|
-
root.style.transitionTimingFunction = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
|
36
|
-
root.style.transitionProperty = 'transform, border-radius';
|
|
37
|
-
root.style.transitionDuration = '0.5s';
|
|
38
|
-
root.style.transformOrigin = 'center top';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function cleanup() {
|
|
42
|
-
const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
|
|
43
|
-
const body = document.querySelector('body') as HTMLBodyElement;
|
|
44
|
-
if (!root) return;
|
|
45
|
-
|
|
46
|
-
setTimeout(() => {
|
|
47
|
-
body.style.removeProperty('background-color');
|
|
48
|
-
root.style.removeProperty('overflow');
|
|
49
|
-
root.style.removeProperty('transition-timing-function');
|
|
50
|
-
root.style.removeProperty('transition-property');
|
|
51
|
-
root.style.removeProperty('transition-duration');
|
|
52
|
-
root.style.removeProperty('transform-origin');
|
|
53
|
-
root.style.removeProperty('transform');
|
|
54
|
-
root.style.removeProperty('border-top-right-radius');
|
|
55
|
-
root.style.removeProperty('border-top-left-radius');
|
|
56
|
-
}, 100);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
useIsomorphicLayoutEffect(() => {
|
|
60
|
-
return () => {
|
|
61
|
-
if (rootId) cleanup();
|
|
62
|
-
};
|
|
63
|
-
}, []);
|
|
64
|
-
|
|
65
|
-
useEffect(() => {
|
|
66
|
-
const root = document.querySelector(`#${rootId}`) as HTMLDivElement;
|
|
67
|
-
if (!root) return;
|
|
68
|
-
|
|
69
|
-
function onCompleted() {
|
|
70
|
-
if (y.get() - 10 >= heightRef.current) cleanup();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const removeStartListener = y.on('animationStart', () => {
|
|
74
|
-
heightRef.current = sheetRef.current?.offsetHeight || window.innerHeight;
|
|
75
|
-
setup();
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
const removeChangeListener = y.on('change', (value) => {
|
|
79
|
-
if (root) {
|
|
80
|
-
const progress = Math.max(0, 1 - value / heightRef.current);
|
|
81
|
-
const pageWidth = window.innerWidth;
|
|
82
|
-
const scale = (pageWidth - 16) / pageWidth;
|
|
83
|
-
const ty = transform(progress, [0, 1], [0, 24]);
|
|
84
|
-
const s = transform(progress, [0, 1], [1, scale]);
|
|
85
|
-
const borderRadius = transform(progress, [0, 1], [0, 10]);
|
|
86
|
-
const inset = 'env(safe-area-inset-top)';
|
|
87
|
-
|
|
88
|
-
root.style.transform = `scale(${s}) translate3d(0, calc(${inset} + ${ty}px), 0)`; // prettier-ignore
|
|
89
|
-
root.style.borderTopRightRadius = `${borderRadius}px`;
|
|
90
|
-
root.style.borderTopLeftRadius = `${borderRadius}px`;
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const removeCompleteListener = y.on('animationComplete', onCompleted);
|
|
95
|
-
const removeCancelListener = y.on('animationCancel', onCompleted);
|
|
96
|
-
|
|
97
|
-
return () => {
|
|
98
|
-
removeStartListener();
|
|
99
|
-
removeChangeListener();
|
|
100
|
-
removeCompleteListener();
|
|
101
|
-
removeCancelListener();
|
|
102
|
-
};
|
|
103
|
-
}, [y, rootId]);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export function useEventCallbacks(
|
|
107
|
-
isOpen: boolean,
|
|
108
|
-
callbacks: RefObject<SheetEvents>
|
|
109
|
-
) {
|
|
110
|
-
const prevOpen = usePrevious(isOpen);
|
|
111
|
-
const didOpen = useRef(false);
|
|
112
|
-
|
|
113
|
-
// Because of AnimatePrecence we don't have access to latest isOpen value
|
|
114
|
-
// so we need to read and write to a ref to determine if we are
|
|
115
|
-
// opening or closing the sheet
|
|
116
|
-
const handleAnimationComplete = useCallback(() => {
|
|
117
|
-
if (!didOpen.current) {
|
|
118
|
-
callbacks.current.onOpenEnd?.();
|
|
119
|
-
didOpen.current = true;
|
|
120
|
-
} else {
|
|
121
|
-
callbacks.current.onCloseEnd?.();
|
|
122
|
-
didOpen.current = false;
|
|
123
|
-
}
|
|
124
|
-
}, [isOpen, prevOpen]);
|
|
125
|
-
|
|
126
|
-
useEffect(() => {
|
|
127
|
-
if (!prevOpen && isOpen) {
|
|
128
|
-
callbacks.current.onOpenStart?.();
|
|
129
|
-
} else if (!isOpen && prevOpen) {
|
|
130
|
-
callbacks.current.onCloseStart?.();
|
|
131
|
-
}
|
|
132
|
-
}, [isOpen, prevOpen]);
|
|
133
|
-
|
|
134
|
-
return { handleAnimationComplete };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function useDimensions() {
|
|
138
|
-
const [dimensions, setDimensions] = useState({
|
|
139
|
-
height: !IS_SSR ? window.innerHeight : 0,
|
|
140
|
-
width: !IS_SSR ? window.innerWidth : 0,
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
useIsomorphicLayoutEffect(() => {
|
|
144
|
-
function handler() {
|
|
145
|
-
setDimensions({
|
|
146
|
-
height: window.innerHeight,
|
|
147
|
-
width: window.innerWidth,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
handler();
|
|
152
|
-
|
|
153
|
-
window.addEventListener('resize', handler);
|
|
154
|
-
|
|
155
|
-
return () => {
|
|
156
|
-
window.removeEventListener('resize', handler);
|
|
157
|
-
};
|
|
158
|
-
}, []);
|
|
159
|
-
|
|
160
|
-
return dimensions;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export function usePrevious<T>(state: T): T | undefined {
|
|
164
|
-
const ref = useRef<T>(undefined);
|
|
165
|
-
|
|
166
|
-
useEffect(() => {
|
|
167
|
-
ref.current = state;
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
return ref.current;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Userland version of the `useEffectEvent` React hook:
|
|
174
|
-
// RFC: https://react.dev/reference/react/experimental_useEffectEvent
|
|
175
|
-
export function useEffectEvent<T extends (...args: any[]) => any>(handler: T) {
|
|
176
|
-
const handlerRef = useRef<T>(undefined);
|
|
177
|
-
|
|
178
|
-
useIsomorphicLayoutEffect(() => {
|
|
179
|
-
handlerRef.current = handler;
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
return useCallback((...args: any[]) => {
|
|
183
|
-
const fn = handlerRef.current;
|
|
184
|
-
return fn?.(...args);
|
|
185
|
-
}, []) as T;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// This is a hacky way to fix a bug in motion/react where the drag
|
|
189
|
-
// constraints are not updated when window is resized.
|
|
190
|
-
// https://github.com/framer/motion/issues/1659
|
|
191
|
-
const constraints: BoundingBox = { bottom: 0, top: 0, left: 0, right: 0 };
|
|
192
|
-
|
|
193
|
-
export function useDragConstraints() {
|
|
194
|
-
const constraintsRef = useRef<any>(null);
|
|
195
|
-
const onMeasureDragConstraints = useCallback(() => constraints, []);
|
|
196
|
-
return { constraintsRef, onMeasureDragConstraints };
|
|
197
|
-
}
|