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,558 @@
1
+ /**
2
+ * @file Unit tests for revealDrawerStateMachine pure functions and reducer.
3
+ *
4
+ * Tests the state computation logic for reveal drawer animations,
5
+ * separated from React/DOM concerns.
6
+ */
7
+ import {
8
+ computeProgressFromDisplacement,
9
+ computeSwipeEndTarget,
10
+ computePositionFromProgress,
11
+ computeTargetPosition,
12
+ shouldAnimate,
13
+ interpolatePosition,
14
+ createInitialState,
15
+ revealDrawerReducer,
16
+ revealDrawerActions,
17
+ type RevealDrawerConfig,
18
+ } from "./revealDrawerStateMachine.js";
19
+ import {
20
+ REVEAL_DRAWER_OPEN_DISTANCE_THRESHOLD,
21
+ REVEAL_DRAWER_DISMISS_RATIO,
22
+ REVEAL_DRAWER_CLOSED_OFFSET_PERCENT,
23
+ } from "./revealDrawerConstants.js";
24
+
25
+ describe("computeProgressFromDisplacement", () => {
26
+ const drawerSize = 300;
27
+
28
+ describe("opening direction", () => {
29
+ it("returns 0 when displacement is 0", () => {
30
+ expect(computeProgressFromDisplacement(0, drawerSize, "opening")).toBe(0);
31
+ });
32
+
33
+ it("returns 1 when displacement equals drawer size", () => {
34
+ expect(computeProgressFromDisplacement(300, drawerSize, "opening")).toBe(1);
35
+ });
36
+
37
+ it("returns correct ratio for partial displacement", () => {
38
+ expect(computeProgressFromDisplacement(150, drawerSize, "opening")).toBeCloseTo(0.5);
39
+ });
40
+
41
+ it("clamps at 1 for displacement exceeding drawer size", () => {
42
+ expect(computeProgressFromDisplacement(400, drawerSize, "opening")).toBe(1);
43
+ });
44
+ });
45
+
46
+ describe("closing direction", () => {
47
+ it("returns 1 when displacement is 0 (fully open)", () => {
48
+ expect(computeProgressFromDisplacement(0, drawerSize, "closing")).toBe(1);
49
+ });
50
+
51
+ it("returns 0 when displacement equals drawer size (fully closed)", () => {
52
+ expect(computeProgressFromDisplacement(300, drawerSize, "closing")).toBe(0);
53
+ });
54
+
55
+ it("returns correct ratio for partial displacement", () => {
56
+ expect(computeProgressFromDisplacement(150, drawerSize, "closing")).toBeCloseTo(0.5);
57
+ });
58
+ });
59
+
60
+ describe("null direction", () => {
61
+ it("returns 0 regardless of displacement", () => {
62
+ expect(computeProgressFromDisplacement(100, drawerSize, null)).toBe(0);
63
+ });
64
+ });
65
+
66
+ describe("edge cases", () => {
67
+ it("returns 0 when drawer size is 0", () => {
68
+ expect(computeProgressFromDisplacement(100, 0, "opening")).toBe(0);
69
+ });
70
+
71
+ it("returns 0 for negative drawer size", () => {
72
+ expect(computeProgressFromDisplacement(100, -100, "opening")).toBe(0);
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("computeSwipeEndTarget", () => {
78
+ const drawerSize = 300;
79
+
80
+ describe("opening direction", () => {
81
+ it("returns true when displacement >= 100px threshold", () => {
82
+ expect(computeSwipeEndTarget(100, drawerSize, "opening")).toBe(true);
83
+ expect(computeSwipeEndTarget(150, drawerSize, "opening")).toBe(true);
84
+ });
85
+
86
+ it("returns false when displacement < 100px threshold", () => {
87
+ expect(computeSwipeEndTarget(99, drawerSize, "opening")).toBe(false);
88
+ expect(computeSwipeEndTarget(50, drawerSize, "opening")).toBe(false);
89
+ });
90
+
91
+ it("uses REVEAL_DRAWER_OPEN_DISTANCE_THRESHOLD constant", () => {
92
+ expect(computeSwipeEndTarget(REVEAL_DRAWER_OPEN_DISTANCE_THRESHOLD, drawerSize, "opening")).toBe(true);
93
+ expect(computeSwipeEndTarget(REVEAL_DRAWER_OPEN_DISTANCE_THRESHOLD - 1, drawerSize, "opening")).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("closing direction", () => {
98
+ it("returns true (stays open) when ratio < 30% dismiss threshold", () => {
99
+ // 30% of 300 = 90
100
+ expect(computeSwipeEndTarget(89, drawerSize, "closing")).toBe(true);
101
+ expect(computeSwipeEndTarget(45, drawerSize, "closing")).toBe(true);
102
+ });
103
+
104
+ it("returns false (closes) when ratio >= 30% dismiss threshold", () => {
105
+ // 30% of 300 = 90
106
+ expect(computeSwipeEndTarget(90, drawerSize, "closing")).toBe(false);
107
+ expect(computeSwipeEndTarget(150, drawerSize, "closing")).toBe(false);
108
+ });
109
+
110
+ it("uses REVEAL_DRAWER_DISMISS_RATIO constant", () => {
111
+ const threshold = drawerSize * REVEAL_DRAWER_DISMISS_RATIO;
112
+ expect(computeSwipeEndTarget(threshold - 1, drawerSize, "closing")).toBe(true);
113
+ expect(computeSwipeEndTarget(threshold, drawerSize, "closing")).toBe(false);
114
+ });
115
+ });
116
+
117
+ describe("null direction", () => {
118
+ it("returns false", () => {
119
+ expect(computeSwipeEndTarget(100, drawerSize, null)).toBe(false);
120
+ });
121
+ });
122
+ });
123
+
124
+ describe("computePositionFromProgress", () => {
125
+ const config: RevealDrawerConfig = { placement: "left", drawerSize: 300 };
126
+
127
+ describe("left placement", () => {
128
+ it("returns closed position when progress is 0", () => {
129
+ const pos = computePositionFromProgress(0, config);
130
+ expect(pos.drawerPx).toBe(-90); // -30% of 300
131
+ expect(pos.contentPx).toBeCloseTo(0);
132
+ });
133
+
134
+ it("returns open position when progress is 1", () => {
135
+ const pos = computePositionFromProgress(1, config);
136
+ expect(pos.drawerPx).toBeCloseTo(0);
137
+ expect(pos.contentPx).toBe(300);
138
+ });
139
+
140
+ it("returns interpolated position for partial progress", () => {
141
+ const pos = computePositionFromProgress(0.5, config);
142
+ expect(pos.drawerPx).toBe(-45); // -15% of 300
143
+ expect(pos.contentPx).toBe(150);
144
+ });
145
+ });
146
+
147
+ describe("right placement", () => {
148
+ const rightConfig: RevealDrawerConfig = { placement: "right", drawerSize: 300 };
149
+
150
+ it("returns closed position when progress is 0", () => {
151
+ const pos = computePositionFromProgress(0, rightConfig);
152
+ expect(pos.drawerPx).toBe(90); // +30% of 300
153
+ expect(pos.contentPx).toBeCloseTo(0);
154
+ });
155
+
156
+ it("returns open position when progress is 1", () => {
157
+ const pos = computePositionFromProgress(1, rightConfig);
158
+ expect(pos.drawerPx).toBeCloseTo(0);
159
+ expect(pos.contentPx).toBe(-300);
160
+ });
161
+ });
162
+
163
+ describe("top placement", () => {
164
+ const topConfig: RevealDrawerConfig = { placement: "top", drawerSize: 200 };
165
+
166
+ it("returns closed position when progress is 0", () => {
167
+ const pos = computePositionFromProgress(0, topConfig);
168
+ expect(pos.drawerPx).toBe(-60); // -30% of 200
169
+ expect(pos.contentPx).toBeCloseTo(0);
170
+ });
171
+
172
+ it("returns open position when progress is 1", () => {
173
+ const pos = computePositionFromProgress(1, topConfig);
174
+ expect(pos.drawerPx).toBeCloseTo(0);
175
+ expect(pos.contentPx).toBe(200);
176
+ });
177
+ });
178
+
179
+ describe("bottom placement", () => {
180
+ const bottomConfig: RevealDrawerConfig = { placement: "bottom", drawerSize: 200 };
181
+
182
+ it("returns closed position when progress is 0", () => {
183
+ const pos = computePositionFromProgress(0, bottomConfig);
184
+ expect(pos.drawerPx).toBe(60); // +30% of 200
185
+ expect(pos.contentPx).toBeCloseTo(0);
186
+ });
187
+
188
+ it("returns open position when progress is 1", () => {
189
+ const pos = computePositionFromProgress(1, bottomConfig);
190
+ expect(pos.drawerPx).toBeCloseTo(0);
191
+ expect(pos.contentPx).toBe(-200);
192
+ });
193
+ });
194
+
195
+ it("uses REVEAL_DRAWER_CLOSED_OFFSET_PERCENT constant", () => {
196
+ const pos = computePositionFromProgress(0, config);
197
+ const expectedOffset = config.drawerSize * (REVEAL_DRAWER_CLOSED_OFFSET_PERCENT / 100);
198
+ expect(Math.abs(pos.drawerPx)).toBe(expectedOffset);
199
+ });
200
+ });
201
+
202
+ describe("computeTargetPosition", () => {
203
+ const config: RevealDrawerConfig = { placement: "left", drawerSize: 300 };
204
+
205
+ it("returns open position when isOpen is true", () => {
206
+ const pos = computeTargetPosition(true, config);
207
+ expect(pos.drawerPx).toBeCloseTo(0);
208
+ expect(pos.contentPx).toBe(300);
209
+ });
210
+
211
+ it("returns closed position when isOpen is false", () => {
212
+ const pos = computeTargetPosition(false, config);
213
+ expect(pos.drawerPx).toBe(-90);
214
+ expect(pos.contentPx).toBeCloseTo(0);
215
+ });
216
+ });
217
+
218
+ describe("shouldAnimate", () => {
219
+ it("returns true when positions differ by more than snap threshold", () => {
220
+ const from = { drawerPx: 0, contentPx: 0 };
221
+ const to = { drawerPx: 0, contentPx: 100 };
222
+ expect(shouldAnimate(from, to)).toBe(true);
223
+ });
224
+
225
+ it("returns false when positions differ by snap threshold or less", () => {
226
+ const from = { drawerPx: 0, contentPx: 100 };
227
+ const to = { drawerPx: 0, contentPx: 100.5 };
228
+ expect(shouldAnimate(from, to)).toBe(false);
229
+ });
230
+
231
+ it("returns false when positions are identical", () => {
232
+ const from = { drawerPx: -90, contentPx: 0 };
233
+ const to = { drawerPx: -90, contentPx: 0 };
234
+ expect(shouldAnimate(from, to)).toBe(false);
235
+ });
236
+ });
237
+
238
+ describe("interpolatePosition", () => {
239
+ const from = { drawerPx: -90, contentPx: 0 };
240
+ const to = { drawerPx: 0, contentPx: 300 };
241
+
242
+ it("returns from position when progress is 0", () => {
243
+ const pos = interpolatePosition(from, to, 0);
244
+ expect(pos.drawerPx).toBe(-90);
245
+ expect(pos.contentPx).toBe(0);
246
+ });
247
+
248
+ it("returns to position when progress is 1", () => {
249
+ const pos = interpolatePosition(from, to, 1);
250
+ expect(pos.drawerPx).toBe(0);
251
+ expect(pos.contentPx).toBe(300);
252
+ });
253
+
254
+ it("returns interpolated position for partial progress", () => {
255
+ const pos = interpolatePosition(from, to, 0.5);
256
+ expect(pos.drawerPx).toBe(-45);
257
+ expect(pos.contentPx).toBe(150);
258
+ });
259
+ });
260
+
261
+ describe("createInitialState", () => {
262
+ const config: RevealDrawerConfig = { placement: "left", drawerSize: 300 };
263
+
264
+ it("creates closed state when isOpen is false", () => {
265
+ const state = createInitialState(false, config);
266
+ expect(state.phase).toBe("closed");
267
+ expect(state.targetOpen).toBe(false);
268
+ expect(state.position.drawerPx).toBe(-90);
269
+ expect(state.position.contentPx).toBe(0);
270
+ expect(state.isOperating).toBe(false);
271
+ expect(state.swipeDirection).toBeNull();
272
+ expect(state.animation).toBeNull();
273
+ });
274
+
275
+ it("creates open state when isOpen is true", () => {
276
+ const state = createInitialState(true, config);
277
+ expect(state.phase).toBe("open");
278
+ expect(state.targetOpen).toBe(true);
279
+ expect(state.position.drawerPx).toBeCloseTo(0);
280
+ expect(state.position.contentPx).toBe(300);
281
+ expect(state.isOperating).toBe(false);
282
+ expect(state.swipeDirection).toBeNull();
283
+ expect(state.animation).toBeNull();
284
+ });
285
+ });
286
+
287
+ describe("revealDrawerReducer", () => {
288
+ const config: RevealDrawerConfig = { placement: "left", drawerSize: 300 };
289
+
290
+ describe("swipe flow: START -> UPDATE -> END", () => {
291
+ it("handles opening swipe that crosses threshold", () => {
292
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
293
+ let state = createInitialState(false, config);
294
+
295
+ // Start swipe
296
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("opening"), config);
297
+ expect(state.isOperating).toBe(true);
298
+ expect(state.swipeDirection).toBe("opening");
299
+
300
+ // Update with displacement
301
+ state = revealDrawerReducer(state, revealDrawerActions.swipeUpdate(150), config);
302
+ expect(state.displacement).toBe(150);
303
+ expect(state.position.contentPx).toBeCloseTo(150);
304
+
305
+ // End swipe - should animate to open
306
+ state = revealDrawerReducer(state, revealDrawerActions.swipeEnd(), config);
307
+ expect(state.isOperating).toBe(false);
308
+ expect(state.targetOpen).toBe(true);
309
+ expect(state.phase).toBe("opening");
310
+ expect(state.animation).not.toBeNull();
311
+ expect(state.animation?.type).toBe("opening");
312
+ });
313
+
314
+ it("handles opening swipe that does not cross threshold", () => {
315
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
316
+ let state = createInitialState(false, config);
317
+
318
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("opening"), config);
319
+ state = revealDrawerReducer(state, revealDrawerActions.swipeUpdate(50), config);
320
+ state = revealDrawerReducer(state, revealDrawerActions.swipeEnd(), config);
321
+
322
+ expect(state.targetOpen).toBe(false);
323
+ expect(state.phase).toBe("closing");
324
+ });
325
+
326
+ it("handles closing swipe that crosses dismiss threshold", () => {
327
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
328
+ let state = createInitialState(true, config);
329
+
330
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("closing"), config);
331
+ state = revealDrawerReducer(state, revealDrawerActions.swipeUpdate(100), config); // 33%
332
+ state = revealDrawerReducer(state, revealDrawerActions.swipeEnd(), config);
333
+
334
+ expect(state.targetOpen).toBe(false);
335
+ expect(state.phase).toBe("closing");
336
+ });
337
+
338
+ it("handles closing swipe that does not cross dismiss threshold", () => {
339
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
340
+ let state = createInitialState(true, config);
341
+
342
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("closing"), config);
343
+ state = revealDrawerReducer(state, revealDrawerActions.swipeUpdate(50), config); // 16.6%
344
+ state = revealDrawerReducer(state, revealDrawerActions.swipeEnd(), config);
345
+
346
+ expect(state.targetOpen).toBe(true);
347
+ expect(state.phase).toBe("opening");
348
+ });
349
+ });
350
+
351
+ describe("animation flow: FRAME -> COMPLETE", () => {
352
+ it("interpolates position during animation frame", () => {
353
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
354
+ let state = createInitialState(false, config);
355
+
356
+ // Trigger button open to start animation
357
+ state = revealDrawerReducer(state, revealDrawerActions.buttonOpen(), config);
358
+ expect(state.animation).not.toBeNull();
359
+
360
+ // Animation frame at 50%
361
+ state = revealDrawerReducer(state, revealDrawerActions.animationFrame(0.5), config);
362
+ expect(state.position.contentPx).toBeCloseTo(150);
363
+
364
+ // Animation complete
365
+ state = revealDrawerReducer(state, revealDrawerActions.animationComplete(), config);
366
+ expect(state.phase).toBe("open");
367
+ expect(state.animation).toBeNull();
368
+ expect(state.position.contentPx).toBe(300);
369
+ });
370
+ });
371
+
372
+ describe("button operations", () => {
373
+ it("buttonOpen starts animation when closed", () => {
374
+ const state = createInitialState(false, config);
375
+ const newState = revealDrawerReducer(state, revealDrawerActions.buttonOpen(), config);
376
+
377
+ expect(newState.phase).toBe("opening");
378
+ expect(newState.targetOpen).toBe(true);
379
+ expect(newState.animation).not.toBeNull();
380
+ });
381
+
382
+ it("buttonOpen is no-op when already open", () => {
383
+ const state = createInitialState(true, config);
384
+ const newState = revealDrawerReducer(state, revealDrawerActions.buttonOpen(), config);
385
+
386
+ expect(newState).toBe(state);
387
+ });
388
+
389
+ it("buttonOpen is no-op when already opening", () => {
390
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
391
+ let state = createInitialState(false, config);
392
+ state = revealDrawerReducer(state, revealDrawerActions.buttonOpen(), config);
393
+ expect(state.phase).toBe("opening");
394
+
395
+ const newState = revealDrawerReducer(state, revealDrawerActions.buttonOpen(), config);
396
+ expect(newState).toBe(state);
397
+ });
398
+
399
+ it("buttonClose starts animation when open", () => {
400
+ const state = createInitialState(true, config);
401
+ const newState = revealDrawerReducer(state, revealDrawerActions.buttonClose(), config);
402
+
403
+ expect(newState.phase).toBe("closing");
404
+ expect(newState.targetOpen).toBe(false);
405
+ expect(newState.animation).not.toBeNull();
406
+ });
407
+
408
+ it("buttonClose is no-op when already closed", () => {
409
+ const state = createInitialState(false, config);
410
+ const newState = revealDrawerReducer(state, revealDrawerActions.buttonClose(), config);
411
+
412
+ expect(newState).toBe(state);
413
+ });
414
+
415
+ it("button operations are ignored during swipe", () => {
416
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
417
+ let state = createInitialState(false, config);
418
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("opening"), config);
419
+
420
+ const newState = revealDrawerReducer(state, revealDrawerActions.buttonOpen(), config);
421
+ expect(newState).toBe(state);
422
+ });
423
+ });
424
+
425
+ describe("syncOpenState", () => {
426
+ it("starts animation when state differs from target", () => {
427
+ const state = createInitialState(false, config);
428
+ const newState = revealDrawerReducer(state, revealDrawerActions.syncOpenState(true), config);
429
+
430
+ expect(newState.phase).toBe("opening");
431
+ expect(newState.targetOpen).toBe(true);
432
+ expect(newState.animation).not.toBeNull();
433
+ });
434
+
435
+ it("is no-op when already at target state", () => {
436
+ const state = createInitialState(true, config);
437
+ const newState = revealDrawerReducer(state, revealDrawerActions.syncOpenState(true), config);
438
+
439
+ expect(newState).toBe(state);
440
+ });
441
+
442
+ it("updates targetOpen during swipe but does not start animation", () => {
443
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
444
+ let state = createInitialState(false, config);
445
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("opening"), config);
446
+
447
+ const newState = revealDrawerReducer(state, revealDrawerActions.syncOpenState(true), config);
448
+ expect(newState.targetOpen).toBe(true);
449
+ expect(newState.animation).toBeNull();
450
+ expect(newState.isOperating).toBe(true);
451
+ });
452
+ });
453
+
454
+ describe("initialize", () => {
455
+ it("resets state to initial values", () => {
456
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
457
+ let state = createInitialState(false, config);
458
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("opening"), config);
459
+ state = revealDrawerReducer(state, revealDrawerActions.swipeUpdate(150), config);
460
+
461
+ const newState = revealDrawerReducer(state, revealDrawerActions.initialize(true), config);
462
+ expect(newState.phase).toBe("open");
463
+ expect(newState.isOperating).toBe(false);
464
+ expect(newState.displacement).toBe(0);
465
+ expect(newState.position.contentPx).toBe(300);
466
+ });
467
+ });
468
+
469
+ describe("placement variations", () => {
470
+ it.each([
471
+ ["left", 1],
472
+ ["right", -1],
473
+ ["top", 1],
474
+ ["bottom", -1],
475
+ ] as const)("%s placement has correct sign", (placement, expectedSign) => {
476
+ const placementConfig: RevealDrawerConfig = { placement, drawerSize: 300 };
477
+ const state = createInitialState(true, placementConfig);
478
+
479
+ // Content should move in the expected direction when open
480
+ expect(Math.sign(state.position.contentPx)).toBe(expectedSign);
481
+ });
482
+
483
+ it("horizontal placements use X axis", () => {
484
+ const leftConfig: RevealDrawerConfig = { placement: "left", drawerSize: 300 };
485
+ const rightConfig: RevealDrawerConfig = { placement: "right", drawerSize: 300 };
486
+
487
+ const leftState = createInitialState(true, leftConfig);
488
+ const rightState = createInitialState(true, rightConfig);
489
+
490
+ // Both should have non-zero content offset (just different directions)
491
+ expect(Math.abs(leftState.position.contentPx)).toBe(300);
492
+ expect(Math.abs(rightState.position.contentPx)).toBe(300);
493
+ });
494
+
495
+ it("vertical placements use Y axis", () => {
496
+ const topConfig: RevealDrawerConfig = { placement: "top", drawerSize: 200 };
497
+ const bottomConfig: RevealDrawerConfig = { placement: "bottom", drawerSize: 200 };
498
+
499
+ const topState = createInitialState(true, topConfig);
500
+ const bottomState = createInitialState(true, bottomConfig);
501
+
502
+ expect(Math.abs(topState.position.contentPx)).toBe(200);
503
+ expect(Math.abs(bottomState.position.contentPx)).toBe(200);
504
+ });
505
+ });
506
+
507
+ describe("edge cases", () => {
508
+ it("swipeUpdate is no-op when not operating", () => {
509
+ const state = createInitialState(false, config);
510
+ const newState = revealDrawerReducer(state, revealDrawerActions.swipeUpdate(100), config);
511
+ expect(newState).toBe(state);
512
+ });
513
+
514
+ it("swipeEnd is no-op when not operating", () => {
515
+ const state = createInitialState(false, config);
516
+ const newState = revealDrawerReducer(state, revealDrawerActions.swipeEnd(), config);
517
+ expect(newState).toBe(state);
518
+ });
519
+
520
+ it("animationFrame is no-op when no animation", () => {
521
+ const state = createInitialState(false, config);
522
+ const newState = revealDrawerReducer(state, revealDrawerActions.animationFrame(0.5), config);
523
+ expect(newState).toBe(state);
524
+ });
525
+
526
+ it("animationComplete is no-op when no animation", () => {
527
+ const state = createInitialState(false, config);
528
+ const newState = revealDrawerReducer(state, revealDrawerActions.animationComplete(), config);
529
+ expect(newState).toBe(state);
530
+ });
531
+
532
+ it("swipeStart cancels running animation", () => {
533
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
534
+ let state = createInitialState(false, config);
535
+ state = revealDrawerReducer(state, revealDrawerActions.buttonOpen(), config);
536
+ expect(state.animation).not.toBeNull();
537
+
538
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("closing"), config);
539
+ expect(state.animation).toBeNull();
540
+ expect(state.isOperating).toBe(true);
541
+ });
542
+ });
543
+
544
+ describe("snap behavior", () => {
545
+ it("snaps directly when positions are close enough", () => {
546
+ // Create a state very close to closed position
547
+ // eslint-disable-next-line no-restricted-syntax -- state machine testing requires reassignment
548
+ let state = createInitialState(false, config);
549
+ state = revealDrawerReducer(state, revealDrawerActions.swipeStart("opening"), config);
550
+ state = revealDrawerReducer(state, revealDrawerActions.swipeUpdate(0.5), config); // Tiny displacement
551
+ state = revealDrawerReducer(state, revealDrawerActions.swipeEnd(), config);
552
+
553
+ // Should snap directly to closed without animation
554
+ expect(state.phase).toBe("closed");
555
+ expect(state.animation).toBeNull();
556
+ });
557
+ });
558
+ });