react-panel-layout 0.6.0 → 0.7.0

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 (258) hide show
  1. package/dist/{FloatingPanelFrame-SgYLc6Ud.js → FloatingPanelFrame-3eU9AwPo.js} +2 -2
  2. package/dist/{FloatingPanelFrame-SgYLc6Ud.js.map → FloatingPanelFrame-3eU9AwPo.js.map} +1 -1
  3. package/dist/FloatingWindow-Bw2djgpz.js +1542 -0
  4. package/dist/FloatingWindow-Bw2djgpz.js.map +1 -0
  5. package/dist/FloatingWindow-Cvyokf0m.cjs +2 -0
  6. package/dist/FloatingWindow-Cvyokf0m.cjs.map +1 -0
  7. package/dist/GridLayout-B4aCqSyd.js +947 -0
  8. package/dist/{GridLayout-BltqeCPK.js.map → GridLayout-B4aCqSyd.js.map} +1 -1
  9. package/dist/GridLayout-DNOClFzz.cjs +2 -0
  10. package/dist/{GridLayout-B4VRsC0r.cjs.map → GridLayout-DNOClFzz.cjs.map} +1 -1
  11. package/dist/{HorizontalDivider-WF1k_qND.js → HorizontalDivider-DdxzfV0l.js} +3 -3
  12. package/dist/{HorizontalDivider-WF1k_qND.js.map → HorizontalDivider-DdxzfV0l.js.map} +1 -1
  13. package/dist/{HorizontalDivider-B5Z-KZLk.cjs → HorizontalDivider-_pgV4Mcv.cjs} +2 -2
  14. package/dist/{HorizontalDivider-B5Z-KZLk.cjs.map → HorizontalDivider-_pgV4Mcv.cjs.map} +1 -1
  15. package/dist/PanelSystem-B8Igvnb2.cjs +3 -0
  16. package/dist/PanelSystem-B8Igvnb2.cjs.map +1 -0
  17. package/dist/{PanelSystem-Dr1TBhxM.js → PanelSystem-DDUSFjXD.js} +209 -248
  18. package/dist/PanelSystem-DDUSFjXD.js.map +1 -0
  19. package/dist/ResizeHandle-CBcAS918.cjs +2 -0
  20. package/dist/{ResizeHandle-CScipO5l.cjs.map → ResizeHandle-CBcAS918.cjs.map} +1 -1
  21. package/dist/{ResizeHandle-CdA_JYfN.js → ResizeHandle-CXjc1meV.js} +28 -29
  22. package/dist/{ResizeHandle-CdA_JYfN.js.map → ResizeHandle-CXjc1meV.js.map} +1 -1
  23. package/dist/SwipePivotTabBar-DWrCuwEI.js +411 -0
  24. package/dist/SwipePivotTabBar-DWrCuwEI.js.map +1 -0
  25. package/dist/SwipePivotTabBar-fjjXkpj7.cjs +2 -0
  26. package/dist/SwipePivotTabBar-fjjXkpj7.cjs.map +1 -0
  27. package/dist/components/gesture/SwipeSafeZone.d.ts +40 -0
  28. package/dist/components/window/Drawer.d.ts +4 -1
  29. package/dist/components/window/DrawerLayers.d.ts +1 -1
  30. package/dist/components/window/DrawerRevealContext.d.ts +61 -0
  31. package/dist/components/window/drawerRevealAnimationUtils.d.ts +212 -0
  32. package/dist/components/window/drawerStyles.d.ts +74 -0
  33. package/dist/components/window/drawerSwipeConfig.d.ts +29 -0
  34. package/dist/components/window/useDrawerSwipeTransform.d.ts +29 -0
  35. package/dist/components/window/useDrawerTransform.d.ts +68 -0
  36. package/dist/components/window/useRevealDrawerTransform.d.ts +56 -0
  37. package/dist/config.cjs +1 -1
  38. package/dist/config.cjs.map +1 -1
  39. package/dist/config.js +9 -8
  40. package/dist/config.js.map +1 -1
  41. package/dist/constants/styles.d.ts +17 -0
  42. package/dist/dialog/index.d.ts +69 -0
  43. package/dist/floating.js +1 -1
  44. package/dist/grid.cjs +1 -1
  45. package/dist/grid.js +2 -2
  46. package/dist/hooks/gesture/testing/createGestureSimulator.d.ts +7 -0
  47. package/dist/hooks/gesture/types.d.ts +48 -5
  48. package/dist/hooks/gesture/utils.d.ts +19 -0
  49. package/dist/hooks/useAnimationFrame.d.ts +2 -0
  50. package/dist/hooks/useOperationContinuity.d.ts +64 -0
  51. package/dist/hooks/useResizeObserver.d.ts +33 -1
  52. package/dist/hooks/useSharedElementTransition.d.ts +112 -0
  53. package/dist/hooks/useSwipeContentTransform.d.ts +9 -2
  54. package/dist/index.cjs +1 -1
  55. package/dist/index.js +7 -7
  56. package/dist/modules/dialog/AlertDialog.d.ts +9 -0
  57. package/dist/modules/dialog/DialogContainer.d.ts +37 -0
  58. package/dist/modules/dialog/Modal.d.ts +26 -0
  59. package/dist/modules/dialog/SwipeDialogContainer.d.ts +16 -0
  60. package/dist/modules/dialog/dialogAnimationUtils.d.ts +113 -0
  61. package/dist/modules/dialog/types.d.ts +183 -0
  62. package/dist/modules/dialog/useDialog.d.ts +39 -0
  63. package/dist/modules/dialog/useDialogContainer.d.ts +47 -0
  64. package/dist/modules/dialog/useDialogSwipeInput.d.ts +70 -0
  65. package/dist/modules/dialog/useDialogTransform.d.ts +82 -0
  66. package/dist/modules/drawer/drawerStateMachine.d.ts +168 -0
  67. package/dist/modules/drawer/revealDrawerConstants.d.ts +33 -0
  68. package/dist/modules/drawer/revealDrawerStateMachine.d.ts +146 -0
  69. package/dist/modules/drawer/strategies/index.d.ts +8 -0
  70. package/dist/modules/drawer/strategies/overlayStrategy.d.ts +12 -0
  71. package/dist/modules/drawer/strategies/revealStrategy.d.ts +12 -0
  72. package/dist/modules/drawer/strategies/types.d.ts +116 -0
  73. package/dist/modules/drawer/types.d.ts +74 -0
  74. package/dist/modules/drawer/useDrawerSwipeInput.d.ts +24 -0
  75. package/dist/modules/pivot/SwipePivotTabBar.d.ts +3 -0
  76. package/dist/modules/stack/SwipeStackContent.d.ts +6 -3
  77. package/dist/modules/stack/SwipeStackOutlet.d.ts +4 -4
  78. package/dist/modules/stack/computeSwipeStackTransform.d.ts +1 -1
  79. package/dist/panels.cjs +1 -1
  80. package/dist/panels.js +1 -1
  81. package/dist/pivot.cjs +1 -1
  82. package/dist/pivot.js +1 -1
  83. package/dist/resizer.cjs +1 -1
  84. package/dist/resizer.js +2 -2
  85. package/dist/stack.cjs +1 -1
  86. package/dist/stack.cjs.map +1 -1
  87. package/dist/stack.js +480 -780
  88. package/dist/stack.js.map +1 -1
  89. package/dist/sticky-header/calculateStickyMetrics.d.ts +28 -0
  90. package/dist/sticky-header.cjs +1 -1
  91. package/dist/sticky-header.cjs.map +1 -1
  92. package/dist/sticky-header.js +59 -51
  93. package/dist/sticky-header.js.map +1 -1
  94. package/dist/{styles-DPPuJ0sf.js → styles-NkjuMOVS.js} +13 -13
  95. package/dist/{styles-DPPuJ0sf.js.map → styles-NkjuMOVS.js.map} +1 -1
  96. package/dist/styles-qf6ptVLD.cjs.map +1 -1
  97. package/dist/types.d.ts +30 -0
  98. package/dist/useAnimationFrame-BZ6D2lMq.cjs +2 -0
  99. package/dist/useAnimationFrame-BZ6D2lMq.cjs.map +1 -0
  100. package/dist/useAnimationFrame-Bg4e-H8O.js +394 -0
  101. package/dist/useAnimationFrame-Bg4e-H8O.js.map +1 -0
  102. package/dist/useDocumentPointerEvents-DXxw3qWj.js +54 -0
  103. package/dist/useDocumentPointerEvents-DXxw3qWj.js.map +1 -0
  104. package/dist/useDocumentPointerEvents-DxDSOtip.cjs +2 -0
  105. package/dist/useDocumentPointerEvents-DxDSOtip.cjs.map +1 -0
  106. package/dist/window/index.d.ts +2 -0
  107. package/dist/window.cjs +1 -1
  108. package/dist/window.cjs.map +1 -1
  109. package/dist/window.js +114 -103
  110. package/dist/window.js.map +1 -1
  111. package/package.json +6 -1
  112. package/src/components/gesture/SwipeSafeZone.tsx +70 -0
  113. package/src/components/grid/GridLayout.tsx +110 -38
  114. package/src/components/window/Drawer.tsx +353 -162
  115. package/src/components/window/DrawerLayers.tsx +54 -11
  116. package/src/components/window/DrawerRevealContext.spec.ts +20 -0
  117. package/src/components/window/DrawerRevealContext.tsx +99 -0
  118. package/src/components/window/drawerRevealAnimationUtils.spec.ts +375 -0
  119. package/src/components/window/drawerRevealAnimationUtils.ts +415 -0
  120. package/src/components/window/drawerStyles.spec.ts +302 -0
  121. package/src/components/window/drawerStyles.ts +252 -0
  122. package/src/components/window/drawerSwipeConfig.spec.ts +131 -0
  123. package/src/components/window/drawerSwipeConfig.ts +112 -0
  124. package/src/components/window/useDrawerSwipeTransform.ts +67 -0
  125. package/src/components/window/useDrawerTransform.ts +505 -0
  126. package/src/components/window/useRevealDrawerTransform.spec.ts +1936 -0
  127. package/src/components/window/useRevealDrawerTransform.ts +105 -0
  128. package/src/constants/styles.ts +19 -0
  129. package/src/demo/components/FullscreenDemoPage.tsx +47 -0
  130. package/src/demo/fullscreenRoutes.tsx +32 -0
  131. package/src/demo/index.tsx +5 -0
  132. package/src/demo/pages/Dialog/alerts/index.tsx +22 -0
  133. package/src/demo/pages/Dialog/card/index.tsx +22 -0
  134. package/src/demo/pages/Dialog/components/AlertDialogDemo.tsx +124 -0
  135. package/src/demo/pages/Dialog/components/CardExpandDemo.module.css +243 -0
  136. package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +219 -0
  137. package/src/demo/pages/Dialog/components/CustomAlertDialogDemo.tsx +219 -0
  138. package/src/demo/pages/Dialog/components/DialogDemos.module.css +77 -0
  139. package/src/demo/pages/Dialog/components/ModalBasics.tsx +45 -0
  140. package/src/demo/pages/Dialog/components/SwipeDialogDemo.module.css +77 -0
  141. package/src/demo/pages/Dialog/components/SwipeDialogDemo.tsx +181 -0
  142. package/src/demo/pages/Dialog/custom-alert/index.tsx +22 -0
  143. package/src/demo/pages/Dialog/modal/index.tsx +17 -0
  144. package/src/demo/pages/Dialog/swipe/index.tsx +22 -0
  145. package/src/demo/pages/Drawer/components/DrawerBasics.module.css +6 -1
  146. package/src/demo/pages/Drawer/components/DrawerBasics.tsx +14 -4
  147. package/src/demo/pages/Drawer/components/DrawerReveal.module.css +157 -0
  148. package/src/demo/pages/Drawer/components/DrawerReveal.tsx +128 -0
  149. package/src/demo/pages/Drawer/components/DrawerSwipe.module.css +316 -0
  150. package/src/demo/pages/Drawer/components/DrawerSwipe.tsx +178 -0
  151. package/src/demo/pages/Drawer/reveal/index.tsx +17 -0
  152. package/src/demo/pages/Drawer/reveal-fullscreen/index.tsx +135 -0
  153. package/src/demo/pages/Drawer/reveal-fullscreen/styles.module.css +233 -0
  154. package/src/demo/pages/Drawer/swipe/index.tsx +17 -0
  155. package/src/demo/pages/Pivot/components/SwipeTabsPivot.tsx +54 -23
  156. package/src/demo/pages/Pivot/swipe-debug/index.tsx +1 -1
  157. package/src/demo/pages/Stack/components/StackBasics.spec.tsx +156 -0
  158. package/src/demo/pages/Stack/components/StackBasics.tsx +179 -95
  159. package/src/demo/pages/Stack/components/StackTablet.spec.tsx +110 -0
  160. package/src/demo/pages/Stack/components/StackTablet.tsx +42 -21
  161. package/src/demo/routes.tsx +24 -1
  162. package/src/dialog/index.ts +85 -0
  163. package/src/hooks/gesture/testing/createGestureSimulator.spec.ts +68 -64
  164. package/src/hooks/gesture/testing/createGestureSimulator.ts +113 -37
  165. package/src/hooks/gesture/types.ts +83 -6
  166. package/src/hooks/gesture/useEdgeSwipeInput.spec.ts +22 -14
  167. package/src/hooks/gesture/useNativeGestureGuard.spec.ts +99 -31
  168. package/src/hooks/gesture/useNativeGestureGuard.ts +3 -1
  169. package/src/hooks/gesture/utils.ts +102 -0
  170. package/src/hooks/useAnimatedVisibility.spec.ts +44 -24
  171. package/src/hooks/useAnimatedVisibility.ts +28 -2
  172. package/src/hooks/useAnimationFrame.ts +8 -0
  173. package/src/hooks/useOperationContinuity.spec.ts +394 -0
  174. package/src/hooks/useOperationContinuity.ts +135 -0
  175. package/src/hooks/useResizeObserver.spec.tsx +277 -0
  176. package/src/hooks/useResizeObserver.tsx +108 -39
  177. package/src/hooks/useScrollContainer.ts +4 -10
  178. package/src/hooks/useSharedElementTransition.ts +354 -0
  179. package/src/hooks/useSwipeContentTransform.spec.ts +18 -18
  180. package/src/hooks/useSwipeContentTransform.ts +166 -28
  181. package/src/modules/dialog/AlertDialog.spec.tsx +387 -0
  182. package/src/modules/dialog/AlertDialog.tsx +221 -0
  183. package/src/modules/dialog/DialogContainer.spec.tsx +228 -0
  184. package/src/modules/dialog/DialogContainer.tsx +188 -0
  185. package/src/modules/dialog/Modal.spec.tsx +220 -0
  186. package/src/modules/dialog/Modal.tsx +182 -0
  187. package/src/modules/dialog/SwipeDialogContainer.tsx +208 -0
  188. package/src/modules/dialog/dialogAnimationUtils.spec.ts +252 -0
  189. package/src/modules/dialog/dialogAnimationUtils.ts +297 -0
  190. package/src/modules/dialog/types.ts +186 -0
  191. package/src/modules/dialog/useDialog.spec.tsx +447 -0
  192. package/src/modules/dialog/useDialog.ts +214 -0
  193. package/src/modules/dialog/useDialogContainer.spec.ts +339 -0
  194. package/src/modules/dialog/useDialogContainer.ts +150 -0
  195. package/src/modules/dialog/useDialogSwipeInput.spec.ts +178 -0
  196. package/src/modules/dialog/useDialogSwipeInput.ts +350 -0
  197. package/src/modules/dialog/useDialogTransform.spec.ts +403 -0
  198. package/src/modules/dialog/useDialogTransform.ts +407 -0
  199. package/src/modules/drawer/drawerStateMachine.ts +500 -0
  200. package/src/modules/drawer/revealDrawerConstants.ts +38 -0
  201. package/src/modules/drawer/revealDrawerStateMachine.spec.ts +558 -0
  202. package/src/modules/drawer/revealDrawerStateMachine.ts +197 -0
  203. package/src/modules/drawer/strategies/index.ts +9 -0
  204. package/src/modules/drawer/strategies/overlayStrategy.ts +133 -0
  205. package/src/modules/drawer/strategies/revealStrategy.ts +111 -0
  206. package/src/modules/drawer/strategies/types.ts +160 -0
  207. package/src/modules/drawer/types.ts +102 -0
  208. package/src/modules/drawer/useDrawerSwipeInput.spec.ts +566 -0
  209. package/src/modules/drawer/useDrawerSwipeInput.ts +402 -0
  210. package/src/modules/panels/rendering/ContentRegistry.spec.tsx +21 -14
  211. package/src/modules/pivot/SwipePivotContent.position.spec.tsx +12 -8
  212. package/src/modules/pivot/SwipePivotContent.spec.tsx +66 -25
  213. package/src/modules/pivot/SwipePivotContent.tsx +2 -2
  214. package/src/modules/pivot/SwipePivotTabBar.spec.tsx +85 -68
  215. package/src/modules/pivot/SwipePivotTabBar.tsx +75 -15
  216. package/src/modules/pivot/scaleInputState.spec.ts +11 -2
  217. package/src/modules/pivot/usePivot.spec.ts +17 -3
  218. package/src/modules/pivot/usePivotSwipeInput.spec.ts +182 -123
  219. package/src/modules/stack/SwipeStackContent.spec.tsx +387 -100
  220. package/src/modules/stack/SwipeStackContent.tsx +43 -33
  221. package/src/modules/stack/SwipeStackOutlet.spec.tsx +14 -16
  222. package/src/modules/stack/SwipeStackOutlet.tsx +6 -6
  223. package/src/modules/stack/computeSwipeStackTransform.spec.ts +5 -5
  224. package/src/modules/stack/computeSwipeStackTransform.ts +3 -3
  225. package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1133 -0
  226. package/src/modules/stack/useStackAnimationState.spec.ts +3 -1
  227. package/src/modules/stack/useStackAnimationState.ts +18 -13
  228. package/src/modules/stack/useStackNavigation.spec.ts +198 -3
  229. package/src/modules/stack/useStackNavigation.tsx +113 -56
  230. package/src/modules/stack/useStackSwipeInput.spec.ts +65 -32
  231. package/src/modules/stack/useStackSwipeInput.ts +1 -1
  232. package/src/sticky-header/StickyArea.tsx +29 -57
  233. package/src/sticky-header/calculateStickyMetrics.spec.ts +105 -0
  234. package/src/sticky-header/calculateStickyMetrics.ts +50 -0
  235. package/src/types.ts +33 -0
  236. package/src/window/index.ts +2 -0
  237. package/dist/FloatingWindow-BpdOpg_L.js +0 -400
  238. package/dist/FloatingWindow-BpdOpg_L.js.map +0 -1
  239. package/dist/FloatingWindow-TCDNY5gE.cjs +0 -2
  240. package/dist/FloatingWindow-TCDNY5gE.cjs.map +0 -1
  241. package/dist/GridLayout-B4VRsC0r.cjs +0 -2
  242. package/dist/GridLayout-BltqeCPK.js +0 -927
  243. package/dist/PanelSystem-Bs8bQwQF.cjs +0 -3
  244. package/dist/PanelSystem-Bs8bQwQF.cjs.map +0 -1
  245. package/dist/PanelSystem-Dr1TBhxM.js.map +0 -1
  246. package/dist/ResizeHandle-CScipO5l.cjs +0 -2
  247. package/dist/SwipePivotTabBar-BGO9X94m.js +0 -407
  248. package/dist/SwipePivotTabBar-BGO9X94m.js.map +0 -1
  249. package/dist/SwipePivotTabBar-BrQismcZ.cjs +0 -2
  250. package/dist/SwipePivotTabBar-BrQismcZ.cjs.map +0 -1
  251. package/dist/useDocumentPointerEvents-CKdhGXd0.js +0 -46
  252. package/dist/useDocumentPointerEvents-CKdhGXd0.js.map +0 -1
  253. package/dist/useDocumentPointerEvents-ChqrKXDk.cjs +0 -2
  254. package/dist/useDocumentPointerEvents-ChqrKXDk.cjs.map +0 -1
  255. package/dist/useEffectEvent-Dp7HLCf0.js +0 -13
  256. package/dist/useEffectEvent-Dp7HLCf0.js.map +0 -1
  257. package/dist/useEffectEvent-huSsGUnl.cjs +0 -2
  258. package/dist/useEffectEvent-huSsGUnl.cjs.map +0 -1
@@ -0,0 +1,394 @@
1
+ /**
2
+ * @file Tests for useOperationContinuity hook.
3
+ */
4
+ import * as React from "react";
5
+ import { renderHook } from "@testing-library/react";
6
+ import { useOperationContinuity } from "./useOperationContinuity.js";
7
+
8
+ type TestRole = "active" | "behind" | "hidden";
9
+
10
+ const StrictModeWrapper = ({ children }: { children: React.ReactNode }): React.ReactNode => {
11
+ return React.createElement(React.StrictMode, null, children);
12
+ };
13
+
14
+ describe("useOperationContinuity", () => {
15
+ describe("value continuity", () => {
16
+ it("returns current value when not retaining", () => {
17
+ const { result } = renderHook(() => useOperationContinuity("active", false));
18
+ expect(result.current.value).toBe("active");
19
+ });
20
+
21
+ it("returns current value when retaining but value unchanged", () => {
22
+ const { result } = renderHook(() => useOperationContinuity("active", true));
23
+ expect(result.current.value).toBe("active");
24
+ });
25
+
26
+ it("retains previous value when shouldRetainPrevious is true", () => {
27
+ const { result, rerender } = renderHook(
28
+ ({ value, shouldRetainPrevious }) => useOperationContinuity(value, shouldRetainPrevious),
29
+ { initialProps: { value: "behind", shouldRetainPrevious: true } },
30
+ );
31
+
32
+ expect(result.current.value).toBe("behind");
33
+
34
+ // Value changes but we're still retaining
35
+ rerender({ value: "active", shouldRetainPrevious: true });
36
+ expect(result.current.value).toBe("behind");
37
+ });
38
+
39
+ it("accepts new value when shouldRetainPrevious becomes false", () => {
40
+ const { result, rerender } = renderHook(
41
+ ({ value, shouldRetainPrevious }) => useOperationContinuity(value, shouldRetainPrevious),
42
+ { initialProps: { value: "behind", shouldRetainPrevious: true } },
43
+ );
44
+
45
+ // Value changes while retaining
46
+ rerender({ value: "active", shouldRetainPrevious: true });
47
+ expect(result.current.value).toBe("behind");
48
+
49
+ // Stop retaining - should accept new value
50
+ rerender({ value: "active", shouldRetainPrevious: false });
51
+ expect(result.current.value).toBe("active");
52
+ });
53
+
54
+ it("updates stored value when not retaining", () => {
55
+ const { result, rerender } = renderHook(
56
+ ({ value, shouldRetainPrevious }) => useOperationContinuity(value, shouldRetainPrevious),
57
+ { initialProps: { value: "behind", shouldRetainPrevious: false } },
58
+ );
59
+
60
+ rerender({ value: "active", shouldRetainPrevious: false });
61
+ expect(result.current.value).toBe("active");
62
+
63
+ // Start retaining - should keep "active"
64
+ rerender({ value: "hidden", shouldRetainPrevious: true });
65
+ expect(result.current.value).toBe("active");
66
+ });
67
+ });
68
+
69
+ describe("changedDuringOperation tracking", () => {
70
+ it("returns false when value never changed", () => {
71
+ const { result, rerender } = renderHook(
72
+ ({ value, retain }) => useOperationContinuity(value, retain),
73
+ { initialProps: { value: "active", retain: true } },
74
+ );
75
+
76
+ expect(result.current.changedDuringOperation).toBe(false);
77
+
78
+ // End retention without value change
79
+ rerender({ value: "active", retain: false });
80
+ expect(result.current.changedDuringOperation).toBe(false);
81
+ });
82
+
83
+ it("returns true when value changed during retention", () => {
84
+ const { result, rerender } = renderHook(
85
+ ({ value, retain }) => useOperationContinuity(value, retain),
86
+ { initialProps: { value: "behind", retain: true } },
87
+ );
88
+
89
+ expect(result.current.changedDuringOperation).toBe(false);
90
+
91
+ // Value changes while retaining
92
+ rerender({ value: "active", retain: true });
93
+ expect(result.current.changedDuringOperation).toBe(true);
94
+
95
+ // End retention - should still be true (for this render)
96
+ rerender({ value: "active", retain: false });
97
+ expect(result.current.changedDuringOperation).toBe(true);
98
+ });
99
+
100
+ it("resets changedDuringOperation after operation ends", () => {
101
+ const { result, rerender } = renderHook(
102
+ ({ value, retain }) => useOperationContinuity(value, retain),
103
+ { initialProps: { value: "behind", retain: true } },
104
+ );
105
+
106
+ // Value changes while retaining
107
+ rerender({ value: "active", retain: true });
108
+ expect(result.current.changedDuringOperation).toBe(true);
109
+
110
+ // End retention
111
+ rerender({ value: "active", retain: false });
112
+ expect(result.current.changedDuringOperation).toBe(true);
113
+
114
+ // Next render - should be reset
115
+ rerender({ value: "active", retain: false });
116
+ expect(result.current.changedDuringOperation).toBe(false);
117
+ });
118
+
119
+ it("tracks changes across multiple operations", () => {
120
+ const { result, rerender } = renderHook(
121
+ ({ value, retain }) => useOperationContinuity(value, retain),
122
+ { initialProps: { value: "behind", retain: true } },
123
+ );
124
+
125
+ // First operation: value changes
126
+ rerender({ value: "active", retain: true });
127
+ rerender({ value: "active", retain: false });
128
+ expect(result.current.changedDuringOperation).toBe(true);
129
+
130
+ // Reset
131
+ rerender({ value: "active", retain: false });
132
+ expect(result.current.changedDuringOperation).toBe(false);
133
+
134
+ // Second operation: no value change
135
+ rerender({ value: "active", retain: true });
136
+ rerender({ value: "active", retain: false });
137
+ expect(result.current.changedDuringOperation).toBe(false);
138
+
139
+ // Third operation: value changes again
140
+ rerender({ value: "active", retain: true });
141
+ rerender({ value: "hidden", retain: true });
142
+ expect(result.current.changedDuringOperation).toBe(true);
143
+ rerender({ value: "hidden", retain: false });
144
+ expect(result.current.changedDuringOperation).toBe(true);
145
+ });
146
+ });
147
+
148
+ describe("simultaneous value and retention change", () => {
149
+ /**
150
+ * CRITICAL: This tests the over-swipe bug scenario.
151
+ *
152
+ * In the real app, when user releases after over-swipe:
153
+ * - displacement becomes 0 (shouldRetainPrevious becomes false)
154
+ * - role changes from "active" to "hidden"
155
+ * Both happen in the same render!
156
+ *
157
+ * The hook should detect that the value changed even though
158
+ * the change happened at the exact moment retention ended.
159
+ */
160
+ it("detects value change when it happens simultaneously with retention ending", () => {
161
+ const { result, rerender } = renderHook(
162
+ ({ role, displacement }: { role: TestRole; displacement: number }) =>
163
+ useOperationContinuity(role, displacement > 0),
164
+ { initialProps: { role: "active" as TestRole, displacement: 500 } },
165
+ );
166
+
167
+ // During swipe: role="active", retaining
168
+ expect(result.current.value).toBe("active");
169
+ expect(result.current.changedDuringOperation).toBe(false);
170
+
171
+ // Simulate release: BOTH displacement becomes 0 AND role changes to "hidden"
172
+ // This is what happens in the real app during over-swipe
173
+ rerender({ role: "hidden" as const, displacement: 0 });
174
+
175
+ // value should now be "hidden" (retention ended)
176
+ expect(result.current.value).toBe("hidden");
177
+ // CRITICAL: changedDuringOperation should be TRUE because the value
178
+ // changed from "active" to "hidden" at the moment retention ended
179
+ expect(result.current.changedDuringOperation).toBe(true);
180
+ });
181
+
182
+ it("does not report change when value stays the same at retention end", () => {
183
+ const { result, rerender } = renderHook(
184
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
185
+ { initialProps: { role: "active" as const, displacement: 500 } },
186
+ );
187
+
188
+ expect(result.current.value).toBe("active");
189
+ expect(result.current.changedDuringOperation).toBe(false);
190
+
191
+ // Release but role stays "active" (e.g., partial swipe that didn't trigger navigation)
192
+ rerender({ role: "active" as const, displacement: 0 });
193
+
194
+ expect(result.current.value).toBe("active");
195
+ // No change occurred
196
+ expect(result.current.changedDuringOperation).toBe(false);
197
+ });
198
+
199
+ it("does NOT report change during button navigation (no operation)", () => {
200
+ // This is the button navigation case: value changes but there was never
201
+ // any retention (no swipe operation). We should NOT report changedDuringOperation
202
+ // because this is normal navigation, not an operation-related change.
203
+ const { result, rerender } = renderHook(
204
+ ({ role, displacement }: { role: TestRole; displacement: number }) =>
205
+ useOperationContinuity(role, displacement > 0),
206
+ { initialProps: { role: "active" as TestRole, displacement: 0 } },
207
+ );
208
+
209
+ expect(result.current.value).toBe("active");
210
+ expect(result.current.changedDuringOperation).toBe(false);
211
+
212
+ // Button navigation: role changes but there's no operation (displacement is always 0)
213
+ rerender({ role: "behind" as const, displacement: 0 });
214
+
215
+ expect(result.current.value).toBe("behind");
216
+ // CRITICAL: changedDuringOperation should be FALSE because there was no operation
217
+ // This allows the animation to happen normally for button navigation
218
+ expect(result.current.changedDuringOperation).toBe(false);
219
+ });
220
+ });
221
+
222
+ describe("role transition scenarios", () => {
223
+ it("maintains role continuity during swipe (behind -> active)", () => {
224
+ // Simulates: behind panel becomes active during swipe
225
+ // displacement > 0, so we should retain the previous role
226
+ const { result, rerender } = renderHook(
227
+ ({ role, displacement }: { role: TestRole; displacement: number }) =>
228
+ useOperationContinuity(role, displacement > 0),
229
+ { initialProps: { role: "behind" as TestRole, displacement: 100 } },
230
+ );
231
+
232
+ expect(result.current.value).toBe("behind");
233
+ expect(result.current.changedDuringOperation).toBe(false);
234
+
235
+ // Navigation changes role to "active" but displacement is still positive
236
+ rerender({ role: "active" as const, displacement: 100 });
237
+ expect(result.current.value).toBe("behind");
238
+ expect(result.current.changedDuringOperation).toBe(true);
239
+
240
+ // Swipe ends (displacement becomes 0)
241
+ rerender({ role: "active" as const, displacement: 0 });
242
+ expect(result.current.value).toBe("active");
243
+ expect(result.current.changedDuringOperation).toBe(true);
244
+ });
245
+
246
+ it("maintains role continuity during swipe (active -> hidden)", () => {
247
+ // Simulates: over-swipe where active panel becomes hidden
248
+ const { result, rerender } = renderHook(
249
+ ({ role, displacement }: { role: TestRole; displacement: number }) =>
250
+ useOperationContinuity(role, displacement > 0),
251
+ { initialProps: { role: "active" as TestRole, displacement: 400 } },
252
+ );
253
+
254
+ expect(result.current.value).toBe("active");
255
+
256
+ // Over-swipe triggers navigation change
257
+ rerender({ role: "hidden" as const, displacement: 500 });
258
+ expect(result.current.value).toBe("active");
259
+ expect(result.current.changedDuringOperation).toBe(true);
260
+
261
+ // Swipe ends
262
+ rerender({ role: "hidden" as const, displacement: 0 });
263
+ expect(result.current.value).toBe("hidden");
264
+ expect(result.current.changedDuringOperation).toBe(true);
265
+ });
266
+
267
+ it("provides changedDuringOperation for animation decision", () => {
268
+ // This test demonstrates the intended use case:
269
+ // Use changedDuringOperation to decide whether to animate on operation end
270
+ const { result, rerender } = renderHook(
271
+ ({ role, displacement }: { role: TestRole; displacement: number }) =>
272
+ useOperationContinuity(role, displacement > 0),
273
+ { initialProps: { role: "behind" as TestRole, displacement: 100 } },
274
+ );
275
+
276
+ // Simulate role change during swipe
277
+ rerender({ role: "active" as const, displacement: 100 });
278
+
279
+ // When swipe ends, changedDuringOperation tells us to skip animation
280
+ rerender({ role: "active" as const, displacement: 0 });
281
+ expect(result.current.changedDuringOperation).toBe(true);
282
+ // Consumer would use this to skip target change animation
283
+ });
284
+ });
285
+
286
+ describe("works with different value types", () => {
287
+ it("works with numbers", () => {
288
+ const { result, rerender } = renderHook(
289
+ ({ value, retain }) => useOperationContinuity(value, retain),
290
+ { initialProps: { value: 0, retain: true } },
291
+ );
292
+
293
+ rerender({ value: 1, retain: true });
294
+ expect(result.current.value).toBe(0);
295
+
296
+ rerender({ value: 1, retain: false });
297
+ expect(result.current.value).toBe(1);
298
+ });
299
+
300
+ it("works with objects (by reference)", () => {
301
+ const obj1 = { id: 1 };
302
+ const obj2 = { id: 2 };
303
+
304
+ const { result, rerender } = renderHook(
305
+ ({ value, retain }) => useOperationContinuity(value, retain),
306
+ { initialProps: { value: obj1, retain: true } },
307
+ );
308
+
309
+ rerender({ value: obj2, retain: true });
310
+ expect(result.current.value).toBe(obj1);
311
+
312
+ rerender({ value: obj2, retain: false });
313
+ expect(result.current.value).toBe(obj2);
314
+ });
315
+ });
316
+
317
+ describe("React StrictMode compatibility", () => {
318
+ /**
319
+ * CRITICAL: These tests verify the hook works correctly in StrictMode.
320
+ *
321
+ * In StrictMode, React calls the render function twice. Hooks that mutate
322
+ * refs during render will see the mutated value on the second call, which
323
+ * can cause bugs.
324
+ *
325
+ * This hook uses useLayoutEffect for ref mutations to avoid this issue.
326
+ */
327
+ it("operationJustEnded is correct in StrictMode", () => {
328
+ const { result, rerender } = renderHook(
329
+ ({ value, retain }) => useOperationContinuity(value, retain),
330
+ {
331
+ initialProps: { value: "active", retain: true },
332
+ wrapper: StrictModeWrapper,
333
+ },
334
+ );
335
+
336
+ // During retention
337
+ expect(result.current.operationJustEnded).toBe(false);
338
+
339
+ // End retention - operationJustEnded should be true
340
+ rerender({ value: "active", retain: false });
341
+ expect(result.current.operationJustEnded).toBe(true);
342
+
343
+ // Next render - should be false again
344
+ rerender({ value: "active", retain: false });
345
+ expect(result.current.operationJustEnded).toBe(false);
346
+ });
347
+
348
+ it("over-swipe scenario works in StrictMode", () => {
349
+ // This is the exact scenario that was broken before the fix:
350
+ // User swipes beyond 100%, releases, and we need operationJustEnded=true
351
+ // to prevent the visual jump.
352
+ const { result, rerender } = renderHook(
353
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
354
+ {
355
+ initialProps: { role: "active" as const, displacement: 500 },
356
+ wrapper: StrictModeWrapper,
357
+ },
358
+ );
359
+
360
+ // During over-swipe
361
+ expect(result.current.value).toBe("active");
362
+ expect(result.current.operationJustEnded).toBe(false);
363
+
364
+ // Release (displacement becomes 0)
365
+ rerender({ role: "active" as const, displacement: 0 });
366
+
367
+ // CRITICAL: operationJustEnded must be true even in StrictMode
368
+ // This is what was broken before the fix
369
+ expect(result.current.operationJustEnded).toBe(true);
370
+ expect(result.current.value).toBe("active");
371
+ });
372
+
373
+ it("changedDuringOperation is tracked correctly in StrictMode", () => {
374
+ const { result, rerender } = renderHook(
375
+ ({ value, retain }) => useOperationContinuity(value, retain),
376
+ {
377
+ initialProps: { value: "behind", retain: true },
378
+ wrapper: StrictModeWrapper,
379
+ },
380
+ );
381
+
382
+ expect(result.current.changedDuringOperation).toBe(false);
383
+
384
+ // Value changes during retention
385
+ rerender({ value: "active", retain: true });
386
+ expect(result.current.changedDuringOperation).toBe(true);
387
+
388
+ // End retention
389
+ rerender({ value: "active", retain: false });
390
+ expect(result.current.changedDuringOperation).toBe(true);
391
+ expect(result.current.operationJustEnded).toBe(true);
392
+ });
393
+ });
394
+ });
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @file Hook for maintaining value continuity during continuous operations.
3
+ *
4
+ * During operations like swipe gestures, external state (navigation depth, panel roles)
5
+ * may change before the gesture ends. This hook provides a pattern to:
6
+ * - Retain the previous value during the operation for visual continuity
7
+ * - Accept the new value when the operation ends
8
+ * - Track whether the value changed during the operation
9
+ *
10
+ * This is a core primitive for the "operation continuity" pattern used throughout
11
+ * the swipe gesture system.
12
+ */
13
+ import * as React from "react";
14
+
15
+ /**
16
+ * Result from useOperationContinuity hook.
17
+ */
18
+ export type UseOperationContinuityResult<T> = {
19
+ /** The effective value (retained during operation, current after) */
20
+ value: T;
21
+ /**
22
+ * True if the value changed during the operation.
23
+ *
24
+ * This is useful for determining how to handle the transition when the
25
+ * operation ends. For example, if the role changed during a swipe,
26
+ * the target position change at operation end should snap rather than animate.
27
+ *
28
+ * This flag is true on the render where shouldRetainPrevious becomes false
29
+ * (operation end), allowing consumers to handle the transition appropriately.
30
+ * It resets to false on subsequent renders.
31
+ */
32
+ changedDuringOperation: boolean;
33
+ /**
34
+ * True on the render where the operation just ended.
35
+ *
36
+ * This is true when shouldRetainPrevious transitions from true to false,
37
+ * regardless of whether the value changed. Use this to detect the moment
38
+ * when an operation completes and delay any immediate animations.
39
+ *
40
+ * In the over-swipe case, this helps prevent unwanted snap-back animation
41
+ * in the intermediate render before navigation changes.
42
+ */
43
+ operationJustEnded: boolean;
44
+ };
45
+
46
+ /**
47
+ * Hook for maintaining value continuity during continuous operations.
48
+ *
49
+ * When an operation is in progress, this hook retains the previous value
50
+ * to prevent sudden visual changes from state updates. Once the operation
51
+ * ends (shouldRetainPrevious becomes false), the new value is accepted.
52
+ *
53
+ * Additionally, this hook tracks whether the value changed during the operation,
54
+ * which is useful for determining animation behavior at operation end.
55
+ *
56
+ * IMPORTANT: This hook is designed to be idempotent during render to work
57
+ * correctly with React StrictMode, which calls the render function twice.
58
+ * All ref mutations happen in useLayoutEffect, not during render.
59
+ *
60
+ * @param value - The current value from external state
61
+ * @param shouldRetainPrevious - Whether to retain the previous value (true during operation)
62
+ * @returns Object with effective value and whether it changed during operation
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * // Maintain role continuity during swipe
67
+ * const { value: effectiveRole, changedDuringOperation } = useOperationContinuity(
68
+ * role,
69
+ * displacement > 0,
70
+ * );
71
+ *
72
+ * // Use changedDuringOperation to skip animation on operation end
73
+ * useSwipeContentTransform({
74
+ * // ...
75
+ * skipTargetChangeAnimation: changedDuringOperation,
76
+ * });
77
+ * ```
78
+ */
79
+ export function useOperationContinuity<T>(
80
+ value: T,
81
+ shouldRetainPrevious: boolean,
82
+ ): UseOperationContinuityResult<T> {
83
+ // Store previous shouldRetainPrevious to detect transitions
84
+ const prevShouldRetainRef = React.useRef(shouldRetainPrevious);
85
+ // Store retained value (the value at the start of retention)
86
+ const retainedValueRef = React.useRef(value);
87
+ // Track if value changed during retention
88
+ const changedDuringRetentionRef = React.useRef(false);
89
+
90
+ // Derive operationJustEnded from transition: true → false
91
+ // This is idempotent - safe for StrictMode double-render
92
+ const wasRetaining = prevShouldRetainRef.current;
93
+ const operationJustEnded = wasRetaining ? !shouldRetainPrevious : false;
94
+
95
+ // Check if value diverged from retained value
96
+ // This includes both current-render divergence and previously-tracked divergence
97
+ const valueDiverged = value !== retainedValueRef.current;
98
+ const currentlyDiverged = shouldRetainPrevious ? valueDiverged : false;
99
+
100
+ // Derive changedDuringOperation
101
+ // True if:
102
+ // 1. Value diverged during retention (tracked from previous renders via ref)
103
+ // 2. Value diverges right now during retention (immediate comparison)
104
+ // 3. Value diverged at the moment retention ends
105
+ const changedDuringRetention = changedDuringRetentionRef.current ? true : currentlyDiverged;
106
+ const changedAtExit = operationJustEnded ? valueDiverged : false;
107
+ const changedDuringOperation = changedDuringRetention ? true : changedAtExit;
108
+
109
+ // Determine effective value
110
+ // During retention: use retained value
111
+ // After retention ends: use current value
112
+ const effectiveValue = shouldRetainPrevious ? retainedValueRef.current : value;
113
+
114
+ // Update refs in useLayoutEffect to ensure idempotency during render.
115
+ // This runs once per commit, not per render in StrictMode.
116
+ React.useLayoutEffect(() => {
117
+ if (!shouldRetainPrevious) {
118
+ // Retention ended or never started - reset state
119
+ changedDuringRetentionRef.current = false;
120
+ retainedValueRef.current = value;
121
+ } else {
122
+ // During retention - track if value diverged
123
+ if (currentlyDiverged) {
124
+ changedDuringRetentionRef.current = true;
125
+ }
126
+ }
127
+ prevShouldRetainRef.current = shouldRetainPrevious;
128
+ });
129
+
130
+ return {
131
+ value: effectiveValue,
132
+ changedDuringOperation,
133
+ operationJustEnded,
134
+ };
135
+ }