@astryxdesign/core 0.4.3 → 0.4.4-canary.0e8af82

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 (254) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +79 -45
  3. package/dist/Banner/Banner.d.ts +3 -0
  4. package/dist/Banner/Banner.d.ts.map +1 -1
  5. package/dist/Banner/Banner.js +13 -3
  6. package/dist/BottomSheet/BottomSheet.d.ts +75 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +396 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +63 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +441 -0
  12. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  13. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  14. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  15. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  16. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  17. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  18. package/dist/BottomSheet/index.d.ts +11 -0
  19. package/dist/BottomSheet/index.d.ts.map +1 -0
  20. package/dist/BottomSheet/index.js +12 -0
  21. package/dist/BottomSheet/snapOffsets.d.ts +109 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +206 -0
  24. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  25. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  26. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  27. package/dist/BottomSheet/useSheetGestures.d.ts +185 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +1038 -0
  30. package/dist/Calendar/Calendar.d.ts +17 -0
  31. package/dist/Calendar/Calendar.d.ts.map +1 -1
  32. package/dist/Calendar/Calendar.js +25 -1
  33. package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
  34. package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
  35. package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
  36. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  37. package/dist/Chat/ChatComposerInput.js +3 -4
  38. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  39. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  40. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  41. package/dist/ComplexSelector/ComplexSelector.js +18 -7
  42. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  43. package/dist/ContextMenu/ContextMenu.js +4 -2
  44. package/dist/DateInput/DateInput.d.ts.map +1 -1
  45. package/dist/DateInput/DateInput.js +15 -2
  46. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  47. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  48. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  49. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  50. package/dist/DateTimeInput/DateTimeInput.js +38 -3
  51. package/dist/Dialog/Dialog.d.ts.map +1 -1
  52. package/dist/Dialog/Dialog.js +2 -1
  53. package/dist/Field/FieldLabel.d.ts +2 -1
  54. package/dist/Field/FieldLabel.d.ts.map +1 -1
  55. package/dist/Field/FieldLabel.js +21 -3
  56. package/dist/Field/InputClearButton.d.ts +2 -1
  57. package/dist/Field/InputClearButton.d.ts.map +1 -1
  58. package/dist/Field/InputClearButton.js +6 -1
  59. package/dist/FormLayout/FormLayout.d.ts +24 -2
  60. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  61. package/dist/FormLayout/FormLayout.js +7 -2
  62. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  63. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  64. package/dist/FormLayout/FormLayoutContext.js +19 -4
  65. package/dist/FormLayout/index.d.ts +1 -1
  66. package/dist/FormLayout/index.d.ts.map +1 -1
  67. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  68. package/dist/Lightbox/Lightbox.js +9 -3
  69. package/dist/MobileNav/MobileNav.d.ts +12 -0
  70. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  71. package/dist/MobileNav/MobileNav.js +102 -30
  72. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  73. package/dist/MultiSelector/MultiSelector.js +27 -8
  74. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  75. package/dist/NumberInput/NumberInput.js +15 -2
  76. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  77. package/dist/RadioList/RadioList.d.ts.map +1 -1
  78. package/dist/RadioList/RadioList.js +12 -1
  79. package/dist/Selector/Selector.d.ts.map +1 -1
  80. package/dist/Selector/Selector.js +20 -4
  81. package/dist/StatusDot/StatusDot.d.ts +43 -3
  82. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  83. package/dist/StatusDot/StatusDot.js +42 -5
  84. package/dist/Switch/Switch.d.ts.map +1 -1
  85. package/dist/Switch/Switch.js +14 -1
  86. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  87. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  88. package/dist/TextArea/TextArea.d.ts.map +1 -1
  89. package/dist/TextArea/TextArea.js +6 -1
  90. package/dist/TextInput/TextInput.d.ts.map +1 -1
  91. package/dist/TextInput/TextInput.js +6 -1
  92. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  93. package/dist/TimeInput/TimeInput.js +14 -2
  94. package/dist/Tokenizer/Tokenizer.js +11 -5
  95. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  96. package/dist/Tooltip/useTooltip.js +4 -1
  97. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  98. package/dist/Typeahead/BaseTypeahead.js +5 -3
  99. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  100. package/dist/Typeahead/Typeahead.js +10 -1
  101. package/dist/astryx.css +21 -0
  102. package/dist/hooks/index.d.ts +8 -1
  103. package/dist/hooks/index.d.ts.map +1 -1
  104. package/dist/hooks/index.js +8 -1
  105. package/dist/hooks/useFocusTrap.d.ts +0 -11
  106. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  107. package/dist/hooks/useFocusTrap.js +1 -11
  108. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  109. package/dist/hooks/useInputStatusIcon.js +1 -0
  110. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  111. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  112. package/dist/hooks/useResolvedRequired.js +40 -0
  113. package/dist/index.d.ts +1 -0
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +1 -0
  116. package/dist/theme/defineTheme.d.ts +22 -4
  117. package/dist/theme/defineTheme.d.ts.map +1 -1
  118. package/dist/theme/expandColorScale.d.ts +17 -2
  119. package/dist/theme/expandColorScale.d.ts.map +1 -1
  120. package/dist/theme/expandColorScale.js +63 -33
  121. package/dist/utils/ime.d.ts +5 -0
  122. package/dist/utils/ime.d.ts.map +1 -0
  123. package/dist/utils/ime.js +63 -0
  124. package/dist/utils/index.d.ts +1 -0
  125. package/dist/utils/index.d.ts.map +1 -1
  126. package/dist/utils/index.js +2 -1
  127. package/dist/utils/plainDate.d.ts +6 -0
  128. package/dist/utils/plainDate.d.ts.map +1 -1
  129. package/dist/utils/plainDate.js +12 -0
  130. package/locales/af-ZA.json +958 -0
  131. package/locales/ar-SA.json +998 -0
  132. package/locales/ca-ES.json +974 -0
  133. package/locales/cs-CZ.json +990 -0
  134. package/locales/da-DK.json +966 -0
  135. package/locales/de-DE.json +966 -0
  136. package/locales/el-GR.json +990 -0
  137. package/locales/en.json +52 -0
  138. package/locales/es-ES.json +978 -0
  139. package/locales/fi-FI.json +990 -0
  140. package/locales/fr-FR.json +928 -0
  141. package/locales/he-IL.json +990 -0
  142. package/locales/hu-HU.json +990 -0
  143. package/locales/it-IT.json +978 -0
  144. package/locales/ja-JP.json +998 -0
  145. package/locales/ko-KR.json +990 -0
  146. package/locales/nl-NL.json +954 -0
  147. package/locales/no-NO.json +978 -0
  148. package/locales/pl-PL.json +982 -0
  149. package/locales/pseudo.json +39 -0
  150. package/locales/pt-BR.json +982 -0
  151. package/locales/pt-PT.json +982 -0
  152. package/locales/ro-RO.json +982 -0
  153. package/locales/ru-RU.json +990 -0
  154. package/locales/sr-SP.json +990 -0
  155. package/locales/sv-SE.json +986 -0
  156. package/locales/tr-TR.json +990 -0
  157. package/locales/uk-UA.json +990 -0
  158. package/locales/vi-VN.json +986 -0
  159. package/locales/zh-CN.json +998 -0
  160. package/locales/zh-TW.json +998 -0
  161. package/package.json +9 -8
  162. package/src/Banner/Banner.doc.mjs +3 -3
  163. package/src/Banner/Banner.test.tsx +40 -0
  164. package/src/Banner/Banner.tsx +27 -2
  165. package/src/BottomSheet/BottomSheet.doc.mjs +299 -0
  166. package/src/BottomSheet/BottomSheet.test.tsx +2265 -0
  167. package/src/BottomSheet/BottomSheet.tsx +559 -0
  168. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  169. package/src/BottomSheet/BottomSheetPanel.tsx +601 -0
  170. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  171. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  172. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  173. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  174. package/src/BottomSheet/index.ts +19 -0
  175. package/src/BottomSheet/snapOffsets.test.ts +254 -0
  176. package/src/BottomSheet/snapOffsets.ts +245 -0
  177. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  178. package/src/BottomSheet/useSheetGestures.test.ts +1112 -0
  179. package/src/BottomSheet/useSheetGestures.ts +1503 -0
  180. package/src/Calendar/Calendar.doc.mjs +16 -0
  181. package/src/Calendar/Calendar.test.tsx +96 -0
  182. package/src/Calendar/Calendar.tsx +55 -0
  183. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  184. package/src/Chat/ChatComposerInput.tsx +3 -4
  185. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  186. package/src/ComplexSelector/ComplexSelector.tsx +15 -6
  187. package/src/ContextMenu/ContextMenu.tsx +4 -2
  188. package/src/DateInput/DateInput.test.tsx +21 -0
  189. package/src/DateInput/DateInput.tsx +12 -2
  190. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  191. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  192. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  193. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  194. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  195. package/src/DateTimeInput/DateTimeInput.tsx +43 -4
  196. package/src/Dialog/Dialog.tsx +2 -1
  197. package/src/Field/Field.doc.mjs +1 -0
  198. package/src/Field/FieldLabel.tsx +20 -4
  199. package/src/Field/InputClearButton.test.tsx +25 -0
  200. package/src/Field/InputClearButton.tsx +4 -1
  201. package/src/FileInput/FileInput.test.tsx +53 -9
  202. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  203. package/src/FormLayout/FormLayout.test.tsx +181 -4
  204. package/src/FormLayout/FormLayout.tsx +33 -2
  205. package/src/FormLayout/FormLayoutContext.ts +22 -7
  206. package/src/FormLayout/index.ts +1 -1
  207. package/src/Lightbox/Lightbox.test.tsx +49 -28
  208. package/src/Lightbox/Lightbox.tsx +7 -3
  209. package/src/MobileNav/MobileNav.tsx +128 -37
  210. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  211. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  212. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  213. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  214. package/src/MultiSelector/MultiSelector.tsx +25 -9
  215. package/src/NumberInput/NumberInput.test.tsx +18 -0
  216. package/src/NumberInput/NumberInput.tsx +12 -2
  217. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  218. package/src/RadioList/RadioList.tsx +9 -1
  219. package/src/Selector/Selector.test.tsx +85 -26
  220. package/src/Selector/Selector.tsx +16 -5
  221. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  222. package/src/StatusDot/StatusDot.test.tsx +115 -8
  223. package/src/StatusDot/StatusDot.tsx +74 -9
  224. package/src/Switch/Switch.test.tsx +16 -0
  225. package/src/Switch/Switch.tsx +13 -0
  226. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  227. package/src/TextArea/TextArea.tsx +3 -1
  228. package/src/TextInput/TextInput.tsx +3 -1
  229. package/src/TimeInput/TimeInput.test.tsx +23 -0
  230. package/src/TimeInput/TimeInput.tsx +11 -1
  231. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  232. package/src/Tokenizer/Tokenizer.tsx +5 -5
  233. package/src/Tooltip/useTooltip.tsx +4 -1
  234. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  235. package/src/Typeahead/Typeahead.test.tsx +116 -7
  236. package/src/Typeahead/Typeahead.tsx +10 -1
  237. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  238. package/src/__tests__/stubMatchMedia.ts +95 -0
  239. package/src/hooks/index.ts +9 -5
  240. package/src/hooks/useFocusTrap.ts +1 -14
  241. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  242. package/src/hooks/useInputStatusIcon.tsx +7 -0
  243. package/src/hooks/useResolvedRequired.ts +42 -0
  244. package/src/index.ts +1 -0
  245. package/src/theme/defineTheme.ts +22 -4
  246. package/src/theme/expandColorScale.test.ts +138 -0
  247. package/src/theme/expandColorScale.ts +89 -44
  248. package/src/utils/ime.test.ts +32 -0
  249. package/src/utils/ime.ts +69 -0
  250. package/src/utils/index.ts +2 -0
  251. package/src/utils/plainDate.test.ts +50 -0
  252. package/src/utils/plainDate.ts +12 -0
  253. package/dist/astryx.umd.js +0 -147
  254. package/dist/astryx.umd.js.map +0 -7
@@ -0,0 +1,1503 @@
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. A peek detent — a stop that is
23
+ * only a sliver of the sheet — keeps the full height and slides below the
24
+ * viewport instead of reflowing to that sliver.
25
+ *
26
+ * Those are pixels, and the stops behind them are relative to the viewport, so
27
+ * a sheet at rest re-resolves its detent on `resize` / `orientationchange`,
28
+ * and whenever the host swaps the snap points, and re-anchors to the new
29
+ * geometry without animating. The detent it returns to is tracked by index:
30
+ * the pixels stop meaning the same thing when the viewport changes, the stop
31
+ * the user chose does not.
32
+ *
33
+ * On touch, the scrolling body hands the gesture to the sheet at a scroll
34
+ * edge. Two shapes, because the browser only offers one of them a choice: a
35
+ * finger that lands on an edge and pulls away from it promotes by cancelling
36
+ * the first, still-cancelable touchmove; a finger that scrolls INTO the end of
37
+ * the content mid-gesture cannot, since the browser has committed the gesture
38
+ * to scrolling and every remaining event is non-cancelable. The second shape
39
+ * needs no cancelling — the scroller is clamped at its end, so there is
40
+ * nothing left to scroll — and instead anchors at the point where the content
41
+ * ran out and drives the sheet from the travel beyond it.
42
+ *
43
+ * Kept private to BottomSheet: a dismiss edge + detents on a bottom-anchored
44
+ * surface are inherently sheet concepts. It is not a general primitive and is
45
+ * intentionally not exported.
46
+ *
47
+ * SSR-safe: no window/document access at module scope; all measurement
48
+ * happens inside handlers. Respects prefers-reduced-motion by skipping the
49
+ * settle transition.
50
+ *
51
+ * SYNC: When modified, update these files to stay in sync:
52
+ * - /packages/core/src/BottomSheet/useSheetGestures.test.ts
53
+ */
54
+
55
+ import {
56
+ useCallback,
57
+ useEffect,
58
+ useLayoutEffect,
59
+ useMemo,
60
+ useRef,
61
+ useState,
62
+ type CSSProperties,
63
+ type MouseEvent as ReactMouseEvent,
64
+ type PointerEvent as ReactPointerEvent,
65
+ type RefObject,
66
+ type UIEvent as ReactUIEvent,
67
+ } from 'react';
68
+ import {useMediaQuery} from '../hooks';
69
+ import {
70
+ computeDetentOffsets,
71
+ peekOffsetFor,
72
+ resolveSettleOffset,
73
+ scrimOpacityForOffset,
74
+ } from './snapOffsets';
75
+
76
+ /**
77
+ * The stops a sheet of a given height rests at: offsets from fully-open in px,
78
+ * ascending, plus which of them (if any) is the peek.
79
+ */
80
+ interface SheetDetents {
81
+ offsets: number[];
82
+ peekOffset: number | null;
83
+ }
84
+
85
+ // A flick (fast throw) dismisses (down) or expands (up) regardless of where
86
+ // it ends. Requires both a speed and a distance floor so a small nudge
87
+ // doesn't trigger it.
88
+ const FLICK_VELOCITY = 1.2; // px/ms
89
+ const FLICK_MIN_DISTANCE = 48; // px traveled during the gesture
90
+ // On a slow drag below the shortest detent, dismiss once dragged past it by
91
+ // more than this fraction of that detent's height; otherwise snap back to it.
92
+ const DISMISS_OVERSHOOT_RATIO = 0.4;
93
+ // Within this many px of a detent, the live drag is magnetically eased toward
94
+ // it so it "clicks" into place instead of hovering just off the mark.
95
+ const MAGNET_RANGE = 40;
96
+ // The touch path drives the sheet through the same machinery the pointer path
97
+ // uses, by handing it a pointer-shaped object built from a `Touch`. On iOS
98
+ // Safari that object is indistinguishable from the real thing: WebKit raises
99
+ // PointerEvents for a finger under the SAME numeric id it puts in
100
+ // `Touch.identifier`, so such a drag is keyed to a live pointer and the
101
+ // handlers that guard a mouse drag fire against it. Mark the synthetic object
102
+ // so they can tell the two apart.
103
+ type SyntheticTouchPointer = ReactPointerEvent & {syntheticTouch?: true};
104
+
105
+ function isSyntheticTouch(event: ReactPointerEvent): boolean {
106
+ return (event as SyntheticTouchPointer).syntheticTouch === true;
107
+ }
108
+
109
+ // Rubber-band factor for dragging up past fully-open, capped at OVERSCROLL_MAX
110
+ // (the sheet reserves that much bottom padding for the lift to reveal).
111
+ const OVERSCROLL_RESISTANCE = 0.35;
112
+ // SYNC: must match OVERSCROLL_PADDING in BottomSheetPanel.tsx (the reserved
113
+ // bottom padding the lift reveals). Kept as a local const rather than a shared
114
+ // import so it can be used inside stylex.create there.
115
+ const OVERSCROLL_MAX = 48;
116
+ // Travel past the point where a scrolling gesture ran out of content before it
117
+ // hands the sheet the rest of the pull. Small enough to feel continuous with
118
+ // the scroll, large enough that a swipe merely coming to rest on the last pixel
119
+ // doesn't start a drag on jitter.
120
+ const CONTENT_END_HANDOFF_SLOP = 4;
121
+
122
+ // Short haptic tick on detent settle where supported. iOS Safari doesn't
123
+ // expose navigator.vibrate, so this is a no-op there; skipped under
124
+ // reduced-motion.
125
+ function hapticTick(): void {
126
+ if (
127
+ typeof navigator === 'undefined' ||
128
+ typeof navigator.vibrate !== 'function'
129
+ ) {
130
+ return;
131
+ }
132
+ if (prefersReducedMotion()) {
133
+ return;
134
+ }
135
+ navigator.vibrate(8);
136
+ }
137
+
138
+ // Pull a value toward the nearest of `targets` when within MAGNET_RANGE, easing
139
+ // the last stretch so the surface settles crisply onto a detent while dragging.
140
+ function magnetize(value: number, targets: number[]): number {
141
+ let nearestTarget = targets[0];
142
+ let nearestDist = Math.abs(value - nearestTarget);
143
+ for (const t of targets) {
144
+ const d = Math.abs(value - t);
145
+ if (d < nearestDist) {
146
+ nearestDist = d;
147
+ nearestTarget = t;
148
+ }
149
+ }
150
+ if (nearestDist >= MAGNET_RANGE) {
151
+ return value;
152
+ }
153
+ // Ease-in over the range: pull grows as you approach (t^2), so the click
154
+ // feels magnetic near the detent but doesn't fight a deliberate drag-through.
155
+ const t = nearestDist / MAGNET_RANGE;
156
+ const pull = 1 - t * t;
157
+ return value + (nearestTarget - value) * pull;
158
+ }
159
+
160
+ function preservationInsetForOffset(
161
+ baseOffset: number,
162
+ targetOffset: number,
163
+ naturalEndGap: number,
164
+ ): number {
165
+ return Math.max(0, baseOffset - targetOffset - naturalEndGap);
166
+ }
167
+
168
+ function renderedBlockEndPadding(element: HTMLElement): number {
169
+ const value = Number.parseFloat(getComputedStyle(element).paddingBlockEnd);
170
+ return Number.isFinite(value) ? value : 0;
171
+ }
172
+
173
+ // How much further the body could scroll on its own, ignoring the padding the
174
+ // hook adds to preserve scroll position across a height change.
175
+ function naturalEndGapFor(body: HTMLElement | null): number {
176
+ if (!body) {
177
+ return 0;
178
+ }
179
+ const renderedInset = renderedBlockEndPadding(body);
180
+ const naturalMaxScrollTop = Math.max(
181
+ 0,
182
+ body.scrollHeight - body.clientHeight - renderedInset,
183
+ );
184
+ return naturalMaxScrollTop - body.scrollTop;
185
+ }
186
+
187
+ function visibleHeightForOffset(
188
+ sheetHeight: number,
189
+ offset: number,
190
+ offscreenBlockEndInset: number,
191
+ ): number {
192
+ return Math.max(0, sheetHeight - offscreenBlockEndInset - offset);
193
+ }
194
+
195
+ export interface UseSheetGesturesOptions {
196
+ /** Whether the owning sheet is open. Drag state resets when it closes. */
197
+ isOpen: boolean;
198
+ /**
199
+ * Whether a downward swipe may dismiss the sheet. When false, a gesture
200
+ * past the dismiss threshold settles at the shortest detent instead.
201
+ * @default true
202
+ */
203
+ canDismiss?: boolean;
204
+ /**
205
+ * Portion of the measured sheet border box reserved below the viewport.
206
+ * Excluded from detent heights so a 50vh snap has 50vh of visible sheet.
207
+ * @default 0
208
+ */
209
+ offscreenBlockEndInset?: number;
210
+ /** Called on a swipe-to-close (fast downward flick, or drag past the floor). */
211
+ onDismiss: () => void;
212
+ /**
213
+ * Resolver for candidate visible detent heights in px below the fully open
214
+ * visible height. Called at the start of each drag, and again whenever the
215
+ * viewport changes while the sheet rests, so the detents track the window
216
+ * (rotation, a resized desktop window, collapsing browser chrome). The fully
217
+ * open height is always the tallest detent. Omit for a single-height sheet
218
+ * (a drag then only dismisses or springs back).
219
+ *
220
+ * Keep the identity stable: a new function is read as new stops, and
221
+ * re-anchors a resting sheet to them.
222
+ */
223
+ snapHeights?: () => number[];
224
+ /** Notified when the settled visible detent height (px) changes. */
225
+ onSnap?: (heightPx: number) => void;
226
+ /**
227
+ * Called with the scrim opacity the sheet should show (1 = fully visible,
228
+ * 0 = hidden) as the drag moves and on settle. Full while the sheet is at
229
+ * or above its mid detent, fading to 0 as it collapses onto the shortest
230
+ * "peek" detent — thinning to a faint glance state without fully clearing — and
231
+ * on the dismiss overshoot. Lets the owner mirror it onto the scrim.
232
+ */
233
+ onScrimOpacity?: (opacity: number) => void;
234
+ }
235
+
236
+ export interface SheetContentProps {
237
+ style: CSSProperties;
238
+ }
239
+
240
+ export interface SheetHandleProps {
241
+ style: CSSProperties;
242
+ onContextMenu: (event: ReactMouseEvent) => void;
243
+ onLostPointerCapture: (event: ReactPointerEvent) => void;
244
+ onPointerDown: (event: ReactPointerEvent) => void;
245
+ onPointerMove: (event: ReactPointerEvent) => void;
246
+ onPointerUp: (event: ReactPointerEvent) => void;
247
+ onPointerCancel: (event: ReactPointerEvent) => void;
248
+ }
249
+
250
+ export interface SheetBodyProps {
251
+ ref: (node: HTMLElement | null) => void;
252
+ onContextMenu: (event: ReactMouseEvent) => void;
253
+ onLostPointerCapture: (event: ReactPointerEvent) => void;
254
+ onPointerDown: (event: ReactPointerEvent) => void;
255
+ onPointerMove: (event: ReactPointerEvent) => void;
256
+ onPointerUp: (event: ReactPointerEvent) => void;
257
+ onPointerCancel: (event: ReactPointerEvent) => void;
258
+ onScroll: (event: ReactUIEvent<HTMLElement>) => void;
259
+ }
260
+
261
+ export interface UseSheetGesturesResult {
262
+ /**
263
+ * Callback ref for the sheet surface. The hook observes it (ResizeObserver)
264
+ * to keep the fully-open height current, so detents stay correct across
265
+ * rotation / viewport changes without re-measuring mid-drag.
266
+ */
267
+ sheetRef: (node: HTMLElement | null) => void;
268
+ /** Spread on the sliding surface: live translate + touch-action guard. */
269
+ contentProps: SheetContentProps;
270
+ /** Spread on the grab-handle element: pointer drag handlers. */
271
+ handleProps: SheetHandleProps;
272
+ /**
273
+ * Spread on the scrollable body. An overscroll-at-top pull-down starts a
274
+ * sheet drag (a larger, more forgiving target when the content isn't itself
275
+ * scrolling); normal scrolling passes through untouched.
276
+ */
277
+ bodyProps: SheetBodyProps;
278
+ /**
279
+ * The body element `bodyProps.ref` is attached to. The hook tracks the node
280
+ * anyway (it owns the non-passive touch listeners on it), so a host that
281
+ * also needs the element reads it here rather than wrapping `bodyProps.ref`
282
+ * in a second callback ref.
283
+ */
284
+ bodyElementRef: RefObject<HTMLElement | null>;
285
+ /** Current live drag translate in px (0 = fully expanded, larger = collapsed). */
286
+ dragOffset: number;
287
+ /** Translate of the resting detent in px (0 = tallest detent). */
288
+ settledOffset: number;
289
+ /** Whether a drag is currently in progress. */
290
+ isDragging: boolean;
291
+ /** Measured height of the fully expanded sheet. */
292
+ sheetHeight: number;
293
+ /** End padding that preserves the scroll position across height changes. */
294
+ scrollPreservationInset: number;
295
+ /** Layout offset retained until the transform-only snap finishes. */
296
+ settlingLayoutOffset: number | null;
297
+ /**
298
+ * Reconciles the final layout once the transform-only snap finishes. The
299
+ * panel decides WHEN a snap is over (it owns the element and its computed
300
+ * transition), so completion cannot be driven from a `transitionend`
301
+ * listener alone: with transitions disabled — inline `transition: none`, a
302
+ * `0s` duration token, a test harness turning animation off — no event ever
303
+ * arrives and the scroll area would keep its full height forever.
304
+ */
305
+ completeScrollAreaSettle: () => void;
306
+ /**
307
+ * How much of `settledOffset` is expressed as layout height rather than as a
308
+ * transform. Equals `settledOffset` at the resizing detents; 0 at fully open
309
+ * and at the peek, which keep the sheet's full height (see peekOffsetFor).
310
+ */
311
+ settledLayoutOffset: number;
312
+ }
313
+
314
+ function prefersReducedMotion(): boolean {
315
+ if (typeof window === 'undefined' || !window.matchMedia) {
316
+ return false;
317
+ }
318
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
319
+ }
320
+
321
+ /**
322
+ * Drag + snap machinery for the bottom sheet. Returns props to spread on the
323
+ * grab handle and the sliding surface, plus live drag state. A slow drag
324
+ * settles to the nearest detent; a fast downward flick dismisses; a fast
325
+ * upward flick expands to the tallest detent.
326
+ *
327
+ * @example
328
+ * ```
329
+ * const {contentProps, handleProps} = useSheetGestures({
330
+ * isOpen,
331
+ * onDismiss: () => onOpenChange(false),
332
+ * snapHeights: () => [240, 0.5 * (window.visualViewport?.height ?? 0)],
333
+ * });
334
+ * <div {...contentProps}>
335
+ * <div {...handleProps} />
336
+ * {children}
337
+ * </div>
338
+ * ```
339
+ */
340
+ export function useSheetGestures({
341
+ isOpen,
342
+ canDismiss = true,
343
+ offscreenBlockEndInset = 0,
344
+ onDismiss,
345
+ snapHeights,
346
+ onSnap,
347
+ onScrimOpacity,
348
+ }: UseSheetGesturesOptions): UseSheetGesturesResult {
349
+ const [dragOffset, setDragOffset] = useState(0);
350
+ const [settledOffset, setSettledOffset] = useState(0);
351
+ const [isDragging, setIsDragging] = useState(false);
352
+ const [sheetHeight, setSheetHeight] = useState(0);
353
+ const [scrollPreservationInset, setScrollPreservationInset] = useState(0);
354
+ // How much of the settled travel is expressed as layout height. Equal to
355
+ // settledOffset for the resizing detents, and 0 at fully open and at the
356
+ // peek (see peekOffsetFor), which keep the full height and use a transform.
357
+ const [settledLayoutOffset, setSettledLayoutOffset] = useState(0);
358
+ const settledLayoutOffsetRef = useRef(0);
359
+ // WHICH detent the sheet rests on, as an index into the resolved list. The
360
+ // offsets themselves are pixels derived from the viewport, so they stop
361
+ // meaning the same thing the moment the viewport changes; the index survives
362
+ // that and lets a resize re-resolve the same stop against the new geometry.
363
+ const settledDetentIndexRef = useRef(0);
364
+ const [settlingLayoutOffset, setSettlingLayoutOffset] = useState<
365
+ number | null
366
+ >(null);
367
+ const [isScrollAreaReconciling, setIsScrollAreaReconciling] = useState(false);
368
+ const scrollPreservationInsetRef = useRef(0);
369
+ const settlingLayoutOffsetRef = useRef<number | null>(null);
370
+ const pendingScrollPreservationInsetRef = useRef<number | null>(null);
371
+ const offscreenBlockEndInsetRef = useRef(offscreenBlockEndInset);
372
+ offscreenBlockEndInsetRef.current = Math.max(0, offscreenBlockEndInset);
373
+ const recordSettledLayoutOffset = useCallback((offset: number) => {
374
+ const normalizedOffset = Math.max(0, offset);
375
+ settledLayoutOffsetRef.current = normalizedOffset;
376
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- layout offset is recorded from measurement effects
377
+ setSettledLayoutOffset(normalizedOffset);
378
+ }, []);
379
+ const updateScrollPreservationInset = useCallback((nextInset: number) => {
380
+ const normalizedInset = Math.max(0, nextInset);
381
+ const hasChanged =
382
+ Math.abs(normalizedInset - scrollPreservationInsetRef.current) > 0.5;
383
+ if (!hasChanged) {
384
+ return;
385
+ }
386
+ scrollPreservationInsetRef.current = normalizedInset;
387
+ setScrollPreservationInset(normalizedInset);
388
+ }, []);
389
+ const completeScrollAreaSettle = useCallback(() => {
390
+ // Resetting the transform at the same time as the final height swap must
391
+ // not start a second transition. The layouts have identical visible
392
+ // geometry, so reconcile them with transitions disabled for one frame.
393
+ setIsScrollAreaReconciling(true);
394
+ settlingLayoutOffsetRef.current = null;
395
+ setSettlingLayoutOffset(null);
396
+ const pendingInset = pendingScrollPreservationInsetRef.current;
397
+ pendingScrollPreservationInsetRef.current = null;
398
+ if (pendingInset != null) {
399
+ updateScrollPreservationInset(pendingInset);
400
+ }
401
+ }, [updateScrollPreservationInset]);
402
+ const prepareScrollAreaSettle = useCallback(
403
+ (
404
+ baseLayoutOffset: number,
405
+ targetOffset: number,
406
+ targetLayoutOffset: number,
407
+ renderedOffset: number,
408
+ layoutOffset: number,
409
+ naturalEndGap: number,
410
+ shouldAnimate: boolean,
411
+ ) => {
412
+ const targetInset = preservationInsetForOffset(
413
+ baseLayoutOffset,
414
+ targetLayoutOffset,
415
+ naturalEndGap,
416
+ );
417
+ if (
418
+ shouldAnimate &&
419
+ Math.abs(renderedOffset - targetOffset) > 0.5 &&
420
+ !prefersReducedMotion()
421
+ ) {
422
+ pendingScrollPreservationInsetRef.current = targetInset;
423
+ settlingLayoutOffsetRef.current = layoutOffset;
424
+ setSettlingLayoutOffset(layoutOffset);
425
+ return;
426
+ }
427
+ pendingScrollPreservationInsetRef.current = null;
428
+ settlingLayoutOffsetRef.current = null;
429
+ setSettlingLayoutOffset(null);
430
+ // Released on the detent, so there is no travel left to animate — but
431
+ // the layout split may still differ from the one the drag rendered
432
+ // with (magnetize() lands a slow drag exactly on a detent). Swapping
433
+ // height for transform is only invisible while transitions are off:
434
+ // with them live, the composited transform would animate the whole
435
+ // swap while the layout height jumped, throwing the sheet the wrong
436
+ // way. Reconcile in one transition-free frame instead.
437
+ setIsScrollAreaReconciling(
438
+ Math.abs(layoutOffset - targetLayoutOffset) > 0.5,
439
+ );
440
+ updateScrollPreservationInset(targetInset);
441
+ },
442
+ [updateScrollPreservationInset],
443
+ );
444
+ const activeOffsetRef = useRef(0);
445
+ activeOffsetRef.current = isDragging ? dragOffset : settledOffset;
446
+ const isOpenRef = useRef(isOpen);
447
+ isOpenRef.current = isOpen;
448
+
449
+ // The resolver is refreshed during render, not with the callbacks below: the
450
+ // layout effect that re-anchors on a change to it runs BEFORE passive
451
+ // effects, so a resolver parked in one would still be the previous set of
452
+ // stops by the time the re-anchor read it.
453
+ const snapHeightsRef = useRef(snapHeights);
454
+ snapHeightsRef.current = snapHeights;
455
+
456
+ const onDismissRef = useRef(onDismiss);
457
+ const canDismissRef = useRef(canDismiss);
458
+ const onSnapRef = useRef(onSnap);
459
+ const onScrimOpacityRef = useRef(onScrimOpacity);
460
+ useEffect(() => {
461
+ onDismissRef.current = onDismiss;
462
+ canDismissRef.current = canDismiss;
463
+ onSnapRef.current = onSnap;
464
+ onScrimOpacityRef.current = onScrimOpacity;
465
+ });
466
+
467
+ // Live drag bookkeeping (refs so pointermove doesn't churn renders).
468
+ const dragStateRef = useRef<{
469
+ // Whether the touch path drives this drag. Such a drag holds no pointer
470
+ // capture and takes its events from `touchmove`, so every real-pointer
471
+ // handler has to leave it alone.
472
+ syntheticTouch: boolean;
473
+ pointerId: number;
474
+ startCoord: number;
475
+ lastCoord: number;
476
+ lastTime: number;
477
+ velocity: number;
478
+ height: number;
479
+ baseOffset: number;
480
+ baseLayoutOffset: number;
481
+ renderedOffset: number;
482
+ layoutOffset: number;
483
+ naturalEndGap: number;
484
+ } | null>(null);
485
+
486
+ // Fully-open height, tracked by a ResizeObserver (see sheetRef) so detents
487
+ // stay correct across rotation / viewport changes without re-measuring.
488
+ const sheetHeightRef = useRef(0);
489
+ const sheetElRef = useRef<HTMLElement | null>(null);
490
+ const bodyNodeRef = useRef<HTMLElement | null>(null);
491
+ const observerRef = useRef<ResizeObserver | null>(null);
492
+ useLayoutEffect(() => {
493
+ if (!isScrollAreaReconciling) {
494
+ return;
495
+ }
496
+ // Force the transition-free transform reset to resolve before transitions
497
+ // are restored. Otherwise both DOM updates can be coalesced and the reset
498
+ // becomes an unintended fly-in animation from the bottom.
499
+ void sheetElRef.current?.offsetHeight;
500
+ const frame = requestAnimationFrame(() => {
501
+ setIsScrollAreaReconciling(false);
502
+ });
503
+ return () => cancelAnimationFrame(frame);
504
+ }, [isScrollAreaReconciling]);
505
+ const recordSheetHeight = useCallback((renderedHeight: number) => {
506
+ if (renderedHeight <= 0) {
507
+ return;
508
+ }
509
+ // A resized or closing panel's observer reports intermediate heights
510
+ // throughout its animation. Keep the last fully-expanded measurement
511
+ // instead of feeding those transient values back into the rendered height.
512
+ if (!isOpenRef.current || activeOffsetRef.current > 0) {
513
+ return;
514
+ }
515
+ sheetHeightRef.current = renderedHeight;
516
+ setSheetHeight(previousHeight =>
517
+ previousHeight === renderedHeight ? previousHeight : renderedHeight,
518
+ );
519
+ }, []);
520
+ const sheetRef = useCallback(
521
+ (node: HTMLElement | null) => {
522
+ observerRef.current?.disconnect();
523
+ observerRef.current = null;
524
+ sheetElRef.current = node;
525
+ if (!node || typeof ResizeObserver === 'undefined') {
526
+ if (node) {
527
+ recordSheetHeight(node.getBoundingClientRect().height);
528
+ }
529
+ return;
530
+ }
531
+ recordSheetHeight(node.getBoundingClientRect().height);
532
+ const ro = new ResizeObserver(entries => {
533
+ const entry = entries[0];
534
+ if (entry) {
535
+ // Keep this measurement in the same border-box coordinate space as
536
+ // getBoundingClientRect(). contentRect excludes the sheet's reserved
537
+ // bottom padding and would make the first resized drag jump shorter.
538
+ const borderBoxHeight = entry.borderBoxSize?.[0]?.blockSize;
539
+ recordSheetHeight(
540
+ borderBoxHeight ?? entry.target.getBoundingClientRect().height,
541
+ );
542
+ }
543
+ });
544
+ ro.observe(node);
545
+ observerRef.current = ro;
546
+ },
547
+ [recordSheetHeight],
548
+ );
549
+ useEffect(() => () => observerRef.current?.disconnect(), []);
550
+
551
+ // Reset to the tallest detent each time the sheet re-opens.
552
+ useEffect(() => {
553
+ if (isOpen) {
554
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
555
+ setDragOffset(0);
556
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
557
+ setSettledOffset(0);
558
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
559
+ setIsDragging(false);
560
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
561
+ setIsScrollAreaReconciling(false);
562
+ recordSettledLayoutOffset(0);
563
+ settledDetentIndexRef.current = 0;
564
+ scrollPreservationInsetRef.current = 0;
565
+ settlingLayoutOffsetRef.current = null;
566
+ pendingScrollPreservationInsetRef.current = null;
567
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
568
+ setScrollPreservationInset(0);
569
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- resets gesture state on controlled reopen
570
+ setSettlingLayoutOffset(null);
571
+ }
572
+ }, [isOpen, recordSettledLayoutOffset]);
573
+
574
+ // The fully-open height for detent math. Prefer the ResizeObserver-locked
575
+ // value; fall back to a live measure of the tracked sheet element if the
576
+ // observer hasn't reported yet.
577
+ const measureHeight = useCallback((): number => {
578
+ if (sheetHeightRef.current > 0) {
579
+ return sheetHeightRef.current;
580
+ }
581
+ return sheetElRef.current?.getBoundingClientRect().height ?? 0;
582
+ }, []);
583
+
584
+ // Detent translate offsets (px) from the tallest detent, ascending, plus the
585
+ // peek among them. Exclude the border-box portion reserved below the
586
+ // viewport before comparing the candidate visible heights; otherwise every
587
+ // snap point lands that many px too low. Snap heights are resolved lazily so
588
+ // they track the viewport.
589
+ const resolveDetents = useCallback((height: number): SheetDetents => {
590
+ const visibleSheetHeight = visibleHeightForOffset(
591
+ height,
592
+ 0,
593
+ offscreenBlockEndInsetRef.current,
594
+ );
595
+ const offsets = computeDetentOffsets(
596
+ visibleSheetHeight,
597
+ snapHeightsRef.current?.() ?? [],
598
+ );
599
+ return {offsets, peekOffset: peekOffsetFor(offsets, visibleSheetHeight)};
600
+ }, []);
601
+
602
+ // The height the sheet WOULD have fully open, right now. While it rests at a
603
+ // resizing detent the element carries a pixel height computed for whatever
604
+ // the viewport was then, so measuring it directly just reads that stale
605
+ // number back. Drop the inline height for the measurement and put it back in
606
+ // the same synchronous block — the browser cannot paint in between, so this
607
+ // is invisible — and the natural CSS budget is what gets measured.
608
+ const measureFullyOpenHeight = useCallback((): number => {
609
+ const element = sheetElRef.current;
610
+ if (!element) {
611
+ return sheetHeightRef.current;
612
+ }
613
+ const inlineHeight = element.style.height;
614
+ if (inlineHeight === '') {
615
+ return element.getBoundingClientRect().height;
616
+ }
617
+ element.style.height = '';
618
+ const height = element.getBoundingClientRect().height;
619
+ element.style.height = inlineHeight;
620
+ return height;
621
+ }, []);
622
+
623
+ // Detents are viewport fractions, but a settled sheet holds them as pixels:
624
+ // an offset to translate by, and a layout height to render. Both are read
625
+ // once, at gesture time. Without this, a viewport change leaves the sheet
626
+ // frozen at the old pixel geometry — a "half height" sheet showing 75% of a
627
+ // shorter window, a peek detent whose slide-down is taller than the whole
628
+ // window (so the sheet leaves the screen while its dialog stays modal), and
629
+ // a stale fully-open height for the next drag to overshoot past.
630
+ //
631
+ // Re-resolve the SAME detent — by index, the one thing that survives the
632
+ // units changing — against the new geometry, and re-anchor without
633
+ // animating: the geometry moved, not the user's finger, so there is no
634
+ // gesture to continue and nothing to ease.
635
+ const reanchorToSettledDetent = useCallback(() => {
636
+ // A closed sheet re-anchors on its way back open; a live drag re-measures
637
+ // on its own, and a settle in flight owns the layout until it lands.
638
+ if (
639
+ !isOpenRef.current ||
640
+ dragStateRef.current != null ||
641
+ settlingLayoutOffsetRef.current != null
642
+ ) {
643
+ return;
644
+ }
645
+ const height = measureFullyOpenHeight();
646
+ if (height <= 0) {
647
+ return;
648
+ }
649
+ const {offsets, peekOffset} = resolveDetents(height);
650
+ const index = Math.min(settledDetentIndexRef.current, offsets.length - 1);
651
+ const target = offsets[index];
652
+ const targetLayoutOffset = target === peekOffset ? 0 : target;
653
+ const baseLayoutOffset = settledLayoutOffsetRef.current;
654
+ if (
655
+ height === sheetHeightRef.current &&
656
+ Math.abs(target - activeOffsetRef.current) <= 0.5 &&
657
+ Math.abs(targetLayoutOffset - baseLayoutOffset) <= 0.5
658
+ ) {
659
+ return;
660
+ }
661
+
662
+ sheetHeightRef.current = height;
663
+ // Re-anchoring is a measurement: the geometry it reads is only knowable
664
+ // after layout, so the state it corrects can only be set from an effect
665
+ // (or, on the resize path, from the listener). Both writes below are
666
+ // guarded by the equality check above, so a re-anchor that finds nothing
667
+ // to change sets nothing.
668
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- corrects settled geometry from a measurement
669
+ setSheetHeight(height);
670
+ settledDetentIndexRef.current = index;
671
+ prepareScrollAreaSettle(
672
+ baseLayoutOffset,
673
+ target,
674
+ targetLayoutOffset,
675
+ target,
676
+ baseLayoutOffset,
677
+ naturalEndGapFor(bodyNodeRef.current),
678
+ false,
679
+ );
680
+ recordSettledLayoutOffset(targetLayoutOffset);
681
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- corrects settled geometry from a measurement
682
+ setSettledOffset(target);
683
+ onSnapRef.current?.(
684
+ visibleHeightForOffset(height, target, offscreenBlockEndInsetRef.current),
685
+ );
686
+ const maxOffset = offsets[offsets.length - 1];
687
+ const shortestDetentHeight = visibleHeightForOffset(
688
+ height,
689
+ maxOffset,
690
+ offscreenBlockEndInsetRef.current,
691
+ );
692
+ onScrimOpacityRef.current?.(
693
+ scrimOpacityForOffset(
694
+ target,
695
+ offsets,
696
+ maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO,
697
+ peekOffset,
698
+ ),
699
+ );
700
+ }, [
701
+ measureFullyOpenHeight,
702
+ prepareScrollAreaSettle,
703
+ recordSettledLayoutOffset,
704
+ resolveDetents,
705
+ ]);
706
+
707
+ useEffect(() => {
708
+ if (!isOpen || typeof window === 'undefined') {
709
+ return;
710
+ }
711
+ window.addEventListener('resize', reanchorToSettledDetent);
712
+ window.addEventListener('orientationchange', reanchorToSettledDetent);
713
+ return () => {
714
+ window.removeEventListener('resize', reanchorToSettledDetent);
715
+ window.removeEventListener('orientationchange', reanchorToSettledDetent);
716
+ };
717
+ }, [isOpen, reanchorToSettledDetent]);
718
+
719
+ // The other input to the same geometry: the snap points themselves. A host
720
+ // that swaps them while the sheet rests has moved the stops out from under
721
+ // it, exactly as a rotation does, so re-anchor the same way. Skipped on the
722
+ // first run — the sheet is already anchored to the detents it opened with.
723
+ const hasAnchoredSnapHeightsRef = useRef(false);
724
+ useLayoutEffect(() => {
725
+ if (!hasAnchoredSnapHeightsRef.current) {
726
+ hasAnchoredSnapHeightsRef.current = true;
727
+ return;
728
+ }
729
+ reanchorToSettledDetent();
730
+ }, [reanchorToSettledDetent, snapHeights]);
731
+
732
+ const cancelDrag = useCallback(
733
+ (target?: HTMLElement) => {
734
+ const state = dragStateRef.current;
735
+ if (state == null) {
736
+ return;
737
+ }
738
+
739
+ // Clear first: releasePointerCapture() may synchronously dispatch
740
+ // lostpointercapture, which must observe that this drag is already done.
741
+ dragStateRef.current = null;
742
+ if (target?.hasPointerCapture?.(state.pointerId)) {
743
+ target.releasePointerCapture(state.pointerId);
744
+ }
745
+ setDragOffset(state.baseOffset);
746
+ setIsDragging(false);
747
+ prepareScrollAreaSettle(
748
+ state.baseLayoutOffset,
749
+ state.baseOffset,
750
+ state.baseLayoutOffset,
751
+ state.renderedOffset,
752
+ state.layoutOffset,
753
+ state.naturalEndGap,
754
+ true,
755
+ );
756
+
757
+ // An interrupted drag returns to its previous resting detent. Restore
758
+ // the matching scrim opacity as well so the modal shell cannot remain
759
+ // dimmed with its sheet translated out of view.
760
+ const {offsets, peekOffset} = resolveDetents(state.height);
761
+ const maxOffset = offsets[offsets.length - 1];
762
+ const shortestDetentHeight = visibleHeightForOffset(
763
+ state.height,
764
+ maxOffset,
765
+ offscreenBlockEndInsetRef.current,
766
+ );
767
+ const dismissOffset =
768
+ maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
769
+ onScrimOpacityRef.current?.(
770
+ scrimOpacityForOffset(
771
+ state.baseOffset,
772
+ offsets,
773
+ dismissOffset,
774
+ peekOffset,
775
+ ),
776
+ );
777
+ },
778
+ [prepareScrollAreaSettle, resolveDetents],
779
+ );
780
+
781
+ useEffect(() => {
782
+ const handleWindowBlur = () => cancelDrag();
783
+ const handleVisibilityChange = () => {
784
+ if (document.visibilityState === 'hidden') {
785
+ cancelDrag();
786
+ }
787
+ };
788
+ window.addEventListener('blur', handleWindowBlur);
789
+ document.addEventListener('visibilitychange', handleVisibilityChange);
790
+ return () => {
791
+ window.removeEventListener('blur', handleWindowBlur);
792
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
793
+ };
794
+ }, [cancelDrag]);
795
+
796
+ const settleFromDrag = useCallback(
797
+ (
798
+ offset: number,
799
+ velocity: number,
800
+ height: number,
801
+ dir: number,
802
+ travel: number,
803
+ baseOffset: number,
804
+ baseLayoutOffset: number,
805
+ renderedOffset: number,
806
+ layoutOffset: number,
807
+ naturalEndGap: number,
808
+ ) => {
809
+ const {offsets, peekOffset} = resolveDetents(height);
810
+ const maxOffset = offsets[offsets.length - 1];
811
+ const shortestDetentHeight = visibleHeightForOffset(
812
+ height,
813
+ maxOffset,
814
+ offscreenBlockEndInsetRef.current,
815
+ );
816
+ const speed = Math.abs(velocity);
817
+ const isFlick = speed > FLICK_VELOCITY && travel > FLICK_MIN_DISTANCE;
818
+ const settleAt = (target: number) => {
819
+ // A peek keeps the full layout height and slides below the viewport;
820
+ // every taller detent resizes the scrolling area to what it shows.
821
+ const targetLayoutOffset = target === peekOffset ? 0 : target;
822
+ prepareScrollAreaSettle(
823
+ baseLayoutOffset,
824
+ target,
825
+ targetLayoutOffset,
826
+ renderedOffset,
827
+ layoutOffset,
828
+ naturalEndGap,
829
+ true,
830
+ );
831
+ recordSettledLayoutOffset(targetLayoutOffset);
832
+ settledDetentIndexRef.current = Math.max(0, offsets.indexOf(target));
833
+ setSettledOffset(target);
834
+ onSnapRef.current?.(
835
+ visibleHeightForOffset(
836
+ height,
837
+ target,
838
+ offscreenBlockEndInsetRef.current,
839
+ ),
840
+ );
841
+ const dismissOffset =
842
+ maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
843
+ onScrimOpacityRef.current?.(
844
+ scrimOpacityForOffset(target, offsets, dismissOffset, peekOffset),
845
+ );
846
+ if (target !== baseOffset) {
847
+ hapticTick();
848
+ }
849
+ };
850
+
851
+ if (dir > 0 && isFlick) {
852
+ if (canDismissRef.current) {
853
+ prepareScrollAreaSettle(
854
+ baseLayoutOffset,
855
+ baseOffset,
856
+ baseLayoutOffset,
857
+ baseLayoutOffset,
858
+ baseLayoutOffset,
859
+ naturalEndGap,
860
+ false,
861
+ );
862
+ onDismissRef.current();
863
+ } else {
864
+ settleAt(maxOffset);
865
+ }
866
+ return;
867
+ }
868
+ // Fast upward flick = expand to the tallest detent (the sheet's full
869
+ // provided height).
870
+ if (dir < 0 && isFlick) {
871
+ prepareScrollAreaSettle(
872
+ baseLayoutOffset,
873
+ 0,
874
+ 0,
875
+ renderedOffset,
876
+ layoutOffset,
877
+ naturalEndGap,
878
+ true,
879
+ );
880
+ recordSettledLayoutOffset(0);
881
+ settledDetentIndexRef.current = 0;
882
+ setDragOffset(0);
883
+ setSettledOffset(0);
884
+ onSnapRef.current?.(
885
+ visibleHeightForOffset(height, 0, offscreenBlockEndInsetRef.current),
886
+ );
887
+ onScrimOpacityRef.current?.(1);
888
+ hapticTick();
889
+ return;
890
+ }
891
+ if (offset > maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO) {
892
+ if (canDismissRef.current) {
893
+ prepareScrollAreaSettle(
894
+ baseLayoutOffset,
895
+ baseOffset,
896
+ baseLayoutOffset,
897
+ baseLayoutOffset,
898
+ baseLayoutOffset,
899
+ naturalEndGap,
900
+ false,
901
+ );
902
+ onDismissRef.current();
903
+ } else {
904
+ settleAt(maxOffset);
905
+ }
906
+ return;
907
+ }
908
+ // Settle to the nearest detent in the drag direction (never back past
909
+ // the starting detent), de-duped and direction-clamped by the util.
910
+ const target = resolveSettleOffset(offset, offsets, dir, baseOffset);
911
+ settleAt(target);
912
+ },
913
+ [prepareScrollAreaSettle, recordSettledLayoutOffset, resolveDetents],
914
+ );
915
+
916
+ const beginDrag = useCallback(
917
+ (event: ReactPointerEvent, sheetHeight: number, startCoord?: number) => {
918
+ const target = event.currentTarget as HTMLElement;
919
+ const syntheticTouch = isSyntheticTouch(event);
920
+ // Never capture for a touch drag. The id came from `Touch.identifier`,
921
+ // which on iOS names a live pointer: the capture succeeds, WebKit takes
922
+ // it straight back for its own gesture handling, and the
923
+ // `lostpointercapture` a millisecond later cancels the drag that just
924
+ // started. Touch drags need no capture — the listener is on the
925
+ // scroller itself.
926
+ if (!syntheticTouch) {
927
+ target.setPointerCapture?.(event.pointerId);
928
+ }
929
+ // `startCoord` lets a body-overscroll drag anchor at the original
930
+ // pointer-down position (not the promotion point), so the first frame's
931
+ // delta reflects the full pull distance.
932
+ const start = startCoord ?? event.clientY;
933
+ const naturalEndGap = naturalEndGapFor(bodyNodeRef.current);
934
+ const baseLayoutOffset = settledLayoutOffsetRef.current;
935
+ dragStateRef.current = {
936
+ syntheticTouch,
937
+ pointerId: event.pointerId,
938
+ startCoord: start,
939
+ lastCoord: event.clientY,
940
+ lastTime: event.timeStamp,
941
+ velocity: 0,
942
+ height: sheetHeight,
943
+ baseOffset: settledOffset,
944
+ baseLayoutOffset,
945
+ renderedOffset: settledOffset,
946
+ layoutOffset: baseLayoutOffset,
947
+ naturalEndGap,
948
+ };
949
+ updateScrollPreservationInset(
950
+ preservationInsetForOffset(
951
+ baseLayoutOffset,
952
+ baseLayoutOffset,
953
+ naturalEndGap,
954
+ ),
955
+ );
956
+ // Seed dragOffset at the resting detent so flipping isDragging doesn't
957
+ // jump the sheet to fully-open for one frame.
958
+ setDragOffset(settledOffset);
959
+ setIsDragging(true);
960
+ },
961
+ [settledOffset, updateScrollPreservationInset],
962
+ );
963
+
964
+ const handlePointerDown = useCallback(
965
+ (event: ReactPointerEvent) => {
966
+ if (event.button !== 0 || !event.isPrimary) {
967
+ return;
968
+ }
969
+ // The handle has no native focus action. Prevent pointer-down from
970
+ // moving focus off a form control on a tap; once the pointer actually
971
+ // moves, BottomSheet dismisses the keyboard as sheet travel begins.
972
+ event.preventDefault();
973
+ beginDrag(event, measureHeight());
974
+ },
975
+ [beginDrag, measureHeight],
976
+ );
977
+
978
+ const handleContextMenu = useCallback(
979
+ (event: ReactMouseEvent) => {
980
+ if (dragStateRef.current == null) {
981
+ return;
982
+ }
983
+ event.preventDefault();
984
+ cancelDrag(event.currentTarget as HTMLElement);
985
+ },
986
+ [cancelDrag],
987
+ );
988
+
989
+ const handleLostPointerCapture = useCallback(
990
+ (event: ReactPointerEvent) => {
991
+ // A touch drag never took capture, so this is WebKit reclaiming its own
992
+ // pointer for the finger doing the dragging — not the drag losing its
993
+ // grip.
994
+ if (dragStateRef.current?.syntheticTouch) {
995
+ return;
996
+ }
997
+ if (dragStateRef.current?.pointerId === event.pointerId) {
998
+ cancelDrag();
999
+ }
1000
+ },
1001
+ [cancelDrag],
1002
+ );
1003
+
1004
+ const handlePointerMove = useCallback(
1005
+ (event: ReactPointerEvent) => {
1006
+ const state = dragStateRef.current;
1007
+ if (!state || state.pointerId !== event.pointerId) {
1008
+ return;
1009
+ }
1010
+ // Same finger, real pointer event: the touch path already drove this
1011
+ // move. Let it own the drag rather than driving it twice.
1012
+ if (state.syntheticTouch && !isSyntheticTouch(event)) {
1013
+ return;
1014
+ }
1015
+ const delta = event.clientY - state.startCoord;
1016
+ const dt = event.timeStamp - state.lastTime;
1017
+ if (dt > 0) {
1018
+ state.velocity = (event.clientY - state.lastCoord) / dt;
1019
+ state.lastCoord = event.clientY;
1020
+ state.lastTime = event.timeStamp;
1021
+ }
1022
+ const {offsets, peekOffset} = resolveDetents(state.height);
1023
+
1024
+ const raw = state.baseOffset + delta;
1025
+ const maxDetentOffset = offsets[offsets.length - 1];
1026
+ let next: number;
1027
+ if (raw < 0) {
1028
+ // Up past fully-open: damped + capped rubber-band; springs back on release.
1029
+ next = Math.max(-OVERSCROLL_MAX, raw * OVERSCROLL_RESISTANCE);
1030
+ } else if (raw > maxDetentOffset) {
1031
+ // In the dismiss zone: no magnet, so it doesn't fight a drag-to-close.
1032
+ next = raw;
1033
+ } else {
1034
+ // Between detents: magnetically ease toward a nearby one.
1035
+ next = magnetize(raw, offsets);
1036
+ }
1037
+ // Dragging above the base restores the full layout height below the
1038
+ // viewport; otherwise keep whatever layout the base detent settled with
1039
+ // (0 at a peek, so a peek drag stays transform-only).
1040
+ const layoutOffset = next < state.baseOffset ? 0 : state.baseLayoutOffset;
1041
+ state.renderedOffset = next;
1042
+ state.layoutOffset = layoutOffset;
1043
+ setDragOffset(next);
1044
+ updateScrollPreservationInset(
1045
+ preservationInsetForOffset(
1046
+ state.baseLayoutOffset,
1047
+ layoutOffset,
1048
+ state.naturalEndGap,
1049
+ ),
1050
+ );
1051
+
1052
+ // Mirror the scrim to the live drag: full at/above the mid detent,
1053
+ // fading to hidden as it collapses onto the peek detent and through the
1054
+ // dismiss overshoot.
1055
+ const floorOffset = offsets[offsets.length - 1];
1056
+ const shortestDetentHeight = visibleHeightForOffset(
1057
+ state.height,
1058
+ floorOffset,
1059
+ offscreenBlockEndInsetRef.current,
1060
+ );
1061
+ const dismissOffset =
1062
+ floorOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
1063
+ onScrimOpacityRef.current?.(
1064
+ scrimOpacityForOffset(next, offsets, dismissOffset, peekOffset),
1065
+ );
1066
+ },
1067
+ [resolveDetents, updateScrollPreservationInset],
1068
+ );
1069
+
1070
+ const endDrag = useCallback(
1071
+ (event: ReactPointerEvent) => {
1072
+ const state = dragStateRef.current;
1073
+ if (!state || state.pointerId !== event.pointerId) {
1074
+ return;
1075
+ }
1076
+ // `pointercancel` fires for the finger the moment WebKit claims the
1077
+ // gesture; ending a touch drag on it settles the sheet mid-pull. The
1078
+ // touchend handler is what finishes a touch drag.
1079
+ if (state.syntheticTouch && !isSyntheticTouch(event)) {
1080
+ return;
1081
+ }
1082
+ const target = event.currentTarget as HTMLElement;
1083
+ const delta = event.clientY - state.startCoord;
1084
+ const offset = Math.max(0, state.baseOffset + delta);
1085
+ const dir = delta === 0 ? 0 : delta > 0 ? 1 : -1;
1086
+ dragStateRef.current = null;
1087
+ if (!state.syntheticTouch) {
1088
+ target.releasePointerCapture?.(event.pointerId);
1089
+ }
1090
+ setIsDragging(false);
1091
+ settleFromDrag(
1092
+ offset,
1093
+ state.velocity,
1094
+ state.height || 1,
1095
+ dir,
1096
+ Math.abs(delta),
1097
+ state.baseOffset,
1098
+ state.baseLayoutOffset,
1099
+ state.renderedOffset,
1100
+ state.layoutOffset,
1101
+ state.naturalEndGap,
1102
+ );
1103
+ },
1104
+ [settleFromDrag],
1105
+ );
1106
+
1107
+ // Pointer path for the body at-top pull-down (desktop / mouse). Touch uses
1108
+ // the non-passive listener below, since pointer events are cancelled once a
1109
+ // native pan starts.
1110
+ const armedBodyRef = useRef<{
1111
+ pointerId: number;
1112
+ startCoord: number;
1113
+ scroller: HTMLElement;
1114
+ } | null>(null);
1115
+
1116
+ const handleBodyPointerDown = useCallback((event: ReactPointerEvent) => {
1117
+ if (event.button !== 0 || !event.isPrimary) {
1118
+ return;
1119
+ }
1120
+ const scroller = event.currentTarget as HTMLElement;
1121
+ if (scroller.scrollTop > 0) {
1122
+ armedBodyRef.current = null;
1123
+ return;
1124
+ }
1125
+ armedBodyRef.current = {
1126
+ pointerId: event.pointerId,
1127
+ startCoord: event.clientY,
1128
+ scroller,
1129
+ };
1130
+ }, []);
1131
+
1132
+ const handleBodyPointerMove = useCallback(
1133
+ (event: ReactPointerEvent) => {
1134
+ if (dragStateRef.current) {
1135
+ handlePointerMove(event);
1136
+ return;
1137
+ }
1138
+ const armed = armedBodyRef.current;
1139
+ if (!armed || armed.pointerId !== event.pointerId) {
1140
+ return;
1141
+ }
1142
+ const delta = event.clientY - armed.startCoord;
1143
+ if (delta > 0 && armed.scroller.scrollTop <= 0) {
1144
+ // Downward pull at the top: promote to a sheet drag, anchored at the
1145
+ // original pointer-down position so the pull distance carries over.
1146
+ armedBodyRef.current = null;
1147
+ beginDrag(event, measureHeight(), armed.startCoord);
1148
+ handlePointerMove(event);
1149
+ } else if (delta < 0) {
1150
+ // Upward move = the user is scrolling; disarm so we don't hijack it.
1151
+ armedBodyRef.current = null;
1152
+ }
1153
+ },
1154
+ [beginDrag, handlePointerMove, measureHeight],
1155
+ );
1156
+
1157
+ const handleBodyEnd = useCallback(
1158
+ (event: ReactPointerEvent) => {
1159
+ armedBodyRef.current = null;
1160
+ // The pointerup/pointercancel for a finger already driving a touch drag
1161
+ // arrives here carrying that drag's own id; touchend ends those.
1162
+ if (dragStateRef.current?.syntheticTouch) {
1163
+ return;
1164
+ }
1165
+ if (dragStateRef.current) {
1166
+ endDrag(event);
1167
+ }
1168
+ },
1169
+ [endDrag],
1170
+ );
1171
+
1172
+ // Non-passive touchmove is the reliable scroll<->drag handoff on touch:
1173
+ // preventDefault() at a scroll edge stops the native scroll and drives the
1174
+ // sheet drag through the pointer math (Touch adapted to the fields it reads).
1175
+ //
1176
+ // Every touch on the body is tracked, not only the ones that begin at an
1177
+ // edge: a gesture that starts mid-content and scrolls to the end has to hand
1178
+ // off too, and by then it is far too late to arm from `touchstart`.
1179
+ const touchDragRef = useRef<{
1180
+ id: number;
1181
+ startY: number;
1182
+ top: boolean;
1183
+ bottom: boolean;
1184
+ // Where the finger was when the scroller ran out of content, or null while
1185
+ // it can still scroll. A mid-gesture handoff drives the sheet from travel
1186
+ // BEYOND this point, so the part of the swipe that legitimately scrolled
1187
+ // doesn't move the sheet as well.
1188
+ contentEndY: number | null;
1189
+ // Whether the drag in flight was promoted from `contentEndY` rather than
1190
+ // armed at `touchstart`. That drag never cancelled the native scroll, so
1191
+ // it has to yield again if the finger comes back down.
1192
+ promotedAtContentEnd: boolean;
1193
+ } | null>(null);
1194
+ const previousTouchHandlersRef = useRef<{
1195
+ start: (e: TouchEvent) => void;
1196
+ move: (e: TouchEvent) => void;
1197
+ end: (e: TouchEvent) => void;
1198
+ } | null>(null);
1199
+
1200
+ const beginDragRef = useRef(beginDrag);
1201
+ const cancelDragRef = useRef(cancelDrag);
1202
+ const pointerMoveRef = useRef(handlePointerMove);
1203
+ const endDragRef = useRef(endDrag);
1204
+ const measureHeightRef = useRef(measureHeight);
1205
+ useEffect(() => {
1206
+ beginDragRef.current = beginDrag;
1207
+ cancelDragRef.current = cancelDrag;
1208
+ pointerMoveRef.current = handlePointerMove;
1209
+ endDragRef.current = endDrag;
1210
+ measureHeightRef.current = measureHeight;
1211
+ });
1212
+
1213
+ const bodyRef = useCallback((node: HTMLElement | null) => {
1214
+ const asPointer = (touch: Touch, target: HTMLElement) =>
1215
+ ({
1216
+ syntheticTouch: true,
1217
+ pointerId: touch.identifier,
1218
+ clientY: touch.clientY,
1219
+ timeStamp: Date.now(),
1220
+ currentTarget: target,
1221
+ setPointerCapture: () => {},
1222
+ releasePointerCapture: () => {},
1223
+ }) as unknown as ReactPointerEvent;
1224
+
1225
+ const atTop = (el: HTMLElement) => el.scrollTop <= 0;
1226
+ const atBottom = (el: HTMLElement) =>
1227
+ el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
1228
+
1229
+ const onTouchStart = (event: TouchEvent) => {
1230
+ const scroller = event.currentTarget as HTMLElement;
1231
+ const touch = event.changedTouches[0];
1232
+ // Record where the gesture began and whether it began at a scroll edge.
1233
+ // At the top, a pull DOWN hands off (collapse); at the bottom, a pull UP
1234
+ // hands off (expand). A gesture that starts mid-content is an ordinary
1235
+ // scroll, but it is tracked all the same: the scroller can run out of
1236
+ // content while the finger is still down (see onTouchMove).
1237
+ if (!touch) {
1238
+ touchDragRef.current = null;
1239
+ return;
1240
+ }
1241
+ const top = atTop(scroller);
1242
+ // The bottom edge hands off so the sheet can EXPAND, so it is only a
1243
+ // handoff when a taller detent exists. Already at the tallest, an
1244
+ // upward pull has nowhere to travel: promoting it would trade the
1245
+ // user's scroll for a rubber-band the release throws straight back, and
1246
+ // — because promotion preventDefault()s the rest of the gesture — would
1247
+ // strand the scroller for as long as the finger stays down, so reversing
1248
+ // downward to scroll back would collapse the sheet instead. Leave the
1249
+ // gesture with the content.
1250
+ const bottom = atBottom(scroller) && activeOffsetRef.current > 0;
1251
+ touchDragRef.current = {
1252
+ id: touch.identifier,
1253
+ startY: touch.clientY,
1254
+ top,
1255
+ bottom,
1256
+ contentEndY: null,
1257
+ promotedAtContentEnd: false,
1258
+ };
1259
+ };
1260
+
1261
+ const onTouchMove = (event: TouchEvent) => {
1262
+ const scroller = event.currentTarget as HTMLElement;
1263
+ const armed = touchDragRef.current;
1264
+ if (dragStateRef.current) {
1265
+ const t = [...event.changedTouches].find(
1266
+ x => x.identifier === dragStateRef.current?.pointerId,
1267
+ );
1268
+ if (!t) {
1269
+ return;
1270
+ }
1271
+ if (armed?.promotedAtContentEnd && armed.contentEndY != null) {
1272
+ if (t.clientY >= armed.contentEndY) {
1273
+ // Back at the point where the content ran out. This drag never
1274
+ // cancelled the native scroll — it couldn't, the events were no
1275
+ // longer cancelable — so the scroller is about to move again. Hand
1276
+ // the gesture back rather than driving the sheet and the content
1277
+ // at once; a later pull past the end promotes again.
1278
+ armed.contentEndY = null;
1279
+ armed.promotedAtContentEnd = false;
1280
+ cancelDragRef.current(scroller);
1281
+ return;
1282
+ }
1283
+ // Deliberately NOT preventDefault()ed: the scroller is clamped at
1284
+ // its end, so there is no scrolling left to cancel, and claiming the
1285
+ // gesture would strand the content for as long as the finger is down.
1286
+ pointerMoveRef.current(asPointer(t, scroller));
1287
+ return;
1288
+ }
1289
+ event.preventDefault();
1290
+ pointerMoveRef.current(asPointer(t, scroller));
1291
+ return;
1292
+ }
1293
+ if (!armed) {
1294
+ return;
1295
+ }
1296
+ const t = [...event.changedTouches].find(x => x.identifier === armed.id);
1297
+ if (!t) {
1298
+ return;
1299
+ }
1300
+ const delta = t.clientY - armed.startY;
1301
+ // Promote to a sheet drag on a pull that opposes the armed edge and can
1302
+ // no longer scroll that way: at the top, a downward pull (delta > 0)
1303
+ // collapses; at the bottom, an upward pull (delta < 0) expands. The
1304
+ // opposite direction is a real scroll, so disarm and let it through.
1305
+ const pullDownAtTop = armed.top && delta > 0 && atTop(scroller);
1306
+ const pullUpAtBottom = armed.bottom && delta < 0 && atBottom(scroller);
1307
+ if (pullDownAtTop || pullUpAtBottom) {
1308
+ event.preventDefault();
1309
+ touchDragRef.current = null;
1310
+ beginDragRef.current(
1311
+ asPointer(t, scroller),
1312
+ measureHeightRef.current(),
1313
+ armed.startY,
1314
+ );
1315
+ pointerMoveRef.current(asPointer(t, scroller));
1316
+ return;
1317
+ }
1318
+ if ((armed.top && delta < 0) || (armed.bottom && delta > 0)) {
1319
+ // Scrolling away from the armed edge; hand back to native scroll. The
1320
+ // touch stays tracked: this is the swipe that may reach the far edge.
1321
+ armed.top = false;
1322
+ armed.bottom = false;
1323
+ }
1324
+ // Reaching the end of the content mid-gesture. Arming at `touchstart`
1325
+ // cannot see this, and re-arming for a preventDefault() promotion would
1326
+ // be useless anyway: once the browser has committed the gesture to
1327
+ // scrolling, every remaining touchmove is non-cancelable. Nothing needs
1328
+ // cancelling either — the scroller is clamped at its maximum, so further
1329
+ // upward travel scrolls nothing. Anchor at the point where the content
1330
+ // ran out and give the sheet everything past it, so the pull continues
1331
+ // into the sheet with no jump and no lost scrolling.
1332
+ if (activeOffsetRef.current > 0 && atBottom(scroller)) {
1333
+ if (armed.contentEndY == null) {
1334
+ armed.contentEndY = t.clientY;
1335
+ } else if (armed.contentEndY - t.clientY >= CONTENT_END_HANDOFF_SLOP) {
1336
+ armed.promotedAtContentEnd = true;
1337
+ beginDragRef.current(
1338
+ asPointer(t, scroller),
1339
+ measureHeightRef.current(),
1340
+ armed.contentEndY,
1341
+ );
1342
+ pointerMoveRef.current(asPointer(t, scroller));
1343
+ }
1344
+ } else {
1345
+ armed.contentEndY = null;
1346
+ }
1347
+ };
1348
+
1349
+ const onTouchEnd = (event: TouchEvent) => {
1350
+ touchDragRef.current = null;
1351
+ const pointerId = dragStateRef.current?.pointerId;
1352
+ if (pointerId == null) {
1353
+ return;
1354
+ }
1355
+ const t = [...event.changedTouches].find(
1356
+ touch => touch.identifier === pointerId,
1357
+ );
1358
+ const target = event.currentTarget as HTMLElement;
1359
+ if (t) {
1360
+ endDragRef.current(asPointer(t, target));
1361
+ } else if (event.touches.length === 0) {
1362
+ // Some interrupted multi-touch sequences omit the active touch from
1363
+ // changedTouches. If no fingers remain, the drag cannot finish later.
1364
+ cancelDragRef.current(target);
1365
+ }
1366
+ };
1367
+
1368
+ const prev = bodyNodeRef.current;
1369
+ if (prev && previousTouchHandlersRef.current) {
1370
+ const h = previousTouchHandlersRef.current;
1371
+ prev.removeEventListener('touchstart', h.start);
1372
+ prev.removeEventListener('touchmove', h.move);
1373
+ prev.removeEventListener('touchend', h.end);
1374
+ prev.removeEventListener('touchcancel', h.end);
1375
+ }
1376
+ bodyNodeRef.current = node;
1377
+ if (node) {
1378
+ node.addEventListener('touchstart', onTouchStart, {passive: true});
1379
+ node.addEventListener('touchmove', onTouchMove, {passive: false});
1380
+ node.addEventListener('touchend', onTouchEnd, {passive: true});
1381
+ node.addEventListener('touchcancel', onTouchEnd, {passive: true});
1382
+ previousTouchHandlersRef.current = {
1383
+ start: onTouchStart,
1384
+ move: onTouchMove,
1385
+ end: onTouchEnd,
1386
+ };
1387
+ } else {
1388
+ previousTouchHandlersRef.current = null;
1389
+ }
1390
+ }, []);
1391
+
1392
+ // Subscribed, not memoized: the preference can change while a sheet is open,
1393
+ // and this branch decides whether the settle runs as a transition at all.
1394
+ // The imperative gesture paths read prefersReducedMotion() directly.
1395
+ const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
1396
+
1397
+ const reconcileScrollPreservationInset = useCallback(
1398
+ (body: HTMLElement) => {
1399
+ if (scrollPreservationInsetRef.current <= 0) {
1400
+ return;
1401
+ }
1402
+ const renderedInset = renderedBlockEndPadding(body);
1403
+ const naturalMaxScrollTop = Math.max(
1404
+ 0,
1405
+ body.scrollHeight - body.clientHeight - renderedInset,
1406
+ );
1407
+ const requiredInset = Math.max(0, body.scrollTop - naturalMaxScrollTop);
1408
+ if (requiredInset < scrollPreservationInsetRef.current - 0.5) {
1409
+ updateScrollPreservationInset(requiredInset);
1410
+ }
1411
+ },
1412
+ [updateScrollPreservationInset],
1413
+ );
1414
+
1415
+ const handleBodyScroll = useCallback(
1416
+ (event: ReactUIEvent<HTMLElement>) => {
1417
+ if (
1418
+ dragStateRef.current != null ||
1419
+ settlingLayoutOffsetRef.current != null
1420
+ ) {
1421
+ return;
1422
+ }
1423
+ reconcileScrollPreservationInset(event.currentTarget);
1424
+ },
1425
+ [reconcileScrollPreservationInset],
1426
+ );
1427
+
1428
+ // While dragging, follow the finger; otherwise rest at the settled detent.
1429
+ const activeOffset = isDragging ? dragOffset : settledOffset;
1430
+
1431
+ const contentProps = useMemo<SheetContentProps>(
1432
+ () => ({
1433
+ style: {
1434
+ transform:
1435
+ activeOffset !== 0 ? `translateY(${activeOffset}px)` : undefined,
1436
+ transition:
1437
+ isDragging || isScrollAreaReconciling || reducedMotion
1438
+ ? 'none'
1439
+ : undefined,
1440
+ touchAction: 'none',
1441
+ overscrollBehavior: 'contain',
1442
+ },
1443
+ }),
1444
+ [activeOffset, isDragging, isScrollAreaReconciling, reducedMotion],
1445
+ );
1446
+
1447
+ const handleProps = useMemo<SheetHandleProps>(
1448
+ () => ({
1449
+ style: {touchAction: 'none', cursor: 'grab'},
1450
+ onContextMenu: handleContextMenu,
1451
+ onLostPointerCapture: handleLostPointerCapture,
1452
+ onPointerDown: handlePointerDown,
1453
+ onPointerMove: handlePointerMove,
1454
+ onPointerUp: endDrag,
1455
+ onPointerCancel: endDrag,
1456
+ }),
1457
+ [
1458
+ endDrag,
1459
+ handleContextMenu,
1460
+ handleLostPointerCapture,
1461
+ handlePointerDown,
1462
+ handlePointerMove,
1463
+ ],
1464
+ );
1465
+
1466
+ const bodyProps = useMemo<SheetBodyProps>(
1467
+ () => ({
1468
+ ref: bodyRef,
1469
+ onContextMenu: handleContextMenu,
1470
+ onLostPointerCapture: handleLostPointerCapture,
1471
+ onPointerDown: handleBodyPointerDown,
1472
+ onPointerMove: handleBodyPointerMove,
1473
+ onPointerUp: handleBodyEnd,
1474
+ onPointerCancel: handleBodyEnd,
1475
+ onScroll: handleBodyScroll,
1476
+ }),
1477
+ [
1478
+ bodyRef,
1479
+ handleBodyEnd,
1480
+ handleBodyPointerDown,
1481
+ handleBodyPointerMove,
1482
+ handleBodyScroll,
1483
+ handleContextMenu,
1484
+ handleLostPointerCapture,
1485
+ ],
1486
+ );
1487
+
1488
+ return {
1489
+ sheetRef,
1490
+ contentProps,
1491
+ handleProps,
1492
+ bodyProps,
1493
+ bodyElementRef: bodyNodeRef,
1494
+ dragOffset,
1495
+ settledOffset,
1496
+ isDragging,
1497
+ sheetHeight,
1498
+ scrollPreservationInset,
1499
+ settlingLayoutOffset,
1500
+ settledLayoutOffset,
1501
+ completeScrollAreaSettle,
1502
+ };
1503
+ }