From 96e1915d769fc2c627e13b5124434296d0689374 Mon Sep 17 00:00:00 2001 From: Tobias Graf <2226232+42tg@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:53:19 +0100 Subject: [PATCH] feat: add mobile swipe gestures for sidebar navigation - Add sidebar.swipe.logic.ts with pure state machine for horizontal swipe and vertical pull-down gesture detection - Wrap mobile sidebar sheet with SwipeToDismiss for drag-to-close - Detect edge swipes in SidebarInset to open sidebar - Add PullToReveal component for pull-down gesture feedback - Auto-close mobile sidebar on thread selection - 17 new tests for gesture logic --- apps/web/src/components/Sidebar.tsx | 16 +- .../components/ui/sidebar.swipe.logic.test.ts | 125 +++++++++++ .../src/components/ui/sidebar.swipe.logic.ts | 72 ++++++ apps/web/src/components/ui/sidebar.tsx | 211 +++++++++++++++++- 4 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/ui/sidebar.swipe.logic.test.ts create mode 100644 apps/web/src/components/ui/sidebar.swipe.logic.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index fbe4e0528a..4f96070e06 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -106,6 +106,7 @@ import { SidebarMenuSubItem, SidebarSeparator, SidebarTrigger, + useSidebar, } from "./ui/sidebar"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { isNonEmpty as isNonEmptyString } from "effect/String"; @@ -666,6 +667,7 @@ function SortableProjectItem({ } export default function Sidebar() { + const { isMobile, setOpenMobile } = useSidebar(); const projects = useStore((store) => store.projects); const sidebarThreadsById = useStore((store) => store.sidebarThreadsById); const threadIdsByProjectId = useStore((store) => store.threadIdsByProjectId); @@ -1203,6 +1205,9 @@ export default function Sidebar() { clearSelection(); } setSelectionAnchor(threadId); + if (isMobile) { + setOpenMobile(false); + } void navigate({ to: "/$threadId", params: { threadId }, @@ -1210,9 +1215,11 @@ export default function Sidebar() { }, [ clearSelection, + isMobile, navigate, rangeSelectTo, selectedThreadIds.size, + setOpenMobile, setSelectionAnchor, toggleThreadSelection, ], @@ -1224,12 +1231,15 @@ export default function Sidebar() { clearSelection(); } setSelectionAnchor(threadId); + if (isMobile) { + setOpenMobile(false); + } void navigate({ to: "/$threadId", params: { threadId }, }); }, - [clearSelection, navigate, selectedThreadIds.size, setSelectionAnchor], + [clearSelection, isMobile, navigate, selectedThreadIds.size, setOpenMobile, setSelectionAnchor], ); const handleProjectContextMenu = useCallback( @@ -2004,7 +2014,7 @@ export default function Sidebar() { ) : ( <> - + {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( @@ -2164,7 +2174,7 @@ export default function Sidebar() { )} - + diff --git a/apps/web/src/components/ui/sidebar.swipe.logic.test.ts b/apps/web/src/components/ui/sidebar.swipe.logic.test.ts new file mode 100644 index 0000000000..b6799a0583 --- /dev/null +++ b/apps/web/src/components/ui/sidebar.swipe.logic.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; + +import { + resolvePullGestureState, + resolveSwipeGestureState, + SCROLL_CANCEL_PX, + SWIPE_AXIS_RATIO, + SWIPE_LOCK_PX, +} from "./sidebar.swipe.logic"; + +describe("resolveSwipeGestureState", () => { + describe("terminal states pass through unchanged", () => { + it("returns swiping unchanged when already swiping", () => { + // Any delta — even one that would cancel — should not override a committed state + expect(resolveSwipeGestureState("swiping", { dx: 0, dy: SCROLL_CANCEL_PX + 1 })).toBe( + "swiping", + ); + }); + + it("returns cancelled unchanged when already cancelled", () => { + // Any delta — even one that would lock-in — should not override a committed state + expect( + resolveSwipeGestureState("cancelled", { + dx: SWIPE_LOCK_PX + 1, + dy: 0, + }), + ).toBe("cancelled"); + }); + }); + + describe("scroll cancellation", () => { + it("cancels when vertical movement exceeds SCROLL_CANCEL_PX", () => { + expect(resolveSwipeGestureState("idle", { dx: 0, dy: SCROLL_CANCEL_PX + 1 })).toBe( + "cancelled", + ); + }); + + it("does not cancel when vertical movement is exactly at SCROLL_CANCEL_PX", () => { + // Rule uses strict >, so equality stays idle + expect(resolveSwipeGestureState("idle", { dx: 0, dy: SCROLL_CANCEL_PX })).toBe("idle"); + }); + + it("cancels even when horizontal movement would otherwise qualify for lock-in", () => { + // Vertical exceeds cancel threshold, so cancel wins regardless of horizontal + expect( + resolveSwipeGestureState("idle", { + dx: SWIPE_LOCK_PX + 1, + dy: SCROLL_CANCEL_PX + 1, + }), + ).toBe("cancelled"); + }); + }); + + describe("swipe lock-in", () => { + it("locks in when horizontal exceeds SWIPE_LOCK_PX and is more than 2x the vertical", () => { + const dy = 5; + const dx = dy * SWIPE_AXIS_RATIO + SWIPE_LOCK_PX; // satisfies both distance and ratio + expect(resolveSwipeGestureState("idle", { dx, dy })).toBe("swiping"); + }); + + it("does not lock in when horizontal is exactly at SWIPE_LOCK_PX", () => { + // Rule uses strict >, so equality stays idle + expect(resolveSwipeGestureState("idle", { dx: SWIPE_LOCK_PX, dy: 0 })).toBe("idle"); + }); + + it("does not lock in when horizontal is not 2x the vertical", () => { + // dy = SCROLL_CANCEL_PX → no cancel triggered (strict >) + // dx = SWIPE_LOCK_PX + 2 → exceeds distance threshold + // but SWIPE_LOCK_PX + 2 (22) is NOT > SCROLL_CANCEL_PX * SWIPE_AXIS_RATIO (24) → ratio fails + const dy = SCROLL_CANCEL_PX; + const dx = SWIPE_LOCK_PX + 2; + expect(resolveSwipeGestureState("idle", { dx, dy })).toBe("idle"); + }); + + it("does not lock in when only the axis ratio is met but distance is insufficient", () => { + // dx=10 satisfies ratio (dy=4, 10 > 4*2=8) but dx(10) is NOT > SWIPE_LOCK_PX(20) + expect(resolveSwipeGestureState("idle", { dx: 10, dy: 4 })).toBe("idle"); + }); + }); + + describe("idle state", () => { + it("stays idle for small diagonal movement", () => { + // Both dx and dy are small — well under all thresholds + expect(resolveSwipeGestureState("idle", { dx: 5, dy: 3 })).toBe("idle"); + }); + + it("stays idle at zero delta", () => { + expect(resolveSwipeGestureState("idle", { dx: 0, dy: 0 })).toBe("idle"); + }); + }); +}); + +describe("resolvePullGestureState", () => { + describe("terminal states pass through unchanged", () => { + it("returns pulling unchanged when already pulling", () => { + expect(resolvePullGestureState("pulling", { dx: SWIPE_LOCK_PX + 1, dy: 0 })).toBe("pulling"); + }); + + it("returns cancelled unchanged when already cancelled", () => { + expect(resolvePullGestureState("cancelled", { dx: 0, dy: 100 })).toBe("cancelled"); + }); + }); + + describe("cancellation", () => { + it("cancels when horizontal movement exceeds SWIPE_LOCK_PX", () => { + expect(resolvePullGestureState("idle", { dx: SWIPE_LOCK_PX + 1, dy: 30 })).toBe("cancelled"); + }); + + it("cancels when vertical movement is upward", () => { + expect(resolvePullGestureState("idle", { dx: 0, dy: -1 })).toBe("cancelled"); + }); + }); + + describe("pull lock-in", () => { + it("locks in when downward exceeds threshold and dominates horizontal", () => { + expect(resolvePullGestureState("idle", { dx: 2, dy: 20 })).toBe("pulling"); + }); + }); + + describe("idle state", () => { + it("stays idle for small movement", () => { + expect(resolvePullGestureState("idle", { dx: 2, dy: 5 })).toBe("idle"); + }); + }); +}); diff --git a/apps/web/src/components/ui/sidebar.swipe.logic.ts b/apps/web/src/components/ui/sidebar.swipe.logic.ts new file mode 100644 index 0000000000..1080e60822 --- /dev/null +++ b/apps/web/src/components/ui/sidebar.swipe.logic.ts @@ -0,0 +1,72 @@ +export const SWIPE_THRESHOLD = 80; +// Minimum horizontal movement before we consider it a swipe intent +export const SWIPE_LOCK_PX = 20; +// Horizontal movement must be this many times greater than vertical to count as a swipe +export const SWIPE_AXIS_RATIO = 2; +// If vertical movement exceeds this before horizontal lock-in, treat as a scroll and cancel +export const SCROLL_CANCEL_PX = 12; + +export type SwipeGestureState = "idle" | "swiping" | "cancelled"; + +/** + * Pure state transition function for swipe gesture detection. + * Given the current gesture state and cumulative delta from touch start, + * returns the next state. + * + * Rules (evaluated in order): + * 1. Already committed (swiping or cancelled) → state is terminal, return as-is + * 2. Vertical movement exceeds SCROLL_CANCEL_PX → treat as scroll, cancel + * 3. Horizontal movement exceeds SWIPE_LOCK_PX and is 2× the vertical → lock in as swipe + * 4. Otherwise → remain idle + */ +export function resolveSwipeGestureState( + current: SwipeGestureState, + delta: { dx: number; dy: number }, +): SwipeGestureState { + if (current !== "idle") return current; + + if (Math.abs(delta.dy) > SCROLL_CANCEL_PX) return "cancelled"; + + if ( + Math.abs(delta.dx) > SWIPE_LOCK_PX && + Math.abs(delta.dx) > Math.abs(delta.dy) * SWIPE_AXIS_RATIO + ) { + return "swiping"; + } + + return "idle"; +} + +// --- Pull-to-reveal (vertical) gesture --- + +export const PULL_THRESHOLD = 64; +// Minimum downward movement before locking in as a pull gesture +const PULL_LOCK_PY = 16; +// Vertical movement must be this many times greater than horizontal +const PULL_AXIS_RATIO = 1.5; + +export type PullGestureState = "idle" | "pulling" | "cancelled"; + +/** + * Pure state transition for pull-down gesture detection. + * Mirrors resolveSwipeGestureState but for the vertical axis. + * + * Rules: + * 1. Already committed → terminal, return as-is + * 2. Horizontal movement too large → treat as swipe, cancel + * 3. Upward movement → cancel + * 4. Downward movement exceeds lock-in and dominates horizontal → lock in as pull + * 5. Otherwise → remain idle + */ +export function resolvePullGestureState( + current: PullGestureState, + delta: { dx: number; dy: number }, +): PullGestureState { + if (current !== "idle") return current; + if (Math.abs(delta.dx) > SWIPE_LOCK_PX) return "cancelled"; + if (delta.dy < 0) return "cancelled"; + if (delta.dy > PULL_LOCK_PY && delta.dy > Math.abs(delta.dx) * PULL_AXIS_RATIO) { + return "pulling"; + } + return "idle"; +} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index cfa29e950b..02c865b47f 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -20,6 +20,11 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage"; import { Schema } from "effect"; +import { + resolveSwipeGestureState, + SWIPE_THRESHOLD, + type SwipeGestureState, +} from "./sidebar.swipe.logic"; const SIDEBAR_COOKIE_NAME = "sidebar_state"; const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; @@ -85,6 +90,124 @@ function useSidebar() { return context; } +function SwipeToDismiss({ + children, + onDismiss, + side, +}: { + children: React.ReactNode; + onDismiss: () => void; + side: "left" | "right"; +}) { + const containerRef = React.useRef(null); + const touchStartRef = React.useRef<{ x: number; y: number } | null>(null); + const gestureStateRef = React.useRef("idle"); + + const applyTransform = React.useCallback( + (dx: number) => { + const el = containerRef.current; + if (!el) return; + // Only translate in the closing direction + const offset = side === "left" ? Math.min(0, dx) : Math.max(0, dx); + if (offset === 0) { + el.style.transform = ""; + el.style.opacity = ""; + } else { + el.style.transform = `translateX(${offset}px)`; + el.style.opacity = String(Math.max(0.4, 1 - Math.abs(offset) / 280)); + } + }, + [side], + ); + + const resetTransform = React.useCallback((animated: boolean) => { + const el = containerRef.current; + if (!el) return; + if (animated) { + el.style.transition = "transform 0.2s ease-out, opacity 0.2s ease-out"; + el.style.transform = ""; + el.style.opacity = ""; + const cleanup = () => { + el.style.transition = ""; + el.removeEventListener("transitionend", cleanup); + }; + el.addEventListener("transitionend", cleanup); + } else { + el.style.transition = ""; + el.style.transform = ""; + el.style.opacity = ""; + } + }, []); + + const handleTouchStart = React.useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + touchStartRef.current = { x: touch.clientX, y: touch.clientY }; + gestureStateRef.current = "idle"; + if (containerRef.current) containerRef.current.style.transition = ""; + }, []); + + const handleTouchMove = React.useCallback( + (e: React.TouchEvent) => { + const touch = e.touches[0]; + const start = touchStartRef.current; + if (!touch || !start) return; + const dx = touch.clientX - start.x; + const dy = touch.clientY - start.y; + gestureStateRef.current = resolveSwipeGestureState(gestureStateRef.current, { dx, dy }); + if (gestureStateRef.current === "swiping") { + applyTransform(dx); + } + }, + [applyTransform], + ); + + const handleTouchEnd = React.useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0]; + const start = touchStartRef.current; + touchStartRef.current = null; + + if (!touch || !start || gestureStateRef.current !== "swiping") { + resetTransform(true); + return; + } + + const dx = touch.clientX - start.x; + // Sidebar opens from left → swipe left (negative dx) to close + // Sidebar opens from right → swipe right (positive dx) to close + const shouldDismiss = side === "left" ? dx < -SWIPE_THRESHOLD : dx > SWIPE_THRESHOLD; + + if (shouldDismiss) { + resetTransform(false); // Sheet handles its own close animation + onDismiss(); + } else { + resetTransform(true); // Snap back + } + }, + [onDismiss, resetTransform, side], + ); + + const handleTouchCancel = React.useCallback(() => { + touchStartRef.current = null; + gestureStateRef.current = "idle"; + resetTransform(true); + }, [resetTransform]); + + return ( +
+ {children} +
+ ); +} + function SidebarProvider({ defaultOpen = true, open: openProp, @@ -245,7 +368,9 @@ function Sidebar({ Sidebar Displays the mobile sidebar. -
{children}
+ setOpenMobile(false)} side={side}> +
{children}
+
@@ -593,7 +718,74 @@ function SidebarRail({ ); } -function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { +const SWIPE_OPEN_EDGE_PX = 80; + +function SidebarInset({ className, children, ...props }: React.ComponentProps<"main">) { + const { isMobile, setOpenMobile } = useSidebar(); + + const edgeIndicatorRef = React.useRef(null); + const touchStartRef = React.useRef<{ x: number; y: number } | null>(null); + const gestureStateRef = React.useRef("idle"); + + const handleTouchStart = React.useCallback( + (e: React.TouchEvent) => { + if (!isMobile) return; + const touch = e.touches[0]; + if (!touch) return; + // Only start tracking if the touch begins near the left edge + if (touch.clientX > SWIPE_OPEN_EDGE_PX) return; + touchStartRef.current = { x: touch.clientX, y: touch.clientY }; + gestureStateRef.current = "idle"; + }, + [isMobile], + ); + + const handleTouchMove = React.useCallback((e: React.TouchEvent) => { + const touch = e.touches[0]; + const start = touchStartRef.current; + if (!touch || !start) return; + const dx = touch.clientX - start.x; + const dy = touch.clientY - start.y; + gestureStateRef.current = resolveSwipeGestureState(gestureStateRef.current, { dx, dy }); + const el = edgeIndicatorRef.current; + if (el && gestureStateRef.current === "swiping" && dx > 0) { + const progress = Math.min(dx / SWIPE_THRESHOLD, 1); + // Reach full opacity quickly so the background looks solid + el.style.opacity = String(Math.min(progress * 2, 1)); + // Follow the finger directly, cap at half screen width + el.style.width = `${Math.min(dx, window.innerWidth * 0.5)}px`; + } + }, []); + + const handleTouchEnd = React.useCallback( + (e: React.TouchEvent) => { + const touch = e.changedTouches[0]; + const start = touchStartRef.current; + touchStartRef.current = null; + const el = edgeIndicatorRef.current; + if (el) { + el.style.opacity = "0"; + el.style.width = "4px"; + } + if (!touch || !start || gestureStateRef.current !== "swiping") return; + const dx = touch.clientX - start.x; + if (dx > SWIPE_THRESHOLD) { + setOpenMobile(true); + } + }, + [setOpenMobile], + ); + + const handleTouchCancel = React.useCallback(() => { + touchStartRef.current = null; + gestureStateRef.current = "idle"; + const el = edgeIndicatorRef.current; + if (el) { + el.style.opacity = "0"; + el.style.width = "4px"; + } + }, []); + return (
) { )} data-slot="sidebar-inset" {...props} - /> + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onTouchCancel={handleTouchCancel} + > + {/* Left-edge peek that grows while swiping to open the sidebar */} +
+ {children} +
); }