From 9ee9874181be0bf900dad06c5b1d63dc95e13ee4 Mon Sep 17 00:00:00 2001 From: Nicolas Varrot Date: Sun, 15 Feb 2026 16:04:57 +0000 Subject: [PATCH] feat: swipe gesture to open/close sidebar on mobile Swipe right from the left edge to open the sidebar, swipe left to close it. Standard mobile UX pattern with edge detection, vertical drift rejection, and time-based velocity check. --- src/App.tsx | 2 + src/hooks/useSwipeSidebar.ts | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 src/hooks/useSwipeSidebar.ts diff --git a/src/App.tsx b/src/App.tsx index 9ce9b2c..6e35350 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { ToolCollapseProvider } from './contexts/ToolCollapseContext'; import { sessionDisplayName } from './lib/sessionName'; import { X } from 'lucide-react'; import { useT } from './hooks/useLocale'; +import { useSwipeSidebar } from './hooks/useSwipeSidebar'; const Chat = lazy(() => import('./components/Chat').then(m => ({ default: m.Chat }))); @@ -83,6 +84,7 @@ export default function App() { const [sidebarOpen, setSidebarOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false); + useSwipeSidebar(sidebarOpen, () => setSidebarOpen(true), () => setSidebarOpen(false)); const { notify, soundEnabled, toggleSound } = useNotifications(); const prevMessageCountRef = useRef(messages.length); diff --git a/src/hooks/useSwipeSidebar.ts b/src/hooks/useSwipeSidebar.ts new file mode 100644 index 0000000..0b930c5 --- /dev/null +++ b/src/hooks/useSwipeSidebar.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef, useCallback } from 'react'; + +const EDGE_ZONE = 30; // px from left edge to start detecting +const MIN_SWIPE = 50; // minimum px distance to trigger +const MAX_Y_DRIFT = 80; // if vertical movement exceeds this, abort + +/** + * Swipe-to-open / swipe-to-close sidebar on touch devices. + * - Swipe right from the left edge → open + * - Swipe left anywhere when open → close + */ +export function useSwipeSidebar( + isOpen: boolean, + onOpen: () => void, + onClose: () => void, +) { + const touchStart = useRef<{ x: number; y: number; time: number } | null>(null); + const isSwipingRef = useRef(false); + + const handleTouchStart = useCallback((e: TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + + // When closed: only detect from left edge + // When open: detect anywhere (to close) + if (!isOpen && touch.clientX > EDGE_ZONE) return; + + touchStart.current = { x: touch.clientX, y: touch.clientY, time: Date.now() }; + isSwipingRef.current = false; + }, [isOpen]); + + const handleTouchMove = useCallback((e: TouchEvent) => { + if (!touchStart.current) return; + const touch = e.touches[0]; + if (!touch) return; + + const dx = touch.clientX - touchStart.current.x; + const dy = Math.abs(touch.clientY - touchStart.current.y); + + // Too much vertical drift → not a horizontal swipe + if (dy > MAX_Y_DRIFT) { + touchStart.current = null; + return; + } + + if (Math.abs(dx) > 10) { + isSwipingRef.current = true; + } + }, []); + + const handleTouchEnd = useCallback((e: TouchEvent) => { + if (!touchStart.current) return; + const touch = e.changedTouches[0]; + if (!touch) { touchStart.current = null; return; } + + const dx = touch.clientX - touchStart.current.x; + const dy = Math.abs(touch.clientY - touchStart.current.y); + const elapsed = Date.now() - touchStart.current.time; + touchStart.current = null; + + // Only act on horizontal swipes + if (dy > MAX_Y_DRIFT || elapsed > 500) return; + + if (!isOpen && dx > MIN_SWIPE) { + onOpen(); + } else if (isOpen && dx < -MIN_SWIPE) { + onClose(); + } + }, [isOpen, onOpen, onClose]); + + useEffect(() => { + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + document.addEventListener('touchmove', handleTouchMove, { passive: true }); + document.addEventListener('touchend', handleTouchEnd, { passive: true }); + return () => { + document.removeEventListener('touchstart', handleTouchStart); + document.removeEventListener('touchmove', handleTouchMove); + document.removeEventListener('touchend', handleTouchEnd); + }; + }, [handleTouchStart, handleTouchMove, handleTouchEnd]); +}