@defra/interactive-map 0.0.43-fmp-experimental → 0.0.44-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 (275) hide show
  1. package/assets/templates/add-polygons.njk +5 -1
  2. package/assets/templates/add-symbols.njk +5 -1
  3. package/dist/css/index.css +1 -1
  4. package/dist/esm/im-core.js +1 -1
  5. package/dist/esm/im-shell.js +1 -1
  6. package/dist/umd/im-core.js +1 -1
  7. package/dist/umd/index.js +1 -1
  8. package/docs/api/context.md +24 -0
  9. package/docs/api/keyboard-shortcut-definition.md +102 -0
  10. package/docs/api.md +18 -8
  11. package/docs/building-a-plugin.md +1 -0
  12. package/docs/plugins/draw.md +2 -2
  13. package/docs/plugins/plugin-manifest.md +9 -0
  14. package/package.json +4 -2
  15. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  16. package/plugins/beta/draw-es/src/api/newPolygon.js +1 -0
  17. package/plugins/beta/draw-es/src/events.js +17 -8
  18. package/plugins/beta/draw-es/src/reducer.js +8 -1
  19. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  20. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ol/dist/css/index.css +1 -13
  22. package/plugins/beta/draw-ol/dist/esm/im-draw-ol-plugin.js +1 -1
  23. package/plugins/beta/frame/dist/css/index.css +1 -11
  24. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  25. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  26. package/plugins/beta/frame/src/Frame.jsx +28 -2
  27. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  28. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  29. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  30. package/plugins/beta/scale-bar/dist/css/index.css +1 -8
  31. package/plugins/datasets/dist/css/5648.index.css +1 -5
  32. package/plugins/datasets/dist/css/index.css +1 -1
  33. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  34. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  35. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +1 -0
  36. package/plugins/datasets/src/initialise/DatasetsInit.jsx +2 -2
  37. package/plugins/datasets/src/initialise/DatasetsInit.test.jsx +6 -5
  38. package/plugins/datasets/src/registry/dataset.js +8 -12
  39. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  40. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  41. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  42. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  43. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  44. package/plugins/draw/dist/esm/index.js +1 -1
  45. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  46. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  47. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  48. package/plugins/draw/dist/umd/index.js +1 -1
  49. package/plugins/draw/src/DrawInit.jsx +41 -26
  50. package/plugins/draw/src/DrawInit.test.jsx +55 -12
  51. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +113 -114
  52. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.test.js +82 -9
  53. package/plugins/draw/src/adapters/maplibre/drawEvents.js +3 -1
  54. package/plugins/draw/src/adapters/maplibre/modes/createDrawMode.test.js +1 -1
  55. package/plugins/draw/src/adapters/maplibre/modes/drawMode/__helpers__/harness.js +18 -9
  56. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.js +9 -1
  57. package/plugins/draw/src/adapters/maplibre/modes/drawMode/clickHandlers.test.js +11 -1
  58. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +23 -2
  59. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +53 -0
  60. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +29 -19
  61. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +17 -2
  62. package/plugins/draw/src/adapters/maplibre/modes/drawMode/undoHandlers.test.js +1 -1
  63. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.js +35 -29
  64. package/plugins/draw/src/adapters/maplibre/modes/drawPointMode.test.js +24 -6
  65. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/keyboardHandlers.js +22 -20
  66. package/plugins/draw/src/adapters/maplibre/modes/editPointMode/pointOperations.js +1 -1
  67. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.js +2 -2
  68. package/plugins/draw/src/adapters/maplibre/modes/editPointMode.test.js +3 -3
  69. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.js +31 -0
  70. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/geometryHelpers.test.js +8 -1
  71. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +38 -30
  72. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.test.js +20 -3
  73. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/pointerHandlers.js +5 -5
  74. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.js +2 -2
  75. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/touchHandlers.test.js +2 -2
  76. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.js +3 -3
  77. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/undoHandlers.test.js +7 -7
  78. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +4 -4
  79. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +14 -14
  80. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.js +12 -32
  81. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexQueries.test.js +10 -10
  82. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.js +48 -22
  83. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +74 -6
  84. package/plugins/draw/src/adapters/maplibre/utils/snapMovement.js +2 -2
  85. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.js +6 -0
  86. package/plugins/draw/src/adapters/openlayers/OLDrawAdapter.test.js +9 -0
  87. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.js +15 -2
  88. package/plugins/draw/src/adapters/openlayers/core/OLDrawManager.test.js +27 -1
  89. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +1 -1
  90. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +40 -17
  91. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +55 -1
  92. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.js +13 -7
  93. package/plugins/draw/src/adapters/openlayers/draw/vertexPlacement.test.js +7 -15
  94. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +36 -4
  95. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +41 -1
  96. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.js +35 -25
  97. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +40 -2
  98. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  99. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  100. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +40 -54
  101. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +32 -13
  102. package/plugins/draw/src/adapters/openlayers/point/editPointMode.js +1 -1
  103. package/plugins/draw/src/adapters/openlayers/point/editPointMode.test.js +17 -1
  104. package/plugins/draw/src/events.js +4 -4
  105. package/plugins/draw/src/events.test.js +1 -1
  106. package/plugins/draw/src/hooks/useSpatialList.js +180 -0
  107. package/plugins/draw/src/hooks/useSpatialList.test.js +339 -0
  108. package/plugins/draw/src/manifest.js +4 -1
  109. package/plugins/draw/src/manifest.test.js +19 -7
  110. package/plugins/draw/src/utils/spatial.js +1 -31
  111. package/plugins/draw/src/utils/spatial.test.js +1 -24
  112. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  113. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  114. package/plugins/interact/dist/umd/index.js +1 -1
  115. package/plugins/interact/src/InteractInit.jsx +2 -2
  116. package/plugins/interact/src/InteractInit.test.js +3 -3
  117. package/plugins/interact/src/events.js +11 -2
  118. package/plugins/interact/src/events.test.js +32 -0
  119. package/plugins/interact/src/hooks/useAttachEvents.js +20 -0
  120. package/plugins/interact/src/hooks/useAttachEvents.test.js +44 -1
  121. package/plugins/interact/src/hooks/useCrossHairVisibility.js +9 -4
  122. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +7 -7
  123. package/plugins/interact/src/hooks/useSpatialList.js +251 -0
  124. package/plugins/interact/src/hooks/{useMapItemList.test.js → useSpatialList.test.js} +216 -70
  125. package/plugins/interact/src/manifest.js +12 -1
  126. package/plugins/interact/src/manifest.test.js +20 -0
  127. package/plugins/interact/src/utils/spatial.js +24 -1
  128. package/plugins/interact/src/utils/spatial.test.js +26 -1
  129. package/plugins/map-key/dist/css/index.css +1 -106
  130. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  131. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  132. package/plugins/map-key/dist/umd/index.js +1 -1
  133. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  134. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  135. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  136. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  137. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  138. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  139. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  140. package/plugins/map-key/src/manifest.js +1 -1
  141. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  142. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  143. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  144. package/plugins/menu/dist/css/index.css +1 -80
  145. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  146. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  147. package/plugins/menu/dist/umd/index.js +1 -1
  148. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  149. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  150. package/plugins/menu/src/initialise/createMenu.js +3 -1
  151. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  152. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  153. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  154. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  155. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  156. package/plugins/menu/src/reducers/pluginState.js +1 -6
  157. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  158. package/plugins/search/dist/css/index.css +1 -1
  159. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  160. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  161. package/plugins/search/src/manifest.js +5 -9
  162. package/plugins/search/src/manifest.test.js +5 -0
  163. package/providers/beta/esri/dist/css/index.css +1 -34
  164. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  165. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  166. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  167. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  168. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  169. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  170. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  171. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  172. package/providers/maplibre/dist/umd/index.js +1 -1
  173. package/providers/maplibre/src/defaults.test.js +26 -0
  174. package/providers/maplibre/src/utils/labels.js +2 -2
  175. package/providers/maplibre/src/utils/labels.test.js +5 -5
  176. package/providers/maplibre/src/utils/spatial.js +0 -57
  177. package/providers/maplibre/src/utils/spatial.test.js +0 -27
  178. package/rollup.esm.mjs +2 -1
  179. package/scripts/publish-package.sh +36 -0
  180. package/src/App/App.jsx +1 -1
  181. package/src/App/App.test.jsx +6 -1
  182. package/src/App/components/CrossHair/CrossHair.jsx +25 -11
  183. package/src/App/components/CrossHair/CrossHair.module.scss +37 -12
  184. package/src/App/components/CrossHair/CrossHair.test.jsx +40 -11
  185. package/src/App/components/Hints/Hints.jsx +3 -3
  186. package/src/App/components/Hints/Hints.test.jsx +3 -3
  187. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  188. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  189. package/src/App/components/MapButton/MapButton.jsx +19 -7
  190. package/src/App/components/MapButton/MapButton.module.scss +1 -2
  191. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  192. package/src/App/components/{MoveControls/MoveControls.jsx → MapControls/MapControls.jsx} +10 -10
  193. package/src/App/components/{MoveControls/MoveControls.module.scss → MapControls/MapControls.module.scss} +25 -25
  194. package/src/App/components/{MoveControls/MoveControls.test.jsx → MapControls/MapControls.test.jsx} +48 -48
  195. package/src/App/components/Markers/Markers.jsx +2 -2
  196. package/src/App/components/Markers/Markers.test.jsx +3 -3
  197. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  198. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  199. package/src/App/components/Panel/Panel.jsx +19 -20
  200. package/src/App/components/Panel/Panel.module.scss +5 -0
  201. package/src/App/components/Panel/Panel.test.jsx +69 -4
  202. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  203. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  204. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  205. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  206. package/src/App/components/Viewport/SpatialList.jsx +47 -0
  207. package/src/App/components/Viewport/{Features.module.scss → SpatialList.module.scss} +6 -6
  208. package/src/App/components/Viewport/SpatialList.test.jsx +285 -0
  209. package/src/App/components/Viewport/Viewport.jsx +21 -11
  210. package/src/App/components/Viewport/Viewport.test.jsx +18 -2
  211. package/src/App/controls/keyboardMappings.js +7 -2
  212. package/src/App/controls/keyboardMappings.test.js +21 -0
  213. package/src/App/controls/keyboardShortcuts.js +16 -2
  214. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  215. package/src/App/hooks/useCrossHairAPI.js +29 -1
  216. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  217. package/src/App/hooks/useHintsAPI.js +5 -5
  218. package/src/App/hooks/useInterfaceAPI.js +2 -1
  219. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  220. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  221. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  222. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  223. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  224. package/src/App/hooks/useSpatialListFocus.js +296 -0
  225. package/src/App/hooks/useSpatialListFocus.test.js +957 -0
  226. package/src/App/hooks/useSpatialListItems.js +51 -0
  227. package/src/App/hooks/useSpatialListItems.test.js +217 -0
  228. package/src/App/initialiseApp.js +6 -3
  229. package/src/App/initialiseApp.test.js +11 -0
  230. package/src/App/layout/Layout.jsx +4 -2
  231. package/src/App/layout/Layout.test.jsx +21 -0
  232. package/src/App/layout/layout.module.scss +7 -1
  233. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  234. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  235. package/src/App/registry/pluginRegistry.js +13 -0
  236. package/src/App/registry/pluginRegistry.test.js +15 -0
  237. package/src/App/registry/spatialListRegistry.js +104 -0
  238. package/src/App/registry/spatialListRegistry.test.js +218 -0
  239. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  240. package/src/App/renderer/mapButtons.js +4 -0
  241. package/src/App/renderer/mapButtons.test.js +20 -0
  242. package/src/App/renderer/mapPanels.js +33 -53
  243. package/src/App/renderer/mapPanels.test.js +29 -4
  244. package/src/App/renderer/slotHelpers.js +33 -0
  245. package/src/App/renderer/slotHelpers.test.js +79 -2
  246. package/src/App/store/AppProvider.jsx +3 -2
  247. package/src/App/store/ServiceProvider.jsx +5 -3
  248. package/src/App/store/ServiceProvider.test.jsx +20 -1
  249. package/src/InteractiveMap/InteractiveMap.js +13 -2
  250. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  251. package/src/config/appConfig.js +16 -16
  252. package/src/config/appConfig.test.js +19 -19
  253. package/src/config/defaults.js +2 -2
  254. package/src/config/events.js +8 -8
  255. package/src/scss/main.scss +2 -2
  256. package/src/test-utils.js +8 -0
  257. package/src/types.js +51 -9
  258. package/src/utils/findNearestItemInDirection.js +29 -0
  259. package/src/utils/findNearestItemInDirection.test.js +67 -0
  260. package/src/utils/getPanelElementId.js +4 -0
  261. package/src/utils/getPanelElementId.test.js +11 -0
  262. package/src/utils/getPanelRole.js +22 -0
  263. package/src/utils/getPanelRole.test.js +56 -0
  264. package/src/utils/globalAltShortcuts.js +12 -0
  265. package/src/utils/globalAltShortcuts.test.js +27 -0
  266. package/src/utils/spatialNavigate.js +49 -0
  267. package/src/utils/spatialNavigate.test.js +42 -0
  268. package/webpack.umd.mjs +6 -1
  269. package/plugins/interact/src/hooks/useMapItemList.js +0 -187
  270. package/src/App/components/Viewport/Features.jsx +0 -35
  271. package/src/App/components/Viewport/Features.test.jsx +0 -124
  272. package/src/App/hooks/useFeatureFocus.js +0 -199
  273. package/src/App/hooks/useFeatureFocus.test.js +0 -635
  274. package/src/App/hooks/useFeatureItems.js +0 -39
  275. package/src/App/hooks/useFeatureItems.test.js +0 -155
@@ -2,31 +2,20 @@ import React, { useRef, useEffect, useMemo } from 'react'
2
2
  import { useConfig } from '../../store/configContext'
3
3
  import { useApp } from '../../store/appContext'
4
4
  import { stringToKebab } from '../../../utils/stringToKebab.js'
5
+ import { getPanelElementId } from '../../../utils/getPanelElementId.js'
5
6
  import { useModalPanelBehaviour } from '../../hooks/useModalPanelBehaviour.js'
6
7
  import { useIsScrollable } from '../../hooks/useIsScrollable.js'
8
+ import { classifyPanel, getPanelRole } from '../../../utils/getPanelRole.js'
7
9
  import { Icon } from '../Icon/Icon'
8
10
  import { Tabs } from '../Tabs/Tabs.jsx'
9
11
 
10
12
  const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
11
- const isAside = bpConfig.slot === 'side' && bpConfig.open && !bpConfig.modal
12
- const isDialog = !isAside && bpConfig.dismissible
13
- const isModal = bpConfig.modal === true
14
- const isDismissible = bpConfig.dismissible !== false
13
+ const { isModal, isAside, isDismissible, isDialog } = classifyPanel(bpConfig)
15
14
  const shouldFocus = isModal || focusOnOpen === true || (focusOnOpen !== false && focus !== false && (focus === true || Boolean(triggeringElement)))
16
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
17
16
  return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
18
17
  }
19
18
 
20
- const getPanelRole = (isDialog, isDismissible) => {
21
- if (isDialog) {
22
- return 'dialog'
23
- }
24
- if (isDismissible) {
25
- return 'complementary'
26
- }
27
- return 'region'
28
- }
29
-
30
19
  const buildPanelClassNames = (slot, showLabel) => [
31
20
  'im-c-panel',
32
21
  `im-c-panel--${slot}`,
@@ -38,15 +27,17 @@ const buildPanelBodyClassNames = (showLabel, isDismissible) => [
38
27
  !showLabel && isDismissible && 'im-c-panel__body--offset'
39
28
  ].filter(Boolean).join(' ')
40
29
 
41
- const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot }) => ({
30
+ const buildPanelProps = ({ elementId, shouldFocus, isDialog, isDismissible, isModal, width, panelClass, slot, isOpen }) => ({
42
31
  id: elementId,
43
32
  'aria-labelledby': `${elementId}-label`,
44
33
  tabIndex: shouldFocus ? -1 : undefined, // nosonar
45
- role: getPanelRole(isDialog, isDismissible),
34
+ role: getPanelRole({ isDialog, isDismissible }),
46
35
  'aria-modal': isDialog && isModal ? 'true' : undefined,
47
36
  style: width ? { width } : undefined,
48
37
  className: panelClass,
49
- 'data-slot': slot
38
+ 'data-slot': slot,
39
+ // Panel is mounted permanently (see mapPanels.js); hidden is what actually opens/closes it.
40
+ hidden: !isOpen
50
41
  })
51
42
 
52
43
  const buildBodyProps = ({ bodyRef, panelBodyClass, isBodyScrollable, elementId }) => ({
@@ -112,7 +103,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
112
103
 
113
104
  const rootEl = document.getElementById(`${id}-im-app`)
114
105
  const bpConfig = panelConfig[breakpoint]
115
- const elementId = `${id}-panel-${stringToKebab(panelId)}`
106
+ const elementId = getPanelElementId(id, panelId)
116
107
 
117
108
  const { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl } = computePanelState(bpConfig, props?.triggeringElement, panelConfig.focus, focusOnOpen) // nosonar
118
109
 
@@ -129,7 +120,15 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
129
120
  const panelRef = rootRef || internalPanelRef
130
121
 
131
122
  const handleClose = () => {
132
- requestAnimationFrame(() => { (props?.triggeringElement || layoutRefs.viewportRef.current).focus?.({ preventScroll: interfaceType !== 'keyboard' }) })
123
+ requestAnimationFrame(() => {
124
+ // Re-checked here, not at handleClose-call time — the triggering element (a button, a
125
+ // Features-list item, ...) may have been removed from the DOM while the panel was open
126
+ // (e.g. the map panned and the feature it came from dropped out of the current list).
127
+ // document.contains() on a detached node is false, not a throw, so this is a plain check.
128
+ const triggeringElement = props?.triggeringElement
129
+ const focusTarget = triggeringElement && document.contains(triggeringElement) ? triggeringElement : layoutRefs.viewportRef.current
130
+ focusTarget?.focus?.({ preventScroll: interfaceType !== 'keyboard' })
131
+ })
133
132
  dispatch({ type: 'CLOSE_PANEL', payload: panelId })
134
133
  }
135
134
 
@@ -149,7 +148,7 @@ export const Panel = ({ panelId, panelConfig, props, focusOnOpen, WrappedChild,
149
148
  const panelBodyClass = buildPanelBodyClassNames(bpConfig.showLabel ?? true, isDismissible)
150
149
  const innerHtmlProp = useMemo(() => html ? { __html: html } : null, [html])
151
150
 
152
- const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot })
151
+ const panelProps = buildPanelProps({ elementId, shouldFocus, isDialog, isDismissible, isModal, width: bpConfig.width, panelClass, slot: bpConfig.slot, isOpen })
153
152
  const bodyProps = buildBodyProps({ bodyRef, panelBodyClass, isBodyScrollable, elementId })
154
153
  // DOM anchor for controls DOM-projected via the JS/consumer-HTML API — see HtmlElementHost.jsx.
155
154
  // Only present on the items-capable body below: dangerouslySetInnerHTML owns the static-html
@@ -20,6 +20,11 @@
20
20
  pointer-events: auto;
21
21
  color: var(--foreground-color);
22
22
  background-color: var(--background-color);
23
+
24
+ // Override needed: display: flex above beats the browser's default [hidden] rule otherwise.
25
+ &[hidden] {
26
+ display: none;
27
+ }
23
28
  }
24
29
 
25
30
  // 2. Elements
@@ -102,6 +102,20 @@ describe('Panel', () => {
102
102
  expect(dialog).toHaveAttribute('aria-modal', 'true')
103
103
  expect(dialog).toHaveAttribute('tabIndex', '-1')
104
104
  })
105
+
106
+ it('renders dialog role and aria-modal for a modal panel even when dismissible is left unset (WCAG requires a modal to be dismissible)', () => {
107
+ renderPanel({ desktop: { slot: 'overlay', modal: true } })
108
+ const dialog = screen.getByRole('dialog')
109
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
110
+ expect(screen.getByRole('button', { name: 'Close Settings' })).toBeInTheDocument()
111
+ })
112
+
113
+ it('renders dialog role and aria-modal for a modal panel even when dismissible: false is set (a non-dismissible modal would trap keyboard/AT users)', () => {
114
+ renderPanel({ desktop: { slot: 'overlay', modal: true, dismissible: false } })
115
+ const dialog = screen.getByRole('dialog')
116
+ expect(dialog).toHaveAttribute('aria-modal', 'true')
117
+ expect(screen.getByRole('button', { name: 'Close Settings' })).toBeInTheDocument()
118
+ })
105
119
  })
106
120
 
107
121
  describe('focus behaviour', () => {
@@ -142,9 +156,18 @@ describe('Panel', () => {
142
156
  })
143
157
 
144
158
  describe('close functionality', () => {
159
+ // document.contains() needs a real Node — a plain { focus: jest.fn() } mock object throws
160
+ // ("parameter 1 is not of type 'Node'"), so triggeringElement here is a real element, spied
161
+ // on and attached to the document, matching what production always passes
162
+ // (e.currentTarget / document.activeElement).
163
+ const makeAttachedTriggeringElement = () => {
164
+ const el = document.createElement('button')
165
+ document.body.appendChild(el)
166
+ return { el, focusMock: jest.spyOn(el, 'focus') }
167
+ }
168
+
145
169
  it('focuses triggeringElement on close for button slots', () => {
146
- const focusMock = jest.fn()
147
- const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
170
+ const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
148
171
 
149
172
  renderPanel(
150
173
  { desktop: { slot: 'top-button', dismissible: true, open: false } },
@@ -157,8 +180,7 @@ describe('Panel', () => {
157
180
  })
158
181
 
159
182
  it('handles close for non-button slots', () => {
160
- const focusMock = jest.fn()
161
- const triggeringElement = { focus: focusMock, parentNode: document.createElement('div') }
183
+ const { el: triggeringElement, focusMock } = makeAttachedTriggeringElement()
162
184
 
163
185
  renderPanel(
164
186
  { desktop: { slot: 'overlay', dismissible: true, modal: true } },
@@ -176,6 +198,20 @@ describe('Panel', () => {
176
198
  expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
177
199
  expect(dispatch).toHaveBeenCalledWith({ type: 'CLOSE_PANEL', payload: 'Settings' })
178
200
  })
201
+
202
+ it('falls back to viewportRef focus when triggeringElement has been removed from the DOM', () => {
203
+ const triggeringElement = document.createElement('button') // never attached
204
+ const focusMock = jest.spyOn(triggeringElement, 'focus')
205
+
206
+ renderPanel(
207
+ { desktop: { slot: 'side', dismissible: true, open: false } },
208
+ { props: { triggeringElement } }
209
+ )
210
+
211
+ fireEvent.click(screen.getByRole('button', { name: 'Close Settings' }))
212
+ expect(focusMock).not.toHaveBeenCalled()
213
+ expect(layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
214
+ })
179
215
  })
180
216
 
181
217
  describe('content rendering', () => {
@@ -308,4 +344,33 @@ describe('Panel', () => {
308
344
  })
309
345
  })
310
346
  })
347
+
348
+ describe('isOpen (always-mounted, hidden-toggle)', () => {
349
+ it('renders hidden (not unmounted) when isOpen is false, so its id stays a real element', () => {
350
+ renderPanel({}, { isOpen: false })
351
+ const panel = document.getElementById('app-panel-settings')
352
+ expect(panel).toBeInTheDocument()
353
+ expect(panel).toHaveAttribute('hidden')
354
+ })
355
+
356
+ it('does not focus a closed panel, even one that would otherwise auto-focus (modal)', () => {
357
+ renderPanel({ desktop: { slot: 'overlay', dismissible: true, modal: true } }, { isOpen: false })
358
+ const panel = document.getElementById('app-panel-settings')
359
+ expect(document.activeElement).not.toBe(panel)
360
+ })
361
+
362
+ it('keeps the same DOM node (no swap/remount) across an isOpen transition, and un-hides it once open', () => {
363
+ const panelConfig = { desktop: { slot: 'side', open: true, dismissible: false, modal: false, showLabel: true } }
364
+ const { rerender } = render(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' isOpen={false} />)
365
+ const panelWhileClosed = document.getElementById('app-panel-settings')
366
+ expect(panelWhileClosed).toHaveAttribute('hidden')
367
+
368
+ rerender(<Panel panelId='Settings' panelConfig={panelConfig} label='Settings' isOpen items={[{ id: 'a', element: <p>Item</p> }]} />)
369
+
370
+ const panelWhileOpen = document.getElementById('app-panel-settings')
371
+ expect(panelWhileOpen).toBe(panelWhileClosed)
372
+ expect(panelWhileOpen).not.toHaveAttribute('hidden')
373
+ expect(screen.getByText('Item')).toBeInTheDocument()
374
+ })
375
+ })
311
376
  })
@@ -21,7 +21,8 @@ const MenuItem = ({ item, isSelected, hiddenButtons, disabledButtons, pressedBut
21
21
  </li>
22
22
  )
23
23
 
24
- export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect }) => {
24
+ // isOpen defaults to true so direct/standalone usage doesn't need to pass it.
25
+ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect, isOpen = true }) => {
25
26
  const { id } = useConfig()
26
27
  const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons, layoutRefs } = useApp()
27
28
  const instigatorKey = buttonId ?? instigatorId
@@ -43,7 +44,8 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
43
44
  id,
44
45
  menuRef,
45
46
  setIsOpen,
46
- buttonRect
47
+ buttonRect,
48
+ isOpen
47
49
  })
48
50
 
49
51
  return createPortal(
@@ -56,11 +58,13 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
56
58
  style={menuStyle}
57
59
  role='menu' // NOSONAR
58
60
  tabIndex='-1'
61
+ hidden={!isOpen}
59
62
  aria-labelledby={instigatorKey}
60
63
  aria-activedescendant={index >= 0 ? `${id}-${stringToKebab(items[index].id)}` : undefined}
61
64
  onKeyDown={handleMenuKeyDown}
62
65
  >
63
- {items.map((item, i) => (
66
+ {/* Items only mount once open — the <ul> above stays in the DOM either way. */}
67
+ {isOpen && items.map((item, i) => (
64
68
  <MenuItem
65
69
  key={item.id}
66
70
  item={item}
@@ -651,4 +651,75 @@ describe('PopupMenu', () => {
651
651
  window.dispatchEvent(new Event('resize'))
652
652
  expect(mockSetIsOpen).toHaveBeenCalledWith(false)
653
653
  })
654
+
655
+ describe('isOpen (always-mounted, hidden-toggle)', () => {
656
+ it('renders a hidden, empty menu when isOpen is false, without grabbing focus or item content', () => {
657
+ renderMenu({ isOpen: false })
658
+ const ul = screen.getByRole('menu', { hidden: true })
659
+ expect(ul).toHaveAttribute('hidden')
660
+ expect(screen.queryByText('Item 1')).not.toBeInTheDocument()
661
+ expect(document.activeElement).not.toBe(ul)
662
+ })
663
+
664
+ it('keeps the same ul node across an isOpen transition (no swap/remount) and un-hides it once open', () => {
665
+ const { rerender } = renderMenu({ isOpen: false })
666
+ const ulWhileClosed = screen.getByRole('menu', { hidden: true })
667
+
668
+ rerender(
669
+ <PopupMenu
670
+ id='menu'
671
+ pluginId='plugin1'
672
+ instigatorId='instigator'
673
+ startPos='first'
674
+ menuRef={menuRef}
675
+ items={items}
676
+ setIsOpen={mockSetIsOpen}
677
+ isOpen
678
+ />
679
+ )
680
+
681
+ const ulWhileOpen = screen.getByRole('menu')
682
+ expect(ulWhileOpen).toBe(ulWhileClosed)
683
+ expect(ulWhileOpen).not.toHaveAttribute('hidden')
684
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
685
+ expect(document.activeElement).toBe(ulWhileOpen)
686
+ })
687
+
688
+ it('resets selection on every open rather than carrying over a stale index from a previous open', () => {
689
+ const { rerender } = renderMenu({ startPos: 'first', startIndex: undefined })
690
+ expectSelected('Item 1')
691
+
692
+ // Close, then reopen via a plain click — selection should reset, not carry over.
693
+ rerender(
694
+ <PopupMenu
695
+ id='menu'
696
+ pluginId='plugin1'
697
+ instigatorId='instigator'
698
+ menuRef={menuRef}
699
+ items={items}
700
+ setIsOpen={mockSetIsOpen}
701
+ isOpen={false}
702
+ />
703
+ )
704
+ rerender(
705
+ <PopupMenu
706
+ id='menu'
707
+ pluginId='plugin1'
708
+ instigatorId='instigator'
709
+ menuRef={menuRef}
710
+ items={items}
711
+ setIsOpen={mockSetIsOpen}
712
+ isOpen
713
+ />
714
+ )
715
+ expectNotSelected('Item 1')
716
+ expectNotSelected('Item 2')
717
+ })
718
+
719
+ it('defaults isOpen to true for direct/standalone usage', () => {
720
+ renderMenu()
721
+ expect(screen.getByRole('menu')).not.toHaveAttribute('hidden')
722
+ expect(screen.getByText('Item 1')).toBeInTheDocument()
723
+ })
724
+ })
654
725
  })
@@ -275,12 +275,14 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa
275
275
  * @param {object} params.menuRef - Ref to the menu UL element.
276
276
  * @param {Function} params.setIsOpen - Callback to open/close the menu.
277
277
  * @param {DOMRect} params.buttonRect - Bounding rect of the trigger button for positioning.
278
+ * @param {boolean} [params.isOpen] - Whether the menu is currently open (PopupMenu stays
279
+ * mounted; this just toggles visibility, so setup/teardown here is keyed on isOpen, not mount).
278
280
  * @returns {{ index: number, handleMenuKeyDown: Function, handleItemClick: Function,
279
281
  * menuStyle: object, menuDirection: string, menuHAlign: string }}
280
282
  */
281
283
  export const usePopupMenu = ({
282
284
  items, hiddenButtons, startIndex, startPos, instigator, instigatorKey,
283
- buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect
285
+ buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect, isOpen
284
286
  }) => {
285
287
  const { dispatch, layoutRefs } = useApp()
286
288
  const viewportRef = layoutRefs.viewportRef
@@ -329,15 +331,14 @@ export const usePopupMenu = ({
329
331
  }
330
332
  }
331
333
 
334
+ // Keyed on isOpen (not mount) since PopupMenu stays mounted; resolveInitialIndex re-runs each
335
+ // open so selection resets instead of carrying over from a previous open.
332
336
  useEffect(() => {
333
- menuRef.current?.focus()
334
- if (startPos === 'first') {
335
- setIndex(visibleIndices[0] ?? -1)
336
- } else if (startPos === 'last') {
337
- setIndex(visibleIndices[visibleIndices.length - 1] ?? -1) // NOSONAR .length - 1 used instead of .at(-1) for wider browser support
338
- } else {
339
- // No action
337
+ if (!isOpen) {
338
+ return undefined
340
339
  }
340
+ menuRef.current?.focus()
341
+ setIndex(resolveInitialIndex(startIndex, startPos, visibleIndices))
341
342
  const handleResize = () => setIsOpen(false)
342
343
  document.addEventListener('focusin', handleOutside)
343
344
  document.addEventListener('pointerdown', handleOutside)
@@ -347,7 +348,7 @@ export const usePopupMenu = ({
347
348
  document.removeEventListener('pointerdown', handleOutside)
348
349
  window.removeEventListener('resize', handleResize)
349
350
  }
350
- }, [])
351
+ }, [isOpen])
351
352
 
352
353
  const { style: menuStyle, direction: menuDirection, halign: menuHAlign } = getMenuStyle(buttonRect)
353
354
  return { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign }
@@ -11,7 +11,7 @@ export const MapStatus = () => {
11
11
  <div // NOSONAR: div + status role is semantically correct here, <output> implies a calculation result
12
12
  ref={mapStatusRef}
13
13
  role='status'
14
- className='im-c-viewport__status'
14
+ className='im-u-visually-hidden'
15
15
  />
16
16
  )
17
17
  }
@@ -0,0 +1,47 @@
1
+ import React, { forwardRef } from 'react'
2
+ import { useConfig } from '../../store/configContext.js'
3
+
4
+ // Accessible spatial list: a listbox whose items are positioned to match wherever they are on
5
+ // the map, navigable both sequentially (Arrow/Home/End) and spatially (Alt+Arrow — see
6
+ // useSpatialListFocus.js). Content is whatever the current spatialListRegistry provider
7
+ // contributes; `label` names it for aria-label accordingly.
8
+ export const SpatialList = forwardRef(({ activeItemId, tabbableId, selectedIds = [], multiselectable = false, items = [], label = 'Map features', focusable = true, onFocus, onBlur, onSelectItem }, ref) => {
9
+ const { id } = useConfig()
10
+ const hasItems = items.length > 0
11
+ // Roving tabindex: exactly one option is a Tab stop at a time — see useSpatialListFocus.js.
12
+ const currentId = activeItemId ?? tabbableId
13
+ return (
14
+ <ul // NOSONAR: role='listbox' is correct for custom composite widget; native <select> cannot host SVG marker elements
15
+ id={`${id}-spatial-list`}
16
+ ref={ref}
17
+ role='listbox' // NOSONAR
18
+ aria-hidden={hasItems ? undefined : true}
19
+ aria-label={label}
20
+ aria-describedby={`${id}-keyboard-desc`}
21
+ aria-multiselectable={multiselectable || undefined}
22
+ className='im-c-spatial-list'
23
+ onFocus={onFocus}
24
+ onBlur={onBlur}
25
+ >
26
+ {items.map(item => (
27
+ <li // NOSONAR: role='option' overrides implicit listitem; this is the correct ARIA listbox child pattern.
28
+ // tabIndex/onClick let voice control and screen readers reach and activate each option
29
+ // directly. focusable: false (draw's vertex/midpoint items) keeps every item out of
30
+ // the Tab order — that population already has a full keyboard path via the map itself.
31
+ key={item.id} id={`${id}-spatial-list-item-${item.id}`} role='option' // NOSONAR
32
+ data-id={item.id}
33
+ tabIndex={focusable && item.id === currentId ? 0 : -1}
34
+ aria-selected={selectedIds.includes(item.id)}
35
+ // Positions the (visually clipped) option over its on-map feature, so AT overlays like
36
+ // Voice Control's "Show Numbers" number it at the right screen position.
37
+ style={item.x != null ? { left: item.x, top: item.y } : undefined}
38
+ onClick={() => onSelectItem?.(item.id)}
39
+ >
40
+ {item.label}
41
+ </li>
42
+ ))}
43
+ </ul>
44
+ )
45
+ })
46
+
47
+ SpatialList.displayName = 'SpatialList'
@@ -1,10 +1,10 @@
1
1
  @use '../../../scss/tools/index' as tools;
2
2
 
3
3
  // ===================================================
4
- // Component: Features
4
+ // Component: SpatialList
5
5
  // ===================================================
6
6
 
7
- .im-c-features {
7
+ .im-c-spatial-list {
8
8
  position: absolute;
9
9
  inset: 0;
10
10
  pointer-events: none;
@@ -16,10 +16,6 @@
16
16
  $is-inset: true
17
17
  );
18
18
 
19
- &[data-focus-visible="true"][aria-activedescendant]::after {
20
- opacity: 0;
21
- }
22
-
23
19
  [role="option"] {
24
20
  position: absolute;
25
21
  width: 1px;
@@ -27,5 +23,9 @@
27
23
  overflow: hidden;
28
24
  clip-path: inset(50%);
29
25
  white-space: nowrap;
26
+ // Roving tabindex moves real focus (and click activation) onto individual options, so they
27
+ // need pointer events re-enabled — overridden off .im-c-spatial-list's pointer-events: none,
28
+ // which otherwise lets mouse interaction pass through to the map everywhere else.
29
+ pointer-events: auto;
30
30
  }
31
31
  }