react-panel-layout 0.6.0 → 0.6.1

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 (216) 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-CUXnEtrb.js +827 -0
  4. package/dist/FloatingWindow-CUXnEtrb.js.map +1 -0
  5. package/dist/FloatingWindow-DMwyK0eK.cjs +2 -0
  6. package/dist/FloatingWindow-DMwyK0eK.cjs.map +1 -0
  7. package/dist/GridLayout-DKTg_N61.cjs +2 -0
  8. package/dist/{GridLayout-B4VRsC0r.cjs.map → GridLayout-DKTg_N61.cjs.map} +1 -1
  9. package/dist/{GridLayout-BltqeCPK.js → GridLayout-UWNxXw77.js} +34 -35
  10. package/dist/{GridLayout-BltqeCPK.js.map → GridLayout-UWNxXw77.js.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-Dr1TBhxM.js → PanelSystem-BqUzNtf2.js} +5 -5
  16. package/dist/{PanelSystem-Dr1TBhxM.js.map → PanelSystem-BqUzNtf2.js.map} +1 -1
  17. package/dist/{PanelSystem-Bs8bQwQF.cjs → PanelSystem-D603LKKv.cjs} +2 -2
  18. package/dist/{PanelSystem-Bs8bQwQF.cjs.map → PanelSystem-D603LKKv.cjs.map} +1 -1
  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 +3 -1
  29. package/dist/components/window/DrawerLayers.d.ts +1 -1
  30. package/dist/components/window/drawerStyles.d.ts +69 -0
  31. package/dist/components/window/drawerSwipeConfig.d.ts +29 -0
  32. package/dist/components/window/useDrawerSwipeTransform.d.ts +23 -0
  33. package/dist/config.cjs +1 -1
  34. package/dist/config.js +3 -3
  35. package/dist/constants/styles.d.ts +17 -0
  36. package/dist/dialog/index.d.ts +69 -0
  37. package/dist/floating.js +1 -1
  38. package/dist/grid.cjs +1 -1
  39. package/dist/grid.js +2 -2
  40. package/dist/hooks/gesture/testing/createGestureSimulator.d.ts +7 -0
  41. package/dist/hooks/gesture/types.d.ts +48 -5
  42. package/dist/hooks/gesture/utils.d.ts +19 -0
  43. package/dist/hooks/useAnimationFrame.d.ts +2 -0
  44. package/dist/hooks/useOperationContinuity.d.ts +64 -0
  45. package/dist/hooks/useResizeObserver.d.ts +33 -1
  46. package/dist/hooks/useSharedElementTransition.d.ts +112 -0
  47. package/dist/hooks/useSwipeContentTransform.d.ts +9 -2
  48. package/dist/index.cjs +1 -1
  49. package/dist/index.js +7 -7
  50. package/dist/modules/dialog/AlertDialog.d.ts +9 -0
  51. package/dist/modules/dialog/DialogContainer.d.ts +37 -0
  52. package/dist/modules/dialog/Modal.d.ts +26 -0
  53. package/dist/modules/dialog/SwipeDialogContainer.d.ts +16 -0
  54. package/dist/modules/dialog/dialogAnimationUtils.d.ts +113 -0
  55. package/dist/modules/dialog/types.d.ts +183 -0
  56. package/dist/modules/dialog/useDialog.d.ts +39 -0
  57. package/dist/modules/dialog/useDialogContainer.d.ts +47 -0
  58. package/dist/modules/dialog/useDialogSwipeInput.d.ts +70 -0
  59. package/dist/modules/dialog/useDialogTransform.d.ts +82 -0
  60. package/dist/modules/drawer/types.d.ts +74 -0
  61. package/dist/modules/drawer/useDrawerSwipeInput.d.ts +24 -0
  62. package/dist/modules/pivot/SwipePivotTabBar.d.ts +3 -0
  63. package/dist/modules/stack/SwipeStackContent.d.ts +6 -3
  64. package/dist/modules/stack/SwipeStackOutlet.d.ts +4 -4
  65. package/dist/modules/stack/computeSwipeStackTransform.d.ts +1 -1
  66. package/dist/panels.cjs +1 -1
  67. package/dist/panels.js +1 -1
  68. package/dist/pivot.cjs +1 -1
  69. package/dist/pivot.js +1 -1
  70. package/dist/resizer.cjs +1 -1
  71. package/dist/resizer.js +2 -2
  72. package/dist/stack.cjs +1 -1
  73. package/dist/stack.cjs.map +1 -1
  74. package/dist/stack.js +503 -762
  75. package/dist/stack.js.map +1 -1
  76. package/dist/sticky-header/calculateStickyMetrics.d.ts +28 -0
  77. package/dist/sticky-header.cjs +1 -1
  78. package/dist/sticky-header.cjs.map +1 -1
  79. package/dist/sticky-header.js +59 -51
  80. package/dist/sticky-header.js.map +1 -1
  81. package/dist/{styles-DPPuJ0sf.js → styles-NkjuMOVS.js} +13 -13
  82. package/dist/{styles-DPPuJ0sf.js.map → styles-NkjuMOVS.js.map} +1 -1
  83. package/dist/styles-qf6ptVLD.cjs.map +1 -1
  84. package/dist/types.d.ts +16 -0
  85. package/dist/useDocumentPointerEvents-DXxw3qWj.js +54 -0
  86. package/dist/useDocumentPointerEvents-DXxw3qWj.js.map +1 -0
  87. package/dist/useDocumentPointerEvents-DxDSOtip.cjs +2 -0
  88. package/dist/useDocumentPointerEvents-DxDSOtip.cjs.map +1 -0
  89. package/dist/useNativeGestureGuard-C7TSqEkr.cjs +2 -0
  90. package/dist/useNativeGestureGuard-C7TSqEkr.cjs.map +1 -0
  91. package/dist/useNativeGestureGuard-CGYo6O0r.js +347 -0
  92. package/dist/useNativeGestureGuard-CGYo6O0r.js.map +1 -0
  93. package/dist/window/index.d.ts +2 -0
  94. package/dist/window.cjs +1 -1
  95. package/dist/window.cjs.map +1 -1
  96. package/dist/window.js +114 -103
  97. package/dist/window.js.map +1 -1
  98. package/package.json +6 -1
  99. package/src/components/gesture/SwipeSafeZone.tsx +69 -0
  100. package/src/components/window/Drawer.tsx +249 -162
  101. package/src/components/window/DrawerLayers.tsx +13 -3
  102. package/src/components/window/drawerStyles.spec.ts +263 -0
  103. package/src/components/window/drawerStyles.ts +228 -0
  104. package/src/components/window/drawerSwipeConfig.spec.ts +131 -0
  105. package/src/components/window/drawerSwipeConfig.ts +112 -0
  106. package/src/components/window/useDrawerSwipeTransform.spec.ts +234 -0
  107. package/src/components/window/useDrawerSwipeTransform.ts +129 -0
  108. package/src/constants/styles.ts +19 -0
  109. package/src/demo/pages/Dialog/alerts/index.tsx +22 -0
  110. package/src/demo/pages/Dialog/card/index.tsx +22 -0
  111. package/src/demo/pages/Dialog/components/AlertDialogDemo.tsx +124 -0
  112. package/src/demo/pages/Dialog/components/CardExpandDemo.module.css +243 -0
  113. package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +204 -0
  114. package/src/demo/pages/Dialog/components/CustomAlertDialogDemo.tsx +219 -0
  115. package/src/demo/pages/Dialog/components/DialogDemos.module.css +77 -0
  116. package/src/demo/pages/Dialog/components/ModalBasics.tsx +45 -0
  117. package/src/demo/pages/Dialog/components/SwipeDialogDemo.module.css +77 -0
  118. package/src/demo/pages/Dialog/components/SwipeDialogDemo.tsx +181 -0
  119. package/src/demo/pages/Dialog/custom-alert/index.tsx +22 -0
  120. package/src/demo/pages/Dialog/modal/index.tsx +17 -0
  121. package/src/demo/pages/Dialog/swipe/index.tsx +22 -0
  122. package/src/demo/pages/Drawer/components/DrawerSwipe.module.css +316 -0
  123. package/src/demo/pages/Drawer/components/DrawerSwipe.tsx +178 -0
  124. package/src/demo/pages/Drawer/swipe/index.tsx +17 -0
  125. package/src/demo/pages/Pivot/components/SwipeTabsPivot.tsx +54 -23
  126. package/src/demo/pages/Pivot/swipe-debug/index.tsx +1 -1
  127. package/src/demo/pages/Stack/components/StackBasics.spec.tsx +152 -0
  128. package/src/demo/pages/Stack/components/StackBasics.tsx +179 -95
  129. package/src/demo/pages/Stack/components/StackTablet.spec.tsx +120 -0
  130. package/src/demo/pages/Stack/components/StackTablet.tsx +42 -21
  131. package/src/demo/routes.tsx +22 -1
  132. package/src/dialog/index.ts +85 -0
  133. package/src/hooks/gesture/testing/createGestureSimulator.spec.ts +68 -64
  134. package/src/hooks/gesture/testing/createGestureSimulator.ts +112 -37
  135. package/src/hooks/gesture/types.ts +83 -6
  136. package/src/hooks/gesture/useEdgeSwipeInput.spec.ts +22 -14
  137. package/src/hooks/gesture/useNativeGestureGuard.spec.ts +91 -31
  138. package/src/hooks/gesture/useNativeGestureGuard.ts +3 -1
  139. package/src/hooks/gesture/utils.ts +91 -0
  140. package/src/hooks/useAnimatedVisibility.spec.ts +44 -24
  141. package/src/hooks/useAnimatedVisibility.ts +28 -2
  142. package/src/hooks/useAnimationFrame.ts +8 -0
  143. package/src/hooks/useOperationContinuity.spec.ts +387 -0
  144. package/src/hooks/useOperationContinuity.ts +135 -0
  145. package/src/hooks/useResizeObserver.spec.tsx +277 -0
  146. package/src/hooks/useResizeObserver.tsx +108 -39
  147. package/src/hooks/useScrollContainer.ts +4 -10
  148. package/src/hooks/useSharedElementTransition.ts +333 -0
  149. package/src/hooks/useSwipeContentTransform.spec.ts +18 -18
  150. package/src/hooks/useSwipeContentTransform.ts +166 -28
  151. package/src/modules/dialog/AlertDialog.spec.tsx +387 -0
  152. package/src/modules/dialog/AlertDialog.tsx +221 -0
  153. package/src/modules/dialog/DialogContainer.spec.tsx +228 -0
  154. package/src/modules/dialog/DialogContainer.tsx +188 -0
  155. package/src/modules/dialog/Modal.spec.tsx +220 -0
  156. package/src/modules/dialog/Modal.tsx +182 -0
  157. package/src/modules/dialog/SwipeDialogContainer.tsx +208 -0
  158. package/src/modules/dialog/dialogAnimationUtils.spec.ts +253 -0
  159. package/src/modules/dialog/dialogAnimationUtils.ts +297 -0
  160. package/src/modules/dialog/types.ts +186 -0
  161. package/src/modules/dialog/useDialog.spec.tsx +447 -0
  162. package/src/modules/dialog/useDialog.ts +214 -0
  163. package/src/modules/dialog/useDialogContainer.spec.ts +331 -0
  164. package/src/modules/dialog/useDialogContainer.ts +150 -0
  165. package/src/modules/dialog/useDialogSwipeInput.spec.ts +157 -0
  166. package/src/modules/dialog/useDialogSwipeInput.ts +319 -0
  167. package/src/modules/dialog/useDialogTransform.spec.ts +370 -0
  168. package/src/modules/dialog/useDialogTransform.ts +407 -0
  169. package/src/modules/drawer/types.ts +102 -0
  170. package/src/modules/drawer/useDrawerSwipeInput.spec.ts +566 -0
  171. package/src/modules/drawer/useDrawerSwipeInput.ts +399 -0
  172. package/src/modules/panels/rendering/ContentRegistry.spec.tsx +21 -14
  173. package/src/modules/pivot/SwipePivotContent.position.spec.tsx +12 -8
  174. package/src/modules/pivot/SwipePivotContent.spec.tsx +55 -25
  175. package/src/modules/pivot/SwipePivotContent.tsx +2 -2
  176. package/src/modules/pivot/SwipePivotTabBar.spec.tsx +85 -68
  177. package/src/modules/pivot/SwipePivotTabBar.tsx +75 -15
  178. package/src/modules/pivot/scaleInputState.spec.ts +11 -2
  179. package/src/modules/pivot/usePivot.spec.ts +17 -3
  180. package/src/modules/pivot/usePivotSwipeInput.spec.ts +182 -123
  181. package/src/modules/stack/SwipeStackContent.spec.tsx +387 -100
  182. package/src/modules/stack/SwipeStackContent.tsx +43 -33
  183. package/src/modules/stack/SwipeStackOutlet.spec.tsx +14 -16
  184. package/src/modules/stack/SwipeStackOutlet.tsx +6 -6
  185. package/src/modules/stack/computeSwipeStackTransform.spec.ts +5 -5
  186. package/src/modules/stack/computeSwipeStackTransform.ts +3 -3
  187. package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1133 -0
  188. package/src/modules/stack/useStackAnimationState.spec.ts +3 -1
  189. package/src/modules/stack/useStackAnimationState.ts +18 -13
  190. package/src/modules/stack/useStackNavigation.spec.ts +198 -3
  191. package/src/modules/stack/useStackNavigation.tsx +113 -56
  192. package/src/modules/stack/useStackSwipeInput.spec.ts +65 -32
  193. package/src/modules/stack/useStackSwipeInput.ts +1 -1
  194. package/src/sticky-header/StickyArea.tsx +29 -57
  195. package/src/sticky-header/calculateStickyMetrics.spec.ts +105 -0
  196. package/src/sticky-header/calculateStickyMetrics.ts +50 -0
  197. package/src/types.ts +18 -0
  198. package/src/window/index.ts +2 -0
  199. package/dist/FloatingWindow-BpdOpg_L.js +0 -400
  200. package/dist/FloatingWindow-BpdOpg_L.js.map +0 -1
  201. package/dist/FloatingWindow-TCDNY5gE.cjs +0 -2
  202. package/dist/FloatingWindow-TCDNY5gE.cjs.map +0 -1
  203. package/dist/GridLayout-B4VRsC0r.cjs +0 -2
  204. package/dist/ResizeHandle-CScipO5l.cjs +0 -2
  205. package/dist/SwipePivotTabBar-BGO9X94m.js +0 -407
  206. package/dist/SwipePivotTabBar-BGO9X94m.js.map +0 -1
  207. package/dist/SwipePivotTabBar-BrQismcZ.cjs +0 -2
  208. package/dist/SwipePivotTabBar-BrQismcZ.cjs.map +0 -1
  209. package/dist/useDocumentPointerEvents-CKdhGXd0.js +0 -46
  210. package/dist/useDocumentPointerEvents-CKdhGXd0.js.map +0 -1
  211. package/dist/useDocumentPointerEvents-ChqrKXDk.cjs +0 -2
  212. package/dist/useDocumentPointerEvents-ChqrKXDk.cjs.map +0 -1
  213. package/dist/useEffectEvent-Dp7HLCf0.js +0 -13
  214. package/dist/useEffectEvent-Dp7HLCf0.js.map +0 -1
  215. package/dist/useEffectEvent-huSsGUnl.cjs +0 -2
  216. package/dist/useEffectEvent-huSsGUnl.cjs.map +0 -1
@@ -85,3 +85,94 @@ export const mergeGestureContainerProps = (
85
85
  style: mergedStyle,
86
86
  };
87
87
  };
88
+
89
+ // ============================================================================
90
+ // Scroll Detection Utilities
91
+ // ============================================================================
92
+
93
+ /**
94
+ * Check if an element is scrollable in any direction.
95
+ */
96
+ export function isScrollableElement(element: HTMLElement): boolean {
97
+ const style = getComputedStyle(element);
98
+
99
+ const isScrollableX =
100
+ (style.overflowX === "scroll" || style.overflowX === "auto") &&
101
+ element.scrollWidth > element.clientWidth;
102
+
103
+ const isScrollableY =
104
+ (style.overflowY === "scroll" || style.overflowY === "auto") &&
105
+ element.scrollHeight > element.clientHeight;
106
+
107
+ return isScrollableX || isScrollableY;
108
+ }
109
+
110
+ /**
111
+ * Check if we should start drag based on scroll state.
112
+ * Returns false if the target is inside a scrollable element.
113
+ */
114
+ export function shouldStartDrag(
115
+ event: React.PointerEvent,
116
+ container: HTMLElement,
117
+ ): boolean {
118
+ // eslint-disable-next-line no-restricted-syntax -- loop variable requires let
119
+ let current = event.target as HTMLElement | null;
120
+
121
+ while (current !== null && current !== container) {
122
+ if (isScrollableElement(current)) {
123
+ return false;
124
+ }
125
+ current = current.parentElement;
126
+ }
127
+
128
+ return true;
129
+ }
130
+
131
+ /**
132
+ * Check if an element or its ancestors are scrollable in the specified direction.
133
+ * Returns true if scrolling is possible and would block the swipe gesture.
134
+ *
135
+ * @param element - The target element to check
136
+ * @param container - The container boundary
137
+ * @param axis - The axis to check ("x" or "y")
138
+ * @param direction - The swipe direction (1 = right/down, -1 = left/up)
139
+ */
140
+ export function isScrollableInDirection(
141
+ element: HTMLElement,
142
+ container: HTMLElement,
143
+ axis: "x" | "y",
144
+ direction: 1 | -1,
145
+ ): boolean {
146
+ // eslint-disable-next-line no-restricted-syntax -- loop variable requires let
147
+ let current: HTMLElement | null = element;
148
+
149
+ while (current !== null && current !== container) {
150
+ const style = getComputedStyle(current);
151
+ const isHorizontal = axis === "x";
152
+
153
+ const overflow = isHorizontal ? style.overflowX : style.overflowY;
154
+ const isScrollable = overflow === "scroll" || overflow === "auto";
155
+
156
+ if (isScrollable) {
157
+ const scrollSize = isHorizontal
158
+ ? current.scrollWidth - current.clientWidth
159
+ : current.scrollHeight - current.clientHeight;
160
+
161
+ if (scrollSize > 0) {
162
+ const scrollPos = isHorizontal ? current.scrollLeft : current.scrollTop;
163
+
164
+ // If swiping in close direction and not at boundary, block swipe
165
+ if (direction === -1 && scrollPos > 1) {
166
+ return true; // Can scroll left/up, block swipe
167
+ }
168
+ if (direction === 1 && scrollPos < scrollSize - 1) {
169
+ return true; // Can scroll right/down, block swipe
170
+ }
171
+ }
172
+ }
173
+
174
+ current = current.parentElement;
175
+ }
176
+
177
+ return false;
178
+ }
@@ -5,9 +5,46 @@
5
5
  * 1. アニメーションなし → 即座にdisplay:none
6
6
  * 2. アニメーションあり → 完了待ってdisplay:none
7
7
  */
8
+ import type * as React from "react";
8
9
  import { renderHook, act } from "@testing-library/react";
9
10
  import { useAnimatedVisibility } from "./useAnimatedVisibility.js";
10
11
 
12
+ /**
13
+ * Create a mock AnimationEvent for testing.
14
+ */
15
+ function createMockAnimationEvent(
16
+ target: EventTarget,
17
+ currentTarget: EventTarget,
18
+ ): React.AnimationEvent {
19
+ const noop = (): void => {};
20
+ const noopBool = (): boolean => false;
21
+ const nativeEvent = {
22
+ animationName: "test",
23
+ elapsedTime: 0,
24
+ pseudoElement: "",
25
+ } as AnimationEvent;
26
+ return {
27
+ target,
28
+ currentTarget,
29
+ nativeEvent,
30
+ bubbles: true,
31
+ cancelable: false,
32
+ defaultPrevented: false,
33
+ eventPhase: 0,
34
+ isTrusted: true,
35
+ preventDefault: noop,
36
+ isDefaultPrevented: noopBool,
37
+ stopPropagation: noop,
38
+ isPropagationStopped: noopBool,
39
+ persist: noop,
40
+ timeStamp: Date.now(),
41
+ type: "animationend",
42
+ animationName: "test",
43
+ elapsedTime: 0,
44
+ pseudoElement: "",
45
+ };
46
+ }
47
+
11
48
  describe("useAnimatedVisibility", () => {
12
49
  describe("initial state", () => {
13
50
  it("displays when initially visible", () => {
@@ -120,10 +157,7 @@ describe("useAnimatedVisibility", () => {
120
157
 
121
158
  // Simulate animationend event
122
159
  const sharedElement = document.createElement("div");
123
- const mockEvent = {
124
- target: sharedElement,
125
- currentTarget: sharedElement,
126
- } as unknown as React.AnimationEvent;
160
+ const mockEvent = createMockAnimationEvent(sharedElement, sharedElement);
127
161
 
128
162
  act(() => {
129
163
  result.current.props.onAnimationEnd(mockEvent);
@@ -149,10 +183,7 @@ describe("useAnimatedVisibility", () => {
149
183
  // Simulate animationend from a child element (target !== currentTarget)
150
184
  const parent = document.createElement("div");
151
185
  const child = document.createElement("div");
152
- const mockEvent = {
153
- target: child,
154
- currentTarget: parent,
155
- } as unknown as React.AnimationEvent;
186
+ const mockEvent = createMockAnimationEvent(child, parent);
156
187
 
157
188
  act(() => {
158
189
  result.current.props.onAnimationEnd(mockEvent);
@@ -188,14 +219,12 @@ describe("useAnimatedVisibility", () => {
188
219
 
189
220
  describe("timeout fallback", () => {
190
221
  it("hides after timeout if animationEnd never fires", async () => {
191
- vi.useFakeTimers();
192
-
193
222
  const { result, rerender } = renderHook(
194
223
  ({ isVisible }) =>
195
224
  useAnimatedVisibility({
196
225
  isVisible,
197
226
  leaveAnimation: "fadeOut 200ms ease-out",
198
- animationTimeout: 500,
227
+ animationTimeout: 10,
199
228
  }),
200
229
  { initialProps: { isVisible: true } },
201
230
  );
@@ -206,25 +235,21 @@ describe("useAnimatedVisibility", () => {
206
235
 
207
236
  // Advance time past timeout
208
237
  await act(async () => {
209
- vi.advanceTimersByTime(600);
238
+ await new Promise((resolve) => setTimeout(resolve, 20));
210
239
  });
211
240
 
212
241
  // Should be hidden now (fallback triggered)
213
242
  expect(result.current.style.display).toBe("none");
214
243
  expect(result.current.state.isAnimatingOut).toBe(false);
215
-
216
- vi.useRealTimers();
217
244
  });
218
245
 
219
246
  it("clears timeout when animationEnd fires before timeout", async () => {
220
- vi.useFakeTimers();
221
-
222
247
  const { result, rerender } = renderHook(
223
248
  ({ isVisible }) =>
224
249
  useAnimatedVisibility({
225
250
  isVisible,
226
251
  leaveAnimation: "fadeOut 200ms ease-out",
227
- animationTimeout: 500,
252
+ animationTimeout: 10,
228
253
  }),
229
254
  { initialProps: { isVisible: true } },
230
255
  );
@@ -233,10 +258,7 @@ describe("useAnimatedVisibility", () => {
233
258
 
234
259
  // Fire animationEnd before timeout
235
260
  const sharedElement = document.createElement("div");
236
- const mockEvent = {
237
- target: sharedElement,
238
- currentTarget: sharedElement,
239
- } as unknown as React.AnimationEvent;
261
+ const mockEvent = createMockAnimationEvent(sharedElement, sharedElement);
240
262
 
241
263
  act(() => {
242
264
  result.current.props.onAnimationEnd(mockEvent);
@@ -246,12 +268,10 @@ describe("useAnimatedVisibility", () => {
246
268
 
247
269
  // Advance past timeout - should not affect state
248
270
  await act(async () => {
249
- vi.advanceTimersByTime(600);
271
+ await new Promise((resolve) => setTimeout(resolve, 20));
250
272
  });
251
273
 
252
274
  expect(result.current.style.display).toBe("none");
253
-
254
- vi.useRealTimers();
255
275
  });
256
276
  });
257
277
  });
@@ -72,6 +72,32 @@ export function useAnimatedVisibility({
72
72
  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
73
73
 
74
74
  // Clear timeout on unmount
75
+ const shouldSkipLeaveAnimation = (
76
+ isSkipped: boolean,
77
+ animation: string | undefined,
78
+ ): boolean => {
79
+ if (isSkipped) {
80
+ return true;
81
+ }
82
+ if (!animation) {
83
+ return true;
84
+ }
85
+ if (animation === "none") {
86
+ return true;
87
+ }
88
+ return false;
89
+ };
90
+
91
+ const getShouldDisplay = (visible: boolean, animatingOut: boolean): boolean => {
92
+ if (visible) {
93
+ return true;
94
+ }
95
+ if (animatingOut) {
96
+ return true;
97
+ }
98
+ return false;
99
+ };
100
+
75
101
  React.useEffect(() => {
76
102
  return () => {
77
103
  if (timeoutRef.current) {
@@ -92,7 +118,7 @@ export function useAnimatedVisibility({
92
118
 
93
119
  if (wasVisible && !isVisible) {
94
120
  // Transitioning from visible to hidden
95
- if (skipAnimation || !leaveAnimation || leaveAnimation === "none") {
121
+ if (shouldSkipLeaveAnimation(skipAnimation, leaveAnimation)) {
96
122
  // No animation, hide immediately
97
123
  setIsAnimatingOut(false);
98
124
  } else {
@@ -129,7 +155,7 @@ export function useAnimatedVisibility({
129
155
  // Element should be displayed if:
130
156
  // - It's visible, OR
131
157
  // - It's animating out (leave animation in progress)
132
- const shouldDisplay = isVisible || isAnimatingOut;
158
+ const shouldDisplay = getShouldDisplay(isVisible, isAnimatingOut);
133
159
 
134
160
  return {
135
161
  state: {
@@ -40,6 +40,14 @@ export const easings = {
40
40
  }
41
41
  return 1 - Math.pow(-2 * t + 2, 3) / 2;
42
42
  },
43
+
44
+ /** Ease in expo (accelerating, for "suck in" effect) */
45
+ easeInExpo: (t: number): number => {
46
+ if (t === 0) {
47
+ return 0;
48
+ }
49
+ return Math.pow(2, 10 * t - 10);
50
+ },
43
51
  } as const;
44
52
 
45
53
  /**
@@ -0,0 +1,387 @@
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
+ const StrictModeWrapper = ({ children }: { children: React.ReactNode }): React.ReactNode => {
9
+ return React.createElement(React.StrictMode, null, children);
10
+ };
11
+
12
+ describe("useOperationContinuity", () => {
13
+ describe("value continuity", () => {
14
+ it("returns current value when not retaining", () => {
15
+ const { result } = renderHook(() => useOperationContinuity("active", false));
16
+ expect(result.current.value).toBe("active");
17
+ });
18
+
19
+ it("returns current value when retaining but value unchanged", () => {
20
+ const { result } = renderHook(() => useOperationContinuity("active", true));
21
+ expect(result.current.value).toBe("active");
22
+ });
23
+
24
+ it("retains previous value when shouldRetainPrevious is true", () => {
25
+ const { result, rerender } = renderHook(
26
+ ({ value, shouldRetainPrevious }) => useOperationContinuity(value, shouldRetainPrevious),
27
+ { initialProps: { value: "behind", shouldRetainPrevious: true } },
28
+ );
29
+
30
+ expect(result.current.value).toBe("behind");
31
+
32
+ // Value changes but we're still retaining
33
+ rerender({ value: "active", shouldRetainPrevious: true });
34
+ expect(result.current.value).toBe("behind");
35
+ });
36
+
37
+ it("accepts new value when shouldRetainPrevious becomes false", () => {
38
+ const { result, rerender } = renderHook(
39
+ ({ value, shouldRetainPrevious }) => useOperationContinuity(value, shouldRetainPrevious),
40
+ { initialProps: { value: "behind", shouldRetainPrevious: true } },
41
+ );
42
+
43
+ // Value changes while retaining
44
+ rerender({ value: "active", shouldRetainPrevious: true });
45
+ expect(result.current.value).toBe("behind");
46
+
47
+ // Stop retaining - should accept new value
48
+ rerender({ value: "active", shouldRetainPrevious: false });
49
+ expect(result.current.value).toBe("active");
50
+ });
51
+
52
+ it("updates stored value when not retaining", () => {
53
+ const { result, rerender } = renderHook(
54
+ ({ value, shouldRetainPrevious }) => useOperationContinuity(value, shouldRetainPrevious),
55
+ { initialProps: { value: "behind", shouldRetainPrevious: false } },
56
+ );
57
+
58
+ rerender({ value: "active", shouldRetainPrevious: false });
59
+ expect(result.current.value).toBe("active");
60
+
61
+ // Start retaining - should keep "active"
62
+ rerender({ value: "hidden", shouldRetainPrevious: true });
63
+ expect(result.current.value).toBe("active");
64
+ });
65
+ });
66
+
67
+ describe("changedDuringOperation tracking", () => {
68
+ it("returns false when value never changed", () => {
69
+ const { result, rerender } = renderHook(
70
+ ({ value, retain }) => useOperationContinuity(value, retain),
71
+ { initialProps: { value: "active", retain: true } },
72
+ );
73
+
74
+ expect(result.current.changedDuringOperation).toBe(false);
75
+
76
+ // End retention without value change
77
+ rerender({ value: "active", retain: false });
78
+ expect(result.current.changedDuringOperation).toBe(false);
79
+ });
80
+
81
+ it("returns true when value changed during retention", () => {
82
+ const { result, rerender } = renderHook(
83
+ ({ value, retain }) => useOperationContinuity(value, retain),
84
+ { initialProps: { value: "behind", retain: true } },
85
+ );
86
+
87
+ expect(result.current.changedDuringOperation).toBe(false);
88
+
89
+ // Value changes while retaining
90
+ rerender({ value: "active", retain: true });
91
+ expect(result.current.changedDuringOperation).toBe(true);
92
+
93
+ // End retention - should still be true (for this render)
94
+ rerender({ value: "active", retain: false });
95
+ expect(result.current.changedDuringOperation).toBe(true);
96
+ });
97
+
98
+ it("resets changedDuringOperation after operation ends", () => {
99
+ const { result, rerender } = renderHook(
100
+ ({ value, retain }) => useOperationContinuity(value, retain),
101
+ { initialProps: { value: "behind", retain: true } },
102
+ );
103
+
104
+ // Value changes while retaining
105
+ rerender({ value: "active", retain: true });
106
+ expect(result.current.changedDuringOperation).toBe(true);
107
+
108
+ // End retention
109
+ rerender({ value: "active", retain: false });
110
+ expect(result.current.changedDuringOperation).toBe(true);
111
+
112
+ // Next render - should be reset
113
+ rerender({ value: "active", retain: false });
114
+ expect(result.current.changedDuringOperation).toBe(false);
115
+ });
116
+
117
+ it("tracks changes across multiple operations", () => {
118
+ const { result, rerender } = renderHook(
119
+ ({ value, retain }) => useOperationContinuity(value, retain),
120
+ { initialProps: { value: "behind", retain: true } },
121
+ );
122
+
123
+ // First operation: value changes
124
+ rerender({ value: "active", retain: true });
125
+ rerender({ value: "active", retain: false });
126
+ expect(result.current.changedDuringOperation).toBe(true);
127
+
128
+ // Reset
129
+ rerender({ value: "active", retain: false });
130
+ expect(result.current.changedDuringOperation).toBe(false);
131
+
132
+ // Second operation: no value change
133
+ rerender({ value: "active", retain: true });
134
+ rerender({ value: "active", retain: false });
135
+ expect(result.current.changedDuringOperation).toBe(false);
136
+
137
+ // Third operation: value changes again
138
+ rerender({ value: "active", retain: true });
139
+ rerender({ value: "hidden", retain: true });
140
+ expect(result.current.changedDuringOperation).toBe(true);
141
+ rerender({ value: "hidden", retain: false });
142
+ expect(result.current.changedDuringOperation).toBe(true);
143
+ });
144
+ });
145
+
146
+ describe("simultaneous value and retention change", () => {
147
+ /**
148
+ * CRITICAL: This tests the over-swipe bug scenario.
149
+ *
150
+ * In the real app, when user releases after over-swipe:
151
+ * - displacement becomes 0 (shouldRetainPrevious becomes false)
152
+ * - role changes from "active" to "hidden"
153
+ * Both happen in the same render!
154
+ *
155
+ * The hook should detect that the value changed even though
156
+ * the change happened at the exact moment retention ended.
157
+ */
158
+ it("detects value change when it happens simultaneously with retention ending", () => {
159
+ const { result, rerender } = renderHook(
160
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
161
+ { initialProps: { role: "active" as const, displacement: 500 } },
162
+ );
163
+
164
+ // During swipe: role="active", retaining
165
+ expect(result.current.value).toBe("active");
166
+ expect(result.current.changedDuringOperation).toBe(false);
167
+
168
+ // Simulate release: BOTH displacement becomes 0 AND role changes to "hidden"
169
+ // This is what happens in the real app during over-swipe
170
+ rerender({ role: "hidden" as const, displacement: 0 });
171
+
172
+ // value should now be "hidden" (retention ended)
173
+ expect(result.current.value).toBe("hidden");
174
+ // CRITICAL: changedDuringOperation should be TRUE because the value
175
+ // changed from "active" to "hidden" at the moment retention ended
176
+ expect(result.current.changedDuringOperation).toBe(true);
177
+ });
178
+
179
+ it("does not report change when value stays the same at retention end", () => {
180
+ const { result, rerender } = renderHook(
181
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
182
+ { initialProps: { role: "active" as const, displacement: 500 } },
183
+ );
184
+
185
+ expect(result.current.value).toBe("active");
186
+ expect(result.current.changedDuringOperation).toBe(false);
187
+
188
+ // Release but role stays "active" (e.g., partial swipe that didn't trigger navigation)
189
+ rerender({ role: "active" as const, displacement: 0 });
190
+
191
+ expect(result.current.value).toBe("active");
192
+ // No change occurred
193
+ expect(result.current.changedDuringOperation).toBe(false);
194
+ });
195
+
196
+ it("does NOT report change during button navigation (no operation)", () => {
197
+ // This is the button navigation case: value changes but there was never
198
+ // any retention (no swipe operation). We should NOT report changedDuringOperation
199
+ // because this is normal navigation, not an operation-related change.
200
+ const { result, rerender } = renderHook(
201
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
202
+ { initialProps: { role: "active" as const, displacement: 0 } },
203
+ );
204
+
205
+ expect(result.current.value).toBe("active");
206
+ expect(result.current.changedDuringOperation).toBe(false);
207
+
208
+ // Button navigation: role changes but there's no operation (displacement is always 0)
209
+ rerender({ role: "behind" as const, displacement: 0 });
210
+
211
+ expect(result.current.value).toBe("behind");
212
+ // CRITICAL: changedDuringOperation should be FALSE because there was no operation
213
+ // This allows the animation to happen normally for button navigation
214
+ expect(result.current.changedDuringOperation).toBe(false);
215
+ });
216
+ });
217
+
218
+ describe("role transition scenarios", () => {
219
+ it("maintains role continuity during swipe (behind -> active)", () => {
220
+ // Simulates: behind panel becomes active during swipe
221
+ // displacement > 0, so we should retain the previous role
222
+ const { result, rerender } = renderHook(
223
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
224
+ { initialProps: { role: "behind" as const, displacement: 100 } },
225
+ );
226
+
227
+ expect(result.current.value).toBe("behind");
228
+ expect(result.current.changedDuringOperation).toBe(false);
229
+
230
+ // Navigation changes role to "active" but displacement is still positive
231
+ rerender({ role: "active" as const, displacement: 100 });
232
+ expect(result.current.value).toBe("behind");
233
+ expect(result.current.changedDuringOperation).toBe(true);
234
+
235
+ // Swipe ends (displacement becomes 0)
236
+ rerender({ role: "active" as const, displacement: 0 });
237
+ expect(result.current.value).toBe("active");
238
+ expect(result.current.changedDuringOperation).toBe(true);
239
+ });
240
+
241
+ it("maintains role continuity during swipe (active -> hidden)", () => {
242
+ // Simulates: over-swipe where active panel becomes hidden
243
+ const { result, rerender } = renderHook(
244
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
245
+ { initialProps: { role: "active" as const, displacement: 400 } },
246
+ );
247
+
248
+ expect(result.current.value).toBe("active");
249
+
250
+ // Over-swipe triggers navigation change
251
+ rerender({ role: "hidden" as const, displacement: 500 });
252
+ expect(result.current.value).toBe("active");
253
+ expect(result.current.changedDuringOperation).toBe(true);
254
+
255
+ // Swipe ends
256
+ rerender({ role: "hidden" as const, displacement: 0 });
257
+ expect(result.current.value).toBe("hidden");
258
+ expect(result.current.changedDuringOperation).toBe(true);
259
+ });
260
+
261
+ it("provides changedDuringOperation for animation decision", () => {
262
+ // This test demonstrates the intended use case:
263
+ // Use changedDuringOperation to decide whether to animate on operation end
264
+ const { result, rerender } = renderHook(
265
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
266
+ { initialProps: { role: "behind" as const, displacement: 100 } },
267
+ );
268
+
269
+ // Simulate role change during swipe
270
+ rerender({ role: "active" as const, displacement: 100 });
271
+
272
+ // When swipe ends, changedDuringOperation tells us to skip animation
273
+ rerender({ role: "active" as const, displacement: 0 });
274
+ expect(result.current.changedDuringOperation).toBe(true);
275
+ // Consumer would use this to skip target change animation
276
+ });
277
+ });
278
+
279
+ describe("works with different value types", () => {
280
+ it("works with numbers", () => {
281
+ const { result, rerender } = renderHook(
282
+ ({ value, retain }) => useOperationContinuity(value, retain),
283
+ { initialProps: { value: 0, retain: true } },
284
+ );
285
+
286
+ rerender({ value: 1, retain: true });
287
+ expect(result.current.value).toBe(0);
288
+
289
+ rerender({ value: 1, retain: false });
290
+ expect(result.current.value).toBe(1);
291
+ });
292
+
293
+ it("works with objects (by reference)", () => {
294
+ const obj1 = { id: 1 };
295
+ const obj2 = { id: 2 };
296
+
297
+ const { result, rerender } = renderHook(
298
+ ({ value, retain }) => useOperationContinuity(value, retain),
299
+ { initialProps: { value: obj1, retain: true } },
300
+ );
301
+
302
+ rerender({ value: obj2, retain: true });
303
+ expect(result.current.value).toBe(obj1);
304
+
305
+ rerender({ value: obj2, retain: false });
306
+ expect(result.current.value).toBe(obj2);
307
+ });
308
+ });
309
+
310
+ describe("React StrictMode compatibility", () => {
311
+ /**
312
+ * CRITICAL: These tests verify the hook works correctly in StrictMode.
313
+ *
314
+ * In StrictMode, React calls the render function twice. Hooks that mutate
315
+ * refs during render will see the mutated value on the second call, which
316
+ * can cause bugs.
317
+ *
318
+ * This hook uses useLayoutEffect for ref mutations to avoid this issue.
319
+ */
320
+ it("operationJustEnded is correct in StrictMode", () => {
321
+ const { result, rerender } = renderHook(
322
+ ({ value, retain }) => useOperationContinuity(value, retain),
323
+ {
324
+ initialProps: { value: "active", retain: true },
325
+ wrapper: StrictModeWrapper,
326
+ },
327
+ );
328
+
329
+ // During retention
330
+ expect(result.current.operationJustEnded).toBe(false);
331
+
332
+ // End retention - operationJustEnded should be true
333
+ rerender({ value: "active", retain: false });
334
+ expect(result.current.operationJustEnded).toBe(true);
335
+
336
+ // Next render - should be false again
337
+ rerender({ value: "active", retain: false });
338
+ expect(result.current.operationJustEnded).toBe(false);
339
+ });
340
+
341
+ it("over-swipe scenario works in StrictMode", () => {
342
+ // This is the exact scenario that was broken before the fix:
343
+ // User swipes beyond 100%, releases, and we need operationJustEnded=true
344
+ // to prevent the visual jump.
345
+ const { result, rerender } = renderHook(
346
+ ({ role, displacement }) => useOperationContinuity(role, displacement > 0),
347
+ {
348
+ initialProps: { role: "active" as const, displacement: 500 },
349
+ wrapper: StrictModeWrapper,
350
+ },
351
+ );
352
+
353
+ // During over-swipe
354
+ expect(result.current.value).toBe("active");
355
+ expect(result.current.operationJustEnded).toBe(false);
356
+
357
+ // Release (displacement becomes 0)
358
+ rerender({ role: "active" as const, displacement: 0 });
359
+
360
+ // CRITICAL: operationJustEnded must be true even in StrictMode
361
+ // This is what was broken before the fix
362
+ expect(result.current.operationJustEnded).toBe(true);
363
+ expect(result.current.value).toBe("active");
364
+ });
365
+
366
+ it("changedDuringOperation is tracked correctly in StrictMode", () => {
367
+ const { result, rerender } = renderHook(
368
+ ({ value, retain }) => useOperationContinuity(value, retain),
369
+ {
370
+ initialProps: { value: "behind", retain: true },
371
+ wrapper: StrictModeWrapper,
372
+ },
373
+ );
374
+
375
+ expect(result.current.changedDuringOperation).toBe(false);
376
+
377
+ // Value changes during retention
378
+ rerender({ value: "active", retain: true });
379
+ expect(result.current.changedDuringOperation).toBe(true);
380
+
381
+ // End retention
382
+ rerender({ value: "active", retain: false });
383
+ expect(result.current.changedDuringOperation).toBe(true);
384
+ expect(result.current.operationJustEnded).toBe(true);
385
+ });
386
+ });
387
+ });