@defra/interactive-map 0.0.37-alpha → 0.0.39-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 (247) hide show
  1. package/assets/templates/draw-tools.njk +4 -3
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api.md +32 -0
  8. package/docs/examples/draw-tools.mdx +0 -4
  9. package/docs/plugins/draw.md +482 -0
  10. package/docs/plugins.md +2 -6
  11. package/govuk-prototype-kit.config.json +3 -2
  12. package/package.json +2 -4
  13. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  14. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  15. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  16. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  17. package/plugins/beta/map-styles/dist/css/index.css +1 -97
  18. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  19. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  20. package/plugins/datasets/src/registry/dataset.js +3 -1
  21. package/plugins/datasets/src/registry/datasetRegistry.js +30 -24
  22. package/plugins/datasets/src/registry/datasetRegistry.test.js +25 -18
  23. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -0
  24. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -0
  25. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -0
  26. package/plugins/draw/dist/esm/index.js +1 -0
  27. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -0
  28. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +2 -0
  29. package/plugins/draw/dist/umd/im-draw-ol-adapter.js.LICENSE.txt +1 -0
  30. package/plugins/draw/dist/umd/im-draw-plugin.js +2 -0
  31. package/plugins/draw/dist/umd/im-draw-plugin.js.LICENSE.txt +1 -0
  32. package/plugins/draw/dist/umd/index.js +2 -0
  33. package/plugins/draw/dist/umd/index.js.LICENSE.txt +1 -0
  34. package/plugins/draw/src/DrawInit.jsx +89 -0
  35. package/plugins/draw/src/DrawInit.test.jsx +184 -0
  36. package/plugins/draw/src/adapterEvents.js +127 -0
  37. package/plugins/draw/src/adapterEvents.test.js +20 -0
  38. package/plugins/draw/src/adapters/adapterContract.test.js +48 -0
  39. package/plugins/draw/src/adapters/loadDrawAdapter.js +14 -0
  40. package/plugins/draw/src/adapters/loadDrawAdapter.test.js +53 -0
  41. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +367 -0
  42. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +799 -0
  43. package/plugins/draw/src/adapters/maplibre/defaults.js +1 -0
  44. package/plugins/draw/src/adapters/maplibre/drawEvents.js +35 -0
  45. package/plugins/draw/src/adapters/maplibre/mapboxDraw.js +126 -0
  46. package/plugins/draw/src/adapters/maplibre/mapboxDraw.test.js +268 -0
  47. package/plugins/draw/src/adapters/maplibre/mapboxSnap.js +39 -0
  48. package/plugins/draw/src/adapters/maplibre/mapboxSnap.test.js +91 -0
  49. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.js +58 -0
  50. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +53 -0
  51. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.js +23 -0
  52. package/plugins/draw/src/adapters/maplibre/modes/disabledMode.test.js +21 -0
  53. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.js +15 -0
  54. package/plugins/draw/src/adapters/maplibre/modes/drawLineMode.test.js +37 -0
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +115 -0
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +193 -0
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +241 -0
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.js +78 -0
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/keyboardHandlers.test.js +75 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +78 -0
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +48 -0
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +103 -0
  63. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +127 -0
  64. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.js +59 -0
  65. package/plugins/draw/src/adapters/maplibre/modes/drawMode/renderHelpers.test.js +34 -0
  66. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.js +193 -0
  67. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +195 -0
  68. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.js +16 -0
  69. package/plugins/draw/src/adapters/maplibre/modes/drawPolygonMode.test.js +27 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/__helpers__/harness.js +107 -0
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +139 -0
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +46 -0
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.js +2 -0
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/helpers.test.js +17 -0
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +151 -0
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +123 -0
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +161 -0
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.test.js +98 -0
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +133 -0
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +105 -0
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +166 -0
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +133 -0
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +210 -0
  84. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +167 -0
  85. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +121 -0
  86. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +73 -0
  87. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +249 -0
  88. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +153 -0
  89. package/plugins/draw/src/adapters/maplibre/snap/constants.js +2 -0
  90. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.js +39 -0
  91. package/plugins/draw/src/adapters/maplibre/snap/mapHandlers.test.js +106 -0
  92. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.js +148 -0
  93. package/plugins/draw/src/adapters/maplibre/snap/prototypePatches.test.js +238 -0
  94. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.js +115 -0
  95. package/plugins/draw/src/adapters/maplibre/snap/snapInstance.test.js +151 -0
  96. package/plugins/draw/src/adapters/maplibre/snap/sourceData.js +34 -0
  97. package/plugins/draw/src/adapters/maplibre/snap/sourceData.test.js +62 -0
  98. package/plugins/draw/src/adapters/maplibre/styles.js +207 -0
  99. package/plugins/draw/src/adapters/maplibre/styles.test.js +155 -0
  100. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.js +202 -0
  101. package/plugins/draw/src/adapters/maplibre/utils/snapHelpers.test.js +253 -0
  102. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.js +57 -0
  103. package/plugins/draw/src/adapters/maplibre/utils/touchClickWorkaround.test.js +106 -0
  104. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +117 -0
  105. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +159 -0
  106. package/plugins/draw/src/adapters/openlayers/__helpers__/harness.js +90 -0
  107. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +178 -0
  108. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +153 -0
  109. package/plugins/draw/src/adapters/openlayers/core/featureStore.js +69 -0
  110. package/plugins/draw/src/adapters/openlayers/core/featureStore.test.js +58 -0
  111. package/plugins/draw/src/adapters/openlayers/core/internalEvents.js +8 -0
  112. package/plugins/draw/src/adapters/openlayers/core/styles.js +155 -0
  113. package/plugins/draw/src/adapters/openlayers/core/styles.test.js +143 -0
  114. package/plugins/draw/src/adapters/openlayers/defaults.js +1 -0
  115. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +272 -0
  116. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +431 -0
  117. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +139 -0
  118. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +140 -0
  119. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +144 -0
  120. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +196 -0
  121. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +385 -0
  122. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +303 -0
  123. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.js +55 -0
  124. package/plugins/draw/src/adapters/openlayers/edit/activeVertexLayer.test.js +53 -0
  125. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +157 -0
  126. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +195 -0
  127. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.js +57 -0
  128. package/plugins/draw/src/adapters/openlayers/edit/midpointLayer.test.js +42 -0
  129. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.js +83 -0
  130. package/plugins/draw/src/adapters/openlayers/edit/modifyInteraction.test.js +80 -0
  131. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +118 -0
  132. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +165 -0
  133. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.js +79 -0
  134. package/plugins/draw/src/adapters/openlayers/edit/pointerHandlers.test.js +78 -0
  135. package/plugins/draw/src/adapters/openlayers/edit/selectionState.js +116 -0
  136. package/plugins/draw/src/adapters/openlayers/edit/selectionState.test.js +109 -0
  137. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.js +184 -0
  138. package/plugins/draw/src/adapters/openlayers/edit/touchHandler.test.js +133 -0
  139. package/plugins/draw/src/adapters/openlayers/edit/undoOps.js +94 -0
  140. package/plugins/draw/src/adapters/openlayers/edit/undoOps.test.js +64 -0
  141. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.js +74 -0
  142. package/plugins/draw/src/adapters/openlayers/edit/vertexHitTest.test.js +28 -0
  143. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.js +49 -0
  144. package/plugins/draw/src/adapters/openlayers/edit/vertexLayer.test.js +42 -0
  145. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.js +100 -0
  146. package/plugins/draw/src/adapters/openlayers/edit/vertexOps.test.js +80 -0
  147. package/plugins/draw/src/adapters/openlayers/olDraw.js +40 -0
  148. package/plugins/draw/src/adapters/openlayers/olDraw.test.js +70 -0
  149. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.js +220 -0
  150. package/plugins/draw/src/adapters/openlayers/snap/snapEngine.test.js +232 -0
  151. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.js +198 -0
  152. package/plugins/draw/src/adapters/openlayers/snap/snapGeometry.test.js +136 -0
  153. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.js +81 -0
  154. package/plugins/draw/src/adapters/openlayers/snap/snapIndicator.test.js +72 -0
  155. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.js +57 -0
  156. package/plugins/draw/src/adapters/openlayers/snap/snapInteraction.test.js +68 -0
  157. package/plugins/draw/src/adapters/openlayers/snap/snapManager.js +99 -0
  158. package/plugins/draw/src/adapters/openlayers/snap/snapManager.test.js +101 -0
  159. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.js +103 -0
  160. package/plugins/draw/src/adapters/openlayers/utils/geometryHelpers.test.js +76 -0
  161. package/plugins/draw/src/adapters/openlayers/utils/olCoords.js +35 -0
  162. package/plugins/draw/src/adapters/openlayers/utils/olCoords.test.js +22 -0
  163. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.js +22 -0
  164. package/plugins/draw/src/adapters/openlayers/utils/sketchHelpers.test.js +18 -0
  165. package/plugins/draw/src/adapters/openlayers/utils/touchTarget.js +8 -0
  166. package/plugins/draw/src/api/addFeature.js +22 -0
  167. package/plugins/draw/src/api/addFeature.test.js +41 -0
  168. package/plugins/draw/src/api/deleteFeature.js +11 -0
  169. package/plugins/draw/src/api/deleteFeature.test.js +24 -0
  170. package/plugins/draw/src/api/editFeature.js +54 -0
  171. package/plugins/draw/src/api/editFeature.test.js +134 -0
  172. package/plugins/draw/src/api/merge.js +30 -0
  173. package/plugins/draw/src/api/merge.test.js +57 -0
  174. package/plugins/draw/src/api/newLine.js +41 -0
  175. package/plugins/draw/src/api/newLine.test.js +93 -0
  176. package/plugins/draw/src/api/newPolygon.js +41 -0
  177. package/plugins/draw/src/api/newPolygon.test.js +93 -0
  178. package/plugins/draw/src/api/split.js +117 -0
  179. package/plugins/draw/src/api/split.test.js +204 -0
  180. package/plugins/draw/src/defaults.js +52 -0
  181. package/plugins/draw/src/defaults.test.js +18 -0
  182. package/plugins/draw/src/draw.scss +43 -0
  183. package/plugins/draw/src/events.js +237 -0
  184. package/plugins/draw/src/events.test.js +369 -0
  185. package/plugins/draw/src/index.js +10 -0
  186. package/plugins/draw/src/index.test.js +24 -0
  187. package/plugins/draw/src/manifest.js +170 -0
  188. package/plugins/draw/src/manifest.test.js +147 -0
  189. package/plugins/draw/src/reducer.js +69 -0
  190. package/plugins/draw/src/reducer.test.js +85 -0
  191. package/plugins/draw/src/utils/debounce.js +16 -0
  192. package/plugins/draw/src/utils/debounce.test.js +40 -0
  193. package/plugins/draw/src/utils/eventBus.js +36 -0
  194. package/plugins/draw/src/utils/eventBus.test.js +47 -0
  195. package/plugins/draw/src/utils/flattenStyleProperties.js +25 -0
  196. package/plugins/draw/src/utils/flattenStyleProperties.test.js +33 -0
  197. package/plugins/draw/src/utils/getValueForStyle.js +33 -0
  198. package/plugins/draw/src/utils/getValueForStyle.test.js +32 -0
  199. package/plugins/draw/src/utils/resolveColors.js +39 -0
  200. package/plugins/draw/src/utils/resolveColors.test.js +34 -0
  201. package/plugins/draw/src/utils/spatial.js +243 -0
  202. package/plugins/draw/src/utils/spatial.test.js +243 -0
  203. package/plugins/draw/src/utils/touchTarget.js +95 -0
  204. package/plugins/draw/src/utils/touchTarget.test.js +107 -0
  205. package/plugins/draw/src/utils/undoStack.js +31 -0
  206. package/plugins/draw/src/utils/undoStack.test.js +49 -0
  207. package/plugins/draw/src/validation/liveDrawChecks.js +120 -0
  208. package/plugins/draw/src/validation/liveDrawChecks.test.js +159 -0
  209. package/plugins/draw/src/validation/liveStroke.js +85 -0
  210. package/plugins/draw/src/validation/liveStroke.test.js +150 -0
  211. package/plugins/draw/src/validation/rules.areaError.test.js +12 -0
  212. package/plugins/draw/src/validation/rules.js +186 -0
  213. package/plugins/draw/src/validation/rules.test.js +120 -0
  214. package/plugins/draw/src/validation/validateGeometry.js +119 -0
  215. package/plugins/draw/src/validation/validateGeometry.test.js +200 -0
  216. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  217. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  218. package/plugins/interact/src/hooks/useInteractionHandlers.js +1 -1
  219. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +1 -1
  220. package/plugins/interact/src/utils/buildStylesMap.js +1 -0
  221. package/plugins/search/dist/css/index.css +1 -317
  222. package/rollup.esm.mjs +35 -28
  223. package/sonar-project.properties +2 -2
  224. package/src/App/components/Attributions/Attributions.module.scss +1 -0
  225. package/src/App/components/Hints/Hints.module.scss +5 -4
  226. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +1 -1
  227. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +11 -0
  228. package/src/App/components/MoveControl/MoveControl.jsx +73 -9
  229. package/src/App/components/MoveControl/MoveControl.module.scss +12 -2
  230. package/src/App/components/MoveControl/MoveControl.test.jsx +136 -0
  231. package/src/App/hooks/useHintsAPI.js +67 -0
  232. package/src/App/hooks/useHintsAPI.test.js +145 -0
  233. package/src/App/renderer/PluginInits.jsx +4 -0
  234. package/src/App/renderer/PluginInits.test.jsx +7 -1
  235. package/src/InteractiveMap/InteractiveMap.js +20 -0
  236. package/src/InteractiveMap/InteractiveMap.test.js +7 -0
  237. package/src/config/events.js +4 -0
  238. package/src/scss/settings/_dimensions.scss +3 -0
  239. package/src/services/announcer.js +38 -3
  240. package/src/services/announcer.test.js +133 -1
  241. package/src/services/hints.js +3 -0
  242. package/src/services/hints.test.js +19 -0
  243. package/src/types.js +12 -0
  244. package/src/utils/detectInterfaceType.js +5 -1
  245. package/src/utils/isMac.js +15 -0
  246. package/src/utils/isMac.test.js +37 -0
  247. package/webpack.umd.mjs +1 -0
@@ -7,10 +7,10 @@ import { useService } from '../../store/serviceContext.js'
7
7
  import { resolveStepAmount } from '../../../utils/resolveNudgeStep.js'
8
8
 
9
9
  const DIRECTIONS = [
10
- { id: 'panUp', verb: 'up', dx: 0, dy: -1 },
11
- { id: 'panDown', verb: 'down', dx: 0, dy: 1 },
12
- { id: 'panLeft', verb: 'left', dx: -1, dy: 0 },
13
- { id: 'panRight', verb: 'right', dx: 1, dy: 0 }
10
+ { id: 'panUp', verb: 'up', dx: 0, dy: -1, key: 'ArrowUp' },
11
+ { id: 'panDown', verb: 'down', dx: 0, dy: 1, key: 'ArrowDown' },
12
+ { id: 'panLeft', verb: 'left', dx: -1, dy: 0, key: 'ArrowLeft' },
13
+ { id: 'panRight', verb: 'right', dx: 1, dy: 0, key: 'ArrowRight' }
14
14
  ]
15
15
 
16
16
  const ZOOM_ACTIONS = [
@@ -20,7 +20,7 @@ const ZOOM_ACTIONS = [
20
20
 
21
21
  export const MoveControl = () => {
22
22
  const { id: appId, mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta } = useConfig()
23
- const { dispatch, expandedButtons, nudgeStepSize } = useApp()
23
+ const { dispatch, expandedButtons, nudgeStepSize, interfaceType, layoutRefs } = useApp()
24
24
  const { isAtMaxZoom, isAtMinZoom } = useMap()
25
25
  const { announce } = useService()
26
26
 
@@ -38,16 +38,62 @@ export const MoveControl = () => {
38
38
  // keyboard-shortcut vocabulary, so the label always describes the step size in effect.
39
39
  const actionWord = isLargeStep ? 'Move' : 'Nudge'
40
40
 
41
- const handlePan = (dx, dy, verb) => {
42
- const amount = resolveStepAmount(isLargeStep, nudgePanDelta, panDelta)
41
+ // Registry buttons (declared via a plugin's manifest) auto-return focus to the map
42
+ // viewport after a click (see createButtonClickHandler in mapButtons.js) unless
43
+ // marked keepFocus — which is how e.g. draw's own toolbar buttons never leave
44
+ // keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MoveControl
45
+ // renders its own buttons directly, bypassing that mechanism entirely, so it has to
46
+ // replicate the same return-to-viewport behaviour itself. But unlike a one-off
47
+ // action button, the D-pad is meant to be pressed repeatedly — a keyboard/switch
48
+ // user tabs to a direction button once and presses Enter/Space many times in a row,
49
+ // which requires focus to stay put. So this only returns focus when the interface
50
+ // type isn't 'keyboard': a mouse/touch user doesn't need retained focus to click the
51
+ // same visible button again, and returning it immediately means they can switch to
52
+ // arrow keys right after without an extra click into the map first.
53
+ const returnFocusToViewport = () => {
54
+ if (interfaceType !== 'keyboard') {
55
+ requestAnimationFrame(() => layoutRefs.viewportRef.current?.focus())
56
+ }
57
+ }
58
+
59
+ const handlePan = (dx, dy, verb, shiftKey = false) => {
60
+ // Shift is a momentary "use the small step" override — matching the map's own
61
+ // native keyboard shortcuts (keyboardActions.js's getPan/getZoom: "Shift held
62
+ // selects the fine nudge amount... not a large step") — regardless of what the
63
+ // Precision toggle currently says. Only the arrow-key path below passes a
64
+ // shiftKey; button clicks don't, so they're unaffected and still follow the
65
+ // toggle exactly as before. Button labels also stay toggle-only (WAI-ARIA
66
+ // stable-name pattern) — only the actual step taken and its announcement
67
+ // reflect a momentary Shift override.
68
+ const effectiveIsLargeStep = shiftKey ? false : isLargeStep
69
+ const effectiveActionWord = effectiveIsLargeStep ? 'Move' : 'Nudge'
70
+
71
+ // Any plugin can claim the D-pad for something other than panning the map (e.g.
72
+ // moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a
73
+ // generic { move(dx, dy, isLargeStep), label? } contract. MoveControl doesn't
74
+ // know or care what's claiming it; it just checks for a claimant before
75
+ // defaulting to its own panBy behaviour. Read fresh on every click rather than
76
+ // subscribed to reactively, since it's a plain mapProvider property, not state.
77
+ const activeMoveTarget = mapProvider.activeMoveTarget
78
+ if (activeMoveTarget) {
79
+ activeMoveTarget.move(dx, dy, effectiveIsLargeStep)
80
+ const target = activeMoveTarget.label ? `${activeMoveTarget.label} ` : ''
81
+ announce(`${effectiveActionWord}d ${target}${verb}`)
82
+ returnFocusToViewport()
83
+ return
84
+ }
85
+
86
+ const amount = resolveStepAmount(effectiveIsLargeStep, nudgePanDelta, panDelta)
43
87
  mapProvider.panBy([dx * amount, dy * amount])
44
- announce(`${actionWord}d ${verb}`)
88
+ announce(`${effectiveActionWord}d ${verb}`)
89
+ returnFocusToViewport()
45
90
  }
46
91
 
47
92
  const handleZoom = (method, label) => {
48
93
  const amount = resolveStepAmount(isLargeStep, nudgeZoomDelta, zoomDelta)
49
94
  mapProvider[method](amount)
50
95
  announce(label)
96
+ returnFocusToViewport()
51
97
  }
52
98
 
53
99
  const handleToggleStep = () => {
@@ -55,6 +101,24 @@ export const MoveControl = () => {
55
101
  announce(isLargeStep ? 'Precision on' : 'Precision off')
56
102
  }
57
103
 
104
+ // A keyboard user tabs to a direction button and can repeat-press Enter/Space on
105
+ // it (see returnFocusToViewport above — focus deliberately stays put for
106
+ // 'keyboard'), but that leaves them unable to fall back to raw arrow keys without
107
+ // first tabbing all the way back to the map. Handling arrow keys here — while
108
+ // focus is anywhere within this control, not just on a direction button — covers
109
+ // that directly, without needing draw's (or the app's own) keyboard-shortcut
110
+ // guards to special-case MoveControl's buttons. Only arrow keys are handled;
111
+ // other shortcuts (delete/escape/undo) still require focus on the viewport,
112
+ // which mouse/touch users already get back automatically after any click here.
113
+ const handleContainerKeyDown = (e) => {
114
+ const direction = DIRECTIONS.find(({ key }) => key === e.key)
115
+ if (!direction) {
116
+ return
117
+ }
118
+ e.preventDefault()
119
+ handlePan(direction.dx, direction.dy, direction.verb, e.shiftKey)
120
+ }
121
+
58
122
  const containerClassName = [
59
123
  'im-c-move-control',
60
124
  !isOpen && 'im-c-move-control--collapsed'
@@ -105,7 +169,7 @@ export const MoveControl = () => {
105
169
  )
106
170
 
107
171
  return (
108
- <div id={`${appId}-move-control-content`} className={containerClassName}>
172
+ <div id={`${appId}-move-control-content`} className={containerClassName} onKeyDown={handleContainerKeyDown}>{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */}
109
173
  {directionsGroup}
110
174
  {zoomGroup}
111
175
  </div>
@@ -12,7 +12,7 @@
12
12
  gap: var(--divider-gap);
13
13
  }
14
14
 
15
- .im-c-move-control--collapsed {
15
+ .im-c-move-control.im-c-move-control--collapsed {
16
16
  display: none;
17
17
  }
18
18
 
@@ -99,7 +99,17 @@
99
99
  // aria-expanded rather than aria-pressed — the trigger is correctly a disclosure
100
100
  // button (it reveals the control below), not a toggle button, so it doesn't carry
101
101
  // aria-pressed itself; the colour is purely a visual echo of its expanded state.
102
- .im-c-map-button--move-control[aria-expanded="true"] {
102
+ //
103
+ // Compound selector (base class + variant class), not the variant class alone —
104
+ // same reasoning as the --collapsed rule above. This selector's specificity was
105
+ // tied with .im-c-map-button:hover (MapButton.module.scss), both (0,2,0), so
106
+ // hovering the expanded trigger button was a coin-flip decided by cascade order —
107
+ // one cssnano mergeRules reshuffle in production and hover started winning,
108
+ // showing the hover tint instead of the pressed colours. The sibling
109
+ // .im-c-move-control .im-c-map-button[aria-pressed="true"] rule above never had
110
+ // this problem (it's already (0,3,0), safely ahead of hover); this one needed the
111
+ // same headroom.
112
+ .im-c-map-button.im-c-map-button--move-control[aria-expanded="true"] {
103
113
  color: var(--pressed-button-foreground-color);
104
114
  border-color: var(--pressed-button-border-color);
105
115
  background-color: var(--pressed-button-background-color);
@@ -25,6 +25,7 @@ describe('MoveControl', () => {
25
25
  dispatch,
26
26
  expandedButtons: new Set(['moveControl']),
27
27
  nudgeStepSize: 'large',
28
+ layoutRefs: { viewportRef: { current: { focus: jest.fn() } } },
28
29
  ...overrides
29
30
  })
30
31
 
@@ -100,6 +101,31 @@ describe('MoveControl', () => {
100
101
  expect(announce).toHaveBeenCalledWith('Nudged up')
101
102
  })
102
103
 
104
+ it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => {
105
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
106
+ render(<MoveControl />)
107
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
108
+ expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true)
109
+ expect(mapProvider.panBy).not.toHaveBeenCalled()
110
+ expect(announce).toHaveBeenCalledWith('Moved vertex right')
111
+ })
112
+
113
+ it('falls back to panning once activeMoveTarget is released', () => {
114
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
115
+ const { rerender } = render(<MoveControl />)
116
+ mapProvider.activeMoveTarget = null
117
+ rerender(<MoveControl />)
118
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
119
+ expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
120
+ })
121
+
122
+ it('omits the target label from the announcement when activeMoveTarget has none', () => {
123
+ mapProvider.activeMoveTarget = { move: jest.fn() }
124
+ render(<MoveControl />)
125
+ fireEvent.click(screen.getByRole('button', { name: 'Move up' }))
126
+ expect(announce).toHaveBeenCalledWith('Moved up')
127
+ })
128
+
103
129
  it('zooms in and out by the large delta by default and announces the action', () => {
104
130
  render(<MoveControl />)
105
131
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
@@ -139,6 +165,116 @@ describe('MoveControl', () => {
139
165
  expect(mapProvider.zoomOut).not.toHaveBeenCalled()
140
166
  })
141
167
 
168
+ describe('returning focus to the viewport after a click', () => {
169
+ let rafSpy
170
+
171
+ beforeEach(() => {
172
+ rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 })
173
+ })
174
+
175
+ afterEach(() => rafSpy.mockRestore())
176
+
177
+ it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => {
178
+ const appState = buildAppState({ interfaceType: 'mouse' })
179
+ useApp.mockReturnValue(appState)
180
+ render(<MoveControl />)
181
+
182
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
183
+ expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
184
+ })
185
+
186
+ it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => {
187
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
188
+ render(<MoveControl />)
189
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
190
+ expect(rafSpy).not.toHaveBeenCalled()
191
+ })
192
+
193
+ it('also returns focus after zooming on mouse/touch, but not on keyboard', () => {
194
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' }))
195
+ const { rerender } = render(<MoveControl />)
196
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
197
+ expect(rafSpy).toHaveBeenCalledTimes(1)
198
+
199
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
200
+ rerender(<MoveControl />)
201
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
202
+ expect(rafSpy).toHaveBeenCalledTimes(1)
203
+ })
204
+
205
+ it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => {
206
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
207
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' }))
208
+ render(<MoveControl />)
209
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
210
+ expect(rafSpy).toHaveBeenCalledTimes(1)
211
+ })
212
+ })
213
+
214
+ describe('arrow keys while focus is anywhere within the control', () => {
215
+ // A keyboard user who tabs to a direction button and repeat-presses Enter keeps
216
+ // focus there (see the describe block above) — this lets them fall back to raw
217
+ // arrow keys without first tabbing all the way back out to the map.
218
+ it('pans the map on an arrow key, regardless of which button currently has focus', () => {
219
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
220
+ render(<MoveControl />)
221
+ // Focus a button unrelated to the direction being pressed, to prove this
222
+ // isn't just reading the focused button's own handler.
223
+ fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' }))
224
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Zoom in' }), { key: 'ArrowRight' })
225
+ expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
226
+ expect(announce).toHaveBeenCalledWith('Moved right')
227
+ })
228
+
229
+ it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => {
230
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
231
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
232
+ render(<MoveControl />)
233
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' })
234
+ expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true)
235
+ expect(mapProvider.panBy).not.toHaveBeenCalled()
236
+ })
237
+
238
+ it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => {
239
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
240
+ render(<MoveControl />)
241
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' })
242
+ expect(mapProvider.panBy).not.toHaveBeenCalled()
243
+ })
244
+
245
+ it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => {
246
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
247
+ render(<MoveControl />)
248
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true })
249
+ expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
250
+ expect(announce).toHaveBeenCalledWith('Nudged right')
251
+ })
252
+
253
+ it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => {
254
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' }))
255
+ render(<MoveControl />)
256
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true })
257
+ expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
258
+ expect(announce).toHaveBeenCalledWith('Nudged right')
259
+ })
260
+
261
+ it('shift+arrow overrides activeMoveTarget.move to the small step too', () => {
262
+ mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
263
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
264
+ render(<MoveControl />)
265
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true })
266
+ expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false)
267
+ })
268
+
269
+ it('arrow key without shift still follows the Precision toggle as before', () => {
270
+ useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
271
+ render(<MoveControl />)
272
+ fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' })
273
+ expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
274
+ expect(announce).toHaveBeenCalledWith('Moved right')
275
+ })
276
+ })
277
+
142
278
  it('has a stable "Precision" label regardless of state', () => {
143
279
  const { rerender } = render(<MoveControl />)
144
280
  expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
@@ -0,0 +1,67 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { EVENTS as events } from '../../config/events.js'
3
+ import { useService } from '../store/serviceContext.js'
4
+ import { useApp } from '../store/appContext.js'
5
+
6
+ /**
7
+ * Wires the public showHint()/dismissHint() API onto the hints service.
8
+ * The hints service already owns its own subscriber list (see Hints.jsx),
9
+ * so this just forwards eventBus commands to it — no React state involved.
10
+ */
11
+ export const useHintsAPI = () => {
12
+ const { eventBus, hints } = useService()
13
+ const { layoutRefs } = useApp()
14
+ const isHintActiveRef = useRef(false)
15
+
16
+ useEffect(() => {
17
+ const handleShowHint = ({ text, options } = {}) => {
18
+ if (!text) {
19
+ return
20
+ }
21
+ hints.show(text, options)
22
+ }
23
+ const handleDismissHint = () => hints.dismiss()
24
+
25
+ eventBus.on(events.APP_SHOW_HINT, handleShowHint)
26
+ eventBus.on(events.APP_DISMISS_HINT, handleDismissHint)
27
+
28
+ return () => {
29
+ eventBus.off(events.APP_SHOW_HINT, handleShowHint)
30
+ eventBus.off(events.APP_DISMISS_HINT, handleDismissHint)
31
+ }
32
+ }, [eventBus, hints])
33
+
34
+ // Escape dismisses the active hint when the keypress originates inside this
35
+ // map instance. The viewport and features listbox already dismiss hints on
36
+ // Escape within their own narrower focus scope (useKeyboardHint.js /
37
+ // useFeatureFocus.js) — this covers hints shown via the public showHint()
38
+ // API from anywhere else in the same map's UI (e.g. a plugin button).
39
+ //
40
+ // Listening on document (rather than the map's own container) and checking
41
+ // containment ourselves, rather than a container-scoped listener, is
42
+ // deliberate: with multiple map instances on one page, each has its own
43
+ // hints service and its own isHintActiveRef, so without the containment
44
+ // check an Escape press anywhere on the host page would dismiss a hint on
45
+ // every instance that happened to have one showing — including maps the
46
+ // user isn't even looking at.
47
+ useEffect(() => {
48
+ return hints.subscribe((hint) => {
49
+ isHintActiveRef.current = Boolean(hint)
50
+ })
51
+ }, [hints])
52
+
53
+ useEffect(() => {
54
+ const handleKeyDown = (e) => {
55
+ if (e.key !== 'Escape' || !isHintActiveRef.current) {
56
+ return
57
+ }
58
+ const container = layoutRefs.appContainerRef?.current
59
+ if (container && !container.contains(e.target)) {
60
+ return
61
+ }
62
+ hints.dismiss()
63
+ }
64
+ document.addEventListener('keydown', handleKeyDown)
65
+ return () => document.removeEventListener('keydown', handleKeyDown)
66
+ }, [hints, layoutRefs])
67
+ }
@@ -0,0 +1,145 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useHintsAPI } from './useHintsAPI.js'
3
+ import { useService } from '../store/serviceContext.js'
4
+ import { useApp } from '../store/appContext.js'
5
+
6
+ jest.mock('../store/serviceContext.js')
7
+ jest.mock('../store/appContext.js')
8
+
9
+ const makeEventBus = () => {
10
+ const handlers = {}
11
+ return {
12
+ on: jest.fn((event, handler) => { handlers[event] = handler }),
13
+ off: jest.fn(),
14
+ emit: (event, payload) => handlers[event]?.(payload),
15
+ _handlers: handlers
16
+ }
17
+ }
18
+
19
+ const makeHints = () => {
20
+ let subscriber = null
21
+ return {
22
+ show: jest.fn(),
23
+ dismiss: jest.fn(),
24
+ subscribe: jest.fn((fn) => {
25
+ subscriber = fn
26
+ return () => { subscriber = null }
27
+ }),
28
+ _emit: (hint) => subscriber?.(hint)
29
+ }
30
+ }
31
+
32
+ const pressEscape = (target) => {
33
+ target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
34
+ }
35
+
36
+ describe('useHintsAPI', () => {
37
+ let mockEventBus, mockHints, containerEl, insideEl, outsideEl
38
+
39
+ beforeEach(() => {
40
+ mockEventBus = makeEventBus()
41
+ mockHints = makeHints()
42
+ useService.mockReturnValue({ eventBus: mockEventBus, hints: mockHints })
43
+
44
+ // Simulates this map instance's root DOM (layoutRefs.appContainerRef),
45
+ // plus an element outside it — standing in for a second map instance,
46
+ // or unrelated content elsewhere on the host page.
47
+ containerEl = document.createElement('div')
48
+ insideEl = document.createElement('button')
49
+ containerEl.appendChild(insideEl)
50
+ document.body.appendChild(containerEl)
51
+
52
+ outsideEl = document.createElement('button')
53
+ document.body.appendChild(outsideEl)
54
+
55
+ useApp.mockReturnValue({ layoutRefs: { appContainerRef: { current: containerEl } } })
56
+ })
57
+
58
+ afterEach(() => {
59
+ containerEl.remove()
60
+ outsideEl.remove()
61
+ })
62
+
63
+ it('calls hints.show with text and options on app:showhint', () => {
64
+ renderHook(() => useHintsAPI())
65
+ act(() => mockEventBus.emit('app:showhint', { text: 'Press Enter to select', options: { duration: 2000 } }))
66
+ expect(mockHints.show).toHaveBeenCalledWith('Press Enter to select', { duration: 2000 })
67
+ })
68
+
69
+ it('ignores app:showhint with no text', () => {
70
+ renderHook(() => useHintsAPI())
71
+ act(() => mockEventBus.emit('app:showhint', {}))
72
+ act(() => mockEventBus.emit('app:showhint'))
73
+ expect(mockHints.show).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('calls hints.dismiss on app:dismisshint', () => {
77
+ renderHook(() => useHintsAPI())
78
+ act(() => mockEventBus.emit('app:dismisshint'))
79
+ expect(mockHints.dismiss).toHaveBeenCalled()
80
+ })
81
+
82
+ it('unsubscribes on unmount', () => {
83
+ const { unmount } = renderHook(() => useHintsAPI())
84
+ unmount()
85
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:showhint', expect.any(Function))
86
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:dismisshint', expect.any(Function))
87
+ })
88
+
89
+ describe('Escape key', () => {
90
+ it('dismisses the active hint when Escape originates inside this map instance', () => {
91
+ renderHook(() => useHintsAPI())
92
+ act(() => mockHints._emit({ html: 'Press Enter to select' }))
93
+
94
+ act(() => pressEscape(insideEl))
95
+ expect(mockHints.dismiss).toHaveBeenCalled()
96
+ })
97
+
98
+ it('does not dismiss when Escape originates outside this map instance', () => {
99
+ // e.g. a second map instance on the page, or unrelated host-page content
100
+ renderHook(() => useHintsAPI())
101
+ act(() => mockHints._emit({ html: 'Press Enter to select' }))
102
+
103
+ act(() => pressEscape(outsideEl))
104
+ expect(mockHints.dismiss).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('does nothing on Escape when no hint is showing', () => {
108
+ renderHook(() => useHintsAPI())
109
+ act(() => pressEscape(insideEl))
110
+ expect(mockHints.dismiss).not.toHaveBeenCalled()
111
+ })
112
+
113
+ it('stops reacting to Escape once the hint has been dismissed', () => {
114
+ renderHook(() => useHintsAPI())
115
+ act(() => mockHints._emit({ html: 'Press Enter to select' }))
116
+ act(() => mockHints._emit(null)) // hints service reports no active hint
117
+
118
+ act(() => pressEscape(insideEl))
119
+ expect(mockHints.dismiss).not.toHaveBeenCalled()
120
+ })
121
+
122
+ it('falls back to dismissing when appContainerRef is not yet available', () => {
123
+ useApp.mockReturnValue({ layoutRefs: { appContainerRef: { current: null } } })
124
+ renderHook(() => useHintsAPI())
125
+ act(() => mockHints._emit({ html: 'Press Enter to select' }))
126
+
127
+ act(() => pressEscape(outsideEl))
128
+ expect(mockHints.dismiss).toHaveBeenCalled()
129
+ })
130
+
131
+ it('removes the keydown listener on unmount', () => {
132
+ const addSpy = jest.spyOn(document, 'addEventListener')
133
+ const removeSpy = jest.spyOn(document, 'removeEventListener')
134
+
135
+ const { unmount } = renderHook(() => useHintsAPI())
136
+ expect(addSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
137
+
138
+ unmount()
139
+ expect(removeSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
140
+
141
+ addSpy.mockRestore()
142
+ removeSpy.mockRestore()
143
+ })
144
+ })
145
+ })
@@ -3,6 +3,7 @@ import React, { useEffect } from 'react'
3
3
  import { withPluginContexts } from './pluginWrapper.js'
4
4
  import { withPluginApiContexts, usePluginApiState } from './pluginApiWrapper.js'
5
5
  import { useInterfaceAPI } from '../hooks/useInterfaceAPI.js'
6
+ import { useHintsAPI } from '../hooks/useHintsAPI.js'
6
7
  import { useApp } from '../store/appContext.js'
7
8
  import { useConfig } from '../store/configContext.js'
8
9
  import { useEvaluateProp } from '../hooks/useEvaluateProp.js'
@@ -53,6 +54,9 @@ export const PluginInits = () => {
53
54
  // Add button, panel and control API methods (Needs to be top-level)
54
55
  useInterfaceAPI()
55
56
 
57
+ // Wire the showHint()/dismissHint() public API onto the hints service
58
+ useHintsAPI()
59
+
56
60
  // Evaluate reactive button states globally
57
61
  const evaluateProp = useEvaluateProp()
58
62
  useButtonStateEvaluator(evaluateProp)
@@ -6,6 +6,7 @@ import { useButtonStateEvaluator } from '../hooks/useButtonStateEvaluator.js'
6
6
  import { withPluginApiContexts } from './pluginApiWrapper.js'
7
7
  import { withPluginContexts } from './pluginWrapper.js'
8
8
  import { useInterfaceAPI } from '../hooks/useInterfaceAPI.js'
9
+ import { useHintsAPI } from '../hooks/useHintsAPI.js'
9
10
  import { useApp } from '../store/appContext.js'
10
11
  import { useConfig } from '../store/configContext.js'
11
12
 
@@ -29,6 +30,10 @@ jest.mock('../hooks/useInterfaceAPI.js', () => ({
29
30
  useInterfaceAPI: jest.fn()
30
31
  }))
31
32
 
33
+ jest.mock('../hooks/useHintsAPI.js', () => ({
34
+ useHintsAPI: jest.fn()
35
+ }))
36
+
32
37
  jest.mock('../hooks/useEvaluateProp.js', () => ({
33
38
  useEvaluateProp: jest.fn(() => (x) => x)
34
39
  }))
@@ -59,10 +64,11 @@ describe('PluginInits', () => {
59
64
  useConfig.mockReturnValue({ pluginRegistry: pluginRegistryMock })
60
65
  })
61
66
 
62
- it('calls useButtonStateEvaluator and useInterfaceAPI on render', () => {
67
+ it('calls useButtonStateEvaluator, useInterfaceAPI and useHintsAPI on render', () => {
63
68
  render(<PluginInits />)
64
69
  expect(useButtonStateEvaluator).toHaveBeenCalled()
65
70
  expect(useInterfaceAPI).toHaveBeenCalled()
71
+ expect(useHintsAPI).toHaveBeenCalled()
66
72
  })
67
73
 
68
74
  it('renders nothing when no plugins registered', () => {
@@ -3,6 +3,7 @@
3
3
  /**
4
4
  * @typedef {import('../types.js').ButtonDefinition} ButtonDefinition
5
5
  * @typedef {import('../types.js').ControlDefinition} ControlDefinition
6
+ * @typedef {import('../types.js').HintOptions} HintOptions
6
7
  * @typedef {import('../types.js').InteractiveMapConfig} InteractiveMapConfig
7
8
  * @typedef {import('../types.js').MarkerOptions} MarkerOptions
8
9
  * @typedef {import('../types.js').PanelDefinition} PanelDefinition
@@ -481,6 +482,25 @@ export default class InteractiveMap {
481
482
  this.eventBus.emit(events.APP_ADD_CONTROL, { id, config })
482
483
  }
483
484
 
485
+ /**
486
+ * Show a toast hint, announced to screen readers via the live region.
487
+ * Replaces any hint currently showing and restarts its dismiss timer.
488
+ *
489
+ * @param {string} text - Hint text. May contain simple HTML (e.g. `<kbd>`).
490
+ * @param {HintOptions} [options] - Optional hint behaviour.
491
+ */
492
+ showHint (text, options) {
493
+ this.eventBus.emit(events.APP_SHOW_HINT, { text, options })
494
+ }
495
+
496
+ /**
497
+ * Dismiss the active toast hint, if any. No-op if no hint is showing.
498
+ * Mainly useful for hints shown with `{ duration: 0 }`, which otherwise persist indefinitely.
499
+ */
500
+ dismissHint () {
501
+ this.eventBus.emit(events.APP_DISMISS_HINT)
502
+ }
503
+
484
504
  /**
485
505
  * Fit the map view to a bounding box or GeoJSON geometry, respecting the safe zone padding.
486
506
  *
@@ -579,4 +579,11 @@ describe('InteractiveMap — Public API Methods', () => {
579
579
  expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:fittobounds', bbox)
580
580
  expect(map.eventBus.emitWhenReady).toHaveBeenCalledWith('map:setview', { center, zoom: 12 })
581
581
  })
582
+
583
+ it('showHint and dismissHint emit correct events', () => {
584
+ map.showHint('Press <kbd>Enter</kbd> to select', { duration: 2000 })
585
+ map.dismissHint()
586
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:showhint', { text: 'Press <kbd>Enter</kbd> to select', options: { duration: 2000 } })
587
+ expect(map.eventBus.emit).toHaveBeenCalledWith('app:dismisshint')
588
+ })
582
589
  })
@@ -47,6 +47,10 @@ export const EVENTS = {
47
47
  APP_HIDE_PANEL: 'app:hidepanel',
48
48
  /** @internal Add a control. Payload: { id, config } */
49
49
  APP_ADD_CONTROL: 'app:addcontrol',
50
+ /** @internal Show a toast hint, announced to screen readers. Payload: { text, options } */
51
+ APP_SHOW_HINT: 'app:showhint',
52
+ /** @internal Dismiss the active toast hint, if any. */
53
+ APP_DISMISS_HINT: 'app:dismisshint',
50
54
 
51
55
  // ============================================
52
56
  // App responses (end-user / subscribe)
@@ -31,6 +31,9 @@
31
31
  --tooltip-font-size: 14px;
32
32
  --tooltip-border-radius: 3px;
33
33
 
34
+ // Hints
35
+ --hint-max-width: 320px;
36
+
34
37
  // Panels
35
38
  --min-panel-width: 260px;
36
39
  --panel-close-button-gap: 4px;