diff --git a/apps/mobile/src/App.test.tsx b/apps/mobile/src/App.test.tsx index d1dc6db97..cf8cb02cb 100644 --- a/apps/mobile/src/App.test.tsx +++ b/apps/mobile/src/App.test.tsx @@ -1,6 +1,6 @@ import { MOBILE_PROTOCOL_MAX } from "@openmapx/core/navigation"; import { act, fireEvent, render } from "@testing-library/react-native"; -import { Linking } from "react-native"; +import { BackHandler, Linking } from "react-native"; import { App } from "./App"; import { resetRuntimeConfigCache } from "./config/runtimeConfig"; import { groundSessionFixture } from "./storage/testing/sessionFixture"; @@ -22,6 +22,7 @@ jest.mock("react-native-webview", () => { mockWebViewProps = props; React.useImperativeHandle(ref, () => ({ injectJavaScript: (script: string) => mockInjectedScripts.push(script), + goBack: () => mockInjectedScripts.push(""), })); return React.createElement(View, { testID: props.testID as string }); }), @@ -84,6 +85,23 @@ describe("App WebView policy", () => { expect(mockWebViewProps.source).toEqual({ uri: "https://openmapx.com" }); }); + it("sends Android's Back button into the page's history while it has any", async () => { + let press: (() => boolean | null | undefined) | undefined; + jest.spyOn(BackHandler, "addEventListener").mockImplementation((_event, handler) => { + press = () => handler({} as never); + return { remove: jest.fn() }; + }); + await renderApp(); + expect(press?.()).toBe(false); + await act(async () => { + (mockWebViewProps.onNavigationStateChange as (state: { canGoBack: boolean }) => void)({ + canGoBack: true, + }); + }); + expect(press?.()).toBe(true); + expect(mockInjectedScripts).toContain(""); + }); + it("allowlists only the exact configured origin", async () => { await renderApp(); expect(mockWebViewProps.originWhitelist).toEqual(["https://openmapx.com"]); diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 89c1513aa..1cf43d878 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -23,6 +23,7 @@ import { LoadingOverlay } from "./shell/ShellOverlays"; import type { ShellAction } from "./shell/ShellState"; import { deviceMobileLocale } from "./shell/shellCopy"; import { INITIAL_SHELL, shellReducer } from "./shell/shellReducer"; +import { useHardwareBack } from "./shell/useHardwareBack"; /** * The entire visible product is the web UI inside one WebView pointed at the @@ -48,6 +49,7 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement const connectivity = useConnectivity(); const webViewRef = useRef(null); + const handleNavigationStateChange = useHardwareBack(webViewRef); const shell = useMemo( () => createShellBridge({ @@ -232,6 +234,8 @@ function ProductShell({ config }: { config: MobileRuntimeConfig }): ReactElement thirdPartyCookiesEnabled={false} allowsBackForwardNavigationGestures onShouldStartLoadWithRequest={handleNavigationRequest} + // Android's Back button goes back in the page before it leaves the app. + onNavigationStateChange={handleNavigationStateChange} onOpenWindow={(event) => { // Popups get the same structural policy as ordinary navigation; they // never open a second in-app browser. diff --git a/apps/mobile/src/shell/useHardwareBack.test.tsx b/apps/mobile/src/shell/useHardwareBack.test.tsx new file mode 100644 index 000000000..7d7d4107b --- /dev/null +++ b/apps/mobile/src/shell/useHardwareBack.test.tsx @@ -0,0 +1,48 @@ +import { renderHook } from "@testing-library/react-native"; +import { BackHandler } from "react-native"; +import type { WebView } from "react-native-webview"; +import { useHardwareBack } from "./useHardwareBack"; + +function setup() { + let press: (() => boolean | null | undefined) | undefined; + const remove = jest.fn(); + jest.spyOn(BackHandler, "addEventListener").mockImplementation((_event, handler) => { + press = () => handler({} as never); + return { remove }; + }); + const goBack = jest.fn(); + const webViewRef = { current: { goBack } as unknown as WebView }; + return { press: () => press?.(), remove, goBack, webViewRef }; +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe("useHardwareBack", () => { + it("leaves Back to the system while the page has no history", async () => { + const { press, goBack, webViewRef } = setup(); + await renderHook(() => useHardwareBack(webViewRef)); + expect(press()).toBe(false); + expect(goBack).not.toHaveBeenCalled(); + }); + + it("steps back through the page's history once it has some", async () => { + const { press, goBack, webViewRef } = setup(); + const { result } = await renderHook(() => useHardwareBack(webViewRef)); + result.current({ canGoBack: true }); + expect(press()).toBe(true); + expect(goBack).toHaveBeenCalledTimes(1); + + result.current({ canGoBack: false }); + expect(press()).toBe(false); + expect(goBack).toHaveBeenCalledTimes(1); + }); + + it("stops listening when the shell unmounts", async () => { + const { remove, webViewRef } = setup(); + const { unmount } = await renderHook(() => useHardwareBack(webViewRef)); + await unmount(); + expect(remove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/shell/useHardwareBack.ts b/apps/mobile/src/shell/useHardwareBack.ts new file mode 100644 index 000000000..d9f790618 --- /dev/null +++ b/apps/mobile/src/shell/useHardwareBack.ts @@ -0,0 +1,35 @@ +import { type RefObject, useCallback, useEffect, useRef } from "react"; +import { BackHandler } from "react-native"; +import type { WebView, WebViewNavigation } from "react-native-webview"; + +/** + * Android's Back button walks the page's own history before it leaves the app. + * + * The web app gives every sheet, dialog and opened place a history entry, so + * going back in the WebView closes exactly what the user last opened. Only once + * the page has nothing left to go back to does Back fall through to the system, + * which closes the app as usual. iOS needs none of this: its edge swipe already + * drives the same history through `allowsBackForwardNavigationGestures`. + * + * Returns the WebView's `onNavigationStateChange` handler, which keeps track of + * whether the page can go back — pushState entries included. + */ +export function useHardwareBack( + webViewRef: RefObject, +): (navigation: Pick) => void { + const canGoBackRef = useRef(false); + + useEffect(() => { + const subscription = BackHandler.addEventListener("hardwareBackPress", () => { + const webView = webViewRef.current; + if (!canGoBackRef.current || !webView) return false; + webView.goBack(); + return true; + }); + return () => subscription.remove(); + }, [webViewRef]); + + return useCallback((navigation) => { + canGoBackRef.current = navigation.canGoBack; + }, []); +} diff --git a/apps/web/src/app/(legal)/licenses/LicenseTextDialog.tsx b/apps/web/src/app/(legal)/licenses/LicenseTextDialog.tsx index a24f1b859..54ceac154 100644 --- a/apps/web/src/app/(legal)/licenses/LicenseTextDialog.tsx +++ b/apps/web/src/app/(legal)/licenses/LicenseTextDialog.tsx @@ -3,12 +3,12 @@ import CloseIcon from "@mui/icons-material/Close"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import IconButton from "@mui/material/IconButton"; import Typography from "@mui/material/Typography"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; interface Props { packageName: string; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b4eaadbfa..f4328a2d1 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -53,6 +53,7 @@ import { WaypointMarkers } from "@/components/map/WaypointMarkers"; import { HamburgerMenu } from "@/components/menu/HamburgerMenu"; import { HideDuringNavigation } from "@/components/navigation/HideDuringNavigation"; import { NavIncidentsProvider } from "@/components/navigation/NavIncidentsProvider"; +import { NavigationBackHandler } from "@/components/navigation/NavigationBackHandler"; import { NavigationView } from "@/components/navigation/NavigationView"; import { TransitNavigationView } from "@/components/navigation/TransitNavigationView"; import { MapClickFloatingCard } from "@/components/panels/MapClickFloatingCard"; @@ -109,6 +110,7 @@ export default function HomePage() { + diff --git a/apps/web/src/app/settings/offline/OfflineSettingsClient.tsx b/apps/web/src/app/settings/offline/OfflineSettingsClient.tsx index e8f9110c9..8554e5221 100644 --- a/apps/web/src/app/settings/offline/OfflineSettingsClient.tsx +++ b/apps/web/src/app/settings/offline/OfflineSettingsClient.tsx @@ -7,7 +7,6 @@ import Alert from "@mui/material/Alert"; import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -41,6 +40,7 @@ import { } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; import type { ClientEnv } from "@/integration-api/runtime/env"; import { haptics } from "@/lib/haptics"; diff --git a/apps/web/src/components/admin/activity/AuditLog.tsx b/apps/web/src/components/admin/activity/AuditLog.tsx index 56ac20781..66a8e1dea 100644 --- a/apps/web/src/components/admin/activity/AuditLog.tsx +++ b/apps/web/src/components/admin/activity/AuditLog.tsx @@ -9,7 +9,6 @@ import FormControl from "@mui/material/FormControl"; import IconButton from "@mui/material/IconButton"; import InputLabel from "@mui/material/InputLabel"; import ListSubheader from "@mui/material/ListSubheader"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Select from "@mui/material/Select"; import Stack from "@mui/material/Stack"; @@ -24,6 +23,7 @@ import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import Menu from "@/components/ui/Menu"; import { useEnv } from "@/integration-api/runtime/EnvProvider"; import { relativeTimeFromIso } from "@/lib/formatTime"; import { AdminTablePagination } from "../shared/AdminTablePagination"; diff --git a/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx b/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx index 8f28a0699..68c0cef4b 100644 --- a/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx +++ b/apps/web/src/components/admin/extensions/ExtensionStorePage.tsx @@ -16,7 +16,6 @@ import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -47,6 +46,7 @@ import type { } from "@openmapx/core/api-contracts"; import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { useActivityClient } from "@/components/admin/activity/activityQueries"; +import Dialog from "@/components/ui/Dialog"; import { useAddExtensionSource, useExtensionCatalog, diff --git a/apps/web/src/components/admin/integrations/SetCredentialDialog.tsx b/apps/web/src/components/admin/integrations/SetCredentialDialog.tsx index 38c04264b..6ea2cfaa0 100644 --- a/apps/web/src/components/admin/integrations/SetCredentialDialog.tsx +++ b/apps/web/src/components/admin/integrations/SetCredentialDialog.tsx @@ -4,7 +4,6 @@ import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -16,6 +15,7 @@ import Typography from "@mui/material/Typography"; import type { CredentialSetup } from "@openmapx/integration-framework"; import { useMutation } from "@tanstack/react-query"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useIntegrationAdmin } from "@/lib/admin/integrationAdmin"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; import { CredentialSetupGuide } from "./CredentialSetupGuide"; diff --git a/apps/web/src/components/admin/operations/AdminOperationCard.tsx b/apps/web/src/components/admin/operations/AdminOperationCard.tsx index 1d000307c..fbe66e294 100644 --- a/apps/web/src/components/admin/operations/AdminOperationCard.tsx +++ b/apps/web/src/components/admin/operations/AdminOperationCard.tsx @@ -4,7 +4,6 @@ import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -20,6 +19,7 @@ import Typography from "@mui/material/Typography"; import { createApiClient } from "@openmapx/core"; import { type QueryKey, useMutation } from "@tanstack/react-query"; import { useMemo, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; import { diff --git a/apps/web/src/components/admin/services/BackupsPage.tsx b/apps/web/src/components/admin/services/BackupsPage.tsx index 651524379..a07270808 100644 --- a/apps/web/src/components/admin/services/BackupsPage.tsx +++ b/apps/web/src/components/admin/services/BackupsPage.tsx @@ -8,7 +8,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -34,6 +33,7 @@ import type { AdminServiceBackup } from "@openmapx/core/api-contracts"; import { useMutation, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { serviceAdminQueries, serviceOrThrow } from "@/lib/admin/serviceAdmin"; import { useServiceJobRegistration } from "@/lib/admin/serviceAdminJobs"; import { useAdminMutationOwner } from "@/lib/admin/useAdminMutationOwner"; diff --git a/apps/web/src/components/admin/services/ServiceCredentials.tsx b/apps/web/src/components/admin/services/ServiceCredentials.tsx index 469d9b85b..0d0eae383 100644 --- a/apps/web/src/components/admin/services/ServiceCredentials.tsx +++ b/apps/web/src/components/admin/services/ServiceCredentials.tsx @@ -7,7 +7,6 @@ import Card from "@mui/material/Card"; import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -19,6 +18,7 @@ import type { AdminServiceCredentialChanged, } from "@openmapx/core/api-contracts"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useDeleteServiceCredential, useServiceCredentials, diff --git a/apps/web/src/components/admin/services/TransitSourcesSection.tsx b/apps/web/src/components/admin/services/TransitSourcesSection.tsx index 05f8d3444..237cc3b7c 100644 --- a/apps/web/src/components/admin/services/TransitSourcesSection.tsx +++ b/apps/web/src/components/admin/services/TransitSourcesSection.tsx @@ -5,7 +5,6 @@ import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -27,6 +26,7 @@ import type { TransitSource } from "@openmapx/core/api-contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useMemo, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { formatBytes } from "@/lib/storageFormat"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { TableEmptyState } from "../shared/TableEmptyState"; diff --git a/apps/web/src/components/admin/shared/ConfirmDialog.tsx b/apps/web/src/components/admin/shared/ConfirmDialog.tsx index a054bf7c2..ff1efae01 100644 --- a/apps/web/src/components/admin/shared/ConfirmDialog.tsx +++ b/apps/web/src/components/admin/shared/ConfirmDialog.tsx @@ -2,11 +2,11 @@ import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; +import Dialog from "@/components/ui/Dialog"; export interface ConfirmDialogProps { open: boolean; diff --git a/apps/web/src/components/admin/system/SystemMaintenance.tsx b/apps/web/src/components/admin/system/SystemMaintenance.tsx index 2817af27b..363148e18 100644 --- a/apps/web/src/components/admin/system/SystemMaintenance.tsx +++ b/apps/web/src/components/admin/system/SystemMaintenance.tsx @@ -11,7 +11,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import Chip from "@mui/material/Chip"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -30,6 +29,7 @@ import Typography from "@mui/material/Typography"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useEffect, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { activityJobQueryOptions, useActivityClient } from "../activity/activityQueries"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AdminTableSurface } from "../shared/AdminTableSurface"; diff --git a/apps/web/src/components/admin/users/BanUserDialog.tsx b/apps/web/src/components/admin/users/BanUserDialog.tsx index cbfec9a8b..7f0894eda 100644 --- a/apps/web/src/components/admin/users/BanUserDialog.tsx +++ b/apps/web/src/components/admin/users/BanUserDialog.tsx @@ -1,7 +1,6 @@ "use client"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -10,6 +9,7 @@ import TextField from "@mui/material/TextField"; import { authClient } from "@openmapx/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useAdminToast } from "../shared/AdminToast"; interface BanUserDialogProps { diff --git a/apps/web/src/components/admin/users/CreateUserDialog.tsx b/apps/web/src/components/admin/users/CreateUserDialog.tsx index c93bc3198..8b6ea59cd 100644 --- a/apps/web/src/components/admin/users/CreateUserDialog.tsx +++ b/apps/web/src/components/admin/users/CreateUserDialog.tsx @@ -2,7 +2,6 @@ import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -16,6 +15,7 @@ import TextField from "@mui/material/TextField"; import { authClient } from "@openmapx/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useAdminToast } from "../shared/AdminToast"; interface CreateUserDialogProps { diff --git a/apps/web/src/components/admin/users/UserList.tsx b/apps/web/src/components/admin/users/UserList.tsx index ecb6f3ea1..6ae62dbae 100644 --- a/apps/web/src/components/admin/users/UserList.tsx +++ b/apps/web/src/components/admin/users/UserList.tsx @@ -17,7 +17,6 @@ import FormControl from "@mui/material/FormControl"; import IconButton from "@mui/material/IconButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Select from "@mui/material/Select"; import Stack from "@mui/material/Stack"; @@ -37,6 +36,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import Menu from "@/components/ui/Menu"; import { AdminPageHeader } from "../shared/AdminPageHeader"; import { AdminTablePagination } from "../shared/AdminTablePagination"; import { AdminTableSurface } from "../shared/AdminTableSurface"; diff --git a/apps/web/src/components/auth/AccountMenu.tsx b/apps/web/src/components/auth/AccountMenu.tsx index 11a933f43..8fbf33865 100644 --- a/apps/web/src/components/auth/AccountMenu.tsx +++ b/apps/web/src/components/auth/AccountMenu.tsx @@ -8,7 +8,6 @@ import Box from "@mui/material/Box"; import Divider from "@mui/material/Divider"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import type { User } from "@openmapx/core"; @@ -22,6 +21,7 @@ import { import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import Menu from "@/components/ui/Menu"; interface AccountMenuProps { anchorEl: HTMLElement | null; diff --git a/apps/web/src/components/auth/AccountSettingsDialog.tsx b/apps/web/src/components/auth/AccountSettingsDialog.tsx index aaed287ff..d0ab12670 100644 --- a/apps/web/src/components/auth/AccountSettingsDialog.tsx +++ b/apps/web/src/components/auth/AccountSettingsDialog.tsx @@ -17,7 +17,6 @@ import Alert from "@mui/material/Alert"; import Avatar from "@mui/material/Avatar"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import Divider from "@mui/material/Divider"; @@ -35,6 +34,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import QRCode from "qrcode"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useDateTimeFormat } from "@/integration-api/runtime/useDateTimeFormat"; import { mobileFullScreenDialogPaperSx, diff --git a/apps/web/src/components/auth/AuthDialog.tsx b/apps/web/src/components/auth/AuthDialog.tsx index efea4a0d4..b954c1998 100644 --- a/apps/web/src/components/auth/AuthDialog.tsx +++ b/apps/web/src/components/auth/AuthDialog.tsx @@ -9,7 +9,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import Divider from "@mui/material/Divider"; import FormControlLabel from "@mui/material/FormControlLabel"; @@ -21,6 +20,7 @@ import Typography from "@mui/material/Typography"; import { authClient, oauthProviders } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/auth/MangroveConnectDialog.tsx b/apps/web/src/components/auth/MangroveConnectDialog.tsx index 3cf5556d5..28a8de6fe 100644 --- a/apps/web/src/components/auth/MangroveConnectDialog.tsx +++ b/apps/web/src/components/auth/MangroveConnectDialog.tsx @@ -4,7 +4,6 @@ import KeyOutlinedIcon from "@mui/icons-material/KeyOutlined"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -16,6 +15,7 @@ import { useKeypairStore, useMangroveSigner } from "@openmapx/mangrove-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { SiBluesky } from "react-icons/si"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/auth/MangroveExportDialog.tsx b/apps/web/src/components/auth/MangroveExportDialog.tsx index 8d5eef364..afc378f8d 100644 --- a/apps/web/src/components/auth/MangroveExportDialog.tsx +++ b/apps/web/src/components/auth/MangroveExportDialog.tsx @@ -8,7 +8,6 @@ import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -19,6 +18,7 @@ import { toMangroveExportJwk } from "@openmapx/mangrove-client"; import { useMangroveKeypairExport } from "@openmapx/mangrove-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/auth/MangroveLocalAccountSection.tsx b/apps/web/src/components/auth/MangroveLocalAccountSection.tsx index 9f0a3849e..2c271c2be 100644 --- a/apps/web/src/components/auth/MangroveLocalAccountSection.tsx +++ b/apps/web/src/components/auth/MangroveLocalAccountSection.tsx @@ -13,7 +13,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -40,6 +39,7 @@ import { } from "@openmapx/mangrove-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useDateTimeFormat } from "@/integration-api/runtime/useDateTimeFormat"; import { MangroveExportDialog } from "./MangroveExportDialog"; import { MangroveSetupWizard } from "./MangroveSetupWizard"; diff --git a/apps/web/src/components/auth/MangroveSetupWizard.tsx b/apps/web/src/components/auth/MangroveSetupWizard.tsx index daa47c1f7..3b99c7e08 100644 --- a/apps/web/src/components/auth/MangroveSetupWizard.tsx +++ b/apps/web/src/components/auth/MangroveSetupWizard.tsx @@ -15,7 +15,6 @@ import CardActionArea from "@mui/material/CardActionArea"; import CardContent from "@mui/material/CardContent"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -26,6 +25,7 @@ import { fingerprintPem, isWebAuthnAvailable, publicKeyToPem } from "@openmapx/m import { useSetupKeypair } from "@openmapx/mangrove-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/auth/MangroveUnlockDialog.tsx b/apps/web/src/components/auth/MangroveUnlockDialog.tsx index d6c5bdf02..b9d1a596b 100644 --- a/apps/web/src/components/auth/MangroveUnlockDialog.tsx +++ b/apps/web/src/components/auth/MangroveUnlockDialog.tsx @@ -6,7 +6,6 @@ import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -16,6 +15,7 @@ import Typography from "@mui/material/Typography"; import { type KeypairEnvelope, useKeypairState, useUnlockKeypair } from "@openmapx/mangrove-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; interface Props { open: boolean; diff --git a/apps/web/src/components/auth/ResetPasswordDialog.tsx b/apps/web/src/components/auth/ResetPasswordDialog.tsx index 08347d744..023fa00a3 100644 --- a/apps/web/src/components/auth/ResetPasswordDialog.tsx +++ b/apps/web/src/components/auth/ResetPasswordDialog.tsx @@ -6,7 +6,6 @@ import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; @@ -16,6 +15,7 @@ import { authClient } from "@openmapx/core"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; function ResetPasswordDialogInner() { const t = useTranslations("auth"); diff --git a/apps/web/src/components/auth/TimelineConnectionSection.tsx b/apps/web/src/components/auth/TimelineConnectionSection.tsx index 424cc469c..157c07f03 100644 --- a/apps/web/src/components/auth/TimelineConnectionSection.tsx +++ b/apps/web/src/components/auth/TimelineConnectionSection.tsx @@ -6,7 +6,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -32,6 +31,7 @@ import { } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; import { forwardRef, useEffect, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; const DAWARICH_API_KEY_HELP = "https://dawarich.app/docs/api/dawarich-api/"; diff --git a/apps/web/src/components/command-palette/CommandPalette.tsx b/apps/web/src/components/command-palette/CommandPalette.tsx index 5c5ecfcf1..dc4ab7351 100644 --- a/apps/web/src/components/command-palette/CommandPalette.tsx +++ b/apps/web/src/components/command-palette/CommandPalette.tsx @@ -1,6 +1,5 @@ "use client"; -import Dialog from "@mui/material/Dialog"; import { useTheme } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import useMediaQuery from "@mui/material/useMediaQuery"; @@ -14,6 +13,7 @@ import { import { useCommandPaletteStore, useSearchStore } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { CommandPaletteFooter } from "./CommandPaletteFooter"; import { CommandPaletteInput } from "./CommandPaletteInput"; import { buildDefaultCommandRows, CommandPaletteList } from "./CommandPaletteList"; diff --git a/apps/web/src/components/command-palette/KeyboardShortcutsDialog.tsx b/apps/web/src/components/command-palette/KeyboardShortcutsDialog.tsx index 270b4e29b..afdea839d 100644 --- a/apps/web/src/components/command-palette/KeyboardShortcutsDialog.tsx +++ b/apps/web/src/components/command-palette/KeyboardShortcutsDialog.tsx @@ -2,7 +2,6 @@ import CloseIcon from "@mui/icons-material/Close"; import Box from "@mui/material/Box"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import IconButton from "@mui/material/IconButton"; @@ -19,6 +18,7 @@ import { } from "@openmapx/command-palette"; import { useTranslations } from "next-intl"; import { useMemo } from "react"; +import Dialog from "@/components/ui/Dialog"; // Built-in shortcuts that aren't backed by a Command. Pre-parsed once. const BUILTIN_SHORTCUTS = { diff --git a/apps/web/src/components/map/DeepLinkManager.buildings.test.tsx b/apps/web/src/components/map/DeepLinkManager.buildings.test.tsx index 9956f229d..2d01d9f24 100644 --- a/apps/web/src/components/map/DeepLinkManager.buildings.test.tsx +++ b/apps/web/src/components/map/DeepLinkManager.buildings.test.tsx @@ -106,7 +106,7 @@ describe("building camera restoration", () => { expect(useBuildingsStore.getState().layerVisible).toBe(true); }); - it("round-trips manual-off through sharing and history, and allows explicit selection", () => { + it("round-trips manual-off through sharing, keeps it through history, and allows explicit selection", () => { initOverlayRegistry([BUILDINGS]); window.history.replaceState(null, "", VIEW); render(); @@ -116,15 +116,20 @@ describe("building camera restoration", () => { const shared = window.location.search; expect(shared).toContain("buildings=0"); expect(shared).not.toContain("ov="); - pitch(0); - pitch(45); - expect(useBuildingsStore.getState().layerVisible).toBe(true); + // A history traversal changes only the open surfaces, never the layers. act(() => { - window.history.pushState(null, "", shared); + window.history.pushState(null, "", VIEW); window.dispatchEvent(new PopStateEvent("popstate")); }); expect(useBuildingsStore.getState().layerVisible).toBe(false); - expect(fake.map.getPitch()).toBe(45); + expect(window.location.search).toContain("buildings=0"); + pitch(0); + pitch(45); + expect(useBuildingsStore.getState().layerVisible).toBe(true); + act(() => toggleOverlay("3d-buildings", { kind: "user" })); + act(updateUrl); + expect(useBuildingsStore.getState().layerVisible).toBe(false); + expect(window.location.search).toContain("buildings=0"); act(() => toggleOverlay("3d-buildings", { kind: "user" })); act(updateUrl); expect(useBuildingsStore.getState().layerVisible).toBe(true); diff --git a/apps/web/src/components/map/DeepLinkManager.globe.test.tsx b/apps/web/src/components/map/DeepLinkManager.globe.test.tsx index 08094c1d5..8f3987736 100644 --- a/apps/web/src/components/map/DeepLinkManager.globe.test.tsx +++ b/apps/web/src/components/map/DeepLinkManager.globe.test.tsx @@ -81,19 +81,21 @@ describe("globe camera restoration", () => { }, ); - it("preserves a history camera but still reveals globe after a later UI toggle", () => { + it("leaves the camera and projection as they are on a history traversal", () => { render(); + const before = { + zoom: fake.map.getZoom(), + pitch: fake.map.getPitch(), + projection: fake.map.getProjection(), + }; act(() => { window.history.pushState(null, "", VIEW); window.dispatchEvent(new PopStateEvent("popstate")); }); - expect(fake.map.getZoom()).toBe(17.5); - expect(fake.map.getPitch()).toBe(45); - expect(fake.map.getProjection()).toEqual({ type: "globe" }); - - act(() => useLayerStore.getState().setGlobeView(false)); - act(() => useLayerStore.getState().setGlobeView(true)); - expect(fake.map.getZoom()).toBe(3); - expect(fake.map.getPitch()).toBe(45); + // Back/Forward step through open surfaces only; the map stays where the + // user left it, as in a native map app. + expect(fake.map.getZoom()).toBe(before.zoom); + expect(fake.map.getPitch()).toBe(before.pitch); + expect(fake.map.getProjection()).toEqual(before.projection); }); }); diff --git a/apps/web/src/components/map/DeepLinkManager.test.tsx b/apps/web/src/components/map/DeepLinkManager.test.tsx index a6ff5bf7b..5807a60b1 100644 --- a/apps/web/src/components/map/DeepLinkManager.test.tsx +++ b/apps/web/src/components/map/DeepLinkManager.test.tsx @@ -7,7 +7,9 @@ import { import { useWildfireStore } from "@integrations/overlay-wildfires/store"; import { createOverlayStore, + createPlace, getRegisteredOverlayStore, + idsFromPrimaryOrCoords, isOverlayActive, type OverlayStoreBase, PANEL, @@ -24,6 +26,9 @@ vi.mock("@/integration-api/map/MapContext", () => ({ useMap: () => ({ mapRef: { current: null }, mapReady: true }), })); +import { resetBackNavigationForTests } from "@/lib/backNavigation"; +import { DEEPLINK_UPDATE_EVENT } from "@/lib/deepLink"; +import { settleHistory } from "@/test/history"; import { DeepLinkManager } from "./DeepLinkManager"; const FALLBACK: OverlayStoreBase = { @@ -51,6 +56,7 @@ beforeEach(() => { resetOverlay("weather", ["air-quality"]); resetOverlay("air-quality", ["weather"]); window.history.replaceState(null, "", "/"); + resetBackNavigationForTests(); useSidebarStore.setState({ activeSidebarId: null, activeDetailId: null, collapsed: false }); usePlaceStore.setState({ selectedPlace: null }); useCategorySearchStore.getState().clearCategory(); @@ -181,3 +187,101 @@ describe("DeepLinkManager overlay application", () => { expect(window.location.search).toContain("ov=air-quality"); }); }); + +describe("DeepLinkManager history entries", () => { + async function settled(): Promise { + await act(settleHistory); + } + + function writeNow(): void { + act(() => { + window.dispatchEvent(new Event(DEEPLINK_UPDATE_EVENT)); + }); + } + + it("gives an opened place card its own entry, which Back closes without rebuilding the list", async () => { + window.history.replaceState(null, "", "/?panel=category&categoryId=restaurants"); + render(); + const listRevision = useCategorySearchStore.getState().searchRevision; + + act(() => { + usePlaceStore.getState().setSelectedPlace( + createPlace({ + ...idsFromPrimaryOrCoords("stylePoi:1", [6.08, 50.78]), + name: "Grotesque", + address: "Grotesque", + coordinates: [6.08, 50.78], + }), + ); + useSidebarStore.getState().openDetail(PANEL.PLACE_CARD); + }); + writeNow(); + expect(window.location.search).toContain("place="); + expect(window.history.state.omx.depth).toBe(1); + + window.history.back(); + await settled(); + + expect(usePlaceStore.getState().selectedPlace).toBeNull(); + expect(useSidebarStore.getState()).toMatchObject({ + activeSidebarId: PANEL.CATEGORY, + activeDetailId: null, + }); + expect(useCategorySearchStore.getState().activeCategory).toBe("restaurants"); + // Closed in place: the list was not cleared and searched again. + expect(useCategorySearchStore.getState().searchRevision).toBe(listRevision); + expect(window.location.search).not.toContain("place="); + }); + + it("rewrites the card's entry when its own close control is used, so Back does not reopen it", async () => { + window.history.pushState(null, "", "/elsewhere"); + window.history.pushState(null, "", "/?panel=category&categoryId=restaurants"); + resetBackNavigationForTests(); + render(); + act(() => { + usePlaceStore.getState().setSelectedPlace( + createPlace({ + ...idsFromPrimaryOrCoords("stylePoi:1", [6.08, 50.78]), + name: "Grotesque", + address: "Grotesque", + coordinates: [6.08, 50.78], + }), + ); + useSidebarStore.getState().openDetail(PANEL.PLACE_CARD); + }); + writeNow(); + expect(window.history.state.omx.depth).toBe(1); + + act(() => { + usePlaceStore.getState().setSelectedPlace(null); + useSidebarStore.getState().closeDetail(); + }); + writeNow(); + expect(window.history.state.omx.depth).toBe(1); + expect(window.location.search).not.toContain("place="); + + // The entry below shows the same list, so Back steps over it and closes + // the list's page instead of doing nothing visible. + window.history.back(); + await settled(); + expect(window.location.pathname).toBe("/elsewhere"); + expect(usePlaceStore.getState().selectedPlace).toBeNull(); + }); + + it("restores a text search from its link", () => { + window.history.replaceState( + null, + "", + "/?panel=category&search=pizza&bbox=6.0%2C50.7%2C6.1%2C50.8", + ); + render(); + + expect(useCategorySearchStore.getState()).toMatchObject({ + mode: "text", + textQuery: "pizza", + activeCategory: null, + }); + expect(useSidebarStore.getState().activeSidebarId).toBe(PANEL.CATEGORY); + expect(window.location.search).toContain("search=pizza"); + }); +}); diff --git a/apps/web/src/components/map/DeepLinkManager.tsx b/apps/web/src/components/map/DeepLinkManager.tsx index 895c3d9fd..bddbb8cda 100644 --- a/apps/web/src/components/map/DeepLinkManager.tsx +++ b/apps/web/src/components/map/DeepLinkManager.tsx @@ -65,6 +65,12 @@ import type { TransportMode } from "@openmapx/mobility-core/transit"; import type * as maplibregl from "maplibre-gl"; import { useCallback, useEffect, useRef } from "react"; import { useMap } from "@/integration-api/map/MapContext"; +import { + isStrictPrefix, + samePath, + subscribeHistoryRestore, + writeNavigation, +} from "@/lib/backNavigation"; import { frameBoundsInstant, jumpToView } from "@/lib/cameraFraming"; import { DEEPLINK_UPDATE_EVENT, @@ -74,6 +80,7 @@ import { formatLngLat, formatLngLatList, formatScheduleParam, + navigationPath, type ParsedDeepLink, paramsWithoutDeepLink, parseDeepLinkSearch, @@ -451,6 +458,18 @@ function applyCategory(parsed: ParsedDeepLink["category"]): void { useSidebarStore.getState().openSidebar(PANEL.CATEGORY); } +function applyTextSearch(parsed: ParsedDeepLink["textSearch"], map: maplibregl.Map | null): void { + if (!parsed) return; + const bbox = parsed.bbox ?? mapBounds(map); + if (!bbox) return; + const store = useCategorySearchStore.getState(); + store.setSearchBbox(bbox); + store.setMapMoved(false); + store.setExploreText(parsed.text); + useSearchStore.getState().setQuery(parsed.text); + useSidebarStore.getState().openSidebar(PANEL.CATEGORY); +} + function applyDataSource(parsed: ParsedDeepLink["dataSource"]): void { if (!parsed) return; const store = useDataSourceStore.getState(); @@ -545,12 +564,20 @@ function applyDeepLink( syncMapStoreFromMap(map); } else if (parsed.category?.bbox && map) { fitBbox(map, parsed.category.bbox); + } else if (parsed.textSearch?.bbox && map) { + fitBbox(map, parsed.textSearch.bbox); } else if (parsed.dataSource?.bbox && map) { fitBbox(map, parsed.dataSource.bbox); } + applyPanels(parsed, map); +} + +/** The open surfaces of a link: panels, the selected place and the map tools. */ +function applyPanels(parsed: ParsedDeepLink, map: maplibregl.Map | null): void { if (parsed.directions || parsed.panel === PANEL.DIRECTIONS) applyDirections(parsed.directions); if (parsed.category || parsed.panel === PANEL.CATEGORY) applyCategory(parsed.category); + applyTextSearch(parsed.textSearch, map); if (parsed.dataSource || parsed.panel === PANEL.DATASOURCE) applyDataSource(parsed.dataSource); if (parsed.saved || parsed.panel === PANEL.SAVED) applySaved(parsed.saved); if (parsed.place) applyPlace(parsed.place); @@ -558,6 +585,66 @@ function applyDeepLink( applyTravelTime(parsed.travelTime); } +/** + * Closes one open surface the way its own close control does. Back uses this + * when it only has to step out of something, so whatever stays open keeps its + * full in-memory state — the loaded place, the list and its scroll position, a + * search the URL cannot express — instead of being rebuilt from the URL. + */ +function closeSegment(segment: string): void { + const kind = segment.split(":", 1)[0]; + const sidebar = useSidebarStore.getState(); + switch (kind) { + case "tool": + if (segment === "tool:measure") useMeasurementStore.getState().deactivate(); + else useTravelTimeStore.getState().deactivate(); + return; + case "place": + usePlaceStore.getState().setSelectedPlace(null); + useDataSourceStore.getState().clearSelection(); + if (sidebar.activeDetailId === PANEL.PLACE_CARD) sidebar.closeDetail(); + else if (sidebar.activeSidebarId === PANEL.PLACE) sidebar.closeSidebar(); + return; + case "item": + useDataSourceStore.getState().clearSelection(); + return; + case "list": + useSavedPlacesStore.getState().clearSelectedList(); + return; + default: + sidebar.closeSidebar(); + useSearchStore.getState().setQuery(""); + } +} + +function currentNavigationPath(map: maplibregl.Map | null): string[] { + const url = new URL(encodeCurrentUrl(map), window.location.origin); + return navigationPath(url.search); +} + +/** + * Shows the history entry Back/Forward landed on. Only the open surfaces + * follow the entry: the camera, base layer and overlays stay as the user left + * them, like in any native map app, where Back closes the card but does not + * fly the map back to where it was when the card opened. + * + * Returns whether anything had to change. + */ +function restoreNavigation(map: maplibregl.Map | null): boolean { + const target = navigationPath(window.location.search); + const current = currentNavigationPath(map); + if (samePath(target, current)) return false; + + if (isStrictPrefix(target, current)) { + for (const segment of current.slice(target.length).reverse()) closeSegment(segment); + if (samePath(currentNavigationPath(map), target)) return true; + } + + clearPanelState(); + applyPanels(parseDeepLinkSearch(window.location.search), map); + return true; +} + function encodePlace(params: URLSearchParams, place: Place): void { params.set("place", place.id); params.set("at", formatLngLat(place.coordinates)); @@ -613,6 +700,15 @@ function encodePanelState(params: URLSearchParams, map: maplibregl.Map | null): params.set("categoryId", category.activeCategory); const bbox = category.searchBbox ?? fallbackBbox; if (bbox) params.set("bbox", formatBboxParam(bbox)); + } else if ( + sidebar.activeSidebarId === PANEL.CATEGORY && + category.mode === "text" && + category.textQuery + ) { + params.set("panel", PANEL.CATEGORY); + params.set("search", category.textQuery); + const bbox = category.searchBbox ?? fallbackBbox; + if (bbox) params.set("bbox", formatBboxParam(bbox)); } if (dataSource.activeSource) { @@ -775,6 +871,11 @@ export function DeepLinkManager() { const timeoutRef = useRef(null); /** A parsed link whose overlay part is still waiting for the registry. */ const pendingOverlaysRef = useRef(null); + /** + * The page this map lives on. A Back/Forward to another page fires while + * this is still mounted; its URL is none of this component's business. + */ + const pagePathRef = useRef(null); const writeUrl = useCallback(() => { if (typeof window === "undefined" || applyingRef.current) return; @@ -782,17 +883,25 @@ export function DeepLinkManager() { // place that intent exists. Re-encoding from the current, overlay-less // state would silently delete `ov` and every overlay setting. if (pendingOverlaysRef.current) return; - const nextUrl = encodeCurrentUrl(mapRef.current); - const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; - if (nextUrl !== currentUrl) { - window.history.replaceState(window.history.state, "", nextUrl); - } + if (pagePathRef.current !== null && window.location.pathname !== pagePathRef.current) return; + // Opening a surface pushes a history entry so Back can close it again; + // everything else (camera, layers) rewrites the current entry. The URL is + // built when the write runs, which may be after a traversal lands. + writeNavigation(() => { + const url = encodeCurrentUrl(mapRef.current); + return { + url, + path: navigationPath(new URL(url, window.location.origin).search), + currentPath: navigationPath(window.location.search), + }; + }); }, [mapRef]); useEffect(() => { if (!mapReady) return; const map = mapRef.current; + pagePathRef.current = window.location.pathname; const scheduleWrite = () => { if (applyingRef.current) return; @@ -814,18 +923,31 @@ export function DeepLinkManager() { writeUrl(); }; + const onHistoryRestore = () => { + if (window.location.pathname !== pagePathRef.current) return false; + applyingRef.current = true; + let changed: boolean; + try { + changed = restoreNavigation(mapRef.current); + } finally { + applyingRef.current = false; + } + writeUrl(); + return changed; + }; + applyFromLocation(); const unsubscribeStores = APP_STATE_STORES.map((store) => store.subscribe(scheduleWrite)); + const unsubscribeHistory = subscribeHistoryRestore(onHistoryRestore); map?.on("moveend", scheduleWrite); - window.addEventListener("popstate", applyFromLocation); window.addEventListener(DEEPLINK_UPDATE_EVENT, writeUrl); return () => { if (timeoutRef.current) window.clearTimeout(timeoutRef.current); for (const unsubscribe of unsubscribeStores) unsubscribe(); + unsubscribeHistory(); map?.off("moveend", scheduleWrite); - window.removeEventListener("popstate", applyFromLocation); window.removeEventListener(DEEPLINK_UPDATE_EVENT, writeUrl); }; }, [mapReady, mapRef, writeUrl]); diff --git a/apps/web/src/components/map/MapContextMenu.tsx b/apps/web/src/components/map/MapContextMenu.tsx index 8e0c3db04..2120b951c 100644 --- a/apps/web/src/components/map/MapContextMenu.tsx +++ b/apps/web/src/components/map/MapContextMenu.tsx @@ -14,10 +14,8 @@ import { Divider, ListItemIcon, ListItemText, - Menu, MenuItem, MenuList, - Popover, Snackbar, Typography, } from "@mui/material"; @@ -45,6 +43,8 @@ import { useState, } from "react"; import { useSaveParking } from "@/components/panels/parking/useSaveParking"; +import Menu from "@/components/ui/Menu"; +import Popover from "@/components/ui/Popover"; import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers"; import { useMap } from "@/integration-api/map/MapContext"; import { buildLocationShareUrl, shareUrl } from "@/lib/deepLink"; diff --git a/apps/web/src/components/map/StreetLevelViewer.tsx b/apps/web/src/components/map/StreetLevelViewer.tsx index bfe65801a..89609b4d9 100644 --- a/apps/web/src/components/map/StreetLevelViewer.tsx +++ b/apps/web/src/components/map/StreetLevelViewer.tsx @@ -1,7 +1,6 @@ "use client"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -11,6 +10,7 @@ import { safeHref, useDirectionsStore, useStreetLevelStore } from "@openmapx/cor import dynamic from "next/dynamic"; import { useTranslations } from "next-intl"; import { useEffect } from "react"; +import Dialog from "@/components/ui/Dialog"; import { useStreetLevelProviders } from "@/integration-api/components/useStreetLevelProviders"; const StreetLevelViewerInner = dynamic( diff --git a/apps/web/src/components/map/layer-selector/LayerSelector.tsx b/apps/web/src/components/map/layer-selector/LayerSelector.tsx index 815c6410b..591688c0a 100644 --- a/apps/web/src/components/map/layer-selector/LayerSelector.tsx +++ b/apps/web/src/components/map/layer-selector/LayerSelector.tsx @@ -5,7 +5,6 @@ import Box from "@mui/material/Box"; import ButtonBase from "@mui/material/ButtonBase"; import IconButton from "@mui/material/IconButton"; import Paper from "@mui/material/Paper"; -import Popover, { type PopoverActions } from "@mui/material/Popover"; import { useTheme } from "@mui/material/styles"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; @@ -15,6 +14,7 @@ import { useTranslations } from "next-intl"; import type { FocusEvent, MouseEvent } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { LAYER_SELECTOR_OPEN_EVENT } from "@/components/command-palette/constants"; +import Popover, { type PopoverActions } from "@/components/ui/Popover"; import { isPanelShiftActive, PANEL_WIDTH, shouldHideLayerSelector } from "@/lib/layout"; import { MAP_FOOTER_OBSTRUCTION_ID, useMapFooterBox } from "@/lib/mapFooterCenter"; import { useMapObstructionExtent } from "@/lib/mapObstructions"; diff --git a/apps/web/src/components/menu/HamburgerMenu.tsx b/apps/web/src/components/menu/HamburgerMenu.tsx index 17a7cf11d..d5b32026c 100644 --- a/apps/web/src/components/menu/HamburgerMenu.tsx +++ b/apps/web/src/components/menu/HamburgerMenu.tsx @@ -39,6 +39,7 @@ import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { StorageDialog } from "@/components/settings/StorageDialog"; import { shareCurrentUrl } from "@/lib/deepLink"; import { IMPORT_ACCEPT, importGeoFromFile } from "@/lib/importGeoFile"; +import { useBackDismiss } from "@/lib/useBackDismiss"; const DRAWER_WIDTH = 280; @@ -47,6 +48,7 @@ export function HamburgerMenu() { const tCommon = useTranslations("common"); const isOpen = useMenuStore((s) => s.isOpen); const close = useMenuStore((s) => s.close); + useBackDismiss(isOpen, close); const { data: session, isPending: sessionPending } = useSession(); const { data: parked } = useParkedLocations(); const isSignedIn = !sessionPending && !!session?.user?.id; diff --git a/apps/web/src/components/navigation/NavDirectionsDialog.tsx b/apps/web/src/components/navigation/NavDirectionsDialog.tsx index a60e048fd..235d7d0b4 100644 --- a/apps/web/src/components/navigation/NavDirectionsDialog.tsx +++ b/apps/web/src/components/navigation/NavDirectionsDialog.tsx @@ -1,9 +1,9 @@ "use client"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import type { Route } from "@openmapx/core"; import { DetailsView } from "@/components/panels/directions/DetailsView"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/navigation/NavigationBackHandler.tsx b/apps/web/src/components/navigation/NavigationBackHandler.tsx new file mode 100644 index 000000000..d5ed95d13 --- /dev/null +++ b/apps/web/src/components/navigation/NavigationBackHandler.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { useNavigationStore } from "@openmapx/core"; +import { useNavigationMutations } from "@/lib/mobile/useNavigationMutations"; +import { useBackDismiss } from "@/lib/useBackDismiss"; + +/** + * Back during turn-by-turn or transit guidance ends the trip, like the End + * button, and returns to the route that was being followed — instead of + * leaving the app mid-trip. + */ +export function NavigationBackHandler() { + const navigating = useNavigationStore((s) => s.status !== "idle"); + const { endNavigation } = useNavigationMutations(); + useBackDismiss(navigating, () => void endNavigation()); + return null; +} diff --git a/apps/web/src/components/navigation/NavigationSessionResumeDialog.tsx b/apps/web/src/components/navigation/NavigationSessionResumeDialog.tsx index f291f6f53..cfd9b03c9 100644 --- a/apps/web/src/components/navigation/NavigationSessionResumeDialog.tsx +++ b/apps/web/src/components/navigation/NavigationSessionResumeDialog.tsx @@ -2,13 +2,13 @@ import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import Typography from "@mui/material/Typography"; import { formatMeasurementDistance, type NavigationSessionSnapshot } from "@openmapx/core"; import { useTranslations } from "next-intl"; +import Dialog from "@/components/ui/Dialog"; import type { OfflineRouteCoverage } from "@/lib/navigation/offlineRouteCoverage"; interface Props { diff --git a/apps/web/src/components/panels/MapClickFloatingCard.tsx b/apps/web/src/components/panels/MapClickFloatingCard.tsx index 0607ad0b2..1bbc546e5 100644 --- a/apps/web/src/components/panels/MapClickFloatingCard.tsx +++ b/apps/web/src/components/panels/MapClickFloatingCard.tsx @@ -21,6 +21,7 @@ import { import { useLocale, useTranslations } from "next-intl"; import { useMap } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; +import { useBackDismiss } from "@/lib/useBackDismiss"; export function MapClickFloatingCard() { const tc = useTranslations("common"); @@ -33,6 +34,7 @@ export function MapClickFloatingCard() { const { flyTo } = useMap(); const { data: reverseGeo, isLoading } = useReverseGeocoding(clickedLngLat, locale); + useBackDismiss(clickedLngLat !== null, () => setClickedLngLat(null)); if (!clickedLngLat) return null; diff --git a/apps/web/src/components/panels/category/CategoryResultsHeader.tsx b/apps/web/src/components/panels/category/CategoryResultsHeader.tsx index b2f4a4625..987540ddd 100644 --- a/apps/web/src/components/panels/category/CategoryResultsHeader.tsx +++ b/apps/web/src/components/panels/category/CategoryResultsHeader.tsx @@ -6,7 +6,6 @@ import PlaceOutlinedIcon from "@mui/icons-material/PlaceOutlined"; import SortIcon from "@mui/icons-material/Sort"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Switch from "@mui/material/Switch"; import Typography from "@mui/material/Typography"; @@ -15,6 +14,7 @@ import type { Attribution } from "@openmapx/mobility-core/attribution"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { AttributionStrip } from "@/components/ui/AttributionStrip"; +import Menu from "@/components/ui/Menu"; import { ExploreTravelTimeControl } from "./ExploreTravelTimeControl"; type ResultSort = "relevance" | "distance"; diff --git a/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx b/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx index d40224f73..c7b0b93ef 100644 --- a/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx +++ b/apps/web/src/components/panels/directions/DirectionsPanelContent.tsx @@ -13,7 +13,6 @@ import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Snackbar from "@mui/material/Snackbar"; import ToggleButton from "@mui/material/ToggleButton"; @@ -79,6 +78,7 @@ import { useExpandOnBackgroundTap, useMobileSheet } from "@/components/panels/sh import { AutocompleteDropdown } from "@/components/search/AutocompleteDropdown"; import { AttributionDetailsGroup } from "@/components/ui/AttributionDetailsGroup"; import { AttributionStrip } from "@/components/ui/AttributionStrip"; +import Menu from "@/components/ui/Menu"; import { useMapOptional } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; import { useDateTimeFormat } from "@/integration-api/runtime/useDateTimeFormat"; diff --git a/apps/web/src/components/panels/directions/RouteImpactDetailsDialog.tsx b/apps/web/src/components/panels/directions/RouteImpactDetailsDialog.tsx index cbf270dde..a4722cc88 100644 --- a/apps/web/src/components/panels/directions/RouteImpactDetailsDialog.tsx +++ b/apps/web/src/components/panels/directions/RouteImpactDetailsDialog.tsx @@ -5,7 +5,6 @@ import EnergySavingsLeafIcon from "@mui/icons-material/EnergySavingsLeaf"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Chip from "@mui/material/Chip"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import Divider from "@mui/material/Divider"; @@ -22,6 +21,7 @@ import Typography from "@mui/material/Typography"; import { formatMeasurementDistance, type RouteImpact, useSettingsStore } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { formatCo2Emission } from "@/lib/formatCo2"; import { RouteImpactProvenance } from "./RouteImpactProvenance"; diff --git a/apps/web/src/components/panels/directions/ShareRouteDialog.tsx b/apps/web/src/components/panels/directions/ShareRouteDialog.tsx index b5fdd8eba..85bc90275 100644 --- a/apps/web/src/components/panels/directions/ShareRouteDialog.tsx +++ b/apps/web/src/components/panels/directions/ShareRouteDialog.tsx @@ -1,7 +1,6 @@ "use client"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -11,6 +10,7 @@ import { useCreateShare, useDirectionsStore } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { ShareLinkCreated } from "@/components/share/ShareLinkCreated"; +import Dialog from "@/components/ui/Dialog"; const SHAREABLE_MODES = new Set(["driving", "walking", "cycling", "motorcycle"]); diff --git a/apps/web/src/components/panels/directions/WaypointScheduleDialog.tsx b/apps/web/src/components/panels/directions/WaypointScheduleDialog.tsx index 64fbddad2..6a9fab6c4 100644 --- a/apps/web/src/components/panels/directions/WaypointScheduleDialog.tsx +++ b/apps/web/src/components/panels/directions/WaypointScheduleDialog.tsx @@ -2,7 +2,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -16,6 +15,7 @@ import { timeZoneAt, tzOffsetLabel } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { DateTimeField } from "@/components/ui/DateTimeField"; +import Dialog from "@/components/ui/Dialog"; import { BRAND } from "@/integration-api/runtime/theme"; /** Matches the wall-clock shape the routing API accepts. */ diff --git a/apps/web/src/components/panels/place/PlaceFoodActions.tsx b/apps/web/src/components/panels/place/PlaceFoodActions.tsx index 50e978f73..347ffc6b5 100644 --- a/apps/web/src/components/panels/place/PlaceFoodActions.tsx +++ b/apps/web/src/components/panels/place/PlaceFoodActions.tsx @@ -7,7 +7,6 @@ import DeliveryDiningOutlinedIcon from "@mui/icons-material/DeliveryDiningOutlin import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import RestaurantMenuIcon from "@mui/icons-material/RestaurantMenu"; import Box from "@mui/material/Box"; -import Dialog from "@mui/material/Dialog"; import IconButton from "@mui/material/IconButton"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; @@ -28,6 +27,7 @@ import { } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { type MouseEvent, type ReactNode, useId, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { BRAND } from "@/integration-api/runtime/theme"; import { BrandMark } from "../shared/BrandMark"; diff --git a/apps/web/src/components/panels/place/PlaceHotelActions.tsx b/apps/web/src/components/panels/place/PlaceHotelActions.tsx index a7021f821..88ff545f4 100644 --- a/apps/web/src/components/panels/place/PlaceHotelActions.tsx +++ b/apps/web/src/components/panels/place/PlaceHotelActions.tsx @@ -4,7 +4,6 @@ import CloseIcon from "@mui/icons-material/Close"; import HotelOutlinedIcon from "@mui/icons-material/HotelOutlined"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import IconButton from "@mui/material/IconButton"; import Typography from "@mui/material/Typography"; import { @@ -18,6 +17,7 @@ import { } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useId, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { BRAND } from "@/integration-api/runtime/theme"; import { HotelCompareList } from "./HotelCompareList"; import { HotelPriceBadge } from "./HotelPriceBadge"; diff --git a/apps/web/src/components/panels/place/PlaceOverviewTab.tsx b/apps/web/src/components/panels/place/PlaceOverviewTab.tsx index cc47d747f..7c671b7e9 100644 --- a/apps/web/src/components/panels/place/PlaceOverviewTab.tsx +++ b/apps/web/src/components/panels/place/PlaceOverviewTab.tsx @@ -24,7 +24,6 @@ import WorkIcon from "@mui/icons-material/Work"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -66,6 +65,7 @@ import { useLocale, useTranslations } from "next-intl"; import type { ReactNode } from "react"; import { useState } from "react"; import { AuthDialog } from "@/components/auth/AuthDialog"; +import Dialog from "@/components/ui/Dialog"; import { SectionAttribution } from "@/components/ui/SectionAttribution"; import { BRAND } from "@/integration-api/runtime/theme"; import { resolveListIcon } from "@/lib/listIcon"; diff --git a/apps/web/src/components/panels/place/PlacePhotoGallery.tsx b/apps/web/src/components/panels/place/PlacePhotoGallery.tsx index 8e9586b16..295c5d639 100644 --- a/apps/web/src/components/panels/place/PlacePhotoGallery.tsx +++ b/apps/web/src/components/panels/place/PlacePhotoGallery.tsx @@ -29,6 +29,7 @@ import { MapCredits } from "@/components/map/MapCredits"; import { LocationMinimap } from "@/integration-api/components/LocationMinimap"; import { useMap } from "@/integration-api/map/MapContext"; import { useDateTimeFormat } from "@/integration-api/runtime/useDateTimeFormat"; +import { useBackDismiss } from "@/lib/useBackDismiss"; import { PhotoAttribution } from "./PhotoAttribution"; interface Props { @@ -50,6 +51,7 @@ function PlacePhotoGalleryInner({ open, onClose, placeName, placeId, lat, lng }: const tp = useTranslations("photoGallery"); const fmt = useDateTimeFormat(); const [selectedIdx, setSelectedIdx] = useState(0); + useBackDismiss(open, onClose); const [minimapCredits, setMinimapCredits] = useState([]); const { flyTo } = useMap(); const setClickedLngLat = useMapClickStore((s) => s.setClickedLngLat); diff --git a/apps/web/src/components/panels/place/contributions/OsmContributionDialog.tsx b/apps/web/src/components/panels/place/contributions/OsmContributionDialog.tsx index 15c13cf1d..b77b4122e 100644 --- a/apps/web/src/components/panels/place/contributions/OsmContributionDialog.tsx +++ b/apps/web/src/components/panels/place/contributions/OsmContributionDialog.tsx @@ -5,7 +5,6 @@ import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -30,6 +29,7 @@ import { } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useReducer } from "react"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, @@ -76,7 +76,7 @@ export function consumeContributeCallbackMarker(): boolean { const url = new URL(window.location.href); if (url.searchParams.get(OSM_CONTRIBUTE_CALLBACK_PARAM) !== "1") return false; url.searchParams.delete(OSM_CONTRIBUTE_CALLBACK_PARAM); - window.history.replaceState(null, "", url.toString()); + window.history.replaceState(window.history.state, "", url.toString()); return true; } diff --git a/apps/web/src/components/panels/place/reviews/DeleteReviewDialog.tsx b/apps/web/src/components/panels/place/reviews/DeleteReviewDialog.tsx index 2f73059ec..7de8ae40d 100644 --- a/apps/web/src/components/panels/place/reviews/DeleteReviewDialog.tsx +++ b/apps/web/src/components/panels/place/reviews/DeleteReviewDialog.tsx @@ -3,7 +3,6 @@ import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -11,6 +10,7 @@ import DialogTitle from "@mui/material/DialogTitle"; import type { Review } from "@openmapx/core"; import { useSubmitReview } from "@openmapx/mangrove-react"; import { useTranslations } from "next-intl"; +import Dialog from "@/components/ui/Dialog"; interface Props { open: boolean; diff --git a/apps/web/src/components/panels/place/reviews/ReportAbuseDialog.tsx b/apps/web/src/components/panels/place/reviews/ReportAbuseDialog.tsx index a08f24740..04e09315c 100644 --- a/apps/web/src/components/panels/place/reviews/ReportAbuseDialog.tsx +++ b/apps/web/src/components/panels/place/reviews/ReportAbuseDialog.tsx @@ -2,7 +2,6 @@ import Alert from "@mui/material/Alert"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -12,6 +11,7 @@ import type { Review } from "@openmapx/core"; import { useReviewIdentity, useSubmitReview } from "@openmapx/mangrove-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; interface Props { open: boolean; diff --git a/apps/web/src/components/panels/place/reviews/ReviewCard.tsx b/apps/web/src/components/panels/place/reviews/ReviewCard.tsx index deb766cd5..e52290c1a 100644 --- a/apps/web/src/components/panels/place/reviews/ReviewCard.tsx +++ b/apps/web/src/components/panels/place/reviews/ReviewCard.tsx @@ -13,13 +13,13 @@ import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import { proxyImageUrl, type Review, safeHref } from "@openmapx/core"; import { fingerprintPem } from "@openmapx/mangrove-client"; import { useLocale, useTranslations } from "next-intl"; import { useState } from "react"; +import Menu from "@/components/ui/Menu"; import { StarDisplay } from "./StarDisplay"; /** diff --git a/apps/web/src/components/panels/place/reviews/WriteReviewDialog.tsx b/apps/web/src/components/panels/place/reviews/WriteReviewDialog.tsx index 5cf2537a9..81bae54e3 100644 --- a/apps/web/src/components/panels/place/reviews/WriteReviewDialog.tsx +++ b/apps/web/src/components/panels/place/reviews/WriteReviewDialog.tsx @@ -8,7 +8,6 @@ import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import Chip from "@mui/material/Chip"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -30,6 +29,7 @@ import { useEffect, useRef, useState } from "react"; import { MangroveConnectDialog } from "@/components/auth/MangroveConnectDialog"; import { MangroveSetupWizard } from "@/components/auth/MangroveSetupWizard"; import { MangroveUnlockDialog } from "@/components/auth/MangroveUnlockDialog"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/panels/saved/ImportPlacesDialog.tsx b/apps/web/src/components/panels/saved/ImportPlacesDialog.tsx index 9105c7fae..2c7e474af 100644 --- a/apps/web/src/components/panels/saved/ImportPlacesDialog.tsx +++ b/apps/web/src/components/panels/saved/ImportPlacesDialog.tsx @@ -8,7 +8,6 @@ import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -34,6 +33,7 @@ import { contributorsForRecords } from "@openmapx/mobility-core/contributors"; import { useLocale, useTranslations } from "next-intl"; import { useEffect, useMemo, useState } from "react"; import { AttributionStrip } from "@/components/ui/AttributionStrip"; +import Dialog from "@/components/ui/Dialog"; import type { ImportList, ImportPlace, ParsedImport } from "@/lib/savedPlacesImport/types"; interface ReviewList extends ImportList { diff --git a/apps/web/src/components/panels/saved/SavePlaceDialog.tsx b/apps/web/src/components/panels/saved/SavePlaceDialog.tsx index 997cb6a19..284fd2f91 100644 --- a/apps/web/src/components/panels/saved/SavePlaceDialog.tsx +++ b/apps/web/src/components/panels/saved/SavePlaceDialog.tsx @@ -5,7 +5,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Checkbox from "@mui/material/Checkbox"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import TextField from "@mui/material/TextField"; @@ -24,6 +23,7 @@ import { listSavedListPlaces, unwrapSavedResponse } from "@openmapx/core/saved-p import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { BRAND, BRAND_LIGHT } from "@/integration-api/runtime/theme"; import { haptics } from "@/lib/haptics"; import { resolveListIcon } from "@/lib/listIcon"; diff --git a/apps/web/src/components/panels/saved/SavedLabeledTab.tsx b/apps/web/src/components/panels/saved/SavedLabeledTab.tsx index 125be4fac..376679f07 100644 --- a/apps/web/src/components/panels/saved/SavedLabeledTab.tsx +++ b/apps/web/src/components/panels/saved/SavedLabeledTab.tsx @@ -10,7 +10,6 @@ import CircularProgress from "@mui/material/CircularProgress"; import IconButton from "@mui/material/IconButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import type { LabeledPlace } from "@openmapx/core"; @@ -31,6 +30,7 @@ import { useTranslations } from "next-intl"; import { useState } from "react"; import { AttributionDetailsGroup } from "@/components/ui/AttributionDetailsGroup"; import { AttributionStrip } from "@/components/ui/AttributionStrip"; +import Menu from "@/components/ui/Menu"; import { useMap } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; diff --git a/apps/web/src/components/panels/saved/SavedListDetail.tsx b/apps/web/src/components/panels/saved/SavedListDetail.tsx index 8f140a3b5..3c3ecb1ad 100644 --- a/apps/web/src/components/panels/saved/SavedListDetail.tsx +++ b/apps/web/src/components/panels/saved/SavedListDetail.tsx @@ -13,7 +13,6 @@ import UploadFileIcon from "@mui/icons-material/UploadFile"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -22,9 +21,7 @@ import Divider from "@mui/material/Divider"; import IconButton from "@mui/material/IconButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; -import Popover from "@mui/material/Popover"; import Snackbar from "@mui/material/Snackbar"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; @@ -42,6 +39,9 @@ import { useTranslations } from "next-intl"; import type React from "react"; import { useRef, useState } from "react"; import { AttributionDetailsGroup } from "@/components/ui/AttributionDetailsGroup"; +import Dialog from "@/components/ui/Dialog"; +import Menu from "@/components/ui/Menu"; +import Popover from "@/components/ui/Popover"; import { useMap } from "@/integration-api/map/MapContext"; import { BRAND, BRAND_LIGHT } from "@/integration-api/runtime/theme"; import { exportSavedList } from "@/lib/exportSavedList"; diff --git a/apps/web/src/components/panels/saved/SavedListsTab.tsx b/apps/web/src/components/panels/saved/SavedListsTab.tsx index 940f21924..2eed8c39d 100644 --- a/apps/web/src/components/panels/saved/SavedListsTab.tsx +++ b/apps/web/src/components/panels/saved/SavedListsTab.tsx @@ -8,7 +8,6 @@ import UploadFileIcon from "@mui/icons-material/UploadFile"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -16,7 +15,6 @@ import DialogTitle from "@mui/material/DialogTitle"; import IconButton from "@mui/material/IconButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; -import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import Typography from "@mui/material/Typography"; import type { SavedList } from "@openmapx/core"; @@ -30,6 +28,8 @@ import { import { useTranslations } from "next-intl"; import type React from "react"; import { useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; +import Menu from "@/components/ui/Menu"; import { BRAND, BRAND_LIGHT } from "@/integration-api/runtime/theme"; import { resolveListIcon } from "@/lib/listIcon"; diff --git a/apps/web/src/components/panels/saved/ShareListDialog.tsx b/apps/web/src/components/panels/saved/ShareListDialog.tsx index 3aad44a35..638e86bd4 100644 --- a/apps/web/src/components/panels/saved/ShareListDialog.tsx +++ b/apps/web/src/components/panels/saved/ShareListDialog.tsx @@ -4,7 +4,6 @@ import AutorenewIcon from "@mui/icons-material/Autorenew"; import DeleteIcon from "@mui/icons-material/Delete"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -19,6 +18,7 @@ import { useCreateShare, useRevokeShare, useRotateShare, useShares } from "@open import { useTranslations } from "next-intl"; import { useState } from "react"; import { ShareLinkCreated } from "@/components/share/ShareLinkCreated"; +import Dialog from "@/components/ui/Dialog"; const EXPIRY_OPTIONS = [ { value: 0, key: "expiryNever" }, diff --git a/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx b/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx index 9aafc74f2..ddbfb5ed0 100644 --- a/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx +++ b/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx @@ -1,12 +1,14 @@ import { act, render } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetBackNavigationForTests } from "@/lib/backNavigation"; import { getMapObstructionInsets, publishMapObstruction, subscribeMapObstructions, } from "@/lib/mapObstructions"; import { publishMobilePanelHeight } from "@/lib/mobilePanelHeight"; -import { PLACE_DETENTS } from "./detents"; +import { settleHistory } from "@/test/history"; +import { detentIndex, PLACE_DETENTS } from "./detents"; import { isHostKeyDown, keyboardDetent, MobileBottomSheet } from "./MobileBottomSheet"; vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); @@ -356,3 +358,51 @@ describe("controlled transition interruption", () => { expect(onChange).toHaveBeenCalledWith("full"); }); }); + +describe("MobileBottomSheet and Back", () => { + function expand(host: HTMLElement) { + act(() => + host.dispatchEvent( + new CustomEvent("snap-position-change", { + detail: { snapIndex: detentIndex(PLACE_DETENTS).full, sheetState: "expanded" }, + }), + ), + ); + } + + it("lowers a fully expanded sheet to its middle detent", async () => { + vi.stubGlobal("matchMedia", () => ({ matches: false })); + window.history.replaceState(null, "", "/"); + resetBackNavigationForTests(); + const { container } = render( + +
content
+
, + ); + const host = container.querySelector("bottom-sheet") as HTMLElement & { + snapToPoint: ReturnType; + }; + host.snapToPoint = vi.fn(); + expand(host); + expect(window.history.state?.omx?.depth).toBe(1); + + window.history.back(); + await act(settleHistory); + + expect(host.snapToPoint).toHaveBeenLastCalledWith(detentIndex(PLACE_DETENTS).mid, { + behavior: "smooth", + }); + }); + + it("adds no entry while another sheet covers it", () => { + window.history.replaceState(null, "", "/"); + resetBackNavigationForTests(); + const { container } = render( + +
content
+
, + ); + expand(container.querySelector("bottom-sheet") as HTMLElement); + expect(window.history.state?.omx?.depth ?? 0).toBe(0); + }); +}); diff --git a/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx b/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx index 751f8e1c3..ea4713303 100644 --- a/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx +++ b/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx @@ -24,6 +24,7 @@ import { publishMobilePanelHeight, useMobilePanelFollowCap, } from "@/lib/mobilePanelHeight"; +import { useBackDismiss } from "@/lib/useBackDismiss"; import { useVisualViewport } from "@/lib/useVisualViewport"; import { SHEET_PART_STYLES, sheetChromeVars } from "./chrome"; import { type Detent, type DetentConfig, detentIndex, snapSlots } from "./detents"; @@ -217,6 +218,12 @@ export function MobileBottomSheet({ [host, detents], ); + // An expanded sheet reads as its own screen on a phone, so Back lowers it + // first; the next Back then closes whatever the sheet shows. + useBackDismiss(state.detent === "full" && !obscured, () => + snapTo(detents.mid != null ? "mid" : "peek"), + ); + const settleControlledTransition = useCallback(() => { const requested = pendingDetentRef.current; pendingDetentRef.current = null; diff --git a/apps/web/src/components/pwa/InstallEntry.tsx b/apps/web/src/components/pwa/InstallEntry.tsx index a5712e994..241fa2a37 100644 --- a/apps/web/src/components/pwa/InstallEntry.tsx +++ b/apps/web/src/components/pwa/InstallEntry.tsx @@ -3,7 +3,6 @@ import GetAppIcon from "@mui/icons-material/GetApp"; import IosShareIcon from "@mui/icons-material/IosShare"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -12,6 +11,7 @@ import ListItemButton from "@mui/material/ListItemButton"; import ListItemIcon from "@mui/material/ListItemIcon"; import ListItemText from "@mui/material/ListItemText"; import { useTranslations } from "next-intl"; +import Dialog from "@/components/ui/Dialog"; import { useInstallPrompt } from "./useInstallPrompt"; interface InstallEntryProps { diff --git a/apps/web/src/components/search/CategoryFilterBar.tsx b/apps/web/src/components/search/CategoryFilterBar.tsx index 4e323fce0..616b5b916 100644 --- a/apps/web/src/components/search/CategoryFilterBar.tsx +++ b/apps/web/src/components/search/CategoryFilterBar.tsx @@ -9,7 +9,6 @@ import Button from "@mui/material/Button"; import Chip from "@mui/material/Chip"; import Divider from "@mui/material/Divider"; import Paper from "@mui/material/Paper"; -import Popover from "@mui/material/Popover"; import Radio from "@mui/material/Radio"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTheme } from "@mui/material/styles"; @@ -33,6 +32,7 @@ import { } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useMemo, useState } from "react"; +import Popover from "@/components/ui/Popover"; import { BRAND } from "@/integration-api/runtime/theme"; import { useMeasuredMapObstruction } from "@/lib/mapObstructions"; import { CategoryFiltersPanel } from "./CategoryFiltersPanel"; diff --git a/apps/web/src/components/search/CategoryFiltersPanel.tsx b/apps/web/src/components/search/CategoryFiltersPanel.tsx index 744106424..17fdd8b74 100644 --- a/apps/web/src/components/search/CategoryFiltersPanel.tsx +++ b/apps/web/src/components/search/CategoryFiltersPanel.tsx @@ -7,11 +7,11 @@ import Chip from "@mui/material/Chip"; import Divider from "@mui/material/Divider"; import FormControlLabel from "@mui/material/FormControlLabel"; import Paper from "@mui/material/Paper"; -import Popover from "@mui/material/Popover"; import Typography from "@mui/material/Typography"; import type { CategoryFacet } from "@openmapx/core"; import { useCategoryFacetStore } from "@openmapx/core"; import { useTranslations } from "next-intl"; +import Popover from "@/components/ui/Popover"; import { BRAND } from "@/integration-api/runtime/theme"; function prettifyCuisine(value: string): string { diff --git a/apps/web/src/components/search/SearchBar.tsx b/apps/web/src/components/search/SearchBar.tsx index 2dae6d465..4ca3866d3 100644 --- a/apps/web/src/components/search/SearchBar.tsx +++ b/apps/web/src/components/search/SearchBar.tsx @@ -96,6 +96,7 @@ import { import { SEARCH_BAR_HEIGHT, SEARCH_BAR_INSET } from "@/lib/layout"; import { useMeasuredMapObstruction } from "@/lib/mapObstructions"; import { servedAttributions } from "@/lib/servedAttributions"; +import { useBackDismiss } from "@/lib/useBackDismiss"; import { useHydrated } from "@/lib/useHydrated"; import { useRecentSearchStore } from "@/stores/recentSearchStore"; import { @@ -552,6 +553,14 @@ export function SearchBar({ surface = "map" }: SearchBarProps) { // just a different layout. const fullScreen = isMobile && isFocused; + const handleBack = () => { + cancelSubmission(); + setIsFocused(false); + inputRef.current?.blur(); + }; + // The full-screen search is a screen of its own on a phone; Back leaves it. + useBackDismiss(fullScreen, handleBack); + // Desktop keeps the bar inside the rail's column, which is already registered // as an obstruction; a full-screen search covers the map entirely, and // framing against that would leave nothing to aim at. The street-level viewer @@ -972,12 +981,6 @@ export function SearchBar({ surface = "map" }: SearchBarProps) { !showDropdown && !syntheticResult; - const handleBack = () => { - cancelSubmission(); - setIsFocused(false); - inputRef.current?.blur(); - }; - const handleSelectLabeledPlace = (place: LabeledPlace) => { cancelSubmission(); setQuery(place.label); diff --git a/apps/web/src/components/settings/NavigationSettingsDialog.tsx b/apps/web/src/components/settings/NavigationSettingsDialog.tsx index 226a10228..24f5fc60f 100644 --- a/apps/web/src/components/settings/NavigationSettingsDialog.tsx +++ b/apps/web/src/components/settings/NavigationSettingsDialog.tsx @@ -2,7 +2,6 @@ import CloseIcon from "@mui/icons-material/Close"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import IconButton from "@mui/material/IconButton"; @@ -16,6 +15,7 @@ import { type VoiceGuidanceTiming, } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/settings/SettingsDialog.tsx b/apps/web/src/components/settings/SettingsDialog.tsx index c98cd7b60..690d77be7 100644 --- a/apps/web/src/components/settings/SettingsDialog.tsx +++ b/apps/web/src/components/settings/SettingsDialog.tsx @@ -2,7 +2,6 @@ import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import CloseIcon from "@mui/icons-material/Close"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import IconButton from "@mui/material/IconButton"; @@ -24,6 +23,7 @@ import { } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { localeNames, locales } from "@/i18n/config"; import { mobileFullScreenDialogPaperSx, diff --git a/apps/web/src/components/settings/StorageDialog.tsx b/apps/web/src/components/settings/StorageDialog.tsx index d4aad6762..611e1d78f 100644 --- a/apps/web/src/components/settings/StorageDialog.tsx +++ b/apps/web/src/components/settings/StorageDialog.tsx @@ -2,7 +2,6 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -16,6 +15,7 @@ import Typography from "@mui/material/Typography"; import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; import { type ChangeEvent, useCallback, useEffect, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { createOfflinePackageStorage } from "@/lib/offlineAreas"; import { isStoragePersisted, diff --git a/apps/web/src/components/settings/VehiclesDialog.tsx b/apps/web/src/components/settings/VehiclesDialog.tsx index dc75e9bfb..9f5327504 100644 --- a/apps/web/src/components/settings/VehiclesDialog.tsx +++ b/apps/web/src/components/settings/VehiclesDialog.tsx @@ -6,7 +6,6 @@ import Autocomplete from "@mui/material/Autocomplete"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import IconButton from "@mui/material/IconButton"; @@ -35,6 +34,7 @@ import { import { getVehiclePreset, listVehicles } from "@openmapx/ev-charge-planner"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { mobileFullScreenDialogPaperSx, useFullScreenOnMobile, diff --git a/apps/web/src/components/ui/AttributionDetails.tsx b/apps/web/src/components/ui/AttributionDetails.tsx index 2c8fa49de..9225ad02b 100644 --- a/apps/web/src/components/ui/AttributionDetails.tsx +++ b/apps/web/src/components/ui/AttributionDetails.tsx @@ -4,7 +4,6 @@ import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import ButtonBase from "@mui/material/ButtonBase"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -12,6 +11,7 @@ import type { AttributionDetail } from "@openmapx/core/attribution"; import type { AttributionLogo } from "@openmapx/mobility-core/licensing"; import { useTranslations } from "next-intl"; import { useId, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; import { AttributionEntry } from "./AttributionEntry"; import { AttributionLogos } from "./AttributionNotices"; diff --git a/apps/web/src/components/ui/ClockTimeField.tsx b/apps/web/src/components/ui/ClockTimeField.tsx index fcdc03a78..3f6507c6e 100644 --- a/apps/web/src/components/ui/ClockTimeField.tsx +++ b/apps/web/src/components/ui/ClockTimeField.tsx @@ -5,7 +5,6 @@ import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import ButtonBase from "@mui/material/ButtonBase"; -import Dialog from "@mui/material/Dialog"; import IconButton from "@mui/material/IconButton"; import InputAdornment from "@mui/material/InputAdornment"; import InputBase from "@mui/material/InputBase"; @@ -15,6 +14,7 @@ import Typography from "@mui/material/Typography"; import { type TimeFormat, useSettingsStore } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; import { type KeyboardEvent, type PointerEvent, useId, useRef, useState } from "react"; +import Dialog from "@/components/ui/Dialog"; /** Whether times should show AM/PM: the user's time-format setting, else the locale's own convention. */ export function usesTwelveHourClock(locale: string, timeFormat: TimeFormat | undefined): boolean { diff --git a/apps/web/src/components/ui/Dialog.test.tsx b/apps/web/src/components/ui/Dialog.test.tsx new file mode 100644 index 000000000..91a9aa42d --- /dev/null +++ b/apps/web/src/components/ui/Dialog.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom + +import { act, render, waitFor } from "@testing-library/react"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resetBackNavigationForTests } from "@/lib/backNavigation"; +import { settleHistory } from "@/test/history"; +import Dialog from "./Dialog"; + +function depth(): number { + return window.history.state?.omx?.depth ?? 0; +} + +async function settled(): Promise { + await act(settleHistory); +} + +function Harness({ onClose }: { onClose?: (reason: string) => void }) { + const [open, setOpen] = useState(true); + return ( + <> + + { + onClose?.(reason); + setOpen(false); + }} + > +

content

+
+ + ); +} + +beforeEach(() => { + window.history.replaceState(null, "", "/"); + resetBackNavigationForTests(); +}); + +describe("Dialog", () => { + it("closes on Back the way Escape closes it", async () => { + const onClose = vi.fn(); + const view = render(); + expect(depth()).toBe(1); + + await act(async () => { + window.history.back(); + }); + await settled(); + + expect(onClose).toHaveBeenCalledWith("escapeKeyDown"); + await waitFor(() => expect(view.queryByText("content")).toBeNull()); + expect(depth()).toBe(0); + }); + + it("leaves history alone when closed by its own controls, and the next Back steps past it", async () => { + window.history.pushState(null, "", "/elsewhere"); + window.history.pushState(null, "", "/"); + resetBackNavigationForTests(); + const view = render(); + expect(depth()).toBe(1); + + act(() => view.getByText("done").click()); + await settled(); + // No traversal of its own: one could cancel a page navigation in flight. + expect(depth()).toBe(1); + + window.history.back(); + await settled(); + expect(window.location.pathname).toBe("/elsewhere"); + }); + + it("adds no entry for a dialog nobody can close", () => { + render( + +

content

+
, + ); + expect(depth()).toBe(0); + }); +}); diff --git a/apps/web/src/components/ui/Dialog.tsx b/apps/web/src/components/ui/Dialog.tsx new file mode 100644 index 000000000..29f15bdea --- /dev/null +++ b/apps/web/src/components/ui/Dialog.tsx @@ -0,0 +1,17 @@ +"use client"; + +import MuiDialog, { type DialogProps } from "@mui/material/Dialog"; +import { useBackDismiss } from "@/lib/useBackDismiss"; + +export type { DialogProps }; + +/** + * MUI's Dialog, closed by the system Back gesture the way Escape closes it. + * Use it instead of `@mui/material/Dialog` (lint enforces this): on a phone, + * Back is how people dismiss a dialog, and without it Back leaves the app. + */ +export default function Dialog(props: DialogProps) { + const { open, onClose } = props; + useBackDismiss(open && Boolean(onClose), () => onClose?.({}, "escapeKeyDown")); + return ; +} diff --git a/apps/web/src/components/ui/Menu.tsx b/apps/web/src/components/ui/Menu.tsx new file mode 100644 index 000000000..ccbc375e2 --- /dev/null +++ b/apps/web/src/components/ui/Menu.tsx @@ -0,0 +1,13 @@ +"use client"; + +import MuiMenu, { type MenuProps } from "@mui/material/Menu"; +import { useBackDismiss } from "@/lib/useBackDismiss"; + +export type { MenuProps }; + +/** MUI's Menu, closed by the system Back gesture the way Escape closes it. See `./Dialog`. */ +export default function Menu(props: MenuProps) { + const { open, onClose } = props; + useBackDismiss(open && Boolean(onClose), () => onClose?.({}, "escapeKeyDown")); + return ; +} diff --git a/apps/web/src/components/ui/NlpConsentDialog.tsx b/apps/web/src/components/ui/NlpConsentDialog.tsx index 759b47ba4..d2dfa4842 100644 --- a/apps/web/src/components/ui/NlpConsentDialog.tsx +++ b/apps/web/src/components/ui/NlpConsentDialog.tsx @@ -1,12 +1,12 @@ "use client"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; import { useTranslations } from "next-intl"; +import Dialog from "@/components/ui/Dialog"; interface NlpConsentDialogProps { open: boolean; diff --git a/apps/web/src/components/ui/Popover.tsx b/apps/web/src/components/ui/Popover.tsx new file mode 100644 index 000000000..baf60254d --- /dev/null +++ b/apps/web/src/components/ui/Popover.tsx @@ -0,0 +1,13 @@ +"use client"; + +import MuiPopover, { type PopoverActions, type PopoverProps } from "@mui/material/Popover"; +import { useBackDismiss } from "@/lib/useBackDismiss"; + +export type { PopoverActions, PopoverProps }; + +/** MUI's Popover, closed by the system Back gesture the way Escape closes it. See `./Dialog`. */ +export default function Popover(props: PopoverProps) { + const { open, onClose } = props; + useBackDismiss(open && Boolean(onClose), () => onClose?.({}, "escapeKeyDown")); + return ; +} diff --git a/apps/web/src/integration-api/components/Dialog.tsx b/apps/web/src/integration-api/components/Dialog.tsx new file mode 100644 index 000000000..80542d275 --- /dev/null +++ b/apps/web/src/integration-api/components/Dialog.tsx @@ -0,0 +1,5 @@ +/** + * The host's Dialog: MUI's Dialog that the system Back gesture closes like + * Escape. Integrations use it instead of `@mui/material/Dialog`. + */ +export { type DialogProps, default } from "@/components/ui/Dialog"; diff --git a/apps/web/src/integration-api/components/TrafficInfo.tsx b/apps/web/src/integration-api/components/TrafficInfo.tsx index 1e1b50d57..dff34d32e 100644 --- a/apps/web/src/integration-api/components/TrafficInfo.tsx +++ b/apps/web/src/integration-api/components/TrafficInfo.tsx @@ -4,10 +4,10 @@ import CloseIcon from "@mui/icons-material/Close"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; -import Popover from "@mui/material/Popover"; import Typography from "@mui/material/Typography"; import { useTranslations } from "next-intl"; import { type ReactNode, useId, useState } from "react"; +import Popover from "@/components/ui/Popover"; /** Optional traffic explanation; never nested inside route selection controls. */ export function TrafficInfo({ children }: { children: ReactNode }) { diff --git a/apps/web/src/lib/backNavigation.test.ts b/apps/web/src/lib/backNavigation.test.ts new file mode 100644 index 000000000..356d448b3 --- /dev/null +++ b/apps/web/src/lib/backNavigation.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { settleHistory } from "@/test/history"; +import { + historyStep, + type NavigationPath, + openBackLayer, + resetBackNavigationForTests, + subscribeHistoryRestore, + writeNavigation, +} from "./backNavigation"; + +/** `?p=a/b` stands in for the open surfaces DeepLinkManager encodes. */ +function pathOf(search: string): string[] { + const panel = new URLSearchParams(search).get("p"); + return panel ? panel.split("/") : []; +} + +/** A stand-in app: what it shows follows its own writes and the entries Back lands on. */ +let shown: string[] = []; +/** What each restore reported: whether the landed entry changed what is shown. */ +let restores: boolean[] = []; + +function navigate(url: string): void { + shown = pathOf(new URL(url, window.location.origin).search); + writeNavigation(() => ({ + url, + path: pathOf(new URL(url, window.location.origin).search), + currentPath: pathOf(window.location.search), + })); +} + +function mountApp(): void { + subscribeHistoryRestore(() => { + const target = pathOf(window.location.search); + const changed = target.join("/") !== shown.join("/"); + shown = target; + restores.push(changed); + return changed; + }); +} + +function location(): string { + return `${window.location.pathname}${window.location.search}`; +} + +function depth(): number { + return window.history.state?.omx?.depth ?? 0; +} + +async function back(): Promise { + window.history.back(); + await settleHistory(); +} + +beforeEach(() => { + // Something outside the app sits below it, to see a Back leave the app. + window.history.pushState(null, "", "/elsewhere"); + window.history.pushState(null, "", "/"); + resetBackNavigationForTests(); + shown = []; + restores = []; +}); + +afterEach(async () => { + await settleHistory(); + resetBackNavigationForTests(); +}); + +describe("historyStep", () => { + const cases: Array<[string, NavigationPath, NavigationPath, string]> = [ + ["unchanged surfaces", ["category:a"], ["category:a"], "replace"], + ["opening the first surface", [], ["place:x"], "push"], + ["drilling into a list", ["category:a"], ["category:a", "place:x"], "push"], + ["closing the innermost surface", ["category:a", "place:x"], ["category:a"], "replace"], + ["closing everything", ["category:a", "place:x"], [], "replace"], + [ + "switching to a sibling place", + ["category:a", "place:x"], + ["category:a", "place:y"], + "replace", + ], + ["switching category chips", ["category:a"], ["category:b"], "replace"], + ["opening a different panel", ["category:a", "place:x"], ["saved"], "push"], + ["starting a tool", ["place:x"], ["place:x", "tool:measure"], "push"], + ]; + + it.each(cases)("%s", (_name, current, next, expected) => { + expect(historyStep(current, next)).toBe(expected); + }); +}); + +describe("writeNavigation", () => { + it("pushes a surface the user opens and lets Back close it", async () => { + mountApp(); + navigate("/?map=1"); + navigate("/?map=1&p=place:x"); + expect(location()).toBe("/?map=1&p=place:x"); + expect(depth()).toBe(1); + + await back(); + expect(location()).toBe("/?map=1"); + expect(restores).toEqual([true]); + expect(shown).toEqual([]); + }); + + it("rewrites the current entry for camera changes", () => { + navigate("/?map=1&p=place:x"); + const length = window.history.length; + navigate("/?map=2&p=place:x"); + expect(window.history.length).toBe(length); + expect(location()).toBe("/?map=2&p=place:x"); + }); + + it("rewrites the entry when a surface is closed, so Back does not reopen it", async () => { + mountApp(); + navigate("/?map=1"); + navigate("/?map=1&p=place:x"); + navigate("/?map=1"); + expect(depth()).toBe(1); + + // Nothing is left to close: one Back leaves the app. + await back(); + expect(location()).toBe("/elsewhere"); + }); + + it("reuses a closed surface's entry for the next one instead of stacking", () => { + navigate("/?map=1"); + navigate("/?map=1&p=place:x"); + navigate("/?map=1"); + navigate("/?map=1&p=place:y"); + expect(depth()).toBe(1); + }); + + it("does not take over an entry an earlier page load wrote", () => { + window.history.replaceState({ omx: { depth: 1, below: [], doc: "earlier-load" } }, "", "/"); + resetBackNavigationForTests(); + navigate("/?p=place:x"); + expect(depth()).toBe(2); + }); +}); + +describe("openBackLayer", () => { + it("lets Back dismiss the layer without leaving the page", async () => { + mountApp(); + navigate("/?map=1"); + const onBack = vi.fn(); + openBackLayer(onBack); + expect(depth()).toBe(1); + expect(location()).toBe("/?map=1"); + + await back(); + expect(onBack).toHaveBeenCalledTimes(1); + expect(location()).toBe("/?map=1"); + }); + + it("never traverses history when its owner closes it, so a link's navigation survives", async () => { + navigate("/?map=1"); + const menu = openBackLayer(vi.fn()); + // The menu item closes the menu and the router pushes its page. + menu.release(); + window.history.pushState(null, "", "/settings/offline"); + await settleHistory(); + expect(location()).toBe("/settings/offline"); + }); + + it("makes the Back after a self-closed layer close what lies beneath", async () => { + mountApp(); + navigate("/?map=1"); + navigate("/?map=1&p=place:x"); + openBackLayer(vi.fn()).release(); + expect(depth()).toBe(2); + + await back(); + expect(shown).toEqual([]); + expect(location()).toBe("/?map=1"); + }); + + it("reuses a closed layer's entry for the next layer", () => { + openBackLayer(vi.fn()).release(); + openBackLayer(vi.fn()); + expect(depth()).toBe(1); + }); + + it("steps over a closed layer's entry when coming back from another page", async () => { + mountApp(); + navigate("/?map=1"); + const menu = openBackLayer(vi.fn()); + window.history.pushState(null, "", "/settings"); + menu.release(); + + await back(); + expect(location()).toBe("/?map=1"); + expect(depth()).toBe(0); + }); + + it("closes layers a Back jumped over together with the navigation", async () => { + navigate("/?map=1"); + const lower = vi.fn(); + const upper = vi.fn(); + openBackLayer(lower); + openBackLayer(upper); + window.history.go(-2); + await settleHistory(); + expect(upper).toHaveBeenCalledTimes(1); + expect(lower).toHaveBeenCalledTimes(1); + }); + + it("puts a surface opened while a layer is live above it", async () => { + mountApp(); + navigate("/?map=1"); + const onBack = vi.fn(); + openBackLayer(onBack); + navigate("/?map=1&p=place:x"); + expect(depth()).toBe(2); + + await back(); + expect(shown).toEqual([]); + expect(onBack).not.toHaveBeenCalled(); + await back(); + expect(onBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/lib/backNavigation.ts b/apps/web/src/lib/backNavigation.ts new file mode 100644 index 000000000..82c48d0e0 --- /dev/null +++ b/apps/web/src/lib/backNavigation.ts @@ -0,0 +1,345 @@ +/** + * The app's single owner of browser history. + * + * Mobile users expect the system Back gesture to step out of whatever they + * opened — a sheet, a dialog, a place card — before it leaves the app. That + * only works if opening those surfaces adds history entries. Two kinds are + * written here: + * - navigation entries, for the URL-encoded app state (`writeNavigation`); + * - layer entries, the same URL again, for a transient surface that Back + * should dismiss without changing that state (`openBackLayer`). + * + * Closing something never traverses history itself. A `history.back()` from + * app code is a popstate like any other to the Next.js router, and it cancels + * a route navigation in flight — a drawer link that closes the drawer and + * opens another page would silently stay on the map. So a closed surface + * leaves its entry where it is: a closed layer's entry is marked dead, a + * closed panel's entry is rewritten to what is open now. A later Back that + * lands somewhere showing exactly what was already shown keeps going, and the + * next surface to open reuses such an entry instead of stacking on it. + * + * Each entry carries `omx` metadata in `history.state`, beside Next.js's own + * keys, which are always spread through unchanged. + */ + +/** Ordered surfaces the URL has open, outermost first. See `navigationPath` in deepLink. */ +export type NavigationPath = readonly string[]; + +interface EntryMeta { + /** Entries this app pushed beneath this one. 0 for an entry it never pushed. */ + depth: number; + /** The navigation path of the entry right below, for a navigation entry this module pushed. */ + below?: NavigationPath; + /** The layer this entry was pushed for. */ + layer?: string; + /** The page load that wrote this entry; layer ids and reuse only mean something within it. */ + doc?: string; +} + +interface Layer { + /** Depth of the layer's own entry; null until its push has actually run. */ + depth: number | null; + onBack: () => void; + /** Its owner closed it; its entry, if any, is left for Back to step over. */ + dead: boolean; +} + +export interface BackLayerHandle { + /** The owner closed the layer itself. */ + release(): void; +} + +export interface NavigationWrite { + url: string; + path: NavigationPath; + /** Path of the URL currently in the address bar. */ + currentPath: NavigationPath; +} + +export type HistoryStep = "replace" | "push"; + +/** + * Applies the entry a Back/Forward landed on. Returns whether that changed + * what is open; Back keeps going while nothing changes. + */ +export type HistoryRestoreListener = () => boolean; + +const STATE_KEY = "omx"; +/** A step-over whose popstate never arrives must not wedge every later write. */ +const POP_TIMEOUT_MS = 1000; +const DOCUMENT_ID = Math.random().toString(36).slice(2); + +const layers = new Map(); +const restoreListeners = new Set(); +let queue: Array<() => void> = []; +/** + * A step over a leftover entry, in flight. `unchanged` keeps stepping until + * something changes; `stale` only clears a leftover layer entry reached from + * another page, whose map is still mounting and cannot say what changed. + */ +let pending: { reason: "unchanged" | "stale"; timer: ReturnType } | null = null; +/** The entry last seen current: the one a traversal leaves. */ +let last: { meta: EntryMeta; pathname: string } | null = null; +let layerSeq = 0; +let listening = false; + +export function samePath(a: NavigationPath, b: NavigationPath): boolean { + return a.length === b.length && a.every((segment, index) => segment === b[index]); +} + +/** Whether `prefix` is `path` with one or more innermost surfaces closed. */ +export function isStrictPrefix(prefix: NavigationPath, path: NavigationPath): boolean { + return prefix.length < path.length && prefix.every((segment, index) => segment === path[index]); +} + +function segmentKind(segment: string): string { + const colon = segment.indexOf(":"); + return colon === -1 ? segment : segment.slice(0, colon); +} + +/** + * How a change of navigation path is recorded. + * + * Opening something deeper or different is a new entry. Closing rewrites the + * current one, so a later Back cannot reopen what the user just closed. + * Switching between siblings (another place in the same list, another + * category chip) rewrites too: tapping through pins on a map is browsing, not + * drilling in. + */ +export function historyStep(current: NavigationPath, next: NavigationPath): HistoryStep { + if (samePath(current, next) || isStrictPrefix(next, current)) return "replace"; + const lastIndex = current.length - 1; + if ( + current.length > 0 && + current.length === next.length && + samePath(current.slice(0, lastIndex), next.slice(0, lastIndex)) && + segmentKind(current[lastIndex]) === segmentKind(next[lastIndex]) + ) { + return "replace"; + } + return "push"; +} + +function readMeta(state: unknown = window.history.state): EntryMeta { + const meta = (state as Record | null)?.[STATE_KEY]; + if (!meta || typeof meta !== "object") return { depth: 0 }; + const { depth, below, layer, doc } = meta as Partial; + return { + depth: typeof depth === "number" && Number.isFinite(depth) ? depth : 0, + ...(Array.isArray(below) ? { below: below.filter((s) => typeof s === "string") } : {}), + ...(typeof layer === "string" ? { layer } : {}), + ...(typeof doc === "string" ? { doc } : {}), + }; +} + +function currentUrl(): string { + return `${window.location.pathname}${window.location.search}${window.location.hash}`; +} + +function remember(meta: EntryMeta): void { + last = { meta, pathname: window.location.pathname }; +} + +function writeEntry(mode: "push" | "replace", meta: EntryMeta, url: string): void { + // Spreading the current state keeps Next.js's router keys; its patched + // history methods then pass the call straight through. + const state = { ...(window.history.state ?? {}), [STATE_KEY]: meta }; + if (mode === "push") window.history.pushState(state, "", url); + else window.history.replaceState(state, "", url); + remember(meta); +} + +function isLive(id: string | undefined): boolean { + const layer = id ? layers.get(id) : undefined; + return Boolean(layer && !layer.dead); +} + +/** + * An entry that shows nothing the entry below does not: a closed layer's, or + * a panel entry rewritten after its panel closed. The next surface to open + * takes it over instead of stacking another entry on top. + */ +function isRedundant(meta: EntryMeta, currentPath: NavigationPath | null): boolean { + if (meta.doc !== DOCUMENT_ID || meta.depth === 0) return false; + if (meta.layer) return !isLive(meta.layer); + return Boolean(meta.below && currentPath && samePath(meta.below, currentPath)); +} + +/** Anything at or above this depth was in forward history, which a write there discarded. */ +function pruneFrom(depth: number): void { + for (const [id, layer] of layers) { + if (layer.depth !== null && layer.depth >= depth) layers.delete(id); + } +} + +function run(op: () => void): void { + if (pending) queue.push(op); + else op(); +} + +function drain(): void { + while (!pending && queue.length > 0) queue.shift()?.(); +} + +function stepOver(reason: "unchanged" | "stale"): void { + pending = { reason, timer: setTimeout(settleTimeout, POP_TIMEOUT_MS) }; + window.history.back(); +} + +function settleTimeout(): void { + pending = null; + remember(readMeta()); + drain(); +} + +function notifyRestore(): boolean { + let changed = false; + for (const listener of [...restoreListeners]) changed = listener() || changed; + return changed; +} + +function closeLayersAbove(depth: number): boolean { + let closed = false; + const above = [...layers].filter(([, layer]) => layer.depth !== null && layer.depth > depth); + above.sort(([, a], [, b]) => (b.depth ?? 0) - (a.depth ?? 0)); + for (const [id, layer] of above) { + layers.delete(id); + if (layer.dead) continue; + layer.onBack(); + closed = true; + } + return closed; +} + +function onPopState(event: PopStateEvent): void { + const meta = readMeta(event.state); + const left = last ?? { meta, pathname: window.location.pathname }; + remember(meta); + const reason = pending?.reason; + if (pending) { + clearTimeout(pending.timer); + pending = null; + } + + const back = meta.depth < left.meta.depth; + let changed = back && closeLayersAbove(meta.depth); + // On another page the router takes it from here. + const samePage = window.location.pathname === left.pathname; + if (samePage) changed = notifyRestore() || changed; + + // Back should always visibly do something. Landing on an entry that shows + // what was already shown — the one under a dialog closed by its own button, + // or under a panel entry rewritten when the panel closed — means this Back + // only crossed leftovers, so it keeps going. + const leftOurs = left.meta.doc === DOCUMENT_ID; + if (!changed && samePage && back && leftOurs && reason !== "stale") { + stepOver("unchanged"); + return; + } + // A leftover layer entry reached from another page (whose own entry this + // module never saw) is stepped over too, unless this was a Forward. + const staleLayer = meta.layer !== undefined && !isLive(meta.layer); + if (!changed && staleLayer && meta.depth <= left.meta.depth) { + stepOver("stale"); + return; + } + drain(); +} + +function ensureListening(): void { + if (listening || typeof window === "undefined") return; + listening = true; + remember(readMeta()); + window.addEventListener("popstate", onPopState); +} + +/** + * Records the app's URL state. `build` runs when the write actually happens — + * possibly after a step-over in flight lands — so it must read state then. + */ +export function writeNavigation(build: () => NavigationWrite): void { + if (typeof window === "undefined") return; + ensureListening(); + run(() => { + const { url, path, currentPath } = build(); + const meta = readMeta(); + + // A live layer is on top: URL changes underneath it are recorded on its + // entry, and a newly opened surface goes above it, so Back closes that + // first and the layer after. + const step = isLive(meta.layer) + ? samePath(currentPath, path) + ? "replace" + : "push" + : historyStep(currentPath, path); + + if (step === "push") { + const reuse = isRedundant(meta, currentPath); + const depth = reuse ? meta.depth : meta.depth + 1; + pruneFrom(depth); + const entry = { depth, below: currentPath, doc: DOCUMENT_ID }; + writeEntry(reuse ? "replace" : "push", entry, url); + return; + } + if (url !== currentUrl() || !window.history.state?.[STATE_KEY]) { + writeEntry("replace", meta, url); + } + }); +} + +/** + * Gives a transient surface its own history entry; Back then calls `onBack` + * instead of leaving the page. `onBack` should close the surface the same way + * its own close control does. + */ +export function openBackLayer(onBack: () => void): BackLayerHandle { + ensureListening(); + layerSeq += 1; + const id = `layer-${layerSeq}`; + const layer: Layer = { depth: null, onBack, dead: false }; + layers.set(id, layer); + run(() => { + if (layer.dead) { + layers.delete(id); + return; + } + const meta = readMeta(); + // Only a closed layer's entry is taken over here: a layer has no + // navigation path to judge a panel entry by. + const reuse = meta.layer !== undefined && isRedundant(meta, null); + const depth = reuse ? meta.depth : meta.depth + 1; + pruneFrom(depth); + layer.depth = depth; + writeEntry(reuse ? "replace" : "push", { depth, layer: id, doc: DOCUMENT_ID }, currentUrl()); + }); + return { + release: () => { + layer.dead = true; + }, + }; +} + +/** Called when Back/Forward lands on an entry of the current page. */ +export function subscribeHistoryRestore(listener: HistoryRestoreListener): () => void { + ensureListening(); + restoreListeners.add(listener); + return () => { + restoreListeners.delete(listener); + }; +} + +/** Test seam: whether a step-over is still in flight. */ +export function isBackNavigationIdle(): boolean { + return !pending; +} + +/** Test seam: forget all in-memory bookkeeping. */ +export function resetBackNavigationForTests(): void { + if (pending) clearTimeout(pending.timer); + pending = null; + queue = []; + layers.clear(); + restoreListeners.clear(); + last = null; + if (typeof window !== "undefined" && listening) remember(readMeta()); +} diff --git a/apps/web/src/lib/deepLink.test.ts b/apps/web/src/lib/deepLink.test.ts index 74ef6fd64..155bff3da 100644 --- a/apps/web/src/lib/deepLink.test.ts +++ b/apps/web/src/lib/deepLink.test.ts @@ -7,6 +7,7 @@ import { formatCameraParam, formatLabeledPoint, formatScheduleParam, + navigationPath, paramsWithoutDeepLink, parseCameraParam, parseDeepLinkSearch, @@ -304,3 +305,38 @@ describe("schedule deep-link param", () => { expect(parsed.directions?.schedules).toEqual({ 0: { arriveBy: "2026-09-02T14:00" } }); }); }); + +describe("navigationPath", () => { + it.each<[string, string, string[]]>([ + ["the bare map", "?map=50,6,12", []], + ["a standalone place", "?map=50,6,12&panel=place&place=n1&at=50,6&name=A", ["place:n1"]], + [ + "a place card over a category", + "?panel=category&categoryId=food&bbox=1,2,3,4&place=n1&at=50,6", + ["category:food", "place:n1"], + ], + ["a text search", "?panel=category&search=pizza&bbox=1,2,3,4", ["search:pizza"]], + ["a saved list", "?panel=saved&savedTab=lists&list=l1", ["saved", "list:l1"]], + [ + "a data-source item", + "?panel=datasource&source=s&item=i&bbox=1,2,3,4", + ["datasource:s", "item:i"], + ], + ["directions with waypoints", "?panel=directions&wp=50,6&wp=51,7", ["directions"]], + ["a parking panel", "?panel=parking", ["parking"]], + [ + "the measuring tool over a place", + "?place=n1&at=50,6&measure=line", + ["place:n1", "tool:measure"], + ], + ["travel time alone", "?iso=walking&isoAt=50,6", ["tool:iso"]], + ])("%s", (_name, search, expected) => { + expect(navigationPath(search)).toEqual(expected); + }); + + it("ignores camera, base layer and overlays", () => { + expect(navigationPath("?map=1,2,3&base=satellite&ov=weather&weather=radar&globe=1")).toEqual( + [], + ); + }); +}); diff --git a/apps/web/src/lib/deepLink.ts b/apps/web/src/lib/deepLink.ts index a376a1e9d..4a2f94d76 100644 --- a/apps/web/src/lib/deepLink.ts +++ b/apps/web/src/lib/deepLink.ts @@ -40,6 +40,7 @@ export const DEEPLINK_PARAMS = new Set([ "wp", "sched", "categoryId", + "search", "bbox", "source", "item", @@ -115,6 +116,11 @@ export interface ParsedDeepLink { id: string; bbox?: BoundingBox; }; + /** A free-text search of an area (the results list a typed query opens). */ + textSearch?: { + text: string; + bbox?: BoundingBox; + }; dataSource?: { id: string; itemId?: string; @@ -399,10 +405,7 @@ export function parseLngLatListParam(value: string | null): LngLat[] { } export function paramsWithoutDeepLink(search: string | URLSearchParams): URLSearchParams { - const params = - typeof search === "string" - ? new URLSearchParams(search.startsWith("?") ? search.slice(1) : search) - : new URLSearchParams(search.toString()); + const params = toSearchParams(search); for (const key of DEEPLINK_PARAMS) { params.delete(key); @@ -411,6 +414,46 @@ export function paramsWithoutDeepLink(search: string | URLSearchParams): URLSear return params; } +function toSearchParams(search: string | URLSearchParams): URLSearchParams { + return typeof search === "string" + ? new URLSearchParams(search.startsWith("?") ? search.slice(1) : search) + : new URLSearchParams(search.toString()); +} + +/** + * The surfaces a deep link has open, outermost first — what the Back gesture + * steps through, one entry per level. The camera, base layer and overlays are + * deliberately not part of it: panning the map or toggling a layer is not a + * place to go back to. + * + * Segments are `kind` or `kind:identity`; two paths that differ only in the + * identity of their last segment are siblings (another place in the same list). + */ +export function navigationPath(search: string | URLSearchParams): string[] { + const params = toSearchParams(search); + const panel = params.get("panel"); + const categoryId = params.get("categoryId"); + const text = params.get("search"); + const source = params.get("source"); + const path: string[] = []; + + if (categoryId) path.push(`category:${categoryId}`); + else if (text) path.push(`search:${text}`); + else if (source) path.push(`datasource:${source}`); + else if (panel && panel !== PANEL.PLACE && panel !== PANEL.PLACE_CARD) path.push(panel); + + const list = params.get("list"); + if (panel === PANEL.SAVED && list) path.push(`list:${list}`); + const item = params.get("item"); + if (source && item) path.push(`item:${item}`); + const place = params.get("place"); + if (place && params.has("at")) path.push(`place:${place}`); + + if (params.has("measure")) path.push("tool:measure"); + if (params.has("iso")) path.push("tool:iso"); + return path; +} + function hasAnyDeepLinkParam(params: URLSearchParams): boolean { for (const key of DEEPLINK_PARAMS) { if (params.has(key)) return true; @@ -419,10 +462,7 @@ function hasAnyDeepLinkParam(params: URLSearchParams): boolean { } export function parseDeepLinkSearch(search: string | URLSearchParams): ParsedDeepLink { - const params = - typeof search === "string" - ? new URLSearchParams(search.startsWith("?") ? search.slice(1) : search) - : new URLSearchParams(search.toString()); + const params = toSearchParams(search); const placeId = params.get("place"); const placeCoords = parseLngLatParam(params.get("at")); @@ -440,6 +480,7 @@ export function parseDeepLinkSearch(search: string | URLSearchParams): ParsedDee : undefined; const categoryId = params.get("categoryId"); + const searchText = params.get("search"); const sourceId = params.get("source"); const measureMode = params.get("measure"); const isoMode = params.get("iso"); @@ -484,6 +525,10 @@ export function parseDeepLinkSearch(search: string | URLSearchParams): ParsedDee category: categoryId ? { id: categoryId, bbox: parseBboxParam(params.get("bbox")) ?? undefined } : undefined, + textSearch: + !categoryId && searchText + ? { text: searchText, bbox: parseBboxParam(params.get("bbox")) ?? undefined } + : undefined, dataSource: sourceId ? { id: sourceId, diff --git a/apps/web/src/lib/useBackDismiss.ts b/apps/web/src/lib/useBackDismiss.ts new file mode 100644 index 000000000..6c737a0a9 --- /dev/null +++ b/apps/web/src/lib/useBackDismiss.ts @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { openBackLayer } from "./backNavigation"; + +/** + * Lets the system Back gesture dismiss a transient surface while it is open. + * + * `onBack` should close the surface exactly like its own close control. When + * the surface closes any other way, its entry stays behind and a later Back + * steps over it (see backNavigation). An owner that ignores `onBack` and stays + * open simply loses the entry: the next Back then goes past it rather than + * being trapped. + */ +export function useBackDismiss(active: boolean, onBack: () => void): void { + const onBackRef = useRef(onBack); + useEffect(() => { + onBackRef.current = onBack; + }); + + useEffect(() => { + if (!active) return; + const layer = openBackLayer(() => onBackRef.current()); + return () => layer.release(); + }, [active]); +} diff --git a/apps/web/src/test/history.ts b/apps/web/src/test/history.ts new file mode 100644 index 000000000..c126c9a8d --- /dev/null +++ b/apps/web/src/test/history.ts @@ -0,0 +1,16 @@ +import { isBackNavigationIdle } from "@/lib/backNavigation"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Waits for history to stop moving: jsdom delivers a traversal's popstate on a + * later task, and a Back that crossed only leftover entries steps on by itself. + * Uses real timers; call it with fake timers off. + */ +export async function settleHistory(): Promise { + await sleep(5); + for (let waited = 0; !isBackNavigationIdle() && waited < 1500; waited += 5) await sleep(5); + await sleep(5); +} diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index 8265f6910..04151c316 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -3,6 +3,9 @@ // @testing-library/react auto-cleans the DOM after each test when Vitest globals // are enabled, so no explicit afterEach(cleanup) is needed here. import "@testing-library/jest-dom/vitest"; +import { afterEach, vi } from "vitest"; +import { isBackNavigationIdle, resetBackNavigationForTests } from "@/lib/backNavigation"; +import { settleHistory } from "./history"; // jsdom does not implement Element.scrollIntoView; components that scroll a // highlighted item into view (autocomplete dropdowns, etc.) call it from a @@ -12,3 +15,13 @@ import "@testing-library/jest-dom/vitest"; if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } + +// Dialogs, menus, sheets and panels write history entries, and a Back that +// crossed only leftovers steps on by itself. jsdom's history lives for the +// whole file, so let such a step land before the next test, then forget the +// bookkeeping of the components this test unmounted. +afterEach(async () => { + if (typeof window === "undefined") return; + if (!vi.isFakeTimers() && !isBackNavigationIdle()) await settleHistory(); + resetBackNavigationForTests(); +}); diff --git a/apps/web/src/vitest.d.ts b/apps/web/src/vitest.d.ts index fecdfeaef..ece4da508 100644 --- a/apps/web/src/vitest.d.ts +++ b/apps/web/src/vitest.d.ts @@ -111,6 +111,7 @@ declare module "vitest" { useFakeTimers(options?: { shouldAdvanceTime?: boolean }): void; setSystemTime(now?: string | number | Date): void; useRealTimers(): void; + isFakeTimers(): boolean; advanceTimersByTime(ms: number): void; advanceTimersByTimeAsync(ms: number): Promise; waitFor( diff --git a/biome.json b/biome.json index f3e2ccabb..e70ccebc1 100644 --- a/biome.json +++ b/biome.json @@ -96,6 +96,37 @@ } } }, + { + "includes": [ + "apps/web/src/**", + "integrations/**", + "!apps/web/src/components/ui/Dialog.tsx", + "!apps/web/src/components/ui/Menu.tsx", + "!apps/web/src/components/ui/Popover.tsx", + "!**/*.test.ts", + "!**/*.test.tsx" + ], + "linter": { + "rules": { + "style": { + "noRestrictedImports": { + "level": "error", + "options": { + "paths": { + "@mui/material/Dialog": "Use @/components/ui/Dialog (integrations: @/integration-api/components/Dialog), which the Back gesture closes.", + "@mui/material/Menu": "Use @/components/ui/Menu, which the Back gesture closes.", + "@mui/material/Popover": "Use @/components/ui/Popover, which the Back gesture closes.", + "@mui/material": { + "importNames": ["Dialog", "Menu", "Popover"], + "message": "Use the Back-aware wrappers in @/components/ui (Dialog, Menu, Popover)." + } + } + } + } + } + } + } + }, { "includes": ["integrations/overlay-tool-travel-time/transit-field-layer.ts"], "linter": { diff --git a/docs/docs/developer/mobile-architecture.md b/docs/docs/developer/mobile-architecture.md index e6b8c851c..7837e9de9 100644 --- a/docs/docs/developer/mobile-architecture.md +++ b/docs/docs/developer/mobile-architecture.md @@ -155,6 +155,38 @@ available, native loading and recovery UI must remain usable. Physical-device qualification must cover suspension, renderer loss, audio, alerts and recovery before offline navigation claims are published. +## Back navigation + +The system Back gesture steps out of what the user last opened, in the installed +PWA, a browser and the native shell alike. The web app records that in browser +history; `apps/web/src/lib/backNavigation.ts` is the only code that writes it. + +- Opening a panel, place, list item or map tool pushes an entry. The navigation + path that decides this (`navigationPath` in `lib/deepLink.ts`) leaves out the + camera, base layer and overlays, so panning or toggling a layer rewrites the + current entry instead. +- Closing rewrites the current entry, so a later Back cannot reopen what was + just closed. Switching between siblings, such as another place in the same + list, rewrites it too. +- App code never traverses history itself: to the Next.js router that popstate + would cancel a page navigation in flight, such as a drawer link that also + closes the drawer. A surface closed by its own control leaves its entry, and a + Back that lands on an entry showing exactly what was already shown keeps + going. The next surface to open reuses such an entry. +- Back restores only the open surfaces. The map stays where the user left it. + When Back only steps out of something, the remaining surfaces keep their + in-memory state instead of being rebuilt from the URL. +- Transient surfaces get an entry of their own through `useBackDismiss`: + dialogs, menus and popovers (use the wrappers in `@/components/ui`, which lint + requires), the drawer menu, the full-screen search, the photo gallery, the + map-click card, an expanded bottom sheet (Back lowers it to the middle detent) + and active guidance (Back ends the trip, like the End button). + +Android's hardware Back reaches that history through `useHardwareBack` in the +native shell, which calls the WebView's `goBack()` while it can go back and +otherwise leaves Back to the system. iOS uses the WebView's own +back-forward swipe. + ## What is not here There is no EAS build configuration, hosted wrapper, remote push or native OTA diff --git a/docs/docs/developer/writing-an-integration.md b/docs/docs/developer/writing-an-integration.md index a352a1b57..a5b12254e 100644 --- a/docs/docs/developer/writing-an-integration.md +++ b/docs/docs/developer/writing-an-integration.md @@ -294,6 +294,10 @@ paths. This is the trusted web host's app-local API, not an extension SDK. Installed community integrations cannot execute presentation JavaScript and cannot consume these modules; they remain declarative-only. +Dialogs use `@/integration-api/components/Dialog` instead of +`@mui/material/Dialog`, so the system Back gesture closes them on a phone; lint +rejects the direct import. + The full `frontend.overlay` schema and the community-code restriction are documented in [Integration system → Map overlays](./integration-system.md#map-overlays). diff --git a/integrations/crowd-reports/ReportDialog.tsx b/integrations/crowd-reports/ReportDialog.tsx index e0689a88e..49ca79d8b 100644 --- a/integrations/crowd-reports/ReportDialog.tsx +++ b/integrations/crowd-reports/ReportDialog.tsx @@ -20,7 +20,6 @@ import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import CircularProgress from "@mui/material/CircularProgress"; -import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; @@ -32,6 +31,7 @@ import { createSvgIcon } from "@mui/material/utils"; import { useMapStore } from "@openmapx/core"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import Dialog from "@/integration-api/components/Dialog"; import { LocationMinimap } from "@/integration-api/components/LocationMinimap"; import { mobileFullScreenDialogPaperSx,