@astryxdesign/lab 0.2.0-canary.8d64648 → 0.2.0-canary.8dfd7cf
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/BottomSheet/BottomSheet.d.ts +41 -35
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +222 -75
- package/dist/BottomSheet/snapOffsets.d.ts +54 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +101 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +55 -12
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +383 -40
- package/dist/Chart/index.d.ts +1 -1
- package/dist/Chart/index.d.ts.map +1 -1
- package/dist/Chart/index.js +1 -1
- package/dist/Stepper/Step.d.ts +1 -1
- package/dist/Stepper/Step.js +1 -1
- package/dist/Stepper/Stepper.d.ts +1 -1
- package/dist/Stepper/Stepper.js +1 -1
- package/dist/index.js +1 -1
- package/dist/lab.css +15 -8
- package/package.json +3 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +13 -13
- package/src/BottomSheet/BottomSheet.test.tsx +81 -4
- package/src/BottomSheet/BottomSheet.tsx +333 -97
- package/src/BottomSheet/snapOffsets.test.ts +146 -0
- package/src/BottomSheet/snapOffsets.ts +126 -0
- package/src/BottomSheet/useSheetGestures.doc.mjs +7 -6
- package/src/BottomSheet/useSheetGestures.test.ts +315 -65
- package/src/BottomSheet/useSheetGestures.ts +515 -55
- package/src/Chart/ChartArea.doc.mjs +1 -1
- package/src/Chart/ChartBar.doc.mjs +1 -1
- package/src/Chart/ChartCandlestick.doc.mjs +1 -1
- package/src/Chart/ChartDot.doc.mjs +1 -1
- package/src/Chart/ChartDotGL.doc.mjs +1 -1
- package/src/Chart/ChartDotGLInteractive.doc.mjs +1 -1
- package/src/Chart/ChartHeatmapGL.doc.mjs +1 -1
- package/src/Chart/ChartLine.doc.mjs +1 -1
- package/src/Chart/ChartStreamGL.doc.mjs +1 -1
- package/src/Chart/index.ts +0 -1
- package/src/Chat/ChatEmojiPicker.doc.mjs +1 -1
- package/src/Chat/ChatReactionBar.doc.mjs +1 -1
- package/src/Chat/ChatTypingIndicator.doc.mjs +1 -1
- package/src/Chat/ChatUnreadDivider.doc.mjs +1 -1
- package/src/ChatReasoning/ChatReasoning.doc.mjs +1 -1
- package/src/CircularProgress/CircularProgress.doc.mjs +3 -3
- package/src/CodeEditor/CodeEditor.doc.mjs +1 -1
- package/src/Drawer/Drawer.doc.mjs +3 -3
- package/src/InfoTip/InfoTip.doc.mjs +3 -3
- package/src/LogStream/LogStream.doc.mjs +1 -1
- package/src/Radial/RadialChart.doc.mjs +1 -1
- package/src/RichTextEditor/RichTextEditor.doc.mjs +1 -1
- package/src/SVGIcon/SVGIcon.doc.mjs +1 -1
- package/src/Sankey/SankeyChart.doc.mjs +1 -1
- package/src/Schedule/Schedule.doc.mjs +1 -1
- package/src/Stat/Stat.doc.mjs +3 -3
- package/src/Stepper/Step.doc.mjs +1 -1
- package/src/Stepper/Step.tsx +1 -1
- package/src/Stepper/Stepper.doc.mjs +3 -3
- package/src/Stepper/Stepper.tsx +1 -1
- package/src/ThreeD/3DBar.doc.mjs +1 -1
- package/src/ThreeD/3DChart.doc.mjs +1 -1
- package/src/ThreeD/3DScatter.doc.mjs +1 -1
- package/src/ThreeD/3DScatterGL.doc.mjs +1 -1
- package/src/ThreeD/3DSurface.doc.mjs +1 -1
- package/src/index.ts +1 -1
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
* @output Exports useSheetGestures hook and its option/result types
|
|
9
9
|
* @position Internal to BottomSheet; not exported from the lab entry point
|
|
10
10
|
*
|
|
11
|
-
* Drag
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Drag + snap machinery for the bottom sheet. Tracks a pointer drag down the
|
|
12
|
+
* block axis, translates the sliding surface live, and on release either
|
|
13
|
+
* settles to the nearest snap detent (a slow drag) or dismisses (a fast flick
|
|
14
|
+
* down). A fast flick up expands to the tallest detent. This is the core
|
|
15
|
+
* behavior split the sheet needs: DRAG places, SWIPE closes.
|
|
15
16
|
*
|
|
16
|
-
* Kept private to BottomSheet:
|
|
17
|
-
*
|
|
17
|
+
* Kept private to BottomSheet: a dismiss edge + detents on a bottom-anchored
|
|
18
|
+
* surface are inherently sheet concepts. It is not a general primitive and is
|
|
18
19
|
* intentionally not exported.
|
|
19
20
|
*
|
|
20
21
|
* SSR-safe: no window/document access at module scope; all measurement
|
|
@@ -35,18 +36,93 @@ import {
|
|
|
35
36
|
type CSSProperties,
|
|
36
37
|
type PointerEvent as ReactPointerEvent,
|
|
37
38
|
} from 'react';
|
|
39
|
+
import {
|
|
40
|
+
computeDetentOffsets,
|
|
41
|
+
resolveSettleOffset,
|
|
42
|
+
scrimOpacityForOffset,
|
|
43
|
+
} from './snapOffsets';
|
|
44
|
+
|
|
45
|
+
// A flick (fast throw) dismisses (down) or expands (up) regardless of where
|
|
46
|
+
// it ends. Requires both a speed and a distance floor so a small nudge
|
|
47
|
+
// doesn't trigger it.
|
|
48
|
+
const FLICK_VELOCITY = 1.2; // px/ms
|
|
49
|
+
const FLICK_MIN_DISTANCE = 48; // px traveled during the gesture
|
|
50
|
+
// On a slow drag below the shortest detent, dismiss once dragged past it by
|
|
51
|
+
// more than this fraction of that detent's height; otherwise snap back to it.
|
|
52
|
+
const DISMISS_OVERSHOOT_RATIO = 0.4;
|
|
53
|
+
// Within this many px of a detent, the live drag is magnetically eased toward
|
|
54
|
+
// it so it "clicks" into place instead of hovering just off the mark.
|
|
55
|
+
const MAGNET_RANGE = 40;
|
|
56
|
+
// Rubber-band factor for dragging up past fully-open, capped at OVERSCROLL_MAX
|
|
57
|
+
// (the sheet reserves that much bottom padding for the lift to reveal).
|
|
58
|
+
const OVERSCROLL_RESISTANCE = 0.35;
|
|
59
|
+
// SYNC: must match OVERSCROLL_PADDING in BottomSheet.tsx (the reserved bottom
|
|
60
|
+
// padding the lift reveals). Kept as a local const rather than a shared import
|
|
61
|
+
// so it can be used inside stylex.create there.
|
|
62
|
+
const OVERSCROLL_MAX = 48;
|
|
38
63
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
// Short haptic tick on detent settle where supported. iOS Safari doesn't
|
|
65
|
+
// expose navigator.vibrate, so this is a no-op there; skipped under
|
|
66
|
+
// reduced-motion.
|
|
67
|
+
function hapticTick(): void {
|
|
68
|
+
if (
|
|
69
|
+
typeof navigator === 'undefined' ||
|
|
70
|
+
typeof navigator.vibrate !== 'function'
|
|
71
|
+
) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (prefersReducedMotion()) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
navigator.vibrate(8);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pull a value toward the nearest of `targets` when within MAGNET_RANGE, easing
|
|
81
|
+
// the last stretch so the surface settles crisply onto a detent while dragging.
|
|
82
|
+
function magnetize(value: number, targets: number[]): number {
|
|
83
|
+
let nearestTarget = targets[0];
|
|
84
|
+
let nearestDist = Math.abs(value - nearestTarget);
|
|
85
|
+
for (const t of targets) {
|
|
86
|
+
const d = Math.abs(value - t);
|
|
87
|
+
if (d < nearestDist) {
|
|
88
|
+
nearestDist = d;
|
|
89
|
+
nearestTarget = t;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (nearestDist >= MAGNET_RANGE) {
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
// Ease-in over the range: pull grows as you approach (t^2), so the click
|
|
96
|
+
// feels magnetic near the detent but doesn't fight a deliberate drag-through.
|
|
97
|
+
const t = nearestDist / MAGNET_RANGE;
|
|
98
|
+
const pull = 1 - t * t;
|
|
99
|
+
return value + (nearestTarget - value) * pull;
|
|
100
|
+
}
|
|
44
101
|
|
|
45
102
|
export interface UseSheetGesturesOptions {
|
|
46
103
|
/** Whether the owning sheet is open. Drag state resets when it closes. */
|
|
47
104
|
isOpen: boolean;
|
|
48
|
-
/** Called
|
|
105
|
+
/** Called on a swipe-to-close (fast downward flick, or drag past the floor). */
|
|
49
106
|
onDismiss: () => void;
|
|
107
|
+
/**
|
|
108
|
+
* Resolver for candidate detent heights in px BELOW the full sheet height.
|
|
109
|
+
* Called lazily at the start of each drag so the values stay current (e.g.
|
|
110
|
+
* after a rotation or the virtual keyboard opening) without a persistent
|
|
111
|
+
* resize listener. The measured full height is always the tallest detent,
|
|
112
|
+
* so the effective detent set is `[...snapHeights(), fullHeight]`. Omit for
|
|
113
|
+
* a single-height sheet (a drag then only dismisses or springs back).
|
|
114
|
+
*/
|
|
115
|
+
snapHeights?: () => number[];
|
|
116
|
+
/** Notified when the settled detent height (px) changes. */
|
|
117
|
+
onSnap?: (heightPx: number) => void;
|
|
118
|
+
/**
|
|
119
|
+
* Called with the scrim opacity the sheet should show (1 = fully visible,
|
|
120
|
+
* 0 = hidden) as the drag moves and on settle. Full while the sheet is at
|
|
121
|
+
* or above its mid detent, fading to 0 as it collapses onto the shortest
|
|
122
|
+
* "peek" detent — so a glance-height sheet reveals the content behind — and
|
|
123
|
+
* on the dismiss overshoot. Lets the owner mirror it onto the scrim.
|
|
124
|
+
*/
|
|
125
|
+
onScrimOpacity?: (opacity: number) => void;
|
|
50
126
|
}
|
|
51
127
|
|
|
52
128
|
export interface SheetContentProps {
|
|
@@ -61,13 +137,35 @@ export interface SheetHandleProps {
|
|
|
61
137
|
onPointerCancel: (event: ReactPointerEvent) => void;
|
|
62
138
|
}
|
|
63
139
|
|
|
140
|
+
export interface SheetBodyProps {
|
|
141
|
+
ref: (node: HTMLElement | null) => void;
|
|
142
|
+
onPointerDown: (event: ReactPointerEvent) => void;
|
|
143
|
+
onPointerMove: (event: ReactPointerEvent) => void;
|
|
144
|
+
onPointerUp: (event: ReactPointerEvent) => void;
|
|
145
|
+
onPointerCancel: (event: ReactPointerEvent) => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
64
148
|
export interface UseSheetGesturesResult {
|
|
149
|
+
/**
|
|
150
|
+
* Callback ref for the sheet surface. The hook observes it (ResizeObserver)
|
|
151
|
+
* to keep the fully-open height current, so detents stay correct across
|
|
152
|
+
* rotation / viewport changes without re-measuring mid-drag.
|
|
153
|
+
*/
|
|
154
|
+
sheetRef: (node: HTMLElement | null) => void;
|
|
65
155
|
/** Spread on the sliding surface: live translate + touch-action guard. */
|
|
66
156
|
contentProps: SheetContentProps;
|
|
67
157
|
/** Spread on the grab-handle element: pointer drag handlers. */
|
|
68
158
|
handleProps: SheetHandleProps;
|
|
69
|
-
/**
|
|
159
|
+
/**
|
|
160
|
+
* Spread on the scrollable body. An overscroll-at-top pull-down starts a
|
|
161
|
+
* sheet drag (a larger, more forgiving target when the content isn't itself
|
|
162
|
+
* scrolling); normal scrolling passes through untouched.
|
|
163
|
+
*/
|
|
164
|
+
bodyProps: SheetBodyProps;
|
|
165
|
+
/** Current live drag translate in px (0 = fully expanded, larger = collapsed). */
|
|
70
166
|
dragOffset: number;
|
|
167
|
+
/** Translate of the resting detent in px (0 = tallest detent). */
|
|
168
|
+
settledOffset: number;
|
|
71
169
|
/** Whether a drag is currently in progress. */
|
|
72
170
|
isDragging: boolean;
|
|
73
171
|
}
|
|
@@ -80,15 +178,17 @@ function prefersReducedMotion(): boolean {
|
|
|
80
178
|
}
|
|
81
179
|
|
|
82
180
|
/**
|
|
83
|
-
* Drag
|
|
84
|
-
*
|
|
85
|
-
*
|
|
181
|
+
* Drag + snap machinery for the bottom sheet. Returns props to spread on the
|
|
182
|
+
* grab handle and the sliding surface, plus live drag state. A slow drag
|
|
183
|
+
* settles to the nearest detent; a fast downward flick dismisses; a fast
|
|
184
|
+
* upward flick expands to the tallest detent.
|
|
86
185
|
*
|
|
87
186
|
* @example
|
|
88
187
|
* ```
|
|
89
188
|
* const {contentProps, handleProps} = useSheetGestures({
|
|
90
189
|
* isOpen,
|
|
91
190
|
* onDismiss: () => onOpenChange(false),
|
|
191
|
+
* snapHeights: () => [240, 0.5 * (window.visualViewport?.height ?? 0)],
|
|
92
192
|
* });
|
|
93
193
|
* <div {...contentProps}>
|
|
94
194
|
* <div {...handleProps} />
|
|
@@ -99,14 +199,23 @@ function prefersReducedMotion(): boolean {
|
|
|
99
199
|
export function useSheetGestures({
|
|
100
200
|
isOpen,
|
|
101
201
|
onDismiss,
|
|
202
|
+
snapHeights,
|
|
203
|
+
onSnap,
|
|
204
|
+
onScrimOpacity,
|
|
102
205
|
}: UseSheetGesturesOptions): UseSheetGesturesResult {
|
|
103
206
|
const [dragOffset, setDragOffset] = useState(0);
|
|
207
|
+
const [settledOffset, setSettledOffset] = useState(0);
|
|
104
208
|
const [isDragging, setIsDragging] = useState(false);
|
|
105
209
|
|
|
106
|
-
// Latest callback ref so pointer handlers stay stable across renders.
|
|
107
210
|
const onDismissRef = useRef(onDismiss);
|
|
211
|
+
const onSnapRef = useRef(onSnap);
|
|
212
|
+
const onScrimOpacityRef = useRef(onScrimOpacity);
|
|
213
|
+
const snapHeightsRef = useRef(snapHeights);
|
|
108
214
|
useEffect(() => {
|
|
109
215
|
onDismissRef.current = onDismiss;
|
|
216
|
+
onSnapRef.current = onSnap;
|
|
217
|
+
onScrimOpacityRef.current = onScrimOpacity;
|
|
218
|
+
snapHeightsRef.current = snapHeights;
|
|
110
219
|
});
|
|
111
220
|
|
|
112
221
|
// Live drag bookkeeping (refs so pointermove doesn't churn renders).
|
|
@@ -117,68 +226,189 @@ export function useSheetGestures({
|
|
|
117
226
|
lastTime: number;
|
|
118
227
|
velocity: number;
|
|
119
228
|
height: number;
|
|
229
|
+
baseOffset: number;
|
|
120
230
|
} | null>(null);
|
|
121
231
|
|
|
122
|
-
//
|
|
232
|
+
// Fully-open height, tracked by a ResizeObserver (see sheetRef) so detents
|
|
233
|
+
// stay correct across rotation / viewport changes without re-measuring.
|
|
234
|
+
const sheetHeightRef = useRef(0);
|
|
235
|
+
const sheetElRef = useRef<HTMLElement | null>(null);
|
|
236
|
+
const observerRef = useRef<ResizeObserver | null>(null);
|
|
237
|
+
const sheetRef = useCallback((node: HTMLElement | null) => {
|
|
238
|
+
observerRef.current?.disconnect();
|
|
239
|
+
observerRef.current = null;
|
|
240
|
+
sheetElRef.current = node;
|
|
241
|
+
if (!node || typeof ResizeObserver === 'undefined') {
|
|
242
|
+
if (node) {
|
|
243
|
+
sheetHeightRef.current = node.getBoundingClientRect().height;
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
sheetHeightRef.current = node.getBoundingClientRect().height;
|
|
248
|
+
const ro = new ResizeObserver(entries => {
|
|
249
|
+
const box = entries[0]?.contentRect;
|
|
250
|
+
if (box && box.height > 0) {
|
|
251
|
+
sheetHeightRef.current = box.height;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
ro.observe(node);
|
|
255
|
+
observerRef.current = ro;
|
|
256
|
+
}, []);
|
|
257
|
+
useEffect(() => () => observerRef.current?.disconnect(), []);
|
|
258
|
+
|
|
259
|
+
// Reset to the tallest detent each time the sheet re-opens.
|
|
123
260
|
useEffect(() => {
|
|
124
261
|
if (isOpen) {
|
|
125
262
|
setDragOffset(0);
|
|
263
|
+
setSettledOffset(0);
|
|
126
264
|
setIsDragging(false);
|
|
127
265
|
}
|
|
128
266
|
}, [isOpen]);
|
|
129
267
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
268
|
+
// The fully-open height for detent math. Prefer the ResizeObserver-locked
|
|
269
|
+
// value; fall back to a live measure of the tracked sheet element if the
|
|
270
|
+
// observer hasn't reported yet.
|
|
271
|
+
const measureHeight = useCallback((): number => {
|
|
272
|
+
if (sheetHeightRef.current > 0) {
|
|
273
|
+
return sheetHeightRef.current;
|
|
274
|
+
}
|
|
275
|
+
return sheetElRef.current?.getBoundingClientRect().height ?? 0;
|
|
276
|
+
}, []);
|
|
277
|
+
|
|
278
|
+
// Detent translate offsets (px) from the tallest detent, ascending. The
|
|
279
|
+
// tallest detent is offset 0; shorter detents translate further down.
|
|
280
|
+
// Snap heights are resolved lazily here (at drag start) so they reflect the
|
|
281
|
+
// current viewport without a persistent listener.
|
|
282
|
+
const detentOffsets = useCallback((height: number): number[] => {
|
|
283
|
+
return computeDetentOffsets(height, snapHeightsRef.current?.() ?? []);
|
|
133
284
|
}, []);
|
|
134
285
|
|
|
135
286
|
const settleFromDrag = useCallback(
|
|
136
|
-
(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
287
|
+
(
|
|
288
|
+
offset: number,
|
|
289
|
+
velocity: number,
|
|
290
|
+
height: number,
|
|
291
|
+
dir: number,
|
|
292
|
+
travel: number,
|
|
293
|
+
baseOffset: number,
|
|
294
|
+
) => {
|
|
295
|
+
const offsets = detentOffsets(height);
|
|
296
|
+
const maxOffset = offsets[offsets.length - 1];
|
|
297
|
+
const shortestDetentHeight = height - maxOffset;
|
|
298
|
+
const speed = Math.abs(velocity);
|
|
299
|
+
const isFlick = speed > FLICK_VELOCITY && travel > FLICK_MIN_DISTANCE;
|
|
300
|
+
|
|
301
|
+
if (dir > 0 && isFlick) {
|
|
140
302
|
onDismissRef.current();
|
|
141
303
|
return;
|
|
142
304
|
}
|
|
143
|
-
//
|
|
144
|
-
|
|
305
|
+
// Fast upward flick = expand to the tallest detent (the sheet's full
|
|
306
|
+
// provided height).
|
|
307
|
+
if (dir < 0 && isFlick) {
|
|
308
|
+
setSettledOffset(0);
|
|
309
|
+
onSnapRef.current?.(height);
|
|
310
|
+
onScrimOpacityRef.current?.(1);
|
|
311
|
+
hapticTick();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (offset > maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO) {
|
|
315
|
+
onDismissRef.current();
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
// Settle to the nearest detent in the drag direction (never back past
|
|
319
|
+
// the starting detent), de-duped and direction-clamped by the util.
|
|
320
|
+
const target = resolveSettleOffset(offset, offsets, dir, baseOffset);
|
|
321
|
+
setSettledOffset(target);
|
|
322
|
+
onSnapRef.current?.(height - target);
|
|
323
|
+
// Keep the scrim in sync with the resting detent (hidden at the peek).
|
|
324
|
+
const dismissOffset =
|
|
325
|
+
maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
|
|
326
|
+
onScrimOpacityRef.current?.(
|
|
327
|
+
scrimOpacityForOffset(target, offsets, dismissOffset),
|
|
328
|
+
);
|
|
329
|
+
// Haptic "tick" when landing on a different detent (where supported).
|
|
330
|
+
if (target !== baseOffset) {
|
|
331
|
+
hapticTick();
|
|
332
|
+
}
|
|
145
333
|
},
|
|
146
|
-
[],
|
|
334
|
+
[detentOffsets],
|
|
147
335
|
);
|
|
148
336
|
|
|
149
|
-
const
|
|
150
|
-
(event: ReactPointerEvent) => {
|
|
337
|
+
const beginDrag = useCallback(
|
|
338
|
+
(event: ReactPointerEvent, sheetHeight: number, startCoord?: number) => {
|
|
151
339
|
const target = event.currentTarget as HTMLElement;
|
|
152
340
|
target.setPointerCapture?.(event.pointerId);
|
|
341
|
+
// `startCoord` lets a body-overscroll drag anchor at the original
|
|
342
|
+
// pointer-down position (not the promotion point), so the first frame's
|
|
343
|
+
// delta reflects the full pull distance.
|
|
344
|
+
const start = startCoord ?? event.clientY;
|
|
153
345
|
dragStateRef.current = {
|
|
154
346
|
pointerId: event.pointerId,
|
|
155
|
-
startCoord:
|
|
347
|
+
startCoord: start,
|
|
156
348
|
lastCoord: event.clientY,
|
|
157
349
|
lastTime: event.timeStamp,
|
|
158
350
|
velocity: 0,
|
|
159
|
-
height:
|
|
351
|
+
height: sheetHeight,
|
|
352
|
+
baseOffset: settledOffset,
|
|
160
353
|
};
|
|
354
|
+
// Seed dragOffset at the resting detent so flipping isDragging doesn't
|
|
355
|
+
// jump the sheet to fully-open for one frame.
|
|
356
|
+
setDragOffset(settledOffset);
|
|
161
357
|
setIsDragging(true);
|
|
162
358
|
},
|
|
163
|
-
[
|
|
359
|
+
[settledOffset],
|
|
164
360
|
);
|
|
165
361
|
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
state
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
362
|
+
const handlePointerDown = useCallback(
|
|
363
|
+
(event: ReactPointerEvent) => {
|
|
364
|
+
beginDrag(event, measureHeight());
|
|
365
|
+
},
|
|
366
|
+
[beginDrag, measureHeight],
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const handlePointerMove = useCallback(
|
|
370
|
+
(event: ReactPointerEvent) => {
|
|
371
|
+
const state = dragStateRef.current;
|
|
372
|
+
if (!state || state.pointerId !== event.pointerId) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const delta = event.clientY - state.startCoord;
|
|
376
|
+
const dt = event.timeStamp - state.lastTime;
|
|
377
|
+
if (dt > 0) {
|
|
378
|
+
state.velocity = (event.clientY - state.lastCoord) / dt;
|
|
379
|
+
state.lastCoord = event.clientY;
|
|
380
|
+
state.lastTime = event.timeStamp;
|
|
381
|
+
}
|
|
382
|
+
const offsets = detentOffsets(state.height);
|
|
383
|
+
|
|
384
|
+
const raw = state.baseOffset + delta;
|
|
385
|
+
const maxDetentOffset = offsets[offsets.length - 1];
|
|
386
|
+
let next: number;
|
|
387
|
+
if (raw < 0) {
|
|
388
|
+
// Up past fully-open: damped + capped rubber-band; springs back on release.
|
|
389
|
+
next = Math.max(-OVERSCROLL_MAX, raw * OVERSCROLL_RESISTANCE);
|
|
390
|
+
} else if (raw > maxDetentOffset) {
|
|
391
|
+
// In the dismiss zone: no magnet, so it doesn't fight a drag-to-close.
|
|
392
|
+
next = raw;
|
|
393
|
+
} else {
|
|
394
|
+
// Between detents: magnetically ease toward a nearby one.
|
|
395
|
+
next = magnetize(raw, offsets);
|
|
396
|
+
}
|
|
397
|
+
setDragOffset(next);
|
|
398
|
+
|
|
399
|
+
// Mirror the scrim to the live drag: full at/above the mid detent,
|
|
400
|
+
// fading to hidden as it collapses onto the peek detent and through the
|
|
401
|
+
// dismiss overshoot.
|
|
402
|
+
const floorOffset = offsets[offsets.length - 1];
|
|
403
|
+
const shortestDetentHeight = state.height - floorOffset;
|
|
404
|
+
const dismissOffset =
|
|
405
|
+
floorOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
|
|
406
|
+
onScrimOpacityRef.current?.(
|
|
407
|
+
scrimOpacityForOffset(next, offsets, dismissOffset),
|
|
408
|
+
);
|
|
409
|
+
},
|
|
410
|
+
[detentOffsets],
|
|
411
|
+
);
|
|
182
412
|
|
|
183
413
|
const endDrag = useCallback(
|
|
184
414
|
(event: ReactPointerEvent) => {
|
|
@@ -188,27 +418,238 @@ export function useSheetGestures({
|
|
|
188
418
|
}
|
|
189
419
|
const target = event.currentTarget as HTMLElement;
|
|
190
420
|
target.releasePointerCapture?.(event.pointerId);
|
|
191
|
-
const
|
|
421
|
+
const delta = event.clientY - state.startCoord;
|
|
422
|
+
const offset = Math.max(0, state.baseOffset + delta);
|
|
423
|
+
const dir = delta === 0 ? 0 : delta > 0 ? 1 : -1;
|
|
192
424
|
dragStateRef.current = null;
|
|
193
425
|
setIsDragging(false);
|
|
194
|
-
settleFromDrag(
|
|
426
|
+
settleFromDrag(
|
|
427
|
+
offset,
|
|
428
|
+
state.velocity,
|
|
429
|
+
state.height || 1,
|
|
430
|
+
dir,
|
|
431
|
+
Math.abs(delta),
|
|
432
|
+
state.baseOffset,
|
|
433
|
+
);
|
|
195
434
|
},
|
|
196
435
|
[settleFromDrag],
|
|
197
436
|
);
|
|
198
437
|
|
|
199
|
-
//
|
|
438
|
+
// Pointer path for the body at-top pull-down (desktop / mouse). Touch uses
|
|
439
|
+
// the non-passive listener below, since pointer events are cancelled once a
|
|
440
|
+
// native pan starts.
|
|
441
|
+
const armedBodyRef = useRef<{
|
|
442
|
+
pointerId: number;
|
|
443
|
+
startCoord: number;
|
|
444
|
+
scroller: HTMLElement;
|
|
445
|
+
} | null>(null);
|
|
446
|
+
|
|
447
|
+
const handleBodyPointerDown = useCallback((event: ReactPointerEvent) => {
|
|
448
|
+
const scroller = event.currentTarget as HTMLElement;
|
|
449
|
+
if (scroller.scrollTop > 0) {
|
|
450
|
+
armedBodyRef.current = null;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
armedBodyRef.current = {
|
|
454
|
+
pointerId: event.pointerId,
|
|
455
|
+
startCoord: event.clientY,
|
|
456
|
+
scroller,
|
|
457
|
+
};
|
|
458
|
+
}, []);
|
|
459
|
+
|
|
460
|
+
const handleBodyPointerMove = useCallback(
|
|
461
|
+
(event: ReactPointerEvent) => {
|
|
462
|
+
if (dragStateRef.current) {
|
|
463
|
+
handlePointerMove(event);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const armed = armedBodyRef.current;
|
|
467
|
+
if (!armed || armed.pointerId !== event.pointerId) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const delta = event.clientY - armed.startCoord;
|
|
471
|
+
if (delta > 0 && armed.scroller.scrollTop <= 0) {
|
|
472
|
+
// Downward pull at the top: promote to a sheet drag, anchored at the
|
|
473
|
+
// original pointer-down position so the pull distance carries over.
|
|
474
|
+
armedBodyRef.current = null;
|
|
475
|
+
beginDrag(event, measureHeight(), armed.startCoord);
|
|
476
|
+
handlePointerMove(event);
|
|
477
|
+
} else if (delta < 0) {
|
|
478
|
+
// Upward move = the user is scrolling; disarm so we don't hijack it.
|
|
479
|
+
armedBodyRef.current = null;
|
|
480
|
+
}
|
|
481
|
+
},
|
|
482
|
+
[beginDrag, handlePointerMove, measureHeight],
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const handleBodyEnd = useCallback(
|
|
486
|
+
(event: ReactPointerEvent) => {
|
|
487
|
+
armedBodyRef.current = null;
|
|
488
|
+
if (dragStateRef.current) {
|
|
489
|
+
endDrag(event);
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
[endDrag],
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// Non-passive touchmove is the reliable scroll<->drag handoff on touch:
|
|
496
|
+
// preventDefault() at a scroll edge stops the native scroll and drives the
|
|
497
|
+
// sheet drag through the pointer math (Touch adapted to the fields it reads).
|
|
498
|
+
const bodyNodeRef = useRef<HTMLElement | null>(null);
|
|
499
|
+
const touchDragRef = useRef<{
|
|
500
|
+
id: number;
|
|
501
|
+
startY: number;
|
|
502
|
+
top: boolean;
|
|
503
|
+
bottom: boolean;
|
|
504
|
+
} | null>(null);
|
|
505
|
+
const prevTouchHandlers = useRef<{
|
|
506
|
+
start: (e: TouchEvent) => void;
|
|
507
|
+
move: (e: TouchEvent) => void;
|
|
508
|
+
end: (e: TouchEvent) => void;
|
|
509
|
+
} | null>(null);
|
|
510
|
+
|
|
511
|
+
const beginDragRef = useRef(beginDrag);
|
|
512
|
+
const pointerMoveRef = useRef(handlePointerMove);
|
|
513
|
+
const endDragRef = useRef(endDrag);
|
|
514
|
+
const measureHeightRef = useRef(measureHeight);
|
|
515
|
+
useEffect(() => {
|
|
516
|
+
beginDragRef.current = beginDrag;
|
|
517
|
+
pointerMoveRef.current = handlePointerMove;
|
|
518
|
+
endDragRef.current = endDrag;
|
|
519
|
+
measureHeightRef.current = measureHeight;
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const bodyRef = useCallback((node: HTMLElement | null) => {
|
|
523
|
+
const asPointer = (touch: Touch, target: HTMLElement) =>
|
|
524
|
+
({
|
|
525
|
+
pointerId: touch.identifier,
|
|
526
|
+
clientY: touch.clientY,
|
|
527
|
+
timeStamp: Date.now(),
|
|
528
|
+
currentTarget: target,
|
|
529
|
+
setPointerCapture: () => {},
|
|
530
|
+
releasePointerCapture: () => {},
|
|
531
|
+
}) as unknown as ReactPointerEvent;
|
|
532
|
+
|
|
533
|
+
const atTop = (el: HTMLElement) => el.scrollTop <= 0;
|
|
534
|
+
const atBottom = (el: HTMLElement) =>
|
|
535
|
+
el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
|
536
|
+
|
|
537
|
+
const onTouchStart = (event: TouchEvent) => {
|
|
538
|
+
const scroller = event.currentTarget as HTMLElement;
|
|
539
|
+
const touch = event.changedTouches[0];
|
|
540
|
+
// Arm only at a scroll edge; from mid-content this is an ordinary scroll.
|
|
541
|
+
// At the top, a pull DOWN hands off (collapse); at the bottom, a pull UP
|
|
542
|
+
// hands off (expand). Record the edge so the move handler matches it.
|
|
543
|
+
if (!touch) {
|
|
544
|
+
touchDragRef.current = null;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const top = atTop(scroller);
|
|
548
|
+
const bottom = atBottom(scroller);
|
|
549
|
+
if (!top && !bottom) {
|
|
550
|
+
touchDragRef.current = null;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
touchDragRef.current = {
|
|
554
|
+
id: touch.identifier,
|
|
555
|
+
startY: touch.clientY,
|
|
556
|
+
top,
|
|
557
|
+
bottom,
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const onTouchMove = (event: TouchEvent) => {
|
|
562
|
+
const scroller = event.currentTarget as HTMLElement;
|
|
563
|
+
if (dragStateRef.current) {
|
|
564
|
+
const t = [...event.changedTouches].find(
|
|
565
|
+
x => x.identifier === dragStateRef.current?.pointerId,
|
|
566
|
+
);
|
|
567
|
+
if (t) {
|
|
568
|
+
event.preventDefault();
|
|
569
|
+
pointerMoveRef.current(asPointer(t, scroller));
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const armed = touchDragRef.current;
|
|
574
|
+
if (!armed) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const t = [...event.changedTouches].find(x => x.identifier === armed.id);
|
|
578
|
+
if (!t) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const delta = t.clientY - armed.startY;
|
|
582
|
+
// Promote to a sheet drag on a pull that opposes the armed edge and can
|
|
583
|
+
// no longer scroll that way: at the top, a downward pull (delta > 0)
|
|
584
|
+
// collapses; at the bottom, an upward pull (delta < 0) expands. The
|
|
585
|
+
// opposite direction is a real scroll, so disarm and let it through.
|
|
586
|
+
const pullDownAtTop = armed.top && delta > 0 && atTop(scroller);
|
|
587
|
+
const pullUpAtBottom = armed.bottom && delta < 0 && atBottom(scroller);
|
|
588
|
+
if (pullDownAtTop || pullUpAtBottom) {
|
|
589
|
+
event.preventDefault();
|
|
590
|
+
touchDragRef.current = null;
|
|
591
|
+
beginDragRef.current(
|
|
592
|
+
asPointer(t, scroller),
|
|
593
|
+
measureHeightRef.current(),
|
|
594
|
+
armed.startY,
|
|
595
|
+
);
|
|
596
|
+
pointerMoveRef.current(asPointer(t, scroller));
|
|
597
|
+
} else if ((armed.top && delta < 0) || (armed.bottom && delta > 0)) {
|
|
598
|
+
// Scrolling away from the armed edge; hand back to native scroll.
|
|
599
|
+
touchDragRef.current = null;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const onTouchEnd = (event: TouchEvent) => {
|
|
604
|
+
touchDragRef.current = null;
|
|
605
|
+
if (dragStateRef.current) {
|
|
606
|
+
const t = event.changedTouches[0];
|
|
607
|
+
if (t) {
|
|
608
|
+
endDragRef.current(asPointer(t, event.currentTarget as HTMLElement));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const prev = bodyNodeRef.current;
|
|
614
|
+
if (prev && prevTouchHandlers.current) {
|
|
615
|
+
const h = prevTouchHandlers.current;
|
|
616
|
+
prev.removeEventListener('touchstart', h.start);
|
|
617
|
+
prev.removeEventListener('touchmove', h.move);
|
|
618
|
+
prev.removeEventListener('touchend', h.end);
|
|
619
|
+
prev.removeEventListener('touchcancel', h.end);
|
|
620
|
+
}
|
|
621
|
+
bodyNodeRef.current = node;
|
|
622
|
+
if (node) {
|
|
623
|
+
node.addEventListener('touchstart', onTouchStart, {passive: true});
|
|
624
|
+
node.addEventListener('touchmove', onTouchMove, {passive: false});
|
|
625
|
+
node.addEventListener('touchend', onTouchEnd, {passive: true});
|
|
626
|
+
node.addEventListener('touchcancel', onTouchEnd, {passive: true});
|
|
627
|
+
prevTouchHandlers.current = {
|
|
628
|
+
start: onTouchStart,
|
|
629
|
+
move: onTouchMove,
|
|
630
|
+
end: onTouchEnd,
|
|
631
|
+
};
|
|
632
|
+
} else {
|
|
633
|
+
prevTouchHandlers.current = null;
|
|
634
|
+
}
|
|
635
|
+
}, []);
|
|
636
|
+
|
|
200
637
|
const reducedMotion = useMemo(prefersReducedMotion, [isOpen]);
|
|
201
638
|
|
|
639
|
+
// While dragging, follow the finger; otherwise rest at the settled detent.
|
|
640
|
+
const activeOffset = isDragging ? dragOffset : settledOffset;
|
|
641
|
+
|
|
202
642
|
const contentProps = useMemo<SheetContentProps>(
|
|
203
643
|
() => ({
|
|
204
644
|
style: {
|
|
205
|
-
transform:
|
|
645
|
+
transform:
|
|
646
|
+
activeOffset !== 0 ? `translateY(${activeOffset}px)` : undefined,
|
|
206
647
|
transition: isDragging || reducedMotion ? 'none' : undefined,
|
|
207
648
|
touchAction: 'none',
|
|
208
649
|
overscrollBehavior: 'contain',
|
|
209
650
|
},
|
|
210
651
|
}),
|
|
211
|
-
[
|
|
652
|
+
[activeOffset, isDragging, reducedMotion],
|
|
212
653
|
);
|
|
213
654
|
|
|
214
655
|
const handleProps = useMemo<SheetHandleProps>(
|
|
@@ -222,5 +663,24 @@ export function useSheetGestures({
|
|
|
222
663
|
[handlePointerDown, handlePointerMove, endDrag],
|
|
223
664
|
);
|
|
224
665
|
|
|
225
|
-
|
|
666
|
+
const bodyProps = useMemo<SheetBodyProps>(
|
|
667
|
+
() => ({
|
|
668
|
+
ref: bodyRef,
|
|
669
|
+
onPointerDown: handleBodyPointerDown,
|
|
670
|
+
onPointerMove: handleBodyPointerMove,
|
|
671
|
+
onPointerUp: handleBodyEnd,
|
|
672
|
+
onPointerCancel: handleBodyEnd,
|
|
673
|
+
}),
|
|
674
|
+
[bodyRef, handleBodyPointerDown, handleBodyPointerMove, handleBodyEnd],
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
sheetRef,
|
|
679
|
+
contentProps,
|
|
680
|
+
handleProps,
|
|
681
|
+
bodyProps,
|
|
682
|
+
dragOffset,
|
|
683
|
+
settledOffset,
|
|
684
|
+
isDragging,
|
|
685
|
+
};
|
|
226
686
|
}
|