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.
Files changed (115) hide show
  1. package/dist/FloatingWindow-Bw2djgpz.js +1542 -0
  2. package/dist/FloatingWindow-Bw2djgpz.js.map +1 -0
  3. package/dist/FloatingWindow-Cvyokf0m.cjs +2 -0
  4. package/dist/FloatingWindow-Cvyokf0m.cjs.map +1 -0
  5. package/dist/GridLayout-B4aCqSyd.js +947 -0
  6. package/dist/{GridLayout-UWNxXw77.js.map → GridLayout-B4aCqSyd.js.map} +1 -1
  7. package/dist/GridLayout-DNOClFzz.cjs +2 -0
  8. package/dist/{GridLayout-DKTg_N61.cjs.map → GridLayout-DNOClFzz.cjs.map} +1 -1
  9. package/dist/PanelSystem-B8Igvnb2.cjs +3 -0
  10. package/dist/PanelSystem-B8Igvnb2.cjs.map +1 -0
  11. package/dist/{PanelSystem-BqUzNtf2.js → PanelSystem-DDUSFjXD.js} +208 -247
  12. package/dist/PanelSystem-DDUSFjXD.js.map +1 -0
  13. package/dist/components/window/Drawer.d.ts +1 -0
  14. package/dist/components/window/DrawerRevealContext.d.ts +61 -0
  15. package/dist/components/window/drawerRevealAnimationUtils.d.ts +212 -0
  16. package/dist/components/window/drawerStyles.d.ts +5 -0
  17. package/dist/components/window/useDrawerSwipeTransform.d.ts +8 -2
  18. package/dist/components/window/useDrawerTransform.d.ts +68 -0
  19. package/dist/components/window/useRevealDrawerTransform.d.ts +56 -0
  20. package/dist/config.cjs +1 -1
  21. package/dist/config.cjs.map +1 -1
  22. package/dist/config.js +8 -7
  23. package/dist/config.js.map +1 -1
  24. package/dist/grid.cjs +1 -1
  25. package/dist/grid.js +2 -2
  26. package/dist/index.cjs +1 -1
  27. package/dist/index.js +4 -4
  28. package/dist/modules/drawer/drawerStateMachine.d.ts +168 -0
  29. package/dist/modules/drawer/revealDrawerConstants.d.ts +33 -0
  30. package/dist/modules/drawer/revealDrawerStateMachine.d.ts +146 -0
  31. package/dist/modules/drawer/strategies/index.d.ts +8 -0
  32. package/dist/modules/drawer/strategies/overlayStrategy.d.ts +12 -0
  33. package/dist/modules/drawer/strategies/revealStrategy.d.ts +12 -0
  34. package/dist/modules/drawer/strategies/types.d.ts +116 -0
  35. package/dist/panels.cjs +1 -1
  36. package/dist/panels.js +1 -1
  37. package/dist/stack.cjs +1 -1
  38. package/dist/stack.cjs.map +1 -1
  39. package/dist/stack.js +306 -347
  40. package/dist/stack.js.map +1 -1
  41. package/dist/types.d.ts +14 -0
  42. package/dist/useAnimationFrame-BZ6D2lMq.cjs +2 -0
  43. package/dist/useAnimationFrame-BZ6D2lMq.cjs.map +1 -0
  44. package/dist/useAnimationFrame-Bg4e-H8O.js +394 -0
  45. package/dist/useAnimationFrame-Bg4e-H8O.js.map +1 -0
  46. package/dist/window.cjs +1 -1
  47. package/dist/window.js +1 -1
  48. package/package.json +1 -1
  49. package/src/components/gesture/SwipeSafeZone.tsx +1 -0
  50. package/src/components/grid/GridLayout.tsx +110 -38
  51. package/src/components/window/Drawer.tsx +114 -10
  52. package/src/components/window/DrawerLayers.tsx +48 -15
  53. package/src/components/window/DrawerRevealContext.spec.ts +20 -0
  54. package/src/components/window/DrawerRevealContext.tsx +99 -0
  55. package/src/components/window/drawerRevealAnimationUtils.spec.ts +375 -0
  56. package/src/components/window/drawerRevealAnimationUtils.ts +415 -0
  57. package/src/components/window/drawerStyles.spec.ts +39 -0
  58. package/src/components/window/drawerStyles.ts +24 -0
  59. package/src/components/window/useDrawerSwipeTransform.ts +28 -90
  60. package/src/components/window/useDrawerTransform.ts +505 -0
  61. package/src/components/window/useRevealDrawerTransform.spec.ts +1936 -0
  62. package/src/components/window/useRevealDrawerTransform.ts +105 -0
  63. package/src/demo/components/FullscreenDemoPage.tsx +47 -0
  64. package/src/demo/fullscreenRoutes.tsx +32 -0
  65. package/src/demo/index.tsx +5 -0
  66. package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +23 -8
  67. package/src/demo/pages/Drawer/components/DrawerBasics.module.css +6 -1
  68. package/src/demo/pages/Drawer/components/DrawerBasics.tsx +14 -4
  69. package/src/demo/pages/Drawer/components/DrawerReveal.module.css +157 -0
  70. package/src/demo/pages/Drawer/components/DrawerReveal.tsx +128 -0
  71. package/src/demo/pages/Drawer/reveal/index.tsx +17 -0
  72. package/src/demo/pages/Drawer/reveal-fullscreen/index.tsx +135 -0
  73. package/src/demo/pages/Drawer/reveal-fullscreen/styles.module.css +233 -0
  74. package/src/demo/pages/Stack/components/StackBasics.spec.tsx +56 -52
  75. package/src/demo/pages/Stack/components/StackTablet.spec.tsx +39 -49
  76. package/src/demo/routes.tsx +2 -0
  77. package/src/hooks/gesture/testing/createGestureSimulator.ts +1 -0
  78. package/src/hooks/gesture/useNativeGestureGuard.spec.ts +10 -2
  79. package/src/hooks/gesture/utils.ts +15 -4
  80. package/src/hooks/useAnimatedVisibility.spec.ts +3 -3
  81. package/src/hooks/useOperationContinuity.spec.ts +17 -10
  82. package/src/hooks/useOperationContinuity.ts +5 -5
  83. package/src/hooks/useSharedElementTransition.ts +28 -7
  84. package/src/modules/dialog/dialogAnimationUtils.spec.ts +0 -1
  85. package/src/modules/dialog/useDialogContainer.spec.ts +11 -3
  86. package/src/modules/dialog/useDialogSwipeInput.spec.ts +49 -28
  87. package/src/modules/dialog/useDialogSwipeInput.ts +37 -6
  88. package/src/modules/dialog/useDialogTransform.spec.ts +63 -30
  89. package/src/modules/drawer/drawerStateMachine.ts +500 -0
  90. package/src/modules/drawer/revealDrawerConstants.ts +38 -0
  91. package/src/modules/drawer/revealDrawerStateMachine.spec.ts +558 -0
  92. package/src/modules/drawer/revealDrawerStateMachine.ts +197 -0
  93. package/src/modules/drawer/strategies/index.ts +9 -0
  94. package/src/modules/drawer/strategies/overlayStrategy.ts +133 -0
  95. package/src/modules/drawer/strategies/revealStrategy.ts +111 -0
  96. package/src/modules/drawer/strategies/types.ts +160 -0
  97. package/src/modules/drawer/useDrawerSwipeInput.ts +7 -4
  98. package/src/modules/pivot/SwipePivotContent.spec.tsx +48 -37
  99. package/src/modules/pivot/usePivotSwipeInput.spec.ts +8 -8
  100. package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1 -1
  101. package/src/types.ts +15 -0
  102. package/dist/FloatingWindow-CUXnEtrb.js +0 -827
  103. package/dist/FloatingWindow-CUXnEtrb.js.map +0 -1
  104. package/dist/FloatingWindow-DMwyK0eK.cjs +0 -2
  105. package/dist/FloatingWindow-DMwyK0eK.cjs.map +0 -1
  106. package/dist/GridLayout-DKTg_N61.cjs +0 -2
  107. package/dist/GridLayout-UWNxXw77.js +0 -926
  108. package/dist/PanelSystem-BqUzNtf2.js.map +0 -1
  109. package/dist/PanelSystem-D603LKKv.cjs +0 -3
  110. package/dist/PanelSystem-D603LKKv.cjs.map +0 -1
  111. package/dist/useNativeGestureGuard-C7TSqEkr.cjs +0 -2
  112. package/dist/useNativeGestureGuard-C7TSqEkr.cjs.map +0 -1
  113. package/dist/useNativeGestureGuard-CGYo6O0r.js +0 -347
  114. package/dist/useNativeGestureGuard-CGYo6O0r.js.map +0 -1
  115. 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
- return isScrollableX || isScrollableY;
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: EventTarget,
17
- currentTarget: EventTarget,
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 }) => useOperationContinuity(role, displacement > 0),
161
- { initialProps: { role: "active" as const, displacement: 500 } },
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 }) => useOperationContinuity(role, displacement > 0),
202
- { initialProps: { role: "active" as const, displacement: 0 } },
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 }) => useOperationContinuity(role, displacement > 0),
224
- { initialProps: { role: "behind" as const, displacement: 100 } },
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 }) => useOperationContinuity(role, displacement > 0),
245
- { initialProps: { role: "active" as const, displacement: 400 } },
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 }) => useOperationContinuity(role, displacement > 0),
266
- { initialProps: { role: "behind" as const, displacement: 100 } },
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 && !shouldRetainPrevious;
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 && valueDiverged;
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 || currentlyDiverged;
106
- const changedAtExit = operationJustEnded && valueDiverged;
107
- const changedDuringOperation = changedDuringRetention || changedAtExit;
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
- return typeof document !== "undefined" && "startViewTransition" in document;
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
- const handlePointerUp = React.useCallback((event: React.PointerEvent) => {
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 || isThisItemCollapsing;
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 || displacement.x !== 0 || displacement.y !== 0
295
- ? `translate(${displacement.x}px, ${displacement.y}px)`
296
- : undefined;
317
+ const transform = computeSwipeTransform(isSwiping, displacement);
297
318
 
298
319
  return {
299
320
  style: {
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * @file Tests for dialog animation utilities
3
3
  */
4
- import { describe, expect, it } from "vitest";
5
4
  import {
6
5
  computeCloseTransform,
7
6
  computeOpenTransform,
@@ -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: EventTarget,
65
- currentTarget: EventTarget,
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: window,
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
- // Mock ResizeObserver
10
- vi.stubGlobal(
11
- "ResizeObserver",
12
- class {
13
- observe = vi.fn();
14
- unobserve = vi.fn();
15
- disconnect = vi.fn();
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: vi.fn(),
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: vi.fn(),
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: vi.fn(),
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: vi.fn(),
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: vi.fn(),
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: vi.fn(),
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
- return isScrollableX || isScrollableY;
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 > 0 ? 1 : primaryDisplacement < 0 ? -1 : 0;
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 > 0 ? 1 : primaryValue < 0 ? -1 : 0;
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 && tracking.current
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 mockResizeObserver = vi.fn();
12
- vi.stubGlobal("ResizeObserver", class {
13
- constructor(callback: ResizeObserverCallback) {
14
- mockResizeObserver(callback);
15
- }
16
- observe = vi.fn();
17
- unobserve = vi.fn();
18
- disconnect = vi.fn();
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
- let rafCallbacks: FrameRequestCallback[] = [];
23
- let rafId = 0;
35
+ const createRAFTracker = () => {
36
+ const callbacks: FrameRequestCallback[] = [];
37
+ const tracker = { callbacks, nextId: 0 };
38
+ return tracker;
39
+ };
24
40
 
25
- beforeEach(() => {
26
- rafCallbacks = [];
27
- rafId = 0;
41
+ const rafTracker = createRAFTracker();
42
+ const originalRAF = globalThis.requestAnimationFrame;
43
+ const originalCAF = globalThis.cancelAnimationFrame;
28
44
 
29
- vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
30
- rafCallbacks.push(cb);
31
- return ++rafId;
32
- });
33
- vi.stubGlobal("cancelAnimationFrame", (id: number) => {
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
- vi.unstubAllGlobals();
40
- vi.clearAllMocks();
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 = [...rafCallbacks];
47
- rafCallbacks = [];
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: vi.fn(),
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 onOpenComplete = vi.fn();
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(onOpenComplete).toHaveBeenCalled();
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 onCloseComplete = vi.fn();
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(onCloseComplete).toHaveBeenCalled();
296
+ expect(onCloseCompleteCalls.length).toBeGreaterThan(0);
264
297
  });
265
298
  });
266
299