@astryxdesign/core 0.4.3 → 0.4.4-canary.0f4d45f
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/CHANGELOG.md +60 -0
- package/README.md +79 -45
- package/dist/Banner/Banner.d.ts +3 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +13 -3
- package/dist/BottomSheet/BottomSheet.d.ts +66 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +392 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +398 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
- package/dist/BottomSheet/index.d.ts +11 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +12 -0
- package/dist/BottomSheet/snapOffsets.d.ts +72 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +130 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +373 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +989 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +12 -6
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +9 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +31 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/Field/InputClearButton.d.ts +2 -1
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -1
- package/dist/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +9 -3
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +21 -7
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +9 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +14 -3
- package/dist/StatusDot/StatusDot.d.ts +43 -3
- package/dist/StatusDot/StatusDot.d.ts.map +1 -1
- package/dist/StatusDot/StatusDot.js +42 -5
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +8 -1
- package/dist/Tokenizer/Tokenizer.js +11 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -3
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +21 -0
- package/dist/hooks/index.d.ts +8 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -1
- package/dist/hooks/useFocusTrap.d.ts +0 -11
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +1 -11
- package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/defineTheme.d.ts +22 -4
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/utils/ime.d.ts +5 -0
- package/dist/utils/ime.d.ts.map +1 -0
- package/dist/utils/ime.js +63 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/locales/af-ZA.json +958 -0
- package/locales/ar-SA.json +998 -0
- package/locales/ca-ES.json +974 -0
- package/locales/cs-CZ.json +990 -0
- package/locales/da-DK.json +966 -0
- package/locales/de-DE.json +966 -0
- package/locales/el-GR.json +990 -0
- package/locales/en.json +48 -0
- package/locales/es-ES.json +978 -0
- package/locales/fi-FI.json +990 -0
- package/locales/fr-FR.json +928 -0
- package/locales/he-IL.json +990 -0
- package/locales/hu-HU.json +990 -0
- package/locales/it-IT.json +978 -0
- package/locales/ja-JP.json +998 -0
- package/locales/ko-KR.json +990 -0
- package/locales/nl-NL.json +954 -0
- package/locales/no-NO.json +978 -0
- package/locales/pl-PL.json +982 -0
- package/locales/pseudo.json +36 -0
- package/locales/pt-BR.json +982 -0
- package/locales/pt-PT.json +982 -0
- package/locales/ro-RO.json +982 -0
- package/locales/ru-RU.json +990 -0
- package/locales/sr-SP.json +990 -0
- package/locales/sv-SE.json +986 -0
- package/locales/tr-TR.json +990 -0
- package/locales/uk-UA.json +990 -0
- package/locales/vi-VN.json +986 -0
- package/locales/zh-CN.json +998 -0
- package/locales/zh-TW.json +998 -0
- package/package.json +9 -8
- package/src/Banner/Banner.doc.mjs +3 -3
- package/src/Banner/Banner.test.tsx +40 -0
- package/src/Banner/Banner.tsx +27 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
- package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
- package/src/BottomSheet/BottomSheet.tsx +545 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
- package/src/BottomSheet/index.ts +15 -0
- package/src/BottomSheet/snapOffsets.test.ts +187 -0
- package/src/BottomSheet/snapOffsets.ts +159 -0
- package/src/BottomSheet/useMobileKeyboard.ts +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
- package/src/BottomSheet/useSheetGestures.ts +1459 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/ComplexSelector/ComplexSelector.tsx +12 -5
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +9 -1
- package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +39 -2
- package/src/Dialog/Dialog.tsx +2 -1
- package/src/Field/Field.doc.mjs +1 -0
- package/src/Field/InputClearButton.test.tsx +25 -0
- package/src/Field/InputClearButton.tsx +4 -1
- package/src/FileInput/FileInput.test.tsx +53 -9
- package/src/Lightbox/Lightbox.test.tsx +49 -28
- package/src/Lightbox/Lightbox.tsx +7 -3
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/MultiSelector/MultiSelector.test.tsx +195 -51
- package/src/MultiSelector/MultiSelector.tsx +22 -8
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +9 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/Selector/Selector.test.tsx +85 -26
- package/src/Selector/Selector.tsx +13 -4
- package/src/StatusDot/StatusDot.doc.mjs +13 -0
- package/src/StatusDot/StatusDot.test.tsx +115 -8
- package/src/StatusDot/StatusDot.tsx +74 -9
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +8 -0
- package/src/Tokenizer/Tokenizer.test.tsx +50 -32
- package/src/Tokenizer/Tokenizer.tsx +5 -5
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/Typeahead/BaseTypeahead.tsx +10 -3
- package/src/Typeahead/Typeahead.test.tsx +116 -7
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/hooks/index.ts +9 -5
- package/src/hooks/useFocusTrap.ts +1 -14
- package/src/hooks/useInputStatusIcon.test.tsx +19 -0
- package/src/hooks/useInputStatusIcon.tsx +7 -0
- package/src/index.ts +1 -0
- package/src/theme/defineTheme.ts +22 -4
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +2 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -0,0 +1,989 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file useSheetGestures.ts
|
|
7
|
+
* @input Uses React (useCallback, useEffect, useMemo, useRef, useState) and
|
|
8
|
+
* the core useMediaQuery hook
|
|
9
|
+
* @output Exports useSheetGestures hook and its option/result types
|
|
10
|
+
* @position Internal to BottomSheet; not exported from the core entry point
|
|
11
|
+
*
|
|
12
|
+
* Drag + snap machinery for the bottom sheet. Tracks a pointer drag down the
|
|
13
|
+
* block axis, translates the sliding surface live, and on release either
|
|
14
|
+
* settles to the nearest snap detent (a slow drag) or dismisses (a fast flick
|
|
15
|
+
* down). A fast flick up expands to the tallest detent. This is the core
|
|
16
|
+
* behavior split the sheet needs: DRAG places, SWIPE closes.
|
|
17
|
+
*
|
|
18
|
+
* A settled detent is split across two properties: `settledLayoutOffset` is
|
|
19
|
+
* the part the scrolling area gives up as layout height, and the remainder is
|
|
20
|
+
* a transform. Gestures and snaps only ever move the transform, so they stay
|
|
21
|
+
* on the compositor; layout height changes at rest, in one transition-free
|
|
22
|
+
* render whose visible geometry is identical. The peek detent keeps the full
|
|
23
|
+
* height and slides below the viewport instead of reflowing to a sliver.
|
|
24
|
+
*
|
|
25
|
+
* Those are pixels, and the detents behind them are viewport fractions, so a
|
|
26
|
+
* sheet at rest re-resolves its detent on `resize` / `orientationchange` and
|
|
27
|
+
* re-anchors to the new geometry without animating. The detent it returns to
|
|
28
|
+
* is tracked by index: the pixels stop meaning the same thing when the
|
|
29
|
+
* viewport changes, the stop the user chose does not.
|
|
30
|
+
*
|
|
31
|
+
* On touch, the scrolling body hands the gesture to the sheet at a scroll
|
|
32
|
+
* edge. Two shapes, because the browser only offers one of them a choice: a
|
|
33
|
+
* finger that lands on an edge and pulls away from it promotes by cancelling
|
|
34
|
+
* the first, still-cancelable touchmove; a finger that scrolls INTO the end of
|
|
35
|
+
* the content mid-gesture cannot, since the browser has committed the gesture
|
|
36
|
+
* to scrolling and every remaining event is non-cancelable. The second shape
|
|
37
|
+
* needs no cancelling — the scroller is clamped at its end, so there is
|
|
38
|
+
* nothing left to scroll — and instead anchors at the point where the content
|
|
39
|
+
* ran out and drives the sheet from the travel beyond it.
|
|
40
|
+
*
|
|
41
|
+
* Kept private to BottomSheet: a dismiss edge + detents on a bottom-anchored
|
|
42
|
+
* surface are inherently sheet concepts. It is not a general primitive and is
|
|
43
|
+
* intentionally not exported.
|
|
44
|
+
*
|
|
45
|
+
* SSR-safe: no window/document access at module scope; all measurement
|
|
46
|
+
* happens inside handlers. Respects prefers-reduced-motion by skipping the
|
|
47
|
+
* settle transition.
|
|
48
|
+
*
|
|
49
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
50
|
+
* - /packages/core/src/BottomSheet/useSheetGestures.test.ts
|
|
51
|
+
*/
|
|
52
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
53
|
+
import { useMediaQuery } from "../hooks/index.js";
|
|
54
|
+
import { computeDetentOffsets, isPeekOffset, resolveSettleOffset, scrimOpacityForOffset } from "./snapOffsets.js";
|
|
55
|
+
|
|
56
|
+
// A flick (fast throw) dismisses (down) or expands (up) regardless of where
|
|
57
|
+
// it ends. Requires both a speed and a distance floor so a small nudge
|
|
58
|
+
// doesn't trigger it.
|
|
59
|
+
const FLICK_VELOCITY = 1.2; // px/ms
|
|
60
|
+
const FLICK_MIN_DISTANCE = 48; // px traveled during the gesture
|
|
61
|
+
// On a slow drag below the shortest detent, dismiss once dragged past it by
|
|
62
|
+
// more than this fraction of that detent's height; otherwise snap back to it.
|
|
63
|
+
const DISMISS_OVERSHOOT_RATIO = 0.4;
|
|
64
|
+
// Within this many px of a detent, the live drag is magnetically eased toward
|
|
65
|
+
// it so it "clicks" into place instead of hovering just off the mark.
|
|
66
|
+
const MAGNET_RANGE = 40;
|
|
67
|
+
// The touch path drives the sheet through the same machinery the pointer path
|
|
68
|
+
// uses, by handing it a pointer-shaped object built from a `Touch`. On iOS
|
|
69
|
+
// Safari that object is indistinguishable from the real thing: WebKit raises
|
|
70
|
+
// PointerEvents for a finger under the SAME numeric id it puts in
|
|
71
|
+
// `Touch.identifier`, so such a drag is keyed to a live pointer and the
|
|
72
|
+
// handlers that guard a mouse drag fire against it. Mark the synthetic object
|
|
73
|
+
// so they can tell the two apart.
|
|
74
|
+
|
|
75
|
+
function isSyntheticTouch(event) {
|
|
76
|
+
return event.syntheticTouch === true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Rubber-band factor for dragging up past fully-open, capped at OVERSCROLL_MAX
|
|
80
|
+
// (the sheet reserves that much bottom padding for the lift to reveal).
|
|
81
|
+
const OVERSCROLL_RESISTANCE = 0.35;
|
|
82
|
+
// SYNC: must match OVERSCROLL_PADDING in BottomSheetPanel.tsx (the reserved
|
|
83
|
+
// bottom padding the lift reveals). Kept as a local const rather than a shared
|
|
84
|
+
// import so it can be used inside stylex.create there.
|
|
85
|
+
const OVERSCROLL_MAX = 48;
|
|
86
|
+
// Travel past the point where a scrolling gesture ran out of content before it
|
|
87
|
+
// hands the sheet the rest of the pull. Small enough to feel continuous with
|
|
88
|
+
// the scroll, large enough that a swipe merely coming to rest on the last pixel
|
|
89
|
+
// doesn't start a drag on jitter.
|
|
90
|
+
const CONTENT_END_HANDOFF_SLOP = 4;
|
|
91
|
+
|
|
92
|
+
// Short haptic tick on detent settle where supported. iOS Safari doesn't
|
|
93
|
+
// expose navigator.vibrate, so this is a no-op there; skipped under
|
|
94
|
+
// reduced-motion.
|
|
95
|
+
function hapticTick() {
|
|
96
|
+
if (typeof navigator === 'undefined' || typeof navigator.vibrate !== 'function') {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (prefersReducedMotion()) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
navigator.vibrate(8);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pull a value toward the nearest of `targets` when within MAGNET_RANGE, easing
|
|
106
|
+
// the last stretch so the surface settles crisply onto a detent while dragging.
|
|
107
|
+
function magnetize(value, targets) {
|
|
108
|
+
let nearestTarget = targets[0];
|
|
109
|
+
let nearestDist = Math.abs(value - nearestTarget);
|
|
110
|
+
for (const t of targets) {
|
|
111
|
+
const d = Math.abs(value - t);
|
|
112
|
+
if (d < nearestDist) {
|
|
113
|
+
nearestDist = d;
|
|
114
|
+
nearestTarget = t;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (nearestDist >= MAGNET_RANGE) {
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
// Ease-in over the range: pull grows as you approach (t^2), so the click
|
|
121
|
+
// feels magnetic near the detent but doesn't fight a deliberate drag-through.
|
|
122
|
+
const t = nearestDist / MAGNET_RANGE;
|
|
123
|
+
const pull = 1 - t * t;
|
|
124
|
+
return value + (nearestTarget - value) * pull;
|
|
125
|
+
}
|
|
126
|
+
function preservationInsetForOffset(baseOffset, targetOffset, naturalEndGap) {
|
|
127
|
+
return Math.max(0, baseOffset - targetOffset - naturalEndGap);
|
|
128
|
+
}
|
|
129
|
+
function renderedBlockEndPadding(element) {
|
|
130
|
+
const value = Number.parseFloat(getComputedStyle(element).paddingBlockEnd);
|
|
131
|
+
return Number.isFinite(value) ? value : 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// How much further the body could scroll on its own, ignoring the padding the
|
|
135
|
+
// hook adds to preserve scroll position across a height change.
|
|
136
|
+
function naturalEndGapFor(body) {
|
|
137
|
+
if (!body) {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
const renderedInset = renderedBlockEndPadding(body);
|
|
141
|
+
const naturalMaxScrollTop = Math.max(0, body.scrollHeight - body.clientHeight - renderedInset);
|
|
142
|
+
return naturalMaxScrollTop - body.scrollTop;
|
|
143
|
+
}
|
|
144
|
+
function visibleHeightForOffset(sheetHeight, offset, offscreenBlockEndInset) {
|
|
145
|
+
return Math.max(0, sheetHeight - offscreenBlockEndInset - offset);
|
|
146
|
+
}
|
|
147
|
+
function prefersReducedMotion() {
|
|
148
|
+
if (typeof window === 'undefined' || !window.matchMedia) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Drag + snap machinery for the bottom sheet. Returns props to spread on the
|
|
156
|
+
* grab handle and the sliding surface, plus live drag state. A slow drag
|
|
157
|
+
* settles to the nearest detent; a fast downward flick dismisses; a fast
|
|
158
|
+
* upward flick expands to the tallest detent.
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```
|
|
162
|
+
* const {contentProps, handleProps} = useSheetGestures({
|
|
163
|
+
* isOpen,
|
|
164
|
+
* onDismiss: () => onOpenChange(false),
|
|
165
|
+
* snapHeights: () => [240, 0.5 * (window.visualViewport?.height ?? 0)],
|
|
166
|
+
* });
|
|
167
|
+
* <div {...contentProps}>
|
|
168
|
+
* <div {...handleProps} />
|
|
169
|
+
* {children}
|
|
170
|
+
* </div>
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export function useSheetGestures({
|
|
174
|
+
isOpen,
|
|
175
|
+
canDismiss = true,
|
|
176
|
+
offscreenBlockEndInset = 0,
|
|
177
|
+
onDismiss,
|
|
178
|
+
snapHeights,
|
|
179
|
+
onSnap,
|
|
180
|
+
onScrimOpacity
|
|
181
|
+
}) {
|
|
182
|
+
const [dragOffset, setDragOffset] = useState(0);
|
|
183
|
+
const [settledOffset, setSettledOffset] = useState(0);
|
|
184
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
185
|
+
const [sheetHeight, setSheetHeight] = useState(0);
|
|
186
|
+
const [scrollPreservationInset, setScrollPreservationInset] = useState(0);
|
|
187
|
+
// How much of the settled travel is expressed as layout height. Equal to
|
|
188
|
+
// settledOffset for the resizing detents, and 0 at fully open and at the
|
|
189
|
+
// peek (see isPeekOffset), which keep the full height and use a transform.
|
|
190
|
+
const [settledLayoutOffset, setSettledLayoutOffset] = useState(0);
|
|
191
|
+
const settledLayoutOffsetRef = useRef(0);
|
|
192
|
+
// WHICH detent the sheet rests on, as an index into the resolved list. The
|
|
193
|
+
// offsets themselves are pixels derived from the viewport, so they stop
|
|
194
|
+
// meaning the same thing the moment the viewport changes; the index survives
|
|
195
|
+
// that and lets a resize re-resolve the same stop against the new geometry.
|
|
196
|
+
const settledDetentIndexRef = useRef(0);
|
|
197
|
+
const [settlingLayoutOffset, setSettlingLayoutOffset] = useState(null);
|
|
198
|
+
const [isScrollAreaReconciling, setIsScrollAreaReconciling] = useState(false);
|
|
199
|
+
const scrollPreservationInsetRef = useRef(0);
|
|
200
|
+
const settlingLayoutOffsetRef = useRef(null);
|
|
201
|
+
const pendingScrollPreservationInsetRef = useRef(null);
|
|
202
|
+
const offscreenBlockEndInsetRef = useRef(offscreenBlockEndInset);
|
|
203
|
+
offscreenBlockEndInsetRef.current = Math.max(0, offscreenBlockEndInset);
|
|
204
|
+
const recordSettledLayoutOffset = useCallback(offset => {
|
|
205
|
+
const normalizedOffset = Math.max(0, offset);
|
|
206
|
+
settledLayoutOffsetRef.current = normalizedOffset;
|
|
207
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- layout offset is recorded from measurement effects
|
|
208
|
+
setSettledLayoutOffset(normalizedOffset);
|
|
209
|
+
}, []);
|
|
210
|
+
const updateScrollPreservationInset = useCallback(nextInset => {
|
|
211
|
+
const normalizedInset = Math.max(0, nextInset);
|
|
212
|
+
const hasChanged = Math.abs(normalizedInset - scrollPreservationInsetRef.current) > 0.5;
|
|
213
|
+
if (!hasChanged) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
scrollPreservationInsetRef.current = normalizedInset;
|
|
217
|
+
setScrollPreservationInset(normalizedInset);
|
|
218
|
+
}, []);
|
|
219
|
+
const completeScrollAreaSettle = useCallback(() => {
|
|
220
|
+
// Resetting the transform at the same time as the final height swap must
|
|
221
|
+
// not start a second transition. The layouts have identical visible
|
|
222
|
+
// geometry, so reconcile them with transitions disabled for one frame.
|
|
223
|
+
setIsScrollAreaReconciling(true);
|
|
224
|
+
settlingLayoutOffsetRef.current = null;
|
|
225
|
+
setSettlingLayoutOffset(null);
|
|
226
|
+
const pendingInset = pendingScrollPreservationInsetRef.current;
|
|
227
|
+
pendingScrollPreservationInsetRef.current = null;
|
|
228
|
+
if (pendingInset != null) {
|
|
229
|
+
updateScrollPreservationInset(pendingInset);
|
|
230
|
+
}
|
|
231
|
+
}, [updateScrollPreservationInset]);
|
|
232
|
+
const prepareScrollAreaSettle = useCallback((baseLayoutOffset, targetOffset, targetLayoutOffset, renderedOffset, layoutOffset, naturalEndGap, shouldAnimate) => {
|
|
233
|
+
const targetInset = preservationInsetForOffset(baseLayoutOffset, targetLayoutOffset, naturalEndGap);
|
|
234
|
+
if (shouldAnimate && Math.abs(renderedOffset - targetOffset) > 0.5 && !prefersReducedMotion()) {
|
|
235
|
+
pendingScrollPreservationInsetRef.current = targetInset;
|
|
236
|
+
settlingLayoutOffsetRef.current = layoutOffset;
|
|
237
|
+
setSettlingLayoutOffset(layoutOffset);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
pendingScrollPreservationInsetRef.current = null;
|
|
241
|
+
settlingLayoutOffsetRef.current = null;
|
|
242
|
+
setSettlingLayoutOffset(null);
|
|
243
|
+
// Released on the detent, so there is no travel left to animate — but
|
|
244
|
+
// the layout split may still differ from the one the drag rendered
|
|
245
|
+
// with (magnetize() lands a slow drag exactly on a detent). Swapping
|
|
246
|
+
// height for transform is only invisible while transitions are off:
|
|
247
|
+
// with them live, the composited transform would animate the whole
|
|
248
|
+
// swap while the layout height jumped, throwing the sheet the wrong
|
|
249
|
+
// way. Reconcile in one transition-free frame instead.
|
|
250
|
+
setIsScrollAreaReconciling(Math.abs(layoutOffset - targetLayoutOffset) > 0.5);
|
|
251
|
+
updateScrollPreservationInset(targetInset);
|
|
252
|
+
}, [updateScrollPreservationInset]);
|
|
253
|
+
const activeOffsetRef = useRef(0);
|
|
254
|
+
activeOffsetRef.current = isDragging ? dragOffset : settledOffset;
|
|
255
|
+
const isOpenRef = useRef(isOpen);
|
|
256
|
+
isOpenRef.current = isOpen;
|
|
257
|
+
const onDismissRef = useRef(onDismiss);
|
|
258
|
+
const canDismissRef = useRef(canDismiss);
|
|
259
|
+
const onSnapRef = useRef(onSnap);
|
|
260
|
+
const onScrimOpacityRef = useRef(onScrimOpacity);
|
|
261
|
+
const snapHeightsRef = useRef(snapHeights);
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
onDismissRef.current = onDismiss;
|
|
264
|
+
canDismissRef.current = canDismiss;
|
|
265
|
+
onSnapRef.current = onSnap;
|
|
266
|
+
onScrimOpacityRef.current = onScrimOpacity;
|
|
267
|
+
snapHeightsRef.current = snapHeights;
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// Live drag bookkeeping (refs so pointermove doesn't churn renders).
|
|
271
|
+
const dragStateRef = useRef(null);
|
|
272
|
+
|
|
273
|
+
// Fully-open height, tracked by a ResizeObserver (see sheetRef) so detents
|
|
274
|
+
// stay correct across rotation / viewport changes without re-measuring.
|
|
275
|
+
const sheetHeightRef = useRef(0);
|
|
276
|
+
const sheetElRef = useRef(null);
|
|
277
|
+
const bodyNodeRef = useRef(null);
|
|
278
|
+
const observerRef = useRef(null);
|
|
279
|
+
useLayoutEffect(() => {
|
|
280
|
+
if (!isScrollAreaReconciling) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Force the transition-free transform reset to resolve before transitions
|
|
284
|
+
// are restored. Otherwise both DOM updates can be coalesced and the reset
|
|
285
|
+
// becomes an unintended fly-in animation from the bottom.
|
|
286
|
+
void sheetElRef.current?.offsetHeight;
|
|
287
|
+
const frame = requestAnimationFrame(() => {
|
|
288
|
+
setIsScrollAreaReconciling(false);
|
|
289
|
+
});
|
|
290
|
+
return () => cancelAnimationFrame(frame);
|
|
291
|
+
}, [isScrollAreaReconciling]);
|
|
292
|
+
const recordSheetHeight = useCallback(renderedHeight => {
|
|
293
|
+
if (renderedHeight <= 0) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
// A resized or closing panel's observer reports intermediate heights
|
|
297
|
+
// throughout its animation. Keep the last fully-expanded measurement
|
|
298
|
+
// instead of feeding those transient values back into the rendered height.
|
|
299
|
+
if (!isOpenRef.current || activeOffsetRef.current > 0) {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
sheetHeightRef.current = renderedHeight;
|
|
303
|
+
setSheetHeight(previousHeight => previousHeight === renderedHeight ? previousHeight : renderedHeight);
|
|
304
|
+
}, []);
|
|
305
|
+
const sheetRef = useCallback(node => {
|
|
306
|
+
observerRef.current?.disconnect();
|
|
307
|
+
observerRef.current = null;
|
|
308
|
+
sheetElRef.current = node;
|
|
309
|
+
if (!node || typeof ResizeObserver === 'undefined') {
|
|
310
|
+
if (node) {
|
|
311
|
+
recordSheetHeight(node.getBoundingClientRect().height);
|
|
312
|
+
}
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
recordSheetHeight(node.getBoundingClientRect().height);
|
|
316
|
+
const ro = new ResizeObserver(entries => {
|
|
317
|
+
const entry = entries[0];
|
|
318
|
+
if (entry) {
|
|
319
|
+
// Keep this measurement in the same border-box coordinate space as
|
|
320
|
+
// getBoundingClientRect(). contentRect excludes the sheet's reserved
|
|
321
|
+
// bottom padding and would make the first resized drag jump shorter.
|
|
322
|
+
const borderBoxHeight = entry.borderBoxSize?.[0]?.blockSize;
|
|
323
|
+
recordSheetHeight(borderBoxHeight ?? entry.target.getBoundingClientRect().height);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
ro.observe(node);
|
|
327
|
+
observerRef.current = ro;
|
|
328
|
+
}, [recordSheetHeight]);
|
|
329
|
+
useEffect(() => () => observerRef.current?.disconnect(), []);
|
|
330
|
+
|
|
331
|
+
// Reset to the tallest detent each time the sheet re-opens.
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (isOpen) {
|
|
334
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
|
|
335
|
+
setDragOffset(0);
|
|
336
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
|
|
337
|
+
setSettledOffset(0);
|
|
338
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
|
|
339
|
+
setIsDragging(false);
|
|
340
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
|
|
341
|
+
setIsScrollAreaReconciling(false);
|
|
342
|
+
recordSettledLayoutOffset(0);
|
|
343
|
+
settledDetentIndexRef.current = 0;
|
|
344
|
+
scrollPreservationInsetRef.current = 0;
|
|
345
|
+
settlingLayoutOffsetRef.current = null;
|
|
346
|
+
pendingScrollPreservationInsetRef.current = null;
|
|
347
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
|
|
348
|
+
setScrollPreservationInset(0);
|
|
349
|
+
// eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
|
|
350
|
+
setSettlingLayoutOffset(null);
|
|
351
|
+
}
|
|
352
|
+
}, [isOpen, recordSettledLayoutOffset]);
|
|
353
|
+
|
|
354
|
+
// The fully-open height for detent math. Prefer the ResizeObserver-locked
|
|
355
|
+
// value; fall back to a live measure of the tracked sheet element if the
|
|
356
|
+
// observer hasn't reported yet.
|
|
357
|
+
const measureHeight = useCallback(() => {
|
|
358
|
+
if (sheetHeightRef.current > 0) {
|
|
359
|
+
return sheetHeightRef.current;
|
|
360
|
+
}
|
|
361
|
+
return sheetElRef.current?.getBoundingClientRect().height ?? 0;
|
|
362
|
+
}, []);
|
|
363
|
+
|
|
364
|
+
// Detent translate offsets (px) from the tallest detent, ascending. Exclude
|
|
365
|
+
// the border-box portion reserved below the viewport before comparing the
|
|
366
|
+
// candidate visible heights; otherwise every snap point lands that many px
|
|
367
|
+
// too low. Snap heights are resolved lazily so they track the viewport.
|
|
368
|
+
const detentOffsets = useCallback(height => {
|
|
369
|
+
const visibleSheetHeight = visibleHeightForOffset(height, 0, offscreenBlockEndInsetRef.current);
|
|
370
|
+
return computeDetentOffsets(visibleSheetHeight, snapHeightsRef.current?.() ?? []);
|
|
371
|
+
}, []);
|
|
372
|
+
|
|
373
|
+
// The height the sheet WOULD have fully open, right now. While it rests at a
|
|
374
|
+
// resizing detent the element carries a pixel height computed for whatever
|
|
375
|
+
// the viewport was then, so measuring it directly just reads that stale
|
|
376
|
+
// number back. Drop the inline height for the measurement and put it back in
|
|
377
|
+
// the same synchronous block — the browser cannot paint in between, so this
|
|
378
|
+
// is invisible — and the natural CSS budget is what gets measured.
|
|
379
|
+
const measureFullyOpenHeight = useCallback(() => {
|
|
380
|
+
const element = sheetElRef.current;
|
|
381
|
+
if (!element) {
|
|
382
|
+
return sheetHeightRef.current;
|
|
383
|
+
}
|
|
384
|
+
const inlineHeight = element.style.height;
|
|
385
|
+
if (inlineHeight === '') {
|
|
386
|
+
return element.getBoundingClientRect().height;
|
|
387
|
+
}
|
|
388
|
+
element.style.height = '';
|
|
389
|
+
const height = element.getBoundingClientRect().height;
|
|
390
|
+
element.style.height = inlineHeight;
|
|
391
|
+
return height;
|
|
392
|
+
}, []);
|
|
393
|
+
|
|
394
|
+
// Detents are viewport fractions, but a settled sheet holds them as pixels:
|
|
395
|
+
// an offset to translate by, and a layout height to render. Both are read
|
|
396
|
+
// once, at gesture time. Without this, a viewport change leaves the sheet
|
|
397
|
+
// frozen at the old pixel geometry — a "half height" sheet showing 75% of a
|
|
398
|
+
// shorter window, a peek detent whose slide-down is taller than the whole
|
|
399
|
+
// window (so the sheet leaves the screen while its dialog stays modal), and
|
|
400
|
+
// a stale fully-open height for the next drag to overshoot past.
|
|
401
|
+
//
|
|
402
|
+
// Re-resolve the SAME detent — by index, the one thing that survives the
|
|
403
|
+
// units changing — against the new geometry, and re-anchor without
|
|
404
|
+
// animating: the viewport moved, not the user's finger, so there is no
|
|
405
|
+
// gesture to continue and nothing to ease.
|
|
406
|
+
useEffect(() => {
|
|
407
|
+
if (!isOpen || typeof window === 'undefined') {
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const handleViewportChange = () => {
|
|
411
|
+
// A live drag re-measures on its own, and a settle in flight owns the
|
|
412
|
+
// layout until it lands.
|
|
413
|
+
if (dragStateRef.current != null || settlingLayoutOffsetRef.current != null) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const height = measureFullyOpenHeight();
|
|
417
|
+
if (height <= 0) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const offsets = detentOffsets(height);
|
|
421
|
+
const index = Math.min(settledDetentIndexRef.current, offsets.length - 1);
|
|
422
|
+
const target = offsets[index];
|
|
423
|
+
const targetLayoutOffset = isPeekOffset(target, offsets) ? 0 : target;
|
|
424
|
+
const baseLayoutOffset = settledLayoutOffsetRef.current;
|
|
425
|
+
if (height === sheetHeightRef.current && Math.abs(target - activeOffsetRef.current) <= 0.5 && Math.abs(targetLayoutOffset - baseLayoutOffset) <= 0.5) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
sheetHeightRef.current = height;
|
|
429
|
+
setSheetHeight(height);
|
|
430
|
+
settledDetentIndexRef.current = index;
|
|
431
|
+
prepareScrollAreaSettle(baseLayoutOffset, target, targetLayoutOffset, target, baseLayoutOffset, naturalEndGapFor(bodyNodeRef.current), false);
|
|
432
|
+
recordSettledLayoutOffset(targetLayoutOffset);
|
|
433
|
+
setSettledOffset(target);
|
|
434
|
+
onSnapRef.current?.(visibleHeightForOffset(height, target, offscreenBlockEndInsetRef.current));
|
|
435
|
+
const maxOffset = offsets[offsets.length - 1];
|
|
436
|
+
const shortestDetentHeight = visibleHeightForOffset(height, maxOffset, offscreenBlockEndInsetRef.current);
|
|
437
|
+
onScrimOpacityRef.current?.(scrimOpacityForOffset(target, offsets, maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO));
|
|
438
|
+
};
|
|
439
|
+
window.addEventListener('resize', handleViewportChange);
|
|
440
|
+
window.addEventListener('orientationchange', handleViewportChange);
|
|
441
|
+
return () => {
|
|
442
|
+
window.removeEventListener('resize', handleViewportChange);
|
|
443
|
+
window.removeEventListener('orientationchange', handleViewportChange);
|
|
444
|
+
};
|
|
445
|
+
}, [detentOffsets, isOpen, measureFullyOpenHeight, prepareScrollAreaSettle, recordSettledLayoutOffset]);
|
|
446
|
+
const cancelDrag = useCallback(target => {
|
|
447
|
+
const state = dragStateRef.current;
|
|
448
|
+
if (state == null) {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Clear first: releasePointerCapture() may synchronously dispatch
|
|
453
|
+
// lostpointercapture, which must observe that this drag is already done.
|
|
454
|
+
dragStateRef.current = null;
|
|
455
|
+
if (target?.hasPointerCapture?.(state.pointerId)) {
|
|
456
|
+
target.releasePointerCapture(state.pointerId);
|
|
457
|
+
}
|
|
458
|
+
setDragOffset(state.baseOffset);
|
|
459
|
+
setIsDragging(false);
|
|
460
|
+
prepareScrollAreaSettle(state.baseLayoutOffset, state.baseOffset, state.baseLayoutOffset, state.renderedOffset, state.layoutOffset, state.naturalEndGap, true);
|
|
461
|
+
|
|
462
|
+
// An interrupted drag returns to its previous resting detent. Restore
|
|
463
|
+
// the matching scrim opacity as well so the modal shell cannot remain
|
|
464
|
+
// dimmed with its sheet translated out of view.
|
|
465
|
+
const offsets = detentOffsets(state.height);
|
|
466
|
+
const maxOffset = offsets[offsets.length - 1];
|
|
467
|
+
const shortestDetentHeight = visibleHeightForOffset(state.height, maxOffset, offscreenBlockEndInsetRef.current);
|
|
468
|
+
const dismissOffset = maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
|
|
469
|
+
onScrimOpacityRef.current?.(scrimOpacityForOffset(state.baseOffset, offsets, dismissOffset));
|
|
470
|
+
}, [detentOffsets, prepareScrollAreaSettle]);
|
|
471
|
+
useEffect(() => {
|
|
472
|
+
const handleWindowBlur = () => cancelDrag();
|
|
473
|
+
const handleVisibilityChange = () => {
|
|
474
|
+
if (document.visibilityState === 'hidden') {
|
|
475
|
+
cancelDrag();
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
window.addEventListener('blur', handleWindowBlur);
|
|
479
|
+
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
480
|
+
return () => {
|
|
481
|
+
window.removeEventListener('blur', handleWindowBlur);
|
|
482
|
+
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
483
|
+
};
|
|
484
|
+
}, [cancelDrag]);
|
|
485
|
+
const settleFromDrag = useCallback((offset, velocity, height, dir, travel, baseOffset, baseLayoutOffset, renderedOffset, layoutOffset, naturalEndGap) => {
|
|
486
|
+
const offsets = detentOffsets(height);
|
|
487
|
+
const maxOffset = offsets[offsets.length - 1];
|
|
488
|
+
const shortestDetentHeight = visibleHeightForOffset(height, maxOffset, offscreenBlockEndInsetRef.current);
|
|
489
|
+
const speed = Math.abs(velocity);
|
|
490
|
+
const isFlick = speed > FLICK_VELOCITY && travel > FLICK_MIN_DISTANCE;
|
|
491
|
+
const settleAt = target => {
|
|
492
|
+
// A peek keeps the full layout height and slides below the viewport;
|
|
493
|
+
// every taller detent resizes the scrolling area to what it shows.
|
|
494
|
+
const targetLayoutOffset = isPeekOffset(target, offsets) ? 0 : target;
|
|
495
|
+
prepareScrollAreaSettle(baseLayoutOffset, target, targetLayoutOffset, renderedOffset, layoutOffset, naturalEndGap, true);
|
|
496
|
+
recordSettledLayoutOffset(targetLayoutOffset);
|
|
497
|
+
settledDetentIndexRef.current = Math.max(0, offsets.indexOf(target));
|
|
498
|
+
setSettledOffset(target);
|
|
499
|
+
onSnapRef.current?.(visibleHeightForOffset(height, target, offscreenBlockEndInsetRef.current));
|
|
500
|
+
const dismissOffset = maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
|
|
501
|
+
onScrimOpacityRef.current?.(scrimOpacityForOffset(target, offsets, dismissOffset));
|
|
502
|
+
if (target !== baseOffset) {
|
|
503
|
+
hapticTick();
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
if (dir > 0 && isFlick) {
|
|
507
|
+
if (canDismissRef.current) {
|
|
508
|
+
prepareScrollAreaSettle(baseLayoutOffset, baseOffset, baseLayoutOffset, baseLayoutOffset, baseLayoutOffset, naturalEndGap, false);
|
|
509
|
+
onDismissRef.current();
|
|
510
|
+
} else {
|
|
511
|
+
settleAt(maxOffset);
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// Fast upward flick = expand to the tallest detent (the sheet's full
|
|
516
|
+
// provided height).
|
|
517
|
+
if (dir < 0 && isFlick) {
|
|
518
|
+
prepareScrollAreaSettle(baseLayoutOffset, 0, 0, renderedOffset, layoutOffset, naturalEndGap, true);
|
|
519
|
+
recordSettledLayoutOffset(0);
|
|
520
|
+
settledDetentIndexRef.current = 0;
|
|
521
|
+
setDragOffset(0);
|
|
522
|
+
setSettledOffset(0);
|
|
523
|
+
onSnapRef.current?.(visibleHeightForOffset(height, 0, offscreenBlockEndInsetRef.current));
|
|
524
|
+
onScrimOpacityRef.current?.(1);
|
|
525
|
+
hapticTick();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (offset > maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO) {
|
|
529
|
+
if (canDismissRef.current) {
|
|
530
|
+
prepareScrollAreaSettle(baseLayoutOffset, baseOffset, baseLayoutOffset, baseLayoutOffset, baseLayoutOffset, naturalEndGap, false);
|
|
531
|
+
onDismissRef.current();
|
|
532
|
+
} else {
|
|
533
|
+
settleAt(maxOffset);
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
// Settle to the nearest detent in the drag direction (never back past
|
|
538
|
+
// the starting detent), de-duped and direction-clamped by the util.
|
|
539
|
+
const target = resolveSettleOffset(offset, offsets, dir, baseOffset);
|
|
540
|
+
settleAt(target);
|
|
541
|
+
}, [detentOffsets, prepareScrollAreaSettle, recordSettledLayoutOffset]);
|
|
542
|
+
const beginDrag = useCallback((event, sheetHeight, startCoord) => {
|
|
543
|
+
const target = event.currentTarget;
|
|
544
|
+
const syntheticTouch = isSyntheticTouch(event);
|
|
545
|
+
// Never capture for a touch drag. The id came from `Touch.identifier`,
|
|
546
|
+
// which on iOS names a live pointer: the capture succeeds, WebKit takes
|
|
547
|
+
// it straight back for its own gesture handling, and the
|
|
548
|
+
// `lostpointercapture` a millisecond later cancels the drag that just
|
|
549
|
+
// started. Touch drags need no capture — the listener is on the
|
|
550
|
+
// scroller itself.
|
|
551
|
+
if (!syntheticTouch) {
|
|
552
|
+
target.setPointerCapture?.(event.pointerId);
|
|
553
|
+
}
|
|
554
|
+
// `startCoord` lets a body-overscroll drag anchor at the original
|
|
555
|
+
// pointer-down position (not the promotion point), so the first frame's
|
|
556
|
+
// delta reflects the full pull distance.
|
|
557
|
+
const start = startCoord ?? event.clientY;
|
|
558
|
+
const naturalEndGap = naturalEndGapFor(bodyNodeRef.current);
|
|
559
|
+
const baseLayoutOffset = settledLayoutOffsetRef.current;
|
|
560
|
+
dragStateRef.current = {
|
|
561
|
+
syntheticTouch,
|
|
562
|
+
pointerId: event.pointerId,
|
|
563
|
+
startCoord: start,
|
|
564
|
+
lastCoord: event.clientY,
|
|
565
|
+
lastTime: event.timeStamp,
|
|
566
|
+
velocity: 0,
|
|
567
|
+
height: sheetHeight,
|
|
568
|
+
baseOffset: settledOffset,
|
|
569
|
+
baseLayoutOffset,
|
|
570
|
+
renderedOffset: settledOffset,
|
|
571
|
+
layoutOffset: baseLayoutOffset,
|
|
572
|
+
naturalEndGap
|
|
573
|
+
};
|
|
574
|
+
updateScrollPreservationInset(preservationInsetForOffset(baseLayoutOffset, baseLayoutOffset, naturalEndGap));
|
|
575
|
+
// Seed dragOffset at the resting detent so flipping isDragging doesn't
|
|
576
|
+
// jump the sheet to fully-open for one frame.
|
|
577
|
+
setDragOffset(settledOffset);
|
|
578
|
+
setIsDragging(true);
|
|
579
|
+
}, [settledOffset, updateScrollPreservationInset]);
|
|
580
|
+
const handlePointerDown = useCallback(event => {
|
|
581
|
+
if (event.button !== 0 || !event.isPrimary) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
// The handle has no native focus action. Prevent pointer-down from
|
|
585
|
+
// moving focus off a form control on a tap; once the pointer actually
|
|
586
|
+
// moves, BottomSheet dismisses the keyboard as sheet travel begins.
|
|
587
|
+
event.preventDefault();
|
|
588
|
+
beginDrag(event, measureHeight());
|
|
589
|
+
}, [beginDrag, measureHeight]);
|
|
590
|
+
const handleContextMenu = useCallback(event => {
|
|
591
|
+
if (dragStateRef.current == null) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
event.preventDefault();
|
|
595
|
+
cancelDrag(event.currentTarget);
|
|
596
|
+
}, [cancelDrag]);
|
|
597
|
+
const handleLostPointerCapture = useCallback(event => {
|
|
598
|
+
// A touch drag never took capture, so this is WebKit reclaiming its own
|
|
599
|
+
// pointer for the finger doing the dragging — not the drag losing its
|
|
600
|
+
// grip.
|
|
601
|
+
if (dragStateRef.current?.syntheticTouch) {
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (dragStateRef.current?.pointerId === event.pointerId) {
|
|
605
|
+
cancelDrag();
|
|
606
|
+
}
|
|
607
|
+
}, [cancelDrag]);
|
|
608
|
+
const handlePointerMove = useCallback(event => {
|
|
609
|
+
const state = dragStateRef.current;
|
|
610
|
+
if (!state || state.pointerId !== event.pointerId) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
// Same finger, real pointer event: the touch path already drove this
|
|
614
|
+
// move. Let it own the drag rather than driving it twice.
|
|
615
|
+
if (state.syntheticTouch && !isSyntheticTouch(event)) {
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const delta = event.clientY - state.startCoord;
|
|
619
|
+
const dt = event.timeStamp - state.lastTime;
|
|
620
|
+
if (dt > 0) {
|
|
621
|
+
state.velocity = (event.clientY - state.lastCoord) / dt;
|
|
622
|
+
state.lastCoord = event.clientY;
|
|
623
|
+
state.lastTime = event.timeStamp;
|
|
624
|
+
}
|
|
625
|
+
const offsets = detentOffsets(state.height);
|
|
626
|
+
const raw = state.baseOffset + delta;
|
|
627
|
+
const maxDetentOffset = offsets[offsets.length - 1];
|
|
628
|
+
let next;
|
|
629
|
+
if (raw < 0) {
|
|
630
|
+
// Up past fully-open: damped + capped rubber-band; springs back on release.
|
|
631
|
+
next = Math.max(-OVERSCROLL_MAX, raw * OVERSCROLL_RESISTANCE);
|
|
632
|
+
} else if (raw > maxDetentOffset) {
|
|
633
|
+
// In the dismiss zone: no magnet, so it doesn't fight a drag-to-close.
|
|
634
|
+
next = raw;
|
|
635
|
+
} else {
|
|
636
|
+
// Between detents: magnetically ease toward a nearby one.
|
|
637
|
+
next = magnetize(raw, offsets);
|
|
638
|
+
}
|
|
639
|
+
// Dragging above the base restores the full layout height below the
|
|
640
|
+
// viewport; otherwise keep whatever layout the base detent settled with
|
|
641
|
+
// (0 at a peek, so a peek drag stays transform-only).
|
|
642
|
+
const layoutOffset = next < state.baseOffset ? 0 : state.baseLayoutOffset;
|
|
643
|
+
state.renderedOffset = next;
|
|
644
|
+
state.layoutOffset = layoutOffset;
|
|
645
|
+
setDragOffset(next);
|
|
646
|
+
updateScrollPreservationInset(preservationInsetForOffset(state.baseLayoutOffset, layoutOffset, state.naturalEndGap));
|
|
647
|
+
|
|
648
|
+
// Mirror the scrim to the live drag: full at/above the mid detent,
|
|
649
|
+
// fading to hidden as it collapses onto the peek detent and through the
|
|
650
|
+
// dismiss overshoot.
|
|
651
|
+
const floorOffset = offsets[offsets.length - 1];
|
|
652
|
+
const shortestDetentHeight = visibleHeightForOffset(state.height, floorOffset, offscreenBlockEndInsetRef.current);
|
|
653
|
+
const dismissOffset = floorOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
|
|
654
|
+
onScrimOpacityRef.current?.(scrimOpacityForOffset(next, offsets, dismissOffset));
|
|
655
|
+
}, [detentOffsets, updateScrollPreservationInset]);
|
|
656
|
+
const endDrag = useCallback(event => {
|
|
657
|
+
const state = dragStateRef.current;
|
|
658
|
+
if (!state || state.pointerId !== event.pointerId) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// `pointercancel` fires for the finger the moment WebKit claims the
|
|
662
|
+
// gesture; ending a touch drag on it settles the sheet mid-pull. The
|
|
663
|
+
// touchend handler is what finishes a touch drag.
|
|
664
|
+
if (state.syntheticTouch && !isSyntheticTouch(event)) {
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const target = event.currentTarget;
|
|
668
|
+
const delta = event.clientY - state.startCoord;
|
|
669
|
+
const offset = Math.max(0, state.baseOffset + delta);
|
|
670
|
+
const dir = delta === 0 ? 0 : delta > 0 ? 1 : -1;
|
|
671
|
+
dragStateRef.current = null;
|
|
672
|
+
if (!state.syntheticTouch) {
|
|
673
|
+
target.releasePointerCapture?.(event.pointerId);
|
|
674
|
+
}
|
|
675
|
+
setIsDragging(false);
|
|
676
|
+
settleFromDrag(offset, state.velocity, state.height || 1, dir, Math.abs(delta), state.baseOffset, state.baseLayoutOffset, state.renderedOffset, state.layoutOffset, state.naturalEndGap);
|
|
677
|
+
}, [settleFromDrag]);
|
|
678
|
+
|
|
679
|
+
// Pointer path for the body at-top pull-down (desktop / mouse). Touch uses
|
|
680
|
+
// the non-passive listener below, since pointer events are cancelled once a
|
|
681
|
+
// native pan starts.
|
|
682
|
+
const armedBodyRef = useRef(null);
|
|
683
|
+
const handleBodyPointerDown = useCallback(event => {
|
|
684
|
+
if (event.button !== 0 || !event.isPrimary) {
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const scroller = event.currentTarget;
|
|
688
|
+
if (scroller.scrollTop > 0) {
|
|
689
|
+
armedBodyRef.current = null;
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
armedBodyRef.current = {
|
|
693
|
+
pointerId: event.pointerId,
|
|
694
|
+
startCoord: event.clientY,
|
|
695
|
+
scroller
|
|
696
|
+
};
|
|
697
|
+
}, []);
|
|
698
|
+
const handleBodyPointerMove = useCallback(event => {
|
|
699
|
+
if (dragStateRef.current) {
|
|
700
|
+
handlePointerMove(event);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const armed = armedBodyRef.current;
|
|
704
|
+
if (!armed || armed.pointerId !== event.pointerId) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const delta = event.clientY - armed.startCoord;
|
|
708
|
+
if (delta > 0 && armed.scroller.scrollTop <= 0) {
|
|
709
|
+
// Downward pull at the top: promote to a sheet drag, anchored at the
|
|
710
|
+
// original pointer-down position so the pull distance carries over.
|
|
711
|
+
armedBodyRef.current = null;
|
|
712
|
+
beginDrag(event, measureHeight(), armed.startCoord);
|
|
713
|
+
handlePointerMove(event);
|
|
714
|
+
} else if (delta < 0) {
|
|
715
|
+
// Upward move = the user is scrolling; disarm so we don't hijack it.
|
|
716
|
+
armedBodyRef.current = null;
|
|
717
|
+
}
|
|
718
|
+
}, [beginDrag, handlePointerMove, measureHeight]);
|
|
719
|
+
const handleBodyEnd = useCallback(event => {
|
|
720
|
+
armedBodyRef.current = null;
|
|
721
|
+
// The pointerup/pointercancel for a finger already driving a touch drag
|
|
722
|
+
// arrives here carrying that drag's own id; touchend ends those.
|
|
723
|
+
if (dragStateRef.current?.syntheticTouch) {
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (dragStateRef.current) {
|
|
727
|
+
endDrag(event);
|
|
728
|
+
}
|
|
729
|
+
}, [endDrag]);
|
|
730
|
+
|
|
731
|
+
// Non-passive touchmove is the reliable scroll<->drag handoff on touch:
|
|
732
|
+
// preventDefault() at a scroll edge stops the native scroll and drives the
|
|
733
|
+
// sheet drag through the pointer math (Touch adapted to the fields it reads).
|
|
734
|
+
//
|
|
735
|
+
// Every touch on the body is tracked, not only the ones that begin at an
|
|
736
|
+
// edge: a gesture that starts mid-content and scrolls to the end has to hand
|
|
737
|
+
// off too, and by then it is far too late to arm from `touchstart`.
|
|
738
|
+
const touchDragRef = useRef(null);
|
|
739
|
+
const previousTouchHandlersRef = useRef(null);
|
|
740
|
+
const beginDragRef = useRef(beginDrag);
|
|
741
|
+
const cancelDragRef = useRef(cancelDrag);
|
|
742
|
+
const pointerMoveRef = useRef(handlePointerMove);
|
|
743
|
+
const endDragRef = useRef(endDrag);
|
|
744
|
+
const measureHeightRef = useRef(measureHeight);
|
|
745
|
+
useEffect(() => {
|
|
746
|
+
beginDragRef.current = beginDrag;
|
|
747
|
+
cancelDragRef.current = cancelDrag;
|
|
748
|
+
pointerMoveRef.current = handlePointerMove;
|
|
749
|
+
endDragRef.current = endDrag;
|
|
750
|
+
measureHeightRef.current = measureHeight;
|
|
751
|
+
});
|
|
752
|
+
const bodyRef = useCallback(node => {
|
|
753
|
+
const asPointer = (touch, target) => ({
|
|
754
|
+
syntheticTouch: true,
|
|
755
|
+
pointerId: touch.identifier,
|
|
756
|
+
clientY: touch.clientY,
|
|
757
|
+
timeStamp: Date.now(),
|
|
758
|
+
currentTarget: target,
|
|
759
|
+
setPointerCapture: () => {},
|
|
760
|
+
releasePointerCapture: () => {}
|
|
761
|
+
});
|
|
762
|
+
const atTop = el => el.scrollTop <= 0;
|
|
763
|
+
const atBottom = el => el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
|
764
|
+
const onTouchStart = event => {
|
|
765
|
+
const scroller = event.currentTarget;
|
|
766
|
+
const touch = event.changedTouches[0];
|
|
767
|
+
// Record where the gesture began and whether it began at a scroll edge.
|
|
768
|
+
// At the top, a pull DOWN hands off (collapse); at the bottom, a pull UP
|
|
769
|
+
// hands off (expand). A gesture that starts mid-content is an ordinary
|
|
770
|
+
// scroll, but it is tracked all the same: the scroller can run out of
|
|
771
|
+
// content while the finger is still down (see onTouchMove).
|
|
772
|
+
if (!touch) {
|
|
773
|
+
touchDragRef.current = null;
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const top = atTop(scroller);
|
|
777
|
+
// The bottom edge hands off so the sheet can EXPAND, so it is only a
|
|
778
|
+
// handoff when a taller detent exists. Already at the tallest, an
|
|
779
|
+
// upward pull has nowhere to travel: promoting it would trade the
|
|
780
|
+
// user's scroll for a rubber-band the release throws straight back, and
|
|
781
|
+
// — because promotion preventDefault()s the rest of the gesture — would
|
|
782
|
+
// strand the scroller for as long as the finger stays down, so reversing
|
|
783
|
+
// downward to scroll back would collapse the sheet instead. Leave the
|
|
784
|
+
// gesture with the content.
|
|
785
|
+
const bottom = atBottom(scroller) && activeOffsetRef.current > 0;
|
|
786
|
+
touchDragRef.current = {
|
|
787
|
+
id: touch.identifier,
|
|
788
|
+
startY: touch.clientY,
|
|
789
|
+
top,
|
|
790
|
+
bottom,
|
|
791
|
+
contentEndY: null,
|
|
792
|
+
promotedAtContentEnd: false
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
const onTouchMove = event => {
|
|
796
|
+
const scroller = event.currentTarget;
|
|
797
|
+
const armed = touchDragRef.current;
|
|
798
|
+
if (dragStateRef.current) {
|
|
799
|
+
const t = [...event.changedTouches].find(x => x.identifier === dragStateRef.current?.pointerId);
|
|
800
|
+
if (!t) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (armed?.promotedAtContentEnd && armed.contentEndY != null) {
|
|
804
|
+
if (t.clientY >= armed.contentEndY) {
|
|
805
|
+
// Back at the point where the content ran out. This drag never
|
|
806
|
+
// cancelled the native scroll — it couldn't, the events were no
|
|
807
|
+
// longer cancelable — so the scroller is about to move again. Hand
|
|
808
|
+
// the gesture back rather than driving the sheet and the content
|
|
809
|
+
// at once; a later pull past the end promotes again.
|
|
810
|
+
armed.contentEndY = null;
|
|
811
|
+
armed.promotedAtContentEnd = false;
|
|
812
|
+
cancelDragRef.current(scroller);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
// Deliberately NOT preventDefault()ed: the scroller is clamped at
|
|
816
|
+
// its end, so there is no scrolling left to cancel, and claiming the
|
|
817
|
+
// gesture would strand the content for as long as the finger is down.
|
|
818
|
+
pointerMoveRef.current(asPointer(t, scroller));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
event.preventDefault();
|
|
822
|
+
pointerMoveRef.current(asPointer(t, scroller));
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (!armed) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const t = [...event.changedTouches].find(x => x.identifier === armed.id);
|
|
829
|
+
if (!t) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
const delta = t.clientY - armed.startY;
|
|
833
|
+
// Promote to a sheet drag on a pull that opposes the armed edge and can
|
|
834
|
+
// no longer scroll that way: at the top, a downward pull (delta > 0)
|
|
835
|
+
// collapses; at the bottom, an upward pull (delta < 0) expands. The
|
|
836
|
+
// opposite direction is a real scroll, so disarm and let it through.
|
|
837
|
+
const pullDownAtTop = armed.top && delta > 0 && atTop(scroller);
|
|
838
|
+
const pullUpAtBottom = armed.bottom && delta < 0 && atBottom(scroller);
|
|
839
|
+
if (pullDownAtTop || pullUpAtBottom) {
|
|
840
|
+
event.preventDefault();
|
|
841
|
+
touchDragRef.current = null;
|
|
842
|
+
beginDragRef.current(asPointer(t, scroller), measureHeightRef.current(), armed.startY);
|
|
843
|
+
pointerMoveRef.current(asPointer(t, scroller));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (armed.top && delta < 0 || armed.bottom && delta > 0) {
|
|
847
|
+
// Scrolling away from the armed edge; hand back to native scroll. The
|
|
848
|
+
// touch stays tracked: this is the swipe that may reach the far edge.
|
|
849
|
+
armed.top = false;
|
|
850
|
+
armed.bottom = false;
|
|
851
|
+
}
|
|
852
|
+
// Reaching the end of the content mid-gesture. Arming at `touchstart`
|
|
853
|
+
// cannot see this, and re-arming for a preventDefault() promotion would
|
|
854
|
+
// be useless anyway: once the browser has committed the gesture to
|
|
855
|
+
// scrolling, every remaining touchmove is non-cancelable. Nothing needs
|
|
856
|
+
// cancelling either — the scroller is clamped at its maximum, so further
|
|
857
|
+
// upward travel scrolls nothing. Anchor at the point where the content
|
|
858
|
+
// ran out and give the sheet everything past it, so the pull continues
|
|
859
|
+
// into the sheet with no jump and no lost scrolling.
|
|
860
|
+
if (activeOffsetRef.current > 0 && atBottom(scroller)) {
|
|
861
|
+
if (armed.contentEndY == null) {
|
|
862
|
+
armed.contentEndY = t.clientY;
|
|
863
|
+
} else if (armed.contentEndY - t.clientY >= CONTENT_END_HANDOFF_SLOP) {
|
|
864
|
+
armed.promotedAtContentEnd = true;
|
|
865
|
+
beginDragRef.current(asPointer(t, scroller), measureHeightRef.current(), armed.contentEndY);
|
|
866
|
+
pointerMoveRef.current(asPointer(t, scroller));
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
armed.contentEndY = null;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
const onTouchEnd = event => {
|
|
873
|
+
touchDragRef.current = null;
|
|
874
|
+
const pointerId = dragStateRef.current?.pointerId;
|
|
875
|
+
if (pointerId == null) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
const t = [...event.changedTouches].find(touch => touch.identifier === pointerId);
|
|
879
|
+
const target = event.currentTarget;
|
|
880
|
+
if (t) {
|
|
881
|
+
endDragRef.current(asPointer(t, target));
|
|
882
|
+
} else if (event.touches.length === 0) {
|
|
883
|
+
// Some interrupted multi-touch sequences omit the active touch from
|
|
884
|
+
// changedTouches. If no fingers remain, the drag cannot finish later.
|
|
885
|
+
cancelDragRef.current(target);
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
const prev = bodyNodeRef.current;
|
|
889
|
+
if (prev && previousTouchHandlersRef.current) {
|
|
890
|
+
const h = previousTouchHandlersRef.current;
|
|
891
|
+
prev.removeEventListener('touchstart', h.start);
|
|
892
|
+
prev.removeEventListener('touchmove', h.move);
|
|
893
|
+
prev.removeEventListener('touchend', h.end);
|
|
894
|
+
prev.removeEventListener('touchcancel', h.end);
|
|
895
|
+
}
|
|
896
|
+
bodyNodeRef.current = node;
|
|
897
|
+
if (node) {
|
|
898
|
+
node.addEventListener('touchstart', onTouchStart, {
|
|
899
|
+
passive: true
|
|
900
|
+
});
|
|
901
|
+
node.addEventListener('touchmove', onTouchMove, {
|
|
902
|
+
passive: false
|
|
903
|
+
});
|
|
904
|
+
node.addEventListener('touchend', onTouchEnd, {
|
|
905
|
+
passive: true
|
|
906
|
+
});
|
|
907
|
+
node.addEventListener('touchcancel', onTouchEnd, {
|
|
908
|
+
passive: true
|
|
909
|
+
});
|
|
910
|
+
previousTouchHandlersRef.current = {
|
|
911
|
+
start: onTouchStart,
|
|
912
|
+
move: onTouchMove,
|
|
913
|
+
end: onTouchEnd
|
|
914
|
+
};
|
|
915
|
+
} else {
|
|
916
|
+
previousTouchHandlersRef.current = null;
|
|
917
|
+
}
|
|
918
|
+
}, []);
|
|
919
|
+
|
|
920
|
+
// Subscribed, not memoized: the preference can change while a sheet is open,
|
|
921
|
+
// and this branch decides whether the settle runs as a transition at all.
|
|
922
|
+
// The imperative gesture paths read prefersReducedMotion() directly.
|
|
923
|
+
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
|
|
924
|
+
const reconcileScrollPreservationInset = useCallback(body => {
|
|
925
|
+
if (scrollPreservationInsetRef.current <= 0) {
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
const renderedInset = renderedBlockEndPadding(body);
|
|
929
|
+
const naturalMaxScrollTop = Math.max(0, body.scrollHeight - body.clientHeight - renderedInset);
|
|
930
|
+
const requiredInset = Math.max(0, body.scrollTop - naturalMaxScrollTop);
|
|
931
|
+
if (requiredInset < scrollPreservationInsetRef.current - 0.5) {
|
|
932
|
+
updateScrollPreservationInset(requiredInset);
|
|
933
|
+
}
|
|
934
|
+
}, [updateScrollPreservationInset]);
|
|
935
|
+
const handleBodyScroll = useCallback(event => {
|
|
936
|
+
if (dragStateRef.current != null || settlingLayoutOffsetRef.current != null) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
reconcileScrollPreservationInset(event.currentTarget);
|
|
940
|
+
}, [reconcileScrollPreservationInset]);
|
|
941
|
+
|
|
942
|
+
// While dragging, follow the finger; otherwise rest at the settled detent.
|
|
943
|
+
const activeOffset = isDragging ? dragOffset : settledOffset;
|
|
944
|
+
const contentProps = useMemo(() => ({
|
|
945
|
+
style: {
|
|
946
|
+
transform: activeOffset !== 0 ? `translateY(${activeOffset}px)` : undefined,
|
|
947
|
+
transition: isDragging || isScrollAreaReconciling || reducedMotion ? 'none' : undefined,
|
|
948
|
+
touchAction: 'none',
|
|
949
|
+
overscrollBehavior: 'contain'
|
|
950
|
+
}
|
|
951
|
+
}), [activeOffset, isDragging, isScrollAreaReconciling, reducedMotion]);
|
|
952
|
+
const handleProps = useMemo(() => ({
|
|
953
|
+
style: {
|
|
954
|
+
touchAction: 'none',
|
|
955
|
+
cursor: 'grab'
|
|
956
|
+
},
|
|
957
|
+
onContextMenu: handleContextMenu,
|
|
958
|
+
onLostPointerCapture: handleLostPointerCapture,
|
|
959
|
+
onPointerDown: handlePointerDown,
|
|
960
|
+
onPointerMove: handlePointerMove,
|
|
961
|
+
onPointerUp: endDrag,
|
|
962
|
+
onPointerCancel: endDrag
|
|
963
|
+
}), [endDrag, handleContextMenu, handleLostPointerCapture, handlePointerDown, handlePointerMove]);
|
|
964
|
+
const bodyProps = useMemo(() => ({
|
|
965
|
+
ref: bodyRef,
|
|
966
|
+
onContextMenu: handleContextMenu,
|
|
967
|
+
onLostPointerCapture: handleLostPointerCapture,
|
|
968
|
+
onPointerDown: handleBodyPointerDown,
|
|
969
|
+
onPointerMove: handleBodyPointerMove,
|
|
970
|
+
onPointerUp: handleBodyEnd,
|
|
971
|
+
onPointerCancel: handleBodyEnd,
|
|
972
|
+
onScroll: handleBodyScroll
|
|
973
|
+
}), [bodyRef, handleBodyEnd, handleBodyPointerDown, handleBodyPointerMove, handleBodyScroll, handleContextMenu, handleLostPointerCapture]);
|
|
974
|
+
return {
|
|
975
|
+
sheetRef,
|
|
976
|
+
contentProps,
|
|
977
|
+
handleProps,
|
|
978
|
+
bodyProps,
|
|
979
|
+
bodyElementRef: bodyNodeRef,
|
|
980
|
+
dragOffset,
|
|
981
|
+
settledOffset,
|
|
982
|
+
isDragging,
|
|
983
|
+
sheetHeight,
|
|
984
|
+
scrollPreservationInset,
|
|
985
|
+
settlingLayoutOffset,
|
|
986
|
+
settledLayoutOffset,
|
|
987
|
+
completeScrollAreaSettle
|
|
988
|
+
};
|
|
989
|
+
}
|