react-panel-layout 0.6.1 → 0.7.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 (127) hide show
  1. package/dist/FloatingWindow-CE-WzkNv.js +1542 -0
  2. package/dist/FloatingWindow-CE-WzkNv.js.map +1 -0
  3. package/dist/FloatingWindow-DpFpmX1f.cjs +2 -0
  4. package/dist/FloatingWindow-DpFpmX1f.cjs.map +1 -0
  5. package/dist/GridLayout-EwKszYBy.cjs +2 -0
  6. package/dist/{GridLayout-DKTg_N61.cjs.map → GridLayout-EwKszYBy.cjs.map} +1 -1
  7. package/dist/GridLayout-kiWdpMLQ.js +947 -0
  8. package/dist/{GridLayout-UWNxXw77.js.map → GridLayout-kiWdpMLQ.js.map} +1 -1
  9. package/dist/PanelSystem-Dmy5YI_6.cjs +3 -0
  10. package/dist/PanelSystem-Dmy5YI_6.cjs.map +1 -0
  11. package/dist/{PanelSystem-BqUzNtf2.js → PanelSystem-DrYsYwuV.js} +208 -247
  12. package/dist/PanelSystem-DrYsYwuV.js.map +1 -0
  13. package/dist/components/window/Drawer.d.ts +1 -0
  14. package/dist/components/window/DrawerRevealContext.d.ts +61 -0
  15. package/dist/components/window/drawerRevealAnimationUtils.d.ts +212 -0
  16. package/dist/components/window/drawerStyles.d.ts +5 -0
  17. package/dist/components/window/useDrawerSwipeTransform.d.ts +8 -2
  18. package/dist/components/window/useDrawerTransform.d.ts +68 -0
  19. package/dist/components/window/useRevealDrawerTransform.d.ts +56 -0
  20. package/dist/config.cjs +1 -1
  21. package/dist/config.cjs.map +1 -1
  22. package/dist/config.js +8 -7
  23. package/dist/config.js.map +1 -1
  24. package/dist/dialog/index.d.ts +1 -1
  25. package/dist/grid.cjs +1 -1
  26. package/dist/grid.js +2 -2
  27. package/dist/index.cjs +1 -1
  28. package/dist/index.js +4 -4
  29. package/dist/modules/dialog/DialogContainer.d.ts +22 -2
  30. package/dist/modules/dialog/Modal.d.ts +23 -2
  31. package/dist/modules/dialog/SwipeDialogContainer.d.ts +6 -2
  32. package/dist/modules/dialog/types.d.ts +12 -0
  33. package/dist/modules/drawer/drawerStateMachine.d.ts +168 -0
  34. package/dist/modules/drawer/revealDrawerConstants.d.ts +33 -0
  35. package/dist/modules/drawer/revealDrawerStateMachine.d.ts +146 -0
  36. package/dist/modules/drawer/strategies/index.d.ts +8 -0
  37. package/dist/modules/drawer/strategies/overlayStrategy.d.ts +12 -0
  38. package/dist/modules/drawer/strategies/revealStrategy.d.ts +12 -0
  39. package/dist/modules/drawer/strategies/types.d.ts +116 -0
  40. package/dist/panels.cjs +1 -1
  41. package/dist/panels.js +1 -1
  42. package/dist/stack.cjs +1 -1
  43. package/dist/stack.cjs.map +1 -1
  44. package/dist/stack.js +306 -347
  45. package/dist/stack.js.map +1 -1
  46. package/dist/types.d.ts +14 -0
  47. package/dist/useAnimationFrame-CRuFlk5t.js +394 -0
  48. package/dist/useAnimationFrame-CRuFlk5t.js.map +1 -0
  49. package/dist/useAnimationFrame-XRpDXkwV.cjs +2 -0
  50. package/dist/useAnimationFrame-XRpDXkwV.cjs.map +1 -0
  51. package/dist/window.cjs +1 -1
  52. package/dist/window.js +1 -1
  53. package/package.json +1 -1
  54. package/src/components/gesture/SwipeSafeZone.tsx +1 -0
  55. package/src/components/grid/GridLayout.tsx +110 -38
  56. package/src/components/window/Drawer.tsx +114 -10
  57. package/src/components/window/DrawerLayers.tsx +48 -15
  58. package/src/components/window/DrawerRevealContext.spec.ts +20 -0
  59. package/src/components/window/DrawerRevealContext.tsx +99 -0
  60. package/src/components/window/drawerRevealAnimationUtils.spec.ts +375 -0
  61. package/src/components/window/drawerRevealAnimationUtils.ts +415 -0
  62. package/src/components/window/drawerStyles.spec.ts +39 -0
  63. package/src/components/window/drawerStyles.ts +24 -0
  64. package/src/components/window/useDrawerSwipeTransform.ts +28 -90
  65. package/src/components/window/useDrawerTransform.ts +505 -0
  66. package/src/components/window/useRevealDrawerTransform.spec.ts +1936 -0
  67. package/src/components/window/useRevealDrawerTransform.ts +105 -0
  68. package/src/demo/components/FullscreenDemoPage.tsx +47 -0
  69. package/src/demo/fullscreenRoutes.tsx +32 -0
  70. package/src/demo/index.tsx +5 -0
  71. package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +23 -8
  72. package/src/demo/pages/Drawer/components/DrawerBasics.module.css +6 -1
  73. package/src/demo/pages/Drawer/components/DrawerBasics.tsx +14 -4
  74. package/src/demo/pages/Drawer/components/DrawerReveal.module.css +157 -0
  75. package/src/demo/pages/Drawer/components/DrawerReveal.tsx +128 -0
  76. package/src/demo/pages/Drawer/reveal/index.tsx +17 -0
  77. package/src/demo/pages/Drawer/reveal-fullscreen/index.tsx +135 -0
  78. package/src/demo/pages/Drawer/reveal-fullscreen/styles.module.css +233 -0
  79. package/src/demo/pages/Stack/components/StackBasics.spec.tsx +56 -52
  80. package/src/demo/pages/Stack/components/StackTablet.spec.tsx +39 -49
  81. package/src/demo/routes.tsx +2 -0
  82. package/src/dialog/index.ts +2 -0
  83. package/src/hooks/gesture/testing/createGestureSimulator.ts +1 -0
  84. package/src/hooks/gesture/useNativeGestureGuard.spec.ts +10 -2
  85. package/src/hooks/gesture/useSwipeInput.spec.ts +69 -0
  86. package/src/hooks/gesture/useSwipeInput.ts +2 -0
  87. package/src/hooks/gesture/utils.ts +15 -4
  88. package/src/hooks/useAnimatedVisibility.spec.ts +3 -3
  89. package/src/hooks/useOperationContinuity.spec.ts +17 -10
  90. package/src/hooks/useOperationContinuity.ts +5 -5
  91. package/src/hooks/useSharedElementTransition.ts +28 -7
  92. package/src/modules/dialog/DialogContainer.tsx +39 -5
  93. package/src/modules/dialog/Modal.tsx +46 -4
  94. package/src/modules/dialog/SwipeDialogContainer.tsx +12 -2
  95. package/src/modules/dialog/dialogAnimationUtils.spec.ts +0 -1
  96. package/src/modules/dialog/types.ts +14 -0
  97. package/src/modules/dialog/useDialogContainer.spec.ts +11 -3
  98. package/src/modules/dialog/useDialogSwipeInput.spec.ts +49 -28
  99. package/src/modules/dialog/useDialogSwipeInput.ts +37 -6
  100. package/src/modules/dialog/useDialogTransform.spec.ts +63 -30
  101. package/src/modules/drawer/drawerStateMachine.ts +500 -0
  102. package/src/modules/drawer/revealDrawerConstants.ts +38 -0
  103. package/src/modules/drawer/revealDrawerStateMachine.spec.ts +558 -0
  104. package/src/modules/drawer/revealDrawerStateMachine.ts +197 -0
  105. package/src/modules/drawer/strategies/index.ts +9 -0
  106. package/src/modules/drawer/strategies/overlayStrategy.ts +133 -0
  107. package/src/modules/drawer/strategies/revealStrategy.ts +111 -0
  108. package/src/modules/drawer/strategies/types.ts +160 -0
  109. package/src/modules/drawer/useDrawerSwipeInput.ts +7 -4
  110. package/src/modules/pivot/SwipePivotContent.spec.tsx +48 -37
  111. package/src/modules/pivot/usePivotSwipeInput.spec.ts +8 -8
  112. package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1 -1
  113. package/src/types.ts +15 -0
  114. package/dist/FloatingWindow-CUXnEtrb.js +0 -827
  115. package/dist/FloatingWindow-CUXnEtrb.js.map +0 -1
  116. package/dist/FloatingWindow-DMwyK0eK.cjs +0 -2
  117. package/dist/FloatingWindow-DMwyK0eK.cjs.map +0 -1
  118. package/dist/GridLayout-DKTg_N61.cjs +0 -2
  119. package/dist/GridLayout-UWNxXw77.js +0 -926
  120. package/dist/PanelSystem-BqUzNtf2.js.map +0 -1
  121. package/dist/PanelSystem-D603LKKv.cjs +0 -3
  122. package/dist/PanelSystem-D603LKKv.cjs.map +0 -1
  123. package/dist/useNativeGestureGuard-C7TSqEkr.cjs +0 -2
  124. package/dist/useNativeGestureGuard-C7TSqEkr.cjs.map +0 -1
  125. package/dist/useNativeGestureGuard-CGYo6O0r.js +0 -347
  126. package/dist/useNativeGestureGuard-CGYo6O0r.js.map +0 -1
  127. package/src/components/window/useDrawerSwipeTransform.spec.ts +0 -234
@@ -0,0 +1,1936 @@
1
+ /**
2
+ * @file Tests for useRevealDrawerTransform hook.
3
+ *
4
+ * Comprehensive tests covering all operation scenarios for reveal drawer.
5
+ * Tests follow TDD approach to ensure animation continuity and correct state management.
6
+ */
7
+ import * as React from "react";
8
+ import { renderHook, act } from "@testing-library/react";
9
+ import { useRevealDrawerTransform } from "./useRevealDrawerTransform.js";
10
+ import type { UseRevealDrawerTransformOptions } from "./useRevealDrawerTransform.js";
11
+ import type { ContinuousOperationState } from "../../hooks/gesture/types.js";
12
+
13
+ // ============================================================================
14
+ // Test Utilities
15
+ // ============================================================================
16
+
17
+ /**
18
+ * Create mock drawer element with style tracking.
19
+ */
20
+ function createMockDrawer(): HTMLDivElement {
21
+ const element = document.createElement("div");
22
+ return element;
23
+ }
24
+
25
+ /**
26
+ * Create mock content element (simulates #root or grid container).
27
+ */
28
+ function createMockContent(): HTMLDivElement {
29
+ const element = document.createElement("div");
30
+ element.id = "root";
31
+ document.body.appendChild(element);
32
+ return element;
33
+ }
34
+
35
+ /**
36
+ * Create idle swipe state.
37
+ */
38
+ function createIdleSwipeState(): ContinuousOperationState {
39
+ return {
40
+ phase: "idle",
41
+ displacement: { x: 0, y: 0 },
42
+ velocity: { x: 0, y: 0 },
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Create operating swipe state.
48
+ */
49
+ function createOperatingSwipeState(displacement: { x: number; y: number }): ContinuousOperationState {
50
+ return {
51
+ phase: "operating",
52
+ displacement,
53
+ velocity: { x: 0, y: 0 },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Default options for hook.
59
+ */
60
+ function createDefaultOptions(
61
+ drawerRef: React.RefObject<HTMLElement | null>,
62
+ overrides: Partial<UseRevealDrawerTransformOptions> = {},
63
+ ): UseRevealDrawerTransformOptions {
64
+ return {
65
+ drawerRef,
66
+ placement: "left",
67
+ drawerSize: 300,
68
+ isOpen: false,
69
+ swipeState: createIdleSwipeState(),
70
+ displacement: 0,
71
+ isOpening: false,
72
+ isClosing: false,
73
+ enabled: true,
74
+ inline: false,
75
+ contentBackground: "#fff",
76
+ ...overrides,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Setup mock requestAnimationFrame for animation testing.
82
+ */
83
+ function setupAnimationFrameMock(): { runFrame: () => void; runAllFrames: () => void } {
84
+ const callbacks: FrameRequestCallback[] = [];
85
+ const counter = { frameId: 0 };
86
+
87
+ globalThis.requestAnimationFrame = (callback: FrameRequestCallback): number => {
88
+ callbacks.push(callback);
89
+ counter.frameId += 1;
90
+ return counter.frameId;
91
+ };
92
+
93
+ globalThis.cancelAnimationFrame = (): void => {
94
+ // Simple implementation - in real tests we might need to track by ID
95
+ };
96
+
97
+ return {
98
+ runFrame: () => {
99
+ const callback = callbacks.shift();
100
+ if (callback) {
101
+ callback(performance.now());
102
+ }
103
+ },
104
+ runAllFrames: () => {
105
+ while (callbacks.length > 0) {
106
+ const callback = callbacks.shift();
107
+ if (callback) {
108
+ callback(performance.now());
109
+ }
110
+ }
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Cleanup after tests.
117
+ */
118
+ function cleanup(): void {
119
+ const root = document.getElementById("root");
120
+ if (root) {
121
+ root.remove();
122
+ }
123
+ document.body.style.overflow = "";
124
+ }
125
+
126
+ // ============================================================================
127
+ // Tests
128
+ // ============================================================================
129
+
130
+ describe("useRevealDrawerTransform", () => {
131
+ afterEach(() => {
132
+ cleanup();
133
+ });
134
+
135
+ // --------------------------------------------------------------------------
136
+ // Initial State Tests
137
+ // --------------------------------------------------------------------------
138
+
139
+ describe("Initial State", () => {
140
+ it("starts with closed phase when isOpen=false", () => {
141
+ const drawer = createMockDrawer();
142
+ const ref = { current: drawer };
143
+ createMockContent();
144
+
145
+ const { result } = renderHook(() =>
146
+ useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: false })),
147
+ );
148
+
149
+ expect(result.current.phase).toBe("closed");
150
+ expect(result.current.isAnimating).toBe(false);
151
+ });
152
+
153
+ it("starts with open phase when isOpen=true", () => {
154
+ const drawer = createMockDrawer();
155
+ const ref = { current: drawer };
156
+ createMockContent();
157
+
158
+ const { result } = renderHook(() =>
159
+ useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: true })),
160
+ );
161
+
162
+ expect(result.current.phase).toBe("open");
163
+ expect(result.current.isAnimating).toBe(false);
164
+ });
165
+
166
+ it("drawer is hidden initially when closed", () => {
167
+ const drawer = createMockDrawer();
168
+ const ref = { current: drawer };
169
+ createMockContent();
170
+
171
+ renderHook(() =>
172
+ useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: false })),
173
+ );
174
+
175
+ expect(drawer.style.visibility).toBe("hidden");
176
+ expect(drawer.style.pointerEvents).toBe("none");
177
+ });
178
+
179
+ it("drawer is visible initially when open", () => {
180
+ const drawer = createMockDrawer();
181
+ const ref = { current: drawer };
182
+ createMockContent();
183
+
184
+ renderHook(() =>
185
+ useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: true })),
186
+ );
187
+
188
+ // When open, drawer should be visible with correct transform
189
+ expect(drawer.style.visibility).toBe("visible");
190
+ expect(drawer.style.pointerEvents).toBe("auto");
191
+ });
192
+ });
193
+
194
+ // --------------------------------------------------------------------------
195
+ // Swipe Opening Tests
196
+ // --------------------------------------------------------------------------
197
+
198
+ describe("Opening via Swipe", () => {
199
+ it("drawer becomes visible when swipe starts", () => {
200
+ const drawer = createMockDrawer();
201
+ const ref = { current: drawer };
202
+ createMockContent();
203
+
204
+ const { rerender } = renderHook(
205
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
206
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
207
+ );
208
+
209
+ // Start opening swipe
210
+ rerender(createDefaultOptions(ref, {
211
+ isOpen: false,
212
+ swipeState: createOperatingSwipeState({ x: 50, y: 0 }),
213
+ displacement: 50,
214
+ isOpening: true,
215
+ }));
216
+
217
+ expect(drawer.style.visibility).toBe("visible");
218
+ });
219
+
220
+ it("drawer transform updates during swipe", () => {
221
+ const drawer = createMockDrawer();
222
+ const ref = { current: drawer };
223
+ createMockContent();
224
+
225
+ const { rerender } = renderHook(
226
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
227
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
228
+ );
229
+
230
+ // Swipe to 50%
231
+ rerender(createDefaultOptions(ref, {
232
+ isOpen: false,
233
+ swipeState: createOperatingSwipeState({ x: 150, y: 0 }),
234
+ displacement: 150,
235
+ isOpening: true,
236
+ }));
237
+
238
+ // Drawer should have transform applied
239
+ expect(drawer.style.transform).not.toBe("");
240
+ expect(drawer.style.transform).toContain("translate");
241
+ });
242
+
243
+ it("content transform updates during swipe", () => {
244
+ const drawer = createMockDrawer();
245
+ const ref = { current: drawer };
246
+ const content = createMockContent();
247
+
248
+ const { rerender } = renderHook(
249
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
250
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
251
+ );
252
+
253
+ // Swipe to 50%
254
+ rerender(createDefaultOptions(ref, {
255
+ isOpen: false,
256
+ swipeState: createOperatingSwipeState({ x: 150, y: 0 }),
257
+ displacement: 150,
258
+ isOpening: true,
259
+ }));
260
+
261
+ // Content should have transform applied
262
+ expect(content.style.transform).not.toBe("");
263
+ expect(content.style.transform).toContain("translate");
264
+ });
265
+
266
+ it("stacking context is applied to content during swipe", () => {
267
+ const drawer = createMockDrawer();
268
+ const ref = { current: drawer };
269
+ const content = createMockContent();
270
+
271
+ const { rerender } = renderHook(
272
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
273
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
274
+ );
275
+
276
+ // Start opening swipe
277
+ rerender(createDefaultOptions(ref, {
278
+ isOpen: false,
279
+ swipeState: createOperatingSwipeState({ x: 50, y: 0 }),
280
+ displacement: 50,
281
+ isOpening: true,
282
+ }));
283
+
284
+ expect(content.style.position).toBe("relative");
285
+ expect(content.style.zIndex).toBe("1");
286
+ });
287
+ });
288
+
289
+ // --------------------------------------------------------------------------
290
+ // Animation Continuity Tests (Critical for preventing jumps)
291
+ // --------------------------------------------------------------------------
292
+
293
+ describe("Animation Continuity", () => {
294
+ it("preserves position when swipe ends - no jump", () => {
295
+ const drawer = createMockDrawer();
296
+ const ref = { current: drawer };
297
+ createMockContent();
298
+ setupAnimationFrameMock();
299
+
300
+ const { rerender } = renderHook(
301
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
302
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
303
+ );
304
+
305
+ // Swipe to 50%
306
+ rerender(createDefaultOptions(ref, {
307
+ isOpen: false,
308
+ swipeState: createOperatingSwipeState({ x: 150, y: 0 }),
309
+ displacement: 150,
310
+ isOpening: true,
311
+ }));
312
+
313
+ // End swipe - isOpening becomes false, displacement resets
314
+ // This simulates the multi-render issue where isOpen changes after
315
+ rerender(createDefaultOptions(ref, {
316
+ isOpen: false, // Not yet updated
317
+ swipeState: createIdleSwipeState(),
318
+ displacement: 0, // Reset!
319
+ isOpening: false, // No longer opening
320
+ isClosing: false,
321
+ }));
322
+
323
+ // Transform should NOT have jumped to closed position
324
+ // It should either be the same (preserved) or animation started
325
+ // The key assertion: transform should not reset to closed position immediately
326
+ expect(drawer.style.transform).not.toBe("");
327
+ });
328
+
329
+ it("starts animation from current position when swipe ends with threshold crossed", () => {
330
+ const drawer = createMockDrawer();
331
+ const ref = { current: drawer };
332
+ createMockContent();
333
+ setupAnimationFrameMock();
334
+
335
+ const { result, rerender } = renderHook(
336
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
337
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
338
+ );
339
+
340
+ // Swipe past threshold (50%)
341
+ rerender(createDefaultOptions(ref, {
342
+ isOpen: false,
343
+ swipeState: createOperatingSwipeState({ x: 200, y: 0 }),
344
+ displacement: 200,
345
+ isOpening: true,
346
+ }));
347
+
348
+ // End swipe with isOpen changing to true
349
+ act(() => {
350
+ rerender(createDefaultOptions(ref, {
351
+ isOpen: true, // Now open!
352
+ swipeState: createIdleSwipeState(),
353
+ displacement: 0,
354
+ isOpening: false,
355
+ isClosing: false,
356
+ }));
357
+ });
358
+
359
+ // Should be animating to open position
360
+ expect(result.current.phase).toBe("opening");
361
+ expect(result.current.isAnimating).toBe(true);
362
+ });
363
+
364
+ it("starts animation from current position when swipe ends without threshold crossed", () => {
365
+ const drawer = createMockDrawer();
366
+ const ref = { current: drawer };
367
+ createMockContent();
368
+ setupAnimationFrameMock();
369
+
370
+ const { result, rerender } = renderHook(
371
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
372
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
373
+ );
374
+
375
+ // Swipe just a bit (not past threshold)
376
+ rerender(createDefaultOptions(ref, {
377
+ isOpen: false,
378
+ swipeState: createOperatingSwipeState({ x: 50, y: 0 }),
379
+ displacement: 50,
380
+ isOpening: true,
381
+ }));
382
+
383
+ // End swipe - isOpen stays false
384
+ act(() => {
385
+ rerender(createDefaultOptions(ref, {
386
+ isOpen: false,
387
+ swipeState: createIdleSwipeState(),
388
+ displacement: 0,
389
+ isOpening: false,
390
+ isClosing: false,
391
+ }));
392
+ });
393
+
394
+ // Should be animating back to closed position
395
+ expect(result.current.phase).toBe("closing");
396
+ expect(result.current.isAnimating).toBe(true);
397
+ });
398
+ });
399
+
400
+ // --------------------------------------------------------------------------
401
+ // Stable State Tests (Critical for "open but shows closed" bug)
402
+ // --------------------------------------------------------------------------
403
+
404
+ describe("Stable Open State", () => {
405
+ it("drawer remains visible after opening animation completes", () => {
406
+ const drawer = createMockDrawer();
407
+ const ref = { current: drawer };
408
+ createMockContent();
409
+ const { runAllFrames } = setupAnimationFrameMock();
410
+
411
+ const { rerender } = renderHook(
412
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
413
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
414
+ );
415
+
416
+ // Trigger open via button (non-swipe)
417
+ act(() => {
418
+ rerender(createDefaultOptions(ref, { isOpen: true }));
419
+ });
420
+
421
+ // Run animation to completion
422
+ act(() => {
423
+ runAllFrames();
424
+ });
425
+
426
+ // Drawer should remain visible
427
+ expect(drawer.style.visibility).toBe("visible");
428
+ expect(drawer.style.pointerEvents).toBe("auto");
429
+ });
430
+
431
+ it("drawer has correct transform after opening animation completes", () => {
432
+ const drawer = createMockDrawer();
433
+ const ref = { current: drawer };
434
+ createMockContent();
435
+ const { runAllFrames } = setupAnimationFrameMock();
436
+
437
+ const { rerender } = renderHook(
438
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
439
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
440
+ );
441
+
442
+ // Trigger open
443
+ act(() => {
444
+ rerender(createDefaultOptions(ref, { isOpen: true }));
445
+ });
446
+
447
+ // Run animation to completion
448
+ act(() => {
449
+ runAllFrames();
450
+ });
451
+
452
+ // Drawer should be at open position (translateX(0px) for left placement)
453
+ expect(drawer.style.transform).toContain("0px");
454
+ });
455
+
456
+ it("content has correct transform after opening animation completes", () => {
457
+ const drawer = createMockDrawer();
458
+ const ref = { current: drawer };
459
+ const content = createMockContent();
460
+ const { runAllFrames } = setupAnimationFrameMock();
461
+
462
+ const { rerender } = renderHook(
463
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
464
+ {
465
+ initialProps: createDefaultOptions(ref, {
466
+ isOpen: false,
467
+ drawerSize: 300,
468
+ }),
469
+ },
470
+ );
471
+
472
+ // Trigger open
473
+ act(() => {
474
+ rerender(createDefaultOptions(ref, { isOpen: true, drawerSize: 300 }));
475
+ });
476
+
477
+ // Run animation to completion
478
+ act(() => {
479
+ runAllFrames();
480
+ });
481
+
482
+ // Content should be offset by drawer size
483
+ expect(content.style.transform).toContain("300px");
484
+ });
485
+
486
+ it("phase becomes open after animation completes", () => {
487
+ const drawer = createMockDrawer();
488
+ const ref = { current: drawer };
489
+ createMockContent();
490
+ const { runAllFrames } = setupAnimationFrameMock();
491
+
492
+ const { result, rerender } = renderHook(
493
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
494
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
495
+ );
496
+
497
+ // Trigger open
498
+ act(() => {
499
+ rerender(createDefaultOptions(ref, { isOpen: true }));
500
+ });
501
+
502
+ // Run animation to completion
503
+ act(() => {
504
+ runAllFrames();
505
+ });
506
+
507
+ expect(result.current.phase).toBe("open");
508
+ expect(result.current.isAnimating).toBe(false);
509
+ });
510
+ });
511
+
512
+ describe("Stable Closed State", () => {
513
+ it("drawer is hidden after closing animation completes", () => {
514
+ const drawer = createMockDrawer();
515
+ const ref = { current: drawer };
516
+ createMockContent();
517
+ const { runAllFrames } = setupAnimationFrameMock();
518
+
519
+ const { rerender } = renderHook(
520
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
521
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
522
+ );
523
+
524
+ // Trigger close
525
+ act(() => {
526
+ rerender(createDefaultOptions(ref, { isOpen: false }));
527
+ });
528
+
529
+ // Run animation to completion
530
+ act(() => {
531
+ runAllFrames();
532
+ });
533
+
534
+ expect(drawer.style.visibility).toBe("hidden");
535
+ expect(drawer.style.pointerEvents).toBe("none");
536
+ });
537
+
538
+ it("content transform is cleared after closing animation completes", () => {
539
+ const drawer = createMockDrawer();
540
+ const ref = { current: drawer };
541
+ const content = createMockContent();
542
+ const { runAllFrames } = setupAnimationFrameMock();
543
+
544
+ const { rerender } = renderHook(
545
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
546
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
547
+ );
548
+
549
+ // Trigger close
550
+ act(() => {
551
+ rerender(createDefaultOptions(ref, { isOpen: false }));
552
+ });
553
+
554
+ // Run animation to completion
555
+ act(() => {
556
+ runAllFrames();
557
+ });
558
+
559
+ expect(content.style.transform).toBe("");
560
+ });
561
+
562
+ it("stacking context is cleared after closing animation completes", () => {
563
+ const drawer = createMockDrawer();
564
+ const ref = { current: drawer };
565
+ const content = createMockContent();
566
+ const { runAllFrames } = setupAnimationFrameMock();
567
+
568
+ const { rerender } = renderHook(
569
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
570
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
571
+ );
572
+
573
+ // Trigger close
574
+ act(() => {
575
+ rerender(createDefaultOptions(ref, { isOpen: false }));
576
+ });
577
+
578
+ // Run animation to completion
579
+ act(() => {
580
+ runAllFrames();
581
+ });
582
+
583
+ expect(content.style.position).toBe("");
584
+ expect(content.style.zIndex).toBe("");
585
+ expect(content.style.background).toBe("");
586
+ });
587
+
588
+ it("phase becomes closed after closing animation completes", () => {
589
+ const drawer = createMockDrawer();
590
+ const ref = { current: drawer };
591
+ createMockContent();
592
+ const { runAllFrames } = setupAnimationFrameMock();
593
+
594
+ const { result, rerender } = renderHook(
595
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
596
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
597
+ );
598
+
599
+ // Trigger close
600
+ act(() => {
601
+ rerender(createDefaultOptions(ref, { isOpen: false }));
602
+ });
603
+
604
+ // Run animation to completion
605
+ act(() => {
606
+ runAllFrames();
607
+ });
608
+
609
+ expect(result.current.phase).toBe("closed");
610
+ expect(result.current.isAnimating).toBe(false);
611
+ });
612
+ });
613
+
614
+ // --------------------------------------------------------------------------
615
+ // Non-Swipe State Change Tests (Button clicks)
616
+ // --------------------------------------------------------------------------
617
+
618
+ describe("Non-Swipe State Changes (Button)", () => {
619
+ it("animates from closed to open when isOpen changes to true", () => {
620
+ const drawer = createMockDrawer();
621
+ const ref = { current: drawer };
622
+ createMockContent();
623
+ setupAnimationFrameMock();
624
+
625
+ const { result, rerender } = renderHook(
626
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
627
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
628
+ );
629
+
630
+ // Click open button
631
+ act(() => {
632
+ rerender(createDefaultOptions(ref, { isOpen: true }));
633
+ });
634
+
635
+ expect(result.current.phase).toBe("opening");
636
+ expect(result.current.isAnimating).toBe(true);
637
+ expect(drawer.style.visibility).toBe("visible");
638
+ });
639
+
640
+ it("animates from open to closed when isOpen changes to false", () => {
641
+ const drawer = createMockDrawer();
642
+ const ref = { current: drawer };
643
+ createMockContent();
644
+ setupAnimationFrameMock();
645
+
646
+ const { result, rerender } = renderHook(
647
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
648
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
649
+ );
650
+
651
+ // Click close button
652
+ act(() => {
653
+ rerender(createDefaultOptions(ref, { isOpen: false }));
654
+ });
655
+
656
+ expect(result.current.phase).toBe("closing");
657
+ expect(result.current.isAnimating).toBe(true);
658
+ });
659
+ });
660
+
661
+ // --------------------------------------------------------------------------
662
+ // Closing via Swipe Tests
663
+ // --------------------------------------------------------------------------
664
+
665
+ describe("Closing via Swipe", () => {
666
+ it("drawer transform updates during close swipe", () => {
667
+ const drawer = createMockDrawer();
668
+ const ref = { current: drawer };
669
+ createMockContent();
670
+
671
+ const { rerender } = renderHook(
672
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
673
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
674
+ );
675
+
676
+ // Start closing swipe
677
+ rerender(createDefaultOptions(ref, {
678
+ isOpen: true,
679
+ swipeState: createOperatingSwipeState({ x: -50, y: 0 }),
680
+ displacement: 50, // Displacement is absolute
681
+ isClosing: true,
682
+ }));
683
+
684
+ expect(drawer.style.transform).not.toBe("");
685
+ });
686
+
687
+ it("animates to closed when close swipe crosses threshold", () => {
688
+ const drawer = createMockDrawer();
689
+ const ref = { current: drawer };
690
+ createMockContent();
691
+ setupAnimationFrameMock();
692
+
693
+ const { result, rerender } = renderHook(
694
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
695
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
696
+ );
697
+
698
+ // Swipe past threshold
699
+ rerender(createDefaultOptions(ref, {
700
+ isOpen: true,
701
+ swipeState: createOperatingSwipeState({ x: -150, y: 0 }),
702
+ displacement: 150,
703
+ isClosing: true,
704
+ }));
705
+
706
+ // End swipe with isOpen changing to false
707
+ act(() => {
708
+ rerender(createDefaultOptions(ref, {
709
+ isOpen: false,
710
+ swipeState: createIdleSwipeState(),
711
+ displacement: 0,
712
+ isOpening: false,
713
+ isClosing: false,
714
+ }));
715
+ });
716
+
717
+ expect(result.current.phase).toBe("closing");
718
+ expect(result.current.isAnimating).toBe(true);
719
+ });
720
+
721
+ it("animates back to open when close swipe does not cross threshold", () => {
722
+ const drawer = createMockDrawer();
723
+ const ref = { current: drawer };
724
+ createMockContent();
725
+ setupAnimationFrameMock();
726
+
727
+ const { result, rerender } = renderHook(
728
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
729
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
730
+ );
731
+
732
+ // Swipe just a bit
733
+ rerender(createDefaultOptions(ref, {
734
+ isOpen: true,
735
+ swipeState: createOperatingSwipeState({ x: -30, y: 0 }),
736
+ displacement: 30,
737
+ isClosing: true,
738
+ }));
739
+
740
+ // End swipe - isOpen stays true
741
+ act(() => {
742
+ rerender(createDefaultOptions(ref, {
743
+ isOpen: true,
744
+ swipeState: createIdleSwipeState(),
745
+ displacement: 0,
746
+ isOpening: false,
747
+ isClosing: false,
748
+ }));
749
+ });
750
+
751
+ expect(result.current.phase).toBe("opening");
752
+ expect(result.current.isAnimating).toBe(true);
753
+ });
754
+ });
755
+
756
+ // --------------------------------------------------------------------------
757
+ // Edge Cases
758
+ // --------------------------------------------------------------------------
759
+
760
+ describe("Edge Cases", () => {
761
+ it("handles disabled state correctly", () => {
762
+ const drawer = createMockDrawer();
763
+ const ref = { current: drawer };
764
+ createMockContent();
765
+
766
+ const { rerender } = renderHook(
767
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
768
+ { initialProps: createDefaultOptions(ref, { isOpen: false, enabled: true }) },
769
+ );
770
+
771
+ // Disable the hook
772
+ rerender(createDefaultOptions(ref, { isOpen: false, enabled: false }));
773
+
774
+ // Transform should be cleared, but visibility stays hidden
775
+ // (disabled drawer should not become visible)
776
+ expect(drawer.style.transform).toBe("");
777
+ expect(drawer.style.visibility).toBe("hidden");
778
+ });
779
+
780
+ it("handles null drawer ref gracefully", () => {
781
+ const nullRef: React.RefObject<HTMLElement | null> = { current: null };
782
+ createMockContent();
783
+
784
+ const { result } = renderHook(() =>
785
+ useRevealDrawerTransform(createDefaultOptions(nullRef, { isOpen: false })),
786
+ );
787
+
788
+ // Should not throw and have default state
789
+ expect(result.current.phase).toBe("closed");
790
+ expect(result.current.isAnimating).toBe(false);
791
+ });
792
+
793
+ it("handles content element not found gracefully", () => {
794
+ const drawer = createMockDrawer();
795
+ const ref = { current: drawer };
796
+ // Don't create content element
797
+
798
+ const { result } = renderHook(() =>
799
+ useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: false })),
800
+ );
801
+
802
+ // Should not throw
803
+ expect(result.current.phase).toBe("closed");
804
+ });
805
+
806
+ it("handles rapid open/close toggles", () => {
807
+ const drawer = createMockDrawer();
808
+ const ref = { current: drawer };
809
+ createMockContent();
810
+ setupAnimationFrameMock();
811
+
812
+ const { result, rerender } = renderHook(
813
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
814
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
815
+ );
816
+
817
+ // Rapid toggles
818
+ act(() => {
819
+ rerender(createDefaultOptions(ref, { isOpen: true }));
820
+ });
821
+ act(() => {
822
+ rerender(createDefaultOptions(ref, { isOpen: false }));
823
+ });
824
+ act(() => {
825
+ rerender(createDefaultOptions(ref, { isOpen: true }));
826
+ });
827
+
828
+ // Should handle without error and be in some valid state
829
+ expect(["opening", "closing", "open", "closed"]).toContain(result.current.phase);
830
+ });
831
+
832
+ it("applies overflow hidden during animation", () => {
833
+ const drawer = createMockDrawer();
834
+ const ref = { current: drawer };
835
+ createMockContent();
836
+ setupAnimationFrameMock();
837
+
838
+ renderHook(
839
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
840
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
841
+ );
842
+
843
+ // Body should have overflow hidden when open
844
+ expect(document.body.style.overflow).toBe("hidden");
845
+ });
846
+ });
847
+
848
+ // --------------------------------------------------------------------------
849
+ // Abnormal User Behavior Tests (Edge cases for unusual interactions)
850
+ // --------------------------------------------------------------------------
851
+
852
+ describe("Abnormal User Behaviors", () => {
853
+ describe("Opening attempt then give up", () => {
854
+ it("animates back to closed when user starts opening but gives up before threshold", () => {
855
+ const drawer = createMockDrawer();
856
+ const ref = { current: drawer };
857
+ createMockContent();
858
+ setupAnimationFrameMock();
859
+
860
+ const { result, rerender } = renderHook(
861
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
862
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
863
+ );
864
+
865
+ // Start opening swipe
866
+ rerender(createDefaultOptions(ref, {
867
+ isOpen: false,
868
+ swipeState: createOperatingSwipeState({ x: 80, y: 0 }),
869
+ displacement: 80, // ~27% of 300, below threshold
870
+ isOpening: true,
871
+ }));
872
+
873
+ expect(drawer.style.visibility).toBe("visible");
874
+ const transformDuringSwipe = drawer.style.transform;
875
+ expect(transformDuringSwipe).not.toBe("");
876
+
877
+ // User gives up - releases without crossing threshold
878
+ act(() => {
879
+ rerender(createDefaultOptions(ref, {
880
+ isOpen: false, // Stays closed
881
+ swipeState: createIdleSwipeState(),
882
+ displacement: 0,
883
+ isOpening: false,
884
+ isClosing: false,
885
+ }));
886
+ });
887
+
888
+ // Should animate back to closed
889
+ expect(result.current.phase).toBe("closing");
890
+ expect(result.current.isAnimating).toBe(true);
891
+ });
892
+
893
+ it("drawer becomes hidden after give-up animation completes", () => {
894
+ const drawer = createMockDrawer();
895
+ const ref = { current: drawer };
896
+ createMockContent();
897
+ const { runAllFrames } = setupAnimationFrameMock();
898
+
899
+ const { result, rerender } = renderHook(
900
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
901
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
902
+ );
903
+
904
+ // Start then give up
905
+ rerender(createDefaultOptions(ref, {
906
+ isOpen: false,
907
+ swipeState: createOperatingSwipeState({ x: 50, y: 0 }),
908
+ displacement: 50,
909
+ isOpening: true,
910
+ }));
911
+
912
+ act(() => {
913
+ rerender(createDefaultOptions(ref, {
914
+ isOpen: false,
915
+ swipeState: createIdleSwipeState(),
916
+ displacement: 0,
917
+ isOpening: false,
918
+ isClosing: false,
919
+ }));
920
+ });
921
+
922
+ // Run animation to completion
923
+ act(() => {
924
+ runAllFrames();
925
+ });
926
+
927
+ expect(result.current.phase).toBe("closed");
928
+ expect(drawer.style.visibility).toBe("hidden");
929
+ });
930
+ });
931
+
932
+ describe("Closing attempt then give up", () => {
933
+ it("animates back to open when user starts closing but gives up before threshold", () => {
934
+ const drawer = createMockDrawer();
935
+ const ref = { current: drawer };
936
+ createMockContent();
937
+ setupAnimationFrameMock();
938
+
939
+ const { result, rerender } = renderHook(
940
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
941
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
942
+ );
943
+
944
+ // Start closing swipe
945
+ rerender(createDefaultOptions(ref, {
946
+ isOpen: true,
947
+ swipeState: createOperatingSwipeState({ x: -50, y: 0 }),
948
+ displacement: 50, // Below 30% threshold
949
+ isClosing: true,
950
+ }));
951
+
952
+ // User gives up - releases without crossing threshold
953
+ act(() => {
954
+ rerender(createDefaultOptions(ref, {
955
+ isOpen: true, // Stays open
956
+ swipeState: createIdleSwipeState(),
957
+ displacement: 0,
958
+ isOpening: false,
959
+ isClosing: false,
960
+ }));
961
+ });
962
+
963
+ // Should animate back to open
964
+ expect(result.current.phase).toBe("opening");
965
+ expect(result.current.isAnimating).toBe(true);
966
+ });
967
+
968
+ it("drawer remains visible after give-up animation completes", () => {
969
+ const drawer = createMockDrawer();
970
+ const ref = { current: drawer };
971
+ createMockContent();
972
+ const { runAllFrames } = setupAnimationFrameMock();
973
+
974
+ const { result, rerender } = renderHook(
975
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
976
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
977
+ );
978
+
979
+ // Start closing then give up
980
+ rerender(createDefaultOptions(ref, {
981
+ isOpen: true,
982
+ swipeState: createOperatingSwipeState({ x: -40, y: 0 }),
983
+ displacement: 40,
984
+ isClosing: true,
985
+ }));
986
+
987
+ act(() => {
988
+ rerender(createDefaultOptions(ref, {
989
+ isOpen: true,
990
+ swipeState: createIdleSwipeState(),
991
+ displacement: 0,
992
+ isOpening: false,
993
+ isClosing: false,
994
+ }));
995
+ });
996
+
997
+ // Run animation to completion
998
+ act(() => {
999
+ runAllFrames();
1000
+ });
1001
+
1002
+ expect(result.current.phase).toBe("open");
1003
+ expect(drawer.style.visibility).toBe("visible");
1004
+ });
1005
+ });
1006
+
1007
+ describe("Incremental small movements (jittery swipe)", () => {
1008
+ it("tracks position correctly during jittery opening swipe", () => {
1009
+ const drawer = createMockDrawer();
1010
+ const ref = { current: drawer };
1011
+ createMockContent();
1012
+
1013
+ const { rerender } = renderHook(
1014
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1015
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
1016
+ );
1017
+
1018
+ // Small incremental movements
1019
+ const positions = [10, 15, 12, 20, 18, 25, 30, 28, 35];
1020
+ const transforms: string[] = [];
1021
+
1022
+ for (const pos of positions) {
1023
+ rerender(createDefaultOptions(ref, {
1024
+ isOpen: false,
1025
+ swipeState: createOperatingSwipeState({ x: pos, y: 0 }),
1026
+ displacement: pos,
1027
+ isOpening: true,
1028
+ }));
1029
+ transforms.push(drawer.style.transform);
1030
+ }
1031
+
1032
+ // Each position should have a valid transform
1033
+ for (const transform of transforms) {
1034
+ expect(transform).not.toBe("");
1035
+ expect(transform).toContain("translate");
1036
+ }
1037
+ });
1038
+
1039
+ it("tracks position correctly during jittery closing swipe then give up", () => {
1040
+ const drawer = createMockDrawer();
1041
+ const ref = { current: drawer };
1042
+ createMockContent();
1043
+ setupAnimationFrameMock();
1044
+
1045
+ const { result, rerender } = renderHook(
1046
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1047
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
1048
+ );
1049
+
1050
+ // Jittery closing movements
1051
+ const positions = [10, 15, 12, 20, 18, 25, 20, 15, 10];
1052
+
1053
+ for (const pos of positions) {
1054
+ rerender(createDefaultOptions(ref, {
1055
+ isOpen: true,
1056
+ swipeState: createOperatingSwipeState({ x: -pos, y: 0 }),
1057
+ displacement: pos,
1058
+ isClosing: true,
1059
+ }));
1060
+ }
1061
+
1062
+ // Give up - didn't cross threshold
1063
+ act(() => {
1064
+ rerender(createDefaultOptions(ref, {
1065
+ isOpen: true,
1066
+ swipeState: createIdleSwipeState(),
1067
+ displacement: 0,
1068
+ isOpening: false,
1069
+ isClosing: false,
1070
+ }));
1071
+ });
1072
+
1073
+ // Should animate back to open
1074
+ expect(result.current.phase).toBe("opening");
1075
+ expect(result.current.isAnimating).toBe(true);
1076
+ });
1077
+ });
1078
+
1079
+ describe("Overshoot then reverse", () => {
1080
+ it("handles overshoot opening then reverse to close", () => {
1081
+ const drawer = createMockDrawer();
1082
+ const ref = { current: drawer };
1083
+ createMockContent();
1084
+ setupAnimationFrameMock();
1085
+
1086
+ const { result, rerender } = renderHook(
1087
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1088
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
1089
+ );
1090
+
1091
+ // Overshoot past threshold (past 100%)
1092
+ rerender(createDefaultOptions(ref, {
1093
+ isOpen: false,
1094
+ swipeState: createOperatingSwipeState({ x: 350, y: 0 }),
1095
+ displacement: 350, // More than drawer size (300)
1096
+ isOpening: true,
1097
+ }));
1098
+
1099
+ expect(drawer.style.visibility).toBe("visible");
1100
+
1101
+ // Pull back below threshold
1102
+ rerender(createDefaultOptions(ref, {
1103
+ isOpen: false,
1104
+ swipeState: createOperatingSwipeState({ x: 50, y: 0 }),
1105
+ displacement: 50, // Back below threshold
1106
+ isOpening: true,
1107
+ }));
1108
+
1109
+ // Release - should close because below threshold
1110
+ act(() => {
1111
+ rerender(createDefaultOptions(ref, {
1112
+ isOpen: false, // Didn't cross threshold on release
1113
+ swipeState: createIdleSwipeState(),
1114
+ displacement: 0,
1115
+ isOpening: false,
1116
+ isClosing: false,
1117
+ }));
1118
+ });
1119
+
1120
+ // Should animate to closed
1121
+ expect(result.current.phase).toBe("closing");
1122
+ expect(result.current.isAnimating).toBe(true);
1123
+ });
1124
+
1125
+ it("handles overshoot closing then reverse to stay open", () => {
1126
+ const drawer = createMockDrawer();
1127
+ const ref = { current: drawer };
1128
+ createMockContent();
1129
+ setupAnimationFrameMock();
1130
+
1131
+ const { result, rerender } = renderHook(
1132
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1133
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
1134
+ );
1135
+
1136
+ // Overshoot past close threshold
1137
+ rerender(createDefaultOptions(ref, {
1138
+ isOpen: true,
1139
+ swipeState: createOperatingSwipeState({ x: -200, y: 0 }),
1140
+ displacement: 200, // Well past 30% threshold
1141
+ isClosing: true,
1142
+ }));
1143
+
1144
+ // Pull back below threshold
1145
+ rerender(createDefaultOptions(ref, {
1146
+ isOpen: true,
1147
+ swipeState: createOperatingSwipeState({ x: -30, y: 0 }),
1148
+ displacement: 30, // Back below threshold
1149
+ isClosing: true,
1150
+ }));
1151
+
1152
+ // Release - should stay open because below threshold
1153
+ act(() => {
1154
+ rerender(createDefaultOptions(ref, {
1155
+ isOpen: true, // Stays open
1156
+ swipeState: createIdleSwipeState(),
1157
+ displacement: 0,
1158
+ isOpening: false,
1159
+ isClosing: false,
1160
+ }));
1161
+ });
1162
+
1163
+ // Should animate back to open
1164
+ expect(result.current.phase).toBe("opening");
1165
+ expect(result.current.isAnimating).toBe(true);
1166
+ });
1167
+
1168
+ it("preserves transform continuity during overshoot and reverse", () => {
1169
+ const drawer = createMockDrawer();
1170
+ const ref = { current: drawer };
1171
+ createMockContent();
1172
+
1173
+ const { rerender } = renderHook(
1174
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1175
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
1176
+ );
1177
+
1178
+ // Track transforms during the sequence
1179
+ const sequence = [
1180
+ { displacement: 100, expected: "moving forward" },
1181
+ { displacement: 200, expected: "more forward" },
1182
+ { displacement: 300, expected: "at full open" },
1183
+ { displacement: 350, expected: "overshoot" },
1184
+ { displacement: 300, expected: "pulling back" },
1185
+ { displacement: 200, expected: "more back" },
1186
+ { displacement: 100, expected: "back to 1/3" },
1187
+ { displacement: 50, expected: "below threshold" },
1188
+ ];
1189
+
1190
+ const transforms: string[] = [];
1191
+
1192
+ for (const step of sequence) {
1193
+ rerender(createDefaultOptions(ref, {
1194
+ isOpen: false,
1195
+ swipeState: createOperatingSwipeState({ x: step.displacement, y: 0 }),
1196
+ displacement: step.displacement,
1197
+ isOpening: true,
1198
+ }));
1199
+ transforms.push(drawer.style.transform);
1200
+ }
1201
+
1202
+ // All transforms should be valid (no empty strings or undefined)
1203
+ for (const transform of transforms) {
1204
+ expect(transform).not.toBe("");
1205
+ expect(transform).toContain("translateX");
1206
+ }
1207
+
1208
+ // Transforms should actually change with displacement
1209
+ const uniqueTransforms = new Set(transforms);
1210
+ expect(uniqueTransforms.size).toBeGreaterThan(1);
1211
+ });
1212
+ });
1213
+
1214
+ describe("Interrupted animations", () => {
1215
+ it("handles swipe interrupting an opening animation", () => {
1216
+ const drawer = createMockDrawer();
1217
+ const ref = { current: drawer };
1218
+ createMockContent();
1219
+ setupAnimationFrameMock();
1220
+
1221
+ const { result, rerender } = renderHook(
1222
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1223
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
1224
+ );
1225
+
1226
+ // Start opening via button
1227
+ act(() => {
1228
+ rerender(createDefaultOptions(ref, { isOpen: true }));
1229
+ });
1230
+
1231
+ expect(result.current.isAnimating).toBe(true);
1232
+
1233
+ // User starts swiping during animation
1234
+ rerender(createDefaultOptions(ref, {
1235
+ isOpen: true,
1236
+ swipeState: createOperatingSwipeState({ x: -50, y: 0 }),
1237
+ displacement: 50,
1238
+ isClosing: true,
1239
+ }));
1240
+
1241
+ // Animation should be cancelled during operation
1242
+ expect(result.current.isAnimating).toBe(false);
1243
+ });
1244
+
1245
+ it("handles swipe interrupting a closing animation", () => {
1246
+ const drawer = createMockDrawer();
1247
+ const ref = { current: drawer };
1248
+ createMockContent();
1249
+ setupAnimationFrameMock();
1250
+
1251
+ const { result, rerender } = renderHook(
1252
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1253
+ { initialProps: createDefaultOptions(ref, { isOpen: true }) },
1254
+ );
1255
+
1256
+ // Start closing via button
1257
+ act(() => {
1258
+ rerender(createDefaultOptions(ref, { isOpen: false }));
1259
+ });
1260
+
1261
+ expect(result.current.isAnimating).toBe(true);
1262
+
1263
+ // User starts swiping during animation (trying to reopen)
1264
+ rerender(createDefaultOptions(ref, {
1265
+ isOpen: false,
1266
+ swipeState: createOperatingSwipeState({ x: 100, y: 0 }),
1267
+ displacement: 100,
1268
+ isOpening: true,
1269
+ }));
1270
+
1271
+ // Animation should be cancelled during operation
1272
+ expect(result.current.isAnimating).toBe(false);
1273
+ });
1274
+ });
1275
+
1276
+ describe("Multiple direction changes during single gesture", () => {
1277
+ it("handles back-and-forth movement during opening", () => {
1278
+ const drawer = createMockDrawer();
1279
+ const ref = { current: drawer };
1280
+ createMockContent();
1281
+
1282
+ const { rerender } = renderHook(
1283
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1284
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
1285
+ );
1286
+
1287
+ // Simulate back-and-forth user movement
1288
+ const movements = [50, 100, 80, 150, 120, 200, 180, 250, 200, 280];
1289
+
1290
+ for (const displacement of movements) {
1291
+ rerender(createDefaultOptions(ref, {
1292
+ isOpen: false,
1293
+ swipeState: createOperatingSwipeState({ x: displacement, y: 0 }),
1294
+ displacement,
1295
+ isOpening: true,
1296
+ }));
1297
+
1298
+ // Should always have valid transform
1299
+ expect(drawer.style.transform).not.toBe("");
1300
+ expect(drawer.style.visibility).toBe("visible");
1301
+ }
1302
+ });
1303
+ });
1304
+
1305
+ describe("Edge: Very small movements", () => {
1306
+ it("handles micro-movements without breaking", () => {
1307
+ const drawer = createMockDrawer();
1308
+ const ref = { current: drawer };
1309
+ createMockContent();
1310
+ setupAnimationFrameMock();
1311
+
1312
+ const { result, rerender } = renderHook(
1313
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1314
+ { initialProps: createDefaultOptions(ref, { isOpen: false }) },
1315
+ );
1316
+
1317
+ // Very small movements (1-5 pixels)
1318
+ for (let i = 1; i <= 5; i++) {
1319
+ rerender(createDefaultOptions(ref, {
1320
+ isOpen: false,
1321
+ swipeState: createOperatingSwipeState({ x: i, y: 0 }),
1322
+ displacement: i,
1323
+ isOpening: true,
1324
+ }));
1325
+ }
1326
+
1327
+ // Release with tiny displacement
1328
+ act(() => {
1329
+ rerender(createDefaultOptions(ref, {
1330
+ isOpen: false,
1331
+ swipeState: createIdleSwipeState(),
1332
+ displacement: 0,
1333
+ isOpening: false,
1334
+ isClosing: false,
1335
+ }));
1336
+ });
1337
+
1338
+ // Should handle gracefully
1339
+ expect(["closing", "closed"]).toContain(result.current.phase);
1340
+ });
1341
+ });
1342
+
1343
+ describe("Edge: Maximum displacement (beyond drawer size)", () => {
1344
+ it("handles displacement larger than drawer size", () => {
1345
+ const drawer = createMockDrawer();
1346
+ const ref = { current: drawer };
1347
+ createMockContent();
1348
+ setupAnimationFrameMock();
1349
+
1350
+ const { result, rerender } = renderHook(
1351
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1352
+ { initialProps: createDefaultOptions(ref, { isOpen: false, drawerSize: 300 }) },
1353
+ );
1354
+
1355
+ // Displacement way beyond drawer size
1356
+ rerender(createDefaultOptions(ref, {
1357
+ isOpen: false,
1358
+ drawerSize: 300,
1359
+ swipeState: createOperatingSwipeState({ x: 500, y: 0 }),
1360
+ displacement: 500, // 167% of drawer size
1361
+ isOpening: true,
1362
+ }));
1363
+
1364
+ // Should handle without error
1365
+ expect(drawer.style.transform).not.toBe("");
1366
+
1367
+ // Release
1368
+ act(() => {
1369
+ rerender(createDefaultOptions(ref, {
1370
+ isOpen: true, // Threshold crossed
1371
+ drawerSize: 300,
1372
+ swipeState: createIdleSwipeState(),
1373
+ displacement: 0,
1374
+ isOpening: false,
1375
+ isClosing: false,
1376
+ }));
1377
+ });
1378
+
1379
+ // When overshoot > 100%, we're already at target, so may snap directly to "open"
1380
+ // or animate if there's a small distance
1381
+ expect(["opening", "open"]).toContain(result.current.phase);
1382
+ });
1383
+ });
1384
+
1385
+ // =========================================================================
1386
+ // Overshoot Issues (Critical bugs to fix)
1387
+ // =========================================================================
1388
+
1389
+ describe("Overshoot Issues", () => {
1390
+ describe("Opening with overshoot should stay open", () => {
1391
+ it("drawer stays open when released at overshoot position", () => {
1392
+ const drawer = createMockDrawer();
1393
+ const ref = { current: drawer };
1394
+ createMockContent();
1395
+ const { runAllFrames } = setupAnimationFrameMock();
1396
+
1397
+ const { result, rerender } = renderHook(
1398
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1399
+ { initialProps: createDefaultOptions(ref, { isOpen: false, drawerSize: 300 }) },
1400
+ );
1401
+
1402
+ // Overshoot past 100% - swipe to 400px when drawer is 300px
1403
+ rerender(createDefaultOptions(ref, {
1404
+ isOpen: false,
1405
+ drawerSize: 300,
1406
+ swipeState: createOperatingSwipeState({ x: 400, y: 0 }),
1407
+ displacement: 400,
1408
+ isOpening: true,
1409
+ }));
1410
+
1411
+ // Drawer should be fully visible (progress clamped to 1)
1412
+ expect(drawer.style.visibility).toBe("visible");
1413
+
1414
+ // Release at overshoot position - isOpen should become true
1415
+ act(() => {
1416
+ rerender(createDefaultOptions(ref, {
1417
+ isOpen: true, // Threshold definitely crossed
1418
+ drawerSize: 300,
1419
+ swipeState: createIdleSwipeState(),
1420
+ displacement: 0,
1421
+ isOpening: false,
1422
+ isClosing: false,
1423
+ }));
1424
+ });
1425
+
1426
+ // When at overshoot position, we're already at target, so may snap to "open" directly
1427
+ // The key assertion: drawer should NOT be closing
1428
+ expect(["opening", "open"]).toContain(result.current.phase);
1429
+ expect(drawer.style.visibility).toBe("visible");
1430
+
1431
+ // Run animation to completion (if any)
1432
+ act(() => {
1433
+ runAllFrames();
1434
+ });
1435
+
1436
+ // Final state should be open
1437
+ expect(result.current.phase).toBe("open");
1438
+ expect(drawer.style.visibility).toBe("visible");
1439
+ });
1440
+
1441
+ it("drawer transform is at open position after overshoot release", () => {
1442
+ const drawer = createMockDrawer();
1443
+ const ref = { current: drawer };
1444
+ createMockContent();
1445
+ const { runAllFrames } = setupAnimationFrameMock();
1446
+
1447
+ const { rerender } = renderHook(
1448
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1449
+ { initialProps: createDefaultOptions(ref, { isOpen: false, drawerSize: 300 }) },
1450
+ );
1451
+
1452
+ // Overshoot
1453
+ rerender(createDefaultOptions(ref, {
1454
+ isOpen: false,
1455
+ drawerSize: 300,
1456
+ swipeState: createOperatingSwipeState({ x: 350, y: 0 }),
1457
+ displacement: 350,
1458
+ isOpening: true,
1459
+ }));
1460
+
1461
+ // Release
1462
+ act(() => {
1463
+ rerender(createDefaultOptions(ref, {
1464
+ isOpen: true,
1465
+ drawerSize: 300,
1466
+ swipeState: createIdleSwipeState(),
1467
+ displacement: 0,
1468
+ isOpening: false,
1469
+ isClosing: false,
1470
+ }));
1471
+ });
1472
+
1473
+ // Run to completion
1474
+ act(() => {
1475
+ runAllFrames();
1476
+ });
1477
+
1478
+ // Drawer should be at 0px (fully open) for left placement
1479
+ expect(drawer.style.transform).toBe("translateX(0px)");
1480
+ });
1481
+
1482
+ it("content is offset by drawer size after overshoot open", () => {
1483
+ const drawer = createMockDrawer();
1484
+ const ref = { current: drawer };
1485
+ const content = createMockContent();
1486
+ const { runAllFrames } = setupAnimationFrameMock();
1487
+
1488
+ const { rerender } = renderHook(
1489
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1490
+ { initialProps: createDefaultOptions(ref, { isOpen: false, drawerSize: 300 }) },
1491
+ );
1492
+
1493
+ // Overshoot and release
1494
+ rerender(createDefaultOptions(ref, {
1495
+ isOpen: false,
1496
+ drawerSize: 300,
1497
+ swipeState: createOperatingSwipeState({ x: 400, y: 0 }),
1498
+ displacement: 400,
1499
+ isOpening: true,
1500
+ }));
1501
+
1502
+ act(() => {
1503
+ rerender(createDefaultOptions(ref, {
1504
+ isOpen: true,
1505
+ drawerSize: 300,
1506
+ swipeState: createIdleSwipeState(),
1507
+ displacement: 0,
1508
+ isOpening: false,
1509
+ isClosing: false,
1510
+ }));
1511
+ });
1512
+
1513
+ act(() => {
1514
+ runAllFrames();
1515
+ });
1516
+
1517
+ // Content should be offset by 300px
1518
+ expect(content.style.transform).toBe("translateX(300px)");
1519
+ });
1520
+ });
1521
+
1522
+ describe("Closing with overshoot should not cause content jump", () => {
1523
+ it("content position is correct during and after overshoot close", () => {
1524
+ const drawer = createMockDrawer();
1525
+ const ref = { current: drawer };
1526
+ const content = createMockContent();
1527
+ setupAnimationFrameMock();
1528
+
1529
+ const { result, rerender } = renderHook(
1530
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1531
+ { initialProps: createDefaultOptions(ref, { isOpen: true, drawerSize: 300 }) },
1532
+ );
1533
+
1534
+ // Overshoot close - swipe past the drawer size
1535
+ rerender(createDefaultOptions(ref, {
1536
+ isOpen: true,
1537
+ drawerSize: 300,
1538
+ swipeState: createOperatingSwipeState({ x: -350, y: 0 }),
1539
+ displacement: 350, // Overshoot past drawer size
1540
+ isClosing: true,
1541
+ }));
1542
+
1543
+ // Content should be at 0px (closed position) due to progress clamping
1544
+ // Progress = 1 - 350/300 = clamped to 0
1545
+ const duringOvershootTransform = content.style.transform;
1546
+ expect(duringOvershootTransform).toBe("translateX(0px)");
1547
+
1548
+ // Release at overshoot
1549
+ act(() => {
1550
+ rerender(createDefaultOptions(ref, {
1551
+ isOpen: false, // Threshold crossed
1552
+ drawerSize: 300,
1553
+ swipeState: createIdleSwipeState(),
1554
+ displacement: 0,
1555
+ isOpening: false,
1556
+ isClosing: false,
1557
+ }));
1558
+ });
1559
+
1560
+ // Since we're already at target (0px), should go directly to closed
1561
+ // No animation needed - distance is 0
1562
+ expect(result.current.phase).toBe("closed");
1563
+
1564
+ // Content transform should be cleared (we were already at target)
1565
+ expect(content.style.transform).toBe("");
1566
+ });
1567
+
1568
+ it("content does not jump when closing with partial overshoot", () => {
1569
+ const drawer = createMockDrawer();
1570
+ const ref = { current: drawer };
1571
+ const content = createMockContent();
1572
+ const { runFrame } = setupAnimationFrameMock();
1573
+
1574
+ const { result, rerender } = renderHook(
1575
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1576
+ { initialProps: createDefaultOptions(ref, { isOpen: true, drawerSize: 300 }) },
1577
+ );
1578
+
1579
+ // Close swipe not quite at overshoot - should still animate
1580
+ rerender(createDefaultOptions(ref, {
1581
+ isOpen: true,
1582
+ drawerSize: 300,
1583
+ swipeState: createOperatingSwipeState({ x: -250, y: 0 }),
1584
+ displacement: 250,
1585
+ isClosing: true,
1586
+ }));
1587
+
1588
+ // Progress = 1 - 250/300 ≈ 0.167, content at ~50px
1589
+ const duringSwipeTransform = content.style.transform;
1590
+ // Use regex to match approximately 50px (floating point precision)
1591
+ expect(duringSwipeTransform).toMatch(/translateX\(49\.9+|translateX\(50/);
1592
+
1593
+ // Release
1594
+ act(() => {
1595
+ rerender(createDefaultOptions(ref, {
1596
+ isOpen: false,
1597
+ drawerSize: 300,
1598
+ swipeState: createIdleSwipeState(),
1599
+ displacement: 0,
1600
+ isOpening: false,
1601
+ isClosing: false,
1602
+ }));
1603
+ });
1604
+
1605
+ // Should be animating (distance = 50 > 1)
1606
+ expect(result.current.phase).toBe("closing");
1607
+ expect(result.current.isAnimating).toBe(true);
1608
+
1609
+ // First animation frame - content should be close to 50px, not jumping
1610
+ act(() => {
1611
+ runFrame();
1612
+ });
1613
+
1614
+ // Content should be animating from ~50px toward 0px
1615
+ // At very start of animation, should be close to starting position
1616
+ const afterFirstFrame = content.style.transform;
1617
+ expect(afterFirstFrame).toContain("px");
1618
+ // Should not have jumped to some random position
1619
+ });
1620
+
1621
+ it("drawer hides correctly after overshoot close completes", () => {
1622
+ const drawer = createMockDrawer();
1623
+ const ref = { current: drawer };
1624
+ createMockContent();
1625
+ const { runAllFrames } = setupAnimationFrameMock();
1626
+
1627
+ const { result, rerender } = renderHook(
1628
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1629
+ { initialProps: createDefaultOptions(ref, { isOpen: true, drawerSize: 300 }) },
1630
+ );
1631
+
1632
+ // Overshoot close
1633
+ rerender(createDefaultOptions(ref, {
1634
+ isOpen: true,
1635
+ drawerSize: 300,
1636
+ swipeState: createOperatingSwipeState({ x: -400, y: 0 }),
1637
+ displacement: 400,
1638
+ isClosing: true,
1639
+ }));
1640
+
1641
+ // Release
1642
+ act(() => {
1643
+ rerender(createDefaultOptions(ref, {
1644
+ isOpen: false,
1645
+ drawerSize: 300,
1646
+ swipeState: createIdleSwipeState(),
1647
+ displacement: 0,
1648
+ isOpening: false,
1649
+ isClosing: false,
1650
+ }));
1651
+ });
1652
+
1653
+ // Run animation
1654
+ act(() => {
1655
+ runAllFrames();
1656
+ });
1657
+
1658
+ // Drawer should be hidden
1659
+ expect(result.current.phase).toBe("closed");
1660
+ expect(drawer.style.visibility).toBe("hidden");
1661
+ });
1662
+
1663
+ it("content transform is cleared after overshoot close completes", () => {
1664
+ const drawer = createMockDrawer();
1665
+ const ref = { current: drawer };
1666
+ const content = createMockContent();
1667
+ const { runAllFrames } = setupAnimationFrameMock();
1668
+
1669
+ const { rerender } = renderHook(
1670
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1671
+ { initialProps: createDefaultOptions(ref, { isOpen: true, drawerSize: 300 }) },
1672
+ );
1673
+
1674
+ // Overshoot and release
1675
+ rerender(createDefaultOptions(ref, {
1676
+ isOpen: true,
1677
+ drawerSize: 300,
1678
+ swipeState: createOperatingSwipeState({ x: -350, y: 0 }),
1679
+ displacement: 350,
1680
+ isClosing: true,
1681
+ }));
1682
+
1683
+ act(() => {
1684
+ rerender(createDefaultOptions(ref, {
1685
+ isOpen: false,
1686
+ drawerSize: 300,
1687
+ swipeState: createIdleSwipeState(),
1688
+ displacement: 0,
1689
+ isOpening: false,
1690
+ isClosing: false,
1691
+ }));
1692
+ });
1693
+
1694
+ act(() => {
1695
+ runAllFrames();
1696
+ });
1697
+
1698
+ // Content transform should be cleared (back to normal)
1699
+ expect(content.style.transform).toBe("");
1700
+ });
1701
+
1702
+ it("no CSS transition on content during close animation", () => {
1703
+ const drawer = createMockDrawer();
1704
+ const ref = { current: drawer };
1705
+ const content = createMockContent();
1706
+ setupAnimationFrameMock();
1707
+
1708
+ const { rerender } = renderHook(
1709
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1710
+ { initialProps: createDefaultOptions(ref, { isOpen: true, drawerSize: 300 }) },
1711
+ );
1712
+
1713
+ // Close swipe
1714
+ rerender(createDefaultOptions(ref, {
1715
+ isOpen: true,
1716
+ drawerSize: 300,
1717
+ swipeState: createOperatingSwipeState({ x: -200, y: 0 }),
1718
+ displacement: 200,
1719
+ isClosing: true,
1720
+ }));
1721
+
1722
+ // During swipe - no transition
1723
+ expect(content.style.transition).toBe("none");
1724
+
1725
+ // Release
1726
+ act(() => {
1727
+ rerender(createDefaultOptions(ref, {
1728
+ isOpen: false,
1729
+ drawerSize: 300,
1730
+ swipeState: createIdleSwipeState(),
1731
+ displacement: 0,
1732
+ isOpening: false,
1733
+ isClosing: false,
1734
+ }));
1735
+ });
1736
+
1737
+ // During animation - still no CSS transition (using RAF)
1738
+ expect(content.style.transition).toBe("none");
1739
+ });
1740
+ });
1741
+ });
1742
+ });
1743
+
1744
+ // --------------------------------------------------------------------------
1745
+ // Placement Tests
1746
+ // --------------------------------------------------------------------------
1747
+
1748
+ describe("Placement Variations", () => {
1749
+ it("uses translateX for left placement", () => {
1750
+ const drawer = createMockDrawer();
1751
+ const ref = { current: drawer };
1752
+ createMockContent();
1753
+
1754
+ const { rerender } = renderHook(
1755
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1756
+ { initialProps: createDefaultOptions(ref, { isOpen: false, placement: "left" }) },
1757
+ );
1758
+
1759
+ rerender(createDefaultOptions(ref, {
1760
+ isOpen: false,
1761
+ placement: "left",
1762
+ swipeState: createOperatingSwipeState({ x: 100, y: 0 }),
1763
+ displacement: 100,
1764
+ isOpening: true,
1765
+ }));
1766
+
1767
+ expect(drawer.style.transform).toContain("translateX");
1768
+ });
1769
+
1770
+ it("uses translateX for right placement", () => {
1771
+ const drawer = createMockDrawer();
1772
+ const ref = { current: drawer };
1773
+ createMockContent();
1774
+
1775
+ const { rerender } = renderHook(
1776
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1777
+ { initialProps: createDefaultOptions(ref, { isOpen: false, placement: "right" }) },
1778
+ );
1779
+
1780
+ rerender(createDefaultOptions(ref, {
1781
+ isOpen: false,
1782
+ placement: "right",
1783
+ swipeState: createOperatingSwipeState({ x: -100, y: 0 }),
1784
+ displacement: 100,
1785
+ isOpening: true,
1786
+ }));
1787
+
1788
+ expect(drawer.style.transform).toContain("translateX");
1789
+ });
1790
+
1791
+ it("uses translateY for top placement", () => {
1792
+ const drawer = createMockDrawer();
1793
+ const ref = { current: drawer };
1794
+ createMockContent();
1795
+
1796
+ const { rerender } = renderHook(
1797
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1798
+ { initialProps: createDefaultOptions(ref, { isOpen: false, placement: "top" }) },
1799
+ );
1800
+
1801
+ rerender(createDefaultOptions(ref, {
1802
+ isOpen: false,
1803
+ placement: "top",
1804
+ swipeState: createOperatingSwipeState({ x: 0, y: 100 }),
1805
+ displacement: 100,
1806
+ isOpening: true,
1807
+ }));
1808
+
1809
+ expect(drawer.style.transform).toContain("translateY");
1810
+ });
1811
+
1812
+ it("uses translateY for bottom placement", () => {
1813
+ const drawer = createMockDrawer();
1814
+ const ref = { current: drawer };
1815
+ createMockContent();
1816
+
1817
+ const { rerender } = renderHook(
1818
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1819
+ { initialProps: createDefaultOptions(ref, { isOpen: false, placement: "bottom" }) },
1820
+ );
1821
+
1822
+ rerender(createDefaultOptions(ref, {
1823
+ isOpen: false,
1824
+ placement: "bottom",
1825
+ swipeState: createOperatingSwipeState({ x: 0, y: -100 }),
1826
+ displacement: 100,
1827
+ isOpening: true,
1828
+ }));
1829
+
1830
+ expect(drawer.style.transform).toContain("translateY");
1831
+ });
1832
+ });
1833
+
1834
+ // --------------------------------------------------------------------------
1835
+ // React Strict Mode Compatibility Tests
1836
+ // --------------------------------------------------------------------------
1837
+
1838
+ describe("React StrictMode Compatibility", () => {
1839
+ /**
1840
+ * CRITICAL: These tests verify the hook works correctly in StrictMode.
1841
+ *
1842
+ * In StrictMode, React calls the render function twice and mounts/unmounts
1843
+ * effects to catch bugs. The drawer hook must handle this correctly:
1844
+ * - Drawer visibility must be set on initial mount
1845
+ * - Cleanup must not break subsequent mounts
1846
+ */
1847
+ const StrictModeWrapper = ({ children }: { children: React.ReactNode }): React.ReactNode => {
1848
+ return React.createElement(React.StrictMode, null, children);
1849
+ };
1850
+
1851
+ it("drawer is hidden on initial render when closed (Strict Mode)", () => {
1852
+ const drawer = createMockDrawer();
1853
+ const ref = { current: drawer };
1854
+ createMockContent();
1855
+
1856
+ renderHook(
1857
+ () => useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: false })),
1858
+ { wrapper: StrictModeWrapper },
1859
+ );
1860
+
1861
+ // After StrictMode's double-mount, drawer should still be hidden
1862
+ expect(drawer.style.visibility).toBe("hidden");
1863
+ expect(drawer.style.pointerEvents).toBe("none");
1864
+ });
1865
+
1866
+ it("drawer is visible on initial render when open (Strict Mode)", () => {
1867
+ const drawer = createMockDrawer();
1868
+ const ref = { current: drawer };
1869
+ createMockContent();
1870
+
1871
+ renderHook(
1872
+ () => useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: true })),
1873
+ { wrapper: StrictModeWrapper },
1874
+ );
1875
+
1876
+ // After StrictMode's double-mount, drawer should still be visible
1877
+ expect(drawer.style.visibility).toBe("visible");
1878
+ expect(drawer.style.pointerEvents).toBe("auto");
1879
+ });
1880
+
1881
+ it("drawer visibility survives StrictMode double-mount cycle", () => {
1882
+ const drawer = createMockDrawer();
1883
+ const ref = { current: drawer };
1884
+ createMockContent();
1885
+ const { runAllFrames } = setupAnimationFrameMock();
1886
+
1887
+ // Start closed
1888
+ const { rerender } = renderHook(
1889
+ (props: UseRevealDrawerTransformOptions) => useRevealDrawerTransform(props),
1890
+ {
1891
+ initialProps: createDefaultOptions(ref, { isOpen: false }),
1892
+ wrapper: StrictModeWrapper,
1893
+ },
1894
+ );
1895
+
1896
+ expect(drawer.style.visibility).toBe("hidden");
1897
+
1898
+ // Open drawer
1899
+ rerender(createDefaultOptions(ref, { isOpen: true }));
1900
+ expect(drawer.style.visibility).toBe("visible");
1901
+
1902
+ // Close drawer - animation starts
1903
+ rerender(createDefaultOptions(ref, { isOpen: false }));
1904
+ // During animation, drawer is still visible
1905
+
1906
+ // Complete animation
1907
+ act(() => {
1908
+ runAllFrames();
1909
+ });
1910
+
1911
+ // After animation, drawer should be hidden
1912
+ expect(drawer.style.visibility).toBe("hidden");
1913
+ });
1914
+
1915
+ it("body overflow is cleared on unmount (navigation scenario)", () => {
1916
+ const drawer = createMockDrawer();
1917
+ const ref = { current: drawer };
1918
+ createMockContent();
1919
+
1920
+ // Open drawer with body overflow
1921
+ const { unmount } = renderHook(
1922
+ () => useRevealDrawerTransform(createDefaultOptions(ref, { isOpen: true })),
1923
+ { wrapper: StrictModeWrapper },
1924
+ );
1925
+
1926
+ // Body should have overflow hidden when drawer is open
1927
+ expect(document.body.style.overflow).toBe("hidden");
1928
+
1929
+ // Unmount (simulates navigation)
1930
+ unmount();
1931
+
1932
+ // Body overflow should be cleared
1933
+ expect(document.body.style.overflow).toBe("");
1934
+ });
1935
+ });
1936
+ });