react-panel-layout 0.6.1 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/dist/FloatingWindow-CE-WzkNv.js +1542 -0
  2. package/dist/FloatingWindow-CE-WzkNv.js.map +1 -0
  3. package/dist/FloatingWindow-DpFpmX1f.cjs +2 -0
  4. package/dist/FloatingWindow-DpFpmX1f.cjs.map +1 -0
  5. package/dist/GridLayout-EwKszYBy.cjs +2 -0
  6. package/dist/{GridLayout-DKTg_N61.cjs.map → GridLayout-EwKszYBy.cjs.map} +1 -1
  7. package/dist/GridLayout-kiWdpMLQ.js +947 -0
  8. package/dist/{GridLayout-UWNxXw77.js.map → GridLayout-kiWdpMLQ.js.map} +1 -1
  9. package/dist/PanelSystem-Dmy5YI_6.cjs +3 -0
  10. package/dist/PanelSystem-Dmy5YI_6.cjs.map +1 -0
  11. package/dist/{PanelSystem-BqUzNtf2.js → PanelSystem-DrYsYwuV.js} +208 -247
  12. package/dist/PanelSystem-DrYsYwuV.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/dialog/index.d.ts +1 -1
  25. package/dist/grid.cjs +1 -1
  26. package/dist/grid.js +2 -2
  27. package/dist/index.cjs +1 -1
  28. package/dist/index.js +4 -4
  29. package/dist/modules/dialog/DialogContainer.d.ts +22 -2
  30. package/dist/modules/dialog/Modal.d.ts +23 -2
  31. package/dist/modules/dialog/SwipeDialogContainer.d.ts +6 -2
  32. package/dist/modules/dialog/types.d.ts +12 -0
  33. package/dist/modules/drawer/drawerStateMachine.d.ts +168 -0
  34. package/dist/modules/drawer/revealDrawerConstants.d.ts +33 -0
  35. package/dist/modules/drawer/revealDrawerStateMachine.d.ts +146 -0
  36. package/dist/modules/drawer/strategies/index.d.ts +8 -0
  37. package/dist/modules/drawer/strategies/overlayStrategy.d.ts +12 -0
  38. package/dist/modules/drawer/strategies/revealStrategy.d.ts +12 -0
  39. package/dist/modules/drawer/strategies/types.d.ts +116 -0
  40. package/dist/panels.cjs +1 -1
  41. package/dist/panels.js +1 -1
  42. package/dist/stack.cjs +1 -1
  43. package/dist/stack.cjs.map +1 -1
  44. package/dist/stack.js +306 -347
  45. package/dist/stack.js.map +1 -1
  46. package/dist/types.d.ts +14 -0
  47. package/dist/useAnimationFrame-CRuFlk5t.js +394 -0
  48. package/dist/useAnimationFrame-CRuFlk5t.js.map +1 -0
  49. package/dist/useAnimationFrame-XRpDXkwV.cjs +2 -0
  50. package/dist/useAnimationFrame-XRpDXkwV.cjs.map +1 -0
  51. package/dist/window.cjs +1 -1
  52. package/dist/window.js +1 -1
  53. package/package.json +1 -1
  54. package/src/components/gesture/SwipeSafeZone.tsx +1 -0
  55. package/src/components/grid/GridLayout.tsx +110 -38
  56. package/src/components/window/Drawer.tsx +114 -10
  57. package/src/components/window/DrawerLayers.tsx +48 -15
  58. package/src/components/window/DrawerRevealContext.spec.ts +20 -0
  59. package/src/components/window/DrawerRevealContext.tsx +99 -0
  60. package/src/components/window/drawerRevealAnimationUtils.spec.ts +375 -0
  61. package/src/components/window/drawerRevealAnimationUtils.ts +415 -0
  62. package/src/components/window/drawerStyles.spec.ts +39 -0
  63. package/src/components/window/drawerStyles.ts +24 -0
  64. package/src/components/window/useDrawerSwipeTransform.ts +28 -90
  65. package/src/components/window/useDrawerTransform.ts +505 -0
  66. package/src/components/window/useRevealDrawerTransform.spec.ts +1936 -0
  67. package/src/components/window/useRevealDrawerTransform.ts +105 -0
  68. package/src/demo/components/FullscreenDemoPage.tsx +47 -0
  69. package/src/demo/fullscreenRoutes.tsx +32 -0
  70. package/src/demo/index.tsx +5 -0
  71. package/src/demo/pages/Dialog/components/CardExpandDemo.tsx +23 -8
  72. package/src/demo/pages/Drawer/components/DrawerBasics.module.css +6 -1
  73. package/src/demo/pages/Drawer/components/DrawerBasics.tsx +14 -4
  74. package/src/demo/pages/Drawer/components/DrawerReveal.module.css +157 -0
  75. package/src/demo/pages/Drawer/components/DrawerReveal.tsx +128 -0
  76. package/src/demo/pages/Drawer/reveal/index.tsx +17 -0
  77. package/src/demo/pages/Drawer/reveal-fullscreen/index.tsx +135 -0
  78. package/src/demo/pages/Drawer/reveal-fullscreen/styles.module.css +233 -0
  79. package/src/demo/pages/Stack/components/StackBasics.spec.tsx +56 -52
  80. package/src/demo/pages/Stack/components/StackTablet.spec.tsx +39 -49
  81. package/src/demo/routes.tsx +2 -0
  82. package/src/dialog/index.ts +2 -0
  83. package/src/hooks/gesture/testing/createGestureSimulator.ts +1 -0
  84. package/src/hooks/gesture/useNativeGestureGuard.spec.ts +10 -2
  85. package/src/hooks/gesture/useSwipeInput.spec.ts +69 -0
  86. package/src/hooks/gesture/useSwipeInput.ts +2 -0
  87. package/src/hooks/gesture/utils.ts +15 -4
  88. package/src/hooks/useAnimatedVisibility.spec.ts +3 -3
  89. package/src/hooks/useOperationContinuity.spec.ts +17 -10
  90. package/src/hooks/useOperationContinuity.ts +5 -5
  91. package/src/hooks/useSharedElementTransition.ts +28 -7
  92. package/src/modules/dialog/DialogContainer.tsx +39 -5
  93. package/src/modules/dialog/Modal.tsx +46 -4
  94. package/src/modules/dialog/SwipeDialogContainer.tsx +12 -2
  95. package/src/modules/dialog/dialogAnimationUtils.spec.ts +0 -1
  96. package/src/modules/dialog/types.ts +14 -0
  97. package/src/modules/dialog/useDialogContainer.spec.ts +11 -3
  98. package/src/modules/dialog/useDialogSwipeInput.spec.ts +49 -28
  99. package/src/modules/dialog/useDialogSwipeInput.ts +37 -6
  100. package/src/modules/dialog/useDialogTransform.spec.ts +63 -30
  101. package/src/modules/drawer/drawerStateMachine.ts +500 -0
  102. package/src/modules/drawer/revealDrawerConstants.ts +38 -0
  103. package/src/modules/drawer/revealDrawerStateMachine.spec.ts +558 -0
  104. package/src/modules/drawer/revealDrawerStateMachine.ts +197 -0
  105. package/src/modules/drawer/strategies/index.ts +9 -0
  106. package/src/modules/drawer/strategies/overlayStrategy.ts +133 -0
  107. package/src/modules/drawer/strategies/revealStrategy.ts +111 -0
  108. package/src/modules/drawer/strategies/types.ts +160 -0
  109. package/src/modules/drawer/useDrawerSwipeInput.ts +7 -4
  110. package/src/modules/pivot/SwipePivotContent.spec.tsx +48 -37
  111. package/src/modules/pivot/usePivotSwipeInput.spec.ts +8 -8
  112. package/src/modules/stack/swipeTransitionContinuity.spec.tsx +1 -1
  113. package/src/types.ts +15 -0
  114. package/dist/FloatingWindow-CUXnEtrb.js +0 -827
  115. package/dist/FloatingWindow-CUXnEtrb.js.map +0 -1
  116. package/dist/FloatingWindow-DMwyK0eK.cjs +0 -2
  117. package/dist/FloatingWindow-DMwyK0eK.cjs.map +0 -1
  118. package/dist/GridLayout-DKTg_N61.cjs +0 -2
  119. package/dist/GridLayout-UWNxXw77.js +0 -926
  120. package/dist/PanelSystem-BqUzNtf2.js.map +0 -1
  121. package/dist/PanelSystem-D603LKKv.cjs +0 -3
  122. package/dist/PanelSystem-D603LKKv.cjs.map +0 -1
  123. package/dist/useNativeGestureGuard-C7TSqEkr.cjs +0 -2
  124. package/dist/useNativeGestureGuard-C7TSqEkr.cjs.map +0 -1
  125. package/dist/useNativeGestureGuard-CGYo6O0r.js +0 -347
  126. package/dist/useNativeGestureGuard-CGYo6O0r.js.map +0 -1
  127. package/src/components/window/useDrawerSwipeTransform.spec.ts +0 -234
@@ -12,13 +12,21 @@ type TestPointerEvent = React.PointerEvent<HTMLElement> & {
12
12
  wasDefaultPrevented: () => boolean;
13
13
  };
14
14
 
15
+ /**
16
+ * Create a mock view for test events.
17
+ */
18
+ function createMockView(): React.PointerEvent<HTMLElement>["view"] {
19
+ // eslint-disable-next-line custom/no-as-outside-guard -- test helper for view casting
20
+ return window as unknown as React.PointerEvent<HTMLElement>["view"];
21
+ }
22
+
15
23
  /**
16
24
  * Creates a mock PointerEvent that satisfies the React.PointerEvent interface.
17
25
  */
18
26
  function createMockPointerEvent(props: {
19
27
  clientX: number;
20
28
  clientY: number;
21
- pointerType: string;
29
+ pointerType: "mouse" | "touch" | "pen";
22
30
  }): TestPointerEvent {
23
31
  const noop = (): void => {};
24
32
  const noopBool = (): boolean => false;
@@ -82,7 +90,7 @@ function createMockPointerEvent(props: {
82
90
  width: 1,
83
91
  // UIEvent properties
84
92
  detail: 0,
85
- view: window,
93
+ view: createMockView(),
86
94
  };
87
95
  }
88
96
 
@@ -426,6 +426,75 @@ describe("useSwipeInput", () => {
426
426
  });
427
427
  });
428
428
 
429
+ describe("touch event cleanup on unmount", () => {
430
+ it("removes touchmove listener when unmounted during active touch", () => {
431
+ const containerRef = createRef();
432
+ const removeSpy = vi.spyOn(document, "removeEventListener");
433
+
434
+ const { unmount } = renderHook(() =>
435
+ useSwipeInput({ containerRef, axis: "horizontal" }),
436
+ );
437
+
438
+ // Simulate touchstart to add the touchmove listener
439
+ const touchStartEvent = new TouchEvent("touchstart", {
440
+ touches: [{ clientX: 100, clientY: 100, identifier: 1 } as Touch],
441
+ bubbles: true,
442
+ cancelable: true,
443
+ });
444
+ containerRef.current!.dispatchEvent(touchStartEvent);
445
+
446
+ // Unmount WITHOUT triggering touchend
447
+ unmount();
448
+
449
+ // Verify touchmove listener was removed during cleanup
450
+ const touchMoveRemoveCalls = removeSpy.mock.calls.filter(
451
+ (call) => call[0] === "touchmove",
452
+ );
453
+ expect(touchMoveRemoveCalls.length).toBeGreaterThanOrEqual(1);
454
+
455
+ removeSpy.mockRestore();
456
+ });
457
+
458
+ it("touch scroll should work after component unmount", () => {
459
+ const containerRef = createRef();
460
+
461
+ const { unmount } = renderHook(() =>
462
+ useSwipeInput({ containerRef, axis: "horizontal" }),
463
+ );
464
+
465
+ // Simulate touchstart
466
+ const touchStartEvent = new TouchEvent("touchstart", {
467
+ touches: [{ clientX: 100, clientY: 100, identifier: 1 } as Touch],
468
+ bubbles: true,
469
+ cancelable: true,
470
+ });
471
+ containerRef.current!.dispatchEvent(touchStartEvent);
472
+
473
+ // Unmount without touchend (simulates navigation during touch)
474
+ unmount();
475
+
476
+ // After unmount, touchmove should NOT be prevented
477
+ // Create a new touchmove event and verify it's not prevented
478
+ let wasDefaultPrevented = false;
479
+ const touchMoveEvent = new TouchEvent("touchmove", {
480
+ touches: [{ clientX: 150, clientY: 100, identifier: 1 } as Touch],
481
+ bubbles: true,
482
+ cancelable: true,
483
+ });
484
+
485
+ // Add a listener to check if default was prevented
486
+ const checkListener = (e: Event) => {
487
+ wasDefaultPrevented = e.defaultPrevented;
488
+ };
489
+ document.addEventListener("touchmove", checkListener);
490
+ document.dispatchEvent(touchMoveEvent);
491
+ document.removeEventListener("touchmove", checkListener);
492
+
493
+ // The touchmove should NOT be prevented after unmount
494
+ expect(wasDefaultPrevented).toBe(false);
495
+ });
496
+ });
497
+
429
498
  describe("wheel swipe (trackpad two-finger swipe)", () => {
430
499
  const createContainerRef = (): React.RefObject<HTMLDivElement> => {
431
500
  const element = document.createElement("div");
@@ -139,6 +139,8 @@ export function useSwipeInput(options: UseSwipeInputOptions): UseSwipeInputResul
139
139
  container.removeEventListener("touchstart", handleTouchStart);
140
140
  document.removeEventListener("touchend", handleTouchEnd);
141
141
  document.removeEventListener("touchcancel", handleTouchEnd);
142
+ // Must also remove disableTouchMove in case unmount happens during active touch
143
+ document.removeEventListener("touchmove", disableTouchMove);
142
144
  };
143
145
  }, [containerRef, enabled]);
144
146
 
@@ -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: {
@@ -2,7 +2,7 @@
2
2
  * @file Base dialog container component using native <dialog> element
3
3
  */
4
4
  import * as React from "react";
5
- import type { DialogContainerProps } from "./types.js";
5
+ import type { DialogContainerProps, DialogContainerHandle } from "./types.js";
6
6
  import { useDialogContainer } from "./useDialogContainer.js";
7
7
  import { SwipeDialogContainer } from "./SwipeDialogContainer.js";
8
8
  import {
@@ -35,9 +35,12 @@ const contentStyle: React.CSSProperties = {
35
35
 
36
36
  const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
37
37
 
38
- type DialogContainerImplProps = DialogContainerProps;
38
+ type DialogContainerImplProps = DialogContainerProps & {
39
+ ref?: React.Ref<DialogContainerHandle>;
40
+ };
39
41
 
40
42
  const DialogContainerImpl: React.FC<DialogContainerImplProps> = ({
43
+ ref,
41
44
  visible,
42
45
  onClose,
43
46
  children,
@@ -62,6 +65,17 @@ const DialogContainerImpl: React.FC<DialogContainerImplProps> = ({
62
65
  preventBodyScroll,
63
66
  });
64
67
 
68
+ // For CSS mode, triggerClose just calls onClose immediately
69
+ // (CSS transitions handle the animation via visibility state)
70
+ const triggerClose = React.useCallback(async (): Promise<void> => {
71
+ onClose();
72
+ }, [onClose]);
73
+
74
+ // Expose triggerClose via ref for API consistency
75
+ React.useImperativeHandle(ref, () => ({
76
+ triggerClose,
77
+ }), [triggerClose]);
78
+
65
79
  const backdropStyle = React.useMemo((): string => {
66
80
  if (transitionMode === "none") {
67
81
  return `
@@ -171,18 +185,38 @@ const DialogContainerImpl: React.FC<DialogContainerImplProps> = ({
171
185
  * <div>Swipe down to close</div>
172
186
  * </DialogContainer>
173
187
  * ```
188
+ *
189
+ * @example Using ref to trigger close animation
190
+ * ```tsx
191
+ * const dialogRef = useRef<DialogContainerHandle>(null);
192
+ *
193
+ * <DialogContainer
194
+ * ref={dialogRef}
195
+ * visible={isOpen}
196
+ * onClose={() => setIsOpen(false)}
197
+ * transitionMode="swipe"
198
+ * >
199
+ * <button onClick={() => dialogRef.current?.triggerClose()}>
200
+ * Close with animation
201
+ * </button>
202
+ * </DialogContainer>
203
+ * ```
174
204
  */
175
- export const DialogContainer: React.FC<DialogContainerProps> = (props) => {
205
+ type DialogContainerComponentProps = DialogContainerProps & {
206
+ ref?: React.Ref<DialogContainerHandle>;
207
+ };
208
+
209
+ export const DialogContainer: React.FC<DialogContainerComponentProps> = ({ ref, ...props }) => {
176
210
  if (!isBrowser) {
177
211
  return null;
178
212
  }
179
213
 
180
214
  // Use SwipeDialogContainer for swipe mode
181
215
  if (props.transitionMode === "swipe") {
182
- return <SwipeDialogContainer {...props} />;
216
+ return <SwipeDialogContainer ref={ref} {...props} />;
183
217
  }
184
218
 
185
- return <DialogContainerImpl {...props} />;
219
+ return <DialogContainerImpl ref={ref} {...props} />;
186
220
  };
187
221
 
188
222
  DialogContainer.displayName = "DialogContainer";
@@ -2,7 +2,7 @@
2
2
  * @file Modal component - Centered modal dialog with optional chrome
3
3
  */
4
4
  import * as React from "react";
5
- import type { ModalProps } from "./types";
5
+ import type { ModalProps, ModalHandle, DialogContainerHandle } from "./types";
6
6
  import { DialogContainer } from "./DialogContainer";
7
7
  import {
8
8
  FloatingPanelFrame,
@@ -59,7 +59,11 @@ const ModalContentWithChrome: React.FC<ModalContentProps> = ({
59
59
  >
60
60
  <FloatingPanelTitle>{header?.title}</FloatingPanelTitle>
61
61
  <React.Activity mode={shouldShowClose ? "visible" : "hidden"}>
62
- <FloatingPanelCloseButton onClick={onClose} aria-label="Close modal" style={{ marginLeft: "auto" }} />
62
+ <FloatingPanelCloseButton
63
+ onClick={onClose}
64
+ aria-label="Close modal"
65
+ style={{ marginLeft: "auto" }}
66
+ />
63
67
  </React.Activity>
64
68
  </FloatingPanelHeader>
65
69
  </React.Activity>
@@ -79,6 +83,10 @@ const ModalContentRenderer: React.FC<ModalContentRendererProps> = ({ chrome, chi
79
83
  return <>{children}</>;
80
84
  };
81
85
 
86
+ type ModalComponentProps = ModalProps & {
87
+ ref?: React.Ref<ModalHandle>;
88
+ };
89
+
82
90
  /**
83
91
  * Modal component for displaying centered dialogs.
84
92
  * Uses native <dialog> element for proper accessibility and top-layer rendering.
@@ -98,8 +106,26 @@ const ModalContentRenderer: React.FC<ModalContentRendererProps> = ({ chrome, chi
98
106
  * </form>
99
107
  * </Modal>
100
108
  * ```
109
+ *
110
+ * @example Using ref to trigger close animation
111
+ * ```tsx
112
+ * const modalRef = useRef<ModalHandle>(null);
113
+ *
114
+ * <Modal
115
+ * ref={modalRef}
116
+ * visible={isOpen}
117
+ * onClose={() => setIsOpen(false)}
118
+ * transitionMode="swipe"
119
+ * header={{ title: "Settings" }}
120
+ * >
121
+ * <button onClick={() => modalRef.current?.triggerClose()}>
122
+ * Close with animation
123
+ * </button>
124
+ * </Modal>
125
+ * ```
101
126
  */
102
- export const Modal: React.FC<ModalProps> = ({
127
+ export const Modal: React.FC<ModalComponentProps> = ({
128
+ ref: externalRef,
103
129
  visible,
104
130
  onClose,
105
131
  children,
@@ -124,6 +150,21 @@ export const Modal: React.FC<ModalProps> = ({
124
150
  openDirection = "center",
125
151
  useViewTransition = false,
126
152
  }) => {
153
+ // Internal ref to DialogContainer for close button
154
+ const internalRef = React.useRef<DialogContainerHandle>(null);
155
+
156
+ // Merge refs - expose the same handle to external ref
157
+ React.useImperativeHandle(externalRef, () => ({
158
+ triggerClose: async () => {
159
+ await internalRef.current?.triggerClose();
160
+ },
161
+ }), []);
162
+
163
+ // Handler for close button that triggers animation
164
+ const handleCloseButtonClick = React.useCallback(() => {
165
+ // Use triggerClose to animate, which will call onClose when complete
166
+ internalRef.current?.triggerClose();
167
+ }, []);
127
168
  const computedStyle = React.useMemo((): React.CSSProperties => {
128
169
  const style: React.CSSProperties = { ...modalContentStyle };
129
170
 
@@ -147,6 +188,7 @@ export const Modal: React.FC<ModalProps> = ({
147
188
 
148
189
  return (
149
190
  <DialogContainer
191
+ ref={internalRef}
150
192
  visible={visible}
151
193
  onClose={onClose}
152
194
  position="center"
@@ -169,7 +211,7 @@ export const Modal: React.FC<ModalProps> = ({
169
211
  chrome={chrome}
170
212
  header={header}
171
213
  dismissible={dismissible}
172
- onClose={onClose}
214
+ onClose={handleCloseButtonClick}
173
215
  contentStyle={propContentStyle}
174
216
  >
175
217
  {children}
@@ -6,7 +6,7 @@
6
6
  * positioning and custom transform animations.
7
7
  */
8
8
  import * as React from "react";
9
- import type { DialogContainerProps } from "./types.js";
9
+ import type { DialogContainerProps, DialogContainerHandle } from "./types.js";
10
10
  import { useDialogContainer } from "./useDialogContainer.js";
11
11
  import { useDialogSwipeInput } from "./useDialogSwipeInput.js";
12
12
  import { useDialogTransform } from "./useDialogTransform.js";
@@ -43,13 +43,18 @@ const backdropStyle: React.CSSProperties = {
43
43
  willChange: "opacity",
44
44
  };
45
45
 
46
+ type SwipeDialogContainerProps = DialogContainerProps & {
47
+ ref?: React.Ref<DialogContainerHandle>;
48
+ };
49
+
46
50
  /**
47
51
  * Swipeable dialog container with multi-phase animation.
48
52
  *
49
53
  * Uses native <dialog> for top layer positioning and custom JS animations
50
54
  * for swipe-to-dismiss functionality.
51
55
  */
52
- export const SwipeDialogContainer: React.FC<DialogContainerProps> = ({
56
+ export const SwipeDialogContainer: React.FC<SwipeDialogContainerProps> = ({
57
+ ref,
53
58
  visible,
54
59
  onClose,
55
60
  children,
@@ -106,6 +111,11 @@ export const SwipeDialogContainer: React.FC<DialogContainerProps> = ({
106
111
  onCloseComplete: onClose,
107
112
  });
108
113
 
114
+ // Expose triggerClose via ref
115
+ React.useImperativeHandle(ref, () => ({
116
+ triggerClose,
117
+ }), [triggerClose]);
118
+
109
119
  // Handle backdrop click
110
120
  const handleBackdropClick = React.useCallback(() => {
111
121
  if (!dismissible) {
@@ -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,
@@ -4,6 +4,15 @@
4
4
  import type * as React from "react";
5
5
  import type { Position } from "../../types";
6
6
 
7
+ /**
8
+ * Handle exposed by DialogContainer via ref.
9
+ * Allows programmatic control of dialog animations.
10
+ */
11
+ export type DialogContainerHandle = {
12
+ /** Trigger the close animation. Returns a promise that resolves when animation completes. */
13
+ triggerClose: () => Promise<void>;
14
+ };
15
+
7
16
  /**
8
17
  * Transition mode for dialog animations
9
18
  * - "none": No animation
@@ -68,6 +77,11 @@ export type ModalHeader = {
68
77
  showCloseButton?: boolean;
69
78
  };
70
79
 
80
+ /**
81
+ * Alias for DialogContainerHandle (used by Modal)
82
+ */
83
+ export type ModalHandle = DialogContainerHandle;
84
+
71
85
  /**
72
86
  * Props for Modal component
73
87
  */
@@ -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