@defra/interactive-map 0.0.18-alpha → 0.0.20-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 (91) hide show
  1. package/README.md +1 -1
  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/button-definition.md +22 -5
  8. package/docs/api/map-style-config.md +2 -11
  9. package/docs/api/marker-config.md +19 -5
  10. package/docs/api/panel-definition.md +16 -0
  11. package/docs/api.md +101 -48
  12. package/docs/architecture.md +0 -2
  13. package/docs/assets/css/docusaurus.css +205 -0
  14. package/docs/assets/images/basic-map.jpg +0 -0
  15. package/docs/assets/images/button-first.jpg +0 -0
  16. package/{assets → docs/assets}/images/hero.png +0 -0
  17. package/docs/assets/images/marker-panel.jpg +0 -0
  18. package/docs/examples/add-marker-with-panel.mdx +123 -0
  19. package/docs/examples/basic-map.mdx +53 -0
  20. package/docs/examples/button-map.mdx +53 -0
  21. package/docs/examples/index.mdx +49 -0
  22. package/docs/getting-started.md +80 -53
  23. package/docs/index.mdx +1 -1
  24. package/docs/plugins/interact.md +32 -1
  25. package/docs/plugins.md +1 -1
  26. package/docusaurus.config.cjs +14 -5
  27. package/package.json +3 -3
  28. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  31. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  32. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  33. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  34. package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
  35. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  36. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  37. package/plugins/interact/dist/umd/index.js +1 -1
  38. package/plugins/interact/src/InteractInit.jsx +5 -3
  39. package/plugins/interact/src/api/clear.js +1 -1
  40. package/plugins/interact/src/api/selectMarker.js +14 -0
  41. package/plugins/interact/src/api/selectMarker.test.js +25 -0
  42. package/plugins/interact/src/api/unselectMarker.js +14 -0
  43. package/plugins/interact/src/api/unselectMarker.test.js +14 -0
  44. package/plugins/interact/src/events.js +18 -30
  45. package/plugins/interact/src/events.test.js +113 -108
  46. package/plugins/interact/src/manifest.js +10 -2
  47. package/plugins/interact/src/reducer.js +36 -1
  48. package/plugins/interact/src/reducer.test.js +40 -1
  49. package/plugins/interact/src/utils/interactionModes.js +12 -0
  50. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  51. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  52. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  53. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  54. package/providers/maplibre/src/maplibreProvider.js +3 -2
  55. package/providers/maplibre/src/maplibreProvider.test.js +22 -0
  56. package/src/App/components/Panel/Panel.jsx +6 -6
  57. package/src/App/components/Panel/Panel.test.jsx +37 -0
  58. package/src/App/components/Viewport/Viewport.jsx +5 -15
  59. package/src/App/components/Viewport/Viewport.module.scss +2 -0
  60. package/src/App/components/Viewport/Viewport.test.jsx +16 -33
  61. package/src/App/hooks/useInterfaceAPI.js +7 -7
  62. package/src/App/hooks/useInterfaceAPI.test.js +15 -9
  63. package/src/App/hooks/useLayoutMeasurements.js +64 -72
  64. package/src/App/layout/Layout.jsx +1 -1
  65. package/src/App/layout/layout.module.scss +1 -8
  66. package/src/App/renderer/HtmlElementHost.jsx +10 -5
  67. package/src/App/renderer/mapPanels.js +2 -1
  68. package/src/App/store/appActionsMap.js +4 -4
  69. package/src/App/store/appActionsMap.test.js +10 -0
  70. package/src/InteractiveMap/InteractiveMap.js +61 -13
  71. package/src/InteractiveMap/InteractiveMap.test.js +130 -8
  72. package/src/InteractiveMap/domStateManager.js +18 -6
  73. package/src/InteractiveMap/domStateManager.test.js +21 -0
  74. package/src/InteractiveMap/historyManager.js +28 -16
  75. package/src/InteractiveMap/historyManager.test.js +17 -0
  76. package/src/config/appConfig.js +2 -1
  77. package/src/config/appConfig.test.js +3 -13
  78. package/src/config/defaults.js +2 -1
  79. package/src/config/events.js +20 -21
  80. package/src/services/closeApp.js +1 -10
  81. package/src/services/closeApp.test.js +3 -43
  82. package/src/services/eventBus.js +61 -0
  83. package/src/services/eventBus.test.js +70 -0
  84. package/src/types.js +6 -1
  85. package/src/utils/mapStateSync.js +48 -10
  86. package/src/utils/mapStateSync.test.js +29 -9
  87. package/assets/css/docusaurus.css +0 -104
  88. package/docs/examples.mdx +0 -70
  89. /package/docs/assets/{screens-blue.jpg → images/screens-blue.jpg} +0 -0
  90. /package/docs/assets/{screens-white.jpg → images/screens-white.jpg} +0 -0
  91. /package/{assets → docs/assets}/images/slot-map.svg +0 -0
@@ -39,8 +39,8 @@ jest.mock('../utils/detectInterfaceType.js', () => ({
39
39
  }))
40
40
  jest.mock('../services/reverseGeocode.js', () => ({ createReverseGeocode: jest.fn() }))
41
41
  jest.mock('../services/eventBus.js', () => ({
42
- createEventBus: jest.fn(() => ({ on: jest.fn(), off: jest.fn(), emit: jest.fn(), destroy: jest.fn() })),
43
- default: { on: jest.fn(), off: jest.fn(), emit: jest.fn(), destroy: jest.fn() }
42
+ createEventBus: jest.fn(() => ({ on: jest.fn(), off: jest.fn(), emit: jest.fn(), emitWhenReady: jest.fn(), destroy: jest.fn() })),
43
+ default: { on: jest.fn(), off: jest.fn(), emit: jest.fn(), emitWhenReady: jest.fn(), destroy: jest.fn() }
44
44
  }))
45
45
  jest.mock('../App/initialiseApp.js', () => ({ initialiseApp: jest.fn() }))
46
46
 
@@ -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
 
@@ -513,12 +635,12 @@ describe('InteractiveMap Public API Methods', () => {
513
635
  it('fitToBounds emits MAP_FIT_TO_BOUNDS with bbox', () => {
514
636
  const bbox = [-0.489, 51.28, 0.236, 51.686]
515
637
  map.fitToBounds(bbox)
516
- expect(map.eventBus.emit).toHaveBeenCalledWith('map:fittobounds', bbox)
638
+ expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:fittobounds', bbox)
517
639
  })
518
640
 
519
641
  it('setView emits MAP_SET_VIEW with opts', () => {
520
642
  const opts = { center: [-0.1276, 51.5074], zoom: 12 }
521
643
  map.setView(opts)
522
- expect(map.eventBus.emit).toHaveBeenCalledWith('map:setview', opts)
644
+ expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:setview', opts)
523
645
  })
524
646
  })
@@ -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
  })
@@ -1,16 +1,43 @@
1
1
  class EventBus {
2
2
  constructor () {
3
3
  this.events = {}
4
+ this._queued = {}
4
5
  }
5
6
 
7
+ /**
8
+ * Register a handler for an event. If a value was queued via `emitWhenReady`
9
+ * before any listeners existed, the handler is called immediately with that
10
+ * value and the queue is cleared.
11
+ *
12
+ * @param {string} eventName
13
+ * @param {Function} handler
14
+ * @returns {this}
15
+ */
6
16
  on (eventName, handler) {
7
17
  if (!this.events[eventName]) {
8
18
  this.events[eventName] = []
9
19
  }
10
20
  this.events[eventName].push(handler)
21
+
22
+ if (this._queued[eventName] !== undefined) {
23
+ try {
24
+ handler(...this._queued[eventName])
25
+ } catch (error) {
26
+ console.error(`Error in event handler for '${eventName}':`, error)
27
+ }
28
+ delete this._queued[eventName]
29
+ }
30
+
11
31
  return this
12
32
  }
13
33
 
34
+ /**
35
+ * Remove a handler for an event. Omit `handler` to remove all handlers.
36
+ *
37
+ * @param {string} eventName
38
+ * @param {Function} [handler]
39
+ * @returns {this}
40
+ */
14
41
  off (eventName, handler) {
15
42
  if (!this.events[eventName]) {
16
43
  return this
@@ -23,6 +50,13 @@ class EventBus {
23
50
  return this
24
51
  }
25
52
 
53
+ /**
54
+ * Emit an event, calling all registered handlers synchronously.
55
+ *
56
+ * @param {string} eventName
57
+ * @param {...*} args
58
+ * @returns {this}
59
+ */
26
60
  emit (eventName, ...args) {
27
61
  if (!this.events[eventName]) {
28
62
  return this
@@ -38,13 +72,40 @@ class EventBus {
38
72
  return this
39
73
  }
40
74
 
75
+ /**
76
+ * Like `emit`, but queues the args if no listeners are registered yet.
77
+ * The first listener to subscribe will receive the queued value immediately,
78
+ * after which the queue is cleared. Subsequent `emitWhenReady` calls for the
79
+ * same event before a listener arrives replace the queued value.
80
+ *
81
+ * Use this for events that may be emitted before the listener side is ready
82
+ * (e.g. called from `map:ready` before React effects have run).
83
+ *
84
+ * @param {string} eventName
85
+ * @param {...*} args
86
+ * @returns {this}
87
+ */
88
+ emitWhenReady (eventName, ...args) {
89
+ if (!this.events[eventName] || this.events[eventName].length === 0) {
90
+ this._queued[eventName] = args
91
+ return this
92
+ }
93
+ return this.emit(eventName, ...args)
94
+ }
95
+
96
+ /**
97
+ * Remove all handlers and clear any queued events.
98
+ */
41
99
  destroy () {
42
100
  this.events = {}
101
+ this._queued = {}
43
102
  }
44
103
  }
45
104
 
46
105
  /**
47
106
  * Factory for map-local EventBus
107
+ *
108
+ * @returns {EventBus}
48
109
  */
49
110
  export function createEventBus () {
50
111
  return new EventBus()