@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.
Files changed (63) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +41 -35
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +222 -75
  4. package/dist/BottomSheet/snapOffsets.d.ts +54 -0
  5. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  6. package/dist/BottomSheet/snapOffsets.js +101 -0
  7. package/dist/BottomSheet/useSheetGestures.d.ts +55 -12
  8. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  9. package/dist/BottomSheet/useSheetGestures.js +383 -40
  10. package/dist/Chart/index.d.ts +1 -1
  11. package/dist/Chart/index.d.ts.map +1 -1
  12. package/dist/Chart/index.js +1 -1
  13. package/dist/Stepper/Step.d.ts +1 -1
  14. package/dist/Stepper/Step.js +1 -1
  15. package/dist/Stepper/Stepper.d.ts +1 -1
  16. package/dist/Stepper/Stepper.js +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/lab.css +15 -8
  19. package/package.json +3 -2
  20. package/src/BottomSheet/BottomSheet.doc.mjs +13 -13
  21. package/src/BottomSheet/BottomSheet.test.tsx +81 -4
  22. package/src/BottomSheet/BottomSheet.tsx +333 -97
  23. package/src/BottomSheet/snapOffsets.test.ts +146 -0
  24. package/src/BottomSheet/snapOffsets.ts +126 -0
  25. package/src/BottomSheet/useSheetGestures.doc.mjs +7 -6
  26. package/src/BottomSheet/useSheetGestures.test.ts +315 -65
  27. package/src/BottomSheet/useSheetGestures.ts +515 -55
  28. package/src/Chart/ChartArea.doc.mjs +1 -1
  29. package/src/Chart/ChartBar.doc.mjs +1 -1
  30. package/src/Chart/ChartCandlestick.doc.mjs +1 -1
  31. package/src/Chart/ChartDot.doc.mjs +1 -1
  32. package/src/Chart/ChartDotGL.doc.mjs +1 -1
  33. package/src/Chart/ChartDotGLInteractive.doc.mjs +1 -1
  34. package/src/Chart/ChartHeatmapGL.doc.mjs +1 -1
  35. package/src/Chart/ChartLine.doc.mjs +1 -1
  36. package/src/Chart/ChartStreamGL.doc.mjs +1 -1
  37. package/src/Chart/index.ts +0 -1
  38. package/src/Chat/ChatEmojiPicker.doc.mjs +1 -1
  39. package/src/Chat/ChatReactionBar.doc.mjs +1 -1
  40. package/src/Chat/ChatTypingIndicator.doc.mjs +1 -1
  41. package/src/Chat/ChatUnreadDivider.doc.mjs +1 -1
  42. package/src/ChatReasoning/ChatReasoning.doc.mjs +1 -1
  43. package/src/CircularProgress/CircularProgress.doc.mjs +3 -3
  44. package/src/CodeEditor/CodeEditor.doc.mjs +1 -1
  45. package/src/Drawer/Drawer.doc.mjs +3 -3
  46. package/src/InfoTip/InfoTip.doc.mjs +3 -3
  47. package/src/LogStream/LogStream.doc.mjs +1 -1
  48. package/src/Radial/RadialChart.doc.mjs +1 -1
  49. package/src/RichTextEditor/RichTextEditor.doc.mjs +1 -1
  50. package/src/SVGIcon/SVGIcon.doc.mjs +1 -1
  51. package/src/Sankey/SankeyChart.doc.mjs +1 -1
  52. package/src/Schedule/Schedule.doc.mjs +1 -1
  53. package/src/Stat/Stat.doc.mjs +3 -3
  54. package/src/Stepper/Step.doc.mjs +1 -1
  55. package/src/Stepper/Step.tsx +1 -1
  56. package/src/Stepper/Stepper.doc.mjs +3 -3
  57. package/src/Stepper/Stepper.tsx +1 -1
  58. package/src/ThreeD/3DBar.doc.mjs +1 -1
  59. package/src/ThreeD/3DChart.doc.mjs +1 -1
  60. package/src/ThreeD/3DScatter.doc.mjs +1 -1
  61. package/src/ThreeD/3DScatterGL.doc.mjs +1 -1
  62. package/src/ThreeD/3DSurface.doc.mjs +1 -1
  63. 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-to-dismiss machinery for the bottom sheet. Tracks a pointer drag
12
- * down the block axis, translates the sliding surface live, and on release
13
- * either dismisses (past a distance or velocity threshold) or springs back
14
- * to fully open. Pointer-events based so one code path covers mouse + touch.
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: the behavior is sheet-specific (a dismiss
17
- * edge on a bottom-anchored surface). It is not a general primitive and is
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
- // Fraction of the measured sheet height a drag must cross (with low
40
- // velocity) before release dismisses instead of springing back.
41
- const DISMISS_DISTANCE_RATIO = 0.25;
42
- // A quick flick past this speed (px/ms) dismisses regardless of distance.
43
- const DISMISS_VELOCITY = 0.5;
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 when a drag crosses the dismiss threshold (distance or flick). */
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
- /** Current live drag offset in px (positive = dragged toward the edge). */
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-to-dismiss machinery for the bottom sheet. Returns props to spread on
84
- * the grab handle and the sliding surface, plus live drag state for callers
85
- * that want to drive their own animation.
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
- // Reset drag state each time the sheet re-opens.
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
- const measureHeight = useCallback((el: HTMLElement | null): number => {
131
- const sheet = el?.closest<HTMLElement>('[data-astryx-sheet]') ?? el;
132
- return sheet?.getBoundingClientRect().height ?? 0;
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
- (offset: number, velocity: number, height: number) => {
137
- const dismiss =
138
- offset > height * DISMISS_DISTANCE_RATIO || velocity > DISMISS_VELOCITY;
139
- if (dismiss) {
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
- // Below threshold: spring back to fully open.
144
- setDragOffset(0);
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 handlePointerDown = useCallback(
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: event.clientY,
347
+ startCoord: start,
156
348
  lastCoord: event.clientY,
157
349
  lastTime: event.timeStamp,
158
350
  velocity: 0,
159
- height: measureHeight(target),
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
- [measureHeight],
359
+ [settledOffset],
164
360
  );
165
361
 
166
- const handlePointerMove = useCallback((event: ReactPointerEvent) => {
167
- const state = dragStateRef.current;
168
- if (!state || state.pointerId !== event.pointerId) {
169
- return;
170
- }
171
- // Dragging DOWN (increasing Y) collapses/dismisses. Only allow drag in
172
- // the closing direction past the fully-open position (no overscroll).
173
- const delta = event.clientY - state.startCoord;
174
- const dt = event.timeStamp - state.lastTime;
175
- if (dt > 0) {
176
- state.velocity = (event.clientY - state.lastCoord) / dt;
177
- state.lastCoord = event.clientY;
178
- state.lastTime = event.timeStamp;
179
- }
180
- setDragOffset(Math.max(0, delta));
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 offset = Math.max(0, event.clientY - state.startCoord);
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(offset, state.velocity, state.height || 1);
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
- // Reduced motion skips the settle transition (snaps instantly).
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: dragOffset !== 0 ? `translateY(${dragOffset}px)` : undefined,
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
- [dragOffset, isDragging, reducedMotion],
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
- return {contentProps, handleProps, dragOffset, isDragging};
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
  }