react-modal-sheet 5.4.0 → 5.5.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/dist/index.d.mts +0 -5
- package/dist/index.d.ts +0 -5
- package/dist/index.js +374 -275
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +375 -276
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SheetContent.tsx +9 -3
- package/src/constants.ts +1 -1
- package/src/hooks/use-keyboard-avoidance.ts +75 -0
- package/src/hooks/use-prevent-scroll.ts +5 -27
- package/src/hooks/use-scroll-position.ts +0 -1
- package/src/hooks/use-virtual-keyboard.ts +127 -52
- package/src/sheet.tsx +79 -143
- package/src/snap.ts +205 -102
- package/src/types.tsx +1 -2
- package/src/utils.ts +26 -0
- package/src/hooks/use-focused-input.ts +0 -46
package/src/sheet.tsx
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
} from 'motion/react';
|
|
10
10
|
import React, {
|
|
11
11
|
forwardRef,
|
|
12
|
-
useEffect,
|
|
13
12
|
useImperativeHandle,
|
|
14
13
|
useRef,
|
|
15
14
|
useState,
|
|
@@ -26,20 +25,15 @@ import {
|
|
|
26
25
|
} from './constants';
|
|
27
26
|
import { SheetContext } from './context';
|
|
28
27
|
import { useDimensions } from './hooks/use-dimensions';
|
|
29
|
-
import {
|
|
28
|
+
import { useKeyboardAvoidance } from './hooks/use-keyboard-avoidance';
|
|
30
29
|
import { useModalEffect } from './hooks/use-modal-effect';
|
|
31
30
|
import { usePreventScroll } from './hooks/use-prevent-scroll';
|
|
32
31
|
import { useSheetState } from './hooks/use-sheet-state';
|
|
33
32
|
import { useStableCallback } from './hooks/use-stable-callback';
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
computeSnapPoints,
|
|
37
|
-
handleHighVelocityDrag,
|
|
38
|
-
handleLowVelocityDrag,
|
|
39
|
-
} from './snap';
|
|
33
|
+
import { classifyDragEnd, computeSnapPoints } from './snap';
|
|
40
34
|
import { styles } from './styles';
|
|
41
35
|
import { type SheetContextType, type SheetProps } from './types';
|
|
42
|
-
import { applyStyles, waitForElement } from './utils';
|
|
36
|
+
import { applyStyles, waitForElement, willOpenKeyboard } from './utils';
|
|
43
37
|
|
|
44
38
|
export const Sheet = forwardRef<any, SheetProps>(
|
|
45
39
|
(
|
|
@@ -98,14 +92,6 @@ export const Sheet = forwardRef<any, SheetProps>(
|
|
|
98
92
|
...(reduceMotion ? REDUCED_MOTION_TWEEN_CONFIG : tweenConfig),
|
|
99
93
|
};
|
|
100
94
|
|
|
101
|
-
const keyboard = useVirtualKeyboard({
|
|
102
|
-
isEnabled: isOpen && avoidKeyboard,
|
|
103
|
-
containerRef: sheetRef,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// Disable drag if the keyboard is open to avoid weird behavior
|
|
107
|
-
const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
|
|
108
|
-
|
|
109
95
|
// +2 for tolerance in case the animated value is slightly off
|
|
110
96
|
const zIndex = useTransform(y, (val) =>
|
|
111
97
|
val + 2 >= closedY ? -1 : (style?.zIndex ?? 9999)
|
|
@@ -158,22 +144,70 @@ export const Sheet = forwardRef<any, SheetProps>(
|
|
|
158
144
|
});
|
|
159
145
|
});
|
|
160
146
|
|
|
147
|
+
const keyboard = useKeyboardAvoidance({
|
|
148
|
+
isEnabled: isOpen && avoidKeyboard,
|
|
149
|
+
containerRef: sheetRef,
|
|
150
|
+
onWillOpenKeyboard: async () => {
|
|
151
|
+
const lastSnapPoint = snapPoints[snapPoints.length - 1];
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* If there are snap points and the sheet is not already at the last snap point,
|
|
155
|
+
* move it there to make sure the focused input is not covered by the keyboard
|
|
156
|
+
*/
|
|
157
|
+
if (lastSnapPoint && lastSnapPoint.snapIndex !== currentSnap) {
|
|
158
|
+
await animate(y, lastSnapPoint.snapValueY, animationOptions);
|
|
159
|
+
updateSnap(lastSnapPoint.snapIndex);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
onDidOpenKeyboard: (focusedElement) => {
|
|
163
|
+
const sheetElement = sheetRef.current;
|
|
164
|
+
if (!sheetElement) return;
|
|
165
|
+
|
|
166
|
+
const inputRect = focusedElement.getBoundingClientRect();
|
|
167
|
+
const containerRect = sheetElement.getBoundingClientRect();
|
|
168
|
+
const scroller = sheetElement.querySelector(
|
|
169
|
+
'.react-modal-sheet-content-scroller'
|
|
170
|
+
) as HTMLElement;
|
|
171
|
+
|
|
172
|
+
const scrollTarget = Math.max(
|
|
173
|
+
inputRect.top -
|
|
174
|
+
containerRect.top +
|
|
175
|
+
scroller.scrollTop -
|
|
176
|
+
inputRect.height,
|
|
177
|
+
0
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
requestAnimationFrame(() => {
|
|
181
|
+
scroller.scrollTo({ top: scrollTarget, behavior: 'smooth' });
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Disable drag if the keyboard is open to avoid weird behavior
|
|
187
|
+
const disableDrag = keyboard.isKeyboardOpen || disableDragProp;
|
|
188
|
+
|
|
161
189
|
const blurActiveInput = useStableCallback(() => {
|
|
162
|
-
|
|
163
|
-
|
|
190
|
+
/**
|
|
191
|
+
* Find focused input inside the sheet and blur it when dragging starts
|
|
192
|
+
* to prevent a weird ghost caret "bug" on mobile
|
|
193
|
+
*/
|
|
164
194
|
const focusedElement = document.activeElement as HTMLElement | null;
|
|
165
|
-
if (!focusedElement || !sheetRef.current) return;
|
|
166
|
-
|
|
167
|
-
const isInput =
|
|
168
|
-
focusedElement.tagName === 'INPUT' ||
|
|
169
|
-
focusedElement.tagName === 'TEXTAREA';
|
|
170
195
|
|
|
171
196
|
// Only blur the focused element if it's inside the sheet
|
|
172
|
-
if (
|
|
197
|
+
if (
|
|
198
|
+
focusedElement &&
|
|
199
|
+
willOpenKeyboard(focusedElement) &&
|
|
200
|
+
sheetRef.current?.contains(focusedElement)
|
|
201
|
+
) {
|
|
173
202
|
focusedElement.blur();
|
|
174
203
|
}
|
|
175
204
|
});
|
|
176
205
|
|
|
206
|
+
const onDragStart = useStableCallback<DragHandler>((event, info) => {
|
|
207
|
+
blurActiveInput();
|
|
208
|
+
onDragStartProp?.(event, info);
|
|
209
|
+
});
|
|
210
|
+
|
|
177
211
|
const onDrag = useStableCallback<DragHandler>((event, info) => {
|
|
178
212
|
onDragProp?.(event, info);
|
|
179
213
|
|
|
@@ -188,74 +222,37 @@ export const Sheet = forwardRef<any, SheetProps>(
|
|
|
188
222
|
y.set(Math.max(currentY + info.delta.y, 0));
|
|
189
223
|
});
|
|
190
224
|
|
|
191
|
-
const onDragStart = useStableCallback<DragHandler>((event, info) => {
|
|
192
|
-
blurActiveInput();
|
|
193
|
-
onDragStartProp?.(event, info);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
225
|
const onDragEnd = useStableCallback<DragHandler>((event, info) => {
|
|
197
226
|
blurActiveInput();
|
|
198
227
|
onDragEndProp?.(event, info);
|
|
199
228
|
|
|
200
229
|
const currentY = y.get();
|
|
201
230
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const isHighVelocity =
|
|
211
|
-
Math.abs(info.velocity.y) > dragVelocityThreshold;
|
|
231
|
+
const result = classifyDragEnd({
|
|
232
|
+
y: currentY,
|
|
233
|
+
info,
|
|
234
|
+
sheetHeight,
|
|
235
|
+
dragCloseThreshold,
|
|
236
|
+
snapPoints,
|
|
237
|
+
dragVelocityThreshold,
|
|
238
|
+
});
|
|
212
239
|
|
|
213
|
-
|
|
240
|
+
let yTo = result.yTo;
|
|
214
241
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
});
|
|
220
|
-
} else {
|
|
221
|
-
result = handleLowVelocityDrag({
|
|
222
|
-
currentSnapPoint,
|
|
223
|
-
currentY,
|
|
224
|
-
dragDirection: dragOffsetDirection,
|
|
225
|
-
snapPoints,
|
|
226
|
-
velocity: info.velocity.y,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
242
|
+
// If disableDismiss is true, prevent closing via gesture
|
|
243
|
+
if (disableDismiss && yTo + 1 >= sheetHeight) {
|
|
244
|
+
// Use the bottom-most open snap point
|
|
245
|
+
const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
|
|
229
246
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (disableDismiss && yTo + 1 >= sheetHeight) {
|
|
234
|
-
// Use the bottom-most open snap point
|
|
235
|
-
const bottomSnapPoint = snapPoints.find((s) => s.snapValue > 0);
|
|
236
|
-
|
|
237
|
-
if (bottomSnapPoint) {
|
|
238
|
-
yTo = bottomSnapPoint.snapValueY;
|
|
239
|
-
updateSnap(bottomSnapPoint.snapIndex);
|
|
240
|
-
} else {
|
|
241
|
-
// If no open snap points available, stay at current position
|
|
242
|
-
yTo = currentY;
|
|
243
|
-
}
|
|
244
|
-
} else if (result.snapIndex !== undefined) {
|
|
245
|
-
updateSnap(result.snapIndex);
|
|
246
|
-
}
|
|
247
|
-
} else if (
|
|
248
|
-
info.velocity.y > dragVelocityThreshold ||
|
|
249
|
-
currentY > sheetHeight * dragCloseThreshold
|
|
250
|
-
) {
|
|
251
|
-
// Close the sheet if dragged past the threshold or if the velocity is high enough
|
|
252
|
-
// But only if disableDismiss is false
|
|
253
|
-
if (disableDismiss) {
|
|
254
|
-
// If disableDismiss, snap back to the open position
|
|
255
|
-
yTo = 0;
|
|
247
|
+
if (bottomSnapPoint) {
|
|
248
|
+
yTo = bottomSnapPoint.snapValueY;
|
|
249
|
+
updateSnap(bottomSnapPoint.snapIndex);
|
|
256
250
|
} else {
|
|
257
|
-
|
|
251
|
+
// If no open snap points available, stay at current position
|
|
252
|
+
yTo = currentY;
|
|
258
253
|
}
|
|
254
|
+
} else if (result.snapIndex !== undefined) {
|
|
255
|
+
updateSnap(result.snapIndex);
|
|
259
256
|
}
|
|
260
257
|
|
|
261
258
|
// Update the spring value so that the sheet is animated to the snap point
|
|
@@ -295,67 +292,6 @@ export const Sheet = forwardRef<any, SheetProps>(
|
|
|
295
292
|
isDisabled: disableScrollLocking || !isOpen,
|
|
296
293
|
});
|
|
297
294
|
|
|
298
|
-
const focusedInput = useFocusedInput({
|
|
299
|
-
isEnabled: isOpen && avoidKeyboard,
|
|
300
|
-
sheetRef,
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Keyboard avoidance.
|
|
305
|
-
*
|
|
306
|
-
* Move the sheet up when the keyboard opens if the sheet has snap points,
|
|
307
|
-
* and then scroll the focused input into view to make sure it's not covered
|
|
308
|
-
* by the keyboard.
|
|
309
|
-
*/
|
|
310
|
-
useEffect(() => {
|
|
311
|
-
if (!isOpen || !avoidKeyboard) {
|
|
312
|
-
return;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async function adjustSheetForKeyboard() {
|
|
316
|
-
const sheetElement = sheetRef.current;
|
|
317
|
-
|
|
318
|
-
if (!sheetElement || !focusedInput || !keyboard.isKeyboardOpen) {
|
|
319
|
-
return;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* If we have snap points and the sheet is currently snapped to a point
|
|
324
|
-
* that is being covered by the keyboard, we want to move the sheet up
|
|
325
|
-
* to reveal it.
|
|
326
|
-
*/
|
|
327
|
-
const lastSnapPoint = snapPoints[snapPoints.length - 1];
|
|
328
|
-
|
|
329
|
-
if (lastSnapPoint) {
|
|
330
|
-
await animate(y, lastSnapPoint.snapValueY, animationOptions);
|
|
331
|
-
updateSnap(lastSnapPoint.snapIndex);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Then scroll the focused input into view if it's covered by the keyboard
|
|
335
|
-
requestAnimationFrame(() => {
|
|
336
|
-
const inputRect = focusedInput.getBoundingClientRect();
|
|
337
|
-
const sheetRect = sheetElement.getBoundingClientRect();
|
|
338
|
-
const scroller = sheetElement.querySelector(
|
|
339
|
-
'.react-modal-sheet-content-scroller'
|
|
340
|
-
) as HTMLElement;
|
|
341
|
-
|
|
342
|
-
const scrollTarget = Math.max(
|
|
343
|
-
inputRect.top -
|
|
344
|
-
sheetRect.top +
|
|
345
|
-
scroller.scrollTop -
|
|
346
|
-
inputRect.height,
|
|
347
|
-
0
|
|
348
|
-
);
|
|
349
|
-
|
|
350
|
-
requestAnimationFrame(() => {
|
|
351
|
-
scroller.scrollTo({ top: scrollTarget, behavior: 'smooth' });
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
adjustSheetForKeyboard();
|
|
357
|
-
}, [isOpen, avoidKeyboard, keyboard.isKeyboardOpen, focusedInput]);
|
|
358
|
-
|
|
359
295
|
const state = useSheetState({
|
|
360
296
|
isOpen,
|
|
361
297
|
onOpen: async () => {
|
package/src/snap.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PanInfo } from 'motion';
|
|
1
2
|
import type { SheetSnapPoint } from './types';
|
|
2
3
|
import { isAscendingOrder } from './utils';
|
|
3
4
|
|
|
@@ -107,136 +108,238 @@ export function computeSnapPoints({
|
|
|
107
108
|
}));
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
function
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
function clamp(value: number, min: number, max: number) {
|
|
112
|
+
return Math.min(Math.max(value, min), max);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sign(value: number) {
|
|
116
|
+
if (value > 0) return 1;
|
|
117
|
+
if (value < 0) return -1;
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toAscendingYSnaps(snapPoints: SheetSnapPoint[]) {
|
|
122
|
+
return snapPoints.slice().sort((a, b) => a.snapValueY - b.snapValueY);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findNearestSnapByY(snapPoints: SheetSnapPoint[], y: number) {
|
|
117
126
|
return snapPoints.reduce((closest, snap) =>
|
|
118
|
-
Math.abs(snap.snapValueY -
|
|
119
|
-
Math.abs(closest.snapValueY - currentY)
|
|
127
|
+
Math.abs(snap.snapValueY - y) < Math.abs(closest.snapValueY - y)
|
|
120
128
|
? snap
|
|
121
129
|
: closest
|
|
122
130
|
);
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
|
133
|
+
function findNearestSnapAboveY(snapPoints: SheetSnapPoint[], y: number) {
|
|
134
|
+
const snaps = toAscendingYSnaps(snapPoints);
|
|
135
|
+
|
|
136
|
+
for (let i = snaps.length - 1; i >= 0; i--) {
|
|
137
|
+
if (snaps[i].snapValueY < y) {
|
|
138
|
+
return snaps[i];
|
|
139
|
+
}
|
|
165
140
|
}
|
|
141
|
+
|
|
142
|
+
return null;
|
|
166
143
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
snapPoints
|
|
144
|
+
|
|
145
|
+
function findNearestSnapBelowY(snapPoints: SheetSnapPoint[], y: number) {
|
|
146
|
+
const snaps = toAscendingYSnaps(snapPoints);
|
|
147
|
+
|
|
148
|
+
for (let i = 0; i < snaps.length; i++) {
|
|
149
|
+
if (snaps[i].snapValueY > y) {
|
|
150
|
+
return snaps[i];
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveDirection({
|
|
158
|
+
offsetY,
|
|
159
|
+
velocityY,
|
|
160
|
+
deltaY,
|
|
170
161
|
}: {
|
|
171
|
-
|
|
172
|
-
|
|
162
|
+
offsetY: number;
|
|
163
|
+
velocityY: number;
|
|
164
|
+
deltaY: number;
|
|
173
165
|
}) {
|
|
174
|
-
|
|
175
|
-
const bottomSnapPoint = snapPoints[0];
|
|
176
|
-
const topSnapPoint = snapPoints[snapPoints.length - 1];
|
|
166
|
+
if (Math.abs(velocityY) > 50) return sign(velocityY);
|
|
177
167
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
return {
|
|
185
|
-
yTo: topSnapPoint.snapValueY,
|
|
186
|
-
snapIndex: topSnapPoint.snapIndex,
|
|
187
|
-
};
|
|
168
|
+
const offsetDirection = sign(offsetY);
|
|
169
|
+
if (offsetDirection !== 0) return offsetDirection;
|
|
170
|
+
|
|
171
|
+
return sign(deltaY);
|
|
188
172
|
}
|
|
189
173
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Classifies the final sheet position when a drag gesture ends.
|
|
176
|
+
*
|
|
177
|
+
* Decision flow (high level):
|
|
178
|
+
* 1) Predict momentum endpoint from current position and velocity.
|
|
179
|
+
* 2) Resolve drag direction (prefer velocity, fallback to offset/delta).
|
|
180
|
+
* 3) Detect flick tiers:
|
|
181
|
+
* - strong flick -> jump fully open/closed
|
|
182
|
+
* - flick -> move to next snap in flick direction
|
|
183
|
+
* 4) For slower drags:
|
|
184
|
+
* - tiny drag or near-snap stickiness -> snap back to nearest snap
|
|
185
|
+
* - otherwise, use progress between surrounding snaps and commit threshold
|
|
186
|
+
* 5) Apply velocity-assisted bias to nearest predicted snap for natural feel.
|
|
187
|
+
* 6) Fallback to nearest snap.
|
|
188
|
+
*
|
|
189
|
+
* Special case with no snap points:
|
|
190
|
+
* - flick: open/close by direction
|
|
191
|
+
* - otherwise: open/close by predicted position vs close threshold midpoint
|
|
192
|
+
*/
|
|
193
|
+
export function classifyDragEnd({
|
|
194
|
+
y,
|
|
195
|
+
info,
|
|
196
|
+
sheetHeight,
|
|
194
197
|
snapPoints,
|
|
195
|
-
|
|
198
|
+
dragVelocityThreshold,
|
|
199
|
+
dragCloseThreshold,
|
|
196
200
|
}: {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
201
|
+
y: number;
|
|
202
|
+
info: PanInfo;
|
|
203
|
+
sheetHeight: number;
|
|
200
204
|
snapPoints: SheetSnapPoint[];
|
|
201
|
-
|
|
205
|
+
dragVelocityThreshold: number;
|
|
206
|
+
dragCloseThreshold: number;
|
|
202
207
|
}) {
|
|
203
|
-
const closestSnapRelativeToCurrentY = findClosestSnapPoint({
|
|
204
|
-
snapPoints,
|
|
205
|
-
currentY,
|
|
206
|
-
});
|
|
207
|
-
|
|
208
208
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* "momentum" that would push the sheet further to the given direction
|
|
209
|
+
* Thresholds and configuration for drag classification.
|
|
210
|
+
* Tweak these to adjust the feel of the sheet.
|
|
212
211
|
*/
|
|
213
|
-
|
|
212
|
+
// Velocity above this is considered a flick gesture.
|
|
213
|
+
const flickVelocity = dragVelocityThreshold;
|
|
214
|
+
// Velocity above this is treated as a strong flick and jumps to full open/closed.
|
|
215
|
+
const strongFlickVelocity = Math.max(2000, flickVelocity);
|
|
216
|
+
// In slow drags, crossing this portion of the current snap region commits to the next snap.
|
|
217
|
+
const distanceCommitRatio = 0.35;
|
|
218
|
+
// Time horizon (in seconds) used to estimate momentum-based end position.
|
|
219
|
+
const predictionTime = 0.2;
|
|
220
|
+
// Very small drags below this distance snap back instead of changing snap state.
|
|
221
|
+
const minDragDistance = 20;
|
|
222
|
+
// If drag ends within this many pixels from a snap, stick to that snap to reduce jitter.
|
|
223
|
+
const snapStickiness = 24;
|
|
224
|
+
|
|
225
|
+
const minY = 0;
|
|
226
|
+
const maxY = sheetHeight;
|
|
227
|
+
const offsetY = info.offset.y;
|
|
228
|
+
const deltaY = info.delta.y;
|
|
229
|
+
const velocityY = info.velocity.y;
|
|
230
|
+
|
|
231
|
+
// Step 1 — Compute predicted end position from momentum.
|
|
232
|
+
const predictedY = clamp(y + velocityY * predictionTime, minY, maxY);
|
|
233
|
+
const absVelocity = Math.abs(velocityY);
|
|
234
|
+
|
|
235
|
+
// Step 2 + 3 — Flick detection and gesture direction resolution.
|
|
236
|
+
const direction = resolveDirection({ offsetY, velocityY, deltaY });
|
|
237
|
+
|
|
238
|
+
const isStrongFlick = absVelocity > strongFlickVelocity;
|
|
239
|
+
const isFlick = absVelocity > flickVelocity;
|
|
240
|
+
|
|
241
|
+
// Step 4 — No snap points: decide between open/close only.
|
|
242
|
+
if (snapPoints.length === 0) {
|
|
243
|
+
if (isFlick) {
|
|
244
|
+
return {
|
|
245
|
+
yTo: direction < 0 ? minY : maxY,
|
|
246
|
+
snapIndex: undefined,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const midpoint = minY + (maxY - minY) * dragCloseThreshold;
|
|
251
|
+
|
|
214
252
|
return {
|
|
215
|
-
yTo:
|
|
216
|
-
snapIndex:
|
|
253
|
+
yTo: predictedY < midpoint ? minY : maxY,
|
|
254
|
+
snapIndex: undefined,
|
|
217
255
|
};
|
|
218
256
|
}
|
|
219
257
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
258
|
+
const nearestSnap = findNearestSnapByY(snapPoints, y);
|
|
259
|
+
|
|
260
|
+
// Slow-drag guard — tiny movement: snap back to nearest.
|
|
261
|
+
if (!isFlick && Math.abs(offsetY) < minDragDistance) {
|
|
262
|
+
return {
|
|
263
|
+
yTo: nearestSnap.snapValueY,
|
|
264
|
+
snapIndex: nearestSnap.snapIndex,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
229
267
|
|
|
230
|
-
|
|
268
|
+
// Hysteresis (stickiness) — avoid jitter if already close to a snap.
|
|
269
|
+
if (!isFlick && Math.abs(y - nearestSnap.snapValueY) < snapStickiness) {
|
|
231
270
|
return {
|
|
232
|
-
yTo:
|
|
233
|
-
snapIndex:
|
|
271
|
+
yTo: nearestSnap.snapValueY,
|
|
272
|
+
snapIndex: nearestSnap.snapIndex,
|
|
234
273
|
};
|
|
235
274
|
}
|
|
236
275
|
|
|
237
|
-
//
|
|
276
|
+
// Step 5 — Strong flick overrides everything and goes fully open/closed.
|
|
277
|
+
if (isStrongFlick) {
|
|
278
|
+
const target = direction < 0 ? minY : maxY;
|
|
279
|
+
const targetSnap = findNearestSnapByY(snapPoints, target);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
yTo: target,
|
|
283
|
+
snapIndex: targetSnap.snapIndex,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Step 5 — Medium flick moves to the next snap in flick direction.
|
|
288
|
+
if (isFlick) {
|
|
289
|
+
const nextSnap =
|
|
290
|
+
direction < 0
|
|
291
|
+
? findNearestSnapAboveY(snapPoints, y)
|
|
292
|
+
: findNearestSnapBelowY(snapPoints, y);
|
|
293
|
+
|
|
294
|
+
const fallbackY = direction < 0 ? minY : maxY;
|
|
295
|
+
const fallbackSnap = findNearestSnapByY(snapPoints, fallbackY);
|
|
296
|
+
const targetSnap = nextSnap ?? fallbackSnap;
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
yTo: targetSnap.snapValueY,
|
|
300
|
+
snapIndex: targetSnap.snapIndex,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const firstSnap = toAscendingYSnaps(snapPoints)[0];
|
|
305
|
+
const lastSnap = toAscendingYSnaps(snapPoints)[snapPoints.length - 1];
|
|
306
|
+
const prevSnap = findNearestSnapAboveY(snapPoints, y) ?? firstSnap;
|
|
307
|
+
const nextSnap = findNearestSnapBelowY(snapPoints, y) ?? lastSnap;
|
|
308
|
+
|
|
309
|
+
if (prevSnap.snapIndex === nextSnap.snapIndex) {
|
|
310
|
+
return {
|
|
311
|
+
yTo: prevSnap.snapValueY,
|
|
312
|
+
snapIndex: prevSnap.snapIndex,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const range = nextSnap.snapValueY - prevSnap.snapValueY;
|
|
317
|
+
const progress = range > 0 ? (y - prevSnap.snapValueY) / range : 0;
|
|
318
|
+
|
|
319
|
+
// Step 6 — Slow drag commit based on progress within the current snap region.
|
|
320
|
+
let selectedSnap = nearestSnap;
|
|
321
|
+
|
|
322
|
+
if (direction > 0) {
|
|
323
|
+
selectedSnap = progress > distanceCommitRatio ? nextSnap : prevSnap;
|
|
324
|
+
} else if (direction < 0) {
|
|
325
|
+
selectedSnap = progress < 1 - distanceCommitRatio ? prevSnap : nextSnap;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Step 7 — Velocity-assisted bias toward predicted-position snap.
|
|
329
|
+
if (absVelocity > 200) {
|
|
330
|
+
const predictedSnap = findNearestSnapByY(snapPoints, predictedY);
|
|
331
|
+
|
|
332
|
+
if (
|
|
333
|
+
Math.abs(predictedSnap.snapValueY - y) <
|
|
334
|
+
Math.abs(selectedSnap.snapValueY - y)
|
|
335
|
+
) {
|
|
336
|
+
selectedSnap = predictedSnap;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Step 8 — Final fallback.
|
|
238
341
|
return {
|
|
239
|
-
yTo:
|
|
240
|
-
snapIndex:
|
|
342
|
+
yTo: selectedSnap.snapValueY,
|
|
343
|
+
snapIndex: selectedSnap.snapIndex,
|
|
241
344
|
};
|
|
242
345
|
}
|
package/src/types.tsx
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
} from 'motion/react';
|
|
8
8
|
import {
|
|
9
9
|
type ComponentPropsWithoutRef,
|
|
10
|
-
type CSSProperties,
|
|
11
10
|
type ForwardRefExoticComponent,
|
|
12
11
|
type FunctionComponent,
|
|
13
12
|
type HTMLAttributes,
|
|
@@ -108,7 +107,7 @@ export interface SheetContextType {
|
|
|
108
107
|
currentSnap?: number;
|
|
109
108
|
detent: SheetDetent;
|
|
110
109
|
disableDrag: boolean;
|
|
111
|
-
dragProps
|
|
110
|
+
dragProps: SheetDragProps;
|
|
112
111
|
indicatorRotation: MotionValue<number>;
|
|
113
112
|
avoidKeyboard: boolean;
|
|
114
113
|
prefersReducedMotion: boolean;
|
package/src/utils.ts
CHANGED
|
@@ -88,3 +88,29 @@ export function waitForElement(
|
|
|
88
88
|
}, interval);
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
|
+
|
|
92
|
+
// HTML input types that do not cause the software keyboard to appear.
|
|
93
|
+
const nonTextInputTypes = new Set([
|
|
94
|
+
'checkbox',
|
|
95
|
+
'radio',
|
|
96
|
+
'range',
|
|
97
|
+
'color',
|
|
98
|
+
'file',
|
|
99
|
+
'image',
|
|
100
|
+
'button',
|
|
101
|
+
'submit',
|
|
102
|
+
'reset',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
export function willOpenKeyboard(target: Element) {
|
|
106
|
+
return (
|
|
107
|
+
(target instanceof HTMLInputElement &&
|
|
108
|
+
!nonTextInputTypes.has(target.type)) ||
|
|
109
|
+
target instanceof HTMLTextAreaElement ||
|
|
110
|
+
(target instanceof HTMLElement && target.isContentEditable)
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function isHTTPS() {
|
|
115
|
+
return typeof window !== 'undefined' && window.isSecureContext;
|
|
116
|
+
}
|