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:
gsxdsm
2026-07-16 18:33:47 -07:00
parent c9aa3b465c
commit f4903d3db0
3 changed files with 228 additions and 12 deletions

View File

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

View File

@@ -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();
}}

View File

@@ -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());
});
});