@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
@@ -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';
@@ -12,8 +12,8 @@
12
12
 
13
13
  // Borders and focus
14
14
  --app-border-width: 1px;
15
- --button-border-radius: 0px; // 4px;
16
- --panel-border-radius: 0px; // 8px;
15
+ --button-border-radius: 4px;
16
+ --panel-border-radius: 8px;
17
17
  --focus-border-width: 3px;
18
18
  --focus-outline-width: 3px;
19
19
 
@@ -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
  *
@@ -1,106 +1,107 @@
1
- function createBreakpointDetector ({ maxMobileWidth, minDesktopWidth, containerEl }) {
2
- let lastBreakpoint = 'unknown'
3
- const listeners = new Set()
4
- let cleanup = null
5
-
6
- const getBreakpointType = (width) => {
7
- if (width <= maxMobileWidth) {
8
- return 'mobile'
9
- }
10
- if (width >= minDesktopWidth) {
11
- return 'desktop'
12
- }
13
- return 'tablet'
1
+ function getBreakpointType (width, maxMobileWidth, minDesktopWidth) {
2
+ if (width <= maxMobileWidth) {
3
+ return 'mobile'
14
4
  }
15
-
16
- const notifyListeners = (type) => {
17
- if (type !== lastBreakpoint) {
18
- lastBreakpoint = type // Set synchronously BEFORE RAF
19
- requestAnimationFrame(() => {
20
- // Double-check it hasn't changed again
21
- if (lastBreakpoint === type) {
22
- listeners.forEach(fn => fn(type))
23
- }
24
- })
25
- }
5
+ if (width >= minDesktopWidth) {
6
+ return 'desktop'
26
7
  }
8
+ return 'tablet'
9
+ }
27
10
 
28
- // Container-based detection
29
- if (containerEl) {
30
- containerEl.style.containerType = 'inline-size'
31
-
32
- // Set initial detection BEFORE observing to prevent double notification
33
- const initialWidth = containerEl.getBoundingClientRect().width
34
- const initialType = getBreakpointType(initialWidth)
35
- containerEl.setAttribute('data-breakpoint', initialType)
36
- lastBreakpoint = initialType // Set this directly, don't notify yet
11
+ function createContainerDetector (containerEl, getType, notifyListeners) {
12
+ containerEl.style.containerType = 'inline-size'
37
13
 
38
- const observer = new ResizeObserver((entries) => {
39
- const width = entries[0]?.borderBoxSize?.[0]?.inlineSize || entries[0]?.contentRect.width
40
- const type = getBreakpointType(width)
41
- containerEl.setAttribute('data-breakpoint', type)
42
- notifyListeners(type)
43
- })
14
+ const initialWidth = containerEl.getBoundingClientRect().width
15
+ const initialType = getType(initialWidth)
16
+ containerEl.dataset.breakpoint = initialType
44
17
 
45
- observer.observe(containerEl)
18
+ const observer = new window.ResizeObserver((entries) => {
19
+ const width = entries[0]?.borderBoxSize?.[0]?.inlineSize || entries[0]?.contentRect.width
20
+ const type = getType(width)
21
+ containerEl.dataset.breakpoint = type
22
+ notifyListeners(type)
23
+ })
46
24
 
47
- // Now notify listeners after observer is set up
48
- notifyListeners(initialType)
25
+ observer.observe(containerEl)
49
26
 
50
- cleanup = () => {
27
+ return {
28
+ initialType,
29
+ cleanup: () => {
51
30
  observer.disconnect()
52
31
  containerEl.style.containerType = ''
53
- containerEl.removeAttribute('data-breakpoint')
32
+ delete containerEl.dataset.breakpoint
54
33
  }
55
- } else {
56
- // Viewport-based fallback
57
- const mq = {
58
- mobile: window.matchMedia(`(max-width: ${maxMobileWidth}px)`),
59
- desktop: window.matchMedia(`(min-width: ${minDesktopWidth}px)`)
60
- }
61
-
62
- const detect = () => {
63
- let type
34
+ }
35
+ }
64
36
 
65
- if (mq.mobile.matches) {
66
- type = 'mobile'
67
- } else if (mq.desktop.matches) {
68
- type = 'desktop'
69
- } else {
70
- type = 'tablet'
71
- }
37
+ function createViewportDetector (maxMobileWidth, minDesktopWidth, notifyListeners) {
38
+ const mq = {
39
+ mobile: window.matchMedia(`(max-width: ${maxMobileWidth}px)`),
40
+ desktop: window.matchMedia(`(min-width: ${minDesktopWidth}px)`)
41
+ }
72
42
 
73
- notifyListeners(type)
43
+ const detect = () => {
44
+ let type = 'tablet'
45
+ if (mq.mobile.matches) {
46
+ type = 'mobile'
47
+ } else if (mq.desktop.matches) {
48
+ type = 'desktop'
49
+ } else {
50
+ // No action
74
51
  }
52
+ notifyListeners(type)
53
+ }
75
54
 
76
- mq.mobile.addEventListener('change', detect)
77
- mq.desktop.addEventListener('change', detect)
78
- detect()
55
+ mq.mobile.addEventListener('change', detect)
56
+ mq.desktop.addEventListener('change', detect)
57
+ detect()
79
58
 
80
- cleanup = () => {
59
+ return {
60
+ cleanup: () => {
81
61
  mq.mobile.removeEventListener('change', detect)
82
62
  mq.desktop.removeEventListener('change', detect)
83
63
  }
84
64
  }
65
+ }
85
66
 
86
- const subscribe = (fn) => {
87
- listeners.add(fn)
88
- return () => listeners.delete(fn)
89
- }
67
+ function createBreakpointDetector ({ maxMobileWidth, minDesktopWidth, containerEl }) {
68
+ let lastBreakpoint = 'unknown'
69
+ const listeners = new Set()
90
70
 
91
- const getBreakpoint = () => {
92
- return lastBreakpoint === 'unknown' ? 'desktop' : lastBreakpoint
71
+ const notifyListeners = (type) => {
72
+ if (type !== lastBreakpoint) {
73
+ lastBreakpoint = type
74
+ requestAnimationFrame(() => {
75
+ if (lastBreakpoint === type) {
76
+ listeners.forEach(fn => fn(type))
77
+ }
78
+ })
79
+ }
93
80
  }
94
81
 
95
- const destroy = () => {
96
- cleanup?.()
97
- listeners.clear()
82
+ const getType = (width) => getBreakpointType(width, maxMobileWidth, minDesktopWidth)
83
+
84
+ let cleanup
85
+ if (containerEl) {
86
+ const detector = createContainerDetector(containerEl, getType, notifyListeners)
87
+ lastBreakpoint = detector.initialType
88
+ notifyListeners(detector.initialType)
89
+ cleanup = detector.cleanup
90
+ } else {
91
+ const detector = createViewportDetector(maxMobileWidth, minDesktopWidth, notifyListeners)
92
+ cleanup = detector.cleanup
98
93
  }
99
94
 
100
95
  return {
101
- subscribe,
102
- getBreakpoint,
103
- destroy
96
+ subscribe: (fn) => {
97
+ listeners.add(fn)
98
+ return () => listeners.delete(fn)
99
+ },
100
+ getBreakpoint: () => lastBreakpoint,
101
+ destroy: () => {
102
+ cleanup?.()
103
+ listeners.clear()
104
+ }
104
105
  }
105
106
  }
106
107