diff --git a/.changeset/fn-8107-mobile-more-drawer-drag-dismiss.md b/.changeset/fn-8107-mobile-more-drawer-drag-dismiss.md new file mode 100644 index 0000000000..c4fa0dfd61 --- /dev/null +++ b/.changeset/fn-8107-mobile-more-drawer-drag-dismiss.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Mobile "More" navigation drawer now closes with a swipe-down gesture. +category: fix +dev: Adds touch drag-to-dismiss to `.mobile-more-sheet` in MobileNavBar; dismiss engages only when the sheet is scrolled to top or dragged by the handle so interior scrolling is preserved. diff --git a/packages/dashboard/app/components/MobileNavBar.css b/packages/dashboard/app/components/MobileNavBar.css index 95cdb5d50f..12cd2c2a3c 100644 --- a/packages/dashboard/app/components/MobileNavBar.css +++ b/packages/dashboard/app/components/MobileNavBar.css @@ -197,9 +197,19 @@ Wrap every tab icon in the same token-sized icon slot and keep unread/pending do max-height: 85vh; overflow-y: auto; -webkit-overflow-scrolling: touch; + touch-action: pan-y; + transition: transform var(--transition-fast); animation: mobile-more-sheet-in 0.25s ease; } +.mobile-more-sheet--gesture-ready { + animation: none; +} + +.mobile-more-sheet--dragging { + transition: none; +} + @keyframes mobile-more-backdrop-in { from { opacity: 0; } to { opacity: 1; } @@ -211,11 +221,21 @@ Wrap every tab icon in the same token-sized icon slot and keep unread/pending do } .mobile-more-sheet-handle { - width: 36px; - height: 4px; - border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: calc(var(--space-xl) + var(--space-md)); + min-height: calc(var(--space-xl) + var(--space-md)); + margin: 0 auto var(--space-sm); +} + +.mobile-more-sheet-handle::after { + width: 100%; + height: calc(var(--space-xs) / 2); + border-radius: var(--radius-sm); background: var(--border); - margin: 0 auto 12px; + content: ""; } .mobile-more-sheet-title { diff --git a/packages/dashboard/app/components/MobileNavBar.tsx b/packages/dashboard/app/components/MobileNavBar.tsx index b27467449c..ab24fcbd4a 100644 --- a/packages/dashboard/app/components/MobileNavBar.tsx +++ b/packages/dashboard/app/components/MobileNavBar.tsx @@ -173,7 +173,18 @@ export function MobileNavBar({ const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false); const [scripts, setScripts] = useState>({}); const [scriptsLoading, setScriptsLoading] = useState(false); + const [dragOffset, setDragOffset] = useState(0); + const [isSheetDragging, setIsSheetDragging] = useState(false); + const [hasSheetDragged, setHasSheetDragged] = useState(false); const navRef = useRef(null); + const sheetRef = useRef(null); + const sheetDragRef = useRef<{ + startY: number; + startedAt: number; + eligible: boolean; + startedOnHandle: boolean; + } | null>(null); + const dragOffsetRef = useRef(0); const scriptEntries = useMemo( () => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)), @@ -203,7 +214,83 @@ export function MobileNavBar({ }; }, [isScriptsSubmenuOpen, projectId]); - const closeMore = useCallback(() => setIsMoreOpen(false), []); + const resetSheetDrag = useCallback(() => { + sheetDragRef.current = null; + dragOffsetRef.current = 0; + setDragOffset(0); + setIsSheetDragging(false); + }, []); + + const closeMore = useCallback(() => { + resetSheetDrag(); + setHasSheetDragged(false); + setIsMoreOpen(false); + }, [resetSheetDrag]); + + /* + FNXC:MobileNav 2026-07-16-12:00: + The mobile More drawer must dismiss when a user drags down from its top or grab handle. + Keep scrollTop===0 as the body-drag guard so a scrolled sheet retains normal interior + scrolling; the non-passive native listener can cancel iOS Safari and Android Chrome + overscroll only for that eligible downward dismissal gesture. + */ + useEffect(() => { + const sheet = sheetRef.current; + if (!isMoreOpen || !sheet) return; + + const onTouchMove = (event: TouchEvent) => { + const drag = sheetDragRef.current; + const touch = event.touches[0]; + if (!drag || !touch) return; + + const offset = Math.max(0, touch.clientY - drag.startY); + const canDismiss = drag.eligible && (drag.startedOnHandle || sheet.scrollTop <= 0); + if (offset === 0 || !canDismiss) return; + + event.preventDefault(); + dragOffsetRef.current = offset; + setHasSheetDragged(true); + setIsSheetDragging(true); + setDragOffset(offset); + }; + + sheet.addEventListener("touchmove", onTouchMove, { passive: false }); + return () => sheet.removeEventListener("touchmove", onTouchMove); + }, [isMoreOpen]); + + const handleSheetTouchStart = useCallback((event: React.TouchEvent) => { + const sheet = sheetRef.current; + const touch = event.touches[0]; + if (!sheet || !touch) return; + + const target = event.target instanceof Element ? event.target : null; + const startedOnHandle = Boolean(target?.closest(".mobile-more-sheet-handle")); + sheetDragRef.current = { + startY: touch.clientY, + startedAt: Date.now(), + eligible: startedOnHandle || sheet.scrollTop <= 0, + startedOnHandle, + }; + dragOffsetRef.current = 0; + setDragOffset(0); + setIsSheetDragging(false); + }, []); + + const finishSheetDrag = useCallback((event: React.TouchEvent) => { + const sheet = sheetRef.current; + const drag = sheetDragRef.current; + const touch = event.changedTouches[0]; + const offset = dragOffsetRef.current; + const elapsed = drag ? Math.max(1, Date.now() - drag.startedAt) : 1; + const sheetHeight = sheet?.getBoundingClientRect().height ?? 0; + const dismissDistance = Math.max(100, sheetHeight / 4); + const shouldDismiss = Boolean(drag?.eligible && offset > 0 && (offset >= dismissDistance || offset / elapsed >= 0.6)); + + resetSheetDrag(); + if (shouldDismiss && touch) { + closeMore(); + } + }, [closeMore, resetSheetDrag]); const handleMoreAction = useCallback( (callback?: () => void) => { @@ -218,13 +305,13 @@ export function MobileNavBar({ const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { - setIsMoreOpen(false); + closeMore(); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); - }, [isMoreOpen]); + }, [closeMore, isMoreOpen]); useLayoutEffect(() => { const navEl = navRef.current; @@ -492,8 +579,15 @@ export function MobileNavBar({ className="mobile-more-sheet-backdrop" onClick={closeMore} /> -
-
+
+