diff --git a/client/src/components/Planner/placeNavigation.test.ts b/client/src/components/Planner/placeNavigation.test.ts index d2ea8ddc0..c99dc81de 100644 --- a/client/src/components/Planner/placeNavigation.test.ts +++ b/client/src/components/Planner/placeNavigation.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { getNavigationTargets, showsAppleMaps } from './placeNavigation' +import { getNavigationTargets, openNavigationTarget, showsAppleMaps } from './placeNavigation' import type { Place } from '../../types' // FE-PLANNER-NAV-001 to FE-PLANNER-NAV-008 @@ -105,3 +105,42 @@ describe('getNavigationTargets', () => { expect(targets.find(t => t.id === 'waze')!.url).toBe('https://waze.com/ul?ll=48.2038,16.3616&navigate=yes') }) }) + +describe('openNavigationTarget', () => { + const target = { id: 'google', label: 'Google Maps', url: 'https://maps.google.com/?q=1,2' } as const + + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) + + function stubDisplayMode(installed: boolean, iosStandalone = false) { + const open = vi.fn() + const assigned: string[] = [] + vi.stubGlobal('window', { + matchMedia: (q: string) => ({ matches: installed && q.includes('display-mode') }), + navigator: { standalone: iosStandalone }, + open, + get location() { return { get href() { return '' }, set href(v: string) { assigned.push(v) } } }, + }) + return { open, assigned } + } + + it('FE-PLANNER-NAV-101: a browser tab keeps opening a tab', () => { + const { open, assigned } = stubDisplayMode(false) + openNavigationTarget(target) + expect(open).toHaveBeenCalledWith(target.url, '_blank', 'noopener,noreferrer') + expect(assigned).toEqual([]) + }) + + it('FE-PLANNER-NAV-102: an installed app hands over from the current context, leaving no blank window (#2218)', () => { + const { open, assigned } = stubDisplayMode(true) + openNavigationTarget(target) + expect(open).not.toHaveBeenCalled() + expect(assigned).toEqual([target.url]) + }) + + it('FE-PLANNER-NAV-103: an iOS home-screen app is recognised without the display-mode query', () => { + const { open, assigned } = stubDisplayMode(false, true) + openNavigationTarget(target) + expect(open).not.toHaveBeenCalled() + expect(assigned).toEqual([target.url]) + }) +}) diff --git a/client/src/components/Planner/placeNavigation.ts b/client/src/components/Planner/placeNavigation.ts index 725e5ca86..61af1fb31 100644 --- a/client/src/components/Planner/placeNavigation.ts +++ b/client/src/components/Planner/placeNavigation.ts @@ -95,7 +95,28 @@ export function getNavigationTargets( return targets } -/** Opens a target the way every external link in the planner is opened. */ +/** + * Hands the place over to a maps application. + * + * In a browser tab this opens a tab, which is what a link should do. In an + * installed app it must not: the maps application takes over from the new + * context before it paints, so what the user comes back to is an empty window + * they have to dismiss before TREK is usable again (#2218). Navigating the + * current context instead means the handover happens from the page the user is + * already on, and returning lands them back where they were, because the app + * shell was never replaced by the time the platform switched away. + */ export function openNavigationTarget(target: NavigationTarget): void { - window.open(target.url, '_blank', 'noopener,noreferrer') + if (isInstalledApp()) window.location.href = target.url + else window.open(target.url, '_blank', 'noopener,noreferrer') +} + +/** True in a display mode that has no tab strip to close a stray window from. */ +function isInstalledApp(): boolean { + if (typeof window === 'undefined') return false + const standalone = ['standalone', 'fullscreen', 'minimal-ui'].some( + mode => window.matchMedia?.(`(display-mode: ${mode})`).matches, + ) + // iOS predates the display-mode query for home-screen apps. + return standalone || (window.navigator as { standalone?: boolean }).standalone === true }