@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
@@ -0,0 +1,87 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useMarkerCursor } from './useMarkerCursor.js'
3
+
4
+ const HIT_BOUNDS = { left: 10, top: 10, right: 50, bottom: 50 }
5
+ const CURSOR_INSIDE = { clientX: 20, clientY: 20 }
6
+ const CURSOR_OUTSIDE = { clientX: 100, clientY: 100 }
7
+
8
+ const makeMarker = (overrides = {}) => ({ id: 'marker-1', isVisible: true, symbol: 'pin', ...overrides })
9
+
10
+ const makeMarkers = (marker, bounds) => {
11
+ const markerRefs = new Map()
12
+ if (bounds) markerRefs.set(marker.id, { getBoundingClientRect: () => bounds })
13
+ return { items: [marker], markerRefs }
14
+ }
15
+
16
+ const fireMove = (vp, { clientX, clientY }) => act(() => {
17
+ vp.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX, clientY }))
18
+ })
19
+
20
+ let viewport
21
+
22
+ beforeEach(() => {
23
+ viewport = document.createElement('div')
24
+ viewport.className = 'im-c-viewport'
25
+ document.body.appendChild(viewport)
26
+ })
27
+
28
+ afterEach(() => { viewport.remove() })
29
+
30
+ describe('useMarkerCursor — inactive guards', () => {
31
+ it('does not track cursor when interactActive is false', () => {
32
+ const markers = makeMarkers(makeMarker(), HIT_BOUNDS)
33
+ renderHook(() => useMarkerCursor(markers, false, { current: viewport }))
34
+ fireMove(viewport, CURSOR_INSIDE)
35
+ expect(viewport.style.cursor).toBe('')
36
+ })
37
+
38
+ it('does not track cursor when viewport ref is null', () => {
39
+ const markers = makeMarkers(makeMarker(), HIT_BOUNDS)
40
+ renderHook(() => useMarkerCursor(markers, true, { current: null }))
41
+ fireMove(viewport, CURSOR_INSIDE)
42
+ expect(viewport.style.cursor).toBe('')
43
+ })
44
+ })
45
+
46
+ describe('useMarkerCursor — cursor tracking', () => {
47
+ it('sets cursor to pointer when mousemove lands inside a marker', () => {
48
+ const markers = makeMarkers(makeMarker(), HIT_BOUNDS)
49
+ renderHook(() => useMarkerCursor(markers, true, { current: viewport }))
50
+ fireMove(viewport, CURSOR_INSIDE)
51
+ expect(viewport.style.cursor).toBe('pointer')
52
+ })
53
+
54
+ it('cursor stays empty when mousemove is outside all markers', () => {
55
+ const markers = makeMarkers(makeMarker(), HIT_BOUNDS)
56
+ renderHook(() => useMarkerCursor(markers, true, { current: viewport }))
57
+ fireMove(viewport, CURSOR_OUTSIDE)
58
+ expect(viewport.style.cursor).toBe('')
59
+ })
60
+
61
+ it('cursor stays empty when marker has no ref element', () => {
62
+ const markers = makeMarkers(makeMarker(), null)
63
+ renderHook(() => useMarkerCursor(markers, true, { current: viewport }))
64
+ fireMove(viewport, CURSOR_INSIDE)
65
+ expect(viewport.style.cursor).toBe('')
66
+ })
67
+
68
+ it('skips standalone label markers in the cursor hit test', () => {
69
+ const marker = makeMarker({ label: 'lbl', symbol: null, symbolSvgContent: null })
70
+ const markers = makeMarkers(marker, HIT_BOUNDS)
71
+ renderHook(() => useMarkerCursor(markers, true, { current: viewport }))
72
+ fireMove(viewport, CURSOR_INSIDE)
73
+ expect(viewport.style.cursor).toBe('')
74
+ })
75
+
76
+ it('clears cursor when interactActive becomes false', () => {
77
+ const markers = makeMarkers(makeMarker(), HIT_BOUNDS)
78
+ const { rerender } = renderHook(
79
+ ({ active }) => useMarkerCursor(markers, active, { current: viewport }),
80
+ { initialProps: { active: true } }
81
+ )
82
+ fireMove(viewport, CURSOR_INSIDE)
83
+ expect(viewport.style.cursor).toBe('pointer')
84
+ act(() => rerender({ active: false }))
85
+ expect(viewport.style.cursor).toBe('')
86
+ })
87
+ })
@@ -50,33 +50,43 @@ const updateMarkerPositions = (items, markerRefs, mapProvider, mapSize, isMapRea
50
50
  }
51
51
 
52
52
  /**
53
- * Registers event bus listeners for adding and removing markers via
54
- * APP_ADD_MARKER and APP_REMOVE_MARKER events.
53
+ * Registers event bus listeners for adding, updating and removing markers via
54
+ * APP_ADD_MARKER, APP_UPDATE_MARKER and APP_REMOVE_MARKER events.
55
55
  *
56
56
  * @param {Object} eventBus - Application event bus
57
- * @param {Object} markers - Markers API object with `add` and `remove` methods
57
+ * @param {Object} markers - Markers API object with `add`, `update` and `remove` methods
58
58
  */
59
- const useMarkerEventListeners = (eventBus, markers) => {
59
+ const useMarkerEventListeners = (eventBus, markersRef) => {
60
60
  useEffect(() => {
61
61
  const handleAddMarker = (payload = {}) => {
62
62
  if (!payload?.id || !payload?.coords) {
63
63
  return
64
64
  }
65
65
  const { id, coords, options } = payload
66
- markers.add(id, coords, options)
66
+ markersRef.current.add(id, coords, options)
67
67
  }
68
68
  eventBus.on(events.APP_ADD_MARKER, handleAddMarker)
69
69
 
70
+ const handleUpdateMarker = (payload = {}) => {
71
+ if (!payload?.id) {
72
+ return
73
+ }
74
+ const { id, options } = payload
75
+ markersRef.current.update(id, options)
76
+ }
77
+ eventBus.on(events.APP_UPDATE_MARKER, handleUpdateMarker)
78
+
70
79
  const handleRemoveMarker = (id) => {
71
80
  if (!id) {
72
81
  return
73
82
  }
74
- markers.remove(id)
83
+ markersRef.current.remove(id)
75
84
  }
76
85
  eventBus.on(events.APP_REMOVE_MARKER, handleRemoveMarker)
77
86
 
78
87
  return () => {
79
88
  eventBus.off(events.APP_ADD_MARKER, handleAddMarker)
89
+ eventBus.off(events.APP_UPDATE_MARKER, handleUpdateMarker)
80
90
  eventBus.off(events.APP_REMOVE_MARKER, handleRemoveMarker)
81
91
  }
82
92
  }, [])
@@ -84,8 +94,8 @@ const useMarkerEventListeners = (eventBus, markers) => {
84
94
 
85
95
  /**
86
96
  * Hook that provides the markers API and ref callback for positioning marker
87
- * elements on the map. Attaches `add`, `remove`, and `getMarker` methods to the
88
- * markers store object and keeps marker positions in sync with map render and
97
+ * elements on the map. Attaches `add`, `update`, `remove`, and `getMarker` methods
98
+ * to the markers store object and keeps marker positions in sync with map render and
89
99
  * resize events.
90
100
  *
91
101
  * @returns {{ markers: Object, markerRef: Function }}
@@ -95,6 +105,7 @@ export const useMarkers = () => {
95
105
  const { eventBus } = useService()
96
106
  const { markers, dispatch, mapSize, isMapReady } = useMap()
97
107
  const markerRefs = useRef(new Map())
108
+ const markersRef = useRef(markers)
98
109
 
99
110
  // Attach add, remove, and getMarker methods to the markers store object.
100
111
  // useLayoutEffect ensures these are assigned before paint so rapid clicks can't
@@ -104,6 +115,7 @@ export const useMarkers = () => {
104
115
  return
105
116
  }
106
117
 
118
+ markersRef.current = markers
107
119
  markers.markerRefs = markerRefs.current
108
120
 
109
121
  markers.add = (id, coords, options) => {
@@ -111,6 +123,17 @@ export const useMarkers = () => {
111
123
  dispatch({ type: 'UPSERT_LOCATION_MARKER', payload: { id, coords, ...options, x, y, isVisible: true } })
112
124
  }
113
125
 
126
+ markers.update = (id, options = {}) => {
127
+ const existing = markers.items.find(m => m.id === id)
128
+ if (!existing) {
129
+ return
130
+ }
131
+ const { coords, ...rest } = options
132
+ const resolvedCoords = coords ?? existing.coords
133
+ const { x, y } = projectCoords(resolvedCoords, mapProvider, mapSize, isMapReady)
134
+ dispatch({ type: 'UPSERT_LOCATION_MARKER', payload: { id, coords: resolvedCoords, ...rest, x, y } })
135
+ }
136
+
114
137
  markers.remove = (id) => {
115
138
  dispatch({ type: 'REMOVE_LOCATION_MARKER', payload: id })
116
139
  }
@@ -150,7 +173,7 @@ export const useMarkers = () => {
150
173
  updateMarkerPositions(markers.items, markerRefs.current, mapProvider, mapSize, isMapReady)
151
174
  }, [mapSize, markers.items, mapProvider, isMapReady])
152
175
 
153
- useMarkerEventListeners(eventBus, markers)
176
+ useMarkerEventListeners(eventBus, markersRef)
154
177
 
155
178
  return { markers, markerRef }
156
179
  }
@@ -15,6 +15,8 @@ jest.mock('../../services/eventBus.js', () => ({
15
15
  }))
16
16
  jest.mock('../../config/appConfig.js', () => ({ scaleFactor: { small: 1, medium: 2, large: 3 } }))
17
17
 
18
+ // ─── projectCoords ────────────────────────────────────────────────────────────
19
+
18
20
  describe('projectCoords', () => {
19
21
  const mockProvider = { mapToScreen: jest.fn(() => ({ x: 100, y: 200 })) }
20
22
 
@@ -29,41 +31,44 @@ describe('projectCoords', () => {
29
31
  })
30
32
  })
31
33
 
32
- describe('useMarkers', () => {
33
- let mockMapProvider, mockDispatch, mockMarkers, mockElement, mockEventBus
34
-
35
- beforeEach(() => {
36
- mockMapProvider = { mapToScreen: jest.fn(() => ({ x: 100, y: 200 })) }
37
- mockDispatch = jest.fn()
38
- mockMarkers = { items: [] }
39
- mockElement = { style: {} }
40
- mockEventBus = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
41
-
42
- eventBus.on = jest.fn()
43
- eventBus.off = jest.fn()
44
-
45
- useConfig.mockReturnValue({ mapProvider: mockMapProvider })
46
- useService.mockReturnValue({ eventBus: mockEventBus })
47
- useMap.mockReturnValue({
48
- markers: mockMarkers,
49
- dispatch: mockDispatch,
50
- mapSize: 'medium',
51
- isMapReady: true
52
- })
34
+ // ─── useMarkers shared setup ──────────────────────────────────────────────────
35
+
36
+ const makeCtx = () => {
37
+ const ctx = {
38
+ mockMapProvider: { mapToScreen: jest.fn(() => ({ x: 100, y: 200 })) },
39
+ mockDispatch: jest.fn(),
40
+ mockMarkers: { items: [] },
41
+ mockElement: { style: {} },
42
+ mockEventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
43
+ }
44
+ eventBus.on = jest.fn()
45
+ eventBus.off = jest.fn()
46
+ useConfig.mockReturnValue({ mapProvider: ctx.mockMapProvider })
47
+ useService.mockReturnValue({ eventBus: ctx.mockEventBus })
48
+ useMap.mockReturnValue({
49
+ markers: ctx.mockMarkers,
50
+ dispatch: ctx.mockDispatch,
51
+ mapSize: 'medium',
52
+ isMapReady: true
53
53
  })
54
+ return ctx
55
+ }
56
+
57
+ // ─── useMarkers — API setup ───────────────────────────────────────────────────
58
+
59
+ describe('useMarkers — API setup', () => {
60
+ let ctx
61
+ beforeEach(() => { ctx = makeCtx() })
54
62
 
55
63
  it('returns markers and markerRef', () => {
56
64
  const { result } = renderHook(() => useMarkers())
57
- expect(result.current).toMatchObject({ markers: mockMarkers, markerRef: expect.any(Function) })
65
+ expect(result.current).toMatchObject({ markers: ctx.mockMarkers, markerRef: expect.any(Function) })
58
66
  })
59
67
 
60
- it('returns early when mapProvider is null (line 24)', () => {
68
+ it('returns early when mapProvider is null', () => {
61
69
  useConfig.mockReturnValue({ mapProvider: null })
62
- mockMarkers.items = [{ id: 'm1', label: 'Test' }]
63
-
70
+ ctx.mockMarkers.items = [{ id: 'm1', label: 'Test' }]
64
71
  const { result } = renderHook(() => useMarkers())
65
-
66
- // The markers object should not have the API methods attached
67
72
  expect(result.current.markers.add).toBeUndefined()
68
73
  expect(result.current.markers.remove).toBeUndefined()
69
74
  expect(result.current.markers.getMarker).toBeUndefined()
@@ -71,122 +76,186 @@ describe('useMarkers', () => {
71
76
  })
72
77
 
73
78
  it('getMarker returns correct marker', () => {
74
- mockMarkers.items = [{ id: 'm1', label: 'First' }, { id: 'm2', label: 'Second' }]
79
+ ctx.mockMarkers.items = [{ id: 'm1', label: 'First' }, { id: 'm2', label: 'Second' }]
75
80
  const { result } = renderHook(() => useMarkers())
76
-
77
- // Found
78
81
  expect(result.current.markers.getMarker('m2')).toEqual({ id: 'm2', label: 'Second' })
79
- // Not found
80
82
  expect(result.current.markers.getMarker('nonexistent')).toBeUndefined()
81
83
  })
84
+ })
82
85
 
83
- it('markerRef stores and removes refs (line 54)', () => {
84
- const { result } = renderHook(() => useMarkers())
85
- const ref = result.current.markerRef('m1')
86
+ // ─── useMarkers markerRef and positioning ───────────────────────────────────
86
87
 
87
- act(() => ref(mockElement))
88
- expect(result.current.markers.markerRefs.get('m1')).toBe(mockElement)
88
+ describe('useMarkers — markerRef and positioning', () => {
89
+ let ctx
90
+ beforeEach(() => { ctx = makeCtx() })
89
91
 
90
- act(() => ref(null)) // line 54 early return
92
+ it('markerRef stores and removes DOM refs', () => {
93
+ const { result } = renderHook(() => useMarkers())
94
+ const ref = result.current.markerRef('m1')
95
+ act(() => ref(ctx.mockElement))
96
+ expect(result.current.markers.markerRefs.get('m1')).toBe(ctx.mockElement)
97
+ act(() => ref(null))
91
98
  expect(result.current.markers.markerRefs.has('m1')).toBe(false)
92
99
  })
93
100
 
94
- it('updateMarkers skips missing coords or ref (line 71)', () => {
95
- mockMarkers.items = [
101
+ it('updateMarkers skips markers with missing coords or no ref', () => {
102
+ ctx.mockMarkers.items = [
96
103
  { id: 'm1', coords: { lat: 1, lng: 1 } },
97
- { id: 'm2', coords: null }, // line 71: missing coords
98
- { id: 'm3', coords: { lat: 0, lng: 0 } } // line 71: missing ref
104
+ { id: 'm2', coords: null },
105
+ { id: 'm3', coords: { lat: 0, lng: 0 } }
99
106
  ]
100
107
  const { result } = renderHook(() => useMarkers())
101
- act(() => result.current.markerRef('m1')(mockElement))
102
-
103
- const renderCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
108
+ act(() => result.current.markerRef('m1')(ctx.mockElement))
109
+ const renderCallback = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
104
110
  act(() => renderCallback())
105
- expect(mockElement.style.transform).toBe('translate(200px, 400px)')
111
+ expect(ctx.mockElement.style.transform).toBe('translate(200px, 400px)')
106
112
  })
107
113
 
108
- it('skips map:render when not ready or no provider (line 60)', () => {
109
- useMap.mockReturnValue({ markers: mockMarkers, dispatch: mockDispatch, mapSize: 'medium', isMapReady: false })
114
+ it('skips map:render callback when not ready or no provider', () => {
115
+ useMap.mockReturnValue({ markers: ctx.mockMarkers, dispatch: ctx.mockDispatch, mapSize: 'medium', isMapReady: false })
110
116
  const { result } = renderHook(() => useMarkers())
111
- act(() => result.current.markerRef('m1')(mockElement))
112
-
113
- const renderCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')?.[1]
114
- if (renderCallback) act(() => renderCallback()) // line 60: early return
117
+ act(() => result.current.markerRef('m1')(ctx.mockElement))
118
+ const renderCallback = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')?.[1]
119
+ if (renderCallback) act(() => renderCallback())
120
+ expect(ctx.mockElement.style.transform).toBeUndefined()
115
121
  })
116
122
 
117
123
  it('updates positions on mapSize change', () => {
118
- mockMarkers.items = [{ id: 'm1', coords: { lat: 1, lng: 1 } }]
124
+ ctx.mockMarkers.items = [{ id: 'm1', coords: { lat: 1, lng: 1 } }]
119
125
  const { result, rerender } = renderHook(() => useMarkers())
120
- act(() => result.current.markerRef('m1')(mockElement))
121
-
122
- useMap.mockReturnValue({
123
- markers: mockMarkers,
124
- dispatch: mockDispatch,
125
- mapSize: 'large',
126
- isMapReady: true
127
- })
126
+ act(() => result.current.markerRef('m1')(ctx.mockElement))
127
+ useMap.mockReturnValue({ markers: ctx.mockMarkers, dispatch: ctx.mockDispatch, mapSize: 'large', isMapReady: true })
128
128
  rerender()
129
- expect(mockElement.style.transform).toBe('translate(300px, 600px)')
129
+ expect(ctx.mockElement.style.transform).toBe('translate(300px, 600px)')
130
130
  })
131
+ })
131
132
 
132
- it('handles app:addmarker safely', () => {
133
- renderHook(() => useMarkers())
134
- const addPayload = { id: 'm1', coords: { lat: 1, lng: 1 }, options: { label: 'Test' } }
133
+ // ─── useMarkers add/update/remove event handlers ───────────────────────────
135
134
 
136
- const handleAddMarker = mockEventBus.on.mock.calls.find(call => call[0] === 'app:addmarker')[1]
137
- act(() => handleAddMarker(addPayload))
138
- expect(mockDispatch).toHaveBeenCalledWith({
135
+ describe('useMarkers add/update/remove event handlers', () => {
136
+ let ctx
137
+ beforeEach(() => { ctx = makeCtx() })
138
+
139
+ it('handles app:addmarker — dispatches with projected coords', () => {
140
+ renderHook(() => useMarkers())
141
+ const handleAddMarker = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:addmarker')[1]
142
+ act(() => handleAddMarker({ id: 'm1', coords: { lat: 1, lng: 1 }, options: { label: 'Test' } }))
143
+ expect(ctx.mockDispatch).toHaveBeenCalledWith({
139
144
  type: 'UPSERT_LOCATION_MARKER',
140
145
  payload: { id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Test', x: 200, y: 400, isVisible: true }
141
146
  })
142
147
  })
143
148
 
144
- it('does not crash on undefined/null payload', () => {
149
+ it('handles app:addmarker guard ignores missing id or coords', () => {
145
150
  renderHook(() => useMarkers())
146
- const handleAddMarker = mockEventBus.on.mock.calls.find(call => call[0] === 'app:addmarker')[1]
147
-
151
+ const handleAddMarker = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:addmarker')[1]
148
152
  act(() => handleAddMarker(undefined))
149
153
  act(() => handleAddMarker(null))
150
- act(() => handleAddMarker({})) // missing id
151
- act(() => handleAddMarker({ id: 'm1' })) // missing coords
152
- expect(mockDispatch).not.toHaveBeenCalled()
154
+ act(() => handleAddMarker({}))
155
+ act(() => handleAddMarker({ id: 'm1' }))
156
+ expect(ctx.mockDispatch).not.toHaveBeenCalled()
153
157
  })
154
158
 
155
- it('handles app:removemarker safely (guard)', () => {
159
+ it('handles app:updatemarker guard — ignores missing id (lines 71-73)', () => {
160
+ ctx.mockMarkers.items = [{ id: 'm1', coords: { lat: 1, lng: 1 } }]
156
161
  renderHook(() => useMarkers())
157
- const handleRemoveMarker = mockEventBus.on.mock.calls.find(call => call[0] === 'app:removemarker')[1]
162
+ const handleUpdateMarker = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:updatemarker')[1]
163
+ act(() => handleUpdateMarker(undefined))
164
+ act(() => handleUpdateMarker(null))
165
+ act(() => handleUpdateMarker({}))
166
+ expect(ctx.mockDispatch).not.toHaveBeenCalled()
167
+ })
158
168
 
159
- act(() => handleRemoveMarker('m1')) // valid id
160
- expect(mockDispatch).toHaveBeenCalledWith({ type: 'REMOVE_LOCATION_MARKER', payload: 'm1' })
169
+ it('handles app:updatemarker — dispatches update for valid id (lines 74-75)', () => {
170
+ ctx.mockMarkers.items = [{ id: 'm1', coords: { lat: 1, lng: 1 } }]
171
+ renderHook(() => useMarkers())
172
+ const handleUpdateMarker = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:updatemarker')[1]
173
+ act(() => handleUpdateMarker({ id: 'm1', options: { showLabel: true } }))
174
+ expect(ctx.mockDispatch).toHaveBeenCalledWith({
175
+ type: 'UPSERT_LOCATION_MARKER',
176
+ payload: { id: 'm1', coords: { lat: 1, lng: 1 }, showLabel: true, x: 200, y: 400 }
177
+ })
178
+ })
161
179
 
162
- act(() => handleRemoveMarker(undefined)) // guard triggers
180
+ it('handles app:removemarker — dispatches and guards null/undefined', () => {
181
+ renderHook(() => useMarkers())
182
+ const handleRemoveMarker = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:removemarker')[1]
183
+ act(() => handleRemoveMarker('m1'))
184
+ expect(ctx.mockDispatch).toHaveBeenCalledWith({ type: 'REMOVE_LOCATION_MARKER', payload: 'm1' })
185
+ act(() => handleRemoveMarker(undefined))
163
186
  act(() => handleRemoveMarker(null))
164
- expect(mockDispatch).toHaveBeenCalledTimes(1)
187
+ expect(ctx.mockDispatch).toHaveBeenCalledTimes(1)
165
188
  })
189
+ })
190
+
191
+ // ─── useMarkers — markers.update ─────────────────────────────────────────────
166
192
 
167
- it('cleans up map:render listener on unmount', () => {
193
+ describe('useMarkers markers.update', () => {
194
+ let ctx
195
+ beforeEach(() => { ctx = makeCtx() })
196
+
197
+ it('dispatches using existing coords when none provided (lines 127-134)', () => {
198
+ ctx.mockMarkers.items = [{ id: 'm1', coords: { lat: 1, lng: 1 }, label: 'Old' }]
168
199
  const { result } = renderHook(() => useMarkers())
200
+ act(() => result.current.markers.update('m1', { showLabel: true }))
201
+ expect(ctx.mockDispatch).toHaveBeenCalledWith({
202
+ type: 'UPSERT_LOCATION_MARKER',
203
+ payload: { id: 'm1', coords: { lat: 1, lng: 1 }, showLabel: true, x: 200, y: 400 }
204
+ })
205
+ })
169
206
 
170
- let cleanup
171
- act(() => { cleanup = result.current.markerRef('m1')(mockElement) })
207
+ it('is a no-op when marker id is not found (lines 128-130)', () => {
208
+ ctx.mockMarkers.items = []
209
+ const { result } = renderHook(() => useMarkers())
210
+ act(() => result.current.markers.update('nonexistent', { showLabel: true }))
211
+ expect(ctx.mockDispatch).not.toHaveBeenCalled()
212
+ })
172
213
 
173
- const updateCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
174
- act(() => { if (cleanup) cleanup() })
214
+ it('uses provided coords over existing (lines 131-133)', () => {
215
+ ctx.mockMarkers.items = [{ id: 'm1', coords: { lat: 1, lng: 1 } }]
216
+ const { result } = renderHook(() => useMarkers())
217
+ const newCoords = { lat: 5, lng: 5 }
218
+ act(() => result.current.markers.update('m1', { coords: newCoords, showLabel: true }))
219
+ expect(ctx.mockDispatch).toHaveBeenCalledWith({
220
+ type: 'UPSERT_LOCATION_MARKER',
221
+ payload: { id: 'm1', coords: newCoords, showLabel: true, x: 200, y: 400 }
222
+ })
223
+ })
175
224
 
176
- expect(mockEventBus.off).toHaveBeenCalledWith('map:render', updateCallback)
225
+ it('uses default empty options when none provided (line 126)', () => {
226
+ ctx.mockMarkers.items = [{ id: 'm1', coords: { lat: 1, lng: 1 } }]
227
+ const { result } = renderHook(() => useMarkers())
228
+ act(() => result.current.markers.update('m1'))
229
+ expect(ctx.mockDispatch).toHaveBeenCalledWith({
230
+ type: 'UPSERT_LOCATION_MARKER',
231
+ payload: { id: 'm1', coords: { lat: 1, lng: 1 }, x: 200, y: 400 }
232
+ })
177
233
  })
234
+ })
178
235
 
179
- it('cleans up eventBus listeners on unmount', () => {
180
- const { unmount } = renderHook(() => useMarkers())
236
+ // ─── useMarkers cleanup ─────────────────────────────────────────────────────
181
237
 
182
- // Get the registered callbacks
183
- const addCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'app:addmarker')[1]
184
- const removeCallback = mockEventBus.on.mock.calls.find(call => call[0] === 'app:removemarker')[1]
238
+ describe('useMarkers cleanup', () => {
239
+ let ctx
240
+ beforeEach(() => { ctx = makeCtx() })
185
241
 
186
- unmount()
242
+ it('cleans up map:render listener when markerRef is called with null', () => {
243
+ const { result } = renderHook(() => useMarkers())
244
+ let cleanup
245
+ act(() => { cleanup = result.current.markerRef('m1')(ctx.mockElement) })
246
+ const updateCallback = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
247
+ act(() => { if (cleanup) cleanup() })
248
+ expect(ctx.mockEventBus.off).toHaveBeenCalledWith('map:render', updateCallback)
249
+ })
187
250
 
188
- // Verify both listeners were removed
189
- expect(mockEventBus.off).toHaveBeenCalledWith('app:addmarker', addCallback)
190
- expect(mockEventBus.off).toHaveBeenCalledWith('app:removemarker', removeCallback)
251
+ it('cleans up add/update/remove eventBus listeners on unmount', () => {
252
+ const { unmount } = renderHook(() => useMarkers())
253
+ const addCallback = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:addmarker')[1]
254
+ const updateCallback = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:updatemarker')[1]
255
+ const removeCallback = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'app:removemarker')[1]
256
+ unmount()
257
+ expect(ctx.mockEventBus.off).toHaveBeenCalledWith('app:addmarker', addCallback)
258
+ expect(ctx.mockEventBus.off).toHaveBeenCalledWith('app:updatemarker', updateCallback)
259
+ expect(ctx.mockEventBus.off).toHaveBeenCalledWith('app:removemarker', removeCallback)
191
260
  })
192
261
  })
@@ -256,9 +256,10 @@ describe('useModalPanelBehaviour', () => {
256
256
  })
257
257
 
258
258
  it('handles null focus targets gracefully', () => {
259
+ refs.panel.current.focus = jest.fn()
259
260
  render(<TestComponent />)
260
261
  dispatchFocusIn(null)
261
- expect(true).toBe(true)
262
+ expect(refs.panel.current.focus).not.toHaveBeenCalled()
262
263
  })
263
264
  })
264
265
 
@@ -10,6 +10,7 @@ import { Attributions } from '../components/Attributions/Attributions'
10
10
  import { layoutSlots } from '../renderer/slots'
11
11
  import { SlotRenderer } from '../renderer/SlotRenderer'
12
12
  import { HtmlElementHost } from '../renderer/HtmlElementHost'
13
+ import { Hints } from '../components/Hints/Hints.jsx'
13
14
  import { getMapThemeVars } from '../../config/mapTheme.js'
14
15
 
15
16
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
@@ -110,6 +111,7 @@ export const Layout = () => {
110
111
  </div>
111
112
  </div>
112
113
  </div>
114
+ <Hints />
113
115
  <HtmlElementHost />
114
116
  </div>
115
117
  )
@@ -21,6 +21,9 @@ jest.mock('../components/Attributions/Attributions', () => ({
21
21
  jest.mock('../renderer/SlotRenderer', () => ({
22
22
  SlotRenderer: jest.fn(({ slot }) => <div data-testid={`slot-${slot.toLowerCase()}`} />)
23
23
  }))
24
+ jest.mock('../components/Hints/Hints', () => ({
25
+ Hints: jest.fn(() => null)
26
+ }))
24
27
 
25
28
  // Mock hooks
26
29
  jest.mock('../store/configContext', () => ({ useConfig: jest.fn() }))
@@ -10,8 +10,9 @@ const pluginShortcutIds = new Set()
10
10
  let providerSupportedIds = new Set()
11
11
 
12
12
  export const registerKeyboardShortcut = ({ shortcut }) => {
13
- // Only add if we haven't seen this ID before
14
- if (!pluginShortcutIds.has(shortcut.id)) {
13
+ if (pluginShortcutIds.has(shortcut.id)) {
14
+ pluginShortcutHelp[pluginShortcutHelp.findIndex(s => s.id === shortcut.id)] = shortcut
15
+ } else {
15
16
  pluginShortcutIds.add(shortcut.id)
16
17
  pluginShortcutHelp.push(shortcut)
17
18
  }
@@ -29,17 +29,15 @@ describe('keyboardShortcutRegistry', () => {
29
29
  expect(shortcuts).toContain(shortcut)
30
30
  })
31
31
 
32
- test('registerKeyboardShortcut should ignore duplicate plugin shortcuts', () => {
32
+ test('registerKeyboardShortcut updates an existing shortcut when re-registered with the same id', () => {
33
33
  const shortcut = { id: 'duplicate', description: 'First' }
34
- const duplicateShortcut = { id: 'duplicate', description: 'Second' }
35
- // Register the first one
34
+ const updatedShortcut = { id: 'duplicate', description: 'Second' }
36
35
  registerKeyboardShortcut({ shortcut })
37
- // Try to register a second shortcut with the same ID
38
- registerKeyboardShortcut({ shortcut: duplicateShortcut })
36
+ registerKeyboardShortcut({ shortcut: updatedShortcut })
39
37
  const shortcuts = getKeyboardShortcuts()
40
- // Only the first shortcut should exist
41
- expect(shortcuts).toContain(shortcut)
42
- expect(shortcuts).not.toContain(duplicateShortcut)
38
+ expect(shortcuts).toContain(updatedShortcut)
39
+ expect(shortcuts).not.toContain(shortcut)
40
+ expect(shortcuts.filter(s => s.id === 'duplicate')).toHaveLength(1)
43
41
  })
44
42
 
45
43
  test('setProviderSupportedShortcuts should filter core shortcuts', () => {
@@ -52,10 +52,14 @@ function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
52
52
  function createButtonClickHandler (btn, appState, evaluateProp) {
53
53
  const [, config] = btn
54
54
  const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
55
+ const isToggle = config.isPressed !== undefined || !!config.pressedWhen
55
56
 
56
57
  return (e) => {
57
58
  if (typeof config.onClick === 'function') {
58
59
  config.onClick(e, evaluateProp(ctx => ctx, config.pluginId))
60
+ if (!isToggle && !config.keepFocus) {
61
+ requestAnimationFrame(() => appState.layoutRefs.viewportRef.current?.focus())
62
+ }
59
63
  return
60
64
  }
61
65
 
@@ -65,7 +69,7 @@ function createButtonClickHandler (btn, appState, evaluateProp) {
65
69
  type: isPanelOpen ? 'CLOSE_PANEL' : 'OPEN_PANEL',
66
70
  payload: isPanelOpen
67
71
  ? config.panelId
68
- : { panelId: config.panelId, props: { triggeringElement } }
72
+ : { panelId: config.panelId, props: { triggeringElement }, ...(config.keepFocus && { focusOnOpen: false }) }
69
73
  })
70
74
  }
71
75
  }