@defra/interactive-map 0.0.4-alpha → 0.0.5-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 (202) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/index.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +8 -0
  7. package/package.json +3 -2
  8. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
  9. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
  10. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
  11. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
  12. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  13. package/plugins/beta/datasets/src/datasets.js +55 -2
  14. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
  15. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
  16. package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
  17. package/plugins/beta/datasets/src/mapLayers.js +41 -3
  18. package/plugins/beta/datasets/src/utils/bbox.js +113 -2
  19. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  23. package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
  24. package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
  25. package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
  26. package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
  27. package/plugins/beta/draw-ml/src/defaults.js +1 -0
  28. package/plugins/beta/draw-ml/src/draw.scss +23 -0
  29. package/plugins/beta/draw-ml/src/events.js +55 -113
  30. package/plugins/beta/draw-ml/src/manifest.js +64 -38
  31. package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
  32. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
  33. package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
  34. package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
  35. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
  36. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
  37. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
  38. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
  39. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
  40. package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
  41. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  42. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  43. package/plugins/beta/frame/src/Frame.jsx +1 -1
  44. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  45. package/plugins/beta/map-styles/dist/esm/index.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
  49. package/plugins/interact/dist/css/index.css +1 -1
  50. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/index.js +1 -1
  53. package/plugins/interact/src/InteractInit.jsx +1 -1
  54. package/plugins/interact/src/defaults.js +1 -0
  55. package/plugins/interact/src/events.js +37 -52
  56. package/plugins/interact/src/events.test.js +35 -0
  57. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  58. package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
  59. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
  60. package/plugins/interact/src/interact.scss +6 -0
  61. package/plugins/interact/src/reducer.js +25 -27
  62. package/plugins/interact/src/reducer.test.js +19 -6
  63. package/plugins/interact/src/utils/featureQueries.js +2 -2
  64. package/plugins/interact/src/utils/spatial.js +95 -0
  65. package/plugins/interact/src/utils/spatial.test.js +93 -0
  66. package/plugins/search/dist/css/index.css +1 -1
  67. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  68. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  69. package/plugins/search/dist/umd/index.js +1 -1
  70. package/plugins/search/src/Search.jsx +2 -2
  71. package/plugins/search/src/components/Form/Form.jsx +1 -1
  72. package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
  73. package/plugins/search/src/datasets.js +1 -1
  74. package/plugins/search/src/defaults.js +3 -0
  75. package/plugins/search/src/events/fetchSuggestions.js +48 -53
  76. package/plugins/search/src/events/formHandlers.js +3 -2
  77. package/plugins/search/src/events/index.js +1 -1
  78. package/plugins/search/src/events/inputHandlers.js +3 -1
  79. package/plugins/search/src/events/suggestionHandlers.js +12 -3
  80. package/plugins/search/src/search.scss +4 -1
  81. package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
  82. package/providers/beta/esri/src/esriProvider.js +1 -1
  83. package/providers/beta/esri/src/index.js +1 -1
  84. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/esm/index.js +1 -1
  86. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  87. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  88. package/providers/maplibre/dist/umd/index.js +1 -1
  89. package/providers/maplibre/src/defaults.js +3 -2
  90. package/providers/maplibre/src/index.js +22 -19
  91. package/providers/maplibre/src/maplibreProvider.js +13 -11
  92. package/providers/maplibre/src/utils/detectWebgl.js +1 -2
  93. package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
  94. package/providers/maplibre/src/utils/labels.js +174 -120
  95. package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
  96. package/providers/maplibre/src/utils/queryFeatures.js +146 -0
  97. package/providers/maplibre/src/utils/spatial.js +14 -17
  98. package/sonar-project.properties +39 -2
  99. package/src/App/components/Actions/Actions.jsx +2 -2
  100. package/src/App/components/Actions/Actions.module.scss +2 -2
  101. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
  102. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
  103. package/src/App/components/Logo/Logo.jsx +1 -1
  104. package/src/App/components/MapButton/MapButton.jsx +217 -41
  105. package/src/App/components/MapButton/MapButton.module.scss +32 -21
  106. package/src/App/components/MapButton/MapButton.test.jsx +128 -84
  107. package/src/App/components/Markers/Markers.jsx +1 -1
  108. package/src/App/components/Markers/Markers.module.scss +0 -5
  109. package/src/App/components/Panel/Panel.jsx +75 -45
  110. package/src/App/components/Panel/Panel.module.scss +18 -24
  111. package/src/App/components/Panel/Panel.test.jsx +18 -0
  112. package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
  113. package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
  114. package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
  115. package/src/App/components/Tooltip/Tooltip.jsx +4 -2
  116. package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
  117. package/src/App/components/Viewport/MapController.jsx +1 -1
  118. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  119. package/src/App/components/Viewport/Viewport.jsx +2 -2
  120. package/src/App/components/Viewport/Viewport.module.scss +2 -2
  121. package/src/App/controls/keyboardActions.js +3 -2
  122. package/src/App/controls/keyboardShortcuts.js +4 -2
  123. package/src/App/hooks/useButtonStateEvaluator.js +58 -45
  124. package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
  125. package/src/App/hooks/useCrossHairAPI.js +49 -47
  126. package/src/App/hooks/useFocusVisible.js +2 -2
  127. package/src/App/hooks/useInterfaceAPI.js +16 -4
  128. package/src/App/hooks/useKeyboardHint.js +1 -1
  129. package/src/App/hooks/useKeyboardShortcuts.js +4 -2
  130. package/src/App/hooks/useMapAnnouncements.js +34 -32
  131. package/src/App/hooks/useMapEvents.js +12 -1
  132. package/src/App/hooks/useMapProviderOverrides.js +3 -3
  133. package/src/App/hooks/useMapStateSync.js +7 -1
  134. package/src/App/hooks/useMapURLSync.js +6 -1
  135. package/src/App/hooks/useMarkersAPI.js +88 -50
  136. package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
  137. package/src/App/hooks/useModalPanelBehaviour.js +46 -40
  138. package/src/App/hooks/useResizeObserver.js +2 -2
  139. package/src/App/initialiseApp.js +45 -38
  140. package/src/App/layout/Layout.jsx +6 -4
  141. package/src/App/layout/layout.module.scss +13 -17
  142. package/src/App/registry/keyboardShortcutRegistry.js +12 -2
  143. package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
  144. package/src/App/registry/mergeManifests.js +1 -1
  145. package/src/App/registry/panelRegistry.js +1 -1
  146. package/src/App/registry/panelRegistry.test.js +30 -1
  147. package/src/App/registry/pluginRegistry.js +14 -51
  148. package/src/App/registry/pluginRegistry.test.js +54 -6
  149. package/src/App/renderer/HtmlElementHost.jsx +186 -0
  150. package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
  151. package/src/App/renderer/mapButtons.js +11 -8
  152. package/src/App/renderer/mapButtons.test.js +6 -4
  153. package/src/App/renderer/mapControls.js +6 -0
  154. package/src/App/renderer/mapControls.test.js +10 -2
  155. package/src/App/renderer/mapPanels.js +39 -26
  156. package/src/App/renderer/mapPanels.test.js +29 -0
  157. package/src/App/renderer/pluginWrapper.test.js +28 -0
  158. package/src/App/renderer/slotHelpers.js +60 -0
  159. package/src/App/renderer/slotHelpers.test.js +82 -0
  160. package/src/App/store/AppProvider.jsx +3 -0
  161. package/src/App/store/AppProvider.test.jsx +40 -0
  162. package/src/App/store/PluginProvider.jsx +7 -5
  163. package/src/App/store/appActionsMap.js +25 -1
  164. package/src/App/store/appActionsMap.test.js +21 -0
  165. package/src/App/store/appReducer.js +1 -0
  166. package/src/App/store/mapActionsMap.js +12 -24
  167. package/src/App/store/mapReducer.js +1 -1
  168. package/src/InteractiveMap/InteractiveMap.js +79 -6
  169. package/src/InteractiveMap/InteractiveMap.test.js +164 -2
  170. package/src/InteractiveMap/behaviourController.js +12 -2
  171. package/src/InteractiveMap/behaviourController.test.js +82 -1
  172. package/src/InteractiveMap/deviceChecker.js +1 -1
  173. package/src/InteractiveMap/deviceChecker.test.js +2 -2
  174. package/src/InteractiveMap/historyManager.js +41 -5
  175. package/src/InteractiveMap/historyManager.test.js +37 -6
  176. package/src/InteractiveMap/polyfills.js +39 -0
  177. package/src/InteractiveMap/polyfills.test.js +127 -0
  178. package/src/config/appConfig.js +5 -5
  179. package/src/config/appConfig.test.js +107 -42
  180. package/src/config/defaults.js +1 -0
  181. package/src/index.umd.js +1 -1
  182. package/src/scss/main.scss +1 -0
  183. package/src/scss/settings/_dimensions.scss +6 -2
  184. package/src/services/eventBus.test.js +24 -1
  185. package/src/test-utils.js +1 -0
  186. package/src/types.js +5 -0
  187. package/src/utils/detectBreakpoint.js +78 -77
  188. package/src/utils/detectBreakpoint.test.js +50 -4
  189. package/src/utils/detectInterfaceType.js +4 -4
  190. package/src/utils/getIsFullscreen.js +3 -1
  191. package/src/utils/getSafeZoneInset.js +23 -7
  192. package/src/utils/getSafeZoneInset.test.js +18 -0
  193. package/src/utils/mapStateSync.js +3 -3
  194. package/src/utils/queryString.js +1 -1
  195. package/src/utils/stringToKebab.js +1 -1
  196. package/src/utils/toggleInertElements.js +37 -12
  197. package/webpack.dev.mjs +3 -4
  198. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  199. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  200. package/plugins/search/src/utils/fetchDataset.js +0 -23
  201. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  202. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -9,6 +9,8 @@
9
9
  */
10
10
 
11
11
  import '../scss/main.scss'
12
+ // Polyfills to ensure entry point works on all devices
13
+ import './polyfills.js'
12
14
  import historyManager from './historyManager.js'
13
15
  import { parseDataProperties } from './parseDataProperties.js'
14
16
  import { checkDeviceSupport } from './deviceChecker.js'
@@ -22,6 +24,7 @@ import { createInterfaceDetector, getInterfaceType } from '../utils/detectInterf
22
24
  import { createReverseGeocode } from '../services/reverseGeocode.js'
23
25
  import { EVENTS as events } from '../config/events.js'
24
26
  import { createEventBus } from '../services/eventBus.js'
27
+ import { toggleInertElements } from '../utils/toggleInertElements.js'
25
28
 
26
29
  /**
27
30
  * Main entry point for the Interactive Map component.
@@ -33,6 +36,7 @@ export default class InteractiveMap {
33
36
  _breakpointDetector = null
34
37
  _interfaceDetectorCleanup = null
35
38
  _hybridBehaviourCleanup = null
39
+ _isHidden = false // tracks if map is hidden but preserved (hybrid mode)
36
40
 
37
41
  /**
38
42
  * Create a new InteractiveMap instance.
@@ -98,16 +102,39 @@ export default class InteractiveMap {
98
102
  }
99
103
 
100
104
  _handleButtonClick (e) {
101
- this.loadApp()
102
105
  history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
106
+ if (this._isHidden) {
107
+ this.showApp()
108
+ } else {
109
+ this.loadApp()
110
+ }
111
+ }
112
+
113
+ _removeMapParamFromUrl (href, key) {
114
+ const regex = new RegExp(`[?&]${key}=[^&]*(&|$)`)
115
+
116
+ if (!regex.test(href)) {
117
+ return href
118
+ }
119
+
120
+ return href
121
+ .replace(regex, (_, p1) => {
122
+ return p1 === '&' ? '?' : ''
123
+ })
124
+ .replace(/\?$/, '')
103
125
  }
104
126
 
105
127
  _handleExitClick () {
106
- this.removeApp()
107
- // Remove the map param from the URL using regex to prevent encoding
128
+ if (this.config.preserveStateOnClose) {
129
+ this.hideApp()
130
+ } else {
131
+ this.removeApp()
132
+ }
133
+
108
134
  const key = this.config.mapViewParamKey
109
135
  const href = location.href
110
- const newUrl = href.replace(new RegExp(`[?&]${key}=[^&]*(&|$)`), (_, p1) => (p1 === '&' ? '?' : '')).replace(/\?$/, '')
136
+ const newUrl = this._removeMapParamFromUrl(href, key)
137
+
111
138
  history.replaceState(history.state, '', newUrl)
112
139
  }
113
140
 
@@ -153,10 +180,10 @@ export default class InteractiveMap {
153
180
  delete appInstance._root
154
181
 
155
182
  // Only assign properties but don't eventBus methods
156
- const protectedKeys = ['on', 'off', 'emit']
183
+ const protectedKeys = new Set(['on', 'off', 'emit'])
157
184
 
158
185
  Object.keys(appInstance).forEach(key => {
159
- if (!protectedKeys.includes(key)) {
186
+ if (!protectedKeys.has(key)) {
160
187
  this[key] = appInstance[key]
161
188
  }
162
189
  })
@@ -190,6 +217,52 @@ export default class InteractiveMap {
190
217
  this.eventBus.emit(events.MAP_DESTROY, { mapId: this.id })
191
218
  }
192
219
 
220
+ /**
221
+ * Hide the map application without destroying it (preserves state).
222
+ * Used in hybrid mode when resizing below breakpoint.
223
+ *
224
+ * @internal Not intended for end-user use.
225
+ */
226
+ hideApp () {
227
+ this._isHidden = true
228
+ this.rootEl.style.display = 'none'
229
+
230
+ // Restore inert elements before focusing button
231
+ toggleInertElements({ containerEl: this.rootEl, isFullscreen: false })
232
+
233
+ if (this._openButton) {
234
+ this._openButton.removeAttribute('style')
235
+ this._openButton.focus()
236
+ }
237
+
238
+ // Remove fullscreen classes
239
+ document.documentElement.classList.remove('im-is-fullscreen')
240
+ this.rootEl.classList.remove('im-is-fullscreen')
241
+
242
+ // Reset page title (remove prepended map title)
243
+ const parts = document.title.split(': ')
244
+ if (parts.length > 1) {
245
+ document.title = parts[parts.length - 1]
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Show a previously hidden map application.
251
+ * Used in hybrid mode when resizing above breakpoint or clicking button.
252
+ *
253
+ * @internal Not intended for end-user use.
254
+ */
255
+ showApp () {
256
+ this._isHidden = false
257
+ this.rootEl.style.display = ''
258
+
259
+ if (this._openButton) {
260
+ this._openButton.style.display = 'none'
261
+ }
262
+
263
+ updateDOMState(this)
264
+ }
265
+
193
266
  /**
194
267
  * Destroy the map instance and clean up all resources.
195
268
  *
@@ -268,20 +268,23 @@ describe('InteractiveMap Core Functionality', () => {
268
268
  expect(() => map.destroy()).not.toThrow()
269
269
  })
270
270
 
271
- it('_handleExitClick removes app and calls replaceState', () => {
271
+ it('_handleExitClick removes app when preserveStateOnClose is false', () => {
272
272
  const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {})
273
273
 
274
274
  const map = new InteractiveMap('map', {
275
275
  behaviour: 'buttonFirst',
276
276
  mapProvider: mapProviderMock,
277
- mapViewParamKey: 'mv'
277
+ mapViewParamKey: 'mv',
278
+ preserveStateOnClose: false
278
279
  })
279
280
 
280
281
  const removeAppSpy = jest.spyOn(map, 'removeApp').mockImplementation(() => {})
282
+ const hideAppSpy = jest.spyOn(map, 'hideApp').mockImplementation(() => {})
281
283
 
282
284
  map._handleExitClick()
283
285
 
284
286
  expect(removeAppSpy).toHaveBeenCalled()
287
+ expect(hideAppSpy).not.toHaveBeenCalled()
285
288
  expect(replaceStateSpy).toHaveBeenCalledWith(
286
289
  history.state,
287
290
  '',
@@ -289,8 +292,158 @@ describe('InteractiveMap Core Functionality', () => {
289
292
  )
290
293
 
291
294
  removeAppSpy.mockRestore()
295
+ hideAppSpy.mockRestore()
292
296
  replaceStateSpy.mockRestore()
293
297
  })
298
+
299
+ it('_handleExitClick hides app when preserveStateOnClose is true', () => {
300
+ const replaceStateSpy = jest.spyOn(history, 'replaceState').mockImplementation(() => {})
301
+
302
+ const map = new InteractiveMap('map', {
303
+ behaviour: 'buttonFirst',
304
+ mapProvider: mapProviderMock,
305
+ mapViewParamKey: 'mv',
306
+ preserveStateOnClose: true
307
+ })
308
+
309
+ const removeAppSpy = jest.spyOn(map, 'removeApp').mockImplementation(() => {})
310
+ const hideAppSpy = jest.spyOn(map, 'hideApp').mockImplementation(() => {})
311
+
312
+ map._handleExitClick()
313
+
314
+ expect(hideAppSpy).toHaveBeenCalled()
315
+ expect(removeAppSpy).not.toHaveBeenCalled()
316
+ expect(replaceStateSpy).toHaveBeenCalled()
317
+
318
+ removeAppSpy.mockRestore()
319
+ hideAppSpy.mockRestore()
320
+ replaceStateSpy.mockRestore()
321
+ })
322
+
323
+ it('_handleButtonClick calls showApp when map is hidden', async () => {
324
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
325
+ map._isHidden = true
326
+ const showAppSpy = jest.spyOn(map, 'showApp').mockImplementation(() => {})
327
+ const loadAppSpy = jest.spyOn(map, 'loadApp').mockResolvedValue()
328
+ const pushStateSpy = jest.spyOn(history, 'pushState').mockImplementation(() => {})
329
+ const fakeEvent = { currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } }
330
+
331
+ await openButtonCallback(fakeEvent)
332
+
333
+ expect(showAppSpy).toHaveBeenCalled()
334
+ expect(loadAppSpy).not.toHaveBeenCalled()
335
+ expect(pushStateSpy).toHaveBeenCalled()
336
+
337
+ showAppSpy.mockRestore()
338
+ loadAppSpy.mockRestore()
339
+ pushStateSpy.mockRestore()
340
+ })
341
+
342
+ it('hideApp sets _isHidden and hides element', () => {
343
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
344
+ map._openButton = mockButtonInstance
345
+
346
+ map.hideApp()
347
+
348
+ expect(map._isHidden).toBe(true)
349
+ expect(map.rootEl.style.display).toBe('none')
350
+ expect(mockButtonInstance.removeAttribute).toHaveBeenCalledWith('style')
351
+ expect(mockButtonInstance.focus).toHaveBeenCalled()
352
+ })
353
+
354
+ it('hideApp restores document title when it contains a map prefix', () => {
355
+ document.title = 'Map View: Original Page Title'
356
+
357
+ const map = new InteractiveMap('map', {
358
+ behaviour: 'buttonFirst',
359
+ mapProvider: mapProviderMock
360
+ })
361
+
362
+ map._openButton = mockButtonInstance
363
+ map.hideApp()
364
+
365
+ expect(document.title).toBe('Original Page Title')
366
+ })
367
+
368
+ it('hideApp works when _openButton is null', () => {
369
+ const map = new InteractiveMap('map', {
370
+ behaviour: 'buttonFirst',
371
+ mapProvider: mapProviderMock
372
+ })
373
+
374
+ map._openButton = null
375
+ map.hideApp()
376
+
377
+ expect(map._isHidden).toBe(true)
378
+ expect(map.rootEl.style.display).toBe('none')
379
+ })
380
+
381
+ it('showApp sets _isHidden false and shows element', () => {
382
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
383
+ map._isHidden = true
384
+ map._openButton = mockButtonInstance
385
+ map.rootEl.style.display = 'none'
386
+
387
+ map.showApp()
388
+
389
+ expect(map._isHidden).toBe(false)
390
+ expect(map.rootEl.style.display).toBe('')
391
+ expect(mockButtonInstance.style.display).toBe('none')
392
+ expect(updateDOMState).toHaveBeenCalledWith(map)
393
+ })
394
+
395
+ it('showApp works when _openButton is null', () => {
396
+ const map = new InteractiveMap('map', {
397
+ behaviour: 'buttonFirst',
398
+ mapProvider: mapProviderMock
399
+ })
400
+
401
+ map._isHidden = true
402
+ map._openButton = null
403
+ map.rootEl.style.display = 'none'
404
+
405
+ map.showApp()
406
+
407
+ expect(map._isHidden).toBe(false)
408
+ expect(map.rootEl.style.display).toBe('')
409
+ expect(updateDOMState).toHaveBeenCalledWith(map)
410
+ })
411
+ })
412
+
413
+ describe('_removeMapParamFromUrl', () => {
414
+ let map
415
+
416
+ beforeEach(() => {
417
+ document.body.innerHTML = '<div id="map"></div>'
418
+ map = new InteractiveMap('map', {
419
+ mapProvider: { load: jest.fn() },
420
+ mapViewParamKey: 'mv'
421
+ })
422
+ })
423
+
424
+ it('removes param when followed by another param (& branch)', () => {
425
+ const href = 'https://example.com/page?mv=map&foo=1'
426
+ const result = map._removeMapParamFromUrl(href, 'mv')
427
+ expect(result).toBe('https://example.com/page?foo=1')
428
+ })
429
+
430
+ it('removes param when it is the last param (end-of-string branch)', () => {
431
+ const href = 'https://example.com/page?foo=1&mv=map'
432
+ const result = map._removeMapParamFromUrl(href, 'mv')
433
+ expect(result).toBe('https://example.com/page?foo=1')
434
+ })
435
+
436
+ it('removes lone param and trailing ?', () => {
437
+ const href = 'https://example.com/page?mv=map'
438
+ const result = map._removeMapParamFromUrl(href, 'mv')
439
+ expect(result).toBe('https://example.com/page')
440
+ })
441
+
442
+ it('returns href unchanged when param does not exist (no-match branch)', () => {
443
+ const href = 'https://example.com/page?foo=1'
444
+ const result = map._removeMapParamFromUrl(href, 'mv')
445
+ expect(result).toBe(href)
446
+ })
294
447
  })
295
448
 
296
449
  describe('InteractiveMap Public API Methods', () => {
@@ -347,4 +500,13 @@ describe('InteractiveMap Public API Methods', () => {
347
500
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:showpanel', 'panel2')
348
501
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:hidepanel', 'panel3')
349
502
  })
503
+
504
+ it('delegates toggleButtonState correctly', () => {
505
+ map.toggleButtonState('btn-1', 'disabled', true)
506
+
507
+ expect(map.eventBus.emit).toHaveBeenCalledWith(
508
+ 'app:togglebuttonstate',
509
+ { id: 'btn-1', prop: 'disabled', value: true }
510
+ )
511
+ })
350
512
  })
@@ -1,5 +1,6 @@
1
1
  import { getQueryParam } from '../utils/queryString.js'
2
2
  import { isHybridFullscreen } from '../utils/getIsFullscreen.js'
3
+ import { updateDOMState } from './domStateManager.js'
3
4
  import defaults from '../config/defaults.js'
4
5
 
5
6
  // -----------------------------------------------------------------------------
@@ -56,9 +57,18 @@ function setupBehavior (mapInstance) {
56
57
 
57
58
  const handleChange = () => {
58
59
  if (shouldLoadComponent(mapInstance.config)) {
59
- mapInstance.loadApp()
60
+ if (mapInstance._isHidden) {
61
+ mapInstance.showApp()
62
+ } else if (mapInstance._root == null) {
63
+ mapInstance.loadApp()
64
+ } else {
65
+ // Map is showing - update DOM state for fullscreen/inline transition
66
+ updateDOMState(mapInstance)
67
+ }
68
+ } else if (mapInstance._root) {
69
+ mapInstance.hideApp()
60
70
  } else {
61
- mapInstance.removeApp()
71
+ // No action
62
72
  }
63
73
  }
64
74
 
@@ -4,8 +4,10 @@
4
4
 
5
5
  import { setupBehavior, shouldLoadComponent } from './behaviourController.js'
6
6
  import * as queryString from '../utils/queryString.js'
7
+ import { updateDOMState } from './domStateManager.js'
7
8
 
8
9
  jest.mock('../utils/queryString.js')
10
+ jest.mock('./domStateManager.js', () => ({ updateDOMState: jest.fn() }))
9
11
 
10
12
  describe('shouldLoadComponent', () => {
11
13
  beforeEach(() => {
@@ -60,8 +62,12 @@ describe('setupBehavior', () => {
60
62
  mockMapInstance = {
61
63
  config: { hybridWidth: null, maxMobileWidth: 640 },
62
64
  _breakpointDetector: mockBreakpointDetector,
65
+ _root: null,
66
+ _isHidden: false,
63
67
  loadApp: jest.fn(),
64
- removeApp: jest.fn()
68
+ removeApp: jest.fn(),
69
+ hideApp: jest.fn(),
70
+ showApp: jest.fn()
65
71
  }
66
72
  // Default: viewport is wide
67
73
  window.matchMedia = jest.fn().mockImplementation(() => ({
@@ -145,4 +151,79 @@ describe('setupBehavior', () => {
145
151
  const cleanup = setupBehavior(mockMapInstance)
146
152
  expect(cleanup).toBeNull()
147
153
  })
154
+
155
+ describe('hybrid behaviour handleChange', () => {
156
+ let handleChange
157
+
158
+ beforeEach(() => {
159
+ const mockAddEventListener = jest.fn((event, cb) => { handleChange = cb })
160
+ window.matchMedia = jest.fn().mockImplementation(() => ({
161
+ matches: false,
162
+ addEventListener: mockAddEventListener,
163
+ removeEventListener: jest.fn()
164
+ }))
165
+ mockMapInstance.config = { id: 'test', behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640 }
166
+ setupBehavior(mockMapInstance)
167
+ })
168
+
169
+ it('calls showApp when map is hidden and should load', () => {
170
+ mockMapInstance._isHidden = true
171
+ queryString.getQueryParam.mockReturnValue(null) // wide viewport, should load
172
+
173
+ handleChange()
174
+
175
+ expect(mockMapInstance.showApp).toHaveBeenCalled()
176
+ expect(mockMapInstance.loadApp).not.toHaveBeenCalled()
177
+ })
178
+
179
+ it('calls loadApp when map has no root and should load', () => {
180
+ mockMapInstance._isHidden = false
181
+ mockMapInstance._root = null
182
+ queryString.getQueryParam.mockReturnValue(null)
183
+
184
+ handleChange()
185
+
186
+ expect(mockMapInstance.loadApp).toHaveBeenCalled()
187
+ expect(mockMapInstance.showApp).not.toHaveBeenCalled()
188
+ })
189
+
190
+ it('calls updateDOMState when map is showing and should load', () => {
191
+ mockMapInstance._isHidden = false
192
+ mockMapInstance._root = {} // has root
193
+ queryString.getQueryParam.mockReturnValue(null)
194
+
195
+ handleChange()
196
+
197
+ expect(updateDOMState).toHaveBeenCalledWith(mockMapInstance)
198
+ expect(mockMapInstance.loadApp).not.toHaveBeenCalled()
199
+ expect(mockMapInstance.showApp).not.toHaveBeenCalled()
200
+ })
201
+
202
+ it('calls hideApp when should not load and has root', () => {
203
+ mockMapInstance._root = {}
204
+ // Simulate narrow viewport where shouldLoadComponent returns false
205
+ window.matchMedia = jest.fn().mockImplementation(() => ({ matches: true }))
206
+ queryString.getQueryParam.mockReturnValue(null)
207
+
208
+ handleChange()
209
+
210
+ expect(mockMapInstance.hideApp).toHaveBeenCalled()
211
+ })
212
+
213
+ it('does nothing when should not load and map has no root', () => {
214
+ mockMapInstance._root = null
215
+ mockMapInstance._isHidden = false
216
+
217
+ // Force shouldLoadComponent === false
218
+ window.matchMedia = jest.fn().mockImplementation(() => ({ matches: true }))
219
+ queryString.getQueryParam.mockReturnValue(null)
220
+
221
+ handleChange()
222
+
223
+ expect(mockMapInstance.hideApp).not.toHaveBeenCalled()
224
+ expect(mockMapInstance.loadApp).not.toHaveBeenCalled()
225
+ expect(mockMapInstance.showApp).not.toHaveBeenCalled()
226
+ expect(updateDOMState).not.toHaveBeenCalled()
227
+ })
228
+ })
148
229
  })
@@ -19,7 +19,7 @@ export function checkDeviceSupport (rootEl, config) {
19
19
 
20
20
  if (!mapProvider) {
21
21
  console.log('No map provider')
22
- return
22
+ return false
23
23
  }
24
24
 
25
25
  if (!device?.isSupported) {
@@ -49,13 +49,13 @@ describe('checkDeviceSupport', () => {
49
49
  expect(console.log).toHaveBeenCalledWith('WebGL not available')
50
50
  })
51
51
 
52
- it('logs "No map provider" and returns undefined if mapProvider is missing', () => {
52
+ it('logs "No map provider" and returns false if mapProvider is missing', () => {
53
53
  config = {
54
54
  mapProvider: null,
55
55
  deviceNotSupportedText: 'Device not supported'
56
56
  }
57
57
 
58
- expect(checkDeviceSupport(rootEl, config)).toBeUndefined()
58
+ expect(checkDeviceSupport(rootEl, config)).toBe(false)
59
59
  expect(console.log).toHaveBeenCalledWith('No map provider')
60
60
  expect(renderError).not.toHaveBeenCalled()
61
61
  expect(removeLoadingState).not.toHaveBeenCalled()
@@ -6,6 +6,40 @@ import defaults from '../config/defaults.js'
6
6
  // Internal helpers
7
7
  // -----------------------------------------------------------------------------
8
8
 
9
+ /**
10
+ * Opens the map application for a given map instance.
11
+ *
12
+ * If the map instance was previously hidden, it restores the existing app.
13
+ * Otherwise, it loads the app for the first time.
14
+ *
15
+ * @param {MapInstance} mapInstance
16
+ * The map instance whose application should be opened.
17
+ */
18
+ function openMap (mapInstance) {
19
+ if (mapInstance._isHidden) {
20
+ mapInstance.showApp?.()
21
+ } else {
22
+ mapInstance.loadApp?.()
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Closes the map application for a given map instance.
28
+ *
29
+ * Depending on the configuration, the map state is either preserved
30
+ * by hiding the app or fully removed from the DOM.
31
+ *
32
+ * @param {MapInstance} mapInstance
33
+ * The map instance whose application should be closed.
34
+ */
35
+ function closeMap (mapInstance) {
36
+ if (mapInstance.config.preserveStateOnClose) {
37
+ mapInstance.hideApp?.()
38
+ } else {
39
+ mapInstance.removeApp?.()
40
+ }
41
+ }
42
+
9
43
  /**
10
44
  * Handles the `popstate` event triggered by browser back/forward navigation.
11
45
  *
@@ -28,11 +62,13 @@ function handlePopstate () {
28
62
  const isHybridVisible = mapInstance.config.behaviour === 'hybrid' && !isHybridFullscreen(mapInstance.config)
29
63
  const isOpen = mapInstance.rootEl?.children.length
30
64
 
31
- if (shouldBeOpen && !isOpen) {
32
- mapInstance.loadApp?.()
33
- } else if (!shouldBeOpen && isOpen && !isHybridVisible) {
34
- mapInstance.removeApp?.()
35
- mapInstance.openButton?.focus?.()
65
+ if (shouldBeOpen && (!isOpen || mapInstance._isHidden)) {
66
+ openMap(mapInstance)
67
+ continue
68
+ }
69
+
70
+ if (!shouldBeOpen && isOpen && !isHybridVisible) {
71
+ closeMap(mapInstance)
36
72
  }
37
73
  }
38
74
  }
@@ -13,19 +13,25 @@ describe('historyManager', () => {
13
13
  beforeEach(() => {
14
14
  component1 = {
15
15
  id: 'map',
16
- config: { behaviour: 'buttonFirst', hybridWidth: null, maxMobileWidth: 640 },
16
+ config: { behaviour: 'buttonFirst', hybridWidth: null, maxMobileWidth: 640, preserveStateOnClose: false },
17
17
  rootEl: document.createElement('div'),
18
18
  loadApp: jest.fn(),
19
19
  removeApp: jest.fn(),
20
- openButton: { focus: jest.fn() }
20
+ hideApp: jest.fn(),
21
+ showApp: jest.fn(),
22
+ openButton: { focus: jest.fn() },
23
+ _isHidden: false
21
24
  }
22
25
  component2 = {
23
26
  id: 'list',
24
- config: { behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640 },
27
+ config: { behaviour: 'hybrid', hybridWidth: null, maxMobileWidth: 640, preserveStateOnClose: false },
25
28
  rootEl: document.createElement('div'),
26
29
  loadApp: jest.fn(),
27
30
  removeApp: jest.fn(),
28
- openButton: { focus: jest.fn() }
31
+ hideApp: jest.fn(),
32
+ showApp: jest.fn(),
33
+ openButton: { focus: jest.fn() },
34
+ _isHidden: false
29
35
  }
30
36
  popstateEvent = new PopStateEvent('popstate')
31
37
  jest.clearAllMocks()
@@ -63,7 +69,8 @@ describe('historyManager', () => {
63
69
  expect(component1.loadApp).not.toHaveBeenCalled()
64
70
  })
65
71
 
66
- it('removes component and focuses button when view param does not match', () => {
72
+ it('removes component when view param does not match and preserveStateOnClose is false', () => {
73
+ component1.config.preserveStateOnClose = false
67
74
  component1.rootEl.appendChild(document.createElement('div'))
68
75
  historyManager.register(component1)
69
76
  queryString.getQueryParam.mockReturnValue(null)
@@ -71,7 +78,19 @@ describe('historyManager', () => {
71
78
  window.dispatchEvent(popstateEvent)
72
79
 
73
80
  expect(component1.removeApp).toHaveBeenCalled()
74
- expect(component1.openButton.focus).toHaveBeenCalled()
81
+ expect(component1.hideApp).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('hides component when view param does not match and preserveStateOnClose is true', () => {
85
+ component1.config.preserveStateOnClose = true
86
+ component1.rootEl.appendChild(document.createElement('div'))
87
+ historyManager.register(component1)
88
+ queryString.getQueryParam.mockReturnValue(null)
89
+
90
+ window.dispatchEvent(popstateEvent)
91
+
92
+ expect(component1.hideApp).toHaveBeenCalled()
93
+ expect(component1.removeApp).not.toHaveBeenCalled()
75
94
  })
76
95
 
77
96
  it('does not remove hybrid component when viewport is wide (inline mode)', () => {
@@ -87,6 +106,7 @@ describe('historyManager', () => {
87
106
  })
88
107
 
89
108
  it('removes hybrid component when viewport is narrow and view does not match', () => {
109
+ component2.config.preserveStateOnClose = false
90
110
  component2.rootEl.appendChild(document.createElement('div'))
91
111
  historyManager.register(component2)
92
112
  queryString.getQueryParam.mockReturnValue(null)
@@ -98,6 +118,17 @@ describe('historyManager', () => {
98
118
  expect(component2.removeApp).toHaveBeenCalled()
99
119
  })
100
120
 
121
+ it('calls showApp when view param matches and component is hidden', () => {
122
+ component1._isHidden = true
123
+ historyManager.register(component1)
124
+ queryString.getQueryParam.mockReturnValue('map')
125
+
126
+ window.dispatchEvent(popstateEvent)
127
+
128
+ expect(component1.showApp).toHaveBeenCalled()
129
+ expect(component1.loadApp).not.toHaveBeenCalled()
130
+ })
131
+
101
132
  it('uses hybridWidth for media query when provided', () => {
102
133
  component2.config.hybridWidth = 768
103
134
  component2.rootEl.appendChild(document.createElement('div'))
@@ -0,0 +1,39 @@
1
+ // crypto.randomUUID
2
+ if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
3
+ let last = 0
4
+ crypto.randomUUID = () => {
5
+ last = Math.max(Date.now(), last + 1)
6
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c, i) => {
7
+ const v = i < 12 ? Number.parseInt(last.toString(16).padStart(12, '0')[i], 16) : Math.random() * 16 | 0 // NOSONAR
8
+ return (c === 'x' ? v : (v & 0x3 | 0x8)).toString(16)
9
+ })
10
+ }
11
+ }
12
+
13
+ // AbortSignal.throwIfAborted
14
+ const needsThrowIfAborted = typeof AbortController !== 'undefined' &&
15
+ !Object.getPrototypeOf(new AbortController().signal).throwIfAborted
16
+
17
+ if (needsThrowIfAborted) {
18
+ const signalProto = Object.getPrototypeOf(new AbortController().signal)
19
+ signalProto.throwIfAborted = function () {
20
+ if (this.aborted) {
21
+ const err = new Error('The operation was aborted.')
22
+ err.name = 'AbortError'
23
+ throw err
24
+ }
25
+ }
26
+ }
27
+
28
+ // Inject polyfill into web workers created from blob URLs (e.g. MapLibre GL)
29
+ // Workers have their own global scope so main thread polyfills don't apply
30
+ if (needsThrowIfAborted && typeof URL !== 'undefined' && URL.createObjectURL) {
31
+ const _createObjectURL = URL.createObjectURL.bind(URL)
32
+ URL.createObjectURL = (blob) => {
33
+ if (blob instanceof Blob && blob.type === 'text/javascript') {
34
+ const p = 'if(typeof AbortController!=="undefined"){var _p=Object.getPrototypeOf(new AbortController().signal);if(!_p.throwIfAborted){_p.throwIfAborted=function(){if(this.aborted){var e=new Error("The operation was aborted.");e.name="AbortError";throw e}}}}\n'
35
+ blob = new Blob([p, blob], { type: 'text/javascript' })
36
+ }
37
+ return _createObjectURL(blob)
38
+ }
39
+ }