@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.
- package/README.md +1 -1
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api/button-definition.md +22 -5
- package/docs/api/map-style-config.md +2 -11
- package/docs/api/marker-config.md +19 -5
- package/docs/api/panel-definition.md +16 -0
- package/docs/api.md +101 -48
- package/docs/architecture.md +0 -2
- package/docs/assets/css/docusaurus.css +205 -0
- package/docs/assets/images/basic-map.jpg +0 -0
- package/docs/assets/images/button-first.jpg +0 -0
- package/{assets → docs/assets}/images/hero.png +0 -0
- package/docs/assets/images/marker-panel.jpg +0 -0
- package/docs/examples/add-marker-with-panel.mdx +123 -0
- package/docs/examples/basic-map.mdx +53 -0
- package/docs/examples/button-map.mdx +53 -0
- package/docs/examples/index.mdx +49 -0
- package/docs/getting-started.md +80 -53
- package/docs/index.mdx +1 -1
- package/docs/plugins/interact.md +32 -1
- package/docs/plugins.md +1 -1
- package/docusaurus.config.cjs +14 -5
- package/package.json +3 -3
- package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
- package/plugins/beta/scale-bar/dist/css/index.css +1 -1
- package/plugins/beta/scale-bar/src/scaleBar.scss +1 -0
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/InteractInit.jsx +5 -3
- package/plugins/interact/src/api/clear.js +1 -1
- package/plugins/interact/src/api/selectMarker.js +14 -0
- package/plugins/interact/src/api/selectMarker.test.js +25 -0
- package/plugins/interact/src/api/unselectMarker.js +14 -0
- package/plugins/interact/src/api/unselectMarker.test.js +14 -0
- package/plugins/interact/src/events.js +18 -30
- package/plugins/interact/src/events.test.js +113 -108
- package/plugins/interact/src/manifest.js +10 -2
- package/plugins/interact/src/reducer.js +36 -1
- package/plugins/interact/src/reducer.test.js +40 -1
- package/plugins/interact/src/utils/interactionModes.js +12 -0
- package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
- package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
- package/providers/maplibre/src/maplibreProvider.js +3 -2
- package/providers/maplibre/src/maplibreProvider.test.js +22 -0
- package/src/App/components/Panel/Panel.jsx +6 -6
- package/src/App/components/Panel/Panel.test.jsx +37 -0
- package/src/App/components/Viewport/Viewport.jsx +5 -15
- package/src/App/components/Viewport/Viewport.module.scss +2 -0
- package/src/App/components/Viewport/Viewport.test.jsx +16 -33
- package/src/App/hooks/useInterfaceAPI.js +7 -7
- package/src/App/hooks/useInterfaceAPI.test.js +15 -9
- package/src/App/hooks/useLayoutMeasurements.js +64 -72
- package/src/App/layout/Layout.jsx +1 -1
- package/src/App/layout/layout.module.scss +1 -8
- package/src/App/renderer/HtmlElementHost.jsx +10 -5
- package/src/App/renderer/mapPanels.js +2 -1
- package/src/App/store/appActionsMap.js +4 -4
- package/src/App/store/appActionsMap.test.js +10 -0
- package/src/InteractiveMap/InteractiveMap.js +61 -13
- package/src/InteractiveMap/InteractiveMap.test.js +130 -8
- package/src/InteractiveMap/domStateManager.js +18 -6
- package/src/InteractiveMap/domStateManager.test.js +21 -0
- package/src/InteractiveMap/historyManager.js +28 -16
- package/src/InteractiveMap/historyManager.test.js +17 -0
- package/src/config/appConfig.js +2 -1
- package/src/config/appConfig.test.js +3 -13
- package/src/config/defaults.js +2 -1
- package/src/config/events.js +20 -21
- package/src/services/closeApp.js +1 -10
- package/src/services/closeApp.test.js +3 -43
- package/src/services/eventBus.js +61 -0
- package/src/services/eventBus.test.js +70 -0
- package/src/types.js +6 -1
- package/src/utils/mapStateSync.js +48 -10
- package/src/utils/mapStateSync.test.js +29 -9
- package/assets/css/docusaurus.css +0 -104
- package/docs/examples.mdx +0 -70
- /package/docs/assets/{screens-blue.jpg → images/screens-blue.jpg} +0 -0
- /package/docs/assets/{screens-white.jpg → images/screens-white.jpg} +0 -0
- /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.
|
|
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.
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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'))
|
package/src/config/appConfig.js
CHANGED
|
@@ -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 || !
|
|
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: {
|
|
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:
|
|
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:
|
|
37
|
+
appConfig: { hasExitButton: true },
|
|
48
38
|
appState: { isFullscreen: true }
|
|
49
39
|
})).toBe(false)
|
|
50
40
|
})
|
package/src/config/defaults.js
CHANGED
|
@@ -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:
|
|
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,
|
package/src/config/events.js
CHANGED
|
@@ -62,32 +62,34 @@ export const EVENTS = {
|
|
|
62
62
|
APP_READY: 'app:ready',
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
|
-
* Emitted when the map application
|
|
65
|
+
* Emitted when the map application has opened and is visible.
|
|
66
66
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
|
|
77
|
+
APP_OPENED: 'app:opened',
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* Emitted when the map application
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
87
|
+
* @example
|
|
88
|
+
* map.on(EVENTS.APP_CLOSED, ({ statePreserved }) => {
|
|
89
|
+
* console.log('Map closed, state preserved:', statePreserved)
|
|
90
|
+
* })
|
|
89
91
|
*/
|
|
90
|
-
|
|
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
|
}
|
package/src/services/closeApp.js
CHANGED
|
@@ -1,13 +1,4 @@
|
|
|
1
1
|
// src/services/closeApp.js
|
|
2
|
-
|
|
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
|
-
|
|
5
|
-
|
|
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/services/eventBus.js
CHANGED
|
@@ -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()
|