@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
@@ -5,22 +5,101 @@ import { useService } from '../store/serviceContext.js'
5
5
  import { scaleFactor } from '../../config/appConfig.js'
6
6
  import { EVENTS as events } from '../../config/events.js'
7
7
 
8
- // Pure function - easier to test
8
+ // Vertical offset to align the marker tip with the coordinate point
9
+ const MARKER_ANCHOR_OFFSET_Y = 19
10
+
11
+ /**
12
+ * Projects geographic coordinates to screen pixel position, scaled for the
13
+ * current map size and offset so the marker tip aligns with the point.
14
+ *
15
+ * @param {Array<number>} coords - [lng, lat] geographic coordinates
16
+ * @param {Object} mapProvider - Map provider instance with `mapToScreen` method
17
+ * @param {string} mapSize - Current map size key (e.g. 'small', 'medium', 'large')
18
+ * @param {boolean} isMapReady - Whether the map has finished initialising
19
+ * @returns {{ x: number, y: number }} Screen position in pixels
20
+ */
9
21
  export const projectCoords = (coords, mapProvider, mapSize, isMapReady) => {
10
22
  if (!mapProvider || !isMapReady) {
11
23
  return { x: 0, y: 0 }
12
24
  }
13
25
  const { x, y } = mapProvider.mapToScreen(coords)
14
- return { x: x * scaleFactor[mapSize], y: y * scaleFactor[mapSize] - 19 }
26
+ return {
27
+ x: x * scaleFactor[mapSize],
28
+ y: y * scaleFactor[mapSize] - MARKER_ANCHOR_OFFSET_Y
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Reprojects and repositions all marker DOM elements to their current screen
34
+ * coordinates. Called on map render and resize events.
35
+ *
36
+ * @param {Array<Object>} items - Marker state items from the store
37
+ * @param {Map<string, HTMLElement>} markerRefs - Map of marker id to DOM element
38
+ * @param {Object} mapProvider - Map provider instance
39
+ * @param {string} mapSize - Current map size key
40
+ * @param {boolean} isMapReady - Whether the map has finished initialising
41
+ */
42
+ const updateMarkerPositions = (items, markerRefs, mapProvider, mapSize, isMapReady) => {
43
+ items.forEach(marker => {
44
+ const ref = markerRefs.get(marker.id)
45
+ if (!ref || !marker.coords) {
46
+ return
47
+ }
48
+
49
+ const { x, y } = projectCoords(marker.coords, mapProvider, mapSize, isMapReady)
50
+ ref.style.transform = `translate(${x}px, ${y}px)`
51
+ ref.style.display = 'block'
52
+ })
53
+ }
54
+
55
+ /**
56
+ * Registers event bus listeners for adding and removing markers via
57
+ * APP_ADD_MARKER and APP_REMOVE_MARKER events.
58
+ *
59
+ * @param {Object} eventBus - Application event bus
60
+ * @param {Object} markers - Markers API object with `add` and `remove` methods
61
+ */
62
+ const useMarkerEventListeners = (eventBus, markers) => {
63
+ useEffect(() => {
64
+ const handleAddMarker = (payload = {}) => {
65
+ if (!payload?.id || !payload?.coords) {
66
+ return
67
+ }
68
+ const { id, coords, options } = payload
69
+ markers.add(id, coords, options)
70
+ }
71
+ eventBus.on(events.APP_ADD_MARKER, handleAddMarker)
72
+
73
+ const handleRemoveMarker = (id) => {
74
+ if (!id) {
75
+ return
76
+ }
77
+ markers.remove(id)
78
+ }
79
+ eventBus.on(events.APP_REMOVE_MARKER, handleRemoveMarker)
80
+
81
+ return () => {
82
+ eventBus.off(events.APP_ADD_MARKER, handleAddMarker)
83
+ eventBus.off(events.APP_REMOVE_MARKER, handleRemoveMarker)
84
+ }
85
+ }, [])
15
86
  }
16
87
 
88
+ /**
89
+ * Hook that provides the markers API and ref callback for positioning marker
90
+ * elements on the map. Attaches `add`, `remove`, and `getMarker` methods to the
91
+ * markers store object and keeps marker positions in sync with map render and
92
+ * resize events.
93
+ *
94
+ * @returns {{ markers: Object, markerRef: Function }}
95
+ */
17
96
  export const useMarkers = () => {
18
97
  const { mapProvider } = useConfig()
19
98
  const { eventBus } = useService()
20
99
  const { markers, dispatch, mapSize, isMapReady } = useMap()
21
100
  const markerRefs = useRef(new Map())
22
101
 
23
- // --- API: Attach methods to markers object ---
102
+ // Attach add, remove, and getMarker methods to the markers store object
24
103
  useEffect(() => {
25
104
  if (!mapProvider) {
26
105
  return
@@ -42,11 +121,11 @@ export const useMarkers = () => {
42
121
  }
43
122
  }, [mapProvider, markers, dispatch, mapSize])
44
123
 
45
- // Update marker position on events.MAP_RENDER
124
+ // Ref callback: stores marker DOM refs and subscribes to MAP_RENDER for repositioning
46
125
  const markerRef = useCallback((id) => (el) => {
47
126
  if (!el) {
48
127
  markerRefs.current.delete(id)
49
- return
128
+ return undefined
50
129
  }
51
130
  markerRefs.current.set(id, el)
52
131
 
@@ -54,17 +133,7 @@ export const useMarkers = () => {
54
133
  if (!isMapReady || !mapProvider) {
55
134
  return
56
135
  }
57
-
58
- markers.items.forEach(marker => {
59
- const ref = markerRefs.current.get(marker.id)
60
- if (!ref || !marker.coords) {
61
- return
62
- }
63
-
64
- const { x, y } = projectCoords(marker.coords, mapProvider, mapSize, isMapReady)
65
- ref.style.transform = `translate(${x}px, ${y}px)`
66
- ref.style.display = 'block'
67
- })
136
+ updateMarkerPositions(markers.items, markerRefs.current, mapProvider, mapSize, isMapReady)
68
137
  }
69
138
  eventBus.on(events.MAP_RENDER, updateMarkers)
70
139
 
@@ -73,47 +142,16 @@ export const useMarkers = () => {
73
142
  }
74
143
  }, [markers, mapProvider, isMapReady, mapSize])
75
144
 
76
- // Update all markers on map resize
145
+ // Reproject all markers when the map size changes
77
146
  useEffect(() => {
78
147
  if (!isMapReady || !mapProvider) {
79
148
  return
80
149
  }
81
150
 
82
- markers.items.forEach(marker => {
83
- const ref = markerRefs.current.get(marker.id)
84
- if (!ref || !marker.coords) {
85
- return
86
- }
87
-
88
- const { x, y } = projectCoords(marker.coords, mapProvider, mapSize, isMapReady)
89
- ref.style.transform = `translate(${x}px, ${y}px)`
90
- })
151
+ updateMarkerPositions(markers.items, markerRefs.current, mapProvider, mapSize, isMapReady)
91
152
  }, [mapSize, markers.items, mapProvider, isMapReady])
92
153
 
93
- // Respond to external API calls via eventBus
94
- useEffect(() => {
95
- const handleAddMarker = (payload = {}) => {
96
- if (!payload || !payload.id || !payload.coords) {
97
- return
98
- }
99
- const { id, coords, options } = payload
100
- markers.add(id, coords, options)
101
- }
102
- eventBus.on(events.APP_ADD_MARKER, handleAddMarker)
103
-
104
- const handleRemoveMarker = (id) => {
105
- if (!id) {
106
- return
107
- }
108
- markers.remove(id)
109
- }
110
- eventBus.on(events.APP_REMOVE_MARKER, handleRemoveMarker)
111
-
112
- return () => {
113
- eventBus.off(events.APP_ADD_MARKER, handleAddMarker)
114
- eventBus.off(events.APP_REMOVE_MARKER, handleRemoveMarker)
115
- }
116
- }, [])
154
+ useMarkerEventListeners(eventBus, markers)
117
155
 
118
156
  return { markers, markerRef }
119
157
  }
@@ -115,4 +115,52 @@ describe('useMediaQueryDispatch', () => {
115
115
  payload: { preferredColorScheme: 'light', prefersReducedMotion: true }
116
116
  })
117
117
  })
118
+
119
+ it('sets up hybrid media query and dispatches on change', () => {
120
+ const options = {
121
+ behaviour: 'hybrid',
122
+ hybridWidth: 500,
123
+ maxMobileWidth: 768,
124
+ appColorScheme: 'light',
125
+ autoColorScheme: true
126
+ }
127
+
128
+ renderHook(() => useMediaQueryDispatch(dispatch, options))
129
+
130
+ // Verify matchMedia was called with the hybrid threshold
131
+ expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 500px)')
132
+
133
+ // Verify addEventListener was called for the 2 standard queries + 1 hybrid query
134
+ expect(mockMediaQuery.addEventListener).toHaveBeenCalledTimes(3)
135
+
136
+ // Find the hybrid change handler (the last one registered)
137
+ const hybridHandler = mockMediaQuery.addEventListener.mock.calls[2][1]
138
+
139
+ // Simulate a media query match event
140
+ act(() => {
141
+ hybridHandler({ matches: true })
142
+ })
143
+
144
+ expect(dispatch).toHaveBeenCalledWith({
145
+ type: 'SET_HYBRID_FULLSCREEN',
146
+ payload: true
147
+ })
148
+ })
149
+
150
+ it('falls back to maxMobileWidth for hybrid threshold when hybridWidth is missing', () => {
151
+ const options = {
152
+ behaviour: 'hybrid',
153
+ // hybridWidth is undefined here
154
+ maxMobileWidth: 800,
155
+ appColorScheme: 'light',
156
+ autoColorScheme: true
157
+ }
158
+
159
+ renderHook(() => useMediaQueryDispatch(dispatch, options))
160
+
161
+ // This covers the right side of the ?? operator
162
+ expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 800px)')
163
+
164
+ expect(mockMediaQuery.addEventListener).toHaveBeenCalledTimes(3)
165
+ })
118
166
  })
@@ -3,18 +3,10 @@ import { useResizeObserver } from './useResizeObserver.js'
3
3
  import { constrainKeyboardFocus } from '../../utils/constrainKeyboardFocus.js'
4
4
  import { toggleInertElements } from '../../utils/toggleInertElements.js'
5
5
 
6
- export function useModalPanelBehaviour ({
7
- mainRef,
8
- panelRef,
9
- isModal,
10
- rootEl,
11
- buttonContainerEl,
12
- handleClose
13
- }) {
14
- // === Escape and Tab key handling === //
6
+ const useModalKeyHandler = (panelRef, isModal, handleClose) => {
15
7
  useEffect(() => {
16
8
  if (!isModal) {
17
- return
9
+ return undefined
18
10
  }
19
11
 
20
12
  const handleKeyDown = (e) => {
@@ -36,6 +28,47 @@ export function useModalPanelBehaviour ({
36
28
  current?.removeEventListener('keydown', handleKeyDown)
37
29
  }
38
30
  }, [isModal, panelRef, handleClose])
31
+ }
32
+
33
+ const useFocusRedirect = (isModal, panelRef, rootEl) => {
34
+ useEffect(() => {
35
+ if (!isModal) {
36
+ return undefined
37
+ }
38
+
39
+ const handleFocusIn = (e) => {
40
+ const focusedEl = e.target
41
+ const panelEl = panelRef.current
42
+
43
+ if (!focusedEl || !panelEl || !rootEl) {
44
+ return
45
+ }
46
+
47
+ const isInsideApp = rootEl.contains(focusedEl)
48
+ const isInsidePanel = panelEl.contains(focusedEl)
49
+
50
+ if (isInsideApp && !isInsidePanel) {
51
+ panelEl.focus()
52
+ }
53
+ }
54
+
55
+ document.addEventListener('focusin', handleFocusIn)
56
+
57
+ return () => {
58
+ document.removeEventListener('focusin', handleFocusIn)
59
+ }
60
+ }, [isModal, panelRef, rootEl])
61
+ }
62
+
63
+ export function useModalPanelBehaviour ({
64
+ mainRef,
65
+ panelRef,
66
+ isModal,
67
+ rootEl,
68
+ buttonContainerEl,
69
+ handleClose
70
+ }) {
71
+ useModalKeyHandler(panelRef, isModal, handleClose)
39
72
 
40
73
  // === Set absolute offset positions and recalculate on mainRef resize === //
41
74
  const root = document.documentElement
@@ -55,7 +88,7 @@ export function useModalPanelBehaviour ({
55
88
  // === Click on modal backdrop to close === //
56
89
  useEffect(() => {
57
90
  if (!isModal) {
58
- return
91
+ return undefined
59
92
  }
60
93
 
61
94
  const handleClick = (e) => {
@@ -74,7 +107,7 @@ export function useModalPanelBehaviour ({
74
107
  // === Inert everything outside the panel but within the app === //
75
108
  useEffect(() => {
76
109
  if (!isModal || !panelRef.current || !rootEl) {
77
- return
110
+ return undefined
78
111
  }
79
112
 
80
113
  toggleInertElements({
@@ -92,32 +125,5 @@ export function useModalPanelBehaviour ({
92
125
  }
93
126
  }, [isModal, panelRef, rootEl])
94
127
 
95
- // === Redirect focus into the panel if it enters the app === //
96
- useEffect(() => {
97
- if (!isModal) {
98
- return
99
- }
100
-
101
- const handleFocusIn = (e) => {
102
- const focusedEl = e.target
103
- const panelEl = panelRef.current
104
-
105
- if (!focusedEl || !panelEl || !rootEl) {
106
- return
107
- }
108
-
109
- const isInsideApp = rootEl.contains(focusedEl)
110
- const isInsidePanel = panelEl.contains(focusedEl)
111
-
112
- if (isInsideApp && !isInsidePanel) {
113
- panelEl.focus()
114
- }
115
- }
116
-
117
- document.addEventListener('focusin', handleFocusIn)
118
-
119
- return () => {
120
- document.removeEventListener('focusin', handleFocusIn)
121
- }
122
- }, [isModal, panelRef, rootEl])
128
+ useFocusRedirect(isModal, panelRef, rootEl)
123
129
  }
@@ -9,10 +9,10 @@ export function useResizeObserver (targetRefs, callback) {
9
9
  const elements = refs.map(r => r?.current).filter(Boolean)
10
10
 
11
11
  if (!elements.length || !callback) {
12
- return
12
+ return undefined
13
13
  }
14
14
 
15
- const observer = new ResizeObserver(entries => {
15
+ const observer = new window.ResizeObserver(entries => {
16
16
  for (const entry of entries) {
17
17
  const { width, height } = entry.contentRect
18
18
  const prev = prevSizes.current.get(entry.target) || {}
@@ -1,6 +1,6 @@
1
1
  import { createRoot } from 'react-dom/client'
2
2
  import { EVENTS as events } from '../config/events.js'
3
- import { appConfig } from '../config/appConfig.js'
3
+ import { defaultAppConfig } from '../config/appConfig.js'
4
4
  import { createButtonRegistry } from './registry/buttonRegistry.js'
5
5
  import { createPanelRegistry } from './registry/panelRegistry.js'
6
6
  import { createControlRegistry } from './registry/controlRegistry.js'
@@ -13,6 +13,47 @@ const rootMap = new WeakMap()
13
13
  const mapProviderMap = new WeakMap()
14
14
  const registryMap = new WeakMap()
15
15
 
16
+ const getOrCreateRegistries = (rootElement) => {
17
+ let registries = registryMap.get(rootElement)
18
+ if (!registries) {
19
+ const buttonRegistry = createButtonRegistry()
20
+ const panelRegistry = createPanelRegistry()
21
+ const controlRegistry = createControlRegistry()
22
+ const pluginRegistry = createPluginRegistry({
23
+ registerButton: buttonRegistry.registerButton,
24
+ registerPanel: panelRegistry.registerPanel,
25
+ registerControl: controlRegistry.registerControl
26
+ })
27
+
28
+ registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry }
29
+ registryMap.set(rootElement, registries)
30
+ }
31
+ return registries
32
+ }
33
+
34
+ const loadPlugins = async (plugins, registerPlugin) => {
35
+ for (const plugin of plugins) {
36
+ if (typeof plugin.load === 'function') {
37
+ const module = await plugin.load()
38
+ const { id: pluginId, load, manifest: overrideManifest, ...config } = plugin
39
+ const { InitComponent, api, reducer, ...baseManifest } = module
40
+
41
+ // Merge runtime overrides with module manifest
42
+ const manifest = mergeManifests(baseManifest, overrideManifest)
43
+
44
+ registerPlugin({
45
+ id: pluginId,
46
+ InitComponent,
47
+ api,
48
+ reducer,
49
+ config,
50
+ manifest,
51
+ _originalPlugin: plugin
52
+ })
53
+ }
54
+ }
55
+ }
56
+
16
57
  export async function initialiseApp (rootElement, {
17
58
  MapProvider: MapProviderClass,
18
59
  mapProviderConfig,
@@ -35,22 +76,7 @@ export async function initialiseApp (rootElement, {
35
76
  }
36
77
 
37
78
  // Reuse or create registries (persist across app open/close cycles)
38
- let registries = registryMap.get(rootElement)
39
- if (!registries) {
40
- const buttonRegistry = createButtonRegistry()
41
- const panelRegistry = createPanelRegistry()
42
- const controlRegistry = createControlRegistry()
43
- const pluginRegistry = createPluginRegistry({
44
- registerButton: buttonRegistry.registerButton,
45
- registerPanel: panelRegistry.registerPanel,
46
- registerControl: controlRegistry.registerControl
47
- })
48
-
49
- registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry }
50
- registryMap.set(rootElement, registries)
51
- }
52
-
53
- const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } = registries
79
+ const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } = getOrCreateRegistries(rootElement)
54
80
  const { registerPlugin } = pluginRegistry
55
81
 
56
82
  // Clear previous plugins (but keep runtime additions)
@@ -59,7 +85,7 @@ export async function initialiseApp (rootElement, {
59
85
  // Register default appConfig as a plugin
60
86
  registerPlugin({
61
87
  id: 'appConfig',
62
- manifest: appConfig
88
+ manifest: defaultAppConfig
63
89
  })
64
90
 
65
91
  // Create root if not already present
@@ -87,26 +113,7 @@ export async function initialiseApp (rootElement, {
87
113
  }
88
114
 
89
115
  // Load plugins
90
- for (const plugin of plugins) {
91
- if (typeof plugin.load === 'function') {
92
- const module = await plugin.load()
93
- const { id: pluginId, load, manifest: overrideManifest, ...config } = plugin
94
- const { InitComponent, api, reducer, ...baseManifest } = module
95
-
96
- // Merge runtime overrides with module manifest
97
- const manifest = mergeManifests(baseManifest, overrideManifest)
98
-
99
- registerPlugin({
100
- id: pluginId,
101
- InitComponent,
102
- api,
103
- reducer,
104
- config,
105
- manifest,
106
- _originalPlugin: plugin
107
- })
108
- }
109
- }
116
+ await loadPlugins(plugins, registerPlugin)
110
117
 
111
118
  root.render(<App
112
119
  {...restProps}
@@ -9,6 +9,7 @@ import { Logo } from '../components/Logo/Logo'
9
9
  import { Attributions } from '../components/Attributions/Attributions'
10
10
  import { layoutSlots } from '../renderer/slots'
11
11
  import { SlotRenderer } from '../renderer/SlotRenderer'
12
+ import { HtmlElementHost } from '../renderer/HtmlElementHost'
12
13
 
13
14
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
14
15
  // sonarjs/disable-next-line function-name
@@ -36,7 +37,7 @@ export const Layout = () => {
36
37
  ref={layoutRefs.appContainerRef}
37
38
  >
38
39
  <Viewport keyboardHintPortalRef={layoutRefs.topRef} />
39
- <div className={`im-o-app__overlay${!isLayoutReady ? ' im-o-app__overlay--not-ready' : ''}`}>
40
+ <div className={`im-o-app__overlay${isLayoutReady ? '' : ' im-o-app__overlay--not-ready'}`}>
40
41
  <div className='im-o-app__side' ref={layoutRefs.sideRef}>
41
42
  <SlotRenderer slot={layoutSlots.SIDE} />
42
43
  </div>
@@ -73,7 +74,7 @@ export const Layout = () => {
73
74
  <SlotRenderer slot={layoutSlots.RIGHT_BOTTOM} />
74
75
  </div>
75
76
  </div>
76
- <div className='im-o-app__middle'>
77
+ <div className='im-o-app__middle' ref={layoutRefs.middleRef}>
77
78
  <SlotRenderer slot={layoutSlots.MIDDLE} />
78
79
  </div>
79
80
  <div className='im-o-app__footer' ref={layoutRefs.footerRef}>
@@ -87,7 +88,7 @@ export const Layout = () => {
87
88
  </div>
88
89
  </div>
89
90
  </div>
90
- <div className='im-o-app__bottom'>
91
+ <div className='im-o-app__bottom' ref={layoutRefs.bottomRef}>
91
92
  <SlotRenderer slot={layoutSlots.BOTTOM} />
92
93
  </div>
93
94
  <div className='im-o-app__actions' ref={layoutRefs.actionsRef}>
@@ -95,7 +96,8 @@ export const Layout = () => {
95
96
  </div>
96
97
  </div>
97
98
  </div>
98
- <div className='im-o-app__modal'>
99
+ <HtmlElementHost />
100
+ <div className='im-o-app__modal' ref={layoutRefs.modalRef}>
99
101
  <SlotRenderer slot={layoutSlots.MODAL} />
100
102
  <div className='im-o-app__modal-backdrop' />
101
103
  </div>
@@ -268,7 +268,10 @@
268
268
  // Panel overrides
269
269
  .im-c-panel {
270
270
  position: absolute;
271
- inset: 0 0 auto;
271
+ top: 0;
272
+ right: 0;
273
+ bottom: auto;
274
+ left: 0;
272
275
  z-index: 1000;
273
276
  }
274
277
 
@@ -318,15 +321,22 @@
318
321
  }
319
322
 
320
323
  [class*="im-c-panel--"][class*="-button"] { // Adjacent to button
321
- inset: var(--modal-inset);
324
+ top: var(--modal-inset);
325
+ right: var(--modal-inset);
326
+ bottom: var(--modal-inset);
327
+ left: var(--modal-inset);
322
328
  }
323
329
  }
324
330
 
325
- // Mobile and tablet modal banner margin
331
+ // Mobile and tablet
326
332
  .im-o-app--mobile, .im-o-app--tablet {
327
333
  .im-o-app__modal .im-c-panel--banner {
328
334
  margin: var(--primary-gap);
329
335
  }
336
+
337
+ .im-o-app__banner {
338
+ z-index: 9;
339
+ }
330
340
  }
331
341
 
332
342
  // Desktop modal banner margin
@@ -347,7 +357,6 @@
347
357
  .im-o-app__right,
348
358
  .im-o-app__right-bottom {
349
359
  opacity: 0;
350
- // visibility: hidden; Breaks focus order in Preact
351
360
  }
352
361
  }
353
362
 
@@ -357,13 +366,6 @@
357
366
  opacity: 0;
358
367
  }
359
368
 
360
- // Banner on mobile and tablet
361
- .im-o-app--mobile, .im-o-app--tablet {
362
- .im-o-app__banner {
363
- z-index: 9;
364
- }
365
- }
366
-
367
369
  // Banner on desktop
368
370
  .im-o-app--desktop .im-c-panel--banner {
369
371
  margin-left: auto;
@@ -404,12 +406,6 @@
404
406
  clip-path: inset(-20px 0 0 0);
405
407
  }
406
408
 
407
- // Bottom inset on large devices
408
- // .im-o-app--tablet .im-o-app__bottom,
409
- // .im-o-app--desktop .im-o-app__bottom {
410
- // margin-top: var(--primary-gap);
411
- // }
412
-
413
409
  // 4. State styles
414
410
 
415
411
  // 5. Responsive tweaks
@@ -21,8 +21,18 @@ export const setProviderSupportedShortcuts = (ids = []) => {
21
21
  providerSupportedIds = new Set(ids)
22
22
  }
23
23
 
24
- export const getKeyboardShortcuts = () => {
25
- const filteredCore = coreShortcuts.filter(s => providerSupportedIds.has(s.id))
24
+ export const getKeyboardShortcuts = (appConfig = {}) => {
25
+ const filteredCore = coreShortcuts.filter(s => {
26
+ // Must be supported by provider
27
+ if (!providerSupportedIds.has(s.id)) {
28
+ return false
29
+ }
30
+ // Check requiredConfig - all specified config values must be truthy
31
+ if (s.requiredConfig) {
32
+ return s.requiredConfig.every(key => appConfig[key])
33
+ }
34
+ return true
35
+ })
26
36
 
27
37
  return [
28
38
  ...filteredCore, // supported core shortcuts
@@ -70,4 +70,32 @@ describe('keyboardShortcutRegistry', () => {
70
70
  const shortcuts = getKeyboardShortcuts()
71
71
  expect(shortcuts).toEqual([])
72
72
  })
73
+
74
+ test('getKeyboardShortcuts filters by requiredConfig when appConfig provided', () => {
75
+ jest.resetModules()
76
+ jest.doMock('../controls/keyboardShortcuts.js', () => ({
77
+ coreShortcuts: [
78
+ { id: 'always', description: 'Always shown' },
79
+ { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
80
+ ]
81
+ }))
82
+ const module = require('./keyboardShortcutRegistry.js')
83
+ module.setProviderSupportedShortcuts(['always', 'conditional'])
84
+
85
+ // Without config, conditional shortcut is excluded
86
+ expect(module.getKeyboardShortcuts()).toEqual([
87
+ { id: 'always', description: 'Always shown' }
88
+ ])
89
+
90
+ // With config false, conditional shortcut is excluded
91
+ expect(module.getKeyboardShortcuts({ featureEnabled: false })).toEqual([
92
+ { id: 'always', description: 'Always shown' }
93
+ ])
94
+
95
+ // With config true, conditional shortcut is included
96
+ expect(module.getKeyboardShortcuts({ featureEnabled: true })).toEqual([
97
+ { id: 'always', description: 'Always shown' },
98
+ { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
99
+ ])
100
+ })
73
101
  })
@@ -8,7 +8,7 @@ export function mergeManifests (base = {}, override = {}) {
8
8
  }
9
9
  const map = new Map(baseArr.map(item => [item.id, item]))
10
10
  overrideArr.forEach(item => {
11
- if (!item || !item.id) {
11
+ if (!item?.id) {
12
12
  return
13
13
  }
14
14
  if (map.has(item.id)) {
@@ -30,7 +30,7 @@ export const addPanel = (currentConfig, id, config) => {
30
30
  }
31
31
 
32
32
  export const removePanel = (currentConfig, id) => {
33
- const { [id]: _, ...rest } = currentConfig
33
+ const { [id]: _, ...rest } = currentConfig // NOSONAR - _ is required to destructure out the key
34
34
  return rest
35
35
  }
36
36