From b4d00db22cde5a22a754ee080664b1cca2ec64c2 Mon Sep 17 00:00:00 2001 From: Florian Date: Mon, 2 Nov 2026 09:54:55 +0000 Subject: [PATCH] feat(web): make Back step out of what was opened instead of leaving the app The map page mirrored all state into the URL with replaceState only, so the history stack never grew and the system Back gesture closed the installed PWA (and the native app). Every surface now gets a history entry and Back closes the last one opened. - lib/backNavigation owns all history writes. Opening a panel, place, list item or map tool pushes an entry (navigationPath ignores camera, base layer and overlays, which keep rewriting the current entry). Closing rewrites the entry; sibling switches rewrite it too. - Back restores only the open surfaces: the map stays where the user left it, and stepping out of something closes it in place instead of rebuilding the rest from the URL. - App code never calls history.back(): to the Next.js router that popstate would cancel a page navigation in flight (a drawer link that also closes the drawer). Closed surfaces leave their entry; a Back landing on an entry that shows nothing new keeps going, and the next surface reuses such an entry. - useBackDismiss gives transient surfaces an entry: Dialog/Menu/Popover via Back-aware wrappers in components/ui (enforced by lint, exposed to integrations), the drawer menu, full-screen search, photo gallery, map-click card, an expanded bottom sheet (Back lowers it) and active guidance (Back ends the trip like the End button). - Text searches are encoded as `search=` so they survive Back and sharing. - The native shell sends Android's hardware Back into the WebView history. --- apps/mobile/src/App.test.tsx | 20 +- apps/mobile/src/App.tsx | 4 + .../mobile/src/shell/useHardwareBack.test.tsx | 48 +++ apps/mobile/src/shell/useHardwareBack.ts | 35 ++ .../(legal)/licenses/LicenseTextDialog.tsx | 2 +- apps/web/src/app/page.tsx | 2 + .../offline/OfflineSettingsClient.tsx | 2 +- .../components/admin/activity/AuditLog.tsx | 2 +- .../admin/extensions/ExtensionStorePage.tsx | 2 +- .../integrations/SetCredentialDialog.tsx | 2 +- .../admin/operations/AdminOperationCard.tsx | 2 +- .../components/admin/services/BackupsPage.tsx | 2 +- .../admin/services/ServiceCredentials.tsx | 2 +- .../admin/services/TransitSourcesSection.tsx | 2 +- .../components/admin/shared/ConfirmDialog.tsx | 2 +- .../admin/system/SystemMaintenance.tsx | 2 +- .../components/admin/users/BanUserDialog.tsx | 2 +- .../admin/users/CreateUserDialog.tsx | 2 +- .../src/components/admin/users/UserList.tsx | 2 +- apps/web/src/components/auth/AccountMenu.tsx | 2 +- .../components/auth/AccountSettingsDialog.tsx | 2 +- apps/web/src/components/auth/AuthDialog.tsx | 2 +- .../components/auth/MangroveConnectDialog.tsx | 2 +- .../components/auth/MangroveExportDialog.tsx | 2 +- .../auth/MangroveLocalAccountSection.tsx | 2 +- .../components/auth/MangroveSetupWizard.tsx | 2 +- .../components/auth/MangroveUnlockDialog.tsx | 2 +- .../components/auth/ResetPasswordDialog.tsx | 2 +- .../auth/TimelineConnectionSection.tsx | 2 +- .../command-palette/CommandPalette.tsx | 2 +- .../KeyboardShortcutsDialog.tsx | 2 +- .../map/DeepLinkManager.buildings.test.tsx | 17 +- .../map/DeepLinkManager.globe.test.tsx | 20 +- .../components/map/DeepLinkManager.test.tsx | 104 ++++++ .../src/components/map/DeepLinkManager.tsx | 136 ++++++- .../web/src/components/map/MapContextMenu.tsx | 4 +- .../src/components/map/StreetLevelViewer.tsx | 2 +- .../map/layer-selector/LayerSelector.tsx | 2 +- .../web/src/components/menu/HamburgerMenu.tsx | 2 + .../navigation/NavDirectionsDialog.tsx | 2 +- .../navigation/NavigationBackHandler.tsx | 17 + .../NavigationSessionResumeDialog.tsx | 2 +- .../panels/MapClickFloatingCard.tsx | 2 + .../panels/category/CategoryResultsHeader.tsx | 2 +- .../directions/DirectionsPanelContent.tsx | 2 +- .../directions/RouteImpactDetailsDialog.tsx | 2 +- .../panels/directions/ShareRouteDialog.tsx | 2 +- .../directions/WaypointScheduleDialog.tsx | 2 +- .../panels/place/PlaceFoodActions.tsx | 2 +- .../panels/place/PlaceHotelActions.tsx | 2 +- .../panels/place/PlaceOverviewTab.tsx | 2 +- .../panels/place/PlacePhotoGallery.tsx | 2 + .../contributions/OsmContributionDialog.tsx | 4 +- .../place/reviews/DeleteReviewDialog.tsx | 2 +- .../place/reviews/ReportAbuseDialog.tsx | 2 +- .../panels/place/reviews/ReviewCard.tsx | 2 +- .../place/reviews/WriteReviewDialog.tsx | 2 +- .../panels/saved/ImportPlacesDialog.tsx | 2 +- .../panels/saved/SavePlaceDialog.tsx | 2 +- .../panels/saved/SavedLabeledTab.tsx | 2 +- .../panels/saved/SavedListDetail.tsx | 6 +- .../components/panels/saved/SavedListsTab.tsx | 4 +- .../panels/saved/ShareListDialog.tsx | 2 +- .../panels/sheet/MobileBottomSheet.test.tsx | 52 ++- .../panels/sheet/MobileBottomSheet.tsx | 7 + apps/web/src/components/pwa/InstallEntry.tsx | 2 +- .../components/search/CategoryFilterBar.tsx | 2 +- .../search/CategoryFiltersPanel.tsx | 2 +- apps/web/src/components/search/SearchBar.tsx | 15 +- .../settings/NavigationSettingsDialog.tsx | 2 +- .../components/settings/SettingsDialog.tsx | 2 +- .../src/components/settings/StorageDialog.tsx | 2 +- .../components/settings/VehiclesDialog.tsx | 2 +- .../src/components/ui/AttributionDetails.tsx | 2 +- apps/web/src/components/ui/ClockTimeField.tsx | 2 +- apps/web/src/components/ui/Dialog.test.tsx | 84 +++++ apps/web/src/components/ui/Dialog.tsx | 17 + apps/web/src/components/ui/Menu.tsx | 13 + .../src/components/ui/NlpConsentDialog.tsx | 2 +- apps/web/src/components/ui/Popover.tsx | 13 + .../src/integration-api/components/Dialog.tsx | 5 + .../components/TrafficInfo.tsx | 2 +- apps/web/src/lib/backNavigation.test.ts | 224 ++++++++++++ apps/web/src/lib/backNavigation.ts | 345 ++++++++++++++++++ apps/web/src/lib/deepLink.test.ts | 36 ++ apps/web/src/lib/deepLink.ts | 61 +++- apps/web/src/lib/useBackDismiss.ts | 26 ++ apps/web/src/test/history.ts | 16 + apps/web/src/test/setup.ts | 13 + apps/web/src/vitest.d.ts | 1 + biome.json | 31 ++ docs/docs/developer/mobile-architecture.md | 32 ++ docs/docs/developer/writing-an-integration.md | 4 + integrations/crowd-reports/ReportDialog.tsx | 2 +- 94 files changed, 1433 insertions(+), 105 deletions(-) create mode 100644 apps/mobile/src/shell/useHardwareBack.test.tsx create mode 100644 apps/mobile/src/shell/useHardwareBack.ts create mode 100644 apps/web/src/components/navigation/NavigationBackHandler.tsx create mode 100644 apps/web/src/components/ui/Dialog.test.tsx create mode 100644 apps/web/src/components/ui/Dialog.tsx create mode 100644 apps/web/src/components/ui/Menu.tsx create mode 100644 apps/web/src/components/ui/Popover.tsx create mode 100644 apps/web/src/integration-api/components/Dialog.tsx create mode 100644 apps/web/src/lib/backNavigation.test.ts create mode 100644 apps/web/src/lib/backNavigation.ts create mode 100644 apps/web/src/lib/useBackDismiss.ts create mode 100644 apps/web/src/test/history.ts 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,