@defra/interactive-map 0.0.22-alpha → 0.0.24-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 (242) hide show
  1. package/assets/images/light-raster-thumb.jpg +0 -0
  2. package/assets/images/outdoor-raster-thumb.jpg +0 -0
  3. package/assets/images/road-raster-thumb.jpg +0 -0
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/button-definition.md +39 -0
  10. package/docs/api/context.md +4 -4
  11. package/docs/api/map-style-config.md +11 -2
  12. package/docs/api/marker-config.md +38 -7
  13. package/docs/api/panel-definition.md +7 -3
  14. package/docs/api/symbol-config.md +10 -26
  15. package/docs/api/symbol-registry.md +28 -13
  16. package/docs/api.md +24 -2
  17. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  18. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  19. package/docs/examples/index.mdx +12 -0
  20. package/docs/examples/toggle-marker-label.mdx +89 -0
  21. package/docs/plugins/interact.md +4 -11
  22. package/docs/plugins/search.md +70 -0
  23. package/docusaurus.config.cjs +2 -1
  24. package/package.json +23 -9
  25. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  26. package/plugins/beta/datasets/dist/css/index.css +2 -2
  27. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  28. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  31. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  32. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  33. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  34. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  35. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  36. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  37. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  39. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  40. package/plugins/beta/draw-ml/dist/css/index.css +1 -21
  41. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  42. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  43. package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
  44. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
  45. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.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/use-location/dist/esm/im-use-location-plugin.js +1 -1
  49. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  50. package/plugins/beta/use-location/src/manifest.js +1 -0
  51. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  53. package/plugins/interact/dist/umd/index.js +1 -1
  54. package/plugins/interact/src/InteractInit.jsx +23 -55
  55. package/plugins/interact/src/InteractInit.test.js +58 -80
  56. package/plugins/interact/src/defaults.js +1 -3
  57. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  58. package/plugins/interact/src/hooks/useAttachEvents.test.js +68 -0
  59. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  60. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  61. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  62. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  65. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  66. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  67. package/plugins/interact/src/index.js +0 -2
  68. package/plugins/interact/src/index.test.js +0 -2
  69. package/plugins/interact/src/manifest.js +37 -6
  70. package/plugins/interact/src/reducer.js +8 -4
  71. package/plugins/interact/src/reducer.test.js +18 -3
  72. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  73. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  74. package/plugins/interact/src/utils/spatial.js +0 -15
  75. package/plugins/interact/src/utils/spatial.test.js +1 -13
  76. package/plugins/search/dist/css/index.css +1 -1
  77. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  78. package/plugins/search/dist/esm/index.js +1 -1
  79. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  80. package/plugins/search/dist/umd/index.js +1 -1
  81. package/plugins/search/src/Search.jsx +2 -1
  82. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  83. package/plugins/search/src/components/Form/Form.jsx +11 -7
  84. package/plugins/search/src/components/Form/Form.test.jsx +23 -1
  85. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  86. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  87. package/plugins/search/src/datasets.js +2 -2
  88. package/plugins/search/src/datasets.test.js +1 -1
  89. package/plugins/search/src/defaults.js +7 -1
  90. package/plugins/search/src/events/fetchSuggestions.js +13 -10
  91. package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
  92. package/plugins/search/src/events/formHandlers.js +2 -4
  93. package/plugins/search/src/events/formHandlers.test.js +3 -3
  94. package/plugins/search/src/events/inputHandlers.js +1 -1
  95. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  96. package/plugins/search/src/events/suggestionHandlers.js +2 -1
  97. package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
  98. package/plugins/search/src/index.js +3 -7
  99. package/plugins/search/src/reducer.js +10 -5
  100. package/plugins/search/src/reducer.test.js +2 -1
  101. package/plugins/search/src/search.scss +1 -0
  102. package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
  103. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  104. package/providers/beta/esri/src/esriProvider.js +4 -0
  105. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
  106. package/providers/beta/openlayers/dist/esm/index.js +1 -0
  107. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
  108. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
  109. package/providers/beta/openlayers/dist/umd/index.js +2 -0
  110. package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
  111. package/providers/beta/openlayers/src/appEvents.js +50 -0
  112. package/providers/beta/openlayers/src/appEvents.test.js +140 -0
  113. package/providers/beta/openlayers/src/defaults.js +36 -0
  114. package/providers/beta/openlayers/src/index.js +21 -0
  115. package/providers/beta/openlayers/src/mapEvents.js +132 -0
  116. package/providers/beta/openlayers/src/mapEvents.test.js +197 -0
  117. package/providers/beta/openlayers/src/openlayersProvider.js +234 -0
  118. package/providers/beta/openlayers/src/openlayersProvider.test.js +331 -0
  119. package/providers/beta/openlayers/src/utils/spatial.js +142 -0
  120. package/providers/beta/openlayers/src/utils/spatial.test.js +117 -0
  121. package/providers/beta/openlayers/src/utils/zoom.js +26 -0
  122. package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
  123. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  124. package/providers/maplibre/dist/esm/index.js +1 -1
  125. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  126. package/providers/maplibre/dist/umd/index.js +1 -1
  127. package/providers/maplibre/src/index.js +1 -1
  128. package/providers/maplibre/src/index.test.js +9 -14
  129. package/providers/maplibre/src/mapEvents.js +11 -5
  130. package/providers/maplibre/src/mapEvents.test.js +14 -3
  131. package/providers/maplibre/src/maplibreProvider.js +18 -11
  132. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  133. package/providers/maplibre/src/utils/highlightFeatures.js +112 -57
  134. package/providers/maplibre/src/utils/highlightFeatures.test.js +179 -51
  135. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  136. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  137. package/providers/maplibre/src/utils/patternImages.js +22 -19
  138. package/providers/maplibre/src/utils/patternImages.test.js +146 -167
  139. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  140. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  141. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  142. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  143. package/rollup.esm.mjs +14 -3
  144. package/src/App/components/Actions/Actions.module.scss +1 -0
  145. package/src/App/components/Actions/Actions.test.jsx +17 -4
  146. package/src/App/components/Hints/Hints.jsx +52 -0
  147. package/src/App/components/Hints/Hints.module.scss +37 -0
  148. package/src/App/components/Hints/Hints.test.jsx +104 -0
  149. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  150. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  151. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  152. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  153. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  154. package/src/App/components/Markers/Markers.jsx +44 -75
  155. package/src/App/components/Markers/Markers.module.scss +58 -0
  156. package/src/App/components/Markers/Markers.test.jsx +90 -113
  157. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  158. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  159. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  160. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  161. package/src/App/components/Panel/Panel.jsx +1 -1
  162. package/src/App/components/Panel/Panel.module.scss +1 -0
  163. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  164. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  165. package/src/App/components/Tabs/Tabs.jsx +68 -0
  166. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  167. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  168. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  169. package/src/App/components/Viewport/Features.jsx +35 -0
  170. package/src/App/components/Viewport/Features.module.scss +31 -0
  171. package/src/App/components/Viewport/Features.test.jsx +124 -0
  172. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  173. package/src/App/components/Viewport/Viewport.jsx +35 -29
  174. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  175. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  176. package/src/App/controls/keyboardActions.js +9 -3
  177. package/src/App/controls/keyboardActions.test.js +122 -87
  178. package/src/App/controls/keyboardShortcuts.js +1 -14
  179. package/src/App/hooks/useFeatureFocus.js +199 -0
  180. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  181. package/src/App/hooks/useFeatureItems.js +30 -0
  182. package/src/App/hooks/useFeatureItems.test.js +98 -0
  183. package/src/App/hooks/useFocusVisible.js +4 -7
  184. package/src/App/hooks/useFocusVisible.test.js +6 -6
  185. package/src/App/hooks/useKeyboardHint.js +9 -34
  186. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  187. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  188. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  189. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  190. package/src/App/hooks/useLayoutMeasurements.test.js +30 -1
  191. package/src/App/hooks/useMarkerCursor.js +33 -0
  192. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  193. package/src/App/hooks/useMarkersAPI.js +32 -9
  194. package/src/App/hooks/useMarkersAPI.test.js +163 -94
  195. package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
  196. package/src/App/layout/Layout.jsx +2 -0
  197. package/src/App/layout/Layout.test.jsx +3 -0
  198. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  199. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  200. package/src/App/renderer/mapButtons.js +5 -1
  201. package/src/App/renderer/mapButtons.test.js +37 -1
  202. package/src/App/store/AppProvider.test.jsx +5 -0
  203. package/src/App/store/MapProvider.test.jsx +8 -1
  204. package/src/App/store/ServiceProvider.jsx +4 -1
  205. package/src/App/store/ServiceProvider.test.jsx +11 -0
  206. package/src/App/store/appActionsMap.js +5 -1
  207. package/src/App/store/appActionsMap.test.js +13 -0
  208. package/src/App/store/appReducer.js +1 -0
  209. package/src/App/store/mapActionsMap.js +2 -1
  210. package/src/InteractiveMap/InteractiveMap.js +12 -0
  211. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  212. package/src/config/appConfig.js +3 -1
  213. package/src/config/appConfig.test.js +4 -0
  214. package/src/config/defaults.js +1 -1
  215. package/src/config/events.js +8 -0
  216. package/src/config/mapTheme.js +8 -4
  217. package/src/config/symbolConfig.js +23 -16
  218. package/src/scss/main.scss +3 -0
  219. package/src/scss/tools/_border-focus.scss +0 -1
  220. package/src/services/announcer.test.js +5 -7
  221. package/src/services/eventBus.js +15 -0
  222. package/src/services/eventBus.test.js +11 -0
  223. package/src/services/hints.js +47 -0
  224. package/src/services/hints.test.js +110 -0
  225. package/src/services/patternRegistry.js +69 -1
  226. package/src/services/patternRegistry.test.js +112 -1
  227. package/src/services/reverseGeocode.js +4 -0
  228. package/src/services/reverseGeocode.test.js +11 -1
  229. package/src/services/symbolRegistry.js +152 -17
  230. package/src/services/symbolRegistry.test.js +141 -31
  231. package/src/types.js +71 -14
  232. package/src/utils/constrainKeyboardFocus.js +5 -5
  233. package/src/utils/detectInterfaceType.js +27 -5
  234. package/src/utils/detectInterfaceType.test.js +29 -3
  235. package/src/utils/patternUtils.js +4 -67
  236. package/src/utils/patternUtils.test.js +1 -123
  237. package/src/utils/symbolUtils.js +11 -13
  238. package/src/utils/symbolUtils.test.js +18 -26
  239. package/webpack.dev.mjs +6 -1
  240. package/webpack.umd.mjs +1 -0
  241. package/plugins/interact/dist/css/index.css +0 -1
  242. package/plugins/interact/src/interact.scss +0 -24
@@ -38,7 +38,8 @@ describe('mapButtons module', () => {
38
38
  pressedButtons: new Set(),
39
39
  expandedButtons: new Set(),
40
40
  buttonConfig: {},
41
- panelConfig: {}
41
+ panelConfig: {},
42
+ layoutRefs: { viewportRef: { current: { focus: jest.fn() } } }
42
43
  }
43
44
  appState.buttonConfig = ({})
44
45
  getPanelConfig.mockReturnValue({})
@@ -227,6 +228,41 @@ describe('mapButtons module', () => {
227
228
  // dispatch should NOT be called because panelId is missing
228
229
  expect(appState.dispatch).not.toHaveBeenCalled()
229
230
  })
231
+
232
+ it('focuses viewport via requestAnimationFrame after onClick when not toggle and no keepFocus', () => {
233
+ const rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 })
234
+ const viewportFocusSpy = jest.spyOn(appState.layoutRefs.viewportRef.current, 'focus')
235
+ const onClick = jest.fn()
236
+ render({ ...baseBtn, onClick }).props.onClick({})
237
+ expect(viewportFocusSpy).toHaveBeenCalled()
238
+ rafSpy.mockRestore()
239
+ viewportFocusSpy.mockRestore()
240
+ })
241
+
242
+ it('does not call requestAnimationFrame when keepFocus is true', () => {
243
+ const rafSpy = jest.spyOn(global, 'requestAnimationFrame')
244
+ const onClick = jest.fn()
245
+ render({ ...baseBtn, onClick, keepFocus: true }).props.onClick({})
246
+ expect(rafSpy).not.toHaveBeenCalled()
247
+ rafSpy.mockRestore()
248
+ })
249
+
250
+ it('does not call requestAnimationFrame when button is a toggle (pressedWhen set)', () => {
251
+ const rafSpy = jest.spyOn(global, 'requestAnimationFrame')
252
+ const onClick = jest.fn()
253
+ render({ ...baseBtn, onClick, pressedWhen: jest.fn() }).props.onClick({})
254
+ expect(rafSpy).not.toHaveBeenCalled()
255
+ rafSpy.mockRestore()
256
+ })
257
+
258
+ it('includes focusOnOpen: false in OPEN_PANEL payload when keepFocus is true', () => {
259
+ const mockButtonEl = document.createElement('button')
260
+ render({ ...baseBtn, panelId: 'p1', keepFocus: true }).props.onClick({ currentTarget: mockButtonEl })
261
+ expect(appState.dispatch).toHaveBeenCalledWith({
262
+ type: 'OPEN_PANEL',
263
+ payload: { panelId: 'p1', props: { triggeringElement: mockButtonEl }, focusOnOpen: false }
264
+ })
265
+ })
230
266
  })
231
267
 
232
268
  // -------------------------
@@ -82,6 +82,9 @@ describe('AppProvider', () => {
82
82
  breakpointCb('sm')
83
83
  interfaceCb('mobile')
84
84
  })
85
+
86
+ expect(mockBreakpointDetector.subscribe).toHaveBeenCalledWith(expect.any(Function))
87
+ expect(detectInterface.subscribeToInterfaceChanges).toHaveBeenCalledWith(expect.any(Function))
85
88
  })
86
89
 
87
90
  test('handles eventBus setmode and revertmode', () => {
@@ -94,6 +97,8 @@ describe('AppProvider', () => {
94
97
  mockOptions.eventBus.off('app:setmode', capturedSetMode)
95
98
  mockOptions.eventBus.off('app:revertmode', capturedRevertMode)
96
99
  })
100
+ expect(mockOptions.eventBus.on).toHaveBeenCalledWith('app:setmode', capturedSetMode)
101
+ expect(mockOptions.eventBus.on).toHaveBeenCalledWith('app:revertmode', capturedRevertMode)
97
102
  })
98
103
 
99
104
  test('provides state, dispatch, and layoutRefs via context', () => {
@@ -186,9 +186,14 @@ describe('MapProvider', () => {
186
186
  })
187
187
 
188
188
  test('handles dispatch actions correctly', () => {
189
+ let contextValue
190
+ const Child = () => {
191
+ contextValue = React.useContext(MapContext)
192
+ return null
193
+ }
189
194
  render(
190
195
  <MapProvider options={{ id: 'map1', mapSize: '100x100', eventBus: mockEventBus }}>
191
- <div>Child</div>
196
+ <Child />
192
197
  </MapProvider>
193
198
  )
194
199
 
@@ -197,5 +202,7 @@ describe('MapProvider', () => {
197
202
  capturedHandlers['map:setstyle']({ id: 'style2' })
198
203
  capturedHandlers['map:setsize']('400x400')
199
204
  })
205
+
206
+ expect(contextValue.isMapReady).toBe(true)
200
207
  })
201
208
  })
@@ -1,6 +1,7 @@
1
1
  // src/App/store/ServiceProvider.jsx
2
2
  import React, { createContext, useMemo, useRef } from 'react'
3
3
  import { createAnnouncer } from '../../services/announcer.js'
4
+ import { createHints } from '../../services/hints.js'
4
5
  import { reverseGeocode } from '../../services/reverseGeocode.js'
5
6
  import { useConfig } from '../store/configContext.js'
6
7
  import { closeApp } from '../../services/closeApp.js'
@@ -13,18 +14,20 @@ export const ServiceProvider = ({ eventBus, children }) => {
13
14
  const { id, handleExitClick, symbolDefaults: constructorSymbolDefaults } = useConfig()
14
15
  const mapStatusRef = useRef(null)
15
16
  const announce = useMemo(() => createAnnouncer(mapStatusRef), [])
17
+ const hints = useMemo(() => createHints(announce), [announce])
16
18
 
17
19
  symbolRegistry.setDefaults(constructorSymbolDefaults || {})
18
20
 
19
21
  const services = useMemo(() => ({
20
22
  announce,
23
+ hints,
21
24
  reverseGeocode: (zoom, center) => reverseGeocode(zoom, center),
22
25
  eventBus,
23
26
  mapStatusRef,
24
27
  closeApp: () => closeApp(id, handleExitClick, eventBus),
25
28
  symbolRegistry,
26
29
  patternRegistry
27
- }), [announce])
30
+ }), [announce, hints])
28
31
 
29
32
  return (
30
33
  <ServiceContext.Provider value={services}>
@@ -11,6 +11,11 @@ jest.mock('../../services/announcer.js', () => ({
11
11
  createAnnouncer: jest.fn(() => jest.fn())
12
12
  }))
13
13
 
14
+ const mockShow = jest.fn()
15
+ jest.mock('../../services/hints.js', () => ({
16
+ createHints: jest.fn(() => ({ show: mockShow, dismiss: jest.fn(), subscribe: jest.fn() }))
17
+ }))
18
+
14
19
  jest.mock('../../services/reverseGeocode.js', () => ({
15
20
  reverseGeocode: jest.fn(() => 'mockedReverseGeocode')
16
21
  }))
@@ -57,6 +62,12 @@ describe('ServiceProvider', () => {
57
62
  expect(result.current).toBeTruthy()
58
63
  })
59
64
 
65
+ test('hints.show() delegates to createHints show', () => {
66
+ const { result } = renderHook(() => React.useContext(ServiceContext), { wrapper })
67
+ result.current.hints.show('<b>test</b>', { duration: 2000 })
68
+ expect(mockShow).toHaveBeenCalledWith('<b>test</b>', { duration: 2000 })
69
+ })
70
+
60
71
  test('closeApp calls closeApp service with id and handleExitClick', () => {
61
72
  const mockHandleExitClick = jest.fn()
62
73
  const { useConfig } = require('../store/configContext.js')
@@ -23,7 +23,7 @@ function buildOpenPanels (state, panelId, breakpoint, props, focusOnOpen) {
23
23
  return {
24
24
  ...(isExclusiveNonModal ? {} : filteredPanels),
25
25
  ...(isModal ? state.openPanels : {}),
26
- [panelId]: { props, ...(focusOnOpen && { focusOnOpen: true }) }
26
+ [panelId]: { props, ...(focusOnOpen !== undefined && { focusOnOpen }) }
27
27
  }
28
28
  }
29
29
 
@@ -158,6 +158,9 @@ const setPluginsEvaluated = (state) =>
158
158
  const clearPluginsEvaluated = (state) =>
159
159
  state.arePluginsEvaluated ? { ...state, arePluginsEvaluated: false } : state
160
160
 
161
+ const setListboxActive = (state) =>
162
+ state.listboxIsActive ? state : { ...state, listboxIsActive: true }
163
+
161
164
  const setSafeZoneInset = (state, { safeZoneInset, syncMapPadding = true }) => {
162
165
  return shallowEqual(state.safeZoneInset, safeZoneInset)
163
166
  ? state
@@ -373,6 +376,7 @@ export const actionsMap = {
373
376
  SET_MODE: setMode,
374
377
  PLUGINS_EVALUATED: setPluginsEvaluated,
375
378
  CLEAR_PLUGINS_EVALUATED: clearPluginsEvaluated,
379
+ SET_LISTBOX_ACTIVE: setListboxActive,
376
380
  SET_SAFE_ZONE_INSET: setSafeZoneInset,
377
381
  REVERT_MODE: revertMode,
378
382
  OPEN_PANEL: openPanel,
@@ -425,4 +425,17 @@ describe('actionsMap full coverage', () => {
425
425
  expect(res.openPanels).toHaveProperty('p1')
426
426
  })
427
427
  })
428
+
429
+ describe('SET_LISTBOX_ACTIVE', () => {
430
+ test('sets listboxIsActive to true when currently false', () => {
431
+ const res = actionsMap.SET_LISTBOX_ACTIVE({ ...state, listboxIsActive: false })
432
+ expect(res.listboxIsActive).toBe(true)
433
+ })
434
+
435
+ test('returns same state reference when listboxIsActive is already true', () => {
436
+ const activeState = { ...state, listboxIsActive: true }
437
+ const res = actionsMap.SET_LISTBOX_ACTIVE(activeState)
438
+ expect(res).toBe(activeState)
439
+ })
440
+ })
428
441
  })
@@ -47,6 +47,7 @@ export const initialState = (config) => {
47
47
  hasExclusiveControl: false,
48
48
  openPanels,
49
49
  previousOpenPanels: {},
50
+ listboxIsActive: false,
50
51
  syncMapPadding: true,
51
52
  pluginRegistry,
52
53
  buttonRegistry,
@@ -36,7 +36,8 @@ const updateCrossHair = (state, payload) => {
36
36
 
37
37
  const upsertMarker = (state, payload) => {
38
38
  const map = new Map(state.markers.items.map(item => [item.id, item]))
39
- map.set(payload.id, payload)
39
+ const existing = map.get(payload.id) || {}
40
+ map.set(payload.id, { ...existing, ...payload })
40
41
  const newItems = Array.from(map.values())
41
42
 
42
43
  return {
@@ -336,6 +336,18 @@ export default class InteractiveMap {
336
336
  this.eventBus.emit(events.APP_ADD_MARKER, { id, coords, options })
337
337
  }
338
338
 
339
+ /**
340
+ * Update an existing marker's options without replacing it. No-op if the marker id is not found.
341
+ * Merges the provided options into the existing marker — unspecified properties are preserved.
342
+ * Pass `coords` inside options to move the marker to new coordinates.
343
+ *
344
+ * @param {string} id - Marker identifier to update.
345
+ * @param {MarkerOptions & { coords?: [number, number] }} [options] - Properties to merge into the marker.
346
+ */
347
+ updateMarker (id, options) {
348
+ this.eventBus.emit(events.APP_UPDATE_MARKER, { id, options })
349
+ }
350
+
339
351
  /**
340
352
  * Remove a marker from the map.
341
353
  *
@@ -586,6 +586,7 @@ describe('InteractiveMap Public API Methods', () => {
586
586
  map.off('testEvent', cb)
587
587
  map.emit('customEvent', 123)
588
588
  map.addMarker('marker-1', coords, options)
589
+ map.updateMarker('marker-1', options)
589
590
  map.removeMarker('marker-1')
590
591
  map.setMode('test-mode')
591
592
 
@@ -593,6 +594,7 @@ describe('InteractiveMap Public API Methods', () => {
593
594
  expect(map.eventBus.off).toHaveBeenCalledWith('testEvent', cb)
594
595
  expect(map.eventBus.emit).toHaveBeenCalledWith('customEvent', 123)
595
596
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:addmarker', { id: 'marker-1', coords, options })
597
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:updatemarker', { id: 'marker-1', options })
596
598
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:removemarker', 'marker-1')
597
599
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:setmode', 'test-mode')
598
600
  })
@@ -45,6 +45,7 @@ export const defaultAppConfig = {
45
45
  group: { name: 'zoom', label: 'Zoom controls', order: 0 },
46
46
  label: 'Zoom in',
47
47
  iconId: 'plus',
48
+ keepFocus: true,
48
49
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
49
50
  excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch',
50
51
  enableWhen: ({ mapState }) => !mapState.isAtMaxZoom,
@@ -56,6 +57,7 @@ export const defaultAppConfig = {
56
57
  group: { name: 'zoom', label: 'Zoom controls', order: 0 },
57
58
  label: 'Zoom out',
58
59
  iconId: 'minus',
60
+ keepFocus: true,
59
61
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
60
62
  excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appState.interfaceType === 'touch',
61
63
  enableWhen: ({ mapState }) => !mapState.isAtMinZoom,
@@ -78,7 +80,7 @@ export const defaultAppConfig = {
78
80
  ...keyboardBasePanelSlots,
79
81
  width: '500px'
80
82
  },
81
- render: () => <KeyboardHelp />
83
+ render: (props) => <KeyboardHelp context={props?.context} />
82
84
  }],
83
85
 
84
86
  icons: [{
@@ -1,6 +1,10 @@
1
1
  import { render } from '@testing-library/react'
2
2
  import { defaultAppConfig, defaultButtonConfig, scaleFactor } from './appConfig'
3
3
 
4
+ jest.mock('../App/store/appContext.js', () => ({
5
+ useApp: () => ({ listboxIsActive: false })
6
+ }))
7
+
4
8
  describe('defaultAppConfig', () => {
5
9
  const appState = {
6
10
  layoutRefs: { appContainerRef: { current: document.createElement('div') } },
@@ -21,7 +21,7 @@ const defaults = {
21
21
  hasExitButton: false,
22
22
  hybridWidth: null, // Defaults to maxMobileWidth if not set
23
23
  keyboardHintText: '<span class="im-u-visually-hidden">Press </span><kbd>Alt</kbd> + <kbd>K</kbd> <span class="im-u-visually-hidden">to view </span>keyboard shortcuts',
24
- mapLabel: 'Interactive map',
24
+ mapLabel: 'Interactive map application',
25
25
  mapProvider: null,
26
26
  mapSize: 'small',
27
27
  manageHistoryState: true,
@@ -25,6 +25,8 @@ export const EVENTS = {
25
25
 
26
26
  /** @internal Add a marker. Payload: { id, coords, options } */
27
27
  APP_ADD_MARKER: 'app:addmarker',
28
+ /** @internal Update an existing marker's options. No-op if id not found. Payload: { id, options } */
29
+ APP_UPDATE_MARKER: 'app:updatemarker',
28
30
  /** @internal Remove a marker. Payload: id */
29
31
  APP_REMOVE_MARKER: 'app:removemarker',
30
32
  /** @internal Set application mode. Payload: mode */
@@ -121,6 +123,12 @@ export const EVENTS = {
121
123
  MAP_SET_STYLE: 'map:setstyle',
122
124
  /** @internal Set map size. Payload: { width, height } */
123
125
  MAP_SET_SIZE: 'map:setsize',
126
+ /** @internal Set the accessible features list. Payload: { items: { id: string, label: string }[] } */
127
+ MAP_SET_FEATURES: 'map:setfeatures',
128
+ /** @internal Set the active feature in the accessible features list. Payload: { id: string | null } */
129
+ MAP_SET_ACTIVE_FEATURE: 'map:setactivefeature',
130
+ /** @internal Select the active listbox feature as the real selection (Enter key). Payload: none */
131
+ MAP_SELECT_FEATURE: 'map:selectfeature',
124
132
  /** @internal Set pixel ratio. Payload: pixelRatio */
125
133
  MAP_SET_PIXEL_RATIO: 'map:setpixelratio',
126
134
  /** @internal Fit the map to a bounding box. Payload: [west, south, east, north] */
@@ -4,18 +4,20 @@
4
4
  * used both by the symbol SVG renderer (JS canvas) and injected as
5
5
  * CSS custom properties onto the app container for CSS-rendered elements.
6
6
  *
7
- * Per-style overrides take precedence: set `haloColor`, `selectedColor`, or
7
+ * Per-style overrides take precedence: set `haloColor`, `selectedColor`, `activeColor`, or
8
8
  * `foregroundColor` directly on a `MapStyleConfig` to override these defaults.
9
9
  */
10
- export const SCHEME_COLORS = {
10
+ export const THEME_COLORS = {
11
11
  light: {
12
12
  haloColor: '#ffffff',
13
13
  selectedColor: '#0b0c0c',
14
+ activeColor: '#ffdd00',
14
15
  foregroundColor: '#0b0c0c'
15
16
  },
16
17
  dark: {
17
18
  haloColor: '#0b0c0c',
18
19
  selectedColor: '#ffffff',
20
+ activeColor: '#ffdd00',
19
21
  foregroundColor: '#ffffff'
20
22
  }
21
23
  }
@@ -30,10 +32,11 @@ export const SCHEME_COLORS = {
30
32
  * @returns {{ haloColor: string, selectedColor: string, foregroundColor: string }}
31
33
  */
32
34
  export const resolveMapTheme = (mapStyle) => {
33
- const scheme = SCHEME_COLORS[mapStyle?.mapColorScheme] ?? SCHEME_COLORS.light
35
+ const scheme = THEME_COLORS[mapStyle?.mapColorScheme] ?? THEME_COLORS.light
34
36
  return {
35
37
  haloColor: mapStyle?.haloColor ?? scheme.haloColor,
36
38
  selectedColor: mapStyle?.selectedColor ?? scheme.selectedColor,
39
+ activeColor: mapStyle?.activeColor ?? scheme.activeColor,
37
40
  foregroundColor: mapStyle?.foregroundColor ?? scheme.foregroundColor
38
41
  }
39
42
  }
@@ -47,10 +50,11 @@ export const resolveMapTheme = (mapStyle) => {
47
50
  * @returns {Object} CSS custom property object
48
51
  */
49
52
  export const getMapThemeVars = (mapStyle) => {
50
- const { haloColor, selectedColor, foregroundColor } = resolveMapTheme(mapStyle)
53
+ const { haloColor, selectedColor, activeColor, foregroundColor } = resolveMapTheme(mapStyle)
51
54
  return {
52
55
  '--map-overlay-halo-color': haloColor,
53
56
  '--map-overlay-selected-color': selectedColor,
57
+ '--map-overlay-active-color': activeColor,
54
58
  '--map-overlay-foreground-color': foregroundColor
55
59
  }
56
60
  }
@@ -7,11 +7,18 @@
7
7
  export const symbolDefaults = {
8
8
  symbol: 'pin',
9
9
  backgroundColor: '#ca3535',
10
- foregroundColor: '#ffffff',
11
- haloWidth: '1',
12
- selectedWidth: '6'
10
+ foregroundColor: '#ffffff'
13
11
  }
14
12
 
13
+ /** Stroke width for the halo (background shape outline) in SVG units */
14
+ export const HALO_STROKE_WIDTH = 2
15
+
16
+ /** Stroke width for the selected state — symbol rings and feature highlight lines */
17
+ export const SELECTED_STROKE_WIDTH = 3
18
+
19
+ /** Stroke width for the active (keyboard cursor) state — double selected, extends 1× each side */
20
+ export const ACTIVE_STROKE_WIDTH = SELECTED_STROKE_WIDTH * 2
21
+
15
22
  /**
16
23
  * Built-in graphic path data strings for use with the `graphic` token.
17
24
  *
@@ -46,35 +53,35 @@ export const graphics = {
46
53
  }
47
54
 
48
55
  // ─── Built-in symbol definitions ─────────────────────────────────────────────
49
- // Each symbol uses a 38×38 viewBox. SVG templates use {{token}} placeholders
56
+ // Each symbol uses a 44×44 viewBox. SVG templates use {{token}} placeholders
50
57
  // resolved at render time by the symbolRegistry.
51
58
 
52
59
  export const pin = {
53
60
  id: 'pin',
54
- viewBox: '0 0 38 38',
61
+ viewBox: '0 0 44 44',
55
62
  anchor: [0.5, 0.9], // NOSONAR
56
63
  graphic: graphics.dot,
57
- svg: `<path d="M19 33.499c-5.318-5-12-9.509-12-16.998 0-6.583 5.417-12 12-12s12 5.417 12 12c0 7.489-6.682 11.998-12 16.998z" fill="none" stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/>
58
- <path d="M19 33.499c-5.318-5-12-9.509-12-16.998 0-6.583 5.417-12 12-12s12 5.417 12 12c0 7.489-6.682 11.998-12 16.998z" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/>
59
- <g transform="translate(19, 16) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
64
+ svg: `<path d="M22 40.999c-3.621 0-8.306-5.864-10.258-8.3C9.02 29.302 6 23.66 6 19.002a16.01 16.01 0 0 1 16-16 16.01 16.01 0 0 1 16 16c0 4.658-3.02 10.3-5.742 13.697-1.952 2.437-6.637 8.3-10.258 8.3z" fill="{{selectedColor}}" stroke="{{activeColor}}" stroke-width="6" paint-order="stroke fill"/>
65
+ <path d="M22 7.001a12.01 12.01 0 0 1 12 12c0 7.623-10.377 17.998-12 17.998S10 26.624 10 19.001a12.01 12.01 0 0 1 12-12z" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="2" paint-order="stroke fill"/>
66
+ <g transform="translate(22, 19) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
60
67
  }
61
68
 
62
69
  export const circle = {
63
70
  id: 'circle',
64
- viewBox: '0 0 38 38',
71
+ viewBox: '0 0 44 44',
65
72
  anchor: [0.5, 0.5],
66
73
  graphic: graphics.dot,
67
- svg: `<path d="M19 7C12.376 7 7 12.376 7 19s5.376 12 12 12a12.01 12.01 0 0 0 12-12A12.01 12.01 0 0 0 19 7z" fill="none" stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/>
68
- <path d="M19 7C12.376 7 7 12.376 7 19s5.376 12 12 12a12.01 12.01 0 0 0 12-12A12.01 12.01 0 0 0 19 7z" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/>
69
- <g transform="translate(19, 19) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
74
+ svg: `<circle cx="22" cy="22" r="16" fill="{{selectedColor}}" stroke="{{activeColor}}" stroke-width="6" paint-order="stroke fill"/>
75
+ <circle cx="22" cy="22" r="12" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="2" paint-order="stroke fill"/>
76
+ <g transform="translate(22, 22) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
70
77
  }
71
78
 
72
79
  export const square = {
73
80
  id: 'square',
74
- viewBox: '0 0 38 38',
81
+ viewBox: '0 0 44 44',
75
82
  anchor: [0.5, 0.5],
76
83
  graphic: graphics.dot,
77
- svg: `<path d="M28 7a3 3 0 0 1 3 3v18a3 3 0 0 1-3 3H10a3 3 0 0 1-3-3V10a3 3 0 0 1 3-3h18z" fill="none" stroke="{{selectedColor}}" stroke-width="{{selectedWidth}}"/>
78
- <path d="M28 7a3 3 0 0 1 3 3v18a3 3 0 0 1-3 3H10a3 3 0 0 1-3-3V10a3 3 0 0 1 3-3h18z" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="{{haloWidth}}"/>
79
- <g transform="translate(19, 19) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
84
+ svg: `<path d="M13 6h18c3.863 0 7 3.137 7 7v18c0 3.863-3.137 7-7 7H13c-3.863 0-7-3.137-7-7V13c0-3.863 3.137-7 7-7" fill="{{selectedColor}}" stroke="{{activeColor}}" stroke-width="6" paint-order="stroke fill"/>
85
+ <path d="M13 34a3 3 0 0 1-3-3V13a3 3 0 0 1 3-3h18a3 3 0 0 1 3 3v18a3 3 0 0 1-3 3H13z" fill-rule="nonzero" fill="{{backgroundColor}}" stroke="{{haloColor}}" stroke-width="2" paint-order="stroke fill"/>
86
+ <g transform="translate(22, 22) scale(0.8) translate(-8, -8)"><path d="{{graphic}}" fill="{{foregroundColor}}"/></g>`
80
87
  }
@@ -17,6 +17,7 @@
17
17
 
18
18
  // Components: specific UI components. Using 'CSS in Component' pattern
19
19
  @use '../App/components/KeyboardHelp/KeyboardHelp.module';
20
+ @use '../App/components/Tabs/Tabs.module';
20
21
  @use '../App/components/MapButton/MapButton.module';
21
22
  @use '../App/components/Panel/Panel.module';
22
23
  @use '../App/components/Icon/Icon.module';
@@ -25,8 +26,10 @@
25
26
  @use '../App/components/PopupMenu/PopupMenu.module';
26
27
  @use '../App/components/Attributions/Attributions.module';
27
28
  @use '../App/components/Logo/Logo.module';
29
+ @use '../App/components/Hints/Hints.module';
28
30
  @use '../App/components/CrossHair/CrossHair.module';
29
31
  @use '../App/components/Markers/Markers.module';
32
+ @use '../App/components/Viewport/Features.module';
30
33
  @use '../App/components/Actions/Actions.module';
31
34
 
32
35
  // Components that dont have a corresponding React Component
@@ -11,7 +11,6 @@
11
11
  /// @include border-focus-base($offset: -1px, $border: panel, $box-shadow: panel);
12
12
  /// ```
13
13
  @mixin border-focus-base($type: null, $is-inset: null) {
14
- position: relative;
15
14
  border: 0;
16
15
 
17
16
  // Border radius
@@ -24,21 +24,19 @@ describe('createAnnouncer', () => {
24
24
 
25
25
  it('does nothing if mapStatusRef.current is null', () => {
26
26
  const announceWithNull = createAnnouncer({ current: null })
27
- announceWithNull('Hello')
28
- jest.advanceTimersByTime(1000)
29
- expect(true).toBe(true) // just confirm no throw
27
+ expect(() => {
28
+ announceWithNull('Hello')
29
+ jest.advanceTimersByTime(1000)
30
+ }).not.toThrow()
30
31
  })
31
32
 
32
33
  it('returns early if mapStatusRef.current becomes null during setTimeout', () => {
33
34
  announce('Test message', 'plugin')
34
35
  expect(mapStatusRef.current.textContent).toBe('') // cleared immediately
35
36
 
36
- // Set current to null before setTimeout fires
37
37
  mapStatusRef.current = null
38
38
 
39
- jest.advanceTimersByTime(100)
40
- // Should not throw, just return early
41
- expect(true).toBe(true)
39
+ expect(() => jest.advanceTimersByTime(100)).not.toThrow()
42
40
  })
43
41
 
44
42
  it('sets textContent for a plugin message immediately', () => {
@@ -31,6 +31,21 @@ class EventBus {
31
31
  return this
32
32
  }
33
33
 
34
+ /**
35
+ * Register a one-time handler that removes itself after the first call.
36
+ *
37
+ * @param {string} eventName
38
+ * @param {Function} handler
39
+ * @returns {this}
40
+ */
41
+ once (eventName, handler) {
42
+ const wrapper = (...args) => {
43
+ this.off(eventName, wrapper)
44
+ handler(...args)
45
+ }
46
+ return this.on(eventName, wrapper)
47
+ }
48
+
34
49
  /**
35
50
  * Remove a handler for an event. Omit `handler` to remove all handlers.
36
51
  *
@@ -82,6 +82,17 @@ describe('EventBus singleton', () => {
82
82
  console.error.mockRestore()
83
83
  })
84
84
 
85
+ it('once fires the handler exactly once', () => {
86
+ const handler = jest.fn()
87
+ eventBus.once('single', handler)
88
+
89
+ eventBus.emit('single', 'a')
90
+ eventBus.emit('single', 'b')
91
+
92
+ expect(handler).toHaveBeenCalledTimes(1)
93
+ expect(handler).toHaveBeenCalledWith('a')
94
+ })
95
+
85
96
  it('destroys all events', () => {
86
97
  const handler = jest.fn()
87
98
  eventBus.on('destroyMe', handler)
@@ -0,0 +1,47 @@
1
+ const stripHtml = (html) => html.replace(/<[^<>]*>/g, '')
2
+
3
+ /**
4
+ * Creates the hints service. Manages a single active toast hint shown in the
5
+ * Hints container. Calling show() replaces any current hint and
6
+ * restarts the dismiss timer. Internally calls announce() so screen readers
7
+ * receive the message through the live region without callers needing to pair
8
+ * the two calls manually.
9
+ */
10
+ export function createHints (announce) {
11
+ const subscribers = new Set()
12
+ let current = null
13
+ let timer = null
14
+
15
+ const notify = () => subscribers.forEach(fn => fn(current))
16
+
17
+ const clearTimer = () => {
18
+ if (timer) {
19
+ clearTimeout(timer)
20
+ timer = null
21
+ }
22
+ }
23
+
24
+ const dismiss = () => {
25
+ clearTimer()
26
+ current = null
27
+ notify()
28
+ }
29
+
30
+ const show = (html, options = {}) => {
31
+ const { duration = 4000, announce: announceText } = options
32
+ clearTimer()
33
+ current = { html }
34
+ notify()
35
+ announce(announceText ?? stripHtml(html), 'plugin')
36
+ if (duration > 0) {
37
+ timer = setTimeout(dismiss, duration)
38
+ }
39
+ }
40
+
41
+ const subscribe = (fn) => {
42
+ subscribers.add(fn)
43
+ return () => subscribers.delete(fn)
44
+ }
45
+
46
+ return { show, dismiss, subscribe }
47
+ }