@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.
Files changed (200) 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 +66 -0
  7. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  8. package/dist/BottomSheet/BottomSheet.js +392 -0
  9. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  10. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  11. package/dist/BottomSheet/BottomSheetPanel.js +398 -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 +72 -0
  22. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  23. package/dist/BottomSheet/snapOffsets.js +130 -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 +180 -0
  28. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  29. package/dist/BottomSheet/useSheetGestures.js +989 -0
  30. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  31. package/dist/Chat/ChatComposerInput.js +3 -4
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +12 -6
  34. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  35. package/dist/ContextMenu/ContextMenu.js +4 -2
  36. package/dist/DateInput/DateInput.d.ts.map +1 -1
  37. package/dist/DateInput/DateInput.js +9 -1
  38. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  39. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  40. package/dist/Dialog/Dialog.d.ts.map +1 -1
  41. package/dist/Dialog/Dialog.js +2 -1
  42. package/dist/Field/InputClearButton.d.ts +2 -1
  43. package/dist/Field/InputClearButton.d.ts.map +1 -1
  44. package/dist/Field/InputClearButton.js +6 -1
  45. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  46. package/dist/Lightbox/Lightbox.js +9 -3
  47. package/dist/MobileNav/MobileNav.d.ts +12 -0
  48. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  49. package/dist/MobileNav/MobileNav.js +102 -30
  50. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  51. package/dist/MultiSelector/MultiSelector.js +21 -7
  52. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  53. package/dist/NumberInput/NumberInput.js +9 -1
  54. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  55. package/dist/Selector/Selector.d.ts.map +1 -1
  56. package/dist/Selector/Selector.js +14 -3
  57. package/dist/StatusDot/StatusDot.d.ts +43 -3
  58. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  59. package/dist/StatusDot/StatusDot.js +42 -5
  60. package/dist/Switch/Switch.d.ts.map +1 -1
  61. package/dist/Switch/Switch.js +5 -1
  62. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  63. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  64. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  65. package/dist/TimeInput/TimeInput.js +8 -1
  66. package/dist/Tokenizer/Tokenizer.js +11 -5
  67. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  68. package/dist/Tooltip/useTooltip.js +4 -1
  69. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  70. package/dist/Typeahead/BaseTypeahead.js +5 -3
  71. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  72. package/dist/Typeahead/Typeahead.js +10 -1
  73. package/dist/astryx.css +21 -0
  74. package/dist/hooks/index.d.ts +8 -1
  75. package/dist/hooks/index.d.ts.map +1 -1
  76. package/dist/hooks/index.js +8 -1
  77. package/dist/hooks/useFocusTrap.d.ts +0 -11
  78. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  79. package/dist/hooks/useFocusTrap.js +1 -11
  80. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  81. package/dist/hooks/useInputStatusIcon.js +1 -0
  82. package/dist/index.d.ts +1 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +1 -0
  85. package/dist/theme/defineTheme.d.ts +22 -4
  86. package/dist/theme/defineTheme.d.ts.map +1 -1
  87. package/dist/theme/expandColorScale.d.ts +17 -2
  88. package/dist/theme/expandColorScale.d.ts.map +1 -1
  89. package/dist/theme/expandColorScale.js +63 -33
  90. package/dist/utils/ime.d.ts +5 -0
  91. package/dist/utils/ime.d.ts.map +1 -0
  92. package/dist/utils/ime.js +63 -0
  93. package/dist/utils/index.d.ts +1 -0
  94. package/dist/utils/index.d.ts.map +1 -1
  95. package/dist/utils/index.js +2 -1
  96. package/locales/af-ZA.json +958 -0
  97. package/locales/ar-SA.json +998 -0
  98. package/locales/ca-ES.json +974 -0
  99. package/locales/cs-CZ.json +990 -0
  100. package/locales/da-DK.json +966 -0
  101. package/locales/de-DE.json +966 -0
  102. package/locales/el-GR.json +990 -0
  103. package/locales/en.json +48 -0
  104. package/locales/es-ES.json +978 -0
  105. package/locales/fi-FI.json +990 -0
  106. package/locales/fr-FR.json +928 -0
  107. package/locales/he-IL.json +990 -0
  108. package/locales/hu-HU.json +990 -0
  109. package/locales/it-IT.json +978 -0
  110. package/locales/ja-JP.json +998 -0
  111. package/locales/ko-KR.json +990 -0
  112. package/locales/nl-NL.json +954 -0
  113. package/locales/no-NO.json +978 -0
  114. package/locales/pl-PL.json +982 -0
  115. package/locales/pseudo.json +36 -0
  116. package/locales/pt-BR.json +982 -0
  117. package/locales/pt-PT.json +982 -0
  118. package/locales/ro-RO.json +982 -0
  119. package/locales/ru-RU.json +990 -0
  120. package/locales/sr-SP.json +990 -0
  121. package/locales/sv-SE.json +986 -0
  122. package/locales/tr-TR.json +990 -0
  123. package/locales/uk-UA.json +990 -0
  124. package/locales/vi-VN.json +986 -0
  125. package/locales/zh-CN.json +998 -0
  126. package/locales/zh-TW.json +998 -0
  127. package/package.json +9 -8
  128. package/src/Banner/Banner.doc.mjs +3 -3
  129. package/src/Banner/Banner.test.tsx +40 -0
  130. package/src/Banner/Banner.tsx +27 -2
  131. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  132. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  133. package/src/BottomSheet/BottomSheet.tsx +545 -0
  134. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  135. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  137. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  138. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  139. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  140. package/src/BottomSheet/index.ts +15 -0
  141. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  142. package/src/BottomSheet/snapOffsets.ts +159 -0
  143. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  144. package/src/BottomSheet/useSheetGestures.test.ts +1059 -0
  145. package/src/BottomSheet/useSheetGestures.ts +1459 -0
  146. package/src/Chat/ChatComposerInput.tsx +3 -4
  147. package/src/ComplexSelector/ComplexSelector.tsx +12 -5
  148. package/src/ContextMenu/ContextMenu.tsx +4 -2
  149. package/src/DateInput/DateInput.test.tsx +21 -0
  150. package/src/DateInput/DateInput.tsx +9 -1
  151. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  152. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  153. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  154. package/src/Dialog/Dialog.tsx +2 -1
  155. package/src/Field/Field.doc.mjs +1 -0
  156. package/src/Field/InputClearButton.test.tsx +25 -0
  157. package/src/Field/InputClearButton.tsx +4 -1
  158. package/src/FileInput/FileInput.test.tsx +53 -9
  159. package/src/Lightbox/Lightbox.test.tsx +49 -28
  160. package/src/Lightbox/Lightbox.tsx +7 -3
  161. package/src/MobileNav/MobileNav.tsx +128 -37
  162. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  163. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  164. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  165. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  166. package/src/MultiSelector/MultiSelector.tsx +22 -8
  167. package/src/NumberInput/NumberInput.test.tsx +18 -0
  168. package/src/NumberInput/NumberInput.tsx +9 -1
  169. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  170. package/src/Selector/Selector.test.tsx +85 -26
  171. package/src/Selector/Selector.tsx +13 -4
  172. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  173. package/src/StatusDot/StatusDot.test.tsx +115 -8
  174. package/src/StatusDot/StatusDot.tsx +74 -9
  175. package/src/Switch/Switch.test.tsx +16 -0
  176. package/src/Switch/Switch.tsx +7 -0
  177. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  178. package/src/TimeInput/TimeInput.test.tsx +23 -0
  179. package/src/TimeInput/TimeInput.tsx +8 -0
  180. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  181. package/src/Tokenizer/Tokenizer.tsx +5 -5
  182. package/src/Tooltip/useTooltip.tsx +4 -1
  183. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  184. package/src/Typeahead/Typeahead.test.tsx +116 -7
  185. package/src/Typeahead/Typeahead.tsx +10 -1
  186. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  187. package/src/__tests__/stubMatchMedia.ts +95 -0
  188. package/src/hooks/index.ts +9 -5
  189. package/src/hooks/useFocusTrap.ts +1 -14
  190. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  191. package/src/hooks/useInputStatusIcon.tsx +7 -0
  192. package/src/index.ts +1 -0
  193. package/src/theme/defineTheme.ts +22 -4
  194. package/src/theme/expandColorScale.test.ts +138 -0
  195. package/src/theme/expandColorScale.ts +89 -44
  196. package/src/utils/ime.test.ts +32 -0
  197. package/src/utils/ime.ts +69 -0
  198. package/src/utils/index.ts +2 -0
  199. package/dist/astryx.umd.js +0 -147
  200. package/dist/astryx.umd.js.map +0 -7
@@ -0,0 +1,1059 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file useSheetGestures.test.ts
5
+ * @input Uses vitest, @testing-library/react renderHook, useSheetGestures
6
+ * @output Unit tests for useSheetGestures behavior
7
+ * @position Testing; validates useSheetGestures.ts implementation
8
+ *
9
+ * SYNC: When useSheetGestures.ts changes, update tests to match new behavior
10
+ */
11
+
12
+ import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
13
+ import {act, renderHook} from '@testing-library/react';
14
+ import {
15
+ useSheetGestures,
16
+ type UseSheetGesturesOptions,
17
+ } from './useSheetGestures';
18
+
19
+ const SHEET_HEIGHT = 400;
20
+
21
+ // A fake pointer event object good enough for the handlers, which only read
22
+ // pointerId, clientY, timeStamp, preventDefault, and currentTarget's capture +
23
+ // measure APIs.
24
+ function pointerEvent(
25
+ clientY: number,
26
+ timeStamp: number,
27
+ target: HTMLElement,
28
+ pointerId = 1,
29
+ button = 0,
30
+ isPrimary = true,
31
+ ) {
32
+ return {
33
+ pointerId,
34
+ button,
35
+ isPrimary,
36
+ clientY,
37
+ timeStamp,
38
+ currentTarget: target,
39
+ preventDefault: vi.fn(),
40
+ } as unknown as React.PointerEvent;
41
+ }
42
+
43
+ function makeTarget(): HTMLElement {
44
+ const el = document.createElement('div');
45
+ el.setPointerCapture = vi.fn();
46
+ el.releasePointerCapture = vi.fn();
47
+ el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
48
+ return el;
49
+ }
50
+
51
+ beforeEach(() => {
52
+ vi.stubGlobal(
53
+ 'matchMedia',
54
+ vi.fn().mockReturnValue({
55
+ matches: false,
56
+ addEventListener: vi.fn(),
57
+ removeEventListener: vi.fn(),
58
+ }),
59
+ );
60
+ });
61
+
62
+ afterEach(() => {
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ function setup(options: Partial<UseSheetGesturesOptions> = {}) {
67
+ const onDismiss = vi.fn();
68
+ const hook = renderHook(
69
+ (props: UseSheetGesturesOptions) => useSheetGestures(props),
70
+ {
71
+ initialProps: {
72
+ isOpen: true,
73
+ onDismiss,
74
+ ...options,
75
+ },
76
+ },
77
+ );
78
+ return {hook, onDismiss};
79
+ }
80
+
81
+ type Hook = {
82
+ result: {current: ReturnType<typeof useSheetGestures>};
83
+ };
84
+
85
+ function down(hook: Hook, y: number, t: number, target: HTMLElement) {
86
+ // Register the sheet element the way the component does on mount, so the
87
+ // hook can measure its height (it no longer queries the DOM for it).
88
+ act(() => hook.result.current.sheetRef(target));
89
+ act(() =>
90
+ hook.result.current.handleProps.onPointerDown(pointerEvent(y, t, target)),
91
+ );
92
+ }
93
+ function move(hook: Hook, y: number, t: number, target: HTMLElement) {
94
+ act(() =>
95
+ hook.result.current.handleProps.onPointerMove(pointerEvent(y, t, target)),
96
+ );
97
+ }
98
+ function up(hook: Hook, y: number, t: number, target: HTMLElement) {
99
+ act(() =>
100
+ hook.result.current.handleProps.onPointerUp(pointerEvent(y, t, target)),
101
+ );
102
+ }
103
+
104
+ describe('useSheetGestures', () => {
105
+ it('dismisses on a fast downward flick (swipe-to-close)', () => {
106
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
107
+ const t = makeTarget();
108
+ down(hook, 0, 0, t);
109
+ // 60px in 20ms = 3px/ms, and 60px > the flick distance floor.
110
+ move(hook, 60, 20, t);
111
+ up(hook, 60, 22, t);
112
+ expect(onDismiss).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it('settles instead of dismissing on a downward flick when canDismiss is false', () => {
116
+ const onSnap = vi.fn();
117
+ const onScrimOpacity = vi.fn();
118
+ const {hook, onDismiss} = setup({
119
+ canDismiss: false,
120
+ snapHeights: () => [200],
121
+ onSnap,
122
+ onScrimOpacity,
123
+ });
124
+ const t = makeTarget();
125
+ down(hook, 0, 0, t);
126
+ move(hook, 60, 20, t);
127
+ up(hook, 60, 22, t);
128
+
129
+ expect(onDismiss).not.toHaveBeenCalled();
130
+ expect(hook.result.current.settledOffset).toBe(200);
131
+ expect(onSnap).toHaveBeenLastCalledWith(200);
132
+ expect(onScrimOpacity).toHaveBeenLastCalledWith(0.3);
133
+ });
134
+
135
+ it('expands to the tallest detent on a fast upward flick', () => {
136
+ const onSnap = vi.fn();
137
+ const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap});
138
+ const t = makeTarget();
139
+ // Start rested at a lower detent by first snapping there via a slow drag.
140
+ down(hook, 0, 0, t);
141
+ move(hook, 200, 400, t); // slow drag down toward the 200px detent
142
+ up(hook, 200, 800, t);
143
+ onSnap.mockClear();
144
+ // Now flick up fast: 60px in 20ms = 3px/ms, over the distance floor.
145
+ down(hook, 200, 1000, t);
146
+ move(hook, 140, 1020, t);
147
+ up(hook, 140, 1022, t);
148
+ expect(onDismiss).not.toHaveBeenCalled();
149
+ expect(onSnap).toHaveBeenLastCalledWith(SHEET_HEIGHT); // full height
150
+ expect(hook.result.current.settledOffset).toBe(0);
151
+ });
152
+
153
+ it('does not flick-dismiss on a fast but short nudge', () => {
154
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
155
+ const t = makeTarget();
156
+ // Fast (20px/10ms = 2px/ms) but only 20px of travel — under the distance
157
+ // floor, so it settles rather than dismissing.
158
+ down(hook, 0, 0, t);
159
+ move(hook, 20, 10, t);
160
+ up(hook, 20, 12, t);
161
+ expect(onDismiss).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it('settles to the nearest detent on a slow drag', () => {
165
+ const onSnap = vi.fn();
166
+ const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap});
167
+ const t = makeTarget();
168
+ // Detents: full=400 (offset 0) and 200 (offset 200). Drag slowly to ~180
169
+ // offset -> nearest is the 200px detent (offset 200).
170
+ down(hook, 0, 0, t);
171
+ move(hook, 90, 300, t);
172
+ move(hook, 180, 700, t);
173
+ up(hook, 180, 1100, t);
174
+ expect(onDismiss).not.toHaveBeenCalled();
175
+ expect(onSnap).toHaveBeenLastCalledWith(200);
176
+ expect(hook.result.current.settledOffset).toBe(200);
177
+ });
178
+
179
+ it('excludes the offscreen block-end reserve from visible detent heights', () => {
180
+ const onSnap = vi.fn();
181
+ const {hook} = setup({
182
+ snapHeights: () => [200],
183
+ offscreenBlockEndInset: 48,
184
+ onSnap,
185
+ });
186
+ const t = makeTarget();
187
+
188
+ // The 400px border box has 352px visible. A 200px visible detent therefore
189
+ // rests at offset 152, not 200; the 48px reserve remains below the viewport.
190
+ down(hook, 0, 0, t);
191
+ move(hook, 150, 700, t);
192
+ up(hook, 150, 1100, t);
193
+
194
+ expect(hook.result.current.settledOffset).toBe(152);
195
+ expect(onSnap).toHaveBeenLastCalledWith(200);
196
+ });
197
+
198
+ it('keeps the full layout height at the peek detent', () => {
199
+ // Three detents on the 400px sheet: full (0), mid 240 (offset 160), and
200
+ // the 80px peek (offset 320).
201
+ const {hook} = setup({snapHeights: () => [80, 240]});
202
+ const t = makeTarget();
203
+
204
+ // Settle at the mid detent: the scrolling area takes that travel as
205
+ // layout height.
206
+ down(hook, 0, 0, t);
207
+ move(hook, 150, 700, t);
208
+ up(hook, 150, 1100, t);
209
+ expect(hook.result.current.settledOffset).toBe(160);
210
+ expect(hook.result.current.settledLayoutOffset).toBe(160);
211
+
212
+ // Settle at the peek: a glance state keeps the sheet's full layout height
213
+ // and slides it below the viewport, so no layout travel is reported.
214
+ down(hook, 150, 2000, t);
215
+ move(hook, 310, 2700, t);
216
+ up(hook, 310, 3100, t);
217
+ expect(hook.result.current.settledOffset).toBe(320);
218
+ expect(hook.result.current.settledLayoutOffset).toBe(0);
219
+ });
220
+
221
+ it('treats the only collapsed stop as a peek, like the scrim does', () => {
222
+ const {hook} = setup({snapHeights: () => [200]});
223
+ const t = makeTarget();
224
+ down(hook, 0, 0, t);
225
+ move(hook, 180, 700, t);
226
+ up(hook, 180, 1100, t);
227
+ expect(hook.result.current.settledOffset).toBe(200);
228
+ expect(hook.result.current.settledLayoutOffset).toBe(0);
229
+ });
230
+
231
+ it('uses the visible shortest detent height for the dismiss overshoot', () => {
232
+ const {hook, onDismiss} = setup({
233
+ snapHeights: () => [200],
234
+ offscreenBlockEndInset: 48,
235
+ });
236
+ const t = makeTarget();
237
+
238
+ // Visible full height is 352px, so the 200px stop is offset 152. Its 40%
239
+ // dismiss overshoot ends at 232px; the hidden reserve must not extend it.
240
+ down(hook, 0, 0, t);
241
+ move(hook, 235, 1000, t);
242
+ up(hook, 235, 2000, t);
243
+
244
+ expect(onDismiss).toHaveBeenCalledTimes(1);
245
+ });
246
+
247
+ it('a downward drag from a middle detent never snaps back up past it', () => {
248
+ // Three detents on a 600px sheet: full (offset 0), mid 360 (offset 240),
249
+ // short 160 (offset 440). Rest at the mid detent, then drag DOWN a modest
250
+ // amount that's still closer to mid than to short. It must not snap back
251
+ // UP to full — only to mid (stay) or further down.
252
+ const onSnap = vi.fn();
253
+ const {hook} = setup({snapHeights: () => [160, 360], onSnap});
254
+ const t = makeTarget();
255
+ t.getBoundingClientRect = () => ({height: 600}) as DOMRect;
256
+ // Drag to the mid detent (offset 240).
257
+ down(hook, 0, 0, t);
258
+ move(hook, 120, 300, t);
259
+ move(hook, 240, 700, t);
260
+ up(hook, 240, 1100, t);
261
+ expect(hook.result.current.settledOffset).toBe(240);
262
+ onSnap.mockClear();
263
+ // Small slow drag DOWN from mid: offset 240 -> ~300.
264
+ down(hook, 300, 2000, t);
265
+ move(hook, 340, 2200, t);
266
+ move(hook, 360, 2600, t);
267
+ up(hook, 360, 3000, t);
268
+ // Must not have jumped back up to full (offset 0).
269
+ expect(hook.result.current.settledOffset).not.toBe(0);
270
+ expect(hook.result.current.settledOffset).toBeGreaterThanOrEqual(240);
271
+ });
272
+
273
+ it('dismisses when dragged well below the shortest detent', () => {
274
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
275
+ const t = makeTarget();
276
+ // Shortest detent offset = 200 (height 200). Drag slowly past it by
277
+ // >40% of 200 -> offset ~320 dismisses.
278
+ down(hook, 0, 0, t);
279
+ move(hook, 160, 400, t);
280
+ move(hook, 330, 900, t);
281
+ up(hook, 330, 1400, t);
282
+ expect(onDismiss).toHaveBeenCalledTimes(1);
283
+ });
284
+
285
+ it('rebounds to the shortest detent when canDismiss is false', () => {
286
+ const onScrimOpacity = vi.fn();
287
+ const {hook, onDismiss} = setup({
288
+ canDismiss: false,
289
+ snapHeights: () => [200],
290
+ onScrimOpacity,
291
+ });
292
+ const t = makeTarget();
293
+ down(hook, 0, 0, t);
294
+ move(hook, 160, 400, t);
295
+ move(hook, 330, 900, t);
296
+ up(hook, 330, 1400, t);
297
+
298
+ expect(onDismiss).not.toHaveBeenCalled();
299
+ expect(hook.result.current.settledOffset).toBe(200);
300
+ expect(onScrimOpacity).toHaveBeenLastCalledWith(0.3);
301
+ });
302
+
303
+ it('fades the scrim from full toward the peek floor as it collapses onto the peek detent', () => {
304
+ const onScrimOpacity = vi.fn();
305
+ // Sheet 400, detents at 200 and 300 -> offsets [0, 100, 200]. The fade
306
+ // spans the mid detent (offset 100) to the peek detent (offset 200):
307
+ // full at/above 100, thinned to the peek floor (0.3) at/below 200.
308
+ const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
309
+ const t = makeTarget();
310
+ down(hook, 0, 0, t);
311
+ move(hook, 60, 200, t); // offset 60, above the mid detent -> full
312
+ move(hook, 150, 600, t); // offset 150, halfway -> between 1 and 0.3 (~0.65)
313
+ move(hook, 220, 1000, t); // offset 220, past the peek -> peek floor
314
+ const values = onScrimOpacity.mock.calls.map(c => Number(c[0]));
315
+ expect(values[0]).toBe(1);
316
+ expect(values[1]).toBeGreaterThan(0.55);
317
+ expect(values[1]).toBeLessThan(0.75);
318
+ expect(values[values.length - 1]).toBeCloseTo(0.3);
319
+ });
320
+
321
+ it('thins the scrim to the peek floor when settled at the peek detent (still modal)', () => {
322
+ const onScrimOpacity = vi.fn();
323
+ const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
324
+ const t = makeTarget();
325
+ // Slow drag down to the peek detent (offset 200).
326
+ down(hook, 0, 0, t);
327
+ move(hook, 100, 400, t);
328
+ move(hook, 200, 900, t);
329
+ up(hook, 200, 1400, t);
330
+ expect(hook.result.current.settledOffset).toBe(200);
331
+ // Last reported opacity (on settle) is the peek floor, not fully hidden —
332
+ // the sheet is still modal, so the backdrop keeps a minimum dim.
333
+ const values = onScrimOpacity.mock.calls.map(c => Number(c[0]));
334
+ expect(values[values.length - 1]).toBeCloseTo(0.3);
335
+ });
336
+
337
+ it('magnetically settles the live drag onto a nearby detent', () => {
338
+ // Detents for a 400px sheet with snaps 300 and 200 -> visible-height snaps
339
+ // become offsets [0, 100, 200]. Approaching the middle detent (offset 100)
340
+ // from just below (raw ~90) — inside MAGNET_RANGE and BELOW the shortest
341
+ // detent so the between-detents magnet applies — should ease toward 100.
342
+ const {hook} = setup({snapHeights: () => [200, 300]});
343
+ const t = makeTarget();
344
+ down(hook, 0, 0, t);
345
+ move(hook, 90, 100, t); // raw offset 90, 10px from the 100 detent
346
+ const off = hook.result.current.dragOffset;
347
+ expect(off).toBeGreaterThan(90); // pulled up toward 100
348
+ expect(off).toBeLessThanOrEqual(100);
349
+ });
350
+
351
+ it('translates the surface live during a drag', () => {
352
+ const {hook} = setup({snapHeights: () => [200]});
353
+ const t = makeTarget();
354
+ down(hook, 0, 0, t);
355
+ move(hook, 50, 100, t);
356
+ expect(hook.result.current.dragOffset).toBe(50);
357
+ expect(hook.result.current.isDragging).toBe(true);
358
+ expect(hook.result.current.contentProps.style.transform).toBe(
359
+ 'translateY(50px)',
360
+ );
361
+ });
362
+
363
+ it('returns to the settled detent when a context menu interrupts a drag', () => {
364
+ const onScrimOpacity = vi.fn();
365
+ const {hook, onDismiss} = setup({
366
+ snapHeights: () => [200, 300],
367
+ onScrimOpacity,
368
+ });
369
+ const target = makeTarget();
370
+ down(hook, 0, 0, target);
371
+ move(hook, 300, 1000, target);
372
+
373
+ const preventDefault = vi.fn();
374
+ act(() =>
375
+ hook.result.current.handleProps.onContextMenu({
376
+ currentTarget: target,
377
+ preventDefault,
378
+ } as unknown as React.MouseEvent),
379
+ );
380
+
381
+ expect(preventDefault).toHaveBeenCalledTimes(1);
382
+ expect(hook.result.current.isDragging).toBe(false);
383
+ expect(hook.result.current.contentProps.style.transform).toBeUndefined();
384
+ expect(onScrimOpacity).toHaveBeenLastCalledWith(1);
385
+ expect(onDismiss).not.toHaveBeenCalled();
386
+ });
387
+
388
+ it('returns to the settled detent when pointer capture is lost', () => {
389
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
390
+ const target = makeTarget();
391
+ down(hook, 0, 0, target);
392
+ move(hook, 300, 1000, target);
393
+
394
+ act(() =>
395
+ hook.result.current.handleProps.onLostPointerCapture(
396
+ pointerEvent(300, 1000, target),
397
+ ),
398
+ );
399
+
400
+ expect(hook.result.current.isDragging).toBe(false);
401
+ expect(hook.result.current.contentProps.style.transform).toBeUndefined();
402
+ expect(onDismiss).not.toHaveBeenCalled();
403
+ });
404
+
405
+ it('does not start a drag from a secondary pointer button', () => {
406
+ const {hook} = setup();
407
+ const target = makeTarget();
408
+ act(() => hook.result.current.sheetRef(target));
409
+
410
+ act(() =>
411
+ hook.result.current.handleProps.onPointerDown(
412
+ pointerEvent(0, 0, target, 1, 2),
413
+ ),
414
+ );
415
+
416
+ expect(hook.result.current.isDragging).toBe(false);
417
+ });
418
+
419
+ it('rubber-bands an upward drag past the fully-open position', () => {
420
+ const {hook} = setup({snapHeights: () => [200]});
421
+ const t = makeTarget();
422
+ down(hook, 100, 0, t);
423
+ move(hook, 20, 100, t); // drag up 80px past the top of a rested-at-top sheet
424
+ // Overscroll is allowed but damped: a resisted fraction of the raw -80,
425
+ // and negative (above the top), not the raw distance and not clamped to 0.
426
+ const off = hook.result.current.dragOffset;
427
+ expect(off).toBeLessThan(0);
428
+ expect(off).toBeGreaterThan(-80); // resisted, so smaller in magnitude
429
+ });
430
+
431
+ it('dismisses a single-height sheet on a slow drag past the floor', () => {
432
+ const {hook, onDismiss} = setup(); // no snapHeights
433
+ const t = makeTarget();
434
+ // Only detent is the full 400px height (offset 0). A slow drag past 40%
435
+ // of it (>160px) with no detent to catch it dismisses.
436
+ down(hook, 0, 0, t);
437
+ move(hook, 120, 400, t);
438
+ move(hook, 240, 900, t);
439
+ up(hook, 240, 1400, t);
440
+ expect(onDismiss).toHaveBeenCalledTimes(1);
441
+ });
442
+
443
+ describe('body overscroll', () => {
444
+ // A scrollable body element nested inside the sheet, with a settable
445
+ // scrollTop so we can simulate "at the top" vs "scrolled".
446
+ function makeBody(scrollTop: number) {
447
+ const body = document.createElement('div');
448
+ Object.defineProperty(body, 'scrollTop', {
449
+ value: scrollTop,
450
+ writable: true,
451
+ });
452
+ body.setPointerCapture = vi.fn();
453
+ body.releasePointerCapture = vi.fn();
454
+ body.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
455
+ return body;
456
+ }
457
+ function bodyDown(hook: Hook, y: number, t: number, el: HTMLElement) {
458
+ // Register a sheet element so the hook can measure height (it reads the
459
+ // tracked node, not the DOM). The body stands in with the right height.
460
+ act(() => hook.result.current.sheetRef(el));
461
+ act(() =>
462
+ hook.result.current.bodyProps.onPointerDown(pointerEvent(y, t, el)),
463
+ );
464
+ }
465
+ function bodyMove(hook: Hook, y: number, t: number, el: HTMLElement) {
466
+ act(() =>
467
+ hook.result.current.bodyProps.onPointerMove(pointerEvent(y, t, el)),
468
+ );
469
+ }
470
+
471
+ it('promotes a top-overscroll pull-down into a sheet drag', () => {
472
+ const {hook} = setup({snapHeights: () => [200]});
473
+ const body = makeBody(0); // at the top
474
+ bodyDown(hook, 0, 0, body);
475
+ bodyMove(hook, 40, 100, body); // pull down while at the top
476
+ expect(hook.result.current.isDragging).toBe(true);
477
+ expect(hook.result.current.dragOffset).toBe(40);
478
+ });
479
+
480
+ it('does not hijack scrolling when the body is scrolled down', () => {
481
+ const {hook} = setup({snapHeights: () => [200]});
482
+ const body = makeBody(120); // scrolled, not at the top
483
+ bodyDown(hook, 0, 0, body);
484
+ bodyMove(hook, 40, 100, body);
485
+ expect(hook.result.current.isDragging).toBe(false);
486
+ });
487
+
488
+ it('does not start a drag on an upward move from the top', () => {
489
+ const {hook} = setup({snapHeights: () => [200]});
490
+ const body = makeBody(0);
491
+ bodyDown(hook, 100, 0, body);
492
+ bodyMove(hook, 60, 100, body); // moving up = scrolling intent
493
+ expect(hook.result.current.isDragging).toBe(false);
494
+ });
495
+ });
496
+
497
+ describe('body touch handoff', () => {
498
+ // Attach a real DOM node via the body ref, then dispatch touch events. The
499
+ // hook's non-passive touchmove listener is how the handoff works on touch
500
+ // devices (pointer events get cancelled once native panning starts).
501
+ function makeScroller(opts: {
502
+ scrollTop: number;
503
+ clientHeight: number;
504
+ scrollHeight: number;
505
+ }) {
506
+ const sheet = makeTarget();
507
+ const el = document.createElement('div');
508
+ Object.defineProperty(el, 'scrollTop', {
509
+ value: opts.scrollTop,
510
+ writable: true,
511
+ });
512
+ Object.defineProperty(el, 'clientHeight', {value: opts.clientHeight});
513
+ Object.defineProperty(el, 'scrollHeight', {value: opts.scrollHeight});
514
+ el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
515
+ sheet.appendChild(el);
516
+ document.body.appendChild(sheet);
517
+ return el;
518
+ }
519
+ function touch(el: HTMLElement, type: string, y: number, id = 1) {
520
+ const ev = new Event(type, {bubbles: true, cancelable: true});
521
+ // jsdom lacks TouchEvent; attach the fields the handler reads.
522
+ Object.defineProperty(ev, 'changedTouches', {
523
+ value: [{identifier: id, clientY: y}],
524
+ });
525
+ Object.defineProperty(ev, 'touches', {
526
+ value:
527
+ type === 'touchend' || type === 'touchcancel'
528
+ ? []
529
+ : [{identifier: id, clientY: y}],
530
+ });
531
+ Object.defineProperty(ev, 'currentTarget', {value: el});
532
+ el.dispatchEvent(ev);
533
+ return ev;
534
+ }
535
+
536
+ it('promotes a top pull-down (touch) into a sheet drag', () => {
537
+ const {hook} = setup({snapHeights: () => [200]});
538
+ const el = makeScroller({
539
+ scrollTop: 0,
540
+ clientHeight: 200,
541
+ scrollHeight: 800,
542
+ });
543
+ act(() => hook.result.current.sheetRef(el));
544
+ act(() => hook.result.current.bodyProps.ref(el));
545
+ act(() => {
546
+ touch(el, 'touchstart', 0);
547
+ touch(el, 'touchmove', 50);
548
+ });
549
+ expect(hook.result.current.isDragging).toBe(true);
550
+ });
551
+
552
+ it('promotes a bottom pull-up (touch) into a sheet drag that travels', () => {
553
+ const {hook} = setup({snapHeights: () => [200]});
554
+ // scrolled to the bottom: scrollTop + clientHeight === scrollHeight
555
+ const el = makeScroller({
556
+ scrollTop: 600,
557
+ clientHeight: 200,
558
+ scrollHeight: 800,
559
+ });
560
+ act(() => hook.result.current.sheetRef(el));
561
+ act(() => hook.result.current.bodyProps.ref(el));
562
+ // Settle at the lower detent first, so expanding has somewhere to go.
563
+ down(hook, 0, 0, el);
564
+ move(hook, 180, 700, el);
565
+ up(hook, 180, 1100, el);
566
+ expect(hook.result.current.settledOffset).toBe(200);
567
+
568
+ act(() => {
569
+ touch(el, 'touchstart', 300);
570
+ touch(el, 'touchmove', 250); // pull up
571
+ });
572
+ expect(hook.result.current.isDragging).toBe(true);
573
+ // The handoff is only worth taking if the sheet follows the finger:
574
+ // 50px of pull expands 50px toward the tallest detent.
575
+ expect(hook.result.current.dragOffset).toBe(150);
576
+ });
577
+
578
+ it('leaves a bottom pull-up with the scroller at the tallest detent', () => {
579
+ // Regression: the sheet is already fully expanded, so an upward pull has
580
+ // nowhere to expand to. Promoting it produced a rubber-band the release
581
+ // threw straight back, and stole the gesture from the scroller.
582
+ const {hook} = setup({snapHeights: () => [200]});
583
+ const el = makeScroller({
584
+ scrollTop: 600,
585
+ clientHeight: 200,
586
+ scrollHeight: 800,
587
+ });
588
+ act(() => hook.result.current.sheetRef(el));
589
+ act(() => hook.result.current.bodyProps.ref(el));
590
+ expect(hook.result.current.settledOffset).toBe(0);
591
+
592
+ act(() => {
593
+ touch(el, 'touchstart', 300);
594
+ touch(el, 'touchmove', 250); // pull up
595
+ });
596
+ expect(hook.result.current.isDragging).toBe(false);
597
+ expect(hook.result.current.dragOffset).toBe(0);
598
+ });
599
+
600
+ it('does not preventDefault a bottom pull-up at the tallest detent', () => {
601
+ // The captured gesture was the worse half of the bug: once promoted,
602
+ // every later touchmove was preventDefault()ed, so reversing downward to
603
+ // scroll back collapsed the sheet instead of scrolling.
604
+ const {hook} = setup({snapHeights: () => [200]});
605
+ const el = makeScroller({
606
+ scrollTop: 600,
607
+ clientHeight: 200,
608
+ scrollHeight: 800,
609
+ });
610
+ act(() => hook.result.current.sheetRef(el));
611
+ act(() => hook.result.current.bodyProps.ref(el));
612
+
613
+ let pullUp: Event | undefined;
614
+ let reverseDown: Event | undefined;
615
+ act(() => {
616
+ touch(el, 'touchstart', 300);
617
+ pullUp = touch(el, 'touchmove', 250);
618
+ reverseDown = touch(el, 'touchmove', 400);
619
+ });
620
+ expect(pullUp?.defaultPrevented).toBe(false);
621
+ expect(reverseDown?.defaultPrevented).toBe(false);
622
+ expect(hook.result.current.isDragging).toBe(false);
623
+ });
624
+
625
+ it('finishes the active drag when another ended touch is listed first', () => {
626
+ const {hook} = setup({snapHeights: () => [200]});
627
+ const el = makeScroller({
628
+ scrollTop: 0,
629
+ clientHeight: 200,
630
+ scrollHeight: 800,
631
+ });
632
+ act(() => hook.result.current.sheetRef(el));
633
+ act(() => hook.result.current.bodyProps.ref(el));
634
+ act(() => {
635
+ touch(el, 'touchstart', 0, 2);
636
+ touch(el, 'touchmove', 300, 2);
637
+
638
+ const end = new Event('touchend', {
639
+ bubbles: true,
640
+ cancelable: true,
641
+ });
642
+ Object.defineProperty(end, 'changedTouches', {
643
+ value: [
644
+ {identifier: 1, clientY: 100},
645
+ {identifier: 2, clientY: 300},
646
+ ],
647
+ });
648
+ Object.defineProperty(end, 'touches', {value: []});
649
+ el.dispatchEvent(end);
650
+ });
651
+
652
+ expect(hook.result.current.isDragging).toBe(false);
653
+ });
654
+
655
+ it('leaves native scrolling alone in the middle of the content', () => {
656
+ const {hook} = setup({snapHeights: () => [200]});
657
+ const el = makeScroller({
658
+ scrollTop: 300,
659
+ clientHeight: 200,
660
+ scrollHeight: 800,
661
+ });
662
+ act(() => hook.result.current.sheetRef(el));
663
+ act(() => hook.result.current.bodyProps.ref(el));
664
+ act(() => {
665
+ touch(el, 'touchstart', 300);
666
+ touch(el, 'touchmove', 250);
667
+ });
668
+ expect(hook.result.current.isDragging).toBe(false);
669
+ });
670
+ });
671
+
672
+ describe('body touch handoff mid-gesture', () => {
673
+ // The reported bug: the finger lands mid-content, swipes up, REACHES the
674
+ // end of the content, and keeps pulling in the same continuous gesture.
675
+ // Arming decided at touchstart can never see that, and by the time the
676
+ // scroller is at its end the browser owns the gesture — every remaining
677
+ // touchmove is non-cancelable, so preventDefault() is not available to
678
+ // promote with. These cases pin the anchored, cancel-free handoff instead.
679
+ function makeScroller(opts: {
680
+ scrollTop: number;
681
+ clientHeight: number;
682
+ scrollHeight: number;
683
+ }) {
684
+ const sheet = makeTarget();
685
+ const el = document.createElement('div');
686
+ Object.defineProperty(el, 'scrollTop', {
687
+ value: opts.scrollTop,
688
+ writable: true,
689
+ });
690
+ Object.defineProperty(el, 'clientHeight', {value: opts.clientHeight});
691
+ Object.defineProperty(el, 'scrollHeight', {value: opts.scrollHeight});
692
+ el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
693
+ sheet.appendChild(el);
694
+ document.body.appendChild(sheet);
695
+ return el;
696
+ }
697
+ function touch(el: HTMLElement, type: string, y: number, id = 1) {
698
+ const ev = new Event(type, {bubbles: true, cancelable: true});
699
+ Object.defineProperty(ev, 'changedTouches', {
700
+ value: [{identifier: id, clientY: y}],
701
+ });
702
+ Object.defineProperty(ev, 'touches', {
703
+ value:
704
+ type === 'touchend' || type === 'touchcancel'
705
+ ? []
706
+ : [{identifier: id, clientY: y}],
707
+ });
708
+ Object.defineProperty(ev, 'currentTarget', {value: el});
709
+ el.dispatchEvent(ev);
710
+ return ev;
711
+ }
712
+ // A scroller with 600px of scrollable content, resting one detent down so
713
+ // an upward pull has somewhere to expand to.
714
+ function midDetentScroller(hook: Hook, scrollTop: number) {
715
+ const el = makeScroller({
716
+ scrollTop,
717
+ clientHeight: 200,
718
+ scrollHeight: 800,
719
+ });
720
+ act(() => hook.result.current.sheetRef(el));
721
+ act(() => hook.result.current.bodyProps.ref(el));
722
+ down(hook, 0, 0, el);
723
+ move(hook, 180, 700, el);
724
+ up(hook, 180, 1100, el);
725
+ expect(hook.result.current.settledOffset).toBe(200);
726
+ return el;
727
+ }
728
+
729
+ // iOS Safari raises PointerEvents for a finger under the SAME numeric id
730
+ // it puts in `Touch.identifier`, so a drag the touch path starts is keyed
731
+ // to a live pointer. WebKit takes that pointer's capture straight back,
732
+ // and the `lostpointercapture` that follows used to cancel the drag one
733
+ // event after it began — leaving the sheet inert for the rest of the pull
734
+ // while the handle still worked. Every browser that keeps the two id
735
+ // spaces apart hides this, which is why it only showed up on device.
736
+ it('survives a lostpointercapture for the finger driving it', () => {
737
+ const {hook} = setup({snapHeights: () => [200]});
738
+ const el = midDetentScroller(hook, 600); // parked at the content end
739
+ act(() => {
740
+ touch(el, 'touchstart', 500, 7);
741
+ touch(el, 'touchmove', 450, 7); // promotes at the armed bottom edge
742
+ });
743
+ expect(hook.result.current.isDragging).toBe(true);
744
+ act(() => {
745
+ hook.result.current.bodyProps.onLostPointerCapture(
746
+ pointerEvent(450, 0, el, 7), // same id as the touch: iOS does this
747
+ );
748
+ });
749
+ expect(hook.result.current.isDragging).toBe(true);
750
+ act(() => {
751
+ touch(el, 'touchmove', 400, 7);
752
+ });
753
+ // 100px of pull past the touchstart, from the 200px detent.
754
+ expect(hook.result.current.dragOffset).toBe(100);
755
+ });
756
+
757
+ // `pointercancel` for that same finger arrives the moment WebKit claims
758
+ // the gesture. Ending the drag on it settles the sheet mid-pull.
759
+ it('does not end the touch drag on a pointercancel for that finger', () => {
760
+ const {hook} = setup({snapHeights: () => [200]});
761
+ const el = midDetentScroller(hook, 600);
762
+ act(() => {
763
+ touch(el, 'touchstart', 500, 7);
764
+ touch(el, 'touchmove', 450, 7);
765
+ });
766
+ act(() => {
767
+ hook.result.current.bodyProps.onPointerCancel(
768
+ pointerEvent(450, 0, el, 7),
769
+ );
770
+ });
771
+ expect(hook.result.current.isDragging).toBe(true);
772
+ });
773
+
774
+ it('hands off when the content runs out under a moving finger', () => {
775
+ const {hook} = setup({snapHeights: () => [200]});
776
+ const el = midDetentScroller(hook, 300); // finger lands mid-content
777
+ act(() => {
778
+ touch(el, 'touchstart', 500);
779
+ el.scrollTop = 450; // the swipe scrolls the content...
780
+ touch(el, 'touchmove', 350);
781
+ el.scrollTop = 600; // ...until it runs out at the bottom
782
+ touch(el, 'touchmove', 200); // the content ended here
783
+ touch(el, 'touchmove', 150); // 50px BEYOND the end
784
+ });
785
+ expect(hook.result.current.isDragging).toBe(true);
786
+ // Anchored where the content ran out, so only the 50px past it moves the
787
+ // sheet. Anchoring at touchstart would have thrown it 350px instead.
788
+ expect(hook.result.current.dragOffset).toBe(150);
789
+ });
790
+
791
+ it('does not preventDefault the mid-gesture handoff', () => {
792
+ // By this point in the gesture the events are non-cancelable anyway, and
793
+ // there is nothing to cancel: the scroller is clamped at its end.
794
+ // Claiming the gesture would only strand the content under the finger.
795
+ const {hook} = setup({snapHeights: () => [200]});
796
+ const el = midDetentScroller(hook, 300);
797
+ let atEnd: Event | undefined;
798
+ let past: Event | undefined;
799
+ let further: Event | undefined;
800
+ act(() => {
801
+ touch(el, 'touchstart', 500);
802
+ el.scrollTop = 600;
803
+ atEnd = touch(el, 'touchmove', 200);
804
+ past = touch(el, 'touchmove', 150);
805
+ further = touch(el, 'touchmove', 100);
806
+ });
807
+ expect(hook.result.current.isDragging).toBe(true);
808
+ expect(atEnd?.defaultPrevented).toBe(false);
809
+ expect(past?.defaultPrevented).toBe(false);
810
+ expect(further?.defaultPrevented).toBe(false);
811
+ });
812
+
813
+ it('gives the gesture back when the finger returns to the content end', () => {
814
+ const {hook} = setup({snapHeights: () => [200]});
815
+ const el = midDetentScroller(hook, 300);
816
+ act(() => {
817
+ touch(el, 'touchstart', 500);
818
+ el.scrollTop = 600;
819
+ touch(el, 'touchmove', 200); // the content ended here
820
+ touch(el, 'touchmove', 150);
821
+ });
822
+ expect(hook.result.current.dragOffset).toBe(150);
823
+
824
+ act(() => {
825
+ touch(el, 'touchmove', 210); // back below the anchor
826
+ });
827
+ // The native scroll was never cancelled, so it resumes from here. Two
828
+ // things moving at once is the failure mode; the sheet yields.
829
+ expect(hook.result.current.isDragging).toBe(false);
830
+ expect(hook.result.current.settledOffset).toBe(200);
831
+
832
+ act(() => {
833
+ el.scrollTop = 600; // scrolled back to the end
834
+ touch(el, 'touchmove', 190); // and pulls past it again
835
+ touch(el, 'touchmove', 140);
836
+ });
837
+ expect(hook.result.current.isDragging).toBe(true);
838
+ expect(hook.result.current.dragOffset).toBe(150);
839
+ });
840
+
841
+ it('does not hand off on reaching the end without travelling past it', () => {
842
+ // Landing on the last pixel is not a pull, and the momentum tail after
843
+ // the finger lifts fires no touchmove at all, so it can never promote.
844
+ const {hook} = setup({snapHeights: () => [200]});
845
+ const el = midDetentScroller(hook, 300);
846
+ act(() => {
847
+ touch(el, 'touchstart', 500);
848
+ el.scrollTop = 600;
849
+ touch(el, 'touchmove', 200);
850
+ touch(el, 'touchmove', 199); // jitter, not a pull
851
+ touch(el, 'touchend', 199);
852
+ el.scrollTop = 600; // momentum carries on with no finger down
853
+ });
854
+ expect(hook.result.current.isDragging).toBe(false);
855
+ expect(hook.result.current.settledOffset).toBe(200);
856
+ });
857
+
858
+ it('leaves the mid-gesture pull to the content at the tallest detent', () => {
859
+ // Same #5161 rule as the touchstart-armed edge: with no taller detent
860
+ // there is nothing to expand into, so the scroller keeps the gesture.
861
+ const {hook} = setup({snapHeights: () => [200]});
862
+ const el = makeScroller({
863
+ scrollTop: 300,
864
+ clientHeight: 200,
865
+ scrollHeight: 800,
866
+ });
867
+ act(() => hook.result.current.sheetRef(el));
868
+ act(() => hook.result.current.bodyProps.ref(el));
869
+ expect(hook.result.current.settledOffset).toBe(0);
870
+
871
+ let past: Event | undefined;
872
+ act(() => {
873
+ touch(el, 'touchstart', 500);
874
+ el.scrollTop = 600;
875
+ touch(el, 'touchmove', 200);
876
+ past = touch(el, 'touchmove', 100);
877
+ });
878
+ expect(hook.result.current.isDragging).toBe(false);
879
+ expect(hook.result.current.dragOffset).toBe(0);
880
+ expect(past?.defaultPrevented).toBe(false);
881
+ });
882
+
883
+ it('does not hand off while the content can still scroll', () => {
884
+ const {hook} = setup({snapHeights: () => [200]});
885
+ const el = midDetentScroller(hook, 0);
886
+ act(() => {
887
+ touch(el, 'touchstart', 500);
888
+ el.scrollTop = 200;
889
+ touch(el, 'touchmove', 300);
890
+ el.scrollTop = 400;
891
+ touch(el, 'touchmove', 100); // 400px of pull, all of it scrolling
892
+ });
893
+ expect(hook.result.current.isDragging).toBe(false);
894
+ expect(hook.result.current.settledOffset).toBe(200);
895
+ });
896
+ });
897
+
898
+ describe('viewport resize', () => {
899
+ // The panel pins the sheet's height in px at a resizing detent; everywhere
900
+ // else the height comes from CSS (`92dvh`), which follows the viewport.
901
+ // Model both, so a measurement taken while pinned reads the pin back —
902
+ // that is the trap the hook has to work around.
903
+ const SNAP_FRACTIONS = [0.14, 0.5, 0.92];
904
+ const RESERVED_BELOW_FLOOR = 48;
905
+
906
+ function viewportHarness(initialViewport: number) {
907
+ let viewport = initialViewport;
908
+ const el = document.createElement('div');
909
+ el.setPointerCapture = vi.fn();
910
+ el.releasePointerCapture = vi.fn();
911
+ el.getBoundingClientRect = () =>
912
+ ({
913
+ height: el.style.height
914
+ ? Number.parseFloat(el.style.height)
915
+ : 0.92 * viewport + RESERVED_BELOW_FLOOR,
916
+ }) as DOMRect;
917
+
918
+ const onSnap = vi.fn();
919
+ const {hook} = setup({
920
+ offscreenBlockEndInset: RESERVED_BELOW_FLOOR,
921
+ snapHeights: () => SNAP_FRACTIONS.map(f => f * viewport),
922
+ onSnap,
923
+ });
924
+ act(() => hook.result.current.sheetRef(el));
925
+
926
+ // What BottomSheetPanel renders at rest.
927
+ const paint = () => {
928
+ const {sheetHeight, settledLayoutOffset} = hook.result.current;
929
+ el.style.height =
930
+ settledLayoutOffset > 0
931
+ ? `${sheetHeight - settledLayoutOffset}px`
932
+ : '';
933
+ };
934
+ const dragTo = (offset: number) => {
935
+ // Anchor anywhere; what matters is the delta from where it rests, so
936
+ // this drags in whichever direction the target detent lies.
937
+ const anchor = 1000;
938
+ const delta = offset - hook.result.current.settledOffset;
939
+ act(() =>
940
+ hook.result.current.handleProps.onPointerDown(
941
+ pointerEvent(anchor, 0, el),
942
+ ),
943
+ );
944
+ // Slow: well under the flick threshold, so the release settles.
945
+ act(() =>
946
+ hook.result.current.handleProps.onPointerMove(
947
+ pointerEvent(anchor + delta, 2000, el),
948
+ ),
949
+ );
950
+ act(() =>
951
+ hook.result.current.handleProps.onPointerUp(
952
+ pointerEvent(anchor + delta, 2400, el),
953
+ ),
954
+ );
955
+ // The panel resolves the settle when the transform transition ends.
956
+ act(() => hook.result.current.completeScrollAreaSettle());
957
+ paint();
958
+ };
959
+ const resizeTo = (next: number) => {
960
+ viewport = next;
961
+ act(() => {
962
+ window.dispatchEvent(new Event('resize'));
963
+ });
964
+ paint();
965
+ };
966
+ // What the user sees: the border box, less the part reserved below the
967
+ // viewport floor and the part translated past it.
968
+ const visibleHeight = () =>
969
+ hook.result.current.sheetHeight -
970
+ RESERVED_BELOW_FLOOR -
971
+ hook.result.current.settledOffset;
972
+
973
+ return {hook, el, onSnap, dragTo, resizeTo, visibleHeight};
974
+ }
975
+
976
+ it('re-resolves the settled detent against the new viewport', () => {
977
+ // 900px viewport: detents at 0 / 378 / 702, so the half-height stop
978
+ // shows 450px of sheet.
979
+ const {hook, dragTo, resizeTo, visibleHeight, onSnap} =
980
+ viewportHarness(900);
981
+ dragTo(378);
982
+ expect(hook.result.current.settledOffset).toBe(378);
983
+ expect(visibleHeight()).toBe(450);
984
+
985
+ resizeTo(600);
986
+
987
+ // Still the half-height stop, now half of 600 — not the 450px (75% of
988
+ // the window) it was frozen at before.
989
+ expect(visibleHeight()).toBe(300);
990
+ expect(hook.result.current.settledOffset).toBe(252);
991
+ expect(onSnap).toHaveBeenLastCalledWith(300);
992
+ });
993
+
994
+ it('keeps the peek detent on screen when the viewport shrinks', () => {
995
+ const {hook, dragTo, resizeTo, visibleHeight} = viewportHarness(900);
996
+ dragTo(702);
997
+ expect(hook.result.current.settledOffset).toBe(702);
998
+ // The peek keeps its full height and slides, so its offset is the whole
999
+ // of its travel — and 702px of travel does not fit in a 600px window.
1000
+ expect(hook.result.current.settledLayoutOffset).toBe(0);
1001
+
1002
+ resizeTo(600);
1003
+
1004
+ expect(hook.result.current.settledOffset).toBe(468);
1005
+ expect(hook.result.current.settledOffset).toBeLessThan(
1006
+ hook.result.current.sheetHeight,
1007
+ );
1008
+ expect(visibleHeight()).toBe(84);
1009
+ });
1010
+
1011
+ it('refreshes the fully open height the next drag measures against', () => {
1012
+ const {hook, dragTo, resizeTo} = viewportHarness(900);
1013
+ dragTo(378);
1014
+ expect(hook.result.current.sheetHeight).toBe(876);
1015
+
1016
+ resizeTo(600);
1017
+
1018
+ // Measured through the pinned height the panel had written for the old
1019
+ // viewport, so this is the natural budget and not the stale pin.
1020
+ expect(hook.result.current.sheetHeight).toBe(600);
1021
+
1022
+ // Dragging back up lands on fully open rather than overshooting past it
1023
+ // toward where fully open used to be.
1024
+ dragTo(0);
1025
+ expect(hook.result.current.settledOffset).toBe(0);
1026
+ expect(hook.result.current.settledLayoutOffset).toBe(0);
1027
+ });
1028
+
1029
+ it('leaves a drag in progress alone', () => {
1030
+ const {hook, el} = viewportHarness(900);
1031
+ act(() =>
1032
+ hook.result.current.handleProps.onPointerDown(pointerEvent(0, 0, el)),
1033
+ );
1034
+ act(() =>
1035
+ hook.result.current.handleProps.onPointerMove(
1036
+ pointerEvent(200, 2000, el),
1037
+ ),
1038
+ );
1039
+
1040
+ act(() => {
1041
+ window.dispatchEvent(new Event('resize'));
1042
+ });
1043
+
1044
+ expect(hook.result.current.isDragging).toBe(true);
1045
+ expect(hook.result.current.sheetHeight).toBe(876);
1046
+ expect(hook.result.current.settledOffset).toBe(0);
1047
+ });
1048
+
1049
+ it('stops re-resolving once the sheet closes', () => {
1050
+ const {hook, dragTo, resizeTo} = viewportHarness(900);
1051
+ dragTo(378);
1052
+ act(() => hook.rerender({isOpen: false, onDismiss: vi.fn()}));
1053
+
1054
+ resizeTo(600);
1055
+
1056
+ expect(hook.result.current.sheetHeight).toBe(876);
1057
+ });
1058
+ });
1059
+ });