diff --git a/components/images/image-lightbox.test.tsx b/components/images/image-lightbox.test.tsx
index be14f22..c23653f 100644
--- a/components/images/image-lightbox.test.tsx
+++ b/components/images/image-lightbox.test.tsx
@@ -864,7 +864,9 @@ describe("ImageLightbox - Mobile Swipe Navigation", () => {
clientY: 180,
});
- expect(onNext).toHaveBeenCalledTimes(1);
+ return waitFor(() => {
+ expect(onNext).toHaveBeenCalledTimes(1);
+ });
});
it("navigates to the previous media on downward touch swipe", () => {
@@ -900,7 +902,140 @@ describe("ImageLightbox - Mobile Swipe Navigation", () => {
clientY: 320,
});
- expect(onPrevious).toHaveBeenCalledTimes(1);
+ return waitFor(() => {
+ expect(onPrevious).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("drags the media with the finger before release", () => {
+ render(
+ ,
+ );
+
+ const swipeRegion = screen.getByTestId("lightbox-swipe-region");
+ const swipeMotion = screen.getByTestId("lightbox-swipe-motion");
+ const initialTransform = swipeMotion.style.transform;
+
+ fireEvent.pointerDown(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 120,
+ clientY: 180,
+ });
+ fireEvent.pointerMove(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 122,
+ clientY: 300,
+ });
+
+ expect(swipeMotion.style.transform).not.toBe(initialTransform);
+ expect(swipeMotion.style.transform).toContain("translate3d");
+ });
+
+ it("ignores mostly horizontal drags", async () => {
+ const onNext = vi.fn();
+ const onPrevious = vi.fn();
+
+ render(
+ ,
+ );
+
+ const swipeRegion = screen.getByTestId("lightbox-swipe-region");
+ fireEvent.pointerDown(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 120,
+ clientY: 180,
+ });
+ fireEvent.pointerMove(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 280,
+ clientY: 210,
+ });
+ fireEvent.pointerUp(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 280,
+ clientY: 210,
+ });
+
+ await waitFor(() => {
+ expect(onNext).not.toHaveBeenCalled();
+ expect(onPrevious).not.toHaveBeenCalled();
+ });
+ });
+
+ it("snaps back when swiping beyond a gallery boundary", async () => {
+ render(
+ ,
+ );
+
+ const swipeRegion = screen.getByTestId("lightbox-swipe-region");
+ const swipeMotion = screen.getByTestId("lightbox-swipe-motion");
+ const initialTransform = swipeMotion.style.transform;
+
+ fireEvent.pointerDown(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 120,
+ clientY: 240,
+ });
+ fireEvent.pointerMove(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 120,
+ clientY: 80,
+ });
+ fireEvent.pointerUp(swipeRegion, {
+ pointerId: 1,
+ pointerType: "touch",
+ isPrimary: true,
+ clientX: 120,
+ clientY: 80,
+ });
+
+ await waitFor(() => {
+ expect(swipeMotion.style.transform).toBe(initialTransform);
+ });
});
it("ignores short touch drags", () => {
diff --git a/components/images/image-lightbox.tsx b/components/images/image-lightbox.tsx
index 0b3c165..86dfe40 100644
--- a/components/images/image-lightbox.tsx
+++ b/components/images/image-lightbox.tsx
@@ -127,7 +127,10 @@ export function ImageLightbox({
// Zoom state for single-image mode (updated via callback from LightboxMediaDisplay)
const [singleModeZoomed, setSingleModeZoomed] = React.useState(false);
- const suppressBackdropClickRef = React.useRef(false);
+ const suppressBackdropClickUntilRef = React.useRef(0);
+ const suppressBackdropClick = React.useCallback(() => {
+ suppressBackdropClickUntilRef.current = Date.now() + 180;
+ }, []);
const resetEditSessionState = React.useCallback(() => {
setEditChain([]);
setSelectedVersionIndex(0);
@@ -138,13 +141,13 @@ export function ImageLightbox({
React.useEffect(() => {
// Reset local-only edit state whenever the opened image changes.
- suppressBackdropClickRef.current = false;
+ suppressBackdropClickUntilRef.current = 0;
resetEditSessionState();
}, [imageSessionKey, resetEditSessionState]);
React.useEffect(() => {
if (!isOpen) {
- suppressBackdropClickRef.current = false;
+ suppressBackdropClickUntilRef.current = 0;
resetEditSessionState();
}
}, [isOpen, resetEditSessionState]);
@@ -259,8 +262,7 @@ export function ImageLightbox({
}, [onClose]);
const handleBackdropCloseAttempt = React.useCallback(() => {
- if (suppressBackdropClickRef.current) {
- suppressBackdropClickRef.current = false;
+ if (Date.now() < suppressBackdropClickUntilRef.current) {
return;
}
@@ -422,23 +424,33 @@ export function ImageLightbox({
const swipeNavigationHandlers = useVerticalSwipeNavigation({
enabled: isSwipeNavigationEnabled,
+ itemKey: imageSessionKey ? String(imageSessionKey) : null,
+ onSwipeIntent: suppressBackdropClick,
onSwipeUp: mediaNavigation?.hasNext
? () => {
- suppressBackdropClickRef.current = true;
+ suppressBackdropClick();
mediaNavigation.onNext();
}
: undefined,
onSwipeDown: mediaNavigation?.hasPrevious
? () => {
- suppressBackdropClickRef.current = true;
+ suppressBackdropClick();
mediaNavigation.onPrevious();
}
: undefined,
});
const {
touchAction: swipeTouchAction,
+ overlayStyle: swipeOverlayStyle,
+ mediaStyle: swipeMediaStyle,
+ isDragging: isSwipeDragging,
+ isAnimating: isSwipeAnimating,
...swipeGestureHandlers
} = swipeNavigationHandlers;
+ const isSwipeInteractionActive = isSwipeDragging || isSwipeAnimating;
+ const lightboxBackdropStyle = isSwipeNavigationEnabled
+ ? swipeOverlayStyle
+ : { backgroundColor: "rgba(0, 0, 0, 0.8)" };
return (
<>
@@ -456,7 +468,10 @@ export function ImageLightbox({
{displayImage && activeImage && (
-
+
{isVideo ? (
-
-