react-panel-layout 0.6.1 → 0.7.0
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/FloatingWindow-Bw2djgpz.js +1542 -0
- package/dist/FloatingWindow-Bw2djgpz.js.map +1 -0
- package/dist/FloatingWindow-Cvyokf0m.cjs +2 -0
- package/dist/FloatingWindow-Cvyokf0m.cjs.map +1 -0
- package/dist/GridLayout-B4aCqSyd.js +947 -0
- package/dist/{GridLayout-UWNxXw77.js.map → GridLayout-B4aCqSyd.js.map} +1 -1
- package/dist/GridLayout-DNOClFzz.cjs +2 -0
- package/dist/{GridLayout-DKTg_N61.cjs.map → GridLayout-DNOClFzz.cjs.map} +1 -1
- package/dist/PanelSystem-B8Igvnb2.cjs +3 -0
- package/dist/PanelSystem-B8Igvnb2.cjs.map +1 -0
- package/dist/{PanelSystem-BqUzNtf2.js → PanelSystem-DDUSFjXD.js} +208 -247
- package/dist/PanelSystem-DDUSFjXD.js.map +1 -0
- package/dist/components/window/Drawer.d.ts +1 -0
- package/dist/components/window/DrawerRevealContext.d.ts +61 -0
- package/dist/components/window/drawerRevealAnimationUtils.d.ts +212 -0
- package/dist/components/window/drawerStyles.d.ts +5 -0
- package/dist/components/window/useDrawerSwipeTransform.d.ts +8 -2
- package/dist/components/window/useDrawerTransform.d.ts +68 -0
- package/dist/components/window/useRevealDrawerTransform.d.ts +56 -0
- package/dist/config.cjs +1 -1
- package/dist/config.cjs.map +1 -1
- package/dist/config.js +8 -7
- package/dist/config.js.map +1 -1
- package/dist/grid.cjs +1 -1
- package/dist/grid.js +2 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +4 -4
- package/dist/modules/drawer/drawerStateMachine.d.ts +168 -0
- package/dist/modules/drawer/revealDrawerConstants.d.ts +33 -0
- package/dist/modules/drawer/revealDrawerStateMachine.d.ts +146 -0
- package/dist/modules/drawer/strategies/index.d.ts +8 -0
- package/dist/modules/drawer/strategies/overlayStrategy.d.ts +12 -0
- package/dist/modules/drawer/strategies/revealStrategy.d.ts +12 -0
- package/dist/modules/drawer/strategies/types.d.ts +116 -0
- package/dist/panels.cjs +1 -1
- package/dist/panels.js +1 -1
- package/dist/stack.cjs +1 -1
- package/dist/stack.cjs.map +1 -1
- package/dist/stack.js +306 -347
- package/dist/stack.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/useAnimationFrame-BZ6D2lMq.cjs +2 -0
- package/dist/useAnimationFrame-BZ6D2lMq.cjs.map +1 -0
- package/dist/useAnimationFrame-Bg4e-H8O.js +394 -0
- package/dist/useAnimationFrame-Bg4e-H8O.js.map +1 -0
- package/dist/window.cjs +1 -1
- package/dist/window.js +1 -1
- package/package.json +1 -1
- package/src/components/gesture/SwipeSafeZone.tsx +1 -0
- package/src/components/grid/GridLayout.tsx +110 -38
- package/src/components/window/Drawer.tsx +114 -10
- package/src/components/window/DrawerLayers.tsx +48 -15
- package/src/components/window/DrawerRevealContext.spec.ts +20 -0
- package/src/components/window/DrawerRevealContext.tsx +99 -0
- package/src/components/window/drawerRevealAnimationUtils.spec.ts +375 -0
- package/src/components/window/drawerRevealAnimationUtils.ts +415 -0
- package/src/components/window/drawerStyles.spec.ts +39 -0
- package/src/components/window/drawerStyles.ts +24 -0
- package/src/components/window/useDrawerSwipeTransform.ts +28 -90
- package/src/components/window/useDrawerTransform.ts +505 -0
- package/src/components/window/useRevealDrawerTransform.spec.ts +1936 -0
- package/src/components/window/useRevealDrawerTransform.ts +105 -0
- package/src/demo/components/FullscreenDemoPage.tsx +47 -0
- package/src/demo/fullscreenRoutes.tsx +32 -0
- package/src/demo/index.tsx +5 -0
- package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +23 -8
- package/src/demo/pages/Drawer/components/DrawerBasics.module.css +6 -1
- package/src/demo/pages/Drawer/components/DrawerBasics.tsx +14 -4
- package/src/demo/pages/Drawer/components/DrawerReveal.module.css +157 -0
- package/src/demo/pages/Drawer/components/DrawerReveal.tsx +128 -0
- package/src/demo/pages/Drawer/reveal/index.tsx +17 -0
- package/src/demo/pages/Drawer/reveal-fullscreen/index.tsx +135 -0
- package/src/demo/pages/Drawer/reveal-fullscreen/styles.module.css +233 -0
- package/src/demo/pages/Stack/components/StackBasics.spec.tsx +56 -52
- package/src/demo/pages/Stack/components/StackTablet.spec.tsx +39 -49
- package/src/demo/routes.tsx +2 -0
- package/src/hooks/gesture/testing/createGestureSimulator.ts +1 -0
- package/src/hooks/gesture/useNativeGestureGuard.spec.ts +10 -2
- package/src/hooks/gesture/utils.ts +15 -4
- package/src/hooks/useAnimatedVisibility.spec.ts +3 -3
- package/src/hooks/useOperationContinuity.spec.ts +17 -10
- package/src/hooks/useOperationContinuity.ts +5 -5
- package/src/hooks/useSharedElementTransition.ts +28 -7
- package/src/modules/dialog/dialogAnimationUtils.spec.ts +0 -1
- package/src/modules/dialog/useDialogContainer.spec.ts +11 -3
- package/src/modules/dialog/useDialogSwipeInput.spec.ts +49 -28
- package/src/modules/dialog/useDialogSwipeInput.ts +37 -6
- package/src/modules/dialog/useDialogTransform.spec.ts +63 -30
- package/src/modules/drawer/drawerStateMachine.ts +500 -0
- package/src/modules/drawer/revealDrawerConstants.ts +38 -0
- package/src/modules/drawer/revealDrawerStateMachine.spec.ts +558 -0
- package/src/modules/drawer/revealDrawerStateMachine.ts +197 -0
- package/src/modules/drawer/strategies/index.ts +9 -0
- package/src/modules/drawer/strategies/overlayStrategy.ts +133 -0
- package/src/modules/drawer/strategies/revealStrategy.ts +111 -0
- package/src/modules/drawer/strategies/types.ts +160 -0
- package/src/modules/drawer/useDrawerSwipeInput.ts +7 -4
- package/src/modules/pivot/SwipePivotContent.spec.tsx +48 -37
- package/src/modules/pivot/usePivotSwipeInput.spec.ts +8 -8
- package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1 -1
- package/src/types.ts +15 -0
- package/dist/FloatingWindow-CUXnEtrb.js +0 -827
- package/dist/FloatingWindow-CUXnEtrb.js.map +0 -1
- package/dist/FloatingWindow-DMwyK0eK.cjs +0 -2
- package/dist/FloatingWindow-DMwyK0eK.cjs.map +0 -1
- package/dist/GridLayout-DKTg_N61.cjs +0 -2
- package/dist/GridLayout-UWNxXw77.js +0 -926
- package/dist/PanelSystem-BqUzNtf2.js.map +0 -1
- package/dist/PanelSystem-D603LKKv.cjs +0 -3
- package/dist/PanelSystem-D603LKKv.cjs.map +0 -1
- package/dist/useNativeGestureGuard-C7TSqEkr.cjs +0 -2
- package/dist/useNativeGestureGuard-C7TSqEkr.cjs.map +0 -1
- package/dist/useNativeGestureGuard-CGYo6O0r.js +0 -347
- package/dist/useNativeGestureGuard-CGYo6O0r.js.map +0 -1
- package/src/components/window/useDrawerSwipeTransform.spec.ts +0 -234
|
@@ -90,6 +90,16 @@ export const mergeGestureContainerProps = (
|
|
|
90
90
|
// Scroll Detection Utilities
|
|
91
91
|
// ============================================================================
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Compute scroll size for an element based on axis.
|
|
95
|
+
*/
|
|
96
|
+
function computeScrollSize(element: HTMLElement, isHorizontal: boolean): number {
|
|
97
|
+
if (isHorizontal) {
|
|
98
|
+
return element.scrollWidth - element.clientWidth;
|
|
99
|
+
}
|
|
100
|
+
return element.scrollHeight - element.clientHeight;
|
|
101
|
+
}
|
|
102
|
+
|
|
93
103
|
/**
|
|
94
104
|
* Check if an element is scrollable in any direction.
|
|
95
105
|
*/
|
|
@@ -104,7 +114,10 @@ export function isScrollableElement(element: HTMLElement): boolean {
|
|
|
104
114
|
(style.overflowY === "scroll" || style.overflowY === "auto") &&
|
|
105
115
|
element.scrollHeight > element.clientHeight;
|
|
106
116
|
|
|
107
|
-
|
|
117
|
+
if (isScrollableX) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return isScrollableY;
|
|
108
121
|
}
|
|
109
122
|
|
|
110
123
|
/**
|
|
@@ -154,9 +167,7 @@ export function isScrollableInDirection(
|
|
|
154
167
|
const isScrollable = overflow === "scroll" || overflow === "auto";
|
|
155
168
|
|
|
156
169
|
if (isScrollable) {
|
|
157
|
-
const scrollSize = isHorizontal
|
|
158
|
-
? current.scrollWidth - current.clientWidth
|
|
159
|
-
: current.scrollHeight - current.clientHeight;
|
|
170
|
+
const scrollSize = computeScrollSize(current, isHorizontal);
|
|
160
171
|
|
|
161
172
|
if (scrollSize > 0) {
|
|
162
173
|
const scrollPos = isHorizontal ? current.scrollLeft : current.scrollTop;
|
|
@@ -13,9 +13,9 @@ import { useAnimatedVisibility } from "./useAnimatedVisibility.js";
|
|
|
13
13
|
* Create a mock AnimationEvent for testing.
|
|
14
14
|
*/
|
|
15
15
|
function createMockAnimationEvent(
|
|
16
|
-
target:
|
|
17
|
-
currentTarget:
|
|
18
|
-
): React.AnimationEvent {
|
|
16
|
+
target: Element,
|
|
17
|
+
currentTarget: Element,
|
|
18
|
+
): React.AnimationEvent<Element> {
|
|
19
19
|
const noop = (): void => {};
|
|
20
20
|
const noopBool = (): boolean => false;
|
|
21
21
|
const nativeEvent = {
|
|
@@ -5,6 +5,8 @@ import * as React from "react";
|
|
|
5
5
|
import { renderHook } from "@testing-library/react";
|
|
6
6
|
import { useOperationContinuity } from "./useOperationContinuity.js";
|
|
7
7
|
|
|
8
|
+
type TestRole = "active" | "behind" | "hidden";
|
|
9
|
+
|
|
8
10
|
const StrictModeWrapper = ({ children }: { children: React.ReactNode }): React.ReactNode => {
|
|
9
11
|
return React.createElement(React.StrictMode, null, children);
|
|
10
12
|
};
|
|
@@ -157,8 +159,9 @@ describe("useOperationContinuity", () => {
|
|
|
157
159
|
*/
|
|
158
160
|
it("detects value change when it happens simultaneously with retention ending", () => {
|
|
159
161
|
const { result, rerender } = renderHook(
|
|
160
|
-
({ role, displacement }
|
|
161
|
-
|
|
162
|
+
({ role, displacement }: { role: TestRole; displacement: number }) =>
|
|
163
|
+
useOperationContinuity(role, displacement > 0),
|
|
164
|
+
{ initialProps: { role: "active" as TestRole, displacement: 500 } },
|
|
162
165
|
);
|
|
163
166
|
|
|
164
167
|
// During swipe: role="active", retaining
|
|
@@ -198,8 +201,9 @@ describe("useOperationContinuity", () => {
|
|
|
198
201
|
// any retention (no swipe operation). We should NOT report changedDuringOperation
|
|
199
202
|
// because this is normal navigation, not an operation-related change.
|
|
200
203
|
const { result, rerender } = renderHook(
|
|
201
|
-
({ role, displacement }
|
|
202
|
-
|
|
204
|
+
({ role, displacement }: { role: TestRole; displacement: number }) =>
|
|
205
|
+
useOperationContinuity(role, displacement > 0),
|
|
206
|
+
{ initialProps: { role: "active" as TestRole, displacement: 0 } },
|
|
203
207
|
);
|
|
204
208
|
|
|
205
209
|
expect(result.current.value).toBe("active");
|
|
@@ -220,8 +224,9 @@ describe("useOperationContinuity", () => {
|
|
|
220
224
|
// Simulates: behind panel becomes active during swipe
|
|
221
225
|
// displacement > 0, so we should retain the previous role
|
|
222
226
|
const { result, rerender } = renderHook(
|
|
223
|
-
({ role, displacement }
|
|
224
|
-
|
|
227
|
+
({ role, displacement }: { role: TestRole; displacement: number }) =>
|
|
228
|
+
useOperationContinuity(role, displacement > 0),
|
|
229
|
+
{ initialProps: { role: "behind" as TestRole, displacement: 100 } },
|
|
225
230
|
);
|
|
226
231
|
|
|
227
232
|
expect(result.current.value).toBe("behind");
|
|
@@ -241,8 +246,9 @@ describe("useOperationContinuity", () => {
|
|
|
241
246
|
it("maintains role continuity during swipe (active -> hidden)", () => {
|
|
242
247
|
// Simulates: over-swipe where active panel becomes hidden
|
|
243
248
|
const { result, rerender } = renderHook(
|
|
244
|
-
({ role, displacement }
|
|
245
|
-
|
|
249
|
+
({ role, displacement }: { role: TestRole; displacement: number }) =>
|
|
250
|
+
useOperationContinuity(role, displacement > 0),
|
|
251
|
+
{ initialProps: { role: "active" as TestRole, displacement: 400 } },
|
|
246
252
|
);
|
|
247
253
|
|
|
248
254
|
expect(result.current.value).toBe("active");
|
|
@@ -262,8 +268,9 @@ describe("useOperationContinuity", () => {
|
|
|
262
268
|
// This test demonstrates the intended use case:
|
|
263
269
|
// Use changedDuringOperation to decide whether to animate on operation end
|
|
264
270
|
const { result, rerender } = renderHook(
|
|
265
|
-
({ role, displacement }
|
|
266
|
-
|
|
271
|
+
({ role, displacement }: { role: TestRole; displacement: number }) =>
|
|
272
|
+
useOperationContinuity(role, displacement > 0),
|
|
273
|
+
{ initialProps: { role: "behind" as TestRole, displacement: 100 } },
|
|
267
274
|
);
|
|
268
275
|
|
|
269
276
|
// Simulate role change during swipe
|
|
@@ -90,21 +90,21 @@ export function useOperationContinuity<T>(
|
|
|
90
90
|
// Derive operationJustEnded from transition: true → false
|
|
91
91
|
// This is idempotent - safe for StrictMode double-render
|
|
92
92
|
const wasRetaining = prevShouldRetainRef.current;
|
|
93
|
-
const operationJustEnded = wasRetaining
|
|
93
|
+
const operationJustEnded = wasRetaining ? !shouldRetainPrevious : false;
|
|
94
94
|
|
|
95
95
|
// Check if value diverged from retained value
|
|
96
96
|
// This includes both current-render divergence and previously-tracked divergence
|
|
97
97
|
const valueDiverged = value !== retainedValueRef.current;
|
|
98
|
-
const currentlyDiverged = shouldRetainPrevious
|
|
98
|
+
const currentlyDiverged = shouldRetainPrevious ? valueDiverged : false;
|
|
99
99
|
|
|
100
100
|
// Derive changedDuringOperation
|
|
101
101
|
// True if:
|
|
102
102
|
// 1. Value diverged during retention (tracked from previous renders via ref)
|
|
103
103
|
// 2. Value diverges right now during retention (immediate comparison)
|
|
104
104
|
// 3. Value diverged at the moment retention ends
|
|
105
|
-
const changedDuringRetention = changedDuringRetentionRef.current
|
|
106
|
-
const changedAtExit = operationJustEnded
|
|
107
|
-
const changedDuringOperation = changedDuringRetention
|
|
105
|
+
const changedDuringRetention = changedDuringRetentionRef.current ? true : currentlyDiverged;
|
|
106
|
+
const changedAtExit = operationJustEnded ? valueDiverged : false;
|
|
107
|
+
const changedDuringOperation = changedDuringRetention ? true : changedAtExit;
|
|
108
108
|
|
|
109
109
|
// Determine effective value
|
|
110
110
|
// During retention: use retained value
|
|
@@ -12,7 +12,10 @@ import { flushSync } from "react-dom";
|
|
|
12
12
|
* Check if View Transitions API is supported.
|
|
13
13
|
*/
|
|
14
14
|
export function supportsViewTransitions(): boolean {
|
|
15
|
-
|
|
15
|
+
if (typeof document === "undefined") {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return "startViewTransition" in document;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
type ViewTransitionHandle = {
|
|
@@ -36,6 +39,25 @@ export function startViewTransition(callback: () => void): ViewTransitionHandle
|
|
|
36
39
|
/** Default dismiss threshold (30% of viewport height) */
|
|
37
40
|
const DEFAULT_DISMISS_THRESHOLD = 0.3;
|
|
38
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Compute transform string for swipe displacement.
|
|
44
|
+
*/
|
|
45
|
+
function computeSwipeTransform(
|
|
46
|
+
isSwiping: boolean,
|
|
47
|
+
displacement: { x: number; y: number },
|
|
48
|
+
): string | undefined {
|
|
49
|
+
if (isSwiping) {
|
|
50
|
+
return `translate(${displacement.x}px, ${displacement.y}px)`;
|
|
51
|
+
}
|
|
52
|
+
if (displacement.x !== 0) {
|
|
53
|
+
return `translate(${displacement.x}px, ${displacement.y}px)`;
|
|
54
|
+
}
|
|
55
|
+
if (displacement.y !== 0) {
|
|
56
|
+
return `translate(${displacement.x}px, ${displacement.y}px)`;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
39
61
|
/** Velocity threshold for quick flick dismissal (px/ms) */
|
|
40
62
|
const VELOCITY_THRESHOLD = 0.5;
|
|
41
63
|
|
|
@@ -174,7 +196,7 @@ export function useSharedElementTransition<T>(
|
|
|
174
196
|
}, []);
|
|
175
197
|
|
|
176
198
|
const collapse = React.useCallback(() => {
|
|
177
|
-
if (!expandedItem) return;
|
|
199
|
+
if (!expandedItem) {return;}
|
|
178
200
|
|
|
179
201
|
setIsSwiping(false);
|
|
180
202
|
|
|
@@ -222,7 +244,8 @@ export function useSharedElementTransition<T>(
|
|
|
222
244
|
setDisplacement({ x: dx, y: dy });
|
|
223
245
|
}, [isSwiping]);
|
|
224
246
|
|
|
225
|
-
|
|
247
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- event parameter required by React handler signature
|
|
248
|
+
const handlePointerUp = React.useCallback((_event: React.PointerEvent) => {
|
|
226
249
|
if (!isSwiping || !startPointRef.current) {
|
|
227
250
|
return;
|
|
228
251
|
}
|
|
@@ -267,7 +290,7 @@ export function useSharedElementTransition<T>(
|
|
|
267
290
|
// - The pending item (clicked card, for old state capture during expand)
|
|
268
291
|
// - The collapsing item (card returning to, for new state capture during collapse)
|
|
269
292
|
// - Never to other cards (they don't participate in the transition)
|
|
270
|
-
const shouldHaveTransitionName = isThisItemPending
|
|
293
|
+
const shouldHaveTransitionName = isThisItemPending ? true : isThisItemCollapsing;
|
|
271
294
|
|
|
272
295
|
return {
|
|
273
296
|
style: {
|
|
@@ -291,9 +314,7 @@ export function useSharedElementTransition<T>(
|
|
|
291
314
|
const name = nameArray[nameIndex];
|
|
292
315
|
|
|
293
316
|
// Apply transform during swipe
|
|
294
|
-
const transform = isSwiping
|
|
295
|
-
? `translate(${displacement.x}px, ${displacement.y}px)`
|
|
296
|
-
: undefined;
|
|
317
|
+
const transform = computeSwipeTransform(isSwiping, displacement);
|
|
297
318
|
|
|
298
319
|
return {
|
|
299
320
|
style: {
|
|
@@ -18,6 +18,14 @@ const createCallTracker = (): CallTracker => {
|
|
|
18
18
|
return { calls, fn };
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Create a mock view for test events.
|
|
23
|
+
*/
|
|
24
|
+
function createMockView(): React.MouseEvent<HTMLDialogElement>["view"] {
|
|
25
|
+
// eslint-disable-next-line custom/no-as-outside-guard -- test helper for view casting
|
|
26
|
+
return window as unknown as React.MouseEvent<HTMLDialogElement>["view"];
|
|
27
|
+
}
|
|
28
|
+
|
|
21
29
|
/**
|
|
22
30
|
* Mock SyntheticEvent with call tracking.
|
|
23
31
|
*/
|
|
@@ -61,8 +69,8 @@ function createMockSyntheticEvent(): MockSyntheticEvent {
|
|
|
61
69
|
* Creates a mock React.MouseEvent for dialog interactions.
|
|
62
70
|
*/
|
|
63
71
|
function createMockMouseEvent(
|
|
64
|
-
target:
|
|
65
|
-
currentTarget:
|
|
72
|
+
target: HTMLElement,
|
|
73
|
+
currentTarget: HTMLDialogElement,
|
|
66
74
|
): React.MouseEvent<HTMLDialogElement> {
|
|
67
75
|
const noop = (): void => {};
|
|
68
76
|
const noopBool = (): boolean => false;
|
|
@@ -103,7 +111,7 @@ function createMockMouseEvent(
|
|
|
103
111
|
screenY: 0,
|
|
104
112
|
// UIEvent properties
|
|
105
113
|
detail: 0,
|
|
106
|
-
view:
|
|
114
|
+
view: createMockView(),
|
|
107
115
|
};
|
|
108
116
|
}
|
|
109
117
|
|
|
@@ -1,20 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Tests for useDialogSwipeInput hook
|
|
3
3
|
*/
|
|
4
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
5
4
|
import { renderHook, act } from "@testing-library/react";
|
|
6
5
|
import * as React from "react";
|
|
7
6
|
import { useDialogSwipeInput } from "./useDialogSwipeInput.js";
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
8
|
+
/**
|
|
9
|
+
* Call tracker for testing callbacks.
|
|
10
|
+
*/
|
|
11
|
+
type CallTracker = {
|
|
12
|
+
calls: ReadonlyArray<ReadonlyArray<unknown>>;
|
|
13
|
+
fn: (...args: ReadonlyArray<unknown>) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const createCallTracker = (): CallTracker => {
|
|
17
|
+
const calls: Array<ReadonlyArray<unknown>> = [];
|
|
18
|
+
const fn = (...args: ReadonlyArray<unknown>): void => {
|
|
19
|
+
calls.push(args);
|
|
20
|
+
};
|
|
21
|
+
return { calls, fn };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cast native PointerEvent to React.PointerEvent for testing.
|
|
26
|
+
*/
|
|
27
|
+
function asReactPointerEvent(e: PointerEvent): React.PointerEvent<HTMLElement> {
|
|
28
|
+
// eslint-disable-next-line custom/no-as-outside-guard -- test helper for event casting
|
|
29
|
+
return e as unknown as React.PointerEvent<HTMLElement>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Simple ResizeObserver mock
|
|
33
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
34
|
+
|
|
35
|
+
// eslint-disable-next-line no-restricted-syntax -- mock class needed for ResizeObserver
|
|
36
|
+
class MockResizeObserver {
|
|
37
|
+
observe(): void {}
|
|
38
|
+
unobserve(): void {}
|
|
39
|
+
disconnect(): void {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
// eslint-disable-next-line custom/no-as-outside-guard -- test mock assignment
|
|
44
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
49
|
+
});
|
|
18
50
|
|
|
19
51
|
describe("useDialogSwipeInput", () => {
|
|
20
52
|
const createMockContainer = (dimensions: { width: number; height: number }) => {
|
|
@@ -24,15 +56,6 @@ describe("useDialogSwipeInput", () => {
|
|
|
24
56
|
return container;
|
|
25
57
|
};
|
|
26
58
|
|
|
27
|
-
beforeEach(() => {
|
|
28
|
-
vi.useFakeTimers();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
afterEach(() => {
|
|
32
|
-
vi.useRealTimers();
|
|
33
|
-
vi.clearAllMocks();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
59
|
describe("initialization", () => {
|
|
37
60
|
it("should return idle state initially", () => {
|
|
38
61
|
const container = createMockContainer({ width: 400, height: 300 });
|
|
@@ -43,7 +66,7 @@ describe("useDialogSwipeInput", () => {
|
|
|
43
66
|
containerRef,
|
|
44
67
|
openDirection: "bottom",
|
|
45
68
|
enabled: true,
|
|
46
|
-
onSwipeDismiss:
|
|
69
|
+
onSwipeDismiss: createCallTracker().fn,
|
|
47
70
|
}),
|
|
48
71
|
);
|
|
49
72
|
|
|
@@ -61,7 +84,7 @@ describe("useDialogSwipeInput", () => {
|
|
|
61
84
|
containerRef,
|
|
62
85
|
openDirection: "bottom",
|
|
63
86
|
enabled: true,
|
|
64
|
-
onSwipeDismiss:
|
|
87
|
+
onSwipeDismiss: createCallTracker().fn,
|
|
65
88
|
}),
|
|
66
89
|
);
|
|
67
90
|
|
|
@@ -80,7 +103,7 @@ describe("useDialogSwipeInput", () => {
|
|
|
80
103
|
containerRef,
|
|
81
104
|
openDirection: "bottom",
|
|
82
105
|
enabled: true,
|
|
83
|
-
onSwipeDismiss:
|
|
106
|
+
onSwipeDismiss: createCallTracker().fn,
|
|
84
107
|
}),
|
|
85
108
|
);
|
|
86
109
|
|
|
@@ -97,7 +120,7 @@ describe("useDialogSwipeInput", () => {
|
|
|
97
120
|
containerRef,
|
|
98
121
|
openDirection: "left",
|
|
99
122
|
enabled: true,
|
|
100
|
-
onSwipeDismiss:
|
|
123
|
+
onSwipeDismiss: createCallTracker().fn,
|
|
101
124
|
}),
|
|
102
125
|
);
|
|
103
126
|
|
|
@@ -116,7 +139,7 @@ describe("useDialogSwipeInput", () => {
|
|
|
116
139
|
containerRef,
|
|
117
140
|
openDirection: "bottom",
|
|
118
141
|
enabled: false,
|
|
119
|
-
onSwipeDismiss:
|
|
142
|
+
onSwipeDismiss: createCallTracker().fn,
|
|
120
143
|
}),
|
|
121
144
|
);
|
|
122
145
|
|
|
@@ -128,9 +151,7 @@ describe("useDialogSwipeInput", () => {
|
|
|
128
151
|
});
|
|
129
152
|
|
|
130
153
|
act(() => {
|
|
131
|
-
result.current.containerProps.onPointerDown?.(
|
|
132
|
-
pointerEvent as unknown as React.PointerEvent,
|
|
133
|
-
);
|
|
154
|
+
result.current.containerProps.onPointerDown?.(asReactPointerEvent(pointerEvent));
|
|
134
155
|
});
|
|
135
156
|
|
|
136
157
|
expect(result.current.state.phase).toBe("idle");
|
|
@@ -147,7 +168,7 @@ describe("useDialogSwipeInput", () => {
|
|
|
147
168
|
containerRef,
|
|
148
169
|
openDirection: "bottom",
|
|
149
170
|
enabled: true,
|
|
150
|
-
onSwipeDismiss:
|
|
171
|
+
onSwipeDismiss: createCallTracker().fn,
|
|
151
172
|
}),
|
|
152
173
|
);
|
|
153
174
|
|
|
@@ -17,6 +17,36 @@ import {
|
|
|
17
17
|
import type { DialogOpenDirection } from "./dialogAnimationUtils.js";
|
|
18
18
|
import { getAnimationAxis, getDirectionSign } from "./dialogAnimationUtils.js";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Get sign from displacement value.
|
|
22
|
+
*/
|
|
23
|
+
function getSignFromDisplacement(value: number): -1 | 0 | 1 {
|
|
24
|
+
if (value > 0) {
|
|
25
|
+
return 1;
|
|
26
|
+
}
|
|
27
|
+
if (value < 0) {
|
|
28
|
+
return -1;
|
|
29
|
+
}
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TrackingPoint = { timestamp: number } | null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compute velocity from tracking points.
|
|
37
|
+
*/
|
|
38
|
+
function computeSwipeVelocity(
|
|
39
|
+
start: TrackingPoint,
|
|
40
|
+
current: TrackingPoint,
|
|
41
|
+
primaryValue: number,
|
|
42
|
+
): number {
|
|
43
|
+
if (start === null || current === null) {
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const duration = Math.max(1, current.timestamp - start.timestamp);
|
|
47
|
+
return Math.abs(primaryValue) / duration;
|
|
48
|
+
}
|
|
49
|
+
|
|
20
50
|
/**
|
|
21
51
|
* Default dismiss threshold (30% of container size).
|
|
22
52
|
*/
|
|
@@ -77,7 +107,10 @@ function isScrollableElement(element: HTMLElement): boolean {
|
|
|
77
107
|
(style.overflowY === "scroll" || style.overflowY === "auto") &&
|
|
78
108
|
element.scrollHeight > element.clientHeight;
|
|
79
109
|
|
|
80
|
-
|
|
110
|
+
if (isScrollableX) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return isScrollableY;
|
|
81
114
|
}
|
|
82
115
|
|
|
83
116
|
/**
|
|
@@ -206,7 +239,7 @@ export function useDialogSwipeInput(
|
|
|
206
239
|
if (containerSize <= 0) {
|
|
207
240
|
return 0;
|
|
208
241
|
}
|
|
209
|
-
const sign = primaryDisplacement
|
|
242
|
+
const sign = getSignFromDisplacement(primaryDisplacement);
|
|
210
243
|
if (sign !== expectedSign) {
|
|
211
244
|
return 0; // Wrong direction
|
|
212
245
|
}
|
|
@@ -244,13 +277,11 @@ export function useDialogSwipeInput(
|
|
|
244
277
|
if (containerSize > 0) {
|
|
245
278
|
const finalDisplacement = lastDisplacementRef.current;
|
|
246
279
|
const primaryValue = axis === "x" ? finalDisplacement.x : finalDisplacement.y;
|
|
247
|
-
const sign = primaryValue
|
|
280
|
+
const sign = getSignFromDisplacement(primaryValue);
|
|
248
281
|
|
|
249
282
|
if (sign === expectedSign) {
|
|
250
283
|
const ratio = Math.abs(primaryValue) / containerSize;
|
|
251
|
-
const velocity = tracking.start
|
|
252
|
-
? Math.abs(primaryValue) / Math.max(1, tracking.current.timestamp - tracking.start.timestamp)
|
|
253
|
-
: 0;
|
|
284
|
+
const velocity = computeSwipeVelocity(tracking.start, tracking.current, primaryValue);
|
|
254
285
|
|
|
255
286
|
if (ratio >= dismissThreshold || velocity >= VELOCITY_THRESHOLD) {
|
|
256
287
|
onSwipeDismiss();
|
|
@@ -1,67 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Tests for useDialogTransform hook
|
|
3
3
|
*/
|
|
4
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
5
4
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
|
6
5
|
import { useDialogTransform } from "./useDialogTransform.js";
|
|
7
6
|
import type { ContinuousOperationState } from "../../hooks/gesture/types.js";
|
|
8
7
|
import { IDLE_CONTINUOUS_OPERATION_STATE } from "../../hooks/gesture/types.js";
|
|
9
8
|
|
|
10
9
|
// Mock ResizeObserver
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
10
|
+
const createMockResizeObserver = () => {
|
|
11
|
+
const callbacks: ResizeObserverCallback[] = [];
|
|
12
|
+
return {
|
|
13
|
+
callbacks,
|
|
14
|
+
MockClass: class {
|
|
15
|
+
constructor(callback: ResizeObserverCallback) {
|
|
16
|
+
callbacks.push(callback);
|
|
17
|
+
}
|
|
18
|
+
observe(): void {
|
|
19
|
+
// no-op
|
|
20
|
+
}
|
|
21
|
+
unobserve(): void {
|
|
22
|
+
// no-op
|
|
23
|
+
}
|
|
24
|
+
disconnect(): void {
|
|
25
|
+
// no-op
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const { MockClass: MockResizeObserver } = createMockResizeObserver();
|
|
32
|
+
globalThis.ResizeObserver = MockResizeObserver;
|
|
20
33
|
|
|
21
34
|
describe("useDialogTransform", () => {
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
const createRAFTracker = () => {
|
|
36
|
+
const callbacks: FrameRequestCallback[] = [];
|
|
37
|
+
const tracker = { callbacks, nextId: 0 };
|
|
38
|
+
return tracker;
|
|
39
|
+
};
|
|
24
40
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
const rafTracker = createRAFTracker();
|
|
42
|
+
const originalRAF = globalThis.requestAnimationFrame;
|
|
43
|
+
const originalCAF = globalThis.cancelAnimationFrame;
|
|
28
44
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
rafTracker.callbacks.length = 0;
|
|
47
|
+
rafTracker.nextId = 0;
|
|
48
|
+
|
|
49
|
+
globalThis.requestAnimationFrame = (cb: FrameRequestCallback): number => {
|
|
50
|
+
rafTracker.callbacks.push(cb);
|
|
51
|
+
rafTracker.nextId += 1;
|
|
52
|
+
return rafTracker.nextId;
|
|
53
|
+
};
|
|
54
|
+
globalThis.cancelAnimationFrame = (): void => {
|
|
34
55
|
// Mock cancel
|
|
35
|
-
}
|
|
56
|
+
};
|
|
36
57
|
});
|
|
37
58
|
|
|
38
59
|
afterEach(() => {
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
globalThis.requestAnimationFrame = originalRAF;
|
|
61
|
+
globalThis.cancelAnimationFrame = originalCAF;
|
|
41
62
|
});
|
|
42
63
|
|
|
43
64
|
const runAnimationFrames = (count = 1, deltaMs = 16) => {
|
|
44
65
|
const now = performance.now();
|
|
45
66
|
for (let i = 0; i < count; i++) {
|
|
46
|
-
const callbacks = [...
|
|
47
|
-
|
|
67
|
+
const callbacks = [...rafTracker.callbacks];
|
|
68
|
+
rafTracker.callbacks.length = 0;
|
|
48
69
|
callbacks.forEach(cb => cb(now + deltaMs * (i + 1)));
|
|
49
70
|
}
|
|
50
71
|
};
|
|
51
72
|
|
|
52
73
|
const createMockElement = (dimensions: { width: number; height: number }) => {
|
|
74
|
+
const setPropertyCalls: Array<{ prop: string; value: string }> = [];
|
|
75
|
+
// eslint-disable-next-line custom/no-as-outside-guard -- Required for DOM mock
|
|
53
76
|
return {
|
|
54
77
|
clientWidth: dimensions.width,
|
|
55
78
|
clientHeight: dimensions.height,
|
|
56
79
|
style: {
|
|
57
80
|
transform: "",
|
|
58
|
-
setProperty:
|
|
81
|
+
setProperty: (prop: string, value: string): void => {
|
|
82
|
+
setPropertyCalls.push({ prop, value });
|
|
83
|
+
},
|
|
59
84
|
viewTransitionName: "",
|
|
60
85
|
},
|
|
86
|
+
_setPropertyCalls: setPropertyCalls,
|
|
61
87
|
} as unknown as HTMLDivElement;
|
|
62
88
|
};
|
|
63
89
|
|
|
64
90
|
const createMockBackdrop = () => {
|
|
91
|
+
// eslint-disable-next-line custom/no-as-outside-guard -- Required for DOM mock
|
|
65
92
|
return {
|
|
66
93
|
style: {
|
|
67
94
|
opacity: "",
|
|
@@ -140,7 +167,10 @@ describe("useDialogTransform", () => {
|
|
|
140
167
|
const backdrop = createMockBackdrop();
|
|
141
168
|
const elementRef = { current: element };
|
|
142
169
|
const backdropRef = { current: backdrop };
|
|
143
|
-
const
|
|
170
|
+
const onOpenCompleteCalls: unknown[] = [];
|
|
171
|
+
const onOpenComplete = (): void => {
|
|
172
|
+
onOpenCompleteCalls.push(undefined);
|
|
173
|
+
};
|
|
144
174
|
|
|
145
175
|
const { result, rerender } = renderHook(
|
|
146
176
|
({ visible }) =>
|
|
@@ -170,7 +200,7 @@ describe("useDialogTransform", () => {
|
|
|
170
200
|
expect(result.current.phase).toBe("open");
|
|
171
201
|
});
|
|
172
202
|
|
|
173
|
-
expect(
|
|
203
|
+
expect(onOpenCompleteCalls.length).toBeGreaterThan(0);
|
|
174
204
|
});
|
|
175
205
|
});
|
|
176
206
|
|
|
@@ -218,7 +248,10 @@ describe("useDialogTransform", () => {
|
|
|
218
248
|
const backdrop = createMockBackdrop();
|
|
219
249
|
const elementRef = { current: element };
|
|
220
250
|
const backdropRef = { current: backdrop };
|
|
221
|
-
const
|
|
251
|
+
const onCloseCompleteCalls: unknown[] = [];
|
|
252
|
+
const onCloseComplete = (): void => {
|
|
253
|
+
onCloseCompleteCalls.push(undefined);
|
|
254
|
+
};
|
|
222
255
|
|
|
223
256
|
const { result, rerender } = renderHook(
|
|
224
257
|
({ visible }) =>
|
|
@@ -260,7 +293,7 @@ describe("useDialogTransform", () => {
|
|
|
260
293
|
expect(result.current.phase).toBe("closed");
|
|
261
294
|
});
|
|
262
295
|
|
|
263
|
-
expect(
|
|
296
|
+
expect(onCloseCompleteCalls.length).toBeGreaterThan(0);
|
|
264
297
|
});
|
|
265
298
|
});
|
|
266
299
|
|