FN-8168: dismiss mobile More sheet on Back
Make the mobile More sheet participate in navigation history so Back dismisses it before changing views. - Register the open More sheet as a navigation modal and remove it on every close path - Cover browser, native, gesture, keyboard, action, and provider-less dismissal flows - Document mobile More-sheet Back behavior Files changed: docs/dashboard-guide.md | 2 + packages/dashboard/app/components/MobileNavBar.tsx | 48 ++++-- .../__tests__/MobileNavBar.swipe-back.test.tsx | 190 +++++++++++++++++++++ 3 files changed, 228 insertions(+), 12 deletions(-) Fusion-Task-Id: FN-8168 Fusion-Task-Lineage: 1c5652db-5f27-4671-b4b7-676753ca4cd0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -74,6 +74,8 @@ The installed mobile/PWA home-screen icons are generated from `packages/dashboar
|
||||
|
||||
The dashboard now handles browser back navigation consistently on desktop and mobile.
|
||||
Using Back will first dismiss open modals and then step back through in-app view changes before leaving the app.
|
||||
On mobile, an open navigation-bar **More** sheet is dismissed by one browser Back action, iOS edge-swipe, or Android Back action before the current dashboard view changes.
|
||||
<!-- FNXC:MobileNavBackDocs 2026-07-16-14:45: The mobile More sheet registers as a navigation modal, so every Back delivery mechanism dismisses it before navigating away. -->
|
||||
When task detail is open from a board card, task popup, mobile list row, right-dock/activity/onboarding link, deep link, or another task detail link, one browser, iOS edge-swipe, or Android Back action closes the current detail first and restores the prior dashboard context (for example, nested task detail → previous task detail, or task detail → board/list).
|
||||
<!-- FNXC:TaskDetailSwipeBackDocs 2026-07-15-10:36: Mobile task popups now register the same navigation entry as modal and full-panel task detail, so every Back delivery mechanism dismisses the popup before it can leave the originating Board or List. -->
|
||||
On mobile board-card detail, **Back to board** also restores the prior board/card scroll position so the same lane context remains visible.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "./MobileNavBar.css";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
@@ -34,6 +34,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { fetchScripts } from "../api";
|
||||
import type { PluginDashboardViewEntry } from "../api";
|
||||
import { useViewportMode } from "./Header";
|
||||
import { NavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import type { TaskView } from "../hooks/useViewState";
|
||||
import { buildPluginTaskViewId, isPluginViewId } from "../plugins/pluginViewRegistry";
|
||||
import { getPluginDashboardViewNavIcon } from "./pluginNavIcon";
|
||||
@@ -169,6 +170,7 @@ export function MobileNavBar({
|
||||
}: MobileNavBarProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const mode = useViewportMode();
|
||||
const navigationHistory = useContext(NavigationHistoryContext);
|
||||
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
||||
const [isScriptsSubmenuOpen, setIsScriptsSubmenuOpen] = useState(false);
|
||||
const [scripts, setScripts] = useState<Record<string, string>>({});
|
||||
@@ -227,6 +229,22 @@ export function MobileNavBar({
|
||||
setIsMoreOpen(false);
|
||||
}, [resetSheetDrag]);
|
||||
|
||||
/*
|
||||
FNXC:MobileNav 2026-07-16-14:30:
|
||||
The More sheet must dismiss before navigation on iOS swipe-back, Android native Back, and browser Back.
|
||||
Register its stable, idempotent closer as a modal entry, while reading nullable context so provider-less
|
||||
component renders retain their existing behavior.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isMoreOpen || !navigationHistory) return;
|
||||
navigationHistory.pushNav({ type: "modal", close: closeMore });
|
||||
}, [closeMore, isMoreOpen, navigationHistory]);
|
||||
|
||||
const dismissMore = useCallback(() => {
|
||||
navigationHistory?.removeNav(closeMore);
|
||||
closeMore();
|
||||
}, [closeMore, navigationHistory]);
|
||||
|
||||
/*
|
||||
FNXC:MobileNav 2026-07-16-12:00:
|
||||
The mobile More drawer must dismiss when a user drags down from its top or grab handle.
|
||||
@@ -288,16 +306,16 @@ export function MobileNavBar({
|
||||
|
||||
resetSheetDrag();
|
||||
if (shouldDismiss && touch) {
|
||||
closeMore();
|
||||
dismissMore();
|
||||
}
|
||||
}, [closeMore, resetSheetDrag]);
|
||||
}, [dismissMore, resetSheetDrag]);
|
||||
|
||||
const handleMoreAction = useCallback(
|
||||
(callback?: () => void) => {
|
||||
closeMore();
|
||||
dismissMore();
|
||||
callback?.();
|
||||
},
|
||||
[closeMore],
|
||||
[dismissMore],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -305,13 +323,13 @@ export function MobileNavBar({
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
closeMore();
|
||||
dismissMore();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [closeMore, isMoreOpen]);
|
||||
}, [dismissMore, isMoreOpen]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const navEl = navRef.current;
|
||||
@@ -561,7 +579,13 @@ export function MobileNavBar({
|
||||
data-testid="mobile-nav-tab-more"
|
||||
role="tab"
|
||||
aria-selected={false}
|
||||
onClick={() => setIsMoreOpen((prev) => !prev)}
|
||||
onClick={() => {
|
||||
if (isMoreOpen) {
|
||||
dismissMore();
|
||||
} else {
|
||||
setIsMoreOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<MoreHorizontal />
|
||||
@@ -577,7 +601,7 @@ export function MobileNavBar({
|
||||
<>
|
||||
<div
|
||||
className="mobile-more-sheet-backdrop"
|
||||
onClick={closeMore}
|
||||
onClick={dismissMore}
|
||||
/>
|
||||
<div
|
||||
ref={sheetRef}
|
||||
@@ -659,7 +683,7 @@ export function MobileNavBar({
|
||||
data-testid={`mobile-more-script-item-${name}`}
|
||||
onClick={() => {
|
||||
if (onRunScript) onRunScript(name, command);
|
||||
closeMore();
|
||||
dismissMore();
|
||||
setIsScriptsSubmenuOpen(false);
|
||||
}}
|
||||
>
|
||||
@@ -673,7 +697,7 @@ export function MobileNavBar({
|
||||
className="mobile-more-item mobile-more-subitem mobile-more-subitem--manage"
|
||||
data-testid="mobile-more-scripts-manage"
|
||||
onClick={() => {
|
||||
closeMore();
|
||||
dismissMore();
|
||||
setIsScriptsSubmenuOpen(false);
|
||||
onOpenScripts();
|
||||
}}
|
||||
@@ -690,7 +714,7 @@ export function MobileNavBar({
|
||||
className="mobile-more-item mobile-more-subitem"
|
||||
data-testid="mobile-more-scripts-manage"
|
||||
onClick={() => {
|
||||
closeMore();
|
||||
dismissMore();
|
||||
setIsScriptsSubmenuOpen(false);
|
||||
onOpenScripts();
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchScripts } from "../../api";
|
||||
import { NavigationHistoryProvider, useNavigationHistory, type UseNavigationHistoryResult } from "../../hooks/useNavigationHistory";
|
||||
import { MobileNavBar } from "../MobileNavBar";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchScripts: vi.fn(),
|
||||
}));
|
||||
|
||||
function mockMobileViewport() {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(() => ({
|
||||
matches: true,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function HistoryHarness({ children, onReady }: { children: ReactNode; onReady: (history: UseNavigationHistoryResult) => void }) {
|
||||
const history = useNavigationHistory({ enabled: true });
|
||||
useEffect(() => onReady(history), [history, onReady]);
|
||||
return <NavigationHistoryProvider value={history}>{children}</NavigationHistoryProvider>;
|
||||
}
|
||||
|
||||
const createDefaultProps = () => ({
|
||||
view: "board" as const,
|
||||
onChangeView: vi.fn(),
|
||||
footerVisible: false,
|
||||
onOpenActivityLog: vi.fn(),
|
||||
onOpenGitManager: vi.fn(),
|
||||
onOpenWorkflowEditor: vi.fn(),
|
||||
onOpenSchedules: vi.fn(),
|
||||
onOpenScripts: vi.fn(),
|
||||
onToggleTerminal: vi.fn(),
|
||||
onOpenFiles: vi.fn(),
|
||||
onOpenGitHubImport: vi.fn(),
|
||||
onOpenPlanning: vi.fn(),
|
||||
onResumePlanning: vi.fn(),
|
||||
onOpenUsage: vi.fn(),
|
||||
onViewAllProjects: vi.fn(),
|
||||
onRunScript: vi.fn(),
|
||||
projectId: "proj_1",
|
||||
});
|
||||
|
||||
function dispatchPopState(navIndex: number) {
|
||||
act(() => {
|
||||
window.dispatchEvent(new PopStateEvent("popstate", { state: { navIndex } }));
|
||||
});
|
||||
}
|
||||
|
||||
async function openMore() {
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||
await waitFor(() => expect(screen.getByTestId("mobile-more-item-activity")).toBeInTheDocument());
|
||||
}
|
||||
|
||||
describe("MobileNavBar More sheet navigation history", () => {
|
||||
let navigationHistory: UseNavigationHistoryResult | null;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMobileViewport();
|
||||
navigationHistory = null;
|
||||
window.history.replaceState({ navIndex: 0 }, "");
|
||||
vi.mocked(fetchScripts).mockReset();
|
||||
vi.mocked(fetchScripts).mockResolvedValue({});
|
||||
vi.spyOn(window.history, "back").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderWithHistory(props = createDefaultProps()) {
|
||||
const rendered = render(
|
||||
<HistoryHarness onReady={(history) => { navigationHistory = history; }}>
|
||||
<MobileNavBar {...props} />
|
||||
</HistoryHarness>,
|
||||
);
|
||||
return { ...rendered, props };
|
||||
}
|
||||
|
||||
it("dismisses the More sheet on browser Back without navigating away", async () => {
|
||||
const { container } = renderWithHistory();
|
||||
await openMore();
|
||||
|
||||
dispatchPopState(0);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".mobile-more-sheet")).toBeNull());
|
||||
dispatchPopState(0);
|
||||
expect(container.querySelector(".mobile-more-sheet")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes Android native Back through history before dismissing the More sheet", async () => {
|
||||
const { container } = renderWithHistory();
|
||||
await openMore();
|
||||
const nativeBack = new CustomEvent("fusion:native-back", { cancelable: true });
|
||||
|
||||
expect(window.dispatchEvent(nativeBack)).toBe(false);
|
||||
expect(window.history.back).toHaveBeenCalledOnce();
|
||||
expect(container.querySelector(".mobile-more-sheet")).not.toBeNull();
|
||||
|
||||
dispatchPopState(0);
|
||||
await waitFor(() => expect(container.querySelector(".mobile-more-sheet")).toBeNull());
|
||||
});
|
||||
|
||||
async function expectProgrammaticCloseConsumesMoreEntry(close: () => void | Promise<void>) {
|
||||
vi.mocked(window.history.back).mockClear();
|
||||
const sentinelClose = vi.fn();
|
||||
const rendered = renderWithHistory();
|
||||
await waitFor(() => expect(navigationHistory).not.toBeNull());
|
||||
navigationHistory?.pushNav({ type: "modal", close: sentinelClose });
|
||||
await openMore();
|
||||
|
||||
await close();
|
||||
expect(screen.queryByTestId("mobile-more-item-activity")).toBeNull();
|
||||
expect(window.history.back).toHaveBeenCalledOnce();
|
||||
|
||||
// removeNav's history.back() self-pop is ignored before the lower entry can handle Back.
|
||||
dispatchPopState(1);
|
||||
expect(sentinelClose).not.toHaveBeenCalled();
|
||||
dispatchPopState(0);
|
||||
expect(sentinelClose).toHaveBeenCalledOnce();
|
||||
rendered.unmount();
|
||||
}
|
||||
|
||||
it("consumes the More entry on backdrop close", async () => {
|
||||
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(document.querySelector(".mobile-more-sheet-backdrop")!));
|
||||
});
|
||||
|
||||
it("consumes the More entry on item action", async () => {
|
||||
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(screen.getByTestId("mobile-more-item-activity")));
|
||||
});
|
||||
|
||||
it("consumes the More entry on Escape", async () => {
|
||||
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.keyDown(document, { key: "Escape" }));
|
||||
});
|
||||
|
||||
it("consumes the More entry on drag dismissal", async () => {
|
||||
await expectProgrammaticCloseConsumesMoreEntry(() => {
|
||||
const sheet = document.querySelector<HTMLDivElement>(".mobile-more-sheet")!;
|
||||
Object.defineProperty(sheet, "getBoundingClientRect", { configurable: true, value: () => ({ height: 400 }) });
|
||||
fireEvent.touchStart(sheet, { touches: [{ clientY: 100 }] });
|
||||
fireEvent.touchMove(sheet, { touches: [{ clientY: 300 }] });
|
||||
fireEvent.touchEnd(sheet, { changedTouches: [{ clientY: 300 }] });
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes the More entry when its tab toggles closed", async () => {
|
||||
await expectProgrammaticCloseConsumesMoreEntry(() => fireEvent.click(screen.getByTestId("mobile-nav-tab-more")));
|
||||
});
|
||||
|
||||
it("consumes the More entry when a script runs", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({ build: "pnpm build" });
|
||||
await expectProgrammaticCloseConsumesMoreEntry(async () => {
|
||||
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
|
||||
await waitFor(() => expect(screen.getByTestId("mobile-more-script-item-build")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId("mobile-more-script-item-build"));
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes the More entry from Manage Scripts", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({ build: "pnpm build" });
|
||||
await expectProgrammaticCloseConsumesMoreEntry(async () => {
|
||||
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
|
||||
await waitFor(() => expect(screen.getByTestId("mobile-more-scripts-manage")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId("mobile-more-scripts-manage"));
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes the More entry from the no-scripts add-one branch", async () => {
|
||||
vi.mocked(fetchScripts).mockResolvedValueOnce({});
|
||||
await expectProgrammaticCloseConsumesMoreEntry(async () => {
|
||||
fireEvent.click(screen.getByTestId("mobile-more-terminal-split-toggle"));
|
||||
await waitFor(() => expect(screen.getByText("No scripts — add one…")).toBeInTheDocument());
|
||||
fireEvent.click(screen.getByTestId("mobile-more-scripts-manage"));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provider-less More-sheet renders functional", async () => {
|
||||
const { container } = render(<MobileNavBar {...createDefaultProps()} />);
|
||||
await openMore();
|
||||
fireEvent.click(document.querySelector(".mobile-more-sheet-backdrop")!);
|
||||
await waitFor(() => expect(container.querySelector(".mobile-more-sheet")).toBeNull());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user