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