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.
- package/dist/{FloatingPanelFrame-SgYLc6Ud.js → FloatingPanelFrame-3eU9AwPo.js} +2 -2
- package/dist/{FloatingPanelFrame-SgYLc6Ud.js.map → FloatingPanelFrame-3eU9AwPo.js.map} +1 -1
- package/dist/FloatingWindow-CUXnEtrb.js +827 -0
- package/dist/FloatingWindow-CUXnEtrb.js.map +1 -0
- package/dist/FloatingWindow-DMwyK0eK.cjs +2 -0
- package/dist/FloatingWindow-DMwyK0eK.cjs.map +1 -0
- package/dist/GridLayout-DKTg_N61.cjs +2 -0
- package/dist/{GridLayout-B4VRsC0r.cjs.map → GridLayout-DKTg_N61.cjs.map} +1 -1
- package/dist/{GridLayout-BltqeCPK.js → GridLayout-UWNxXw77.js} +34 -35
- package/dist/{GridLayout-BltqeCPK.js.map → GridLayout-UWNxXw77.js.map} +1 -1
- package/dist/{HorizontalDivider-WF1k_qND.js → HorizontalDivider-DdxzfV0l.js} +3 -3
- package/dist/{HorizontalDivider-WF1k_qND.js.map → HorizontalDivider-DdxzfV0l.js.map} +1 -1
- package/dist/{HorizontalDivider-B5Z-KZLk.cjs → HorizontalDivider-_pgV4Mcv.cjs} +2 -2
- package/dist/{HorizontalDivider-B5Z-KZLk.cjs.map → HorizontalDivider-_pgV4Mcv.cjs.map} +1 -1
- package/dist/{PanelSystem-Dr1TBhxM.js → PanelSystem-BqUzNtf2.js} +5 -5
- package/dist/{PanelSystem-Dr1TBhxM.js.map → PanelSystem-BqUzNtf2.js.map} +1 -1
- package/dist/{PanelSystem-Bs8bQwQF.cjs → PanelSystem-D603LKKv.cjs} +2 -2
- package/dist/{PanelSystem-Bs8bQwQF.cjs.map → PanelSystem-D603LKKv.cjs.map} +1 -1
- package/dist/ResizeHandle-CBcAS918.cjs +2 -0
- package/dist/{ResizeHandle-CScipO5l.cjs.map → ResizeHandle-CBcAS918.cjs.map} +1 -1
- package/dist/{ResizeHandle-CdA_JYfN.js → ResizeHandle-CXjc1meV.js} +28 -29
- package/dist/{ResizeHandle-CdA_JYfN.js.map → ResizeHandle-CXjc1meV.js.map} +1 -1
- package/dist/SwipePivotTabBar-DWrCuwEI.js +411 -0
- package/dist/SwipePivotTabBar-DWrCuwEI.js.map +1 -0
- package/dist/SwipePivotTabBar-fjjXkpj7.cjs +2 -0
- package/dist/SwipePivotTabBar-fjjXkpj7.cjs.map +1 -0
- package/dist/components/gesture/SwipeSafeZone.d.ts +40 -0
- package/dist/components/window/Drawer.d.ts +3 -1
- package/dist/components/window/DrawerLayers.d.ts +1 -1
- package/dist/components/window/drawerStyles.d.ts +69 -0
- package/dist/components/window/drawerSwipeConfig.d.ts +29 -0
- package/dist/components/window/useDrawerSwipeTransform.d.ts +23 -0
- package/dist/config.cjs +1 -1
- package/dist/config.js +3 -3
- package/dist/constants/styles.d.ts +17 -0
- package/dist/dialog/index.d.ts +69 -0
- package/dist/floating.js +1 -1
- package/dist/grid.cjs +1 -1
- package/dist/grid.js +2 -2
- package/dist/hooks/gesture/testing/createGestureSimulator.d.ts +7 -0
- package/dist/hooks/gesture/types.d.ts +48 -5
- package/dist/hooks/gesture/utils.d.ts +19 -0
- package/dist/hooks/useAnimationFrame.d.ts +2 -0
- package/dist/hooks/useOperationContinuity.d.ts +64 -0
- package/dist/hooks/useResizeObserver.d.ts +33 -1
- package/dist/hooks/useSharedElementTransition.d.ts +112 -0
- package/dist/hooks/useSwipeContentTransform.d.ts +9 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +7 -7
- package/dist/modules/dialog/AlertDialog.d.ts +9 -0
- package/dist/modules/dialog/DialogContainer.d.ts +37 -0
- package/dist/modules/dialog/Modal.d.ts +26 -0
- package/dist/modules/dialog/SwipeDialogContainer.d.ts +16 -0
- package/dist/modules/dialog/dialogAnimationUtils.d.ts +113 -0
- package/dist/modules/dialog/types.d.ts +183 -0
- package/dist/modules/dialog/useDialog.d.ts +39 -0
- package/dist/modules/dialog/useDialogContainer.d.ts +47 -0
- package/dist/modules/dialog/useDialogSwipeInput.d.ts +70 -0
- package/dist/modules/dialog/useDialogTransform.d.ts +82 -0
- package/dist/modules/drawer/types.d.ts +74 -0
- package/dist/modules/drawer/useDrawerSwipeInput.d.ts +24 -0
- package/dist/modules/pivot/SwipePivotTabBar.d.ts +3 -0
- package/dist/modules/stack/SwipeStackContent.d.ts +6 -3
- package/dist/modules/stack/SwipeStackOutlet.d.ts +4 -4
- package/dist/modules/stack/computeSwipeStackTransform.d.ts +1 -1
- package/dist/panels.cjs +1 -1
- package/dist/panels.js +1 -1
- package/dist/pivot.cjs +1 -1
- package/dist/pivot.js +1 -1
- package/dist/resizer.cjs +1 -1
- package/dist/resizer.js +2 -2
- package/dist/stack.cjs +1 -1
- package/dist/stack.cjs.map +1 -1
- package/dist/stack.js +503 -762
- package/dist/stack.js.map +1 -1
- package/dist/sticky-header/calculateStickyMetrics.d.ts +28 -0
- package/dist/sticky-header.cjs +1 -1
- package/dist/sticky-header.cjs.map +1 -1
- package/dist/sticky-header.js +59 -51
- package/dist/sticky-header.js.map +1 -1
- package/dist/{styles-DPPuJ0sf.js → styles-NkjuMOVS.js} +13 -13
- package/dist/{styles-DPPuJ0sf.js.map → styles-NkjuMOVS.js.map} +1 -1
- package/dist/styles-qf6ptVLD.cjs.map +1 -1
- package/dist/types.d.ts +16 -0
- package/dist/useDocumentPointerEvents-DXxw3qWj.js +54 -0
- package/dist/useDocumentPointerEvents-DXxw3qWj.js.map +1 -0
- package/dist/useDocumentPointerEvents-DxDSOtip.cjs +2 -0
- package/dist/useDocumentPointerEvents-DxDSOtip.cjs.map +1 -0
- package/dist/useNativeGestureGuard-C7TSqEkr.cjs +2 -0
- package/dist/useNativeGestureGuard-C7TSqEkr.cjs.map +1 -0
- package/dist/useNativeGestureGuard-CGYo6O0r.js +347 -0
- package/dist/useNativeGestureGuard-CGYo6O0r.js.map +1 -0
- package/dist/window/index.d.ts +2 -0
- package/dist/window.cjs +1 -1
- package/dist/window.cjs.map +1 -1
- package/dist/window.js +114 -103
- package/dist/window.js.map +1 -1
- package/package.json +6 -1
- package/src/components/gesture/SwipeSafeZone.tsx +69 -0
- package/src/components/window/Drawer.tsx +249 -162
- package/src/components/window/DrawerLayers.tsx +13 -3
- package/src/components/window/drawerStyles.spec.ts +263 -0
- package/src/components/window/drawerStyles.ts +228 -0
- package/src/components/window/drawerSwipeConfig.spec.ts +131 -0
- package/src/components/window/drawerSwipeConfig.ts +112 -0
- package/src/components/window/useDrawerSwipeTransform.spec.ts +234 -0
- package/src/components/window/useDrawerSwipeTransform.ts +129 -0
- package/src/constants/styles.ts +19 -0
- package/src/demo/pages/Dialog/alerts/index.tsx +22 -0
- package/src/demo/pages/Dialog/card/index.tsx +22 -0
- package/src/demo/pages/Dialog/components/AlertDialogDemo.tsx +124 -0
- package/src/demo/pages/Dialog/components/CardExpandDemo.module.css +243 -0
- package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +204 -0
- package/src/demo/pages/Dialog/components/CustomAlertDialogDemo.tsx +219 -0
- package/src/demo/pages/Dialog/components/DialogDemos.module.css +77 -0
- package/src/demo/pages/Dialog/components/ModalBasics.tsx +45 -0
- package/src/demo/pages/Dialog/components/SwipeDialogDemo.module.css +77 -0
- package/src/demo/pages/Dialog/components/SwipeDialogDemo.tsx +181 -0
- package/src/demo/pages/Dialog/custom-alert/index.tsx +22 -0
- package/src/demo/pages/Dialog/modal/index.tsx +17 -0
- package/src/demo/pages/Dialog/swipe/index.tsx +22 -0
- package/src/demo/pages/Drawer/components/DrawerSwipe.module.css +316 -0
- package/src/demo/pages/Drawer/components/DrawerSwipe.tsx +178 -0
- package/src/demo/pages/Drawer/swipe/index.tsx +17 -0
- package/src/demo/pages/Pivot/components/SwipeTabsPivot.tsx +54 -23
- package/src/demo/pages/Pivot/swipe-debug/index.tsx +1 -1
- package/src/demo/pages/Stack/components/StackBasics.spec.tsx +152 -0
- package/src/demo/pages/Stack/components/StackBasics.tsx +179 -95
- package/src/demo/pages/Stack/components/StackTablet.spec.tsx +120 -0
- package/src/demo/pages/Stack/components/StackTablet.tsx +42 -21
- package/src/demo/routes.tsx +22 -1
- package/src/dialog/index.ts +85 -0
- package/src/hooks/gesture/testing/createGestureSimulator.spec.ts +68 -64
- package/src/hooks/gesture/testing/createGestureSimulator.ts +112 -37
- package/src/hooks/gesture/types.ts +83 -6
- package/src/hooks/gesture/useEdgeSwipeInput.spec.ts +22 -14
- package/src/hooks/gesture/useNativeGestureGuard.spec.ts +91 -31
- package/src/hooks/gesture/useNativeGestureGuard.ts +3 -1
- package/src/hooks/gesture/utils.ts +91 -0
- package/src/hooks/useAnimatedVisibility.spec.ts +44 -24
- package/src/hooks/useAnimatedVisibility.ts +28 -2
- package/src/hooks/useAnimationFrame.ts +8 -0
- package/src/hooks/useOperationContinuity.spec.ts +387 -0
- package/src/hooks/useOperationContinuity.ts +135 -0
- package/src/hooks/useResizeObserver.spec.tsx +277 -0
- package/src/hooks/useResizeObserver.tsx +108 -39
- package/src/hooks/useScrollContainer.ts +4 -10
- package/src/hooks/useSharedElementTransition.ts +333 -0
- package/src/hooks/useSwipeContentTransform.spec.ts +18 -18
- package/src/hooks/useSwipeContentTransform.ts +166 -28
- package/src/modules/dialog/AlertDialog.spec.tsx +387 -0
- package/src/modules/dialog/AlertDialog.tsx +221 -0
- package/src/modules/dialog/DialogContainer.spec.tsx +228 -0
- package/src/modules/dialog/DialogContainer.tsx +188 -0
- package/src/modules/dialog/Modal.spec.tsx +220 -0
- package/src/modules/dialog/Modal.tsx +182 -0
- package/src/modules/dialog/SwipeDialogContainer.tsx +208 -0
- package/src/modules/dialog/dialogAnimationUtils.spec.ts +253 -0
- package/src/modules/dialog/dialogAnimationUtils.ts +297 -0
- package/src/modules/dialog/types.ts +186 -0
- package/src/modules/dialog/useDialog.spec.tsx +447 -0
- package/src/modules/dialog/useDialog.ts +214 -0
- package/src/modules/dialog/useDialogContainer.spec.ts +331 -0
- package/src/modules/dialog/useDialogContainer.ts +150 -0
- package/src/modules/dialog/useDialogSwipeInput.spec.ts +157 -0
- package/src/modules/dialog/useDialogSwipeInput.ts +319 -0
- package/src/modules/dialog/useDialogTransform.spec.ts +370 -0
- package/src/modules/dialog/useDialogTransform.ts +407 -0
- package/src/modules/drawer/types.ts +102 -0
- package/src/modules/drawer/useDrawerSwipeInput.spec.ts +566 -0
- package/src/modules/drawer/useDrawerSwipeInput.ts +399 -0
- package/src/modules/panels/rendering/ContentRegistry.spec.tsx +21 -14
- package/src/modules/pivot/SwipePivotContent.position.spec.tsx +12 -8
- package/src/modules/pivot/SwipePivotContent.spec.tsx +55 -25
- package/src/modules/pivot/SwipePivotContent.tsx +2 -2
- package/src/modules/pivot/SwipePivotTabBar.spec.tsx +85 -68
- package/src/modules/pivot/SwipePivotTabBar.tsx +75 -15
- package/src/modules/pivot/scaleInputState.spec.ts +11 -2
- package/src/modules/pivot/usePivot.spec.ts +17 -3
- package/src/modules/pivot/usePivotSwipeInput.spec.ts +182 -123
- package/src/modules/stack/SwipeStackContent.spec.tsx +387 -100
- package/src/modules/stack/SwipeStackContent.tsx +43 -33
- package/src/modules/stack/SwipeStackOutlet.spec.tsx +14 -16
- package/src/modules/stack/SwipeStackOutlet.tsx +6 -6
- package/src/modules/stack/computeSwipeStackTransform.spec.ts +5 -5
- package/src/modules/stack/computeSwipeStackTransform.ts +3 -3
- package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1133 -0
- package/src/modules/stack/useStackAnimationState.spec.ts +3 -1
- package/src/modules/stack/useStackAnimationState.ts +18 -13
- package/src/modules/stack/useStackNavigation.spec.ts +198 -3
- package/src/modules/stack/useStackNavigation.tsx +113 -56
- package/src/modules/stack/useStackSwipeInput.spec.ts +65 -32
- package/src/modules/stack/useStackSwipeInput.ts +1 -1
- package/src/sticky-header/StickyArea.tsx +29 -57
- package/src/sticky-header/calculateStickyMetrics.spec.ts +105 -0
- package/src/sticky-header/calculateStickyMetrics.ts +50 -0
- package/src/types.ts +18 -0
- package/src/window/index.ts +2 -0
- package/dist/FloatingWindow-BpdOpg_L.js +0 -400
- package/dist/FloatingWindow-BpdOpg_L.js.map +0 -1
- package/dist/FloatingWindow-TCDNY5gE.cjs +0 -2
- package/dist/FloatingWindow-TCDNY5gE.cjs.map +0 -1
- package/dist/GridLayout-B4VRsC0r.cjs +0 -2
- package/dist/ResizeHandle-CScipO5l.cjs +0 -2
- package/dist/SwipePivotTabBar-BGO9X94m.js +0 -407
- package/dist/SwipePivotTabBar-BGO9X94m.js.map +0 -1
- package/dist/SwipePivotTabBar-BrQismcZ.cjs +0 -2
- package/dist/SwipePivotTabBar-BrQismcZ.cjs.map +0 -1
- package/dist/useDocumentPointerEvents-CKdhGXd0.js +0 -46
- package/dist/useDocumentPointerEvents-CKdhGXd0.js.map +0 -1
- package/dist/useDocumentPointerEvents-ChqrKXDk.cjs +0 -2
- package/dist/useDocumentPointerEvents-ChqrKXDk.cjs.map +0 -1
- package/dist/useEffectEvent-Dp7HLCf0.js +0 -13
- package/dist/useEffectEvent-Dp7HLCf0.js.map +0 -1
- package/dist/useEffectEvent-huSsGUnl.cjs +0 -2
- 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
|
-
|
|
7
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Shared ResizeObserver that can observe multiple elements.
|
|
17
|
+
*/
|
|
8
18
|
type SharedObserver = {
|
|
9
|
-
observe: (target: Element, callback:
|
|
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
|
-
|
|
13
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
52
|
+
callbacks.delete(target);
|
|
53
|
+
resizeObserver.unobserve(target);
|
|
35
54
|
};
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
useIsomorphicLayoutEffect(() => {
|
|
128
|
+
const target = ref.current;
|
|
57
129
|
if (!target) {
|
|
130
|
+
setEntry(null);
|
|
58
131
|
return;
|
|
59
132
|
}
|
|
60
133
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
61
|
+
useIsomorphicLayoutEffect(() => {
|
|
68
62
|
const element = ref.current;
|
|
69
63
|
if (!element) {
|
|
70
64
|
setContainer(null);
|