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;
|
max-height: 85vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
touch-action: pan-y;
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
animation: mobile-more-sheet-in 0.25s ease;
|
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 {
|
@keyframes mobile-more-backdrop-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
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 {
|
.mobile-more-sheet-handle {
|
||||||
width: 36px;
|
display: flex;
|
||||||
height: 4px;
|
align-items: center;
|
||||||
border-radius: 2px;
|
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);
|
background: var(--border);
|
||||||
margin: 0 auto 12px;
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-more-sheet-title {
|
.mobile-more-sheet-title {
|
||||||
|
|||||||
@@ -173,7 +173,18 @@ export function MobileNavBar({
|
|||||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||||
const [scriptsLoading, setScriptsLoading] = useState(false);
|
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 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(
|
const scriptEntries = useMemo(
|
||||||
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
() => Object.entries(scripts).sort(([a], [b]) => a.localeCompare(b)),
|
||||||
@@ -203,7 +214,83 @@ export function MobileNavBar({
|
|||||||
};
|
};
|
||||||
}, [isScriptsSubmenuOpen, projectId]);
|
}, [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(
|
const handleMoreAction = useCallback(
|
||||||
(callback?: () => void) => {
|
(callback?: () => void) => {
|
||||||
@@ -218,13 +305,13 @@ export function MobileNavBar({
|
|||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
setIsMoreOpen(false);
|
closeMore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}, [isMoreOpen]);
|
}, [closeMore, isMoreOpen]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const navEl = navRef.current;
|
const navEl = navRef.current;
|
||||||
@@ -492,8 +579,15 @@ export function MobileNavBar({
|
|||||||
className="mobile-more-sheet-backdrop"
|
className="mobile-more-sheet-backdrop"
|
||||||
onClick={closeMore}
|
onClick={closeMore}
|
||||||
/>
|
/>
|
||||||
<div className="mobile-more-sheet">
|
<div
|
||||||
<div className="mobile-more-sheet-handle" />
|
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>
|
<div className="mobile-more-sheet-title">{t("nav.moreSheetTitle", "Navigate")}</div>
|
||||||
|
|
||||||
{shellConnectionControl ? (
|
{shellConnectionControl ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { resolve } from "path";
|
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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { MobileNavBar } from "../MobileNavBar";
|
import { MobileNavBar } from "../MobileNavBar";
|
||||||
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
import { MOBILE_MEDIA_QUERY } from "../../hooks/useViewportMode";
|
||||||
@@ -131,6 +131,10 @@ describe("MobileNavBar", () => {
|
|||||||
mockViewport("mobile");
|
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", () => {
|
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} />);
|
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", () => {
|
it("returns null when modalOpen is true", () => {
|
||||||
const { container } = render(<MobileNavBar {...createDefaultProps()} modalOpen={true} />);
|
const { container } = render(<MobileNavBar {...createDefaultProps()} modalOpen={true} />);
|
||||||
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
|
expect(container.querySelector(".mobile-nav-bar")).toBeNull();
|
||||||
|
|||||||
Reference in New Issue
Block a user