FN-8107: add mobile drawer drag dismissal

Enable intuitive swipe-down dismissal for the mobile More navigation drawer.

- Add guarded touch drag handling that preserves interior scrolling
- Animate sheet drag and snap-back states with an expanded grab handle
- Cover distance, velocity, scroll, submenu, and cancellation gestures
- Add a patch changeset for the mobile drawer fix

Files changed:
 .../fn-8107-mobile-more-drawer-drag-dismiss.md     |   7 ++
 packages/dashboard/app/components/MobileNavBar.css |  28 ++++-
 packages/dashboard/app/components/MobileNavBar.tsx | 104 +++++++++++++++-
 .../app/components/__tests__/MobileNavBar.test.tsx | 136 ++++++++++++++++++++-
 4 files changed, 265 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8107

Fusion-Task-Lineage: 144bd68a-0c72-4786-9efd-ddf86bb9515d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 11:07:39 -07:00
parent b891e3a37b
commit 504c702262
4 changed files with 265 additions and 10 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -173,7 +173,18 @@ export function MobileNavBar({
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
const [scripts, setScripts] = useState<Record<string, string>>({});
const [scriptsLoading, setScriptsLoading] = useState(false);
const [dragOffset, setDragOffset] = useState(0);
const [isSheetDragging, setIsSheetDragging] = useState(false);
const [hasSheetDragged, setHasSheetDragged] = useState(false);
const navRef = useRef<HTMLElement | null>(null);
const sheetRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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}
/>
<div className="mobile-more-sheet">
<div className="mobile-more-sheet-handle" />
<div
ref={sheetRef}
className={`mobile-more-sheet${isSheetDragging ? " mobile-more-sheet--dragging" : ""}${hasSheetDragged ? " mobile-more-sheet--gesture-ready" : ""}`}
style={{ transform: `translateY(${dragOffset}px)` }}
onTouchStart={handleSheetTouchStart}
onTouchEnd={finishSheetDrag}
onTouchCancel={resetSheetDrag}
>
<div className="mobile-more-sheet-handle" aria-hidden="true" />
<div className="mobile-more-sheet-title">{t("nav.moreSheetTitle", "Navigate")}</div>
{shellConnectionControl ? (

View File

@@ -1,6 +1,6 @@
import { readFileSync } from "fs";
import { resolve } from "path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MobileNavBar } from "../MobileNavBar";
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
@@ -131,6 +131,10 @@ describe("MobileNavBar", () => {
mockViewport("mobile");
});
afterEach(() => {
vi.useRealTimers();
});
it("renders seven top-level tab buttons (command center + tasks + agents + missions + chat + mailbox + more) and keeps skills in More when showSkillsTab is true", () => {
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} />);
@@ -899,6 +903,136 @@ describe("MobileNavBar", () => {
});
});
describe("More-sheet drag dismissal", () => {
const openSheet = (container: HTMLElement) => {
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
const sheet = container.querySelector<HTMLDivElement>(".mobile-more-sheet");
if (!sheet) throw new Error("Expected the More sheet to open");
Object.defineProperty(sheet, "getBoundingClientRect", {
configurable: true,
value: () => ({ height: 400 }),
});
return sheet;
};
const startDrag = (target: Element, startY = 100) => {
fireEvent.touchStart(target, { touches: [{ clientY: startY }] });
};
const moveDrag = (sheet: Element, currentY: number) =>
fireEvent.touchMove(sheet, { touches: [{ clientY: currentY }] });
const endDrag = (sheet: Element, endY: number) => {
fireEvent.touchEnd(sheet, { changedTouches: [{ clientY: endY }] });
};
it("dismisses a top-anchored downward drag past the distance threshold", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
startDrag(sheet);
expect(moveDrag(sheet, 300)).toBe(false);
endDrag(sheet, 300);
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("preserves interior scrolling and does not prevent its downward touchmove", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
sheet.scrollTop = 20;
startDrag(sheet);
expect(moveDrag(sheet, 300)).toBe(true);
endDrag(sheet, 300);
expect(container.querySelector(".mobile-more-sheet")).not.toBeNull();
});
it("snaps back after a slow below-threshold drag", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-16T12:00:00Z"));
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
startDrag(sheet);
moveDrag(sheet, 150);
vi.advanceTimersByTime(500);
endDrag(sheet, 150);
expect(container.querySelector(".mobile-more-sheet")).not.toBeNull();
expect(sheet.style.transform).toBe("translateY(0px)");
});
it("dismisses when a handle-anchored drag starts while the sheet is scrolled", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
sheet.scrollTop = 20;
const handle = sheet.querySelector(".mobile-more-sheet-handle");
expect(handle).not.toBeNull();
startDrag(handle!);
expect(moveDrag(sheet, 300)).toBe(false);
endDrag(sheet, 300);
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("dismisses with the scripts submenu open without swallowing its normal toggle tap", async () => {
vi.mocked(fetchScripts).mockResolvedValue({});
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
await waitFor(() => expect(screen.getByTestId("mobile-more-scripts-manage")).toBeInTheDocument());
startDrag(sheet);
moveDrag(sheet, 300);
endDrag(sheet, 300);
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("never dismisses for an upward drag", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
startDrag(sheet, 300);
expect(moveDrag(sheet, 100)).toBe(true);
endDrag(sheet, 100);
expect(container.querySelector(".mobile-more-sheet")).not.toBeNull();
});
it("dismisses on a fast downward flick below the distance threshold", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-16T12:00:00Z"));
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
startDrag(sheet);
moveDrag(sheet, 160);
vi.advanceTimersByTime(50);
endDrag(sheet, 160);
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
});
it("resets a cancelled drag to the open position", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
const sheet = openSheet(container);
startDrag(sheet);
moveDrag(sheet, 300);
expect(sheet.className).toContain("mobile-more-sheet--dragging");
expect(sheet.style.transform).toBe("translateY(200px)");
fireEvent.touchCancel(sheet);
expect(container.querySelector(".mobile-more-sheet")).not.toBeNull();
expect(sheet.className).not.toContain("mobile-more-sheet--dragging");
expect(sheet.style.transform).toBe("translateY(0px)");
});
});
it("returns null when modalOpen is true", () => {
const { container } = render(<MobileNavBar {...createDefaultProps()} modalOpen={true} />);
expect(container.querySelector(".mobile-nav-bar")).toBeNull();