@defra/interactive-map 0.0.4-alpha → 0.0.6-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 (203) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/index.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api.md +8 -0
  7. package/package.json +3 -2
  8. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +2 -1
  9. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js.LICENSE.txt +1 -0
  10. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +2 -1
  11. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js.LICENSE.txt +1 -0
  12. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  13. package/plugins/beta/datasets/src/datasets.js +55 -2
  14. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +206 -0
  15. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +38 -0
  16. package/plugins/beta/datasets/src/handleSetMapStyle.js +8 -1
  17. package/plugins/beta/datasets/src/mapLayers.js +41 -3
  18. package/plugins/beta/datasets/src/utils/bbox.js +113 -2
  19. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  20. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  21. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  22. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  23. package/plugins/beta/draw-ml/src/api/addFeature.js +15 -3
  24. package/plugins/beta/draw-ml/src/api/editFeature.js +16 -5
  25. package/plugins/beta/draw-ml/src/api/newLine.js +32 -7
  26. package/plugins/beta/draw-ml/src/api/newPolygon.js +33 -8
  27. package/plugins/beta/draw-ml/src/defaults.js +1 -0
  28. package/plugins/beta/draw-ml/src/draw.scss +23 -0
  29. package/plugins/beta/draw-ml/src/events.js +55 -113
  30. package/plugins/beta/draw-ml/src/manifest.js +64 -38
  31. package/plugins/beta/draw-ml/src/mapboxSnap.js +16 -12
  32. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +4 -3
  33. package/plugins/beta/draw-ml/src/modes/editVertex/geometryHelpers.js +135 -0
  34. package/plugins/beta/draw-ml/src/modes/editVertex/helpers.js +2 -0
  35. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +134 -0
  36. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +133 -0
  37. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +141 -0
  38. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +121 -0
  39. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +51 -406
  40. package/plugins/beta/draw-ml/src/utils/flattenStyleProperties.js +49 -0
  41. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  42. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  43. package/plugins/beta/frame/src/Frame.jsx +1 -1
  44. package/plugins/beta/map-styles/dist/css/index.css +1 -1
  45. package/plugins/beta/map-styles/dist/esm/index.js +1 -1
  46. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  47. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  48. package/plugins/beta/map-styles/src/mapStyles.scss +4 -1
  49. package/plugins/interact/dist/css/index.css +1 -1
  50. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/index.js +1 -1
  53. package/plugins/interact/src/InteractInit.jsx +1 -1
  54. package/plugins/interact/src/defaults.js +1 -0
  55. package/plugins/interact/src/events.js +37 -52
  56. package/plugins/interact/src/events.test.js +35 -0
  57. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  58. package/plugins/interact/src/hooks/useInteractionHandlers.js +80 -48
  59. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +64 -8
  60. package/plugins/interact/src/interact.scss +6 -0
  61. package/plugins/interact/src/reducer.js +25 -27
  62. package/plugins/interact/src/reducer.test.js +19 -6
  63. package/plugins/interact/src/utils/featureQueries.js +2 -2
  64. package/plugins/interact/src/utils/spatial.js +99 -0
  65. package/plugins/interact/src/utils/spatial.test.js +100 -0
  66. package/plugins/search/dist/css/index.css +1 -1
  67. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  68. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  69. package/plugins/search/dist/umd/index.js +1 -1
  70. package/plugins/search/src/Search.jsx +2 -2
  71. package/plugins/search/src/components/Form/Form.jsx +1 -1
  72. package/plugins/search/src/components/Suggestions/Suggestions.jsx +5 -5
  73. package/plugins/search/src/datasets.js +1 -1
  74. package/plugins/search/src/defaults.js +3 -0
  75. package/plugins/search/src/events/fetchSuggestions.js +48 -53
  76. package/plugins/search/src/events/formHandlers.js +3 -2
  77. package/plugins/search/src/events/index.js +1 -1
  78. package/plugins/search/src/events/inputHandlers.js +3 -1
  79. package/plugins/search/src/events/suggestionHandlers.js +12 -3
  80. package/plugins/search/src/search.scss +4 -1
  81. package/plugins/search/src/utils/parseOsNamesResults.js +12 -12
  82. package/providers/beta/esri/src/esriProvider.js +1 -1
  83. package/providers/beta/esri/src/index.js +1 -1
  84. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  85. package/providers/maplibre/dist/esm/index.js +1 -1
  86. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  87. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  88. package/providers/maplibre/dist/umd/index.js +1 -1
  89. package/providers/maplibre/src/defaults.js +3 -2
  90. package/providers/maplibre/src/index.js +22 -19
  91. package/providers/maplibre/src/maplibreProvider.js +13 -11
  92. package/providers/maplibre/src/utils/detectWebgl.js +1 -2
  93. package/providers/maplibre/src/utils/highlightFeatures.js +78 -67
  94. package/providers/maplibre/src/utils/labels.js +174 -120
  95. package/providers/maplibre/src/utils/maplibreFixes.js +2 -2
  96. package/providers/maplibre/src/utils/queryFeatures.js +146 -0
  97. package/providers/maplibre/src/utils/spatial.js +14 -17
  98. package/sonar-project.properties +39 -2
  99. package/src/App/components/Actions/Actions.jsx +2 -2
  100. package/src/App/components/Actions/Actions.module.scss +2 -2
  101. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +3 -1
  102. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +9 -0
  103. package/src/App/components/Logo/Logo.jsx +1 -1
  104. package/src/App/components/MapButton/MapButton.jsx +217 -41
  105. package/src/App/components/MapButton/MapButton.module.scss +32 -21
  106. package/src/App/components/MapButton/MapButton.test.jsx +128 -84
  107. package/src/App/components/Markers/Markers.jsx +1 -1
  108. package/src/App/components/Markers/Markers.module.scss +0 -5
  109. package/src/App/components/Panel/Panel.jsx +75 -45
  110. package/src/App/components/Panel/Panel.module.scss +18 -24
  111. package/src/App/components/Panel/Panel.test.jsx +18 -0
  112. package/src/App/components/PopupMenu/PopupMenu.jsx +246 -0
  113. package/src/App/components/PopupMenu/PopupMenu.module.scss +70 -0
  114. package/src/App/components/PopupMenu/PopupMenu.test.jsx +304 -0
  115. package/src/App/components/Tooltip/Tooltip.jsx +4 -2
  116. package/src/App/components/Tooltip/getTooltipPosition.js +1 -1
  117. package/src/App/components/Viewport/MapController.jsx +1 -1
  118. package/src/App/components/Viewport/MapStatus.jsx +1 -1
  119. package/src/App/components/Viewport/Viewport.jsx +2 -2
  120. package/src/App/components/Viewport/Viewport.module.scss +2 -2
  121. package/src/App/controls/keyboardActions.js +3 -2
  122. package/src/App/controls/keyboardShortcuts.js +4 -2
  123. package/src/App/hooks/useButtonStateEvaluator.js +58 -45
  124. package/src/App/hooks/useButtonStateEvaluator.test.js +36 -3
  125. package/src/App/hooks/useCrossHairAPI.js +49 -47
  126. package/src/App/hooks/useFocusVisible.js +2 -2
  127. package/src/App/hooks/useInterfaceAPI.js +16 -4
  128. package/src/App/hooks/useKeyboardHint.js +1 -1
  129. package/src/App/hooks/useKeyboardShortcuts.js +4 -2
  130. package/src/App/hooks/useMapAnnouncements.js +34 -32
  131. package/src/App/hooks/useMapEvents.js +12 -1
  132. package/src/App/hooks/useMapProviderOverrides.js +3 -3
  133. package/src/App/hooks/useMapStateSync.js +7 -1
  134. package/src/App/hooks/useMapURLSync.js +6 -1
  135. package/src/App/hooks/useMarkersAPI.js +88 -50
  136. package/src/App/hooks/useMediaQueryDispatch.test.js +48 -0
  137. package/src/App/hooks/useModalPanelBehaviour.js +46 -40
  138. package/src/App/hooks/useResizeObserver.js +2 -2
  139. package/src/App/initialiseApp.js +45 -38
  140. package/src/App/layout/Layout.jsx +6 -4
  141. package/src/App/layout/layout.module.scss +13 -17
  142. package/src/App/registry/keyboardShortcutRegistry.js +12 -2
  143. package/src/App/registry/keyboardShortcutRegistry.test.js +28 -0
  144. package/src/App/registry/mergeManifests.js +1 -1
  145. package/src/App/registry/panelRegistry.js +1 -1
  146. package/src/App/registry/panelRegistry.test.js +30 -1
  147. package/src/App/registry/pluginRegistry.js +14 -51
  148. package/src/App/registry/pluginRegistry.test.js +54 -6
  149. package/src/App/renderer/HtmlElementHost.jsx +186 -0
  150. package/src/App/renderer/HtmlElementHost.test.jsx +231 -0
  151. package/src/App/renderer/mapButtons.js +11 -8
  152. package/src/App/renderer/mapButtons.test.js +6 -4
  153. package/src/App/renderer/mapControls.js +6 -0
  154. package/src/App/renderer/mapControls.test.js +10 -2
  155. package/src/App/renderer/mapPanels.js +39 -26
  156. package/src/App/renderer/mapPanels.test.js +29 -0
  157. package/src/App/renderer/pluginWrapper.test.js +28 -0
  158. package/src/App/renderer/slotHelpers.js +60 -0
  159. package/src/App/renderer/slotHelpers.test.js +82 -0
  160. package/src/App/store/AppProvider.jsx +21 -18
  161. package/src/App/store/AppProvider.test.jsx +40 -0
  162. package/src/App/store/PluginProvider.jsx +7 -5
  163. package/src/App/store/appActionsMap.js +28 -1
  164. package/src/App/store/appActionsMap.test.js +26 -0
  165. package/src/App/store/appReducer.js +1 -0
  166. package/src/App/store/mapActionsMap.js +12 -24
  167. package/src/App/store/mapReducer.js +1 -1
  168. package/src/InteractiveMap/InteractiveMap.js +79 -6
  169. package/src/InteractiveMap/InteractiveMap.test.js +164 -2
  170. package/src/InteractiveMap/behaviourController.js +12 -2
  171. package/src/InteractiveMap/behaviourController.test.js +82 -1
  172. package/src/InteractiveMap/deviceChecker.js +1 -1
  173. package/src/InteractiveMap/deviceChecker.test.js +2 -2
  174. package/src/InteractiveMap/historyManager.js +41 -5
  175. package/src/InteractiveMap/historyManager.test.js +37 -6
  176. package/src/InteractiveMap/polyfills.js +39 -0
  177. package/src/InteractiveMap/polyfills.test.js +127 -0
  178. package/src/config/appConfig.js +5 -5
  179. package/src/config/appConfig.test.js +107 -42
  180. package/src/config/defaults.js +1 -0
  181. package/src/index.umd.js +1 -1
  182. package/src/scss/main.scss +1 -0
  183. package/src/scss/settings/_colors.scss +1 -1
  184. package/src/scss/settings/_dimensions.scss +4 -0
  185. package/src/services/eventBus.test.js +24 -1
  186. package/src/test-utils.js +1 -0
  187. package/src/types.js +5 -0
  188. package/src/utils/detectBreakpoint.js +78 -77
  189. package/src/utils/detectBreakpoint.test.js +50 -4
  190. package/src/utils/detectInterfaceType.js +6 -5
  191. package/src/utils/getIsFullscreen.js +3 -1
  192. package/src/utils/getSafeZoneInset.js +23 -7
  193. package/src/utils/getSafeZoneInset.test.js +18 -0
  194. package/src/utils/mapStateSync.js +3 -3
  195. package/src/utils/queryString.js +1 -1
  196. package/src/utils/stringToKebab.js +1 -1
  197. package/src/utils/toggleInertElements.js +37 -12
  198. package/webpack.dev.mjs +3 -4
  199. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  200. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  201. package/plugins/search/src/utils/fetchDataset.js +0 -23
  202. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  203. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -1,4 +1,4 @@
1
- import { registerPanel, addPanel, removePanel, getPanelConfig } from './panelRegistry.js'
1
+ import { createPanelRegistry, registerPanel, addPanel, removePanel, getPanelConfig } from './panelRegistry.js'
2
2
  import { defaultPanelConfig } from '../../config/appConfig.js'
3
3
 
4
4
  describe('panelRegistry', () => {
@@ -88,4 +88,33 @@ describe('panelRegistry', () => {
88
88
  expect(updatedConfig.panel1).toBeUndefined()
89
89
  expect(updatedConfig).not.toBe(config) // Immutable
90
90
  })
91
+
92
+ describe('createPanelRegistry (Factory)', () => {
93
+ let registry
94
+
95
+ beforeEach(() => {
96
+ registry = createPanelRegistry()
97
+ })
98
+
99
+ test('should manage state internally via all methods', () => {
100
+ // Test registerPanel state
101
+ registry.registerPanel({ p1: { title: 'P1' } })
102
+ expect(registry.getPanelConfig()).toHaveProperty('p1')
103
+ expect(registry.getPanelConfig().p1.showLabel).toBe(true)
104
+
105
+ // Test addPanel state and return value
106
+ const added = registry.addPanel('p2', { title: 'P2' })
107
+ expect(added.title).toBe('P2')
108
+ expect(registry.getPanelConfig()).toHaveProperty('p2')
109
+
110
+ // Test removePanel state
111
+ registry.removePanel('p1')
112
+ expect(registry.getPanelConfig()).not.toHaveProperty('p1')
113
+ expect(registry.getPanelConfig()).toHaveProperty('p2')
114
+
115
+ // Test clear state
116
+ registry.clear()
117
+ expect(registry.getPanelConfig()).toEqual({})
118
+ })
119
+ })
91
120
  })
@@ -2,6 +2,8 @@
2
2
  import { registerIcon } from './iconRegistry.js'
3
3
  import { registerKeyboardShortcut } from './keyboardShortcutRegistry.js'
4
4
 
5
+ const asArray = (value) => Array.isArray(value) ? value : [value]
6
+
5
7
  export function createPluginRegistry ({ registerButton, registerPanel, registerControl }) {
6
8
  const registeredPlugins = []
7
9
 
@@ -14,76 +16,37 @@ export function createPluginRegistry ({ registerButton, registerPanel, registerC
14
16
  excludeModes: plugin.config?.excludeModes
15
17
  }
16
18
 
17
- // --- Register buttons ---
18
19
  if (manifest.buttons) {
19
- const buttons = Array.isArray(manifest.buttons)
20
- ? manifest.buttons
21
- : [manifest.buttons]
22
-
23
- buttons.forEach(button => {
24
- registerButton({
25
- [button.id]: {
26
- ...pluginConfig,
27
- ...button
28
- }
20
+ asArray(manifest.buttons).forEach(button => {
21
+ registerButton({ [button.id]: { ...pluginConfig, ...button } })
22
+ // Flat button registry including anu menu items
23
+ button?.menuItems?.forEach(menuItem => {
24
+ registerButton({ [menuItem.id]: { ...pluginConfig, ...menuItem } })
29
25
  })
30
26
  })
31
27
  }
32
28
 
33
- // --- Register panels ---
34
29
  if (manifest.panels) {
35
- const panels = Array.isArray(manifest.panels)
36
- ? manifest.panels
37
- : [manifest.panels]
38
-
39
- panels.forEach(panel => {
40
- registerPanel({
41
- [panel.id]: {
42
- ...pluginConfig,
43
- ...panel
44
- }
45
- })
30
+ asArray(manifest.panels).forEach(panel => {
31
+ registerPanel({ [panel.id]: { ...pluginConfig, ...panel } })
46
32
  })
47
33
  }
48
34
 
49
- // --- Register controls ---
50
35
  if (manifest.controls) {
51
- const controls = Array.isArray(manifest.controls)
52
- ? manifest.controls
53
- : [manifest.controls]
54
-
55
- controls.forEach(control => {
56
- registerControl({
57
- [control.id]: {
58
- ...pluginConfig,
59
- ...control
60
- }
61
- })
36
+ asArray(manifest.controls).forEach(control => {
37
+ registerControl({ [control.id]: { ...pluginConfig, ...control } })
62
38
  })
63
39
  }
64
40
 
65
- // --- Register icons ---
66
41
  if (manifest.icons) {
67
- const icons = Array.isArray(manifest.icons)
68
- ? manifest.icons
69
- : [manifest.icons]
70
-
71
- icons.forEach(icon =>
42
+ asArray(manifest.icons).forEach(icon =>
72
43
  registerIcon({ [icon.id]: icon.svgContent })
73
44
  )
74
45
  }
75
46
 
76
- // --- Register keyboard shortcuts ---
77
47
  if (manifest.keyboardShortcuts) {
78
- const shortcuts = Array.isArray(manifest.keyboardShortcuts)
79
- ? manifest.keyboardShortcuts
80
- : [manifest.keyboardShortcuts]
81
-
82
- shortcuts.forEach(shortcut =>
83
- registerKeyboardShortcut({
84
- ...pluginConfig,
85
- shortcut
86
- })
48
+ asArray(manifest.keyboardShortcuts).forEach(shortcut =>
49
+ registerKeyboardShortcut({ ...pluginConfig, shortcut })
87
50
  )
88
51
  }
89
52
 
@@ -41,7 +41,7 @@ describe('pluginRegistry', () => {
41
41
  buttons: { id: 'btn1' },
42
42
  panels: [{ id: 'panel1' }],
43
43
  controls: { id: 'ctrl1' },
44
- icons: { id: 'icon1', svgContent: 'Comp' }, // match implementation
44
+ icons: { id: 'icon1', svgContent: 'Comp' },
45
45
  keyboardShortcuts: { key: 'K' }
46
46
  }
47
47
  }
@@ -65,15 +65,50 @@ describe('pluginRegistry', () => {
65
65
  })
66
66
  expect(registerIcon).toHaveBeenCalledWith({ icon1: 'Comp' })
67
67
  expect(registerKeyboardShortcut).toHaveBeenCalledWith(
68
- expect.objectContaining({
69
- ...expectedPluginConfig,
70
- shortcut: { key: 'K' }
71
- })
68
+ expect.objectContaining({ ...expectedPluginConfig, shortcut: { key: 'K' } })
72
69
  )
73
-
74
70
  expect(pluginRegistry.registeredPlugins).toContain(plugin)
75
71
  })
76
72
 
73
+ it('registers nested menuItems for buttons', () => {
74
+ const plugin = {
75
+ id: 'plugin-menu',
76
+ config: { includeModes: ['mode1'], excludeModes: [] },
77
+ manifest: {
78
+ buttons: [
79
+ {
80
+ id: 'parentBtn',
81
+ menuItems: [
82
+ { id: 'childBtn1', label: 'Child 1' },
83
+ { id: 'childBtn2', label: 'Child 2' }
84
+ ]
85
+ }
86
+ ]
87
+ }
88
+ }
89
+
90
+ pluginRegistry.registerPlugin(plugin)
91
+
92
+ const expectedPluginConfig = {
93
+ pluginId: 'plugin-menu',
94
+ includeModes: ['mode1'],
95
+ excludeModes: []
96
+ }
97
+
98
+ // Parent button
99
+ expect(registerButton).toHaveBeenCalledWith({
100
+ parentBtn: expect.objectContaining(expectedPluginConfig)
101
+ })
102
+
103
+ // Each menu item
104
+ expect(registerButton).toHaveBeenCalledWith({
105
+ childBtn1: expect.objectContaining(expectedPluginConfig)
106
+ })
107
+ expect(registerButton).toHaveBeenCalledWith({
108
+ childBtn2: expect.objectContaining(expectedPluginConfig)
109
+ })
110
+ })
111
+
77
112
  it('handles single vs array manifests correctly', () => {
78
113
  const plugin = {
79
114
  id: 'plugin3',
@@ -103,4 +138,17 @@ describe('pluginRegistry', () => {
103
138
  pluginRegistry.registerPlugin(pluginB)
104
139
  expect(pluginRegistry.registeredPlugins).toEqual([pluginA, pluginB])
105
140
  })
141
+
142
+ it('clears all registered plugins', () => {
143
+ const pluginA = { id: 'A', config: {}, manifest: {} }
144
+ const pluginB = { id: 'B', config: {}, manifest: {} }
145
+
146
+ pluginRegistry.registerPlugin(pluginA)
147
+ pluginRegistry.registerPlugin(pluginB)
148
+ expect(pluginRegistry.registeredPlugins.length).toBe(2)
149
+
150
+ pluginRegistry.clear()
151
+ expect(pluginRegistry.registeredPlugins.length).toBe(0)
152
+ expect(pluginRegistry.registeredPlugins).toEqual([])
153
+ })
106
154
  })
@@ -0,0 +1,186 @@
1
+ // src/App/renderer/HtmlElementHost.jsx
2
+ import React, { useRef, useEffect, useMemo } from 'react'
3
+ import { useApp } from '../store/appContext.js'
4
+ import { Panel } from '../components/Panel/Panel.jsx'
5
+ import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
6
+ import { allowedSlots } from './slots.js'
7
+ import { stringToKebab } from '../../utils/stringToKebab.js'
8
+
9
+ /**
10
+ * Maps slot names to their corresponding layout refs.
11
+ */
12
+ export const getSlotRef = (slot, layoutRefs) => {
13
+ const slotRefMap = {
14
+ side: layoutRefs.sideRef,
15
+ banner: layoutRefs.bannerRef,
16
+ 'top-left': layoutRefs.topLeftColRef,
17
+ 'top-right': layoutRefs.topRightColRef,
18
+ inset: layoutRefs.insetRef,
19
+ middle: layoutRefs.middleRef,
20
+ bottom: layoutRefs.bottomRef,
21
+ actions: layoutRefs.actionsRef,
22
+ modal: layoutRefs.modalRef
23
+ }
24
+ return slotRefMap[slot] || null
25
+ }
26
+
27
+ /**
28
+ * Manages DOM projection for a single persistent element.
29
+ * Moves the wrapper into the target slot when visible, hides it otherwise.
30
+ * Depends on breakpoint to handle conditionally rendered slot containers
31
+ * (e.g. the banner slot swaps DOM nodes between mobile and desktop).
32
+ */
33
+ export const useDomProjection = (wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint) => {
34
+ useEffect(() => {
35
+ const wrapper = wrapperRef.current
36
+
37
+ if (isVisible) {
38
+ const slotRef = getSlotRef(targetSlot, layoutRefs)
39
+ if (slotRef?.current) {
40
+ slotRef.current.appendChild(wrapper)
41
+ wrapper.style.display = ''
42
+ }
43
+ } else {
44
+ wrapper.style.display = 'none'
45
+ }
46
+
47
+ return () => {
48
+ wrapper.style.display = 'none'
49
+ }
50
+ }, [isVisible, targetSlot, layoutRefs, breakpoint, wrapperRef])
51
+ }
52
+
53
+ /**
54
+ * Persistent wrapper for a consumer HTML panel.
55
+ * The Panel component stays mounted for the lifetime of the registration.
56
+ * DOM projection moves it between slots; CSS hides it when closed.
57
+ */
58
+ const PersistentPanel = ({ panelId, config, isOpen, openPanelProps, allowedModalPanelId, appState }) => {
59
+ const panelRootRef = useRef(null)
60
+ const { breakpoint, mode, isFullscreen, layoutRefs } = appState
61
+
62
+ const bpConfig = config[breakpoint]
63
+ const targetSlot = bpConfig ? resolveTargetSlot(bpConfig, breakpoint) : null
64
+
65
+ // Determine visibility using the same logic as mapPanels
66
+ const isVisible = (() => {
67
+ // 1. Initial Guard: Basic requirements
68
+ if (!isOpen || !bpConfig || !targetSlot) {
69
+ return false
70
+ }
71
+
72
+ // 2. Slot Validation
73
+ const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot
74
+ const isSlotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
75
+
76
+ if (!isSlotAllowed) {
77
+ return false
78
+ }
79
+
80
+ // 3. Business Logic: Combine remaining conditions into a single "fail" check
81
+ const isForbiddenModal = bpConfig.modal && panelId !== allowedModalPanelId
82
+ const isForbiddenInline = config.inline === false && !isFullscreen
83
+ const isInvalidMode = !isModeAllowed(config, mode)
84
+
85
+ return !(isForbiddenModal || isForbiddenInline || isInvalidMode)
86
+ })()
87
+
88
+ useDomProjection(panelRootRef, targetSlot, isVisible, layoutRefs, breakpoint)
89
+
90
+ return (
91
+ <Panel
92
+ panelId={panelId}
93
+ panelConfig={config}
94
+ props={openPanelProps}
95
+ html={config.html}
96
+ label={config.label}
97
+ isOpen={isOpen}
98
+ rootRef={panelRootRef}
99
+ />
100
+ )
101
+ }
102
+
103
+ /**
104
+ * Persistent wrapper for a consumer HTML control.
105
+ * The control stays mounted for the lifetime of the registration.
106
+ */
107
+ const PersistentControl = ({ control, appState }) => {
108
+ const wrapperRef = useRef(null)
109
+ const { breakpoint, mode, isFullscreen, layoutRefs } = appState
110
+
111
+ const bpConfig = control[breakpoint]
112
+ const isVisible = isControlVisible(control, { breakpoint, mode, isFullscreen })
113
+ const targetSlot = bpConfig?.slot || null
114
+
115
+ useDomProjection(wrapperRef, targetSlot, isVisible, layoutRefs, breakpoint)
116
+
117
+ const innerHtml = useMemo(() => ({ __html: control.html }), [control.html])
118
+
119
+ return (
120
+ <div
121
+ ref={wrapperRef}
122
+ className='im-c-control'
123
+ style={{ display: 'none' }}
124
+ dangerouslySetInnerHTML={innerHtml}
125
+ />
126
+ )
127
+ }
128
+
129
+ /**
130
+ * Renders all consumer HTML panels and controls persistently.
131
+ * Items mount once on registration and only unmount on deregistration.
132
+ * Visibility and slot placement are handled by DOM projection, not React mount/unmount.
133
+ */
134
+ export const HtmlElementHost = () => {
135
+ const appState = useApp()
136
+ const { panelConfig = {}, controlConfig = {}, openPanels = {}, breakpoint } = appState
137
+
138
+ // Find consumer HTML panels
139
+ const htmlPanels = useMemo(() =>
140
+ Object.entries(panelConfig).filter(([_, config]) => isConsumerHtml(config)),
141
+ [panelConfig]
142
+ )
143
+
144
+ // Find consumer HTML controls
145
+ const htmlControls = useMemo(() =>
146
+ Object.values(controlConfig).filter(control => isConsumerHtml(control)),
147
+ [controlConfig]
148
+ )
149
+
150
+ // Determine which modal panel is allowed (topmost open modal)
151
+ const allowedModalPanelId = useMemo(() => {
152
+ const openPanelEntries = Object.entries(openPanels)
153
+ const modalPanels = openPanelEntries.filter(([panelId]) => {
154
+ const cfg = panelConfig[panelId]?.[breakpoint]
155
+ return cfg?.modal
156
+ })
157
+ return modalPanels.length > 0 ? modalPanels[modalPanels.length - 1][0] : null
158
+ }, [openPanels, panelConfig, breakpoint])
159
+
160
+ if (!htmlPanels.length && !htmlControls.length) {
161
+ return null
162
+ }
163
+
164
+ return (
165
+ <>
166
+ {htmlPanels.map(([panelId, config]) => (
167
+ <PersistentPanel
168
+ key={panelId}
169
+ panelId={panelId}
170
+ config={config}
171
+ isOpen={!!openPanels[panelId]}
172
+ openPanelProps={openPanels[panelId]?.props}
173
+ allowedModalPanelId={allowedModalPanelId}
174
+ appState={appState}
175
+ />
176
+ ))}
177
+ {htmlControls.map(control => (
178
+ <PersistentControl
179
+ key={control.id}
180
+ control={control}
181
+ appState={appState}
182
+ />
183
+ ))}
184
+ </>
185
+ )
186
+ }
@@ -0,0 +1,231 @@
1
+ import React from 'react'
2
+ import { render } from '@testing-library/react'
3
+ import { HtmlElementHost, getSlotRef } from './HtmlElementHost.jsx'
4
+ import { useApp } from '../store/appContext'
5
+
6
+ jest.mock('../store/appContext', () => ({ useApp: jest.fn() }))
7
+ jest.mock('../store/configContext', () => ({ useConfig: jest.fn(() => ({ id: 'test' })) }))
8
+ jest.mock('../components/Panel/Panel.jsx', () => ({
9
+ Panel: ({ panelId, html, isOpen, rootRef }) => (
10
+ <div ref={rootRef} data-testid={`panel-${panelId}`} data-open={isOpen}>
11
+ {html}
12
+ </div>
13
+ )
14
+ }))
15
+ jest.mock('./slots.js', () => ({
16
+ allowedSlots: {
17
+ panel: ['inset', 'side', 'modal', 'bottom'],
18
+ control: ['inset', 'banner', 'bottom', 'actions']
19
+ }
20
+ }))
21
+
22
+ /**
23
+ * Wrapper that provides real DOM slot containers as refs.
24
+ * This keeps projected nodes inside React's render tree so cleanup works.
25
+ */
26
+ const SlotHarness = ({ layoutRefs, children }) => (
27
+ <div>
28
+ <div ref={layoutRefs.insetRef} data-slot='inset' />
29
+ <div ref={layoutRefs.sideRef} data-slot='side' />
30
+ <div ref={layoutRefs.modalRef} data-slot='modal' />
31
+ <div ref={layoutRefs.bottomRef} data-slot='bottom' />
32
+ <div ref={layoutRefs.bannerRef} data-slot='banner' />
33
+ <div ref={layoutRefs.actionsRef} data-slot='actions' />
34
+ {children}
35
+ </div>
36
+ )
37
+
38
+ describe('HtmlElementHost', () => {
39
+ let layoutRefs
40
+
41
+ beforeEach(() => {
42
+ jest.clearAllMocks()
43
+ layoutRefs = {
44
+ sideRef: { current: null },
45
+ bannerRef: { current: null },
46
+ topLeftColRef: { current: null },
47
+ topRightColRef: { current: null },
48
+ insetRef: { current: null },
49
+ middleRef: { current: null },
50
+ bottomRef: { current: null },
51
+ actionsRef: { current: null },
52
+ modalRef: { current: null },
53
+ viewportRef: { current: null },
54
+ mainRef: { current: null }
55
+ }
56
+ })
57
+
58
+ const mockApp = (overrides = {}) => {
59
+ const state = {
60
+ breakpoint: 'desktop',
61
+ mode: 'view',
62
+ isFullscreen: false,
63
+ panelConfig: {},
64
+ controlConfig: {},
65
+ openPanels: {},
66
+ layoutRefs,
67
+ dispatch: jest.fn(),
68
+ ...overrides
69
+ }
70
+ useApp.mockReturnValue(state)
71
+ return state
72
+ }
73
+
74
+ const renderWithSlots = (appOverrides = {}) => {
75
+ mockApp(appOverrides)
76
+ return render(
77
+ <SlotHarness layoutRefs={layoutRefs}>
78
+ <HtmlElementHost />
79
+ </SlotHarness>
80
+ )
81
+ }
82
+
83
+ it('renders nothing when no consumer HTML panels or controls exist', () => {
84
+ mockApp()
85
+ const { container } = render(<HtmlElementHost />)
86
+ expect(container.firstChild).toBeNull()
87
+ })
88
+
89
+ it('ignores plugin panels (with pluginId)', () => {
90
+ mockApp({
91
+ panelConfig: { p1: { pluginId: 'plug1', html: '<p>Hi</p>', desktop: { slot: 'inset' } } },
92
+ openPanels: { p1: { props: {} } }
93
+ })
94
+ const { container } = render(<HtmlElementHost />)
95
+ expect(container.firstChild).toBeNull()
96
+ })
97
+
98
+ it('ignores plugin controls (with pluginId)', () => {
99
+ mockApp({
100
+ controlConfig: { c1: { id: 'c1', pluginId: 'plug1', html: '<p>Hi</p>', desktop: { slot: 'inset' } } }
101
+ })
102
+ const { container } = render(<HtmlElementHost />)
103
+ expect(container.firstChild).toBeNull()
104
+ })
105
+
106
+ it('projects open panel into correct slot', () => {
107
+ const { container } = renderWithSlots({
108
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'inset' } } },
109
+ openPanels: { p1: { props: {} } }
110
+ })
111
+ expect(container.querySelector('[data-slot="inset"] [data-testid="panel-p1"]')).toBeTruthy()
112
+ })
113
+
114
+ it('hides panel when closed and passes isOpen=false', () => {
115
+ const { getByTestId } = renderWithSlots({
116
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'inset' } } },
117
+ openPanels: {}
118
+ })
119
+ expect(getByTestId('panel-p1').style.display).toBe('none')
120
+ expect(getByTestId('panel-p1').dataset.open).toBe('false')
121
+ })
122
+
123
+ it('hides panel when mode is not allowed', () => {
124
+ const { getByTestId } = renderWithSlots({
125
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'inset' }, includeModes: ['edit'] } },
126
+ openPanels: { p1: { props: {} } }
127
+ })
128
+ expect(getByTestId('panel-p1').style.display).toBe('none')
129
+ })
130
+
131
+ it('hides panel with inline:false when not fullscreen', () => {
132
+ const { getByTestId } = renderWithSlots({
133
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'inset' }, inline: false } },
134
+ openPanels: { p1: { props: {} } },
135
+ isFullscreen: false
136
+ })
137
+ expect(getByTestId('panel-p1').style.display).toBe('none')
138
+ })
139
+
140
+ it('resolves bottom slot to inset on desktop', () => {
141
+ const { container } = renderWithSlots({
142
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'bottom' } } },
143
+ openPanels: { p1: { props: {} } }
144
+ })
145
+ expect(container.querySelector('[data-slot="inset"] [data-testid="panel-p1"]')).toBeTruthy()
146
+ expect(container.querySelector('[data-slot="bottom"] [data-testid="panel-p1"]')).toBeNull()
147
+ })
148
+
149
+ it('only shows topmost modal panel', () => {
150
+ const { getByTestId, container } = renderWithSlots({
151
+ panelConfig: {
152
+ p1: { html: '<p>1</p>', label: 'M1', desktop: { slot: 'side', modal: true } },
153
+ p2: { html: '<p>2</p>', label: 'M2', desktop: { slot: 'side', modal: true } }
154
+ },
155
+ openPanels: { p1: { props: {} }, p2: { props: {} } }
156
+ })
157
+ expect(getByTestId('panel-p1').style.display).toBe('none')
158
+ expect(container.querySelector('[data-slot="modal"] [data-testid="panel-p2"]')).toBeTruthy()
159
+ })
160
+
161
+ it('hides panel when breakpoint config is missing', () => {
162
+ const { getByTestId } = renderWithSlots({
163
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', mobile: { slot: 'inset' } } },
164
+ openPanels: { p1: { props: {} } },
165
+ breakpoint: 'desktop'
166
+ })
167
+ expect(getByTestId('panel-p1').style.display).toBe('none')
168
+ })
169
+
170
+ it('projects visible control into correct slot', () => {
171
+ const { container } = renderWithSlots({
172
+ controlConfig: { c1: { id: 'c1', html: '<input type="checkbox">', desktop: { slot: 'inset' } } }
173
+ })
174
+ const control = container.querySelector('[data-slot="inset"] .im-c-control')
175
+ expect(control).toBeTruthy()
176
+ expect(control.innerHTML).toBe('<input type="checkbox">')
177
+ })
178
+
179
+ it('hides panel when slot is not allowed', () => {
180
+ const { getByTestId } = renderWithSlots({
181
+ panelConfig: { p1: { html: '<p>Hi</p>', label: 'Test', desktop: { slot: 'invalid' } } },
182
+ openPanels: { p1: { props: {} } }
183
+ })
184
+ expect(getByTestId('panel-p1').style.display).toBe('none')
185
+ })
186
+
187
+ it('hides control when breakpoint config is missing', () => {
188
+ const { container } = renderWithSlots({
189
+ controlConfig: { c1: { id: 'c1', html: '<p>Hi</p>', mobile: { slot: 'inset' } } },
190
+ breakpoint: 'desktop'
191
+ })
192
+ const control = container.querySelector('.im-c-control')
193
+ expect(control.style.display).toBe('none')
194
+ })
195
+
196
+ it('uses default empty objects when appState is missing configs', () => {
197
+ useApp.mockReturnValue({}) // no panelConfig/controlConfig/openPanels/breakpoint
198
+ const { container } = render(<HtmlElementHost />)
199
+ expect(container.firstChild).toBeNull() // still renders safely
200
+ })
201
+
202
+ test('getSlotRef returns null for unknown slot', () => {
203
+ expect(getSlotRef('unknown-slot', {})).toBeNull()
204
+ })
205
+
206
+ it('does not append child if slotRef exists but current is null', () => {
207
+ // 1. Setup refs where the slot exists in the map but the DOM node (current) is null
208
+ const incompleteRefs = {
209
+ ...layoutRefs,
210
+ sideRef: { current: null }
211
+ }
212
+
213
+ // 2. Mock the app so the panel IS visible, but the slot it wants is the broken one
214
+ mockApp({
215
+ panelConfig: { p1: { html: '<b>Hi</b>', desktop: { slot: 'side' } } },
216
+ openPanels: { p1: { props: {} } },
217
+ layoutRefs: incompleteRefs
218
+ })
219
+
220
+ const { getByTestId, container } = render(<HtmlElementHost />)
221
+
222
+ const panel = getByTestId('panel-p1')
223
+
224
+ // 3. Verify that the panel was NOT moved into a slot container
225
+ // Since sideRef.current is null, it should still be sitting in the root of the Host
226
+ expect(container.querySelector('[data-slot="side"] [data-testid="panel-p1"]')).toBeNull()
227
+
228
+ // The panel exists in the DOM but hasn't been "projected"
229
+ expect(panel).toBeTruthy()
230
+ })
231
+ })
@@ -36,18 +36,19 @@ function getMatchingButtons ({ appState, buttonConfig, slot, evaluateProp }) {
36
36
 
37
37
  function createButtonClickHandler (btn, appState, evaluateProp) {
38
38
  const [, config] = btn
39
- const isOpen = !!(config.panelId && appState.openPanels[config.panelId])
39
+ const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
40
40
 
41
41
  return (e) => {
42
42
  if (typeof config.onClick === 'function') {
43
- return config.onClick(e, evaluateProp(ctx => ctx, config.pluginId))
43
+ config.onClick(e, evaluateProp(ctx => ctx, config.pluginId))
44
+ return
44
45
  }
45
46
 
46
47
  if (config.panelId) {
47
48
  const triggeringElement = e.currentTarget
48
49
  appState.dispatch({
49
- type: isOpen ? 'CLOSE_PANEL' : 'OPEN_PANEL',
50
- payload: isOpen
50
+ type: isPanelOpen ? 'CLOSE_PANEL' : 'OPEN_PANEL',
51
+ payload: isPanelOpen
51
52
  ? config.panelId
52
53
  : { panelId: config.panelId, props: { triggeringElement } }
53
54
  })
@@ -59,7 +60,7 @@ function renderButton ({ btn, appState, appConfig, evaluateProp, groupStart, gro
59
60
  const [buttonId, config] = btn
60
61
  const bpConfig = config[appState.breakpoint] ?? {}
61
62
  const handleClick = createButtonClickHandler(btn, appState, evaluateProp)
62
- const isOpen = !!(config.panelId && appState.openPanels[config.panelId])
63
+ const isPanelOpen = !!(config.panelId && appState.openPanels[config.panelId])
63
64
 
64
65
  return (
65
66
  <MapButton
@@ -74,9 +75,11 @@ function renderButton ({ btn, appState, appConfig, evaluateProp, groupStart, gro
74
75
  isDisabled={appState.disabledButtons.has(buttonId)}
75
76
  isHidden={appState.hiddenButtons.has(buttonId)}
76
77
  isPressed={(config.isPressed !== undefined || config.pressedWhen) ? appState.pressedButtons.has(buttonId) : undefined}
77
- isOpen={isOpen}
78
+ isExpanded={(config.isExpanded !== undefined || config.expandedWhen) ? appState.expandedButtons.has(buttonId) : undefined}
79
+ isPanelOpen={isPanelOpen}
78
80
  onClick={handleClick}
79
81
  panelId={config.panelId}
82
+ menuItems={config.menuItems}
80
83
  idPrefix={appConfig.id}
81
84
  groupStart={groupStart}
82
85
  groupMiddle={groupMiddle}
@@ -115,10 +118,10 @@ function mapButtons ({ slot, appState, appConfig, evaluateProp }) {
115
118
  return matching.map((btn, idx) => {
116
119
  const [buttonId, config] = btn
117
120
  const key = config.group
118
- const indices = key != null ? groupMap.get(key) : null
121
+ const indices = key == null ? null : groupMap.get(key)
119
122
  const groupStart = indices ? idx === indices[0] : false
120
123
  const groupEnd = indices ? idx === indices[indices.length - 1] : false
121
- const groupMiddle = indices && indices.length >= 3 && !groupStart && !groupEnd
124
+ const groupMiddle = indices && indices.length >= 3 && !groupStart && !groupEnd // NOSONAR: 3 = minimum for a start/middle/end group
122
125
  const order = config[breakpoint]?.order ?? 0
123
126
 
124
127
  return {