From 353113f431201a46f943dc80faf1430b81c24205 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 11 Mar 2026 03:53:39 +1100 Subject: [PATCH] fix: polish mobile history lightbox swipe UX --- components/images/image-lightbox.test.tsx | 139 ++++++++- components/images/image-lightbox.tsx | 96 +++--- components/ui/media-player.test.tsx | 12 + components/ui/media-player.tsx | 12 +- hooks/use-media-player.test.ts | 84 +++++- hooks/use-media-player.ts | 337 +++++++++++---------- hooks/use-vertical-swipe-navigation.ts | 342 ++++++++++++++++++++-- 7 files changed, 801 insertions(+), 221 deletions(-) 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 ? (
-
-