From 3ca8ee177d8033850b035963034cbc6f4522690d Mon Sep 17 00:00:00 2001 From: Florian Date: Mon, 2 Nov 2026 11:17:56 +0000 Subject: [PATCH] feat(web): map gestures, dropped-pin actions and swipe-away place cards on mobile On a phone, every tap on empty map dropped a gray pin, even when the user only wanted to put a card away, and there was no touch way to reach the desktop context menu's actions. - Touch: a tap on empty map only puts the dropped pin or the open place away; a long-press (500 ms, cancelled by panning or a second finger) drops the pin. Pickers, map tools and active guidance still take precedence. Mouse clicks are unchanged. - The dropped-pin card on phones carries the context menu's actions as a row: Directions, From here, Share, Save parking, Copy, Details. Both share the route, details and share logic in lib/locationActions. The card rests above an open sheet or the map footer, so the map credits stay readable. - A place (card or standalone panel) closes when its sheet is swiped down from its lowest detent. The closing snap point only exists while the sheet rests there, so a quick flick from higher up lowers it instead of closing it. Lists and directions keep their lowest detent as the floor. - The theme-color tags (status bar) follow the theme chosen in settings, not only the system scheme. - Haptics skip vibration before the page's first tap, which Chrome refuses. --- apps/web/src/app/globals.css | 8 + apps/web/src/app/layout.tsx | 5 +- apps/web/src/app/providers.tsx | 2 + .../src/components/map/DeepLinkManager.tsx | 10 +- .../components/map/MapClickHandler.test.tsx | 134 ++++++ .../src/components/map/MapClickHandler.tsx | 51 ++- .../web/src/components/map/MapContextMenu.tsx | 31 +- .../map/MapStylePoiClickHandler.test.tsx | 1 + .../web/src/components/panels/DetailShell.tsx | 10 +- .../panels/MapClickFloatingCard.test.tsx | 103 +++++ .../panels/MapClickFloatingCard.tsx | 399 +++++++++++++----- apps/web/src/components/panels/PanelHost.tsx | 6 +- .../src/components/panels/SidebarShell.tsx | 4 + .../panels/sheet/MobileBottomSheet.test.tsx | 75 +++- .../panels/sheet/MobileBottomSheet.tsx | 71 +++- .../web/src/components/pwa/ThemeColorSync.tsx | 18 + apps/web/src/lib/closeSurfaces.ts | 25 ++ apps/web/src/lib/haptics.ts | 11 + apps/web/src/lib/locationActions.ts | 51 +++ apps/web/src/lib/mapTouch.test.ts | 108 +++++ apps/web/src/lib/mapTouch.ts | 103 +++++ apps/web/src/lib/themeColor.test.ts | 42 ++ apps/web/src/lib/themeColor.ts | 23 + docs/docs/features/places.md | 5 + docs/docs/features/vehicles-and-parking.md | 3 +- 25 files changed, 1154 insertions(+), 145 deletions(-) create mode 100644 apps/web/src/components/map/MapClickHandler.test.tsx create mode 100644 apps/web/src/components/panels/MapClickFloatingCard.test.tsx create mode 100644 apps/web/src/components/pwa/ThemeColorSync.tsx create mode 100644 apps/web/src/lib/closeSurfaces.ts create mode 100644 apps/web/src/lib/locationActions.ts create mode 100644 apps/web/src/lib/mapTouch.test.ts create mode 100644 apps/web/src/lib/mapTouch.ts create mode 100644 apps/web/src/lib/themeColor.test.ts create mode 100644 apps/web/src/lib/themeColor.ts diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 31afbdcaa..a7b5ca758 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -119,6 +119,14 @@ main { height: 100%; } +/* A long-press on the map drops a pin; keep iOS from also treating it as a + press on text or an image (selection, magnifier, callout). */ +.maplibregl-canvas-container { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + /* Shared MapLibre popup styling */ @layer base { .omx-popup.maplibregl-popup .maplibregl-popup-content { diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 38b0873e0..c799d7161 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -19,6 +19,7 @@ import { InstallPromptCapture } from "@/components/pwa/useInstallPrompt"; import { EnvProvider } from "@/integration-api/runtime/EnvProvider"; import { AttributionLabelsSync } from "@/lib/attributionLabels"; import { loadClientEnv } from "@/lib/serverClientEnv"; +import { THEME_COLOR } from "@/lib/themeColor"; import { Providers } from "./providers"; export const metadata: Metadata = { @@ -49,8 +50,8 @@ export const viewport: Viewport = { maximumScale: 5, viewportFit: "cover", themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#207E23" }, - { media: "(prefers-color-scheme: dark)", color: "#1c1c1c" }, + { media: "(prefers-color-scheme: light)", color: THEME_COLOR.light }, + { media: "(prefers-color-scheme: dark)", color: THEME_COLOR.dark }, ], }; diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index 55967103c..72d673649 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -13,6 +13,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { ImpersonationBanner } from "../components/admin/ImpersonationBanner"; import { SavedPlacesMirror } from "../components/pwa/SavedPlacesMirror"; +import { ThemeColorSync } from "../components/pwa/ThemeColorSync"; import { createAppQueryClient } from "../lib/appQueryClient"; import { MobileRuntimeProvider } from "../lib/mobile/MobileRuntimeProvider"; import { @@ -137,6 +138,7 @@ export function Providers({ children }: { children: React.ReactNode }) { const inner = ( + {/* These teardown-only guards must observe the raw session transition even while the private tree is gated, so old in-memory keys and timeline diff --git a/apps/web/src/components/map/DeepLinkManager.tsx b/apps/web/src/components/map/DeepLinkManager.tsx index bddbb8cda..83a17f5f1 100644 --- a/apps/web/src/components/map/DeepLinkManager.tsx +++ b/apps/web/src/components/map/DeepLinkManager.tsx @@ -72,6 +72,7 @@ import { writeNavigation, } from "@/lib/backNavigation"; import { frameBoundsInstant, jumpToView } from "@/lib/cameraFraming"; +import { closePlaceSelection, closeSidebarPanel } from "@/lib/closeSurfaces"; import { DEEPLINK_UPDATE_EVENT, formatBboxParam, @@ -593,17 +594,13 @@ function applyPanels(parsed: ParsedDeepLink, map: maplibregl.Map | null): void { */ 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(); + closePlaceSelection(); return; case "item": useDataSourceStore.getState().clearSelection(); @@ -612,8 +609,7 @@ function closeSegment(segment: string): void { useSavedPlacesStore.getState().clearSelectedList(); return; default: - sidebar.closeSidebar(); - useSearchStore.getState().setQuery(""); + closeSidebarPanel(); } } diff --git a/apps/web/src/components/map/MapClickHandler.test.tsx b/apps/web/src/components/map/MapClickHandler.test.tsx new file mode 100644 index 000000000..7608e5f8c --- /dev/null +++ b/apps/web/src/components/map/MapClickHandler.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakeMap, render } from "@/test"; + +vi.mock("@/integration-api/map/MapContext", () => { + const mapContext = { mapRef: { current: null as unknown }, mapReady: true, styleVersion: 0 }; + return { __test: mapContext, useMap: () => mapContext }; +}); + +import { + createPlace, + PANEL, + useDirectionsStore, + useMapClickStore, + useNavigationStore, + useParkingStore, + usePlaceStore, + useSidebarStore, +} from "@openmapx/core"; +import * as mapContextModule from "@/integration-api/map/MapContext"; +import { consumeLongPressClick, LONG_PRESS_MS } from "@/lib/mapTouch"; +import { MapClickHandler } from "./MapClickHandler"; + +const mapContextTest = (mapContextModule as unknown as { __test: { mapRef: { current: unknown } } }) + .__test; + +let fake: ReturnType; + +const PLACE = createPlace({ + primaryScheme: "coordinate", + ids: { coordinate: "50.000000-6.000000" }, + name: "Somewhere", + address: "Somewhere", + coordinates: [6, 50], +}); + +function click(pointerType: "mouse" | "touch"): void { + fake.emit("click", { + lngLat: { lng: 6.1, lat: 50.1 }, + point: { x: 10, y: 10 }, + originalEvent: { pointerType }, + }); +} + +function touch(type: string, points: Array<[number, number]>): void { + const event = new Event(type, { bubbles: true }); + Object.defineProperty(event, "touches", { + value: points.map(([clientX, clientY]) => ({ clientX, clientY })), + }); + fake.state.canvas.dispatchEvent(event); +} + +function openPlace(): void { + usePlaceStore.getState().setSelectedPlace(PLACE); + useSidebarStore.getState().openSidebar(PANEL.PLACE); +} + +beforeEach(() => { + fake = createFakeMap(); + fake.map.unproject = (() => ({ lng: 7, lat: 51 })) as never; + mapContextTest.mapRef.current = fake.map; + useMapClickStore.getState().setClickedLngLat(null); + usePlaceStore.getState().setSelectedPlace(null); + useSidebarStore.setState({ activeSidebarId: null, activeDetailId: null, collapsed: false }); + useDirectionsStore.getState().close(); + useNavigationStore.setState({ status: "idle" }); + useParkingStore.setState({ picking: false }); + consumeLongPressClick(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("MapClickHandler with a mouse", () => { + it("drops the gray pin on a click, as before", () => { + render(); + openPlace(); + click("mouse"); + expect(useMapClickStore.getState().clickedLngLat).toEqual([6.1, 50.1]); + expect(usePlaceStore.getState().selectedPlace).toBeNull(); + }); +}); + +describe("MapClickHandler on a touch screen", () => { + it("only puts away the open place on a tap, without dropping a pin", () => { + render(); + openPlace(); + click("touch"); + expect(usePlaceStore.getState().selectedPlace).toBeNull(); + expect(useSidebarStore.getState().activeSidebarId).toBeNull(); + expect(useMapClickStore.getState().clickedLngLat).toBeNull(); + }); + + it("takes a dropped pin away on a tap before anything else", () => { + render(); + useMapClickStore.getState().setClickedLngLat([6, 50]); + click("touch"); + expect(useMapClickStore.getState().clickedLngLat).toBeNull(); + }); + + it("drops the pin on a long-press, closing the open place", () => { + vi.useFakeTimers(); + render(); + openPlace(); + touch("touchstart", [[20, 30]]); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]); + expect(usePlaceStore.getState().selectedPlace).toBeNull(); + + // The click some browsers fire as the finger lifts leaves the pin alone. + click("touch"); + expect(useMapClickStore.getState().clickedLngLat).toEqual([7, 51]); + }); + + it("leaves a long-press to the parking picker while it is choosing a spot", () => { + vi.useFakeTimers(); + useParkingStore.setState({ picking: true }); + render(); + touch("touchstart", [[20, 30]]); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(useMapClickStore.getState().clickedLngLat).toBeNull(); + }); + + it("drops no pin during guidance", () => { + vi.useFakeTimers(); + useNavigationStore.setState({ status: "navigating" }); + render(); + touch("touchstart", [[20, 30]]); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(useMapClickStore.getState().clickedLngLat).toBeNull(); + }); +}); diff --git a/apps/web/src/components/map/MapClickHandler.tsx b/apps/web/src/components/map/MapClickHandler.tsx index e9b1ac1a5..4be7eef4d 100644 --- a/apps/web/src/components/map/MapClickHandler.tsx +++ b/apps/web/src/components/map/MapClickHandler.tsx @@ -18,16 +18,42 @@ import { INTERACTIVE_LAYER_IDS } from "@/integration-api/map/interactiveLayers"; import { useMap } from "@/integration-api/map/MapContext"; import { claimMapClick, getMapClickOwner } from "@/integration-api/map/mapClickOwnership"; import { bindMapMarkerActivation } from "@/integration-api/map/mapMarkerActivation"; +import { closePlaceSelection } from "@/lib/closeSurfaces"; +import { haptics } from "@/lib/haptics"; import { initializeMapClickModes } from "@/lib/mapClickModes"; +import { bindMapLongPress, consumeLongPressClick, isTouchClick } from "@/lib/mapTouch"; // Configure before any map listener mounts. Read live mode state at the first // callback for each click; capture may clear that state before later callbacks. initializeMapClickModes(); +/** Drops the gray pin, replacing whatever place was open. */ +function dropPinAt(coordinates: [number, number]): void { + usePlaceStore.getState().setSelectedPlace(null); + if (useSidebarStore.getState().activeSidebarId === PANEL.PLACE) { + useSidebarStore.getState().closeSidebar(); + } + useSidebarStore.getState().closeDetail(); + useMapClickStore.getState().setClickedLngLat(coordinates); +} + +/** + * A tap on empty map on a touch screen only puts away what is open — the + * dropped pin's card, then the place — as in native map apps, where people tap + * the map to clear it and long-press to drop a pin. + */ +function dismissOnTap(): void { + if (useMapClickStore.getState().clickedLngLat) { + useMapClickStore.getState().setClickedLngLat(null); + return; + } + if (usePlaceStore.getState().selectedPlace) closePlaceSelection(); +} + export function MapClickHandler() { const { mapRef, mapReady, styleVersion } = useMap(); const { clickedLngLat, setClickedLngLat } = useMapClickStore(); - const { selectedPlace, setSelectedPlace } = usePlaceStore(); + const { selectedPlace } = usePlaceStore(); const markerRef = useRef(null); // Register plain-map click handler @@ -37,6 +63,8 @@ export function MapClickHandler() { if (!map || !mapReady) return; const onClick = (e: MapMouseEvent) => { + // The click some browsers fire as a long-press lifts: the pin is down. + if (consumeLongPressClick()) return; const owner = getMapClickOwner(e); // Repositioning a parked pin consumes the tap: falling through would also // deselect the place and drop a grey pin under the one being moved. @@ -78,19 +106,28 @@ export function MapClickHandler() { } } - setSelectedPlace(null); - if (useSidebarStore.getState().activeSidebarId === PANEL.PLACE) { - useSidebarStore.getState().closeSidebar(); + if (isTouchClick(e.originalEvent)) { + dismissOnTap(); + return; } - useSidebarStore.getState().closeDetail(); - setClickedLngLat([e.lngLat.lng, e.lngLat.lat]); + dropPinAt([e.lngLat.lng, e.lngLat.lat]); }; + // Touch screens drop the pin on a long-press instead. Pickers and tools + // own the map's taps while active, and guidance has no use for a pin. + const unbindLongPress = bindMapLongPress(map, (coordinates, event) => { + if (getMapClickOwner(event)) return; + if (useNavigationStore.getState().status !== "idle") return; + haptics.tap(); + dropPinAt(coordinates); + }); + map.on("click", onClick); return () => { map.off("click", onClick); + unbindLongPress(); }; - }, [mapRef, mapReady, styleVersion, setClickedLngLat, setSelectedPlace]); + }, [mapRef, mapReady, styleVersion]); // Clear clicked point when a place is selected externally (search, category click, etc.) useEffect(() => { diff --git a/apps/web/src/components/map/MapContextMenu.tsx b/apps/web/src/components/map/MapContextMenu.tsx index 2120b951c..1716c7a6e 100644 --- a/apps/web/src/components/map/MapContextMenu.tsx +++ b/apps/web/src/components/map/MapContextMenu.tsx @@ -23,14 +23,10 @@ import { coordinateId, createPlace, isLiveNavigationStatus, - PANEL, type Place, - useDirectionsStore, useMapClickStore, useNavigationStore, - usePlaceStore, useReverseGeocoding, - useSidebarStore, } from "@openmapx/core"; import type * as maplibregl from "maplibre-gl"; import { useLocale, useTranslations } from "next-intl"; @@ -47,7 +43,7 @@ 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"; +import { openLocationDetails, routeFromOrTo, shareLocationLink } from "@/lib/locationActions"; import { findStylePoiAtPoint, getStylePoiLayerIds, type StylePoiTarget } from "./mapStylePoiTarget"; interface MapContextTarget { @@ -294,25 +290,13 @@ export function MapContextMenu(): React.ReactNode { const chooseRouteEndpoint = (endpoint: "origin" | "destination") => { if (!target) return; - const directions = useDirectionsStore.getState(); - const index = endpoint === "origin" ? 0 : directions.waypoints.length - 1; - directions.setWaypoint(index, target.coordinates, actionLabel); - directions.open(); - useSidebarStore.getState().closeDetail(); - useSidebarStore.getState().openSidebar(PANEL.DIRECTIONS); + routeFromOrTo(endpoint, target.coordinates, actionLabel); closeMenu(); }; const openPlaceDetails = () => { if (!place) return; - usePlaceStore.getState().setSelectedPlace(place); - const sidebar = useSidebarStore.getState(); - if (!sidebar.activeSidebarId || sidebar.activeSidebarId === PANEL.PLACE) { - sidebar.closeDetail(); - sidebar.openSidebar(PANEL.PLACE); - } else { - sidebar.openDetail(PANEL.PLACE_CARD); - } + openLocationDetails(place); closeMenu(); }; @@ -369,14 +353,7 @@ export function MapContextMenu(): React.ReactNode { if (!place) return; const targetGeneration = targetGenerationRef.current; const actionGeneration = ++actionGenerationRef.current; - const url = buildLocationShareUrl(window.location.href, { - id: place.id, - coordinates: place.coordinates, - name: place.name, - category: place.category, - rawCategory: place.rawCategory, - }); - const result = await shareUrl({ url, title: place.name }); + const result = await shareLocationLink(place); if ( !mountedRef.current || targetGeneration !== targetGenerationRef.current || diff --git a/apps/web/src/components/map/MapStylePoiClickHandler.test.tsx b/apps/web/src/components/map/MapStylePoiClickHandler.test.tsx index 43686a1f2..c29e1f318 100644 --- a/apps/web/src/components/map/MapStylePoiClickHandler.test.tsx +++ b/apps/web/src/components/map/MapStylePoiClickHandler.test.tsx @@ -81,6 +81,7 @@ class FakeMap { this.featuresByLayer[id] || this.styleLayers.some((layer) => layer.id === id) ? { id } : undefined; + getCanvas = () => this.canvas; getCanvasContainer = () => this.canvas; getContainer = () => this.canvas; project = () => ({ x: 400, y: 200 }); diff --git a/apps/web/src/components/panels/DetailShell.tsx b/apps/web/src/components/panels/DetailShell.tsx index 33b6d48f8..7a1e8c582 100644 --- a/apps/web/src/components/panels/DetailShell.tsx +++ b/apps/web/src/components/panels/DetailShell.tsx @@ -37,7 +37,14 @@ export function useDetailChrome(header: ReactNode, footer: ReactNode) { }, [api, footer]); } -export function DetailShell({ children }: { children: ReactNode }) { +export function DetailShell({ + children, + onDismiss, +}: { + children: ReactNode; + /** Mobile: swiping the sheet down past its lowest detent closes the detail this way. */ + onDismiss?: () => void; +}) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const t = useTranslations("common"); @@ -53,6 +60,7 @@ export function DetailShell({ children }: { children: ReactNode }) { // opened from the card portal to the end of and stay on top. zIndex={navigating ? NAV_OVERLAY_Z_INDEX : 11} detents={PLACE_DETENTS} + onDismiss={onDismiss} ariaLabel={t("detailsPanelAriaLabel")} > {children} diff --git a/apps/web/src/components/panels/MapClickFloatingCard.test.tsx b/apps/web/src/components/panels/MapClickFloatingCard.test.tsx new file mode 100644 index 000000000..347173320 --- /dev/null +++ b/apps/web/src/components/panels/MapClickFloatingCard.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, userEvent, waitFor } from "@/test"; + +vi.mock("next-intl", async () => (await import("@/test/intl")).mockNextIntl()); +vi.mock("@/integration-api/map/MapContext", () => ({ useMap: () => ({ flyTo: vi.fn() }) })); +vi.mock("@openmapx/core", async (importOriginal) => ({ + ...(await importOriginal()), + useReverseGeocoding: () => ({ + data: { address: "Marktplatz 1", city: "Aachen" }, + isLoading: false, + }), +})); +const parking = vi.hoisted(() => ({ saveAt: vi.fn(async () => "saved" as const) })); +vi.mock("@/components/panels/parking/useSaveParking", () => ({ + useSaveParking: () => ({ saveAt: parking.saveAt, saveHere: vi.fn(), isSaving: false }), +})); +const share = vi.hoisted(() => ({ shareUrl: vi.fn(async () => "copied" as const) })); +vi.mock("@/lib/deepLink", async (importOriginal) => ({ + ...(await importOriginal()), + shareUrl: share.shareUrl, +})); + +import { PANEL, useDirectionsStore, useMapClickStore, useSidebarStore } from "@openmapx/core"; +import { MapClickFloatingCard } from "./MapClickFloatingCard"; + +const PIN: [number, number] = [6.083, 50.776]; + +function asPhone(phone: boolean): void { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: phone && query.includes("max-width"), + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + })); +} + +beforeEach(() => { + useMapClickStore.getState().setClickedLngLat(PIN); + useDirectionsStore.getState().close(); + useSidebarStore.setState({ activeSidebarId: null, activeDetailId: null, collapsed: false }); + parking.saveAt.mockClear(); + share.shareUrl.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("MapClickFloatingCard on a phone", () => { + beforeEach(() => asPhone(true)); + + it("offers the context menu's actions as a row", () => { + render(); + for (const name of [ + "place.directions", + "mapContextMenu.fromHere", + "common.share", + "parking.saveParking", + "common.copy", + "common.details", + ]) { + expect(screen.getByRole("button", { name })).toBeDefined(); + } + }); + + it("starts a route from the pin", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "mapContextMenu.fromHere" })); + const directions = useDirectionsStore.getState(); + expect(directions.isOpen).toBe(true); + expect(directions.waypoints[0].coords).toEqual(PIN); + expect(useSidebarStore.getState().activeSidebarId).toBe(PANEL.DIRECTIONS); + expect(useMapClickStore.getState().clickedLngLat).toBeNull(); + }); + + it("shares a link to the pin and says when it was copied instead", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "common.share" })); + expect(share.shareUrl).toHaveBeenCalledTimes(1); + expect(await screen.findByText("mapContextMenu.linkCopied")).toBeDefined(); + }); + + it("saves parking at the pin", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "parking.saveParking" })); + expect(parking.saveAt).toHaveBeenCalledWith(PIN, { address: "Marktplatz 1" }); + await waitFor(() => expect(useMapClickStore.getState().clickedLngLat).toBeNull()); + }); +}); + +describe("MapClickFloatingCard on a desktop", () => { + beforeEach(() => asPhone(false)); + + it("keeps the compact card without the action row", () => { + render(); + expect(screen.getByRole("button", { name: "place.directions" })).toBeDefined(); + expect(screen.queryByRole("button", { name: "mapContextMenu.fromHere" })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/panels/MapClickFloatingCard.tsx b/apps/web/src/components/panels/MapClickFloatingCard.tsx index 1bbc546e5..a816acad7 100644 --- a/apps/web/src/components/panels/MapClickFloatingCard.tsx +++ b/apps/web/src/components/panels/MapClickFloatingCard.tsx @@ -1,17 +1,27 @@ "use client"; import CloseIcon from "@mui/icons-material/Close"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DirectionsIcon from "@mui/icons-material/Directions"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import LocalParkingIcon from "@mui/icons-material/LocalParking"; import LocationOnIcon from "@mui/icons-material/LocationOn"; +import ShareIcon from "@mui/icons-material/Share"; +import TripOriginIcon from "@mui/icons-material/TripOrigin"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import IconButton from "@mui/material/IconButton"; import Paper from "@mui/material/Paper"; import Skeleton from "@mui/material/Skeleton"; +import Snackbar from "@mui/material/Snackbar"; +import { useTheme } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; +import useMediaQuery from "@mui/material/useMediaQuery"; import { + coordinateId, createPlace, PANEL, - useDirectionsStore, + type Place, useMapClickStore, usePlaceStore, useReverseGeocoding, @@ -19,139 +29,334 @@ import { useSidebarStore, } from "@openmapx/core"; import { useLocale, useTranslations } from "next-intl"; +import type { ReactNode } from "react"; +import { useMemo, useState, useSyncExternalStore } from "react"; +import { useSaveParking } from "@/components/panels/parking/useSaveParking"; import { useMap } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; +import { routeFromOrTo, shareLocationLink } from "@/lib/locationActions"; +import { useMapFooterBox } from "@/lib/mapFooterCenter"; +import { subscribeMobileFooterSurfaces, topMobileFooterSurface } from "@/lib/mobileFooterSurface"; import { useBackDismiss } from "@/lib/useBackDismiss"; +const CARD_GAP = 8; + +function sheetTop(): number | null { + const surface = topMobileFooterSurface(); + return surface ? surface.bottom - surface.visibleHeight : null; +} + +/** + * Where the phone card rests: above an open sheet, or above the map footer — + * never over the map credits, which have to stay readable. + */ +function usePhoneCardBottom(): string { + const footer = useMapFooterBox(); + const top = useSyncExternalStore(subscribeMobileFooterSurfaces, sheetTop, () => null); + if (top !== null) return `${Math.max(0, window.innerHeight - top) + CARD_GAP}px`; + return `calc(var(--omx-map-footer-bottom, var(--omx-safe-bottom)) + ${footer.height + CARD_GAP}px)`; +} + export function MapClickFloatingCard() { const tc = useTranslations("common"); const tp = useTranslations("place"); + const tm = useTranslations("mapContextMenu"); + const tParking = useTranslations("parking"); const locale = useLocale(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); const { clickedLngLat, setClickedLngLat } = useMapClickStore(); - const { setSelectedPlace } = usePlaceStore(); const { setQuery, setIsFocused } = useSearchStore(); - const { setWaypoint, open: openDirections } = useDirectionsStore(); const { flyTo } = useMap(); + const { saveAt } = useSaveParking(); + const [feedback, setFeedback] = useState(null); + const phoneCardBottom = usePhoneCardBottom(); const { data: reverseGeo, isLoading } = useReverseGeocoding(clickedLngLat, locale); useBackDismiss(clickedLngLat !== null, () => setClickedLngLat(null)); - if (!clickedLngLat) return null; - - const [lng, lat] = clickedLngLat; - const coordLabel = `${lat.toFixed(6)}, ${lng.toFixed(6)}`; + const coordLabel = clickedLngLat + ? `${clickedLngLat[1].toFixed(6)}, ${clickedLngLat[0].toFixed(6)}` + : ""; const placeName = reverseGeo?.city || reverseGeo?.address || coordLabel; + const address = reverseGeo?.address ?? coordLabel; + + const place = useMemo( + () => + clickedLngLat + ? createPlace({ + primaryScheme: "coordinate", + ids: { coordinate: coordinateId(clickedLngLat) }, + name: placeName, + address, + coordinates: clickedLngLat, + }) + : null, + [address, clickedLngLat, placeName], + ); + + const feedbackToast = ( + setFeedback(null)} + /> + ); + + if (!clickedLngLat || !place) return feedbackToast; const handleCoordClick = () => { flyTo(clickedLngLat, 15); setQuery(coordLabel); setIsFocused(false); - setSelectedPlace( - createPlace({ - primaryScheme: "coordinate", - ids: { coordinate: `${lat.toFixed(6)}-${lng.toFixed(6)}` }, - name: placeName, - address: reverseGeo?.address ?? coordLabel, - coordinates: clickedLngLat, - }), - ); + usePlaceStore.getState().setSelectedPlace(place); useSidebarStore.getState().openSidebar(PANEL.PLACE); setClickedLngLat(null); }; - const handleDirections = () => { - const destinationIndex = useDirectionsStore.getState().waypoints.length - 1; - setWaypoint(destinationIndex, clickedLngLat, reverseGeo?.address ?? coordLabel); - openDirections(); - useSidebarStore.getState().openSidebar(PANEL.DIRECTIONS); + const handleRoute = (endpoint: "origin" | "destination") => { + routeFromOrTo(endpoint, clickedLngLat, address); setClickedLngLat(null); }; - return ( - { + const result = await shareLocationLink(place); + if (result === "unavailable") setFeedback(tm("shareFailed")); + else if (result === "copied") setFeedback(tm("linkCopied")); + }; + + const handleCopy = async () => { + try { + if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable"); + await navigator.clipboard.writeText(coordLabel); + setFeedback(tm("copied")); + } catch { + setFeedback(tm("copyFailed")); + } + }; + + const handleSaveParking = async () => { + const outcome = await saveAt(clickedLngLat, { address: reverseGeo?.address ?? null }); + setFeedback(outcome === "saved" ? tParking("savedToast") : tParking("locationUnavailable")); + if (outcome === "saved") setClickedLngLat(null); + }; + + const title = isLoading ? ( + + ) : ( + - - {/* Header row: icon + name + close */} - - - - {isLoading ? ( - - ) : ( - - {placeName} - - )} - - setClickedLngLat(null)} - aria-label={tc("close")} - sx={{ mr: -0.5 }} - > - - - - - {/* Divider */} - + {placeName} + + ); - {/* Coordinate + action buttons */} - + if (isMobile) { + // A dropped pin is where a phone user lands after a long-press, so the + // card carries what the desktop context menu offers, as a row of actions + // within thumb reach instead of a menu under the finger. + return ( + <> + + + + + {title} + {placeName !== coordLabel && ( + + {coordLabel} + + )} + + setClickedLngLat(null)} + aria-label={tc("close")} + sx={{ width: 44, height: 44, mr: -1.5, mt: -1 }} + > + + + - {coordLabel} + } + label={tp("directions")} + onClick={() => handleRoute("destination")} + /> + } + label={tm("fromHere")} + onClick={() => handleRoute("origin")} + /> + } label={tc("share")} onClick={() => void handleShare()} /> + } + label={tParking("saveParking")} + onClick={() => void handleSaveParking()} + /> + } + label={tc("copy")} + onClick={() => void handleCopy()} + /> + } label={tc("details")} onClick={handleCoordClick} /> + + + {feedbackToast} + + ); + } + + return ( + <> + + + {/* Header row: icon + name + close */} + + + {title} + setClickedLngLat(null)} + aria-label={tc("close")} + sx={{ mr: -0.5 }} + > + + + + + {/* Divider */} + + + {/* Coordinate + action buttons */} + + + {coordLabel} + + handleRoute("destination")} + aria-label={tp("directions")} + sx={{ + bgcolor: BRAND, + color: "white", + width: 32, + height: 32, + borderRadius: "50%", + "&:hover": { bgcolor: "var(--omx-brand-hover)" }, + }} + > + + - - - - - + + {feedbackToast} + + ); +} + +function Action({ + icon, + label, + onClick, + primary, +}: { + icon: ReactNode; + label: string; + onClick: () => void; + primary?: boolean; +}) { + return ( + ); } diff --git a/apps/web/src/components/panels/PanelHost.tsx b/apps/web/src/components/panels/PanelHost.tsx index 1da8a6e4b..be0d3438a 100644 --- a/apps/web/src/components/panels/PanelHost.tsx +++ b/apps/web/src/components/panels/PanelHost.tsx @@ -5,6 +5,7 @@ import { useIntegrationRegistry } from "@openmapx/integration-framework/react"; import type { ComponentType } from "react"; import { lazy, Suspense, useEffect, useMemo, useRef } from "react"; import { HideDuringNavigation } from "@/components/navigation/HideDuringNavigation"; +import { closePlaceSelection } from "@/lib/closeSurfaces"; import { DetailShell } from "./DetailShell"; import { DETAIL_PANELS, SIDEBAR_PANELS } from "./panel-map"; import { SidebarShell } from "./SidebarShell"; @@ -77,6 +78,9 @@ export function PanelHost() { searchBarClearance={sidebarEntry.searchBarClearance} detents={sidebarEntry.detents} obscured={Boolean(DetailContent)} + // A place swipes away like a card in a native map app; lists and + // directions stop at their lowest detent and close explicitly. + onDismiss={activeSidebarId === PANEL.PLACE ? closePlaceSelection : undefined} > @@ -86,7 +90,7 @@ export function PanelHost() { {/* A place tapped mid-route still opens its card over the nav overlay. */} {DetailContent && ( - + diff --git a/apps/web/src/components/panels/SidebarShell.tsx b/apps/web/src/components/panels/SidebarShell.tsx index 874c75f7d..e3c64733e 100644 --- a/apps/web/src/components/panels/SidebarShell.tsx +++ b/apps/web/src/components/panels/SidebarShell.tsx @@ -30,6 +30,8 @@ interface SidebarShellProps { * never sets it. */ obscured?: boolean; + /** Mobile: swiping the sheet down past its lowest detent closes the panel this way. */ + onDismiss?: () => void; } export function SidebarShell({ @@ -37,6 +39,7 @@ export function SidebarShell({ searchBarClearance = "shell", detents = LIST_DETENTS, obscured, + onDismiss, }: SidebarShellProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); @@ -50,6 +53,7 @@ export function SidebarShell({ zIndex={11} detents={detents} obscured={obscured} + onDismiss={onDismiss} ariaLabel={t("resultsPanelAriaLabel")} > {children} diff --git a/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx b/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx index ddbfb5ed0..44f2af504 100644 --- a/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx +++ b/apps/web/src/components/panels/sheet/MobileBottomSheet.test.tsx @@ -1,5 +1,5 @@ import { act, render } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { resetBackNavigationForTests } from "@/lib/backNavigation"; import { getMapObstructionInsets, @@ -406,3 +406,76 @@ describe("MobileBottomSheet and Back", () => { expect(window.history.state?.omx?.depth ?? 0).toBe(0); }); }); + +describe("MobileBottomSheet swipe-down to close", () => { + function snap(host: HTMLElement, snapIndex: number, sheetState: string) { + act(() => + host.dispatchEvent( + new CustomEvent("snap-position-change", { detail: { snapIndex, sheetState } }), + ), + ); + } + + /** Lets the sheet come to rest at `snapIndex`: the snap, then a quiet spell after scrolling. */ + function restAt(host: HTMLElement, snapIndex: number) { + snap(host, snapIndex, "partially-expanded"); + act(() => { + host.dispatchEvent(new Event("scroll")); + vi.advanceTimersByTime(200); + }); + } + + function renderSheet(props: { onDismiss?: () => void; obscured?: boolean }) { + const { container } = render( + +
content
+
, + ); + return container.querySelector("bottom-sheet") as HTMLElement; + } + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("closes once swiped down from its lowest detent", () => { + const onDismiss = vi.fn(); + const host = renderSheet({ onDismiss }); + restAt(host, detentIndex(PLACE_DETENTS).peek); + expect(host.hasAttribute("swipe-to-dismiss")).toBe(true); + snap(host, 0, "collapsed"); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("offers no closing position while resting higher, so a flick only lowers it", () => { + const host = renderSheet({ onDismiss: vi.fn() }); + expect(host.hasAttribute("swipe-to-dismiss")).toBe(false); + restAt(host, detentIndex(PLACE_DETENTS).mid ?? 2); + expect(host.hasAttribute("swipe-to-dismiss")).toBe(false); + }); + + it("ignores the collapsed position reported before the sheet first comes to rest", () => { + const onDismiss = vi.fn(); + const host = renderSheet({ onDismiss }); + snap(host, 0, "collapsed"); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it("does not close while another sheet covers it", () => { + const onDismiss = vi.fn(); + const host = renderSheet({ onDismiss, obscured: true }); + restAt(host, detentIndex(PLACE_DETENTS).peek); + snap(host, 0, "collapsed"); + expect(onDismiss).not.toHaveBeenCalled(); + }); + + it("keeps its lowest detent as the floor without a dismiss handler", () => { + const host = renderSheet({}); + restAt(host, detentIndex(PLACE_DETENTS).peek); + expect(host.hasAttribute("swipe-to-dismiss")).toBe(false); + }); +}); diff --git a/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx b/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx index ea4713303..0a138bf3c 100644 --- a/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx +++ b/apps/web/src/components/panels/sheet/MobileBottomSheet.tsx @@ -87,6 +87,13 @@ interface Props { * drag itself, so this can fire mid-gesture, before the finger lifts. */ onDetentChange?: (detent: Detent) => void; + /** + * Lets the user swipe the sheet down past its lowest detent to close what it + * shows, as a place card closes in native map apps. Called once the sheet + * has been dragged off the bottom; the caller closes the surface, which + * unmounts the sheet. + */ + onDismiss?: () => void; /** * Hides the library's own drag-pill part, for a caller that renders its own * (labeled, tappable) handle as part of `header` instead. @@ -123,6 +130,7 @@ export function MobileBottomSheet({ detents, detent, onDetentChange, + onDismiss, hideHandle, disableContentSafeArea, obscured, @@ -151,6 +159,25 @@ export function MobileBottomSheet({ useEffect(() => { onDetentChangeRef.current = onDetentChange; }, [onDetentChange]); + const onDismissRef = useRef(onDismiss); + const obscuredRef = useRef(obscured); + useEffect(() => { + onDismissRef.current = onDismiss; + obscuredRef.current = obscured; + }, [onDismiss, obscured]); + /** + * The library reports the dismissed position for a sheet that has not + * reached its first snap yet, so only a sheet that has rested at a real + * detent can be dismissed by reaching it. + */ + const restedRef = useRef(false); + /** + * The closing snap point exists only while the sheet rests at its lowest + * detent. Offered all the time, a quick flick down from the middle would + * carry the sheet past the lowest detent and close it; this way the flick + * lowers it, and a further swipe closes it, as in native map apps. + */ + const [restingAtPeek, setRestingAtPeek] = useState(detents.initial === "peek"); // Registered by descendant content through useDetailChrome — the pinned // header / docked footer slots. Owned here (rather than by DetailShell) // so every surface that renders a sheet gets the bridge, not just the @@ -237,7 +264,14 @@ export function MobileBottomSheet({ useEffect(() => { if (!host) return; const onSnap = (event: Event) => { - const next = detentFromSnapEvent((event as CustomEvent).detail, detents); + const detail = (event as CustomEvent).detail; + if (detail.snapIndex === 0 && detail.sheetState === "collapsed") { + // A covered sheet is hidden and cannot be swiped; whatever it reports is not the user's. + if (restedRef.current && !obscuredRef.current) onDismissRef.current?.(); + return; + } + restedRef.current = true; + const next = detentFromSnapEvent(detail, detents); if (pendingDetentRef.current === next.detent) pendingDetentRef.current = null; if (detentRef.current !== next.detent) { detentRef.current = next.detent; @@ -249,11 +283,40 @@ export function MobileBottomSheet({ ); }; const onScrollEnd = settleControlledTransition; + // "Came to rest" is a quiet spell after the last scroll with no finger on + // the sheet (`scrollend` is missing in older Safari). A finger holding + // still mid-drag does not count: the library already reports the lower + // detent as soon as a drag leaves the one above, long before it gets there. + let restTimer: ReturnType | null = null; + let touching = false; + const scheduleRest = () => { + if (restTimer !== null) clearTimeout(restTimer); + restTimer = setTimeout(() => { + restTimer = null; + if (!touching) setRestingAtPeek(detentRef.current === "peek"); + }, SHEET_SETTLE_MS); + }; + const onTouchStart = () => { + touching = true; + }; + const onTouchEnd = () => { + touching = false; + scheduleRest(); + }; + host.addEventListener("scroll", scheduleRest, { passive: true }); + host.addEventListener("touchstart", onTouchStart, { passive: true }); + host.addEventListener("touchend", onTouchEnd, { passive: true }); + host.addEventListener("touchcancel", onTouchEnd, { passive: true }); host.addEventListener("snap-position-change", onSnap); host.addEventListener("scrollend", onScrollEnd); return () => { + if (restTimer !== null) clearTimeout(restTimer); host.removeEventListener("snap-position-change", onSnap); host.removeEventListener("scrollend", onScrollEnd); + host.removeEventListener("scroll", scheduleRest); + host.removeEventListener("touchstart", onTouchStart); + host.removeEventListener("touchend", onTouchEnd); + host.removeEventListener("touchcancel", onTouchEnd); }; }, [host, detents, settleControlledTransition]); @@ -478,6 +541,12 @@ export function MobileBottomSheet({ ref={setHost} nested-scroll expand-to-scroll + // Gives the sheet a snap point below its lowest detent while it + // rests there (see `restingAtPeek`); reaching it is reported as + // the collapsed state, which `onSnap` turns into `onDismiss`. The + // library's own dismissal only acts on a sheet inside a , + // which this one is not. + {...(onDismiss && restingAtPeek ? { "swipe-to-dismiss": true } : {})} role="region" aria-label={ariaLabel ?? t("panelAriaLabel")} // `visibility: hidden` (below) already drops a covered sheet from diff --git a/apps/web/src/components/pwa/ThemeColorSync.tsx b/apps/web/src/components/pwa/ThemeColorSync.tsx new file mode 100644 index 000000000..a8216447c --- /dev/null +++ b/apps/web/src/components/pwa/ThemeColorSync.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useColorScheme } from "@mui/material/styles"; +import { useEffect } from "react"; +import { applyThemeColor } from "@/lib/themeColor"; + +/** + * Keeps the status bar on the theme chosen in settings. The `theme-color` + * tags only follow the system scheme, so a user who picked Dark on a phone + * set to light would otherwise get a bright bar above a dark app. + */ +export function ThemeColorSync() { + const { mode } = useColorScheme(); + useEffect(() => { + applyThemeColor(mode === "light" || mode === "dark" ? mode : null); + }, [mode]); + return null; +} diff --git a/apps/web/src/lib/closeSurfaces.ts b/apps/web/src/lib/closeSurfaces.ts new file mode 100644 index 000000000..2b424ea9e --- /dev/null +++ b/apps/web/src/lib/closeSurfaces.ts @@ -0,0 +1,25 @@ +import { + PANEL, + useDataSourceStore, + usePlaceStore, + useSearchStore, + useSidebarStore, +} from "@openmapx/core"; + +/** + * Closes the selected place — the card beside a list, or the standalone place + * panel — the way the card's own close button does. + */ +export function closePlaceSelection(): void { + usePlaceStore.getState().setSelectedPlace(null); + useDataSourceStore.getState().clearSelection(); + const sidebar = useSidebarStore.getState(); + if (sidebar.activeDetailId === PANEL.PLACE_CARD) sidebar.closeDetail(); + else if (sidebar.activeSidebarId === PANEL.PLACE) sidebar.closeSidebar(); +} + +/** Closes the open side panel (list, directions, saved …) and the search it put in the bar. */ +export function closeSidebarPanel(): void { + useSidebarStore.getState().closeSidebar(); + useSearchStore.getState().setQuery(""); +} diff --git a/apps/web/src/lib/haptics.ts b/apps/web/src/lib/haptics.ts index 3a00c88f2..c008df826 100644 --- a/apps/web/src/lib/haptics.ts +++ b/apps/web/src/lib/haptics.ts @@ -33,8 +33,19 @@ function prefersReducedMotion(): boolean { ); } +/** + * Chrome refuses (and logs) vibration before the page's first tap, which a + * long-press on the map can be. + */ +function blockedBeforeFirstTap(): boolean { + const activation = (navigator as Navigator & { userActivation?: { hasBeenActive: boolean } }) + .userActivation; + return activation !== undefined && !activation.hasBeenActive; +} + function vibrate(pattern: number | number[]): void { if (!hapticsSupported() || !isHapticsEnabled() || prefersReducedMotion()) return; + if (blockedBeforeFirstTap()) return; try { navigator.vibrate(pattern); } catch { diff --git a/apps/web/src/lib/locationActions.ts b/apps/web/src/lib/locationActions.ts new file mode 100644 index 000000000..ff5be8aeb --- /dev/null +++ b/apps/web/src/lib/locationActions.ts @@ -0,0 +1,51 @@ +import { + PANEL, + type Place, + useDirectionsStore, + usePlaceStore, + useSidebarStore, +} from "@openmapx/core"; +import { buildLocationShareUrl, type ShareCurrentUrlResult, shareUrl } from "@/lib/deepLink"; + +/** + * Actions on an arbitrary map location, shared by the desktop context menu and + * the dropped-pin card so both treat a point on the map the same way. + */ + +/** Starts or ends a route at the location and shows the directions panel. */ +export function routeFromOrTo( + endpoint: "origin" | "destination", + coordinates: [number, number], + label: string, +): void { + const directions = useDirectionsStore.getState(); + const index = endpoint === "origin" ? 0 : directions.waypoints.length - 1; + directions.setWaypoint(index, coordinates, label); + directions.open(); + useSidebarStore.getState().closeDetail(); + useSidebarStore.getState().openSidebar(PANEL.DIRECTIONS); +} + +/** Opens the location as a place: its own panel, or a card beside the open list. */ +export function openLocationDetails(place: Place): void { + usePlaceStore.getState().setSelectedPlace(place); + const sidebar = useSidebarStore.getState(); + if (!sidebar.activeSidebarId || sidebar.activeSidebarId === PANEL.PLACE) { + sidebar.closeDetail(); + sidebar.openSidebar(PANEL.PLACE); + } else { + sidebar.openDetail(PANEL.PLACE_CARD); + } +} + +/** Shares a link that opens the location as a place. */ +export function shareLocationLink(place: Place): Promise { + const url = buildLocationShareUrl(window.location.href, { + id: place.id, + coordinates: place.coordinates, + name: place.name, + category: place.category, + rawCategory: place.rawCategory, + }); + return shareUrl({ url, title: place.name }); +} diff --git a/apps/web/src/lib/mapTouch.test.ts b/apps/web/src/lib/mapTouch.test.ts new file mode 100644 index 000000000..f58eb0aef --- /dev/null +++ b/apps/web/src/lib/mapTouch.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment jsdom + +import type * as maplibregl from "maplibre-gl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { bindMapLongPress, consumeLongPressClick, isTouchClick, LONG_PRESS_MS } from "./mapTouch"; + +function touch(target: EventTarget, type: string, points: Array<[number, number]>): void { + const event = new Event(type, { bubbles: true }); + Object.defineProperty(event, "touches", { + value: points.map(([clientX, clientY]) => ({ clientX, clientY })), + }); + target.dispatchEvent(event); +} + +let container: HTMLDivElement; +let canvas: HTMLCanvasElement; +let marker: HTMLDivElement; +let unbind: () => void; +const onLongPress = vi.fn(); + +beforeEach(() => { + vi.useFakeTimers(); + container = document.createElement("div"); + canvas = document.createElement("canvas"); + marker = document.createElement("div"); + container.append(canvas, marker); + document.body.append(container); + const map = { + getCanvasContainer: () => container, + getCanvas: () => canvas, + unproject: ([x, y]: [number, number]) => ({ lng: x, lat: y }), + } as unknown as maplibregl.Map; + onLongPress.mockReset(); + unbind = bindMapLongPress(map, onLongPress); +}); + +afterEach(() => { + unbind(); + container.remove(); + consumeLongPressClick(); + vi.useRealTimers(); +}); + +describe("bindMapLongPress", () => { + it("fires where a single finger rested", () => { + touch(canvas, "touchstart", [[30, 40]]); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(onLongPress).toHaveBeenCalledTimes(1); + expect(onLongPress.mock.calls[0][0]).toEqual([30, 40]); + }); + + it("tolerates a little jitter", () => { + touch(canvas, "touchstart", [[30, 40]]); + touch(canvas, "touchmove", [[34, 43]]); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it.each<[string, () => void]>([ + ["the finger pans away", () => touch(canvas, "touchmove", [[60, 40]])], + [ + "a second finger joins", + () => + touch(canvas, "touchmove", [ + [30, 40], + [80, 90], + ]), + ], + ["the finger lifts", () => touch(canvas, "touchend", [])], + ])("does not fire when %s", (_name, interrupt) => { + touch(canvas, "touchstart", [[30, 40]]); + interrupt(); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("ignores a press on a marker above the canvas", () => { + touch(marker, "touchstart", [[30, 40]]); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(onLongPress).not.toHaveBeenCalled(); + }); + + it("swallows the one click a long-press may leave behind", () => { + touch(canvas, "touchstart", [[30, 40]]); + vi.advanceTimersByTime(LONG_PRESS_MS); + expect(consumeLongPressClick()).toBe(true); + expect(consumeLongPressClick()).toBe(false); + }); +}); + +describe("isTouchClick", () => { + it("trusts a click that says it came from touch", () => { + expect(isTouchClick({ pointerType: "touch" })).toBe(true); + expect(isTouchClick({ sourceCapabilities: { firesTouchEvents: true } })).toBe(true); + }); + + it("treats a click right after a touch on the map as that touch (Safari)", () => { + touch(canvas, "touchstart", [[30, 40]]); + touch(canvas, "touchend", []); + expect(isTouchClick({})).toBe(true); + }); + + it("treats a mouse click long after any touch as a mouse click", () => { + touch(canvas, "touchstart", [[30, 40]]); + vi.advanceTimersByTime(5000); + expect(isTouchClick({ pointerType: "mouse" })).toBe(false); + }); +}); diff --git a/apps/web/src/lib/mapTouch.ts b/apps/web/src/lib/mapTouch.ts new file mode 100644 index 000000000..4060f0209 --- /dev/null +++ b/apps/web/src/lib/mapTouch.ts @@ -0,0 +1,103 @@ +import type * as maplibregl from "maplibre-gl"; + +/** How long a finger has to rest on the map before it counts as a long-press. */ +export const LONG_PRESS_MS = 500; +/** How far it may wander meanwhile — farther is a pan. */ +const LONG_PRESS_TOLERANCE_PX = 10; +/** A click this soon after a touch came from that touch, whatever the browser reports. */ +const TOUCH_CLICK_WINDOW_MS = 1000; + +let lastTouchAt = Number.NEGATIVE_INFINITY; +let lastLongPressAt = Number.NEGATIVE_INFINITY; + +/** + * Whether a map click came from a finger. Chrome and Firefox say so on the + * click itself; Safari does not, so a touch that just happened on the map is + * taken as the source too. + */ +export function isTouchClick(event: unknown): boolean { + const pointerEvent = event as + | { pointerType?: string; sourceCapabilities?: { firesTouchEvents?: boolean } } + | undefined; + if (pointerEvent?.pointerType === "touch") return true; + if (pointerEvent?.sourceCapabilities?.firesTouchEvents === true) return true; + return performance.now() - lastTouchAt < TOUCH_CLICK_WINDOW_MS; +} + +/** + * Whether this click is the one some browsers still fire when the finger of a + * long-press lifts. Consumes the long-press, so a later click goes through. + */ +export function consumeLongPressClick(): boolean { + const recent = performance.now() - lastLongPressAt < TOUCH_CLICK_WINDOW_MS; + lastLongPressAt = Number.NEGATIVE_INFINITY; + return recent; +} + +/** + * Calls `onLongPress` when one finger rests still on the map canvas for + * `LONG_PRESS_MS`. A second finger, moving away, lifting, or starting on a + * marker or control above the canvas cancels it. The press event itself is + * passed along so map-click ownership can be checked against it. + */ +export function bindMapLongPress( + map: maplibregl.Map, + onLongPress: (lngLat: [number, number], event: TouchEvent) => void, +): () => void { + const container = map.getCanvasContainer(); + const canvas = map.getCanvas(); + let timer: ReturnType | null = null; + let start: { x: number; y: number } | null = null; + + const cancel = () => { + if (timer !== null) clearTimeout(timer); + timer = null; + start = null; + }; + + const onTouchStart = (event: TouchEvent) => { + lastTouchAt = performance.now(); + cancel(); + if (event.touches.length !== 1 || event.target !== canvas) return; + const touch = event.touches[0]; + start = { x: touch.clientX, y: touch.clientY }; + timer = setTimeout(() => { + timer = null; + if (!start) return; + const rect = canvas.getBoundingClientRect(); + const lngLat = map.unproject([start.x - rect.left, start.y - rect.top]); + start = null; + lastLongPressAt = performance.now(); + onLongPress([lngLat.lng, lngLat.lat], event); + }, LONG_PRESS_MS); + }; + + const onTouchMove = (event: TouchEvent) => { + if (!start) return; + const touch = event.touches[0]; + if ( + event.touches.length !== 1 || + !touch || + Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > LONG_PRESS_TOLERANCE_PX + ) { + cancel(); + } + }; + + const onTouchEnd = () => { + lastTouchAt = performance.now(); + cancel(); + }; + + container.addEventListener("touchstart", onTouchStart, { passive: true }); + container.addEventListener("touchmove", onTouchMove, { passive: true }); + container.addEventListener("touchend", onTouchEnd, { passive: true }); + container.addEventListener("touchcancel", onTouchEnd, { passive: true }); + return () => { + cancel(); + container.removeEventListener("touchstart", onTouchStart); + container.removeEventListener("touchmove", onTouchMove); + container.removeEventListener("touchend", onTouchEnd); + container.removeEventListener("touchcancel", onTouchEnd); + }; +} diff --git a/apps/web/src/lib/themeColor.test.ts b/apps/web/src/lib/themeColor.test.ts new file mode 100644 index 000000000..0dac036e5 --- /dev/null +++ b/apps/web/src/lib/themeColor.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom + +import { beforeEach, describe, expect, it } from "vitest"; +import { applyThemeColor, THEME_COLOR } from "./themeColor"; + +function meta(media: string): HTMLMetaElement { + const element = document.createElement("meta"); + element.name = "theme-color"; + element.media = media; + document.head.append(element); + return element; +} + +let light: HTMLMetaElement; +let dark: HTMLMetaElement; + +beforeEach(() => { + document.head.innerHTML = ""; + light = meta("(prefers-color-scheme: light)"); + dark = meta("(prefers-color-scheme: dark)"); + applyThemeColor(null); +}); + +describe("applyThemeColor", () => { + it("follows each tag's own scheme when the app follows the system", () => { + expect(light.content).toBe(THEME_COLOR.light); + expect(dark.content).toBe(THEME_COLOR.dark); + }); + + it("puts the bar on the dark colour whatever the system says when Dark is chosen", () => { + applyThemeColor("dark"); + expect(light.content).toBe(THEME_COLOR.dark); + expect(dark.content).toBe(THEME_COLOR.dark); + }); + + it("returns to the system's colours when System is chosen again", () => { + applyThemeColor("light"); + applyThemeColor(null); + expect(light.content).toBe(THEME_COLOR.light); + expect(dark.content).toBe(THEME_COLOR.dark); + }); +}); diff --git a/apps/web/src/lib/themeColor.ts b/apps/web/src/lib/themeColor.ts new file mode 100644 index 000000000..fc9db250d --- /dev/null +++ b/apps/web/src/lib/themeColor.ts @@ -0,0 +1,23 @@ +/** + * The browser chrome colour (Android's status bar, the installed app's title + * bar) for each colour scheme. The root layout declares one `theme-color` per + * system scheme; `ThemeColorSync` keeps them on the scheme the user picked in + * settings when that is not the system one. + */ +export const THEME_COLOR = { + light: "#207E23", + dark: "#1c1c1c", +} as const; + +type ThemeScheme = keyof typeof THEME_COLOR; + +/** + * Points every `theme-color` meta tag at `scheme`, or, with `null`, back at + * the scheme its `media` query names. + */ +export function applyThemeColor(scheme: ThemeScheme | null, root: ParentNode = document): void { + for (const meta of root.querySelectorAll('meta[name="theme-color"]')) { + const own: ThemeScheme = meta.media.includes("dark") ? "dark" : "light"; + meta.content = THEME_COLOR[scheme ?? own]; + } +} diff --git a/docs/docs/features/places.md b/docs/docs/features/places.md index ebec16795..e37777aae 100644 --- a/docs/docs/features/places.md +++ b/docs/docs/features/places.md @@ -11,6 +11,11 @@ Use it to check the available address, opening hours, contacts, photos, source credits, and related services. **Directions**, **Save**, **Nearby**, and **Share** provide the next actions. +To drop a pin, click an empty spot on the map; on a phone, long-press it. On a +phone, the pin's card also offers **From here**, **Share**, **Save parking**, +**Copy** and **Details**. A tap on the map puts the pin or the open place away, +and a place card closes when you swipe it down past its lowest position. + Details come from the selected source and enabled enrichment providers. Missing or outdated data can leave a partial card. Opening hours, ratings, and photos do not guarantee that a venue is currently open or accessible. diff --git a/docs/docs/features/vehicles-and-parking.md b/docs/docs/features/vehicles-and-parking.md index 33968a9f0..b5169f216 100644 --- a/docs/docs/features/vehicles-and-parking.md +++ b/docs/docs/features/vehicles-and-parking.md @@ -32,7 +32,8 @@ import retains local data so it can be retried. Save where you parked from three places, all of them a deliberate press: -- right-click (or the context key) on the map → **Save parking here** +- right-click (or the context key) on the map → **Save parking here**; on a phone, + long-press the map → **Save parking** - tap the blue location dot → **Save parking** - the arrival card after driving or motorcycling → **Save parking**