@defra/interactive-map 0.0.4-alpha → 0.0.6-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 (203) 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 +99 -0
  65. package/plugins/interact/src/utils/spatial.test.js +100 -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 +21 -18
  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 +28 -1
  164. package/src/App/store/appActionsMap.test.js +26 -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/_colors.scss +1 -1
  184. package/src/scss/settings/_dimensions.scss +4 -0
  185. package/src/services/eventBus.test.js +24 -1
  186. package/src/test-utils.js +1 -0
  187. package/src/types.js +5 -0
  188. package/src/utils/detectBreakpoint.js +78 -77
  189. package/src/utils/detectBreakpoint.test.js +50 -4
  190. package/src/utils/detectInterfaceType.js +6 -5
  191. package/src/utils/getIsFullscreen.js +3 -1
  192. package/src/utils/getSafeZoneInset.js +23 -7
  193. package/src/utils/getSafeZoneInset.test.js +18 -0
  194. package/src/utils/mapStateSync.js +3 -3
  195. package/src/utils/queryString.js +1 -1
  196. package/src/utils/stringToKebab.js +1 -1
  197. package/src/utils/toggleInertElements.js +37 -12
  198. package/webpack.dev.mjs +3 -4
  199. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  200. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  201. package/plugins/search/src/utils/fetchDataset.js +0 -23
  202. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  203. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -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
+ }
@@ -0,0 +1,127 @@
1
+ describe('Polyfills', () => {
2
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
3
+
4
+ const originalCryptoUUID = crypto.randomUUID
5
+ const originalCreateObjectURL = URL.createObjectURL
6
+ const signalProto = Object.getPrototypeOf(new AbortController().signal)
7
+ const originalThrowIfAborted = signalProto.throwIfAborted
8
+
9
+ beforeEach(() => {
10
+ jest.resetModules()
11
+ })
12
+
13
+ afterEach(() => {
14
+ Object.defineProperty(crypto, 'randomUUID', {
15
+ value: originalCryptoUUID,
16
+ configurable: true,
17
+ writable: true
18
+ })
19
+
20
+ URL.createObjectURL = originalCreateObjectURL
21
+
22
+ if (originalThrowIfAborted) {
23
+ signalProto.throwIfAborted = originalThrowIfAborted
24
+ } else {
25
+ delete signalProto.throwIfAborted
26
+ }
27
+ })
28
+
29
+ const load = () => jest.isolateModules(() => require('./polyfills.js'))
30
+
31
+ // Helper to read Blob text in environments without blob.text()
32
+ const readBlobText = (blob) => new Promise((resolve) => {
33
+ const reader = new FileReader()
34
+ reader.onload = () => resolve(reader.result)
35
+ reader.readAsText(blob)
36
+ })
37
+
38
+ describe('crypto.randomUUID', () => {
39
+ test('polyfills crypto.randomUUID when missing (Lines 3-8)', () => {
40
+ Object.defineProperty(crypto, 'randomUUID', {
41
+ value: undefined,
42
+ configurable: true,
43
+ writable: true
44
+ })
45
+
46
+ load()
47
+
48
+ expect(typeof crypto.randomUUID).toBe('function')
49
+ expect(crypto.randomUUID()).toMatch(UUID_RE)
50
+ })
51
+
52
+ test('crypto.randomUUID generates unique UUIDs', () => {
53
+ Object.defineProperty(crypto, 'randomUUID', {
54
+ value: undefined,
55
+ configurable: true,
56
+ writable: true
57
+ })
58
+ load()
59
+ const ids = new Set(Array.from({ length: 100 }, () => crypto.randomUUID()))
60
+ expect(ids.size).toBe(100)
61
+ })
62
+
63
+ test('does not overwrite existing crypto.randomUUID', () => {
64
+ const fake = jest.fn(() => 'fake')
65
+ Object.defineProperty(crypto, 'randomUUID', {
66
+ value: fake,
67
+ configurable: true,
68
+ writable: true
69
+ })
70
+ load()
71
+ expect(crypto.randomUUID).toBe(fake)
72
+ })
73
+ })
74
+
75
+ describe('AbortSignal.throwIfAborted', () => {
76
+ test('throws AbortError when aborted (True branch)', () => {
77
+ delete signalProto.throwIfAborted
78
+ load()
79
+ const ac = new AbortController()
80
+ ac.abort()
81
+ expect(() => ac.signal.throwIfAborted()).toThrow('The operation was aborted.')
82
+ })
83
+
84
+ test('does nothing when not aborted (False branch)', () => {
85
+ delete signalProto.throwIfAborted
86
+ load()
87
+ const ac = new AbortController()
88
+ // This call should execute line 20, see that aborted is false, and return undefined
89
+ expect(() => ac.signal.throwIfAborted()).not.toThrow()
90
+ })
91
+
92
+ test('wraps URL.createObjectURL for JS blobs', async () => {
93
+ delete signalProto.throwIfAborted
94
+
95
+ const mockCreate = jest.fn(() => 'blob:mock')
96
+ URL.createObjectURL = mockCreate
97
+
98
+ load()
99
+
100
+ const content = 'console.log(1)'
101
+ const blob = new Blob([content], { type: 'text/javascript' })
102
+ const result = URL.createObjectURL(blob)
103
+
104
+ expect(result).toBe('blob:mock')
105
+ expect(mockCreate).toHaveBeenCalled()
106
+
107
+ const passedBlob = mockCreate.mock.calls[0][0]
108
+ const text = await readBlobText(passedBlob)
109
+
110
+ expect(text).toContain('throwIfAborted')
111
+ expect(text).toContain(content)
112
+ })
113
+
114
+ test('does not wrap URL.createObjectURL for non-JS blobs', () => {
115
+ delete signalProto.throwIfAborted
116
+ const mockCreate = jest.fn(() => 'blob:mock')
117
+ URL.createObjectURL = mockCreate
118
+
119
+ load()
120
+
121
+ const blob = new Blob(['{}'], { type: 'application/json' })
122
+ URL.createObjectURL(blob)
123
+
124
+ expect(mockCreate).toHaveBeenCalledWith(blob)
125
+ })
126
+ })
127
+ })
@@ -18,12 +18,12 @@ const exitButtonSlots = {
18
18
  }
19
19
 
20
20
  // Default app buttons, panels and icons
21
- export const appConfig = {
21
+ export const defaultAppConfig = {
22
22
  buttons: [{
23
23
  id: 'exit',
24
24
  label: 'Exit',
25
25
  iconId: 'close',
26
- onClick: (e, { services }) => services.closeApp(),
26
+ onClick: (_e, { services }) => services.closeApp(),
27
27
  excludeWhen: ({ appConfig, appState }) => !appConfig.hasExitButton || !(appState.isFullscreen && (new URL(window.location.href)).searchParams.has(appConfig.mapViewParamKey)),
28
28
  mobile: exitButtonSlots,
29
29
  tablet: exitButtonSlots,
@@ -32,7 +32,7 @@ export const appConfig = {
32
32
  id: 'fullscreen',
33
33
  label: () => `${document.fullscreenElement ? 'Exit' : 'Enter'} fullscreen`,
34
34
  iconId: () => document.fullscreenElement ? 'minimise' : 'maximise',
35
- onClick: (e, { appState }) => {
35
+ onClick: (_e, { appState }) => {
36
36
  const container = appState.layoutRefs.appContainerRef.current
37
37
  document.fullscreenElement ? document.exitFullscreen() : container.requestFullscreen()
38
38
  },
@@ -45,7 +45,7 @@ export const appConfig = {
45
45
  group: 'zoom',
46
46
  label: 'Zoom in',
47
47
  iconId: 'plus',
48
- onClick: (e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
48
+ onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
49
49
  excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch',
50
50
  enableWhen: ({ mapState }) => !mapState.isAtMaxZoom,
51
51
  mobile: buttonSlots,
@@ -56,7 +56,7 @@ export const appConfig = {
56
56
  group: 'zoom',
57
57
  label: 'Zoom out',
58
58
  iconId: 'minus',
59
- onClick: (e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
59
+ onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
60
60
  excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch',
61
61
  enableWhen: ({ mapState }) => !mapState.isAtMinZoom,
62
62
  mobile: buttonSlots,
@@ -1,75 +1,140 @@
1
1
  import { render } from '@testing-library/react'
2
- import { appConfig } from './appConfig'
2
+ import { defaultAppConfig, defaultButtonConfig, scaleFactor, markerSvgPaths } from './appConfig'
3
3
 
4
- describe('appConfig', () => {
5
- const appState = { layoutRefs: { appContainerRef: { current: document.createElement('div') } }, isFullscreen: false }
6
- const buttons = appConfig.buttons
4
+ describe('defaultAppConfig', () => {
5
+ const appState = {
6
+ layoutRefs: { appContainerRef: { current: document.createElement('div') } },
7
+ isFullscreen: false,
8
+ interfaceType: 'mouse'
9
+ }
10
+
11
+ const buttons = defaultAppConfig.buttons
7
12
  const fullscreenBtn = buttons.find(b => b.id === 'fullscreen')
8
13
  const exitBtn = buttons.find(b => b.id === 'exit')
14
+ const zoomInBtn = buttons.find(b => b.id === 'zoomIn')
15
+ const zoomOutBtn = buttons.find(b => b.id === 'zoomOut')
9
16
 
17
+ // --- UI RENDER TESTS ---
10
18
  it('renders KeyboardHelp panel', () => {
11
- const panel = appConfig.panels.find(p => p.id === 'keyboardHelp')
19
+ const panel = defaultAppConfig.panels.find(p => p.id === 'keyboardHelp')
12
20
  const { container } = render(panel.render())
13
21
  expect(container.querySelector('.im-c-keyboard-help')).toBeInTheDocument()
14
22
  })
15
23
 
16
- it('evaluates dynamic button properties', () => {
17
- // label
18
- expect(typeof fullscreenBtn.label).toBe('function')
19
- expect(fullscreenBtn.label({ appState, appConfig })).toMatch(/fullscreen/)
24
+ // --- EXIT BUTTON (Line 27 Coverage) ---
25
+ it('covers all branches of exitBtn excludeWhen', () => {
26
+ const config = { hasExitButton: true, mapViewParamKey: 'view' }
27
+
28
+ expect(exitBtn.excludeWhen({
29
+ appConfig: { ...config, hasExitButton: false },
30
+ appState: { isFullscreen: true }
31
+ })).toBe(true)
32
+
33
+ window.history.pushState({}, '', '?view=map')
34
+ expect(exitBtn.excludeWhen({
35
+ appConfig: config,
36
+ appState: { isFullscreen: false }
37
+ })).toBe(true)
38
+
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
+ expect(exitBtn.excludeWhen({
47
+ appConfig: config,
48
+ appState: { isFullscreen: true }
49
+ })).toBe(false)
50
+ })
51
+
52
+ it('calls exit button onClick correctly', () => {
53
+ const servicesMock = { closeApp: jest.fn() }
54
+ exitBtn.onClick({}, { services: servicesMock })
55
+ expect(servicesMock.closeApp).toHaveBeenCalled()
56
+ })
57
+
58
+ // --- FULLSCREEN BUTTON (Line 39 Coverage) ---
59
+ it('evaluates fullscreen label and icon states', () => {
60
+ const containerMock = { requestFullscreen: jest.fn() }
61
+ Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true, configurable: true })
20
62
 
21
- // iconId
22
- expect(typeof fullscreenBtn.iconId).toBe('function')
23
- expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('maximise')
63
+ expect(fullscreenBtn.label({ appState })).toBe('Enter fullscreen')
64
+ expect(fullscreenBtn.iconId({ appState })).toBe('maximise')
24
65
 
25
- // excludeWhen
26
- expect(exitBtn.excludeWhen({ appConfig: { hasExitButton: false } })).toBe(true)
27
- expect(fullscreenBtn.excludeWhen({ appState, appConfig: { enableFullscreen: true } })).toBe(false)
66
+ Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true, configurable: true })
67
+ expect(fullscreenBtn.label({ appState })).toBe('Exit fullscreen')
68
+ expect(fullscreenBtn.iconId({ appState })).toBe('minimise')
69
+ })
70
+
71
+ it('covers all branches of fullscreen excludeWhen', () => {
72
+ expect(fullscreenBtn.excludeWhen({ appConfig: { enableFullscreen: false }, appState: { isFullscreen: false } })).toBe(true)
73
+ expect(fullscreenBtn.excludeWhen({ appConfig: { enableFullscreen: true }, appState: { isFullscreen: true } })).toBe(true)
74
+ expect(fullscreenBtn.excludeWhen({ appConfig: { enableFullscreen: true }, appState: { isFullscreen: false } })).toBe(false)
28
75
  })
29
76
 
30
77
  it('calls fullscreen onClick correctly', () => {
31
78
  const containerMock = { requestFullscreen: jest.fn() }
32
- const appStateMock = { layoutRefs: { appContainerRef: { current: containerMock } }, isFullscreen: false }
33
-
34
- Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true })
79
+ const appStateMock = { layoutRefs: { appContainerRef: { current: containerMock } } }
35
80
  document.exitFullscreen = jest.fn()
36
81
 
37
- // Enter fullscreen
82
+ Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true, configurable: true })
38
83
  fullscreenBtn.onClick({}, { appState: appStateMock })
39
84
  expect(containerMock.requestFullscreen).toHaveBeenCalled()
40
- expect(document.exitFullscreen).not.toHaveBeenCalled()
41
85
 
42
- // Exit fullscreen
43
- Object.defineProperty(document, 'fullscreenElement', { value: containerMock })
86
+ Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true, configurable: true })
44
87
  fullscreenBtn.onClick({}, { appState: appStateMock })
45
88
  expect(document.exitFullscreen).toHaveBeenCalled()
46
89
  })
47
90
 
48
- it('evaluates fullscreen button label and iconId for both fullscreen states', () => {
49
- const containerMock = { requestFullscreen: jest.fn() }
50
-
51
- // Not in fullscreen
52
- Object.defineProperty(document, 'fullscreenElement', { value: null, writable: true })
53
- expect(fullscreenBtn.label({ appState, appConfig })).toBe('Enter fullscreen')
54
- expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('maximise')
91
+ // --- ZOOM BUTTONS (Line 60 & 70 Coverage) ---
92
+ it('covers all branches of zoom excludeWhen for BOTH buttons', () => {
93
+ // We run this test for both ZoomIn and ZoomOut to ensure
94
+ // identical lines in both button configs are covered.
95
+ [zoomInBtn, zoomOutBtn].forEach((btn) => {
96
+ // Branch A: First part of OR is true (!enableZoomControls)
97
+ expect(btn.excludeWhen({
98
+ appConfig: { enableZoomControls: false },
99
+ appState: { interfaceType: 'mouse' }
100
+ })).toBe(true)
101
+
102
+ // Branch B: Second part of OR is true (interfaceType === 'touch')
103
+ expect(btn.excludeWhen({
104
+ appConfig: { enableZoomControls: true },
105
+ appState: { interfaceType: 'touch' }
106
+ })).toBe(true)
107
+
108
+ // Branch C: Both parts are false (Result: false)
109
+ expect(btn.excludeWhen({
110
+ appConfig: { enableZoomControls: true },
111
+ appState: { interfaceType: 'mouse' }
112
+ })).toBe(false)
113
+ })
114
+ })
55
115
 
56
- // In fullscreen
57
- Object.defineProperty(document, 'fullscreenElement', { value: containerMock, writable: true })
58
- expect(fullscreenBtn.label({ appState, appConfig })).toBe('Exit fullscreen')
59
- expect(fullscreenBtn.iconId({ appState, appConfig })).toBe('minimise')
116
+ it('evaluates zoom enableWhen logic', () => {
117
+ expect(zoomInBtn.enableWhen({ mapState: { isAtMaxZoom: true } })).toBe(false)
118
+ expect(zoomInBtn.enableWhen({ mapState: { isAtMaxZoom: false } })).toBe(true)
119
+ expect(zoomOutBtn.enableWhen({ mapState: { isAtMinZoom: true } })).toBe(false)
120
+ expect(zoomOutBtn.enableWhen({ mapState: { isAtMinZoom: false } })).toBe(true)
60
121
  })
61
122
 
62
- it('calls exit button onClick correctly', () => {
63
- const fakeEvent = {}
64
- const servicesMock = { closeApp: jest.fn() }
65
- const handleExitClickMock = jest.fn()
123
+ it('triggers mapProvider zoom methods on click', () => {
124
+ const mapProviderMock = { zoomIn: jest.fn(), zoomOut: jest.fn() }
125
+ const appConfigMock = { zoomDelta: 2 }
66
126
 
67
- exitBtn.onClick(fakeEvent, { services: servicesMock, appConfig: { handleExitClick: handleExitClickMock } })
127
+ zoomInBtn.onClick({}, { mapProvider: mapProviderMock, appConfig: appConfigMock })
128
+ expect(mapProviderMock.zoomIn).toHaveBeenCalledWith(2)
68
129
 
69
- // Assert that the services.closeApp() is called
70
- expect(servicesMock.closeApp).toHaveBeenCalled()
130
+ zoomOutBtn.onClick({}, { mapProvider: mapProviderMock, appConfig: appConfigMock })
131
+ expect(mapProviderMock.zoomOut).toHaveBeenCalledWith(2)
132
+ })
71
133
 
72
- // If your button no longer calls handleExitClick in the current logic, this should NOT be called
73
- expect(handleExitClickMock).not.toHaveBeenCalled()
134
+ // --- SUPPLEMENTARY CONFIGS ---
135
+ it('exports supplementary configs and constants', () => {
136
+ expect(defaultButtonConfig.label).toBe('Button')
137
+ expect(scaleFactor.large).toBe(2)
138
+ expect(markerSvgPaths[0].shape).toBe('pin')
74
139
  })
75
140
  })
@@ -33,6 +33,7 @@ const defaults = {
33
33
  nudgeZoomDelta: 0.1,
34
34
  panDelta: 100,
35
35
  pageTitle: 'Map view',
36
+ preserveStateOnClose: false,
36
37
  readMapText: false,
37
38
  reverseGeocodeProvider: null,
38
39
  zoomDelta: 1
package/src/index.umd.js CHANGED
@@ -6,7 +6,7 @@ import * as JSXRuntime from 'react/jsx-runtime'
6
6
 
7
7
  import InteractiveMap from './index.js'
8
8
 
9
- const g = typeof window !== 'undefined' ? window : globalThis
9
+ const g = typeof window === 'undefined' ? globalThis : window
10
10
 
11
11
  // Create `defra` namespace if missing
12
12
  g.defra = g.defra || {}
@@ -21,6 +21,7 @@
21
21
  @use '../App/components/Panel/Panel.module';
22
22
  @use '../App/components/Viewport/Viewport.module';
23
23
  @use '../App/components/Tooltip/Tooltip.module';
24
+ @use '../App/components/PopupMenu/PopupMenu.module';
24
25
  @use '../App/components/Attributions/Attributions.module';
25
26
  @use '../App/components/Logo/Logo.module';
26
27
  @use '../App/components/CrossHair/CrossHair.module';
@@ -43,7 +43,7 @@
43
43
  --fixed-foreground-color: #0b0c0c;
44
44
 
45
45
  // Content
46
- --app-border-color: #b1b4b6;
46
+ --app-border-color: #cecece;
47
47
  --content-border-color: #f3f2f1;
48
48
  --secondary-text-color: #505a5f;
49
49
  --image-placeholder-color: #b1b4b6;
@@ -37,6 +37,10 @@
37
37
  --banner-max-desktop-width: 400px;
38
38
  --banner-collapse-margin: 14px;
39
39
 
40
+ // Popup menu
41
+ --popup-menu-min-width: 200px;
42
+ --popup-menu-max-width: 300px;
43
+
40
44
  // Action bar (Needs to fit evently if tools are in right hand column)
41
45
  --action-bar-max-width: calc(100% - (var(--button-size) * 2) - (var(--primary-gap) * 4));
42
46
 
@@ -1,5 +1,5 @@
1
1
  // src/services/eventBus.test.js
2
- import eventBus from './eventBus.js'
2
+ import eventBus, { createEventBus } from './eventBus.js'
3
3
 
4
4
  describe('EventBus singleton', () => {
5
5
  beforeEach(() => {
@@ -93,3 +93,26 @@ describe('EventBus singleton', () => {
93
93
  expect(eventBus.events).toEqual({})
94
94
  })
95
95
  })
96
+
97
+ describe('createEventBus factory', () => {
98
+ /**
99
+ * Test to ensure coverage for the factory function (Line 50).
100
+ * Validates that createEventBus returns a fresh, working EventBus instance.
101
+ */
102
+ it('creates a new, independent EventBus instance', () => {
103
+ const newBus = createEventBus()
104
+ const handler = jest.fn()
105
+
106
+ // Verify it is an instance of the same logic
107
+ expect(newBus).toHaveProperty('on')
108
+ expect(newBus).toHaveProperty('emit')
109
+
110
+ // Verify it is independent of the singleton
111
+ newBus.on('instanceTest', handler)
112
+ eventBus.emit('instanceTest', 'data') // Emit on singleton
113
+ expect(handler).not.toHaveBeenCalled()
114
+
115
+ newBus.emit('instanceTest', 'data') // Emit on the new instance
116
+ expect(handler).toHaveBeenCalledWith('data')
117
+ })
118
+ })
package/src/test-utils.js CHANGED
@@ -48,6 +48,7 @@ export const createMockAppState = (overrides = {}) => {
48
48
  disabledButtons: new Set(),
49
49
  hiddenButtons: new Set(),
50
50
  pressedButtons: new Set(),
51
+ expandedButtons: new Set(),
51
52
  buttonConfig: registries.buttonRegistry.getButtonConfig(),
52
53
  panelConfig: registries.panelRegistry.getPanelConfig(),
53
54
  controlConfig: registries.controlRegistry.getControlConfig(),
package/src/types.js CHANGED
@@ -568,6 +568,11 @@
568
568
  * @property {PluginDescriptor[]} [plugins]
569
569
  * Plugins to load.
570
570
  *
571
+ * @property {boolean} [preserveStateOnClose=false]
572
+ * Whether to preserve the map state when closed via back button or exit button.
573
+ * When true, the map is hidden but not destroyed, preserving markers, zoom, etc.
574
+ * Useful for list/map toggle scenarios. Only applies to 'hybrid' and 'buttonFirst' behaviours.
575
+ *
571
576
  * @property {boolean} [readMapText=false]
572
577
  * Whether map text labels can be selected and read aloud by assistive technologies.
573
578
  *