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,135 @@
1
+ /**
2
+ * @file Hook for maintaining value continuity during continuous operations.
3
+ *
4
+ * During operations like swipe gestures, external state (navigation depth, panel roles)
5
+ * may change before the gesture ends. This hook provides a pattern to:
6
+ * - Retain the previous value during the operation for visual continuity
7
+ * - Accept the new value when the operation ends
8
+ * - Track whether the value changed during the operation
9
+ *
10
+ * This is a core primitive for the "operation continuity" pattern used throughout
11
+ * the swipe gesture system.
12
+ */
13
+ import * as React from "react";
14
+
15
+ /**
16
+ * Result from useOperationContinuity hook.
17
+ */
18
+ export type UseOperationContinuityResult<T> = {
19
+ /** The effective value (retained during operation, current after) */
20
+ value: T;
21
+ /**
22
+ * True if the value changed during the operation.
23
+ *
24
+ * This is useful for determining how to handle the transition when the
25
+ * operation ends. For example, if the role changed during a swipe,
26
+ * the target position change at operation end should snap rather than animate.
27
+ *
28
+ * This flag is true on the render where shouldRetainPrevious becomes false
29
+ * (operation end), allowing consumers to handle the transition appropriately.
30
+ * It resets to false on subsequent renders.
31
+ */
32
+ changedDuringOperation: boolean;
33
+ /**
34
+ * True on the render where the operation just ended.
35
+ *
36
+ * This is true when shouldRetainPrevious transitions from true to false,
37
+ * regardless of whether the value changed. Use this to detect the moment
38
+ * when an operation completes and delay any immediate animations.
39
+ *
40
+ * In the over-swipe case, this helps prevent unwanted snap-back animation
41
+ * in the intermediate render before navigation changes.
42
+ */
43
+ operationJustEnded: boolean;
44
+ };
45
+
46
+ /**
47
+ * Hook for maintaining value continuity during continuous operations.
48
+ *
49
+ * When an operation is in progress, this hook retains the previous value
50
+ * to prevent sudden visual changes from state updates. Once the operation
51
+ * ends (shouldRetainPrevious becomes false), the new value is accepted.
52
+ *
53
+ * Additionally, this hook tracks whether the value changed during the operation,
54
+ * which is useful for determining animation behavior at operation end.
55
+ *
56
+ * IMPORTANT: This hook is designed to be idempotent during render to work
57
+ * correctly with React StrictMode, which calls the render function twice.
58
+ * All ref mutations happen in useLayoutEffect, not during render.
59
+ *
60
+ * @param value - The current value from external state
61
+ * @param shouldRetainPrevious - Whether to retain the previous value (true during operation)
62
+ * @returns Object with effective value and whether it changed during operation
63
+ *
64
+ * @example
65
+ * ```tsx
66
+ * // Maintain role continuity during swipe
67
+ * const { value: effectiveRole, changedDuringOperation } = useOperationContinuity(
68
+ * role,
69
+ * displacement > 0,
70
+ * );
71
+ *
72
+ * // Use changedDuringOperation to skip animation on operation end
73
+ * useSwipeContentTransform({
74
+ * // ...
75
+ * skipTargetChangeAnimation: changedDuringOperation,
76
+ * });
77
+ * ```
78
+ */
79
+ export function useOperationContinuity<T>(
80
+ value: T,
81
+ shouldRetainPrevious: boolean,
82
+ ): UseOperationContinuityResult<T> {
83
+ // Store previous shouldRetainPrevious to detect transitions
84
+ const prevShouldRetainRef = React.useRef(shouldRetainPrevious);
85
+ // Store retained value (the value at the start of retention)
86
+ const retainedValueRef = React.useRef(value);
87
+ // Track if value changed during retention
88
+ const changedDuringRetentionRef = React.useRef(false);
89
+
90
+ // Derive operationJustEnded from transition: true → false
91
+ // This is idempotent - safe for StrictMode double-render
92
+ const wasRetaining = prevShouldRetainRef.current;
93
+ const operationJustEnded = wasRetaining && !shouldRetainPrevious;
94
+
95
+ // Check if value diverged from retained value
96
+ // This includes both current-render divergence and previously-tracked divergence
97
+ const valueDiverged = value !== retainedValueRef.current;
98
+ const currentlyDiverged = shouldRetainPrevious && valueDiverged;
99
+
100
+ // Derive changedDuringOperation
101
+ // True if:
102
+ // 1. Value diverged during retention (tracked from previous renders via ref)
103
+ // 2. Value diverges right now during retention (immediate comparison)
104
+ // 3. Value diverged at the moment retention ends
105
+ const changedDuringRetention = changedDuringRetentionRef.current || currentlyDiverged;
106
+ const changedAtExit = operationJustEnded && valueDiverged;
107
+ const changedDuringOperation = changedDuringRetention || changedAtExit;
108
+
109
+ // Determine effective value
110
+ // During retention: use retained value
111
+ // After retention ends: use current value
112
+ const effectiveValue = shouldRetainPrevious ? retainedValueRef.current : value;
113
+
114
+ // Update refs in useLayoutEffect to ensure idempotency during render.
115
+ // This runs once per commit, not per render in StrictMode.
116
+ React.useLayoutEffect(() => {
117
+ if (!shouldRetainPrevious) {
118
+ // Retention ended or never started - reset state
119
+ changedDuringRetentionRef.current = false;
120
+ retainedValueRef.current = value;
121
+ } else {
122
+ // During retention - track if value diverged
123
+ if (currentlyDiverged) {
124
+ changedDuringRetentionRef.current = true;
125
+ }
126
+ }
127
+ prevShouldRetainRef.current = shouldRetainPrevious;
128
+ });
129
+
130
+ return {
131
+ value: effectiveValue,
132
+ changedDuringOperation,
133
+ operationJustEnded,
134
+ };
135
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * @file Tests for useResizeObserver hook.
3
+ *
4
+ * These tests define the contract that useResizeObserver must fulfill.
5
+ * The key requirement is that consuming components can rely on containerSize
6
+ * being available when their animation logic runs.
7
+ */
8
+ /* eslint-disable no-restricted-syntax -- Dynamic imports needed for isolated module testing */
9
+ import { render, act } from "@testing-library/react";
10
+ import * as React from "react";
11
+
12
+ // We'll import the hook after defining what it should do
13
+ // import { useResizeObserver } from "./useResizeObserver.js";
14
+
15
+ /**
16
+ * Mock ResizeObserver
17
+ */
18
+ class MockResizeObserver implements ResizeObserver {
19
+ private static instances: MockResizeObserver[] = [];
20
+ private callback: ResizeObserverCallback;
21
+ private observed = new Set<Element>();
22
+
23
+ static getInstances(): MockResizeObserver[] {
24
+ return this.instances;
25
+ }
26
+
27
+ static clearInstances(): void {
28
+ this.instances = [];
29
+ }
30
+
31
+ constructor(callback: ResizeObserverCallback) {
32
+ this.callback = callback;
33
+ MockResizeObserver.instances.push(this);
34
+ }
35
+
36
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars -- Required for interface compatibility
37
+ observe(target: Element, options?: ResizeObserverOptions): void {
38
+ this.observed.add(target);
39
+ // Real ResizeObserver fires callback asynchronously after observe
40
+ // We simulate immediate callback for testing
41
+ const entry = this.createEntry(target, 400, 300);
42
+ this.callback([entry], this);
43
+ }
44
+
45
+ unobserve(target: Element): void {
46
+ this.observed.delete(target);
47
+ }
48
+
49
+ disconnect(): void {
50
+ this.observed.clear();
51
+ }
52
+
53
+ triggerResize(target: Element, width: number, height: number): void {
54
+ if (this.observed.has(target)) {
55
+ const entry = this.createEntry(target, width, height);
56
+ this.callback([entry], this);
57
+ }
58
+ }
59
+
60
+ private createEntry(target: Element, width: number, height: number): ResizeObserverEntry {
61
+ return {
62
+ target,
63
+ contentRect: new DOMRect(0, 0, width, height),
64
+ borderBoxSize: [{ inlineSize: width, blockSize: height }],
65
+ contentBoxSize: [{ inlineSize: width, blockSize: height }],
66
+ devicePixelContentBoxSize: [],
67
+ };
68
+ }
69
+ }
70
+
71
+ const originalResizeObserver = globalThis.ResizeObserver;
72
+
73
+ describe("useResizeObserver contract", () => {
74
+ beforeEach(() => {
75
+ MockResizeObserver.clearInstances();
76
+ globalThis.ResizeObserver = MockResizeObserver;
77
+
78
+ // Mock getBoundingClientRect
79
+ Element.prototype.getBoundingClientRect = function () {
80
+ return new DOMRect(0, 0, 400, 300);
81
+ };
82
+ });
83
+
84
+ afterEach(() => {
85
+ globalThis.ResizeObserver = originalResizeObserver;
86
+ });
87
+
88
+ describe("timing requirements", () => {
89
+ /**
90
+ * This is the critical test case.
91
+ *
92
+ * In the real use case (StackTablet -> SwipeStackContent):
93
+ * 1. StackTablet renders, calls useResizeObserver
94
+ * 2. StackTablet passes containerSize to SwipeStackContent
95
+ * 3. SwipeStackContent uses containerSize in its first useLayoutEffect
96
+ *
97
+ * The question: Can containerSize be > 0 when SwipeStackContent's
98
+ * useLayoutEffect runs for the first time?
99
+ *
100
+ * Answer: This depends on React's effect execution order.
101
+ * - Effects run in order of hook registration
102
+ * - Parent's effects run before children's effects? NO.
103
+ * - Actually, React runs effects bottom-up (children first, then parents)
104
+ *
105
+ * So when SwipeStackContent's useLayoutEffect runs:
106
+ * - It's the FIRST effect to run (child before parent)
107
+ * - useResizeObserver's useLayoutEffect hasn't run yet
108
+ * - Therefore containerSize is still 0
109
+ *
110
+ * This means we MUST handle containerSize=0 in SwipeStackContent.
111
+ * The question is: how to abstract this properly?
112
+ */
113
+ it("documents React effect execution order", () => {
114
+ const executionOrder: string[] = [];
115
+
116
+ const Child: React.FC<{ size: number }> = ({ size }) => {
117
+ React.useLayoutEffect(() => {
118
+ executionOrder.push(`child-layout-effect: size=${size}`);
119
+ }, []);
120
+
121
+ return <div>Child: {size}</div>;
122
+ };
123
+
124
+ const Parent: React.FC = () => {
125
+ const ref = React.useRef<HTMLDivElement>(null);
126
+ const [size, setSize] = React.useState(0);
127
+
128
+ React.useLayoutEffect(() => {
129
+ executionOrder.push("parent-layout-effect: measuring");
130
+ if (ref.current) {
131
+ setSize(ref.current.getBoundingClientRect().width);
132
+ }
133
+ }, []);
134
+
135
+ executionOrder.push(`parent-render: size=${size}`);
136
+
137
+ return (
138
+ <div ref={ref}>
139
+ <Child size={size} />
140
+ </div>
141
+ );
142
+ };
143
+
144
+ render(<Parent />);
145
+
146
+ // This documents the actual execution order
147
+ // Parent renders first with size=0
148
+ // Child renders with size=0
149
+ // Child's useLayoutEffect runs (sees size=0)
150
+ // Parent's useLayoutEffect runs (sets size=400)
151
+ // Re-render with size=400
152
+ expect(executionOrder).toContain("child-layout-effect: size=0");
153
+ });
154
+
155
+ /**
156
+ * Given the above, the proper abstraction is:
157
+ *
158
+ * useResizeObserver should provide a way for consumers to know
159
+ * when the size is "ready" (first valid measurement complete).
160
+ *
161
+ * Consumers that depend on size for animation should:
162
+ * - Not start animation until size is ready
163
+ * - OR use a hook that handles this internally
164
+ */
165
+ it("should indicate when size is ready", async () => {
166
+ // Import dynamically to test the implementation
167
+ const { useResizeObserver } = await import("./useResizeObserver.js");
168
+
169
+ const results: Array<{ width: number; isReady: boolean }> = [];
170
+
171
+ const TestComponent: React.FC = () => {
172
+ const ref = React.useRef<HTMLDivElement>(null);
173
+ const { rect } = useResizeObserver(ref, { box: "border-box" });
174
+
175
+ const width = rect?.width ?? 0;
176
+ const isReady = rect !== null;
177
+
178
+ React.useEffect(() => {
179
+ results.push({ width, isReady });
180
+ }, [width, isReady]);
181
+
182
+ return <div ref={ref} style={{ width: 400, height: 300 }} />;
183
+ };
184
+
185
+ render(<TestComponent />);
186
+
187
+ // After effects run, size should be available
188
+ await act(async () => {});
189
+
190
+ const lastResult = results[results.length - 1];
191
+ expect(lastResult.isReady).toBe(true);
192
+ expect(lastResult.width).toBe(400);
193
+ });
194
+ });
195
+
196
+ describe("memory efficiency", () => {
197
+ it("shares ResizeObserver instances for same box option", async () => {
198
+ const { useResizeObserver, clearObserverCache } = await import("./useResizeObserver.js");
199
+ clearObserverCache();
200
+ MockResizeObserver.clearInstances();
201
+
202
+ const TestComponent: React.FC = () => {
203
+ const ref1 = React.useRef<HTMLDivElement>(null);
204
+ const ref2 = React.useRef<HTMLDivElement>(null);
205
+ const ref3 = React.useRef<HTMLDivElement>(null);
206
+
207
+ useResizeObserver(ref1, { box: "border-box" });
208
+ useResizeObserver(ref2, { box: "border-box" });
209
+ useResizeObserver(ref3, { box: "border-box" });
210
+
211
+ return (
212
+ <>
213
+ <div ref={ref1} />
214
+ <div ref={ref2} />
215
+ <div ref={ref3} />
216
+ </>
217
+ );
218
+ };
219
+
220
+ render(<TestComponent />);
221
+
222
+ expect(MockResizeObserver.getInstances().length).toBe(1);
223
+ });
224
+ });
225
+
226
+ describe("resize updates", () => {
227
+ it("updates when element size changes", async () => {
228
+ const { useResizeObserver, clearObserverCache } = await import("./useResizeObserver.js");
229
+ clearObserverCache();
230
+ MockResizeObserver.clearInstances();
231
+
232
+ const widths: number[] = [];
233
+
234
+ const TestComponent: React.FC = () => {
235
+ const ref = React.useRef<HTMLDivElement>(null);
236
+ const { rect } = useResizeObserver(ref, { box: "border-box" });
237
+
238
+ React.useEffect(() => {
239
+ if (rect) {
240
+ widths.push(rect.width);
241
+ }
242
+ }, [rect]);
243
+
244
+ return <div ref={ref} data-testid="target" />;
245
+ };
246
+
247
+ const { getByTestId } = render(<TestComponent />);
248
+
249
+ await act(async () => {});
250
+
251
+ const element = getByTestId("target");
252
+ const observer = MockResizeObserver.getInstances()[0];
253
+
254
+ act(() => {
255
+ observer.triggerResize(element, 800, 600);
256
+ });
257
+
258
+ expect(widths).toContain(400); // Initial
259
+ expect(widths).toContain(800); // After resize
260
+ });
261
+ });
262
+ });
263
+
264
+ /**
265
+ * Based on the tests above, the conclusion is:
266
+ *
267
+ * 1. React's effect execution order means child effects run before parent effects
268
+ * 2. Therefore, on first render, child components will see containerSize=0
269
+ * 3. This is a fundamental React constraint, not a bug in useResizeObserver
270
+ *
271
+ * The proper abstraction for SwipeStackContent is:
272
+ * - Check if containerSize > 0 before consuming isFirstMount
273
+ * - This is NOT a workaround, it's the correct pattern for this React constraint
274
+ *
275
+ * Alternatively, create a higher-level hook like useAnimatedStack that
276
+ * encapsulates both the size observation and the "ready" state.
277
+ */
@@ -1,80 +1,149 @@
1
1
  /**
2
2
  * @file Shared useResizeObserver hook with cached observer instances.
3
+ *
4
+ * Provides element size observation with shared observers for memory efficiency.
5
+ * Size becomes available after the first useLayoutEffect cycle completes.
6
+ *
7
+ * Note: Due to React's effect execution order (children before parents),
8
+ * child components may see containerSize=0 on their first effect run.
9
+ * This is a React constraint, not a bug. Consumers should check for
10
+ * valid size before using it for calculations like animation positions.
3
11
  */
4
12
  import * as React from "react";
13
+ import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect.js";
5
14
 
6
- type Unobserve = () => void;
7
- type Callback = (entry: ResizeObserverEntry, observer: ResizeObserver) => void;
15
+ /**
16
+ * Shared ResizeObserver that can observe multiple elements.
17
+ */
8
18
  type SharedObserver = {
9
- observe: (target: Element, callback: Callback) => Unobserve;
19
+ observe: (target: Element, callback: (entry: ResizeObserverEntry) => void) => () => void;
10
20
  };
21
+
22
+ /** Cache of shared observers per box option */
11
23
  const observerCache = new Map<string, SharedObserver>();
12
- const getSharedObserver = (options: ResizeObserverOptions) => {
13
- const { box = "content-box" } = options;
24
+
25
+ /**
26
+ * Get or create a shared ResizeObserver for the given box option.
27
+ */
28
+ const getSharedObserver = (box: ResizeObserverBoxOptions): SharedObserver => {
14
29
  const observerKey = `resize-box:${box}`;
15
30
  const cached = observerCache.get(observerKey);
16
31
  if (cached) {
17
32
  return cached;
18
33
  }
19
- const observer = new (class {
20
- #callbackMap = new Map<Element, Callback>();
21
- #resizeObserver = new ResizeObserver((entries, observer) => {
22
- entries.forEach((entry) => {
23
- const callback = this.#callbackMap.get(entry.target);
24
- if (callback) {
25
- callback(entry, observer);
26
- }
27
- });
28
- });
29
- observe(target: Element, callback: Callback) {
30
- this.#callbackMap.set(target, callback);
31
- this.#resizeObserver.observe(target, options);
34
+
35
+ const callbacks = new Map<Element, (entry: ResizeObserverEntry) => void>();
36
+
37
+ const resizeObserver = new ResizeObserver((entries) => {
38
+ for (const entry of entries) {
39
+ const callback = callbacks.get(entry.target);
40
+ if (callback) {
41
+ callback(entry);
42
+ }
43
+ }
44
+ });
45
+
46
+ const sharedObserver: SharedObserver = {
47
+ observe(target, callback) {
48
+ callbacks.set(target, callback);
49
+ resizeObserver.observe(target, { box });
50
+
32
51
  return () => {
33
- this.#callbackMap.delete(target);
34
- this.#resizeObserver.unobserve(target);
52
+ callbacks.delete(target);
53
+ resizeObserver.unobserve(target);
35
54
  };
36
- }
37
- })();
38
- observerCache.set(observerKey, observer);
55
+ },
56
+ };
57
+
58
+ observerCache.set(observerKey, sharedObserver);
59
+ return sharedObserver;
60
+ };
61
+
62
+ /**
63
+ * Create a ResizeObserverEntry from getBoundingClientRect.
64
+ */
65
+ const measureElement = (target: Element): ResizeObserverEntry => {
66
+ const rect = target.getBoundingClientRect();
67
+ return {
68
+ target,
69
+ contentRect: rect,
70
+ borderBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
71
+ contentBoxSize: [{ inlineSize: rect.width, blockSize: rect.height }],
72
+ devicePixelContentBoxSize: [],
73
+ };
74
+ };
39
75
 
40
- return observer;
76
+ /**
77
+ * Extract DOMRect from ResizeObserverEntry.
78
+ */
79
+ const entryToRect = (entry: ResizeObserverEntry): DOMRect => {
80
+ if (entry.borderBoxSize?.length > 0) {
81
+ const size = entry.borderBoxSize[0];
82
+ return new DOMRect(0, 0, size.inlineSize, size.blockSize);
83
+ }
84
+ return entry.contentRect;
41
85
  };
86
+
87
+ /**
88
+ * Clear observer cache. Exported for testing purposes.
89
+ */
90
+ export function clearObserverCache(): void {
91
+ observerCache.clear();
92
+ }
93
+
42
94
  /**
43
95
  * Observe size changes for a given element reference using shared resize observers.
44
96
  *
45
97
  * @param ref - Ref holding the element whose size to monitor.
46
98
  * @param options - Resize observer configuration.
47
99
  * @returns Latest resize entry and a derived DOMRect snapshot.
100
+ *
101
+ * @remarks
102
+ * The `rect` will be `null` on the first render. After the initial
103
+ * useLayoutEffect runs and triggers a re-render, `rect` will contain
104
+ * the measured size.
105
+ *
106
+ * Due to React's effect execution order, child components' effects run
107
+ * before parent effects. If you pass `rect.width` to a child as a prop,
108
+ * the child's first effect will see `0` (or whatever default you use).
109
+ * This is expected React behavior.
110
+ *
111
+ * @example
112
+ * ```tsx
113
+ * const containerRef = useRef<HTMLDivElement>(null);
114
+ * const { rect } = useResizeObserver(containerRef, { box: "border-box" });
115
+ * const width = rect?.width ?? 0;
116
+ *
117
+ * // Check if size is ready before using for calculations
118
+ * const isReady = rect !== null;
119
+ * ```
48
120
  */
49
121
  export function useResizeObserver<T extends HTMLElement>(
50
122
  ref: React.RefObject<T | null>,
51
- { box }: ResizeObserverOptions,
123
+ { box = "content-box" }: ResizeObserverOptions,
52
124
  ) {
53
125
  const [entry, setEntry] = React.useState<ResizeObserverEntry | null>(null);
54
- const target = ref.current;
55
126
 
56
- React.useEffect(() => {
127
+ useIsomorphicLayoutEffect(() => {
128
+ const target = ref.current;
57
129
  if (!target) {
130
+ setEntry(null);
58
131
  return;
59
132
  }
60
133
 
61
- const observer = getSharedObserver({ box });
62
- return observer.observe(target, (nextEntry) => {
63
- setEntry(nextEntry);
64
- });
65
- }, [box, target]);
134
+ // Measure immediately
135
+ setEntry(measureElement(target));
136
+
137
+ // Set up ResizeObserver for subsequent updates
138
+ const observer = getSharedObserver(box);
139
+ return observer.observe(target, setEntry);
140
+ }, [ref, box]);
66
141
 
67
142
  const rect = React.useMemo(() => {
68
143
  if (!entry) {
69
144
  return null;
70
145
  }
71
-
72
- if (entry.borderBoxSize?.length > 0) {
73
- const size = entry.borderBoxSize[0];
74
- return new DOMRect(0, 0, size.inlineSize, size.blockSize);
75
- }
76
-
77
- return entry.contentRect;
146
+ return entryToRect(entry);
78
147
  }, [entry]);
79
148
 
80
149
  return { entry, rect };
@@ -5,6 +5,7 @@
5
5
  * Returns null if the document is the scroll container.
6
6
  */
7
7
  import * as React from "react";
8
+ import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
8
9
 
9
10
  /**
10
11
  * Check if an element is a scroll container.
@@ -14,12 +15,7 @@ function isScrollContainer(element: Element): boolean {
14
15
  const overflowY = style.overflowY;
15
16
  const overflowX = style.overflowX;
16
17
 
17
- return (
18
- overflowY === "scroll" ||
19
- overflowY === "auto" ||
20
- overflowX === "scroll" ||
21
- overflowX === "auto"
22
- );
18
+ return overflowY === "scroll" || overflowY === "auto" || overflowX === "scroll" || overflowX === "auto";
23
19
  }
24
20
 
25
21
  /**
@@ -59,12 +55,10 @@ function findScrollContainer(element: Element | null): HTMLElement | null {
59
55
  * // scrollContainer is HTMLElement if in nested scroll, null if document scroll
60
56
  * ```
61
57
  */
62
- export function useScrollContainer<T extends HTMLElement>(
63
- ref: React.RefObject<T | null>,
64
- ): HTMLElement | null {
58
+ export function useScrollContainer<T extends HTMLElement>(ref: React.RefObject<T | null>): HTMLElement | null {
65
59
  const [container, setContainer] = React.useState<HTMLElement | null>(null);
66
60
 
67
- React.useEffect(() => {
61
+ useIsomorphicLayoutEffect(() => {
68
62
  const element = ref.current;
69
63
  if (!element) {
70
64
  setContainer(null);