react-panel-layout 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/dist/{FloatingPanelFrame-SgYLc6Ud.js → FloatingPanelFrame-3eU9AwPo.js} +2 -2
  2. package/dist/{FloatingPanelFrame-SgYLc6Ud.js.map → FloatingPanelFrame-3eU9AwPo.js.map} +1 -1
  3. package/dist/FloatingWindow-CUXnEtrb.js +827 -0
  4. package/dist/FloatingWindow-CUXnEtrb.js.map +1 -0
  5. package/dist/FloatingWindow-DMwyK0eK.cjs +2 -0
  6. package/dist/FloatingWindow-DMwyK0eK.cjs.map +1 -0
  7. package/dist/GridLayout-DKTg_N61.cjs +2 -0
  8. package/dist/{GridLayout-B4VRsC0r.cjs.map → GridLayout-DKTg_N61.cjs.map} +1 -1
  9. package/dist/{GridLayout-BltqeCPK.js → GridLayout-UWNxXw77.js} +34 -35
  10. package/dist/{GridLayout-BltqeCPK.js.map → GridLayout-UWNxXw77.js.map} +1 -1
  11. package/dist/{HorizontalDivider-WF1k_qND.js → HorizontalDivider-DdxzfV0l.js} +3 -3
  12. package/dist/{HorizontalDivider-WF1k_qND.js.map → HorizontalDivider-DdxzfV0l.js.map} +1 -1
  13. package/dist/{HorizontalDivider-B5Z-KZLk.cjs → HorizontalDivider-_pgV4Mcv.cjs} +2 -2
  14. package/dist/{HorizontalDivider-B5Z-KZLk.cjs.map → HorizontalDivider-_pgV4Mcv.cjs.map} +1 -1
  15. package/dist/{PanelSystem-Dr1TBhxM.js → PanelSystem-BqUzNtf2.js} +5 -5
  16. package/dist/{PanelSystem-Dr1TBhxM.js.map → PanelSystem-BqUzNtf2.js.map} +1 -1
  17. package/dist/{PanelSystem-Bs8bQwQF.cjs → PanelSystem-D603LKKv.cjs} +2 -2
  18. package/dist/{PanelSystem-Bs8bQwQF.cjs.map → PanelSystem-D603LKKv.cjs.map} +1 -1
  19. package/dist/ResizeHandle-CBcAS918.cjs +2 -0
  20. package/dist/{ResizeHandle-CScipO5l.cjs.map → ResizeHandle-CBcAS918.cjs.map} +1 -1
  21. package/dist/{ResizeHandle-CdA_JYfN.js → ResizeHandle-CXjc1meV.js} +28 -29
  22. package/dist/{ResizeHandle-CdA_JYfN.js.map → ResizeHandle-CXjc1meV.js.map} +1 -1
  23. package/dist/SwipePivotTabBar-DWrCuwEI.js +411 -0
  24. package/dist/SwipePivotTabBar-DWrCuwEI.js.map +1 -0
  25. package/dist/SwipePivotTabBar-fjjXkpj7.cjs +2 -0
  26. package/dist/SwipePivotTabBar-fjjXkpj7.cjs.map +1 -0
  27. package/dist/components/gesture/SwipeSafeZone.d.ts +40 -0
  28. package/dist/components/window/Drawer.d.ts +3 -1
  29. package/dist/components/window/DrawerLayers.d.ts +1 -1
  30. package/dist/components/window/drawerStyles.d.ts +69 -0
  31. package/dist/components/window/drawerSwipeConfig.d.ts +29 -0
  32. package/dist/components/window/useDrawerSwipeTransform.d.ts +23 -0
  33. package/dist/config.cjs +1 -1
  34. package/dist/config.js +3 -3
  35. package/dist/constants/styles.d.ts +17 -0
  36. package/dist/dialog/index.d.ts +69 -0
  37. package/dist/floating.js +1 -1
  38. package/dist/grid.cjs +1 -1
  39. package/dist/grid.js +2 -2
  40. package/dist/hooks/gesture/testing/createGestureSimulator.d.ts +7 -0
  41. package/dist/hooks/gesture/types.d.ts +48 -5
  42. package/dist/hooks/gesture/utils.d.ts +19 -0
  43. package/dist/hooks/useAnimationFrame.d.ts +2 -0
  44. package/dist/hooks/useOperationContinuity.d.ts +64 -0
  45. package/dist/hooks/useResizeObserver.d.ts +33 -1
  46. package/dist/hooks/useSharedElementTransition.d.ts +112 -0
  47. package/dist/hooks/useSwipeContentTransform.d.ts +9 -2
  48. package/dist/index.cjs +1 -1
  49. package/dist/index.js +7 -7
  50. package/dist/modules/dialog/AlertDialog.d.ts +9 -0
  51. package/dist/modules/dialog/DialogContainer.d.ts +37 -0
  52. package/dist/modules/dialog/Modal.d.ts +26 -0
  53. package/dist/modules/dialog/SwipeDialogContainer.d.ts +16 -0
  54. package/dist/modules/dialog/dialogAnimationUtils.d.ts +113 -0
  55. package/dist/modules/dialog/types.d.ts +183 -0
  56. package/dist/modules/dialog/useDialog.d.ts +39 -0
  57. package/dist/modules/dialog/useDialogContainer.d.ts +47 -0
  58. package/dist/modules/dialog/useDialogSwipeInput.d.ts +70 -0
  59. package/dist/modules/dialog/useDialogTransform.d.ts +82 -0
  60. package/dist/modules/drawer/types.d.ts +74 -0
  61. package/dist/modules/drawer/useDrawerSwipeInput.d.ts +24 -0
  62. package/dist/modules/pivot/SwipePivotTabBar.d.ts +3 -0
  63. package/dist/modules/stack/SwipeStackContent.d.ts +6 -3
  64. package/dist/modules/stack/SwipeStackOutlet.d.ts +4 -4
  65. package/dist/modules/stack/computeSwipeStackTransform.d.ts +1 -1
  66. package/dist/panels.cjs +1 -1
  67. package/dist/panels.js +1 -1
  68. package/dist/pivot.cjs +1 -1
  69. package/dist/pivot.js +1 -1
  70. package/dist/resizer.cjs +1 -1
  71. package/dist/resizer.js +2 -2
  72. package/dist/stack.cjs +1 -1
  73. package/dist/stack.cjs.map +1 -1
  74. package/dist/stack.js +503 -762
  75. package/dist/stack.js.map +1 -1
  76. package/dist/sticky-header/calculateStickyMetrics.d.ts +28 -0
  77. package/dist/sticky-header.cjs +1 -1
  78. package/dist/sticky-header.cjs.map +1 -1
  79. package/dist/sticky-header.js +59 -51
  80. package/dist/sticky-header.js.map +1 -1
  81. package/dist/{styles-DPPuJ0sf.js → styles-NkjuMOVS.js} +13 -13
  82. package/dist/{styles-DPPuJ0sf.js.map → styles-NkjuMOVS.js.map} +1 -1
  83. package/dist/styles-qf6ptVLD.cjs.map +1 -1
  84. package/dist/types.d.ts +16 -0
  85. package/dist/useDocumentPointerEvents-DXxw3qWj.js +54 -0
  86. package/dist/useDocumentPointerEvents-DXxw3qWj.js.map +1 -0
  87. package/dist/useDocumentPointerEvents-DxDSOtip.cjs +2 -0
  88. package/dist/useDocumentPointerEvents-DxDSOtip.cjs.map +1 -0
  89. package/dist/useNativeGestureGuard-C7TSqEkr.cjs +2 -0
  90. package/dist/useNativeGestureGuard-C7TSqEkr.cjs.map +1 -0
  91. package/dist/useNativeGestureGuard-CGYo6O0r.js +347 -0
  92. package/dist/useNativeGestureGuard-CGYo6O0r.js.map +1 -0
  93. package/dist/window/index.d.ts +2 -0
  94. package/dist/window.cjs +1 -1
  95. package/dist/window.cjs.map +1 -1
  96. package/dist/window.js +114 -103
  97. package/dist/window.js.map +1 -1
  98. package/package.json +6 -1
  99. package/src/components/gesture/SwipeSafeZone.tsx +69 -0
  100. package/src/components/window/Drawer.tsx +249 -162
  101. package/src/components/window/DrawerLayers.tsx +13 -3
  102. package/src/components/window/drawerStyles.spec.ts +263 -0
  103. package/src/components/window/drawerStyles.ts +228 -0
  104. package/src/components/window/drawerSwipeConfig.spec.ts +131 -0
  105. package/src/components/window/drawerSwipeConfig.ts +112 -0
  106. package/src/components/window/useDrawerSwipeTransform.spec.ts +234 -0
  107. package/src/components/window/useDrawerSwipeTransform.ts +129 -0
  108. package/src/constants/styles.ts +19 -0
  109. package/src/demo/pages/Dialog/alerts/index.tsx +22 -0
  110. package/src/demo/pages/Dialog/card/index.tsx +22 -0
  111. package/src/demo/pages/Dialog/components/AlertDialogDemo.tsx +124 -0
  112. package/src/demo/pages/Dialog/components/CardExpandDemo.module.css +243 -0
  113. package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +204 -0
  114. package/src/demo/pages/Dialog/components/CustomAlertDialogDemo.tsx +219 -0
  115. package/src/demo/pages/Dialog/components/DialogDemos.module.css +77 -0
  116. package/src/demo/pages/Dialog/components/ModalBasics.tsx +45 -0
  117. package/src/demo/pages/Dialog/components/SwipeDialogDemo.module.css +77 -0
  118. package/src/demo/pages/Dialog/components/SwipeDialogDemo.tsx +181 -0
  119. package/src/demo/pages/Dialog/custom-alert/index.tsx +22 -0
  120. package/src/demo/pages/Dialog/modal/index.tsx +17 -0
  121. package/src/demo/pages/Dialog/swipe/index.tsx +22 -0
  122. package/src/demo/pages/Drawer/components/DrawerSwipe.module.css +316 -0
  123. package/src/demo/pages/Drawer/components/DrawerSwipe.tsx +178 -0
  124. package/src/demo/pages/Drawer/swipe/index.tsx +17 -0
  125. package/src/demo/pages/Pivot/components/SwipeTabsPivot.tsx +54 -23
  126. package/src/demo/pages/Pivot/swipe-debug/index.tsx +1 -1
  127. package/src/demo/pages/Stack/components/StackBasics.spec.tsx +152 -0
  128. package/src/demo/pages/Stack/components/StackBasics.tsx +179 -95
  129. package/src/demo/pages/Stack/components/StackTablet.spec.tsx +120 -0
  130. package/src/demo/pages/Stack/components/StackTablet.tsx +42 -21
  131. package/src/demo/routes.tsx +22 -1
  132. package/src/dialog/index.ts +85 -0
  133. package/src/hooks/gesture/testing/createGestureSimulator.spec.ts +68 -64
  134. package/src/hooks/gesture/testing/createGestureSimulator.ts +112 -37
  135. package/src/hooks/gesture/types.ts +83 -6
  136. package/src/hooks/gesture/useEdgeSwipeInput.spec.ts +22 -14
  137. package/src/hooks/gesture/useNativeGestureGuard.spec.ts +91 -31
  138. package/src/hooks/gesture/useNativeGestureGuard.ts +3 -1
  139. package/src/hooks/gesture/utils.ts +91 -0
  140. package/src/hooks/useAnimatedVisibility.spec.ts +44 -24
  141. package/src/hooks/useAnimatedVisibility.ts +28 -2
  142. package/src/hooks/useAnimationFrame.ts +8 -0
  143. package/src/hooks/useOperationContinuity.spec.ts +387 -0
  144. package/src/hooks/useOperationContinuity.ts +135 -0
  145. package/src/hooks/useResizeObserver.spec.tsx +277 -0
  146. package/src/hooks/useResizeObserver.tsx +108 -39
  147. package/src/hooks/useScrollContainer.ts +4 -10
  148. package/src/hooks/useSharedElementTransition.ts +333 -0
  149. package/src/hooks/useSwipeContentTransform.spec.ts +18 -18
  150. package/src/hooks/useSwipeContentTransform.ts +166 -28
  151. package/src/modules/dialog/AlertDialog.spec.tsx +387 -0
  152. package/src/modules/dialog/AlertDialog.tsx +221 -0
  153. package/src/modules/dialog/DialogContainer.spec.tsx +228 -0
  154. package/src/modules/dialog/DialogContainer.tsx +188 -0
  155. package/src/modules/dialog/Modal.spec.tsx +220 -0
  156. package/src/modules/dialog/Modal.tsx +182 -0
  157. package/src/modules/dialog/SwipeDialogContainer.tsx +208 -0
  158. package/src/modules/dialog/dialogAnimationUtils.spec.ts +253 -0
  159. package/src/modules/dialog/dialogAnimationUtils.ts +297 -0
  160. package/src/modules/dialog/types.ts +186 -0
  161. package/src/modules/dialog/useDialog.spec.tsx +447 -0
  162. package/src/modules/dialog/useDialog.ts +214 -0
  163. package/src/modules/dialog/useDialogContainer.spec.ts +331 -0
  164. package/src/modules/dialog/useDialogContainer.ts +150 -0
  165. package/src/modules/dialog/useDialogSwipeInput.spec.ts +157 -0
  166. package/src/modules/dialog/useDialogSwipeInput.ts +319 -0
  167. package/src/modules/dialog/useDialogTransform.spec.ts +370 -0
  168. package/src/modules/dialog/useDialogTransform.ts +407 -0
  169. package/src/modules/drawer/types.ts +102 -0
  170. package/src/modules/drawer/useDrawerSwipeInput.spec.ts +566 -0
  171. package/src/modules/drawer/useDrawerSwipeInput.ts +399 -0
  172. package/src/modules/panels/rendering/ContentRegistry.spec.tsx +21 -14
  173. package/src/modules/pivot/SwipePivotContent.position.spec.tsx +12 -8
  174. package/src/modules/pivot/SwipePivotContent.spec.tsx +55 -25
  175. package/src/modules/pivot/SwipePivotContent.tsx +2 -2
  176. package/src/modules/pivot/SwipePivotTabBar.spec.tsx +85 -68
  177. package/src/modules/pivot/SwipePivotTabBar.tsx +75 -15
  178. package/src/modules/pivot/scaleInputState.spec.ts +11 -2
  179. package/src/modules/pivot/usePivot.spec.ts +17 -3
  180. package/src/modules/pivot/usePivotSwipeInput.spec.ts +182 -123
  181. package/src/modules/stack/SwipeStackContent.spec.tsx +387 -100
  182. package/src/modules/stack/SwipeStackContent.tsx +43 -33
  183. package/src/modules/stack/SwipeStackOutlet.spec.tsx +14 -16
  184. package/src/modules/stack/SwipeStackOutlet.tsx +6 -6
  185. package/src/modules/stack/computeSwipeStackTransform.spec.ts +5 -5
  186. package/src/modules/stack/computeSwipeStackTransform.ts +3 -3
  187. package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1133 -0
  188. package/src/modules/stack/useStackAnimationState.spec.ts +3 -1
  189. package/src/modules/stack/useStackAnimationState.ts +18 -13
  190. package/src/modules/stack/useStackNavigation.spec.ts +198 -3
  191. package/src/modules/stack/useStackNavigation.tsx +113 -56
  192. package/src/modules/stack/useStackSwipeInput.spec.ts +65 -32
  193. package/src/modules/stack/useStackSwipeInput.ts +1 -1
  194. package/src/sticky-header/StickyArea.tsx +29 -57
  195. package/src/sticky-header/calculateStickyMetrics.spec.ts +105 -0
  196. package/src/sticky-header/calculateStickyMetrics.ts +50 -0
  197. package/src/types.ts +18 -0
  198. package/src/window/index.ts +2 -0
  199. package/dist/FloatingWindow-BpdOpg_L.js +0 -400
  200. package/dist/FloatingWindow-BpdOpg_L.js.map +0 -1
  201. package/dist/FloatingWindow-TCDNY5gE.cjs +0 -2
  202. package/dist/FloatingWindow-TCDNY5gE.cjs.map +0 -1
  203. package/dist/GridLayout-B4VRsC0r.cjs +0 -2
  204. package/dist/ResizeHandle-CScipO5l.cjs +0 -2
  205. package/dist/SwipePivotTabBar-BGO9X94m.js +0 -407
  206. package/dist/SwipePivotTabBar-BGO9X94m.js.map +0 -1
  207. package/dist/SwipePivotTabBar-BrQismcZ.cjs +0 -2
  208. package/dist/SwipePivotTabBar-BrQismcZ.cjs.map +0 -1
  209. package/dist/useDocumentPointerEvents-CKdhGXd0.js +0 -46
  210. package/dist/useDocumentPointerEvents-CKdhGXd0.js.map +0 -1
  211. package/dist/useDocumentPointerEvents-ChqrKXDk.cjs +0 -2
  212. package/dist/useDocumentPointerEvents-ChqrKXDk.cjs.map +0 -1
  213. package/dist/useEffectEvent-Dp7HLCf0.js +0 -13
  214. package/dist/useEffectEvent-Dp7HLCf0.js.map +0 -1
  215. package/dist/useEffectEvent-huSsGUnl.cjs +0 -2
  216. package/dist/useEffectEvent-huSsGUnl.cjs.map +0 -1
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @file Tests for StackBasics component logic.
3
+ *
4
+ * These tests focus on the panel visibility and depth calculation logic,
5
+ * particularly around the exitingPanelId handling during rapid navigation.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+
9
+ /**
10
+ * Pure function version of the isExiting logic for testing.
11
+ * This matches the logic in StackBasics.tsx.
12
+ */
13
+ function computeIsExiting(
14
+ panelId: string,
15
+ exitingPanelId: string | null,
16
+ stack: readonly string[],
17
+ ): boolean {
18
+ // FIX: Check if panel is in current stack - if so, it's not exiting
19
+ const isInCurrentStack = stack.includes(panelId);
20
+ return panelId === exitingPanelId && !isInCurrentStack;
21
+ }
22
+
23
+ /**
24
+ * Pure function version of panelDepth calculation.
25
+ */
26
+ function computePanelDepth(
27
+ panelId: string,
28
+ isExiting: boolean,
29
+ stack: readonly string[],
30
+ depth: number,
31
+ ): number {
32
+ return isExiting ? depth + 1 : stack.indexOf(panelId);
33
+ }
34
+
35
+ describe("StackBasics panel calculation logic", () => {
36
+ describe("isExiting calculation", () => {
37
+ it("returns true when panel is exitingPanelId and not in stack", () => {
38
+ // Back navigation: detail was removed from stack but exitingPanelId is set
39
+ const result = computeIsExiting("detail", "detail", ["list"]);
40
+ expect(result).toBe(true);
41
+ });
42
+
43
+ it("returns false when panel is in current stack even if it matches exitingPanelId", () => {
44
+ // Bug scenario: user pushed detail again before exitingPanelId timeout cleared
45
+ // detail is in stack but exitingPanelId is still "detail"
46
+ const result = computeIsExiting("detail", "detail", ["list", "detail"]);
47
+ expect(result).toBe(false);
48
+ });
49
+
50
+ it("returns false when panel does not match exitingPanelId", () => {
51
+ const result = computeIsExiting("list", "detail", ["list"]);
52
+ expect(result).toBe(false);
53
+ });
54
+
55
+ it("returns false when exitingPanelId is null", () => {
56
+ const result = computeIsExiting("detail", null, ["list", "detail"]);
57
+ expect(result).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe("panelDepth calculation", () => {
62
+ it("returns correct depth for active panel in stack", () => {
63
+ const isExiting = computeIsExiting("detail", null, ["list", "detail"]);
64
+ const depth = computePanelDepth("detail", isExiting, ["list", "detail"], 1);
65
+ expect(depth).toBe(1);
66
+ });
67
+
68
+ it("returns depth + 1 for exiting panel", () => {
69
+ const isExiting = computeIsExiting("detail", "detail", ["list"]);
70
+ const depth = computePanelDepth("detail", isExiting, ["list"], 0);
71
+ expect(depth).toBe(1); // 0 + 1
72
+ });
73
+
74
+ it("returns correct depth for re-pushed panel (not exiting)", () => {
75
+ // Bug scenario: detail was exiting but got re-pushed
76
+ // Should use stack.indexOf, not depth + 1
77
+ const stack = ["list", "detail"];
78
+ const isExiting = computeIsExiting("detail", "detail", stack);
79
+ const depth = computePanelDepth("detail", isExiting, stack, 1);
80
+ expect(depth).toBe(1); // stack.indexOf("detail") = 1, NOT depth + 1 = 2
81
+ });
82
+ });
83
+
84
+ describe("rapid navigation scenarios", () => {
85
+ it("handles push → back → push sequence correctly", () => {
86
+ // Initial: list active
87
+ let stack: readonly string[] = ["list"];
88
+ let depth = 0;
89
+ let exitingPanelId: string | null = null;
90
+
91
+ // Push detail
92
+ stack = ["list", "detail"];
93
+ depth = 1;
94
+
95
+ // Back to list (detail becomes exiting)
96
+ stack = ["list"];
97
+ depth = 0;
98
+ exitingPanelId = "detail";
99
+
100
+ // Verify detail is exiting
101
+ let isExiting = computeIsExiting("detail", exitingPanelId, stack);
102
+ expect(isExiting).toBe(true);
103
+
104
+ // Push detail again BEFORE exitingPanelId clears
105
+ stack = ["list", "detail"];
106
+ depth = 1;
107
+ // exitingPanelId is still "detail" (timeout hasn't fired yet)
108
+
109
+ // Now detail should NOT be exiting because it's in the stack
110
+ isExiting = computeIsExiting("detail", exitingPanelId, stack);
111
+ expect(isExiting).toBe(false);
112
+
113
+ // Panel depth should be correct
114
+ const panelDepth = computePanelDepth("detail", isExiting, stack, depth);
115
+ expect(panelDepth).toBe(1); // Not 2!
116
+ });
117
+
118
+ it("handles deep navigation with rapid back operations", () => {
119
+ // Initial: list → detail → edit
120
+ let stack: readonly string[] = ["list", "detail", "edit"];
121
+ let depth = 2;
122
+ let exitingPanelId: string | null = null;
123
+
124
+ // Back to detail (edit becomes exiting)
125
+ stack = ["list", "detail"];
126
+ depth = 1;
127
+ exitingPanelId = "edit";
128
+
129
+ const editIsExiting = computeIsExiting("edit", exitingPanelId, stack);
130
+ expect(editIsExiting).toBe(true);
131
+
132
+ // Back to list immediately (detail becomes exiting, edit timeout still pending)
133
+ stack = ["list"];
134
+ depth = 0;
135
+ exitingPanelId = "detail"; // Updated to detail
136
+
137
+ const detailIsExiting = computeIsExiting("detail", exitingPanelId, stack);
138
+ expect(detailIsExiting).toBe(true);
139
+
140
+ // Push to detail again
141
+ stack = ["list", "detail"];
142
+ depth = 1;
143
+ // exitingPanelId still "detail"
144
+
145
+ const detailIsExitingAfterPush = computeIsExiting("detail", exitingPanelId, stack);
146
+ expect(detailIsExitingAfterPush).toBe(false);
147
+
148
+ const detailDepth = computePanelDepth("detail", detailIsExitingAfterPush, stack, depth);
149
+ expect(detailDepth).toBe(1);
150
+ });
151
+ });
152
+ });
@@ -1,13 +1,18 @@
1
1
  /**
2
2
  * @file Stack Navigation demo - iOS-style hierarchical navigation
3
+ * Uses SwipeStackContent for direct DOM manipulation during swipe gestures.
3
4
  */
4
5
  import * as React from "react";
5
6
  import { useStackNavigation } from "../../../../modules/stack/useStackNavigation.js";
6
7
  import { useStackSwipeInput } from "../../../../modules/stack/useStackSwipeInput.js";
7
- import { StackContent } from "../../../../modules/stack/StackContent.js";
8
+ import { SwipeStackContent } from "../../../../modules/stack/SwipeStackContent.js";
9
+ import { useResizeObserver } from "../../../../hooks/useResizeObserver.js";
10
+ import { toContinuousOperationState } from "../../../../hooks/gesture/types.js";
8
11
  import type { StackPanel } from "../../../../modules/stack/types.js";
9
12
  import styles from "./Stack.module.css";
10
13
 
14
+ const ANIMATION_DURATION = 300;
15
+
11
16
  const panels: StackPanel[] = [
12
17
  {
13
18
  id: "list",
@@ -61,16 +66,78 @@ export const StackBasics: React.FC = () => {
61
66
  const navigation = useStackNavigation({
62
67
  panels,
63
68
  displayMode: "overlay",
64
- transitionMode: "css",
69
+ transitionMode: "none", // Using direct DOM manipulation
65
70
  });
66
71
 
67
- const { isEdgeSwiping, progress, containerProps } = useStackSwipeInput({
72
+ const { isEdgeSwiping, progress, inputState, containerProps } = useStackSwipeInput({
68
73
  containerRef,
69
74
  navigation,
70
75
  edge: "left",
71
76
  edgeWidth: 30,
72
77
  });
73
78
 
79
+ // Track container size for SwipeStackContent
80
+ const { rect } = useResizeObserver(containerRef, { box: "border-box" });
81
+ const containerSize = rect?.width ?? 0;
82
+
83
+ const { stack, depth } = navigation.state;
84
+
85
+ // Track exiting panel when navigating back.
86
+ // CRITICAL: exitingPanelId must be computed synchronously during render
87
+ // to prevent the exiting panel from being unmounted for a frame.
88
+ const prevDepthRef = React.useRef(depth);
89
+ const prevStackRef = React.useRef<ReadonlyArray<string>>(stack);
90
+ const exitingPanelClearTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
91
+ const [exitingPanelState, setExitingPanelState] = React.useState<string | null>(null);
92
+
93
+ // Compute exiting panel ID synchronously during render
94
+ const prevDepth = prevDepthRef.current;
95
+ const prevStack = prevStackRef.current;
96
+ const isNavigatingBack = depth < prevDepth;
97
+ const computedExitingId = isNavigatingBack ? (prevStack[prevDepth] ?? null) : null;
98
+ const exitingPanelId = computedExitingId ?? exitingPanelState;
99
+
100
+ // Update refs and state in effect
101
+ React.useLayoutEffect(() => {
102
+ prevDepthRef.current = depth;
103
+ prevStackRef.current = stack;
104
+
105
+ if (computedExitingId != null) {
106
+ setExitingPanelState(computedExitingId);
107
+
108
+ if (exitingPanelClearTimeoutRef.current != null) {
109
+ clearTimeout(exitingPanelClearTimeoutRef.current);
110
+ }
111
+
112
+ exitingPanelClearTimeoutRef.current = setTimeout(() => {
113
+ setExitingPanelState(null);
114
+ exitingPanelClearTimeoutRef.current = null;
115
+ }, ANIMATION_DURATION);
116
+ }
117
+
118
+ return () => {
119
+ if (exitingPanelClearTimeoutRef.current != null) {
120
+ clearTimeout(exitingPanelClearTimeoutRef.current);
121
+ }
122
+ };
123
+ }, [depth, stack, computedExitingId]);
124
+
125
+ // Get visible panel IDs: active + behind (for swipe reveal) + exiting (for back animation)
126
+ const visiblePanelIds = React.useMemo(() => {
127
+ const ids: string[] = [];
128
+ // Behind panel (if exists)
129
+ if (depth > 0) {
130
+ ids.push(stack[depth - 1]);
131
+ }
132
+ // Active panel
133
+ ids.push(stack[depth]);
134
+ // Include exiting panel if not already in the list
135
+ if (exitingPanelId != null && !ids.includes(exitingPanelId)) {
136
+ ids.push(exitingPanelId);
137
+ }
138
+ return ids;
139
+ }, [stack, depth, exitingPanelId]);
140
+
74
141
  const handleItemClick = (item: typeof listItems[0]) => {
75
142
  setSelectedItem(item);
76
143
  navigation.push("detail");
@@ -98,11 +165,93 @@ export const StackBasics: React.FC = () => {
98
165
  const showBackButton = navigation.state.depth > 0;
99
166
  const showEditButton = navigation.state.depth === 1;
100
167
 
168
+ const renderBackButton = (): React.ReactNode => {
169
+ if (!showBackButton) {
170
+ return null;
171
+ }
172
+ return <BackButton onClick={backButtonProps.onClick} disabled={backButtonProps.disabled} />;
173
+ };
174
+
175
+ const renderPanelContent = (panelId: string): React.ReactNode => {
176
+ if (panelId === "list") {
177
+ return (
178
+ <div className={styles.panel}>
179
+ <ul className={styles.list}>
180
+ {listItems.map((item) => (
181
+ <li key={item.id} className={styles.listItem}>
182
+ <button
183
+ className={styles.listItemButton}
184
+ onClick={() => handleItemClick(item)}
185
+ >
186
+ <span className={styles.listItemName}>{item.name}</span>
187
+ <span className={styles.listItemDesc}>{item.description}</span>
188
+ <span className={styles.chevron}>→</span>
189
+ </button>
190
+ </li>
191
+ ))}
192
+ </ul>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ if (panelId === "detail") {
198
+ return (
199
+ <div className={styles.panel}>
200
+ <div className={styles.detailContent}>
201
+ <h2>{selectedItem?.name}</h2>
202
+ <p>{selectedItem?.description}</p>
203
+ <div className={styles.detailMeta}>
204
+ <span>ID: {selectedItem?.id}</span>
205
+ </div>
206
+ <p className={styles.hint}>
207
+ Swipe from the left edge to go back, or tap the Back button.
208
+ </p>
209
+ </div>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ if (panelId === "edit") {
215
+ return (
216
+ <div className={styles.panel}>
217
+ <div className={styles.editContent}>
218
+ <h2>Edit {selectedItem?.name}</h2>
219
+ <div className={styles.form}>
220
+ <label className={styles.label}>
221
+ Name
222
+ <input
223
+ type="text"
224
+ className={styles.input}
225
+ defaultValue={selectedItem?.name}
226
+ />
227
+ </label>
228
+ <label className={styles.label}>
229
+ Description
230
+ <textarea
231
+ className={styles.textarea}
232
+ defaultValue={selectedItem?.description}
233
+ />
234
+ </label>
235
+ </div>
236
+ <button
237
+ className={styles.saveButton}
238
+ onClick={() => navigation.go(-1)}
239
+ >
240
+ Save Changes
241
+ </button>
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ return null;
248
+ };
249
+
101
250
  return (
102
251
  <div className={styles.container}>
103
252
  {/* Header */}
104
253
  <header className={styles.header}>
105
- {showBackButton && <BackButton onClick={backButtonProps.onClick} disabled={backButtonProps.disabled} />}
254
+ {renderBackButton()}
106
255
  <h1 className={styles.title}>{getHeaderTitle()}</h1>
107
256
  {showEditButton ? <EditButton onClick={handleEdit} /> : null}
108
257
  </header>
@@ -113,97 +262,32 @@ export const StackBasics: React.FC = () => {
113
262
  className={styles.stackContainer}
114
263
  {...containerProps}
115
264
  >
116
- {/* List Panel */}
117
- <StackContent
118
- id="list"
119
- depth={0}
120
- isActive={navigation.currentPanelId === "list"}
121
- displayMode="overlay"
122
- transitionMode="css"
123
- navigationState={navigation.state}
124
- swipeProgress={navigation.currentPanelId === "list" ? progress : undefined}
125
- >
126
- <div className={styles.panel}>
127
- <ul className={styles.list}>
128
- {listItems.map((item) => (
129
- <li key={item.id} className={styles.listItem}>
130
- <button
131
- className={styles.listItemButton}
132
- onClick={() => handleItemClick(item)}
133
- >
134
- <span className={styles.listItemName}>{item.name}</span>
135
- <span className={styles.listItemDesc}>{item.description}</span>
136
- <span className={styles.chevron}>→</span>
137
- </button>
138
- </li>
139
- ))}
140
- </ul>
141
- </div>
142
- </StackContent>
143
-
144
- {/* Detail Panel */}
145
- <StackContent
146
- id="detail"
147
- depth={1}
148
- isActive={navigation.currentPanelId === "detail"}
149
- displayMode="overlay"
150
- transitionMode="css"
151
- navigationState={navigation.state}
152
- swipeProgress={navigation.currentPanelId === "detail" ? progress : undefined}
153
- >
154
- <div className={styles.panel}>
155
- <div className={styles.detailContent}>
156
- <h2>{selectedItem?.name}</h2>
157
- <p>{selectedItem?.description}</p>
158
- <div className={styles.detailMeta}>
159
- <span>ID: {selectedItem?.id}</span>
160
- </div>
161
- <p className={styles.hint}>
162
- Swipe from the left edge to go back, or tap the Back button.
163
- </p>
164
- </div>
165
- </div>
166
- </StackContent>
167
-
168
- {/* Edit Panel */}
169
- <StackContent
170
- id="edit"
171
- depth={2}
172
- isActive={navigation.currentPanelId === "edit"}
173
- displayMode="overlay"
174
- transitionMode="css"
175
- navigationState={navigation.state}
176
- swipeProgress={navigation.currentPanelId === "edit" ? progress : undefined}
177
- >
178
- <div className={styles.panel}>
179
- <div className={styles.editContent}>
180
- <h2>Edit {selectedItem?.name}</h2>
181
- <div className={styles.form}>
182
- <label className={styles.label}>
183
- Name
184
- <input
185
- type="text"
186
- className={styles.input}
187
- defaultValue={selectedItem?.name}
188
- />
189
- </label>
190
- <label className={styles.label}>
191
- Description
192
- <textarea
193
- className={styles.textarea}
194
- defaultValue={selectedItem?.description}
195
- />
196
- </label>
197
- </div>
198
- <button
199
- className={styles.saveButton}
200
- onClick={() => navigation.go(-1)}
201
- >
202
- Save Changes
203
- </button>
204
- </div>
205
- </div>
206
- </StackContent>
265
+ {visiblePanelIds.map((panelId) => {
266
+ // Panel is only "exiting" if it matches exitingPanelId AND is not in current stack
267
+ // This prevents the bug where a re-pushed panel is incorrectly treated as exiting
268
+ const isInCurrentStack = stack.includes(panelId);
269
+ const isExiting = panelId === exitingPanelId && !isInCurrentStack;
270
+ // For exiting panels, use depth + 1 since they were previously at the active position
271
+ const panelDepth = isExiting ? depth + 1 : stack.indexOf(panelId);
272
+ const isActive = panelDepth === depth && !isExiting;
273
+
274
+ return (
275
+ <SwipeStackContent
276
+ key={panelId}
277
+ id={panelId}
278
+ depth={panelDepth}
279
+ navigationDepth={depth}
280
+ isActive={isActive}
281
+ operationState={toContinuousOperationState(inputState)}
282
+ containerSize={containerSize}
283
+ animateOnMount={true}
284
+ animationDuration={ANIMATION_DURATION}
285
+ displayMode="overlay"
286
+ >
287
+ {renderPanelContent(panelId)}
288
+ </SwipeStackContent>
289
+ );
290
+ })}
207
291
  </div>
208
292
 
209
293
  {/* Debug info */}
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @file Tests for StackTablet component logic.
3
+ *
4
+ * These tests focus on the panel visibility and depth calculation logic,
5
+ * particularly around the exitingPanelId handling during rapid navigation.
6
+ */
7
+ import { describe, it, expect } from "vitest";
8
+
9
+ /**
10
+ * Pure function version of the isExiting logic for testing.
11
+ * This matches the logic in StackTablet.tsx.
12
+ */
13
+ function computeIsExiting(
14
+ panelId: string,
15
+ exitingPanelId: string | null,
16
+ stack: readonly string[],
17
+ ): boolean {
18
+ // FIX: Check if panel is in current stack - if so, it's not exiting
19
+ const isInCurrentStack = stack.includes(panelId);
20
+ return panelId === exitingPanelId && !isInCurrentStack;
21
+ }
22
+
23
+ /**
24
+ * Pure function version of panelDepth calculation.
25
+ */
26
+ function computePanelDepth(
27
+ panelId: string,
28
+ isExiting: boolean,
29
+ stack: readonly string[],
30
+ depth: number,
31
+ ): number {
32
+ return isExiting ? depth + 1 : stack.indexOf(panelId);
33
+ }
34
+
35
+ describe("StackTablet panel calculation logic", () => {
36
+ describe("isExiting calculation", () => {
37
+ it("returns false when panel is in current stack even if it matches exitingPanelId", () => {
38
+ // Bug scenario: user pushed panel again before exitingPanelId timeout cleared
39
+ const result = computeIsExiting("general", "general", ["root", "general"]);
40
+ expect(result).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe("panelDepth calculation", () => {
45
+ it("returns correct depth for re-pushed panel (not exiting)", () => {
46
+ // Bug scenario: general was exiting but got re-pushed
47
+ const stack = ["root", "general"];
48
+ const isExiting = computeIsExiting("general", "general", stack);
49
+ const depth = computePanelDepth("general", isExiting, stack, 1);
50
+ expect(depth).toBe(1); // stack.indexOf("general") = 1, NOT depth + 1 = 2
51
+ });
52
+ });
53
+
54
+ describe("rapid navigation scenarios", () => {
55
+ it("handles push → back → push sequence correctly", () => {
56
+ // Initial: root active
57
+ let stack: readonly string[] = ["root"];
58
+ let depth = 0;
59
+ let exitingPanelId: string | null = null;
60
+
61
+ // Push general
62
+ stack = ["root", "general"];
63
+ depth = 1;
64
+
65
+ // Back to root (general becomes exiting)
66
+ stack = ["root"];
67
+ depth = 0;
68
+ exitingPanelId = "general";
69
+
70
+ // Push general again BEFORE exitingPanelId clears
71
+ stack = ["root", "general"];
72
+ depth = 1;
73
+ // exitingPanelId is still "general" (timeout hasn't fired yet)
74
+
75
+ // Now general should NOT be exiting because it's in the stack
76
+ const isExiting = computeIsExiting("general", exitingPanelId, stack);
77
+ expect(isExiting).toBe(false);
78
+
79
+ // Panel depth should be correct
80
+ const panelDepth = computePanelDepth("general", isExiting, stack, depth);
81
+ expect(panelDepth).toBe(1); // Not 2!
82
+ });
83
+
84
+ it("handles Settings menu rapid navigation", () => {
85
+ // Simulating actual Settings menu navigation
86
+ let stack: readonly string[] = ["root"];
87
+ let depth = 0;
88
+ let exitingPanelId: string | null = null;
89
+
90
+ // Click "General"
91
+ stack = ["root", "general"];
92
+ depth = 1;
93
+
94
+ // Click "About"
95
+ stack = ["root", "general", "about"];
96
+ depth = 2;
97
+
98
+ // Back to General
99
+ stack = ["root", "general"];
100
+ depth = 1;
101
+ exitingPanelId = "about";
102
+
103
+ // Immediately back to root
104
+ stack = ["root"];
105
+ depth = 0;
106
+ exitingPanelId = "general";
107
+
108
+ // Immediately push General again
109
+ stack = ["root", "general"];
110
+ depth = 1;
111
+
112
+ // general should NOT be exiting
113
+ const isExiting = computeIsExiting("general", exitingPanelId, stack);
114
+ expect(isExiting).toBe(false);
115
+
116
+ const panelDepth = computePanelDepth("general", isExiting, stack, depth);
117
+ expect(panelDepth).toBe(1);
118
+ });
119
+ });
120
+ });