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.
- package/dist/FloatingWindow-CE-WzkNv.js +1542 -0
- package/dist/FloatingWindow-CE-WzkNv.js.map +1 -0
- package/dist/FloatingWindow-DpFpmX1f.cjs +2 -0
- package/dist/FloatingWindow-DpFpmX1f.cjs.map +1 -0
- package/dist/GridLayout-EwKszYBy.cjs +2 -0
- package/dist/{GridLayout-DKTg_N61.cjs.map → GridLayout-EwKszYBy.cjs.map} +1 -1
- package/dist/GridLayout-kiWdpMLQ.js +947 -0
- package/dist/{GridLayout-UWNxXw77.js.map → GridLayout-kiWdpMLQ.js.map} +1 -1
- package/dist/PanelSystem-Dmy5YI_6.cjs +3 -0
- package/dist/PanelSystem-Dmy5YI_6.cjs.map +1 -0
- package/dist/{PanelSystem-BqUzNtf2.js → PanelSystem-DrYsYwuV.js} +208 -247
- package/dist/PanelSystem-DrYsYwuV.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/dialog/index.d.ts +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/dialog/DialogContainer.d.ts +22 -2
- package/dist/modules/dialog/Modal.d.ts +23 -2
- package/dist/modules/dialog/SwipeDialogContainer.d.ts +6 -2
- package/dist/modules/dialog/types.d.ts +12 -0
- 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-CRuFlk5t.js +394 -0
- package/dist/useAnimationFrame-CRuFlk5t.js.map +1 -0
- package/dist/useAnimationFrame-XRpDXkwV.cjs +2 -0
- package/dist/useAnimationFrame-XRpDXkwV.cjs.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/dialog/index.ts +2 -0
- package/src/hooks/gesture/testing/createGestureSimulator.ts +1 -0
- package/src/hooks/gesture/useNativeGestureGuard.spec.ts +10 -2
- package/src/hooks/gesture/useSwipeInput.spec.ts +69 -0
- package/src/hooks/gesture/useSwipeInput.ts +2 -0
- 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/DialogContainer.tsx +39 -5
- package/src/modules/dialog/Modal.tsx +46 -4
- package/src/modules/dialog/SwipeDialogContainer.tsx +12 -2
- package/src/modules/dialog/dialogAnimationUtils.spec.ts +0 -1
- package/src/modules/dialog/types.ts +14 -0
- 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
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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: {
|
|
@@ -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
|
-
|
|
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
|
|
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<
|
|
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={
|
|
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<
|
|
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) {
|
|
@@ -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:
|
|
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
|
|