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:
7
.changeset/fn-8107-mobile-more-drawer-drag-dismiss.md
Normal file
7
.changeset/fn-8107-mobile-more-drawer-drag-dismiss.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user