@defra/interactive-map 0.0.18-alpha → 0.0.19-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/assets/css/docusaurus.css +58 -34
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/panel-definition.md +16 -0
  8. package/docs/api.md +28 -4
  9. package/docs/assets/basic-map.jpg +0 -0
  10. package/docs/assets/button-first.jpg +0 -0
  11. package/docs/assets/maker-panel.jpg +0 -0
  12. package/docs/examples/add-marker-with-panel.mdx +59 -0
  13. package/docs/examples/basic-map.mdx +24 -0
  14. package/docs/examples/button-map.mdx +24 -0
  15. package/docs/examples/index.mdx +49 -0
  16. package/docs/index.mdx +1 -1
  17. package/docs/plugins/interact.md +32 -1
  18. package/docs/plugins.md +1 -1
  19. package/docusaurus.config.cjs +9 -1
  20. package/package.json +1 -1
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  23. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  24. package/plugins/beta/draw-ml/dist/css/index.css +2 -19
  25. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  26. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  27. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  28. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  29. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  30. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  31. package/plugins/interact/dist/umd/index.js +1 -1
  32. package/plugins/interact/src/InteractInit.jsx +5 -3
  33. package/plugins/interact/src/api/clear.js +1 -1
  34. package/plugins/interact/src/api/selectMarker.js +14 -0
  35. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  36. package/plugins/interact/src/api/unselectMarker.js +14 -0
  37. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  38. package/plugins/interact/src/events.js +18 -30
  39. package/plugins/interact/src/events.test.js +113 -108
  40. package/plugins/interact/src/manifest.js +10 -2
  41. package/plugins/interact/src/reducer.js +36 -1
  42. package/plugins/interact/src/reducer.test.js +40 -1
  43. package/plugins/interact/src/utils/interactionModes.js +12 -0
  44. package/src/App/components/Panel/Panel.jsx +6 -6
  45. package/src/App/components/Panel/Panel.test.jsx +37 -0
  46. package/src/App/components/Viewport/Viewport.jsx +5 -15
  47. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  48. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  49. package/src/App/hooks/useInterfaceAPI.js +7 -7
  50. package/src/App/hooks/useInterfaceAPI.test.js +15 -9
  51. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  52. package/src/App/layout/Layout.jsx +1 -1
  53. package/src/App/layout/layout.module.scss +1 -8
  54. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  55. package/src/App/renderer/mapPanels.js +2 -1
  56. package/src/App/store/appActionsMap.js +4 -4
  57. package/src/App/store/appActionsMap.test.js +10 -0
  58. package/src/InteractiveMap/InteractiveMap.js +59 -11
  59. package/src/InteractiveMap/InteractiveMap.test.js +126 -4
  60. package/src/InteractiveMap/domStateManager.js +18 -6
  61. package/src/InteractiveMap/domStateManager.test.js +21 -0
  62. package/src/InteractiveMap/historyManager.js +28 -16
  63. package/src/InteractiveMap/historyManager.test.js +17 -0
  64. package/src/config/appConfig.js +2 -1
  65. package/src/config/appConfig.test.js +3 -13
  66. package/src/config/defaults.js +2 -1
  67. package/src/config/events.js +20 -21
  68. package/src/services/closeApp.js +1 -10
  69. package/src/services/closeApp.test.js +3 -43
  70. package/src/types.js +6 -1
  71. package/src/utils/mapStateSync.js +48 -10
  72. package/src/utils/mapStateSync.test.js +29 -9
  73. package/docs/examples.mdx +0 -70
@@ -117,7 +117,7 @@ describe('InteractiveMap Core Functionality', () => {
117
117
  })
118
118
 
119
119
  it('open button click calls _handleButtonClick / loadApp', async () => {
120
- const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
120
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
121
121
  const loadSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
122
122
  const pushStateSpy = jest.spyOn(history, 'pushState').mockImplementation(() => {})
123
123
  const fakeEvent = { currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } }
@@ -195,7 +195,7 @@ describe('InteractiveMap Core Functionality', () => {
195
195
  expect(map.unmount).toHaveBeenCalled()
196
196
  expect(mockButtonInstance.removeAttribute).toHaveBeenCalledWith('style')
197
197
  expect(mockButtonInstance.focus).toHaveBeenCalled()
198
- expect(updateDOMState).toHaveBeenCalledWith(map)
198
+ expect(updateDOMState).toHaveBeenCalledWith(map, { isFullscreen: false })
199
199
  })
200
200
 
201
201
  it('skips unmount if _root is falsy or unmount is not a function', () => {
@@ -275,6 +275,7 @@ describe('InteractiveMap Core Functionality', () => {
275
275
  behaviour: 'buttonFirst',
276
276
  mapProvider: mapProviderMock,
277
277
  mapViewParamKey: 'mv',
278
+ manageHistoryState: true,
278
279
  preserveStateOnClose: false
279
280
  })
280
281
 
@@ -303,6 +304,7 @@ describe('InteractiveMap Core Functionality', () => {
303
304
  behaviour: 'buttonFirst',
304
305
  mapProvider: mapProviderMock,
305
306
  mapViewParamKey: 'mv',
307
+ manageHistoryState: true,
306
308
  preserveStateOnClose: true
307
309
  })
308
310
 
@@ -321,7 +323,7 @@ describe('InteractiveMap Core Functionality', () => {
321
323
  })
322
324
 
323
325
  it('_handleButtonClick calls showApp when map is hidden', async () => {
324
- const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
326
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
325
327
  map._isHidden = true
326
328
  const showAppSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
327
329
  const loadAppSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
@@ -339,6 +341,126 @@ describe('InteractiveMap Core Functionality', () => {
339
341
  pushStateSpy.mockRestore()
340
342
  })
341
343
 
344
+ it('_handleButtonClick skips pushState when manageHistoryState is false', async () => {
345
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: false, mapProvider: mapProviderMock })
346
+ expect(map.config.manageHistoryState).toBe(false)
347
+ const pushStateSpy = jest.spyOn(history, 'pushState').mockImplementation(() => {})
348
+ const fakeEvent = { currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } }
349
+
350
+ await openButtonCallback(fakeEvent)
351
+
352
+ expect(pushStateSpy).not.toHaveBeenCalled()
353
+ pushStateSpy.mockRestore()
354
+ })
355
+
356
+ it('_handleExitClick calls history.back() when history.state.isBack is true', () => {
357
+ const backSpy = jest.spyOn(history, 'back').mockImplementation(() => {})
358
+ Object.defineProperty(history, 'state', { value: { isBack: true }, writable: true, configurable: true })
359
+
360
+ const map = new InteractiveMap('map', {
361
+ behaviour: 'buttonFirst',
362
+ mapProvider: mapProviderMock,
363
+ mapViewParamKey: 'mv',
364
+ manageHistoryState: true,
365
+ preserveStateOnClose: false
366
+ })
367
+ jest.spyOn(map, 'removeApp').mockImplementation(() => {})
368
+
369
+ map._handleExitClick()
370
+
371
+ expect(backSpy).toHaveBeenCalled()
372
+ Object.defineProperty(history, 'state', { value: null, writable: true, configurable: true })
373
+ backSpy.mockRestore()
374
+ })
375
+
376
+ it('_handleExitClick skips history when manageHistoryState is false', () => {
377
+ const backSpy = jest.spyOn(history, 'back').mockImplementation(() => {})
378
+ const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {})
379
+
380
+ const map = new InteractiveMap('map', {
381
+ behaviour: 'buttonFirst',
382
+ mapProvider: mapProviderMock,
383
+ mapViewParamKey: 'mv',
384
+ manageHistoryState: false,
385
+ preserveStateOnClose: false
386
+ })
387
+ jest.spyOn(map, 'removeApp').mockImplementation(() => {})
388
+
389
+ map._handleExitClick()
390
+
391
+ expect(backSpy).not.toHaveBeenCalled()
392
+ expect(replaceStateSpy).not.toHaveBeenCalled()
393
+ backSpy.mockRestore()
394
+ replaceStateSpy.mockRestore()
395
+ })
396
+
397
+ it('loadApp emits APP_OPENED with statePreserved: false', async () => {
398
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
399
+ await map.loadApp()
400
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: false })
401
+ })
402
+
403
+ it('removeApp emits APP_CLOSED with statePreserved: false', () => {
404
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
405
+ map._root = {}
406
+ map.unmount = jest.fn()
407
+ map.removeApp()
408
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:closed', { statePreserved: false })
409
+ })
410
+
411
+ it('hideApp emits APP_CLOSED with statePreserved: true', () => {
412
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
413
+ map.hideApp()
414
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:closed', { statePreserved: true })
415
+ })
416
+
417
+ it('showApp emits APP_OPENED with statePreserved: true', () => {
418
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
419
+ map._isHidden = true
420
+ map.showApp()
421
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: true })
422
+ })
423
+
424
+ it('open() shows hidden map', () => {
425
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
426
+ const showSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
427
+ map._isHidden = true
428
+ map.open()
429
+ expect(showSpy).toHaveBeenCalled()
430
+ showSpy.mockRestore()
431
+ })
432
+
433
+ it('open() loads map when not yet initialised', () => {
434
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
435
+ const loadSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
436
+ map._root = null
437
+ map._isHidden = false
438
+ map.open()
439
+ expect(loadSpy).toHaveBeenCalled()
440
+ loadSpy.mockRestore()
441
+ })
442
+
443
+ it('open() is a no-op when already open', () => {
444
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
445
+ const loadSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
446
+ const showSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
447
+ map._root = {}
448
+ map._isHidden = false
449
+ map.open()
450
+ expect(loadSpy).not.toHaveBeenCalled()
451
+ expect(showSpy).not.toHaveBeenCalled()
452
+ loadSpy.mockRestore()
453
+ showSpy.mockRestore()
454
+ })
455
+
456
+ it('close() delegates to _handleExitClick', () => {
457
+ const map = new InteractiveMap('map', { mapProvider: mapProviderMock })
458
+ const exitSpy = jest.spyOn(map, '_handleExitClick').mockImplementation(() => {})
459
+ map.close()
460
+ expect(exitSpy).toHaveBeenCalled()
461
+ exitSpy.mockRestore()
462
+ })
463
+
342
464
  it('hideApp sets _isHidden and hides element', () => {
343
465
  const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
344
466
  map._openButton = mockButtonInstance
@@ -497,7 +619,7 @@ describe('InteractiveMap Public API Methods', () => {
497
619
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:removepanel', 'panel1')
498
620
 
499
621
  // New assertions for coverage
500
- expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', 'panel2')
622
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', { id: 'panel2', focus: true })
501
623
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:hidepanel', 'panel3')
502
624
  })
503
625
 
@@ -14,11 +14,21 @@ function updatePageTitle ({ pageTitle, isFullscreen }) {
14
14
  }
15
15
 
16
16
  function getIsFullscreen (config) {
17
- const { id, behaviour } = config
18
- const hasViewParam = getQueryParam(defaults.mapViewParamKey) === id
17
+ const { id, behaviour, manageHistoryState } = config
19
18
 
20
- return behaviour === 'mapOnly' ||
21
- (hasViewParam && (behaviour === 'buttonFirst' || isHybridFullscreen(config)))
19
+ if (behaviour === 'mapOnly') {
20
+ return true
21
+ }
22
+
23
+ if (behaviour === 'buttonFirst') {
24
+ // When the SPA manages history, the app is always fullscreen when loaded
25
+ if (manageHistoryState === false) {
26
+ return true
27
+ }
28
+ return getQueryParam(defaults.mapViewParamKey) === id
29
+ }
30
+
31
+ return isHybridFullscreen(config) && getQueryParam(defaults.mapViewParamKey) === id
22
32
  }
23
33
 
24
34
  // -----------------------------------------------------------------------------
@@ -36,12 +46,14 @@ function getIsFullscreen (config) {
36
46
  * @param {string} mapInstance.config.behaviour - Behaviour mode ("mapOnly", "buttonFirst", "hybrid").
37
47
  * @param {string|number} mapInstance.config.containerHeight - Height to use when not fullscreen.
38
48
  * @param {HTMLElement} mapInstance.rootEl - Root element of the app.
49
+ * @param {Object} [options]
50
+ * @param {boolean} [options.isFullscreen] - Override the computed fullscreen state.
39
51
  * @returns {void}
40
52
  */
41
- function updateDOMState (mapInstance) {
53
+ function updateDOMState (mapInstance, { isFullscreen: isFullscreenOverride } = {}) {
42
54
  const { config, rootEl } = mapInstance
43
55
  const { pageTitle, behaviour, containerHeight } = config
44
- const isFullscreen = getIsFullscreen(config)
56
+ const isFullscreen = isFullscreenOverride ?? getIsFullscreen(config)
45
57
 
46
58
  if (['mapOnly', 'buttonFirst', 'hybrid'].includes(behaviour)) {
47
59
  toggleInertElements({ containerEl: rootEl, isFullscreen })
@@ -69,6 +69,27 @@ describe('updateDOMState', () => {
69
69
  }
70
70
  })
71
71
 
72
+ it('isFullscreen override forces non-fullscreen even when URL param matches', () => {
73
+ mapInstance.config.behaviour = 'buttonFirst'
74
+ queryString.getQueryParam.mockReturnValue('map') // URL says fullscreen
75
+
76
+ updateDOMState(mapInstance, { isFullscreen: false }) // but override says no
77
+
78
+ expect(document.documentElement.classList.contains('im-is-fullscreen')).toBe(false)
79
+ expect(rootEl.style.height).toBe('auto')
80
+ })
81
+
82
+ it('buttonFirst with manageHistoryState false is always fullscreen regardless of URL', () => {
83
+ mapInstance.config.behaviour = 'buttonFirst'
84
+ mapInstance.config.manageHistoryState = false
85
+ queryString.getQueryParam.mockReturnValue(null) // no mv param in URL
86
+
87
+ updateDOMState(mapInstance)
88
+
89
+ expect(document.documentElement.classList.contains('im-is-fullscreen')).toBe(true)
90
+ expect(mapInstance.rootEl.style.height).toBe('100%')
91
+ })
92
+
72
93
  describe('hybrid behaviour', () => {
73
94
  beforeEach(() => {
74
95
  mapInstance.config.behaviour = 'hybrid'
@@ -40,6 +40,32 @@ function closeMap (mapInstance) {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Syncs a single map instance against the current URL view parameter.
45
+ *
46
+ * @param {MapInstance} mapInstance
47
+ * @param {string|null} viewId - The current `mv` query param value.
48
+ * @private
49
+ */
50
+ function syncMapInstance (mapInstance, viewId) {
51
+ if (mapInstance.config.manageHistoryState === false) {
52
+ return
53
+ }
54
+
55
+ const shouldBeOpen = mapInstance.id === viewId
56
+ const isHybridVisible = mapInstance.config.behaviour === 'hybrid' && !isHybridFullscreen(mapInstance.config)
57
+ const isOpen = mapInstance.rootEl?.children.length
58
+
59
+ if (shouldBeOpen && (!isOpen || mapInstance._isHidden)) {
60
+ openMap(mapInstance)
61
+ return
62
+ }
63
+
64
+ if (!shouldBeOpen && isOpen && !isHybridVisible) {
65
+ closeMap(mapInstance)
66
+ }
67
+ }
68
+
43
69
  /**
44
70
  * Handles the `popstate` event triggered by browser back/forward navigation.
45
71
  *
@@ -56,21 +82,7 @@ function closeMap (mapInstance) {
56
82
  */
57
83
  function handlePopstate () {
58
84
  const viewId = getQueryParam(defaults.mapViewParamKey)
59
-
60
- for (const mapInstance of components.values()) {
61
- const shouldBeOpen = mapInstance.id === viewId
62
- const isHybridVisible = mapInstance.config.behaviour === 'hybrid' && !isHybridFullscreen(mapInstance.config)
63
- const isOpen = mapInstance.rootEl?.children.length
64
-
65
- if (shouldBeOpen && (!isOpen || mapInstance._isHidden)) {
66
- openMap(mapInstance)
67
- continue
68
- }
69
-
70
- if (!shouldBeOpen && isOpen && !isHybridVisible) {
71
- closeMap(mapInstance)
72
- }
73
- }
85
+ components.forEach(mapInstance => syncMapInstance(mapInstance, viewId))
74
86
  }
75
87
 
76
88
  // -----------------------------------------------------------------------------
@@ -105,7 +117,7 @@ let initialized = false
105
117
  */
106
118
  function register (component) {
107
119
  if (!initialized) {
108
- window.addEventListener('popstate', handlePopstate)
120
+ globalThis.addEventListener('popstate', handlePopstate)
109
121
  initialized = true
110
122
  }
111
123
 
@@ -141,6 +141,23 @@ describe('historyManager', () => {
141
141
  expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 768px)')
142
142
  })
143
143
 
144
+ it('skips component when manageHistoryState is false', () => {
145
+ const managedComponent = {
146
+ id: 'managed',
147
+ config: { behaviour: 'buttonFirst', hybridWidth: null, maxMobileWidth: 640, manageHistoryState: false },
148
+ rootEl: document.createElement('div'),
149
+ loadApp: jest.fn(),
150
+ removeApp: jest.fn(),
151
+ _isHidden: false
152
+ }
153
+ historyManager.register(managedComponent)
154
+ queryString.getQueryParam.mockReturnValue('managed')
155
+
156
+ window.dispatchEvent(popstateEvent)
157
+
158
+ expect(managedComponent.loadApp).not.toHaveBeenCalled()
159
+ })
160
+
144
161
  it('unregisters component', () => {
145
162
  historyManager.register(component1)
146
163
  component1.rootEl.appendChild(document.createElement('div'))
@@ -24,7 +24,7 @@ export const defaultAppConfig = {
24
24
  label: 'Exit',
25
25
  iconId: 'close',
26
26
  onClick: (_e, { services }) => services.closeApp(),
27
- excludeWhen: ({ appConfig, appState }) => !appConfig.hasExitButton || !(appState.isFullscreen && (new URL(window.location.href)).searchParams.has(appConfig.mapViewParamKey)),
27
+ excludeWhen: ({ appConfig, appState }) => !appConfig.hasExitButton || !appState.isFullscreen,
28
28
  mobile: exitButtonSlots,
29
29
  tablet: exitButtonSlots,
30
30
  desktop: exitButtonSlots
@@ -115,6 +115,7 @@ export const defaultButtonConfig = {
115
115
  // Used by addPanel
116
116
  export const defaultPanelConfig = {
117
117
  label: 'Panel',
118
+ focus: true,
118
119
  mobile: {
119
120
  slot: 'drawer',
120
121
  open: true,
@@ -23,28 +23,18 @@ describe('defaultAppConfig', () => {
23
23
 
24
24
  // --- EXIT BUTTON (Line 27 Coverage) ---
25
25
  it('covers all branches of exitBtn excludeWhen', () => {
26
- const config = { hasExitButton: true, mapViewParamKey: 'view' }
27
-
28
26
  expect(exitBtn.excludeWhen({
29
- appConfig: { ...config, hasExitButton: false },
27
+ appConfig: { hasExitButton: false },
30
28
  appState: { isFullscreen: true }
31
29
  })).toBe(true)
32
30
 
33
- window.history.pushState({}, '', '?view=map')
34
31
  expect(exitBtn.excludeWhen({
35
- appConfig: config,
32
+ appConfig: { hasExitButton: true },
36
33
  appState: { isFullscreen: false }
37
34
  })).toBe(true)
38
35
 
39
- window.history.pushState({}, '', '?wrong=param')
40
- expect(exitBtn.excludeWhen({
41
- appConfig: config,
42
- appState: { isFullscreen: true }
43
- })).toBe(true)
44
-
45
- window.history.pushState({}, '', '?view=map')
46
36
  expect(exitBtn.excludeWhen({
47
- appConfig: config,
37
+ appConfig: { hasExitButton: true },
48
38
  appState: { isFullscreen: true }
49
39
  })).toBe(false)
50
40
  })
@@ -16,7 +16,7 @@ const defaults = {
16
16
  containerHeight: '600px',
17
17
  deviceNotSupportedText: 'Your device is not supported. A map is available with a more up-to-date browser or device.',
18
18
  enableFullscreen: false,
19
- enableZoomControls: false,
19
+ enableZoomControls: true,
20
20
  genericErrorText: 'There was a problem loading the map. Please try again later.',
21
21
  hasExitButton: false,
22
22
  hybridWidth: null, // Defaults to maxMobileWidth if not set
@@ -24,6 +24,7 @@ const defaults = {
24
24
  mapLabel: 'Interactive map',
25
25
  mapProvider: null,
26
26
  mapSize: 'small',
27
+ manageHistoryState: true,
27
28
  mapViewParamKey: 'mv',
28
29
  maxMobileWidth: 640,
29
30
  minDesktopWidth: 835,
@@ -62,32 +62,34 @@ export const EVENTS = {
62
62
  APP_READY: 'app:ready',
63
63
 
64
64
  /**
65
- * Emitted when the map application becomes visible after being hidden.
65
+ * Emitted when the map application has opened and is visible.
66
66
  *
67
- * This can occur in 'hybrid behaviour' responsive scenarios where the map is already initialized
68
- * (e.g. initialized inline on desktop) but was hidden and then shown again
69
- * (e.g. resizing to mobile and opening the map).
67
+ * Fired after initial load (`loadApp`) and when the app is shown again after being hidden (`showApp`).
68
+ * Subscribe to this event to react whenever the map becomes visible to the user.
70
69
  *
71
- * @remarks
72
- * - Only emitted when transitioning from hidden → visible.
73
- * - Not fired on initial open.
74
- * - The existing map state may be preserved depending on configuration.
70
+ * Payload: `{ statePreserved: boolean }` — `true` if the map state was preserved from a previous session.
71
+ *
72
+ * @example
73
+ * map.on(EVENTS.APP_OPENED, ({ statePreserved }) => {
74
+ * console.log('Map opened, state preserved:', statePreserved)
75
+ * })
75
76
  */
76
- APP_VISIBLE: 'app:visible',
77
+ APP_OPENED: 'app:opened',
77
78
 
78
79
  /**
79
- * Emitted when the map application becomes hidden.
80
+ * Emitted when the map application has closed and is no longer visible.
81
+ *
82
+ * Fired when the app is hidden (`hideApp`) or removed (`removeApp`).
83
+ * Subscribe to this event to react whenever the map is closed.
80
84
  *
81
- * This can occur in 'hybrid behaviour' responsive scenarios where the map was initialized inline
82
- * (e.g. visible on desktop) but then becomes hidden
83
- * (e.g. resizing to mobile or closing the map view).
85
+ * Payload: `{ statePreserved: boolean }` `true` if the map state was preserved (i.e. can be restored).
84
86
  *
85
- * @remarks
86
- * - Only emitted when transitioning from visible → hidden.
87
- * - Not fired on initial load if the map starts hidden.
88
- * - The map state may be preserved depending on configuration.
87
+ * @example
88
+ * map.on(EVENTS.APP_CLOSED, ({ statePreserved }) => {
89
+ * console.log('Map closed, state preserved:', statePreserved)
90
+ * })
89
91
  */
90
- APP_HIDDEN: 'app:hidden',
92
+ APP_CLOSED: 'app:closed',
91
93
 
92
94
  /**
93
95
  * Emitted when a panel is opened.
@@ -202,9 +204,6 @@ export const EVENTS = {
202
204
  */
203
205
  MAP_CLICK: 'map:click',
204
206
 
205
- /** Emitted when the user exits the map (e.g., via the close button). */
206
- MAP_EXIT: 'map:exit',
207
-
208
207
  /** Emitted when the map is destroyed. Payload: { mapId: string } */
209
208
  MAP_DESTROY: 'map:destroy'
210
209
  }
@@ -1,13 +1,4 @@
1
1
  // src/services/closeApp.js
2
- import { EVENTS as events } from '../config/events.js'
3
-
4
- export function closeApp (mapId, handleExitClick, eventBus) {
5
- eventBus.emit(events.MAP_EXIT, { mapId })
6
-
7
- if (history.state?.isBack) {
8
- history.back()
9
- return
10
- }
11
-
2
+ export function closeApp (_mapId, handleExitClick) {
12
3
  handleExitClick()
13
4
  }
@@ -1,49 +1,9 @@
1
1
  import { closeApp } from './closeApp'
2
2
 
3
3
  describe('closeApp', () => {
4
- let handleExitClickMock
5
- let mockEventBus
6
-
7
- beforeEach(() => {
8
- handleExitClickMock = jest.fn()
9
- mockEventBus = {
10
- emit: jest.fn(),
11
- on: jest.fn(),
12
- off: jest.fn()
13
- }
14
- jest.spyOn(history, 'back').mockImplementation(() => {})
15
- })
16
-
17
- afterEach(() => {
18
- jest.restoreAllMocks()
19
- jest.clearAllMocks()
20
- })
21
-
22
- it('calls history.back() when history.state.isBack is true', () => {
23
- Object.defineProperty(history, 'state', {
24
- value: { isBack: true },
25
- writable: true,
26
- configurable: true
27
- })
28
-
29
- closeApp('map-123', handleExitClickMock, mockEventBus)
30
-
31
- expect(mockEventBus.emit).toHaveBeenCalledWith('map:exit', { mapId: 'map-123' })
32
- expect(history.back).toHaveBeenCalled()
33
- expect(handleExitClickMock).not.toHaveBeenCalled()
34
- })
35
-
36
- it('calls handleExitClick when history.state.isBack is not true', () => {
37
- Object.defineProperty(history, 'state', {
38
- value: null,
39
- writable: true,
40
- configurable: true
41
- })
42
-
43
- closeApp('map-123', handleExitClickMock, mockEventBus)
44
-
45
- expect(mockEventBus.emit).toHaveBeenCalledWith('map:exit', { mapId: 'map-123' })
46
- expect(history.back).not.toHaveBeenCalled()
4
+ it('calls handleExitClick', () => {
5
+ const handleExitClickMock = jest.fn()
6
+ closeApp('map-123', handleExitClickMock)
47
7
  expect(handleExitClickMock).toHaveBeenCalled()
48
8
  })
49
9
  })
package/src/types.js CHANGED
@@ -591,7 +591,7 @@
591
591
  * @property {boolean} [enableFullscreen=false]
592
592
  * Whether a toggle fullscreen button is displayed.
593
593
  *
594
- * @property {boolean} [enableZoomControls=false]
594
+ * @property {boolean} [enableZoomControls=true]
595
595
  * Whether zoom control buttons are displayed.
596
596
  *
597
597
  * @property {[number, number, number, number]} [extent]
@@ -661,6 +661,11 @@
661
661
  * @property {PluginDescriptor[]} [plugins]
662
662
  * Plugins to load.
663
663
  *
664
+ * @property {boolean} [manageHistoryState=true]
665
+ * Whether the library should manage browser history state (pushState/replaceState) when opening and closing the map.
666
+ * Set to `false` in SPA frameworks (e.g. React Router, Docusaurus) that intercept history API calls.
667
+ * When `false`, listen to `APP_OPENED` and `APP_CLOSED` events and manage navigation in your router instead.
668
+ *
664
669
  * @property {boolean} [preserveStateOnClose=false]
665
670
  * Whether to preserve the map state when closed via back button or exit button.
666
671
  * When true, the map is hidden but not destroyed, preserving markers, zoom, etc.
@@ -1,4 +1,11 @@
1
- // Internal (not exported) - Accept optional search string for testing
1
+ /**
2
+ * Reads center and zoom for a given map ID from a URL search string.
3
+ * The `search` parameter is accepted explicitly to keep this function pure and testable.
4
+ *
5
+ * @param {string} id - Map instance ID.
6
+ * @param {string} search - URL search string (e.g. `window.location.search`).
7
+ * @returns {{ center: [number, number], zoom: number } | null}
8
+ */
2
9
  const getMapStateFromURL = (id, search) => {
3
10
  const params = new URLSearchParams(search)
4
11
  const centerStr = params.get(`${id}:center`)
@@ -11,30 +18,61 @@ const getMapStateFromURL = (id, search) => {
11
18
  return { center: [lng, lat], zoom }
12
19
  }
13
20
 
21
+ /**
22
+ * Persists map center and zoom into the page URL without triggering navigation.
23
+ *
24
+ * Existing query parameters are preserved. Parameters for this map ID are
25
+ * replaced if already present. Builds the URL manually to avoid percent-encoding
26
+ * colons and commas that URLSearchParams would otherwise encode.
27
+ *
28
+ * @param {string} id - Map instance ID, used as a namespace prefix for the params.
29
+ * @param {{ center?: [number, number], zoom?: number }} state - Map state to write.
30
+ * @param {string} [currentHref] - URL to update. Defaults to `window.location.href`.
31
+ * @returns {void}
32
+ */
14
33
  const setMapStateInURL = (id, state, currentHref = window.location.href) => {
15
- // Use the passed href or the global one
16
34
  const url = new URL(currentHref || 'http://localhost')
17
- const params = [...new URLSearchParams(url.search)].map(([key, value]) => `${key}=${value}`)
35
+
36
+ const newKeys = new Set()
18
37
  const newParams = []
19
38
 
20
39
  if (state.center) {
21
- newParams.push(`${id}:center=${state.center[0]},${state.center[1]}`)
40
+ const key = `${id}:center`
41
+ newKeys.add(key)
42
+ newParams.push(`${key}=${state.center[0]},${state.center[1]}`)
22
43
  }
23
44
  if (state.zoom != null) {
24
- newParams.push(`${id}:zoom=${state.zoom}`)
45
+ const key = `${id}:zoom`
46
+ newKeys.add(key)
47
+ newParams.push(`${key}=${state.zoom}`)
25
48
  }
26
49
 
27
- const filteredParams = params.filter(p => {
28
- return !newParams.some(np => np.split('=')[0] === p.split('=')[0])
50
+ const existingParams = []
51
+ url.searchParams.forEach((value, key) => {
52
+ if (!newKeys.has(key)) {
53
+ existingParams.push(`${key}=${value}`)
54
+ }
29
55
  })
30
56
 
31
- const hash = url.hash || ''
32
- const newUrl = `${url.origin}${url.pathname}?${[...filteredParams, ...newParams].join('&')}${hash}`
57
+ const allParams = [...existingParams, ...newParams].join('&')
58
+ const search = allParams ? '?' + allParams : ''
59
+ const newUrl = `${url.origin}${url.pathname}${search}${url.hash}`
33
60
  window.history.replaceState(window.history.state, '', newUrl)
34
61
  }
35
62
 
63
+ /**
64
+ * Returns the initial map view state, preferring any saved state from the URL.
65
+ *
66
+ * Resolution order:
67
+ * 1. Center/zoom encoded in the URL search string for this map ID.
68
+ * 2. A `bounds` value if provided (used to fit the view on load).
69
+ * 3. The configured `center` and `zoom` defaults.
70
+ *
71
+ * @param {{ id: string, center: [number, number], zoom: number, bounds?: any }} config - Map config.
72
+ * @param {string} [search] - URL search string. Defaults to `window.location.search`.
73
+ * @returns {{ center: [number, number], zoom: number } | { bounds: any }}
74
+ */
36
75
  const getInitialMapState = ({ id, center, zoom, bounds }, search = window.location.search) => {
37
- // Pass search string down to the internal function
38
76
  const savedState = getMapStateFromURL(id, search)
39
77
  if (savedState) {
40
78
  return {