@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
@@ -0,0 +1,218 @@
1
+ import { createSpatialListRegistry } from './spatialListRegistry.js'
2
+
3
+ const SET_FEATURES = 'map:setspatiallist'
4
+
5
+ const makeEventBus = () => ({ emit: jest.fn() })
6
+
7
+ const lastEmit = (eventBus) => eventBus.emit.mock.calls.at(-1)[1]
8
+
9
+ describe('createSpatialListRegistry', () => {
10
+ test('registering a provider recomputes and emits its items immediately', () => {
11
+ const eventBus = makeEventBus()
12
+ const registry = createSpatialListRegistry({ eventBus })
13
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }], multiselectable: false }) })
14
+ expect(eventBus.emit).toHaveBeenCalledWith(SET_FEATURES, { items: [{ id: '1' }], multiselectable: false })
15
+ })
16
+
17
+ test('concatenates items from every registered provider when no exclusive claim is held', () => {
18
+ const eventBus = makeEventBus()
19
+ const registry = createSpatialListRegistry({ eventBus })
20
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
21
+ registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
22
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: '1' }, { id: '2' }], multiselectable: false })
23
+ })
24
+
25
+ test('multiselectable is true if any additive provider wants it', () => {
26
+ const eventBus = makeEventBus()
27
+ const registry = createSpatialListRegistry({ eventBus })
28
+ registry.registerItemProvider('a', { getItems: () => ({ items: [], multiselectable: false }) })
29
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], multiselectable: true }) })
30
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: true })
31
+ })
32
+
33
+ test('getItems is called fresh on every recompute, not snapshotted at registration', () => {
34
+ const eventBus = makeEventBus()
35
+ const registry = createSpatialListRegistry({ eventBus })
36
+ let current = [{ id: 'first' }]
37
+ registry.registerItemProvider('a', { getItems: () => ({ items: current }) })
38
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'first' }], multiselectable: false })
39
+
40
+ current = [{ id: 'second' }]
41
+ registry.notifyItemsChanged('a')
42
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'second' }], multiselectable: false })
43
+ })
44
+
45
+ test('unregistering a provider removes its items from the next recompute', () => {
46
+ const eventBus = makeEventBus()
47
+ const registry = createSpatialListRegistry({ eventBus })
48
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }) })
49
+ registry.registerItemProvider('b', { getItems: () => ({ items: [{ id: '2' }] }) })
50
+ registry.unregisterItemProvider('a')
51
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: '2' }], multiselectable: false })
52
+ })
53
+
54
+ test('notifyItemsChanged for an unregistered id is a no-op', () => {
55
+ const eventBus = makeEventBus()
56
+ const registry = createSpatialListRegistry({ eventBus })
57
+ registry.notifyItemsChanged('ghost')
58
+ expect(eventBus.emit).not.toHaveBeenCalled()
59
+ })
60
+
61
+ describe('exclusive claims', () => {
62
+ test('claiming exclusive ownership excludes every other provider\'s items', () => {
63
+ const eventBus = makeEventBus()
64
+ const registry = createSpatialListRegistry({ eventBus })
65
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
66
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
67
+
68
+ registry.claimExclusive('draw')
69
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'vertex-1' }], multiselectable: false })
70
+ })
71
+
72
+ test('releasing the exclusive claim reverts to the additive merge of everyone else', () => {
73
+ const eventBus = makeEventBus()
74
+ const registry = createSpatialListRegistry({ eventBus })
75
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
76
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
77
+ registry.claimExclusive('draw')
78
+
79
+ registry.releaseExclusive('draw')
80
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
81
+ })
82
+
83
+ test('releaseExclusive from a provider that does not currently hold the claim is a no-op', () => {
84
+ const eventBus = makeEventBus()
85
+ const registry = createSpatialListRegistry({ eventBus })
86
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
87
+ eventBus.emit.mockClear()
88
+
89
+ registry.releaseExclusive('draw') // never claimed it
90
+ expect(eventBus.emit).not.toHaveBeenCalled()
91
+ })
92
+
93
+ test('a non-exclusive-owning provider\'s own changes are ignored while someone else holds the claim', () => {
94
+ const eventBus = makeEventBus()
95
+ const registry = createSpatialListRegistry({ eventBus })
96
+ let interactItems = [{ id: 'feature-1' }]
97
+ registry.registerItemProvider('interact', { getItems: () => ({ items: interactItems }) })
98
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
99
+ registry.claimExclusive('draw')
100
+ eventBus.emit.mockClear()
101
+
102
+ interactItems = [{ id: 'feature-2' }] // changed while draw holds exclusive ownership
103
+ registry.notifyItemsChanged('interact')
104
+ expect(eventBus.emit).not.toHaveBeenCalled()
105
+ })
106
+
107
+ test('unregistering the current exclusive owner clears the claim and reverts to additive', () => {
108
+ const eventBus = makeEventBus()
109
+ const registry = createSpatialListRegistry({ eventBus })
110
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [{ id: 'feature-1' }] }) })
111
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [{ id: 'vertex-1' }] }), exclusive: true })
112
+ registry.claimExclusive('draw')
113
+
114
+ registry.unregisterItemProvider('draw')
115
+ expect(lastEmit(eventBus)).toEqual({ items: [{ id: 'feature-1' }], multiselectable: false })
116
+ })
117
+ })
118
+
119
+ describe('label', () => {
120
+ test('passes through the exclusive claimant\'s own label while its claim is held', () => {
121
+ const eventBus = makeEventBus()
122
+ const registry = createSpatialListRegistry({ eventBus })
123
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [], label: 'Map features' }) })
124
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [], label: 'Shape points' }), exclusive: true })
125
+
126
+ registry.claimExclusive('draw')
127
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Shape points' })
128
+ })
129
+
130
+ test('uses the first additive provider\'s label (in registration order) when more than one contributes', () => {
131
+ const eventBus = makeEventBus()
132
+ const registry = createSpatialListRegistry({ eventBus })
133
+ registry.registerItemProvider('a', { getItems: () => ({ items: [], label: 'First' }) })
134
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
135
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'First' })
136
+ })
137
+
138
+ test('falls back to a later provider\'s label when an earlier one declares none', () => {
139
+ const eventBus = makeEventBus()
140
+ const registry = createSpatialListRegistry({ eventBus })
141
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no label
142
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], label: 'Second' }) })
143
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, label: 'Second' })
144
+ })
145
+
146
+ test('label is undefined when nothing is registered', () => {
147
+ const eventBus = makeEventBus()
148
+ const registry = createSpatialListRegistry({ eventBus })
149
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
150
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
151
+ })
152
+ })
153
+
154
+ describe('focusable', () => {
155
+ test('passes through the exclusive claimant\'s own focusable: false while its claim is held', () => {
156
+ const eventBus = makeEventBus()
157
+ const registry = createSpatialListRegistry({ eventBus })
158
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) }) // focusable defaults true
159
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
160
+
161
+ registry.claimExclusive('draw')
162
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
163
+ })
164
+
165
+ test('reverts to undefined (consumer default: focusable) once the claim is released', () => {
166
+ const eventBus = makeEventBus()
167
+ const registry = createSpatialListRegistry({ eventBus })
168
+ registry.registerItemProvider('interact', { getItems: () => ({ items: [] }) })
169
+ registry.registerItemProvider('draw', { getItems: () => ({ items: [], focusable: false }), exclusive: true })
170
+ registry.claimExclusive('draw')
171
+
172
+ registry.releaseExclusive('draw')
173
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
174
+ })
175
+
176
+ test('uses the first additive provider\'s explicit focusable value when more than one contributes', () => {
177
+ const eventBus = makeEventBus()
178
+ const registry = createSpatialListRegistry({ eventBus })
179
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) }) // no explicit value
180
+ registry.registerItemProvider('b', { getItems: () => ({ items: [], focusable: false }) })
181
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false, focusable: false })
182
+ })
183
+
184
+ test('focusable is undefined when nothing declares it', () => {
185
+ const eventBus = makeEventBus()
186
+ const registry = createSpatialListRegistry({ eventBus })
187
+ registry.registerItemProvider('a', { getItems: () => ({ items: [] }) })
188
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
189
+ })
190
+ })
191
+
192
+ test('clear removes every provider and any exclusive claim', () => {
193
+ const eventBus = makeEventBus()
194
+ const registry = createSpatialListRegistry({ eventBus })
195
+ registry.registerItemProvider('a', { getItems: () => ({ items: [{ id: '1' }] }), exclusive: true })
196
+ registry.claimExclusive('a')
197
+
198
+ registry.clear()
199
+ eventBus.emit.mockClear()
200
+ registry.notifyItemsChanged('a') // 'a' is no longer registered
201
+ expect(eventBus.emit).not.toHaveBeenCalled()
202
+ })
203
+
204
+ test('a provider whose getItems returns nothing contributes no items and does not throw', () => {
205
+ const eventBus = makeEventBus()
206
+ const registry = createSpatialListRegistry({ eventBus })
207
+ expect(() => registry.registerItemProvider('a', { getItems: () => undefined })).not.toThrow()
208
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
209
+ })
210
+
211
+ test('an exclusive provider whose getItems returns nothing contributes no items while holding the claim', () => {
212
+ const eventBus = makeEventBus()
213
+ const registry = createSpatialListRegistry({ eventBus })
214
+ registry.registerItemProvider('draw', { getItems: () => undefined, exclusive: true })
215
+ expect(() => registry.claimExclusive('draw')).not.toThrow()
216
+ expect(lastEmit(eventBus)).toEqual({ items: [], multiselectable: false })
217
+ })
218
+ })
@@ -2,8 +2,7 @@
2
2
  import React, { useRef, useLayoutEffect, useMemo } from 'react'
3
3
  import { useApp } from '../store/appContext.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
- import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
6
- import { allowedSlots } from './slots.js'
5
+ import { resolveTargetSlot, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js'
7
6
 
8
7
  /**
9
8
  * Maps slot names to their corresponding layout refs.
@@ -90,28 +89,13 @@ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, focusOnOpen,
90
89
  const bpConfig = config[breakpoint]
91
90
  const targetSlot = bpConfig ? resolveTargetSlot(bpConfig, breakpoint) : null
92
91
 
93
- // Determine visibility using the same logic as mapPanels
94
- const isVisible = (() => {
95
- // 1. Initial Guard: Basic requirements
96
- if (!isOpen || !bpConfig || !targetSlot) {
97
- return false
98
- }
99
-
100
- // 2. Slot Validation
101
- const isNextToButton = targetSlot.endsWith('-button')
102
- const isSlotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
103
-
104
- if (!isSlotAllowed) {
105
- return false
106
- }
107
-
108
- // 3. Business Logic: Combine remaining conditions into a single "fail" check
109
- const isForbiddenModal = bpConfig.modal && panelId !== allowedModalPanelId
110
- const isForbiddenInline = config.inline === false && !isFullscreen
111
- const isInvalidMode = !isModeAllowed(config, mode)
112
-
113
- return !(isForbiddenModal || isForbiddenInline || isInvalidMode)
114
- })()
92
+ // Same eligibility/modal-exclusivity rules as mapPanels.js (see slotHelpers.js), combined into
93
+ // one boolean since isVisible alone decides whether useDomProjection shows it.
94
+ const isVisible = Boolean(
95
+ isOpen && bpConfig && targetSlot &&
96
+ isPanelSlotEligible(config, { targetSlot, mode, isFullscreen }) &&
97
+ (!bpConfig.modal || panelId === allowedModalPanelId)
98
+ )
115
99
 
116
100
  useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint)
117
101
 
@@ -181,14 +165,10 @@ export const HtmlElementHost = () => {
181
165
  )
182
166
 
183
167
  // Determine which modal panel is allowed (topmost open modal)
184
- const allowedModalPanelId = useMemo(() => {
185
- const openPanelEntries = Object.entries(openPanels)
186
- const modalPanels = openPanelEntries.filter(([panelId]) => {
187
- const cfg = panelConfig[panelId]?.[breakpoint]
188
- return cfg?.modal
189
- })
190
- return modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR - .at() unsupported on Chrome < 92
191
- }, [openPanels, panelConfig, breakpoint])
168
+ const allowedModalPanelId = useMemo(
169
+ () => getAllowedModalPanelId(openPanels, panelConfig, breakpoint),
170
+ [openPanels, panelConfig, breakpoint]
171
+ )
192
172
 
193
173
  if (!htmlPanels.length && !htmlControls.length) {
194
174
  return null
@@ -3,6 +3,7 @@ import { MapButton } from '../components/MapButton/MapButton.jsx'
3
3
  import { allowedSlots } from './slots.js'
4
4
  import { groupByKey } from './groupByKey.js'
5
5
  import { orderItems } from './orderItems.js'
6
+ import { classifyPanel, getPanelRole } from '../../utils/getPanelRole.js'
6
7
  import { logger } from '../../services/logger.js'
7
8
 
8
9
  function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
@@ -118,6 +119,8 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
118
119
  const bpConfig = config[appState.breakpoint] ?? {}
119
120
  const handleClick = createButtonClickHandler(config, appState, evaluateProp)
120
121
  const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
122
+ const panelBpConfig = config.panelId ? appState.panelConfig?.[config.panelId]?.[appState.breakpoint] : undefined
123
+ const panelRole = panelBpConfig ? getPanelRole(classifyPanel(panelBpConfig)) : undefined
121
124
 
122
125
  return (
123
126
  <MapButton
@@ -133,6 +136,7 @@ function SlotButton ({ buttonId, config, appState, appConfig, evaluateProp }) {
133
136
  isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined}
134
137
  isExpanded={(config.isExpanded !== undefined || config.expandedWhen) ? appState.expandedButtons.has(buttonId) : undefined}
135
138
  isPanelOpen={isPanelOpen}
139
+ panelRole={panelRole}
136
140
  onClick={handleClick}
137
141
  panelId={config.panelId}
138
142
  menuItems={config.menuItems}
@@ -170,6 +170,26 @@ describe('mapButtons module', () => {
170
170
  expect(result.props).toMatchObject({ isDisabled: true, isHidden: true, isPressed: true, isExpanded: true })
171
171
  })
172
172
 
173
+ it.each([
174
+ [{ slot: 'right-top', open: false }, 'dialog'],
175
+ [{ slot: 'right-top', open: false, dismissible: false }, 'region'],
176
+ [{ slot: 'side', open: true }, 'complementary']
177
+ ])('derives panelRole %j -> %s from the target panel\'s breakpoint config', (panelBpConfig, expectedRole) => {
178
+ const state = { ...appState, panelConfig: { p1: { desktop: panelBpConfig } } }
179
+ const result = render({ ...baseBtn, panelId: 'p1' }, state)
180
+ expect(result.props.panelRole).toBe(expectedRole)
181
+ })
182
+
183
+ it('leaves panelRole undefined when the button has no panelId', () => {
184
+ const result = render(baseBtn)
185
+ expect(result.props.panelRole).toBeUndefined()
186
+ })
187
+
188
+ it('leaves panelRole undefined when the target panel has no config at this breakpoint', () => {
189
+ const result = render({ ...baseBtn, panelId: 'missing' })
190
+ expect(result.props.panelRole).toBeUndefined()
191
+ })
192
+
173
193
  it('uses empty object fallback for missing breakpoint config', () => {
174
194
  const result = render(baseBtn, { ...appState, breakpoint: 'mobile' })
175
195
  expect(result.props.showLabel).toBe(true)
@@ -2,8 +2,7 @@
2
2
  import React from 'react'
3
3
  import { withPluginContexts } from './pluginWrapper.js'
4
4
  import { Panel } from '../components/Panel/Panel.jsx'
5
- import { allowedSlots } from './slots.js'
6
- import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js'
5
+ import { resolveTargetSlot, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId } from './slotHelpers.js'
7
6
  import { mapControls } from './mapControls.js'
8
7
  import { orderItems } from './orderItems.js'
9
8
  import { groupIntoTabs } from './groupIntoTabs.js'
@@ -11,69 +10,49 @@ import { stringToKebab } from '../../utils/stringToKebab.js'
11
10
  import { logger } from '../../services/logger.js'
12
11
 
13
12
  /**
14
- * Determines whether a panel should be rendered in the given slot.
15
- * Checks slot eligibility, mode restrictions, inline/fullscreen constraints,
16
- * and ensures only the topmost modal panel is shown.
13
+ * Maps every configured panel eligible for the given layout slot to a renderable entry — open or
14
+ * closed alike, so a button's aria-controls id always resolves to a stable, permanently-mounted
15
+ * <Panel> (hidden when closed). buildPanelBody only runs while open, so nothing expensive mounts
16
+ * before then.
17
17
  */
18
- const isPanelVisible = (panelId, config, bpConfig, { targetSlot, slot, mode, isFullscreen, allowedModalPanelId }) => {
19
- const isNextToButton = targetSlot.endsWith('-button')
20
- if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
21
- return false
18
+ // Consumer HTML panels are managed by HtmlElementHost; the rest need a breakpoint config and to
19
+ // be eligible for this slot. Returns null (skip) or the pieces the caller needs.
20
+ const getEligiblePanelConfig = (panelId, panelConfig, breakpoint, { slot, mode, isFullscreen }) => {
21
+ const config = panelConfig[panelId]
22
+ if (!config || isConsumerHtml(config)) {
23
+ return null
22
24
  }
23
- if (!isModeAllowed(config, mode)) {
24
- return false
25
+ const bpConfig = config[breakpoint]
26
+ if (!bpConfig) {
27
+ return null
25
28
  }
26
- if (config.inline === false && !isFullscreen) {
27
- return false
29
+ const targetSlot = resolveTargetSlot(bpConfig, breakpoint)
30
+ if (!isPanelSlotEligible(config, { targetSlot, slot, mode, isFullscreen })) {
31
+ return null
28
32
  }
29
- if (targetSlot !== slot) {
30
- return false
31
- }
32
- if (bpConfig.modal && panelId !== allowedModalPanelId) {
33
- return false
34
- }
35
- return true
33
+ return { config, bpConfig }
36
34
  }
37
35
 
38
- /**
39
- * Maps open panels to renderable entries for a given layout slot.
40
- * Filters panels by slot, breakpoint, mode, and modal state, then wraps
41
- * each panel's render function with the appropriate plugin contexts.
42
- */
36
+ // A losing modal panel (see getAllowedModalPanelId) still gets a shell — it's just not open.
37
+ const resolveIsOpen = (openEntry, bpConfig, panelId, allowedModalPanelId) =>
38
+ Boolean(openEntry) && (!bpConfig.modal || panelId === allowedModalPanelId)
39
+
43
40
  export function mapPanels ({ slot, appState, evaluateProp }) {
44
41
  const { breakpoint, pluginRegistry, panelConfig, mode, openPanels } = appState
45
42
 
46
- const openPanelEntries = Object.entries(openPanels)
47
-
48
- const modalPanels = openPanelEntries.filter(([panelId]) => {
49
- const cfg = panelConfig[panelId]?.[breakpoint]
50
- return cfg?.modal
51
- })
52
- const allowedModalPanelId = modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null // NOSONAR, .at() is only Chrome 90+
53
-
54
- return openPanelEntries.map(([panelId, { props, focusOnOpen }]) => {
55
- const config = panelConfig[panelId]
56
- if (!config) {
57
- return null
58
- }
59
-
60
- // Consumer HTML panels are managed by HtmlElementHost
61
- if (isConsumerHtml(config)) {
62
- return null
63
- }
43
+ // Only the most-recently-opened modal panel is ever actually shown — see isOpen below.
44
+ const allowedModalPanelId = getAllowedModalPanelId(openPanels, panelConfig, breakpoint)
64
45
 
65
- const bpConfig = config[breakpoint]
66
- if (!bpConfig) {
46
+ return Object.keys(panelConfig).map((panelId) => {
47
+ const eligible = getEligiblePanelConfig(panelId, panelConfig, breakpoint, { slot, mode, isFullscreen: appState.isFullscreen })
48
+ if (!eligible) {
67
49
  return null
68
50
  }
51
+ const { config, bpConfig } = eligible
69
52
 
70
- const targetSlot = resolveTargetSlot(bpConfig, breakpoint)
71
-
72
- if (!isPanelVisible(panelId, config, bpConfig, {
73
- targetSlot, slot, mode, isFullscreen: appState.isFullscreen, allowedModalPanelId
74
- })) {
75
- return null
76
- }
53
+ const openEntry = openPanels[panelId]
54
+ const isOpen = resolveIsOpen(openEntry, bpConfig, panelId, allowedModalPanelId)
55
+ const { props = {}, focusOnOpen } = openEntry ?? {}
77
56
 
78
57
  const plugin = pluginRegistry.registeredPlugins.find(p => p.id === config.pluginId)
79
58
  const pluginId = plugin?.id
@@ -92,7 +71,8 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
92
71
  panelConfig={config}
93
72
  props={props}
94
73
  focusOnOpen={focusOnOpen}
95
- {...buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp })}
74
+ isOpen={isOpen}
75
+ {...(isOpen ? buildPanelBody({ panelId, config, bpConfig, props, plugin, pluginId, html, label, appState, evaluateProp }) : {})}
96
76
  label={label}
97
77
  html={html}
98
78
  />
@@ -40,8 +40,25 @@ describe('mapPanels', () => {
40
40
  defaultAppState.panelConfig = ({ p1: baseConfig })
41
41
  })
42
42
 
43
- it('returns empty array when no panels are open', () => {
44
- expect(map({ ...defaultAppState, openPanels: {} })).toEqual([])
43
+ it('renders a closed shell (not nothing) for an eligible panel that is not open', () => {
44
+ const result = map({ ...defaultAppState, openPanels: {} })
45
+ expect(result).toHaveLength(1)
46
+ expect(result[0]).toMatchObject({ id: 'p1', type: 'panel', order: 1 })
47
+ expect(result[0].element.props.isOpen).toBe(false)
48
+ })
49
+
50
+ it('does not build a closed panel\'s body before it opens', () => {
51
+ const renderFn = jest.fn(() => <div>child</div>)
52
+ defaultAppState.panelConfig = ({ p1: { ...baseConfig, render: renderFn } })
53
+ const result = map({ ...defaultAppState, openPanels: {} })
54
+ expect(mapControls).not.toHaveBeenCalled()
55
+ expect(withPluginContexts).not.toHaveBeenCalled()
56
+ expect(result[0].element.props.items).toBeUndefined()
57
+ expect(result[0].element.props.tabs).toBeUndefined()
58
+ })
59
+
60
+ it('returns an empty array only when there is nothing eligible for the slot at all', () => {
61
+ expect(map({ ...defaultAppState, panelConfig: {} })).toEqual([])
45
62
  })
46
63
 
47
64
  it('skips panel if config is missing', () => {
@@ -49,6 +66,11 @@ describe('mapPanels', () => {
49
66
  expect(map()).toEqual([])
50
67
  })
51
68
 
69
+ it('skips a registered panelId whose config is falsy', () => {
70
+ defaultAppState.panelConfig = ({ p1: null })
71
+ expect(map()).toEqual([])
72
+ })
73
+
52
74
  it('skips panel if breakpoint config is missing', () => {
53
75
  defaultAppState.panelConfig = ({ p1: {} })
54
76
  expect(map()).toEqual([])
@@ -92,7 +114,7 @@ describe('mapPanels', () => {
92
114
  expect(result).toEqual([])
93
115
  })
94
116
 
95
- it('only allows last opened modal panel', () => {
117
+ it('renders both modal panels\' shells but only marks the last-opened one as open', () => {
96
118
  defaultAppState.panelConfig = ({
97
119
  p1: { desktop: { modal: true }, includeModes: ['view'] },
98
120
  p2: { desktop: { modal: true }, includeModes: ['view'] }
@@ -101,7 +123,10 @@ describe('mapPanels', () => {
101
123
  ...defaultAppState,
102
124
  openPanels: { p1: { props: {} }, p2: { props: {} } }
103
125
  }
104
- expect(map(state, 'modal').map(r => r.id)).toEqual(['p2'])
126
+ const result = map(state, 'modal')
127
+ expect(result.map(r => r.id).sort()).toEqual(['p1', 'p2'])
128
+ expect(result.find(r => r.id === 'p1').element.props.isOpen).toBe(false)
129
+ expect(result.find(r => r.id === 'p2').element.props.isOpen).toBe(true)
105
130
  })
106
131
 
107
132
  it('wraps render function with plugin context', () => {
@@ -60,3 +60,36 @@ export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) =>
60
60
  export const isConsumerHtml = (config) => {
61
61
  return typeof config.html === 'string' && !config.pluginId
62
62
  }
63
+
64
+ /**
65
+ * Whether a panel is eligible for a target slot (slot type, mode, inline/fullscreen) —
66
+ * independent of open state and modal exclusivity (see getAllowedModalPanelId). `slot`, if
67
+ * given, also requires an exact match to the panel's resolved targetSlot.
68
+ */
69
+ export const isPanelSlotEligible = (config, { targetSlot, slot, mode, isFullscreen }) => {
70
+ const isNextToButton = targetSlot.endsWith('-button')
71
+ if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
72
+ return false
73
+ }
74
+ if (!isModeAllowed(config, mode)) {
75
+ return false
76
+ }
77
+ if (config.inline === false && !isFullscreen) {
78
+ return false
79
+ }
80
+ if (slot !== undefined && targetSlot !== slot) {
81
+ return false
82
+ }
83
+ return true
84
+ }
85
+
86
+ // The id of the most-recently-opened modal panel — the only one actually allowed to show.
87
+ export const getAllowedModalPanelId = (openPanels, panelConfig, breakpoint) => {
88
+ const openModalPanelIds = Object.keys(openPanels).filter(panelId => panelConfig[panelId]?.[breakpoint]?.modal)
89
+ return openModalPanelIds.length > 0 ? openModalPanelIds[openModalPanelIds.length - 1] : null // NOSONAR, .at() is only Chrome 90+
90
+ }
91
+
92
+ // Whether any modal-configured panel is open — drives the modal backdrop's visibility.
93
+ export const hasOpenModalPanel = (openPanels, panelConfig, breakpoint) => {
94
+ return getAllowedModalPanelId(openPanels, panelConfig, breakpoint) !== null
95
+ }
@@ -1,6 +1,6 @@
1
- import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
1
+ import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml, isPanelSlotEligible, getAllowedModalPanelId, hasOpenModalPanel } from './slotHelpers.js'
2
2
 
3
- jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'] } }))
3
+ jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'], panel: ['header', 'modal', 'left-top'] } }))
4
4
 
5
5
  describe('resolveTargetSlot', () => {
6
6
  it('returns modal for modal panels', () => {
@@ -85,3 +85,80 @@ describe('isConsumerHtml', () => {
85
85
  expect(isConsumerHtml({ render: () => {} })).toBe(false)
86
86
  })
87
87
  })
88
+
89
+ describe('isPanelSlotEligible', () => {
90
+ const base = { includeModes: ['view'] }
91
+ const ctx = { targetSlot: 'header', mode: 'view', isFullscreen: false }
92
+
93
+ it('returns true for a panel in an allowed slot', () => {
94
+ expect(isPanelSlotEligible(base, ctx)).toBe(true)
95
+ })
96
+
97
+ it('returns false when the target slot is not an allowed panel slot', () => {
98
+ expect(isPanelSlotEligible(base, { ...ctx, targetSlot: 'invalid' })).toBe(false)
99
+ })
100
+
101
+ it('allows a target slot next to a button even though it is not a named panel slot', () => {
102
+ expect(isPanelSlotEligible(base, { ...ctx, targetSlot: 'my-button-button' })).toBe(true)
103
+ })
104
+
105
+ it('returns false when mode is not allowed', () => {
106
+ expect(isPanelSlotEligible({ includeModes: ['edit'] }, ctx)).toBe(false)
107
+ })
108
+
109
+ it('returns false when inline:false and not fullscreen', () => {
110
+ expect(isPanelSlotEligible({ ...base, inline: false }, ctx)).toBe(false)
111
+ })
112
+
113
+ it('returns true when inline:false and fullscreen', () => {
114
+ expect(isPanelSlotEligible({ ...base, inline: false }, { ...ctx, isFullscreen: true })).toBe(true)
115
+ })
116
+
117
+ it('returns false when a specific requested slot is given and the target slot does not match it', () => {
118
+ expect(isPanelSlotEligible(base, { ...ctx, slot: 'left-top' })).toBe(false)
119
+ })
120
+
121
+ it('returns true when a specific requested slot is given and matches the target slot', () => {
122
+ expect(isPanelSlotEligible(base, { ...ctx, slot: 'header' })).toBe(true)
123
+ })
124
+
125
+ it('skips the requested-slot check entirely when slot is omitted (HtmlElementHost usage)', () => {
126
+ expect(isPanelSlotEligible(base, { targetSlot: 'header', mode: 'view', isFullscreen: false })).toBe(true)
127
+ })
128
+ })
129
+
130
+ describe('getAllowedModalPanelId', () => {
131
+ const panelConfig = {
132
+ p1: { desktop: { modal: true } },
133
+ p2: { desktop: { modal: true } },
134
+ p3: { desktop: {} }
135
+ }
136
+
137
+ it('returns null when no modal panel is open', () => {
138
+ expect(getAllowedModalPanelId({ p3: { props: {} } }, panelConfig, 'desktop')).toBeNull()
139
+ })
140
+
141
+ it('returns the only open modal panel', () => {
142
+ expect(getAllowedModalPanelId({ p1: { props: {} } }, panelConfig, 'desktop')).toBe('p1')
143
+ })
144
+
145
+ it('returns the most recently opened modal panel when more than one is open', () => {
146
+ expect(getAllowedModalPanelId({ p1: { props: {} }, p2: { props: {} } }, panelConfig, 'desktop')).toBe('p2')
147
+ })
148
+
149
+ it('returns null when there are no open panels at all', () => {
150
+ expect(getAllowedModalPanelId({}, panelConfig, 'desktop')).toBeNull()
151
+ })
152
+ })
153
+
154
+ describe('hasOpenModalPanel', () => {
155
+ const panelConfig = { p1: { desktop: { modal: true } }, p2: { desktop: {} } }
156
+
157
+ it('returns false when no modal panel is open', () => {
158
+ expect(hasOpenModalPanel({ p2: { props: {} } }, panelConfig, 'desktop')).toBe(false)
159
+ })
160
+
161
+ it('returns true when a modal panel is open', () => {
162
+ expect(hasOpenModalPanel({ p1: { props: {} } }, panelConfig, 'desktop')).toBe(true)
163
+ })
164
+ })