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
@@ -1,4 +1,6 @@
1
- import { describe, it, expect } from "vitest";
1
+ /**
2
+ * @file Tests for computeAnimatedPanels helper.
3
+ */
2
4
  import { computeAnimatedPanels, type AnimatedPanel } from "./useStackAnimationState.js";
3
5
 
4
6
  describe("computeAnimatedPanels", () => {
@@ -39,6 +39,22 @@ export function computeAnimatedPanels(
39
39
  prevStack: ReadonlyArray<string>,
40
40
  currentStack: ReadonlyArray<string>,
41
41
  ): ReadonlyArray<AnimatedPanel> {
42
+ const getPanelPhase = (
43
+ wasInPrevStack: boolean,
44
+ prevStackLength: number,
45
+ prevPhase: PanelAnimationPhase | undefined,
46
+ ): PanelAnimationPhase => {
47
+ if (!wasInPrevStack && prevStackLength > 0) {
48
+ // New panel pushed onto stack
49
+ return "entering";
50
+ }
51
+ if (prevPhase === "entering") {
52
+ // Was entering, keep entering until animation completes
53
+ return "entering";
54
+ }
55
+ return "active";
56
+ };
57
+
42
58
  const result: AnimatedPanel[] = [];
43
59
  const currentStackSet = new Set(currentStack);
44
60
 
@@ -47,17 +63,7 @@ export function computeAnimatedPanels(
47
63
  const id = currentStack[i];
48
64
  const wasInPrevStack = prevStack.includes(id);
49
65
  const prevPanel = prevPanels.find((p) => p.id === id);
50
-
51
- let phase: PanelAnimationPhase;
52
- if (!wasInPrevStack && prevStack.length > 0) {
53
- // New panel pushed onto stack
54
- phase = "entering";
55
- } else if (prevPanel?.phase === "entering") {
56
- // Was entering, keep entering until animation completes
57
- phase = "entering";
58
- } else {
59
- phase = "active";
60
- }
66
+ const phase = getPanelPhase(wasInPrevStack, prevStack.length, prevPanel?.phase);
61
67
 
62
68
  result.push({ id, depth: i, phase });
63
69
  }
@@ -103,8 +109,7 @@ export function useStackAnimationState(
103
109
 
104
110
  // Compute panels synchronously during render
105
111
  const prevStack = prevStackRef.current;
106
- const stackChanged =
107
- prevStack.length !== stack.length || prevStack.some((id, i) => stack[i] !== id);
112
+ const stackChanged = prevStack.length !== stack.length ? true : prevStack.some((id, i) => stack[i] !== id);
108
113
 
109
114
  if (stackChanged) {
110
115
  panelsRef.current = computeAnimatedPanels(panelsRef.current, prevStack, stack);
@@ -5,6 +5,19 @@ import { renderHook, act } from "@testing-library/react";
5
5
  import { useStackNavigation } from "./useStackNavigation.js";
6
6
  import type { StackPanel } from "./types.js";
7
7
 
8
+ type CallTracker = {
9
+ calls: ReadonlyArray<ReadonlyArray<unknown>>;
10
+ fn: (...args: ReadonlyArray<unknown>) => void;
11
+ };
12
+
13
+ const createCallTracker = (): CallTracker => {
14
+ const calls: Array<ReadonlyArray<unknown>> = [];
15
+ const fn = (...args: ReadonlyArray<unknown>): void => {
16
+ calls.push(args);
17
+ };
18
+ return { calls, fn };
19
+ };
20
+
8
21
  describe("useStackNavigation", () => {
9
22
  const createPanels = (): ReadonlyArray<StackPanel<"root" | "list" | "detail" | "edit">> => [
10
23
  { id: "root", title: "Root", content: "Root Content" },
@@ -94,16 +107,18 @@ describe("useStackNavigation", () => {
94
107
 
95
108
  it("calls onPanelChange when pushing", () => {
96
109
  const panels = createPanels();
97
- const onPanelChange = vi.fn();
110
+ const onPanelChange = createCallTracker();
98
111
  const { result } = renderHook(() =>
99
- useStackNavigation({ panels, displayMode: "overlay", onPanelChange }),
112
+ useStackNavigation({ panels, displayMode: "overlay", onPanelChange: onPanelChange.fn }),
100
113
  );
101
114
 
102
115
  act(() => {
103
116
  result.current.push("list");
104
117
  });
105
118
 
106
- expect(onPanelChange).toHaveBeenCalledWith("list", 1);
119
+ expect(onPanelChange.calls).toHaveLength(1);
120
+ expect(onPanelChange.calls[0]?.[0]).toBe("list");
121
+ expect(onPanelChange.calls[0]?.[1]).toBe(1);
107
122
  });
108
123
  });
109
124
 
@@ -474,4 +489,184 @@ describe("useStackNavigation", () => {
474
489
  expect(result.current.state.depth).toBe(0);
475
490
  });
476
491
  });
492
+
493
+ describe("rapid navigation stress tests", () => {
494
+ it("handles rapid push-go-push-go cycles correctly", () => {
495
+ const panels = createPanels();
496
+ const { result } = renderHook(() =>
497
+ useStackNavigation({ panels, displayMode: "overlay" }),
498
+ );
499
+
500
+ // Simulate rapid clicks: push -> go back -> push -> go back (all in single act)
501
+ act(() => {
502
+ result.current.push("list");
503
+ result.current.go(-1);
504
+ result.current.push("detail");
505
+ result.current.go(-1);
506
+ result.current.push("edit");
507
+ result.current.go(-1);
508
+ });
509
+
510
+ // Should end at root after all back navigations
511
+ expect(result.current.state.stack).toEqual(["root"]);
512
+ expect(result.current.state.depth).toBe(0);
513
+ expect(result.current.currentPanelId).toBe("root");
514
+ });
515
+
516
+ it("handles deep nesting then rapid back to root", () => {
517
+ const panels = createPanels();
518
+ const { result } = renderHook(() =>
519
+ useStackNavigation({ panels, displayMode: "overlay" }),
520
+ );
521
+
522
+ // Push to max depth
523
+ act(() => {
524
+ result.current.push("list");
525
+ result.current.push("detail");
526
+ result.current.push("edit");
527
+ });
528
+
529
+ expect(result.current.state.depth).toBe(3);
530
+
531
+ // Rapidly go back to root in single act
532
+ act(() => {
533
+ result.current.go(-1);
534
+ result.current.go(-1);
535
+ result.current.go(-1);
536
+ });
537
+
538
+ expect(result.current.state.stack).toEqual(["root"]);
539
+ expect(result.current.state.depth).toBe(0);
540
+ });
541
+
542
+ it("handles alternating push-go-push pattern", () => {
543
+ const panels = createPanels();
544
+ const { result } = renderHook(() =>
545
+ useStackNavigation({ panels, displayMode: "overlay" }),
546
+ );
547
+
548
+ act(() => {
549
+ result.current.push("list"); // depth 1
550
+ result.current.go(-1); // depth 0
551
+ result.current.push("list"); // depth 1
552
+ result.current.push("detail"); // depth 2
553
+ result.current.go(-1); // depth 1
554
+ result.current.go(-1); // depth 0
555
+ result.current.push("edit"); // depth 1
556
+ });
557
+
558
+ expect(result.current.state.stack).toEqual(["root", "edit"]);
559
+ expect(result.current.state.depth).toBe(1);
560
+ });
561
+
562
+ it("handles excessive back navigation attempts gracefully", () => {
563
+ const panels = createPanels();
564
+ const { result } = renderHook(() =>
565
+ useStackNavigation({ panels, displayMode: "overlay" }),
566
+ );
567
+
568
+ act(() => {
569
+ result.current.push("list");
570
+ });
571
+
572
+ // Try to go back more times than possible
573
+ act(() => {
574
+ result.current.go(-1);
575
+ result.current.go(-1); // Already at root, should be ignored
576
+ result.current.go(-1); // Should be ignored
577
+ result.current.go(-1); // Should be ignored
578
+ });
579
+
580
+ expect(result.current.state.stack).toEqual(["root"]);
581
+ expect(result.current.state.depth).toBe(0);
582
+ });
583
+
584
+ it("handles interleaved push/go/replace operations", () => {
585
+ const panels = createPanels();
586
+ const { result } = renderHook(() =>
587
+ useStackNavigation({ panels, displayMode: "overlay" }),
588
+ );
589
+
590
+ act(() => {
591
+ result.current.push("list");
592
+ result.current.replace("detail");
593
+ result.current.push("edit");
594
+ result.current.go(-1);
595
+ result.current.replace("list");
596
+ });
597
+
598
+ expect(result.current.state.stack).toEqual(["root", "list"]);
599
+ expect(result.current.currentPanelId).toBe("list");
600
+ });
601
+
602
+ it("maintains correct onPanelChange calls during rapid navigation", () => {
603
+ const panels = createPanels();
604
+ const onPanelChange = createCallTracker();
605
+ const { result } = renderHook(() =>
606
+ useStackNavigation({
607
+ panels,
608
+ displayMode: "overlay",
609
+ onPanelChange: onPanelChange.fn,
610
+ }),
611
+ );
612
+
613
+ act(() => {
614
+ result.current.push("list");
615
+ result.current.push("detail");
616
+ result.current.go(-1);
617
+ result.current.go(-1);
618
+ });
619
+
620
+ // Should have called onPanelChange for each navigation
621
+ // Final state should be at root
622
+ expect(result.current.state.depth).toBe(0);
623
+ expect(result.current.currentPanelId).toBe("root");
624
+ });
625
+
626
+ it("handles move(0) after deep navigation", () => {
627
+ const panels = createPanels();
628
+ const { result } = renderHook(() =>
629
+ useStackNavigation({ panels, displayMode: "overlay" }),
630
+ );
631
+
632
+ act(() => {
633
+ result.current.push("list");
634
+ result.current.push("detail");
635
+ result.current.push("edit");
636
+ result.current.move(0); // Jump directly to root
637
+ });
638
+
639
+ expect(result.current.state.stack).toEqual(["root"]);
640
+ expect(result.current.state.depth).toBe(0);
641
+ });
642
+
643
+ it("canGo returns correct value during rapid state changes", () => {
644
+ const panels = createPanels();
645
+ const { result } = renderHook(() =>
646
+ useStackNavigation({ panels, displayMode: "overlay" }),
647
+ );
648
+
649
+ act(() => {
650
+ result.current.push("list");
651
+ result.current.push("detail");
652
+ });
653
+
654
+ expect(result.current.canGo(-1)).toBe(true);
655
+ expect(result.current.canGo(-2)).toBe(true);
656
+ expect(result.current.canGo(-3)).toBe(false);
657
+
658
+ act(() => {
659
+ result.current.go(-1);
660
+ });
661
+
662
+ expect(result.current.canGo(-1)).toBe(true);
663
+ expect(result.current.canGo(-2)).toBe(false);
664
+
665
+ act(() => {
666
+ result.current.go(-1);
667
+ });
668
+
669
+ expect(result.current.canGo(-1)).toBe(false);
670
+ });
671
+ });
477
672
  });
@@ -16,6 +16,56 @@ import type {
16
16
  import { StackContent } from "./StackContent.js";
17
17
  import { useContentCache } from "../../hooks/useContentCache.js";
18
18
 
19
+ /**
20
+ * Navigation action types for centralized state management.
21
+ * All navigation operations go through the reducer to avoid stale closure issues.
22
+ */
23
+ type StackAction<TId extends string> =
24
+ | { type: "push"; id: TId }
25
+ | { type: "go"; direction: number }
26
+ | { type: "move"; targetDepth: number }
27
+ | { type: "replace"; id: TId };
28
+
29
+ /**
30
+ * Reducer for stack navigation state.
31
+ * Centralizes all state transitions to ensure consistent behavior during rapid navigation.
32
+ */
33
+ function stackReducer<TId extends string>(
34
+ state: ReadonlyArray<TId>,
35
+ action: StackAction<TId>,
36
+ ): ReadonlyArray<TId> {
37
+ switch (action.type) {
38
+ case "push":
39
+ return [...state, action.id];
40
+
41
+ case "go": {
42
+ if (action.direction >= 0) {
43
+ return state;
44
+ }
45
+ const currentDepth = state.length - 1;
46
+ const targetDepth = currentDepth + action.direction;
47
+ if (targetDepth < 0) {
48
+ return state;
49
+ }
50
+ return state.slice(0, targetDepth + 1);
51
+ }
52
+
53
+ case "move": {
54
+ if (action.targetDepth < 0 || action.targetDepth >= state.length) {
55
+ return state;
56
+ }
57
+ return state.slice(0, action.targetDepth + 1);
58
+ }
59
+
60
+ case "replace": {
61
+ if (state.length === 0) {
62
+ return [action.id];
63
+ }
64
+ return [...state.slice(0, -1), action.id];
65
+ }
66
+ }
67
+ }
68
+
19
69
  /**
20
70
  * Context for sharing stack state with Outlet component.
21
71
  */
@@ -108,14 +158,35 @@ export function useStackNavigation<TId extends string = string>(
108
158
  onPanelChange,
109
159
  } = options;
110
160
 
111
- // Initialize stack with initial panel
112
- const [stack, setStack] = React.useState<ReadonlyArray<TId>>(() => {
113
- const initialId = initialPanelId ?? (panels[0]?.id as TId);
114
- if (!initialId) {
115
- throw new Error("useStackNavigation: No panels provided");
161
+ // Initialize stack with reducer for centralized state management
162
+ const initialId = initialPanelId ?? (panels[0]?.id as TId);
163
+ if (!initialId) {
164
+ throw new Error("useStackNavigation: No panels provided");
165
+ }
166
+
167
+ const [stack, dispatch] = React.useReducer(
168
+ stackReducer<TId>,
169
+ [initialId] as ReadonlyArray<TId>,
170
+ );
171
+
172
+ // Ref for accessing current stack in callbacks without stale closure
173
+ const stackRef = React.useRef(stack);
174
+ stackRef.current = stack;
175
+
176
+ // Track previous stack for onPanelChange callback
177
+ const prevStackRef = React.useRef(stack);
178
+ React.useEffect(() => {
179
+ const prevStack = prevStackRef.current;
180
+ prevStackRef.current = stack;
181
+
182
+ if (onPanelChange && stack !== prevStack) {
183
+ const newDepth = stack.length - 1;
184
+ const newPanelId = stack[newDepth];
185
+ if (newPanelId !== undefined) {
186
+ onPanelChange(newPanelId, newDepth);
187
+ }
116
188
  }
117
- return [initialId];
118
- });
189
+ }, [stack, onPanelChange]);
119
190
 
120
191
  // Reveal state for parent peeking
121
192
  const [revealState, setRevealState] = React.useState<{
@@ -138,85 +209,68 @@ export function useStackNavigation<TId extends string = string>(
138
209
  const currentPanelId = stack[depth] as TId;
139
210
  const previousPanelId = depth > 0 ? stack[depth - 1] as TId : null;
140
211
 
141
- // Push a new panel onto the stack
212
+ // All navigation functions dispatch to reducer - no stale closure issues
142
213
  const push = React.useCallback((id: TId) => {
143
214
  const panel = panels.find((p) => p.id === id);
144
215
  if (!panel) {
145
216
  return;
146
217
  }
147
- setStack((prev) => [...prev, id]);
148
- onPanelChange?.(id, depth + 1);
149
- }, [panels, depth, onPanelChange]);
218
+ dispatch({ type: "push", id });
219
+ }, [panels]);
150
220
 
151
- // Navigate in a direction
152
221
  const go = React.useCallback((direction: number) => {
153
- if (direction >= 0) {
154
- return; // go is only for going back in stack navigation
155
- }
156
- const targetDepth = depth + direction;
157
- if (targetDepth < 0) {
158
- return;
159
- }
160
- setStack((prev) => prev.slice(0, targetDepth + 1));
161
- const targetId = stack[targetDepth] as TId;
162
- onPanelChange?.(targetId, targetDepth);
163
- }, [depth, stack, onPanelChange]);
222
+ dispatch({ type: "go", direction });
223
+ }, []);
164
224
 
165
- // Move to absolute depth
166
225
  const move = React.useCallback((targetDepth: number) => {
167
- if (targetDepth < 0 || targetDepth >= stack.length) {
168
- return;
169
- }
170
- setStack((prev) => prev.slice(0, targetDepth + 1));
171
- const targetId = stack[targetDepth] as TId;
172
- onPanelChange?.(targetId, targetDepth);
173
- }, [stack, onPanelChange]);
226
+ dispatch({ type: "move", targetDepth });
227
+ }, []);
174
228
 
175
- // Replace current panel
176
229
  const replace = React.useCallback((id: TId) => {
177
230
  const panel = panels.find((p) => p.id === id);
178
231
  if (!panel) {
179
232
  return;
180
233
  }
181
- setStack((prev) => [...prev.slice(0, -1), id]);
182
- onPanelChange?.(id, depth);
183
- }, [panels, depth, onPanelChange]);
234
+ dispatch({ type: "replace", id });
235
+ }, [panels]);
184
236
 
185
- // Check if navigation is possible
237
+ // canGo uses stackRef for current state
186
238
  const canGo = React.useCallback((direction: number): boolean => {
187
239
  if (direction >= 0) {
188
- return false; // canGo only checks backward navigation for stacks
240
+ return false;
189
241
  }
190
- const targetDepth = depth + direction;
191
- return targetDepth >= 0;
192
- }, [depth]);
242
+ const currentDepth = stackRef.current.length - 1;
243
+ return currentDepth + direction >= 0;
244
+ }, []);
193
245
 
194
- // Reveal parent panel
246
+ // Reveal functions use stackRef for current depth
195
247
  const revealParent = React.useCallback((targetDepth?: number) => {
196
- const revealTo = targetDepth ?? depth - 1;
197
- if (revealTo < 0 || revealTo >= depth) {
248
+ const currentDepth = stackRef.current.length - 1;
249
+ const revealTo = targetDepth ?? currentDepth - 1;
250
+ if (revealTo < 0 || revealTo >= currentDepth) {
198
251
  return;
199
252
  }
200
253
  setRevealState({ isRevealing: true, revealDepth: revealTo });
201
- }, [depth]);
254
+ }, []);
202
255
 
203
- // Reveal root panel
204
256
  const revealRoot = React.useCallback(() => {
205
- if (depth === 0) {
257
+ const currentDepth = stackRef.current.length - 1;
258
+ if (currentDepth === 0) {
206
259
  return;
207
260
  }
208
261
  setRevealState({ isRevealing: true, revealDepth: 0 });
209
- }, [depth]);
262
+ }, []);
210
263
 
211
- // Dismiss reveal
212
264
  const dismissReveal = React.useCallback(() => {
213
265
  setRevealState({ isRevealing: false, revealDepth: null });
214
266
  }, []);
215
267
 
216
- // Get props for a panel element
268
+ // getPanelProps uses stackRef for current state
217
269
  const getPanelProps = React.useCallback((id: TId): StackPanelProps => {
218
- const panelIndex = stack.indexOf(id);
219
- const isActive = panelIndex === depth;
270
+ const currentStack = stackRef.current;
271
+ const panelIndex = currentStack.indexOf(id);
272
+ const currentDepth = currentStack.length - 1;
273
+ const isActive = panelIndex === currentDepth;
220
274
 
221
275
  return {
222
276
  "data-stack-panel": id,
@@ -224,12 +278,15 @@ export function useStackNavigation<TId extends string = string>(
224
278
  "data-active": isActive ? "true" : "false",
225
279
  "aria-hidden": !isActive,
226
280
  };
227
- }, [stack, depth]);
281
+ }, []);
228
282
 
229
- // Get props for back button
283
+ // getBackButtonProps uses stackRef for current state
230
284
  const getBackButtonProps = React.useCallback((): StackBackButtonProps => {
231
- const canGoBack = depth > 0;
232
- const prevPanel = previousPanelId ? panels.find((p) => p.id === previousPanelId) : null;
285
+ const currentStack = stackRef.current;
286
+ const currentDepth = currentStack.length - 1;
287
+ const canGoBack = currentDepth > 0;
288
+ const prevPanelId = currentDepth > 0 ? currentStack[currentDepth - 1] : null;
289
+ const prevPanel = prevPanelId ? panels.find((p) => p.id === prevPanelId) : null;
233
290
  const label = prevPanel?.title ? `Back to ${prevPanel.title}` : "Go back";
234
291
 
235
292
  return {
@@ -237,7 +294,7 @@ export function useStackNavigation<TId extends string = string>(
237
294
  disabled: !canGoBack,
238
295
  "aria-label": label,
239
296
  };
240
- }, [depth, previousPanelId, panels, go]);
297
+ }, [panels, go]);
241
298
 
242
299
  // Container style
243
300
  const containerStyle: React.CSSProperties = React.useMemo(