@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
@@ -36,6 +36,7 @@ describe('mapButtons module', () => {
36
36
  disabledButtons: new Set(),
37
37
  hiddenButtons: new Set(),
38
38
  pressedButtons: new Set(),
39
+ expandedButtons: new Set(),
39
40
  buttonConfig: {},
40
41
  panelConfig: {}
41
42
  }
@@ -137,15 +138,16 @@ describe('mapButtons module', () => {
137
138
  })
138
139
  })
139
140
 
140
- it('renders correct state flags for disabled, hidden, and pressed buttons', () => {
141
+ it('renders correct state flags for disabled, hidden, pressed and expanded buttons', () => {
141
142
  const state = {
142
143
  ...appState,
143
144
  disabledButtons: new Set(['id']),
144
145
  hiddenButtons: new Set(['id']),
145
- pressedButtons: new Set(['id'])
146
+ pressedButtons: new Set(['id']),
147
+ expandedButtons: new Set(['id'])
146
148
  }
147
- const result = render({ ...baseBtn, pressedWhen: jest.fn() }, state)
148
- expect(result.props).toMatchObject({ isDisabled: true, isHidden: true, isPressed: true })
149
+ const result = render({ ...baseBtn, pressedWhen: jest.fn(), expandedWhen: jest.fn() }, state)
150
+ expect(result.props).toMatchObject({ isDisabled: true, isHidden: true, isPressed: true, isExpanded: true })
149
151
  })
150
152
 
151
153
  it('uses empty object fallback for missing breakpoint config', () => {
@@ -2,6 +2,7 @@
2
2
  import React from 'react'
3
3
  import { withPluginContexts } from './pluginWrapper.js'
4
4
  import { allowedSlots } from './slots.js'
5
+ import { isConsumerHtml } from './slotHelpers.js'
5
6
 
6
7
  /**
7
8
  * Map controls for a given slot and app state.
@@ -12,6 +13,11 @@ export function mapControls ({ slot, appState, evaluateProp }) {
12
13
 
13
14
  return Object.values(controlConfig)
14
15
  .filter(control => {
16
+ // Consumer HTML controls are managed by HtmlElementHost
17
+ if (isConsumerHtml(control)) {
18
+ return false
19
+ }
20
+
15
21
  const bpConfig = control[breakpoint]
16
22
  if (!bpConfig) {
17
23
  return false
@@ -90,14 +90,22 @@ describe('mapControls', () => {
90
90
  expect(result[0].order).toBe(0)
91
91
  })
92
92
 
93
- it('renders HTML controls with dangerouslySetInnerHTML', () => {
93
+ it('renders plugin HTML controls with dangerouslySetInnerHTML', () => {
94
94
  defaultAppState.controlConfig = ({
95
- ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: '<p>Hi</p>', includeModes: ['view'] }
95
+ ctrlHtml: { id: 'ctrlHtml', pluginId: 'plugin1', desktop: { slot: 'header' }, html: '<p>Hi</p>', includeModes: ['view'] }
96
96
  })
97
97
  const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })
98
98
  expect(result[0].element.props.dangerouslySetInnerHTML).toEqual({ __html: '<p>Hi</p>' })
99
99
  })
100
100
 
101
+ it('filters out consumer HTML controls (handled by HtmlElementHost)', () => {
102
+ defaultAppState.controlConfig = ({
103
+ ctrlHtml: { id: 'ctrlHtml', desktop: { slot: 'header' }, html: '<p>Hi</p>', includeModes: ['view'] }
104
+ })
105
+ const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p })
106
+ expect(result).toEqual([])
107
+ })
108
+
101
109
  it('handles plugin-less controls gracefully', () => {
102
110
  defaultAppState.controlConfig = ({
103
111
  ctrl2: { id: 'ctrl2', desktop: { slot: 'header' }, render: () => <div />, includeModes: ['view'] }
@@ -4,7 +4,38 @@ import { stringToKebab } from '../../utils/stringToKebab.js'
4
4
  import { withPluginContexts } from './pluginWrapper.js'
5
5
  import { Panel } from '../components/Panel/Panel.jsx'
6
6
  import { allowedSlots } from './slots.js'
7
+ import { resolveTargetSlot, isModeAllowed, isConsumerHtml } from './slotHelpers.js'
8
+
9
+ /**
10
+ * Determines whether a panel should be rendered in the given slot.
11
+ * Checks slot eligibility, mode restrictions, inline/fullscreen constraints,
12
+ * and ensures only the topmost modal panel is shown.
13
+ */
14
+ const isPanelVisible = (panelId, config, bpConfig, { targetSlot, slot, mode, isFullscreen, allowedModalPanelId }) => {
15
+ const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot
16
+ if (!allowedSlots.panel.includes(targetSlot) && !isNextToButton) {
17
+ return false
18
+ }
19
+ if (!isModeAllowed(config, mode)) {
20
+ return false
21
+ }
22
+ if (config.inline === false && !isFullscreen) {
23
+ return false
24
+ }
25
+ if (targetSlot !== slot) {
26
+ return false
27
+ }
28
+ if (bpConfig.modal && panelId !== allowedModalPanelId) {
29
+ return false
30
+ }
31
+ return true
32
+ }
7
33
 
34
+ /**
35
+ * Maps open panels to renderable entries for a given layout slot.
36
+ * Filters panels by slot, breakpoint, mode, and modal state, then wraps
37
+ * each panel's render function with the appropriate plugin contexts.
38
+ */
8
39
  export function mapPanels ({ slot, appState, evaluateProp }) {
9
40
  const { breakpoint, pluginRegistry, panelConfig, mode, openPanels } = appState
10
41
 
@@ -18,43 +49,25 @@ export function mapPanels ({ slot, appState, evaluateProp }) {
18
49
 
19
50
  return openPanelEntries.map(([panelId, { props }]) => {
20
51
  const config = panelConfig[panelId]
21
-
22
52
  if (!config) {
23
53
  return null
24
54
  }
25
55
 
26
- const bpConfig = config[breakpoint]
27
- if (!bpConfig) {
56
+ // Consumer HTML panels are managed by HtmlElementHost
57
+ if (isConsumerHtml(config)) {
28
58
  return null
29
59
  }
30
60
 
31
- // Slot constriant: modal panels have a dedicated slot
32
- let targetSlot = bpConfig.modal ? 'modal' : bpConfig.slot
33
-
34
- // Slot constraint: bottom slot only permitted for mobile, revert to inset
35
- if (targetSlot === 'bottom' && ['tablet', 'desktop'].includes(breakpoint)) {
36
- targetSlot = 'inset'
37
- }
38
-
39
- const isNextToButton = `${stringToKebab(panelId)}-button` === targetSlot
40
- const slotAllowed = allowedSlots.panel.includes(targetSlot) || isNextToButton
41
- const inModeWhitelist = config.includeModes?.includes(mode) ?? true
42
- const inExcludeModes = config.excludeModes?.includes(mode) ?? false
43
-
44
- if (!slotAllowed || !inModeWhitelist || inExcludeModes) {
45
- return null
46
- }
47
-
48
- // Skip panels marked as inline:false when not in fullscreen mode
49
- if (config.inline === false && !appState.isFullscreen) {
61
+ const bpConfig = config[breakpoint]
62
+ if (!bpConfig) {
50
63
  return null
51
64
  }
52
65
 
53
- if (targetSlot !== slot) {
54
- return null
55
- }
66
+ const targetSlot = resolveTargetSlot(bpConfig, breakpoint)
56
67
 
57
- if (bpConfig.modal && panelId !== allowedModalPanelId) {
68
+ if (!isPanelVisible(panelId, config, bpConfig, {
69
+ targetSlot, slot, mode, isFullscreen: appState.isFullscreen, allowedModalPanelId
70
+ })) {
58
71
  return null
59
72
  }
60
73
 
@@ -65,6 +65,28 @@ describe('mapPanels', () => {
65
65
  expect(map()).toEqual([])
66
66
  })
67
67
 
68
+ it('skips panel if mode is not allowed (isModeAllowed returns false)', () => {
69
+ // 1. Define config that only allows 'edit' mode
70
+ const panelConfig = {
71
+ p1: {
72
+ desktop: { slot: 'header' },
73
+ includeModes: ['edit']
74
+ }
75
+ }
76
+
77
+ // 2. Mock appState with 'view' mode
78
+ const state = {
79
+ ...defaultAppState,
80
+ mode: 'view',
81
+ panelConfig,
82
+ openPanels: { p1: { props: {} } }
83
+ }
84
+
85
+ // 3. Verify it's filtered out even though it's the right slot
86
+ const result = map(state, 'header')
87
+ expect(result).toEqual([])
88
+ })
89
+
68
90
  it('only allows last opened modal panel', () => {
69
91
  defaultAppState.panelConfig = ({
70
92
  p1: { desktop: { modal: true }, includeModes: ['view'] },
@@ -159,4 +181,11 @@ describe('mapPanels', () => {
159
181
  })
160
182
  expect(map().map(p => p.id)).toEqual(['p1'])
161
183
  })
184
+
185
+ it('filters out consumer HTML panels (handled by HtmlElementHost)', () => {
186
+ defaultAppState.panelConfig = ({
187
+ p1: { desktop: { slot: 'header' }, html: '<p>Hi</p>', includeModes: ['view'] }
188
+ })
189
+ expect(map()).toEqual([])
190
+ })
162
191
  })
@@ -44,4 +44,32 @@ describe('withPluginContexts', () => {
44
44
 
45
45
  expect(Wrapped2).toBe(Wrapped1)
46
46
  })
47
+
48
+ it('filters buttonConfig by pluginId', () => {
49
+ const Inner = jest.fn(() => <div>Inner</div>)
50
+
51
+ // Provide a buttonConfig with buttons for multiple plugins
52
+ const appStateMock = {
53
+ user: 'testUser',
54
+ buttonConfig: {
55
+ btn1: { label: 'A', pluginId: 'plugin1' },
56
+ btn2: { label: 'B', pluginId: 'plugin2' }
57
+ }
58
+ }
59
+
60
+ // Override the useApp hook for this test
61
+ const { useApp } = require('../store/appContext.js')
62
+ useApp.mockImplementation(() => appStateMock)
63
+
64
+ const Wrapped = withPluginContexts(Inner, { pluginId: 'plugin1', pluginConfig: { foo: 'bar' } })
65
+
66
+ render(<Wrapped customProp='xyz' />)
67
+
68
+ const props = Inner.mock.calls[0][0]
69
+
70
+ // buttonConfig should include only buttons belonging to plugin1
71
+ expect(props.buttonConfig).toEqual({
72
+ btn1: { label: 'A', pluginId: 'plugin1' }
73
+ })
74
+ })
47
75
  })
@@ -0,0 +1,60 @@
1
+ // src/App/renderer/slotHelpers.js
2
+ import { allowedSlots } from './slots.js'
3
+
4
+ /**
5
+ * Resolves the target slot for a panel based on its breakpoint config.
6
+ * Modal panels always render in the 'modal' slot, and the bottom slot
7
+ * is only available on mobile — tablet and desktop fall back to 'inset'.
8
+ */
9
+ export const resolveTargetSlot = (bpConfig, breakpoint) => {
10
+ if (bpConfig.modal) {
11
+ return 'modal'
12
+ }
13
+ if (bpConfig.slot === 'bottom' && ['tablet', 'desktop'].includes(breakpoint)) {
14
+ return 'inset'
15
+ }
16
+ return bpConfig.slot
17
+ }
18
+
19
+ /**
20
+ * Checks whether the current application mode permits an item to be shown,
21
+ * based on its includeModes and excludModes configuration.
22
+ */
23
+ export const isModeAllowed = (config, mode) => {
24
+ if (config.includeModes && !config.includeModes.includes(mode)) {
25
+ return false
26
+ }
27
+ if (config.excludeModes?.includes(mode)) {
28
+ return false
29
+ }
30
+ return true
31
+ }
32
+
33
+ /**
34
+ * Checks whether a control should be visible based on breakpoint,
35
+ * mode, fullscreen, and slot constraints.
36
+ */
37
+ export const isControlVisible = (control, { breakpoint, mode, isFullscreen }) => {
38
+ const bpConfig = control[breakpoint]
39
+ if (!bpConfig) {
40
+ return false
41
+ }
42
+ if (!allowedSlots.control.includes(bpConfig.slot)) {
43
+ return false
44
+ }
45
+ if (!isModeAllowed(control, mode)) {
46
+ return false
47
+ }
48
+ if (control.inline === false && !isFullscreen) {
49
+ return false
50
+ }
51
+ return true
52
+ }
53
+
54
+ /**
55
+ * Returns true if a panel/control was added via the consumer API with static HTML
56
+ * (i.e. not a plugin component).
57
+ */
58
+ export const isConsumerHtml = (config) => {
59
+ return typeof config.html === 'string' && !config.pluginId
60
+ }
@@ -0,0 +1,82 @@
1
+ import { resolveTargetSlot, isModeAllowed, isControlVisible, isConsumerHtml } from './slotHelpers.js'
2
+
3
+ jest.mock('./slots.js', () => ({ allowedSlots: { control: ['inset', 'banner', 'actions'] } }))
4
+
5
+ describe('resolveTargetSlot', () => {
6
+ it('returns modal for modal panels', () => {
7
+ expect(resolveTargetSlot({ modal: true, slot: 'side' }, 'desktop')).toBe('modal')
8
+ })
9
+
10
+ it('replaces bottom with inset on tablet and desktop', () => {
11
+ expect(resolveTargetSlot({ slot: 'bottom' }, 'tablet')).toBe('inset')
12
+ expect(resolveTargetSlot({ slot: 'bottom' }, 'desktop')).toBe('inset')
13
+ })
14
+
15
+ it('keeps bottom on mobile', () => {
16
+ expect(resolveTargetSlot({ slot: 'bottom' }, 'mobile')).toBe('bottom')
17
+ })
18
+
19
+ it('returns slot as-is otherwise', () => {
20
+ expect(resolveTargetSlot({ slot: 'side' }, 'desktop')).toBe('side')
21
+ })
22
+ })
23
+
24
+ describe('isModeAllowed', () => {
25
+ it('returns true when no mode restrictions', () => {
26
+ expect(isModeAllowed({}, 'view')).toBe(true)
27
+ })
28
+
29
+ it('rejects when mode not in includeModes', () => {
30
+ expect(isModeAllowed({ includeModes: ['edit'] }, 'view')).toBe(false)
31
+ })
32
+
33
+ it('rejects when mode in excludeModes', () => {
34
+ expect(isModeAllowed({ excludeModes: ['view'] }, 'view')).toBe(false)
35
+ })
36
+
37
+ it('allows when mode matches includeModes', () => {
38
+ expect(isModeAllowed({ includeModes: ['view'] }, 'view')).toBe(true)
39
+ })
40
+ })
41
+
42
+ describe('isControlVisible', () => {
43
+ const base = { desktop: { slot: 'inset' } }
44
+
45
+ it('returns true for valid control', () => {
46
+ expect(isControlVisible(base, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(true)
47
+ })
48
+
49
+ it('returns false when breakpoint config missing', () => {
50
+ expect(isControlVisible(base, { breakpoint: 'mobile', mode: 'view', isFullscreen: false })).toBe(false)
51
+ })
52
+
53
+ it('returns false when slot not allowed', () => {
54
+ expect(isControlVisible({ desktop: { slot: 'invalid' } }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false)
55
+ })
56
+
57
+ it('returns false when mode not allowed', () => {
58
+ expect(isControlVisible({ ...base, includeModes: ['edit'] }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false)
59
+ })
60
+
61
+ it('returns false when inline:false and not fullscreen', () => {
62
+ expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: false })).toBe(false)
63
+ })
64
+
65
+ it('returns true when inline:false and fullscreen', () => {
66
+ expect(isControlVisible({ ...base, inline: false }, { breakpoint: 'desktop', mode: 'view', isFullscreen: true })).toBe(true)
67
+ })
68
+ })
69
+
70
+ describe('isConsumerHtml', () => {
71
+ it('returns true for consumer HTML config', () => {
72
+ expect(isConsumerHtml({ html: '<p>Hi</p>' })).toBe(true)
73
+ })
74
+
75
+ it('returns false when pluginId present', () => {
76
+ expect(isConsumerHtml({ html: '<p>Hi</p>', pluginId: 'p1' })).toBe(false)
77
+ })
78
+
79
+ it('returns false when no html', () => {
80
+ expect(isConsumerHtml({ render: () => {} })).toBe(false)
81
+ })
82
+ })
@@ -21,9 +21,12 @@ export const AppProvider = ({ options, children }) => {
21
21
  topRightColRef: useRef(null),
22
22
  insetRef: useRef(null),
23
23
  rightRef: useRef(null),
24
+ middleRef: useRef(null),
25
+ bottomRef: useRef(null),
24
26
  footerRef: useRef(null),
25
27
  actionsRef: useRef(null),
26
28
  bannerRef: useRef(null),
29
+ modalRef: useRef(null),
27
30
  viewportRef: useRef(null)
28
31
  }
29
32
 
@@ -41,22 +44,6 @@ export const AppProvider = ({ options, children }) => {
41
44
 
42
45
  useMediaQueryDispatch(rawDispatch, options)
43
46
 
44
- const handleBreakpointChange = breakpointDetector.subscribe((breakpoint) => {
45
- dispatch({
46
- type: 'SET_BREAKPOINT',
47
- payload: {
48
- behaviour: options.behaviour,
49
- breakpoint,
50
- hybridWidth: options.hybridWidth,
51
- maxMobileWidth: options.maxMobileWidth
52
- }
53
- })
54
- })
55
-
56
- const handleInterfaceTypeChange = subscribeToInterfaceChanges((newType) => {
57
- dispatch({ type: 'SET_INTERFACE_TYPE', payload: newType })
58
- })
59
-
60
47
  const handleSetMode = (mode) => {
61
48
  dispatch({ type: 'SET_MODE', payload: mode })
62
49
  }
@@ -69,11 +56,27 @@ export const AppProvider = ({ options, children }) => {
69
56
  eventBus.on(events.APP_SET_MODE, handleSetMode)
70
57
  eventBus.on(events.APP_REVERT_MODE, handleRevertMode)
71
58
 
59
+ const unsubBreakpoint = breakpointDetector.subscribe((breakpoint) => {
60
+ dispatch({
61
+ type: 'SET_BREAKPOINT',
62
+ payload: {
63
+ behaviour: options.behaviour,
64
+ breakpoint,
65
+ hybridWidth: options.hybridWidth,
66
+ maxMobileWidth: options.maxMobileWidth
67
+ }
68
+ })
69
+ })
70
+
71
+ const unsubInterface = subscribeToInterfaceChanges((newType) => {
72
+ dispatch({ type: 'SET_INTERFACE_TYPE', payload: newType })
73
+ })
74
+
72
75
  return () => {
73
76
  eventBus.off(events.APP_SET_MODE, handleSetMode)
74
77
  eventBus.off(events.APP_REVERT_MODE, handleRevertMode)
75
- handleBreakpointChange()
76
- handleInterfaceTypeChange()
78
+ unsubBreakpoint()
79
+ unsubInterface()
77
80
  }
78
81
  }, [])
79
82
 
@@ -4,6 +4,7 @@ import { AppProvider, AppContext } from './AppProvider.jsx'
4
4
  import { createMockRegistries } from '../../test-utils.js'
5
5
  import * as mediaHook from '../hooks/useMediaQueryDispatch.js'
6
6
  import * as detectInterface from '../../utils/detectInterfaceType.js'
7
+ import * as appReducerModule from './appReducer.js'
7
8
 
8
9
  jest.mock('../hooks/useMediaQueryDispatch.js')
9
10
  jest.mock('../../utils/detectInterfaceType.js')
@@ -111,4 +112,43 @@ describe('AppProvider', () => {
111
112
  expect(contextValue.layoutRefs).toHaveProperty('mainRef')
112
113
  expect(contextValue.layoutRefs).toHaveProperty('footerRef')
113
114
  })
115
+
116
+ test('dispatch fallback uses options.panelRegistry.getPanelConfig() when state.panelConfig missing', () => {
117
+ const getPanelConfigMock = jest.fn(() => ({ panel1: {} }))
118
+
119
+ // Mock initialState to return state without panelConfig but with panelRegistry
120
+ jest.spyOn(appReducerModule, 'initialState').mockImplementation(() => ({
121
+ mode: 'view',
122
+ previousMode: 'edit',
123
+ openPanels: {},
124
+ previousOpenPanels: {},
125
+ interfaceType: 'default',
126
+ isFullscreen: false,
127
+ hasExclusiveControl: false,
128
+ panelRegistry: { getPanelConfig: getPanelConfigMock } // <-- provide it here!
129
+ }))
130
+
131
+ const mockEventBus = { on: jest.fn(), off: jest.fn() }
132
+ const mockBreakpointDetector = { subscribe: jest.fn(() => jest.fn()) }
133
+ const mockOptions = {
134
+ ...createMockRegistries({ panelConfig: undefined }),
135
+ eventBus: mockEventBus,
136
+ breakpointDetector: mockBreakpointDetector
137
+ }
138
+
139
+ render(
140
+ <AppProvider options={mockOptions}>
141
+ <div>Child</div>
142
+ </AppProvider>
143
+ )
144
+
145
+ // Trigger a dispatch via eventBus to hit the dispatch wrapper
146
+ act(() => {
147
+ mockEventBus.on.mock.calls.forEach(([event, handler]) => {
148
+ if (event === 'app:setmode') handler('newMode')
149
+ })
150
+ })
151
+
152
+ expect(getPanelConfigMock).toHaveBeenCalled()
153
+ })
114
154
  })
@@ -27,19 +27,21 @@ export const PluginProvider = ({ children }) => {
27
27
  }
28
28
 
29
29
  // Initialize ref registry for each plugin
30
- if (!refs.current[plugin.id]) refs.current[plugin.id] = {}
30
+ if (!refs.current[plugin.id]) {
31
+ refs.current[plugin.id] = {}
32
+ }
31
33
  })
32
34
 
33
35
  // Combined reducer
34
- const combinedReducer = (state, action) => {
36
+ const combinedReducer = (pluginState, action) => {
35
37
  const { pluginId } = action
36
38
  if (pluginId && pluginReducers[pluginId]) {
37
39
  return {
38
- ...state,
39
- [pluginId]: pluginReducers[pluginId](state[pluginId], action)
40
+ ...pluginState,
41
+ [pluginId]: pluginReducers[pluginId](pluginState[pluginId], action)
40
42
  }
41
43
  }
42
- return state
44
+ return pluginState
43
45
  }
44
46
 
45
47
  const [state, dispatch] = useReducer(combinedReducer, initialState)
@@ -91,6 +91,9 @@ const setBreakpoint = (state, payload) => {
91
91
  }
92
92
 
93
93
  const setInterfaceType = (state, payload) => {
94
+ if (state.interfaceType === payload) {
95
+ return state
96
+ }
94
97
  return {
95
98
  ...state,
96
99
  interfaceType: payload
@@ -200,6 +203,22 @@ const toggleButtonPressed = (state, payload) => {
200
203
  }
201
204
  }
202
205
 
206
+ const toggleButtonExpanded = (state, payload) => {
207
+ const { id, isExpanded } = payload
208
+ const updated = new Set(state.expandedButtons)
209
+
210
+ if (isExpanded) {
211
+ updated.add(id)
212
+ } else {
213
+ updated.delete(id)
214
+ }
215
+
216
+ return {
217
+ ...state,
218
+ expandedButtons: updated
219
+ }
220
+ }
221
+
203
222
  // Registry mutation actions
204
223
  const registerButton = (state, payload) => {
205
224
  return {
@@ -230,6 +249,12 @@ const addButton = (state, payload) => {
230
249
  pressedButtons.add(id)
231
250
  }
232
251
 
252
+ // Set expanded state
253
+ const expandedButtons = new Set(state.expandedButtons)
254
+ if (config.isExpanded) {
255
+ expandedButtons.add(id)
256
+ }
257
+
233
258
  // Also update the registry instance for persistence across app lifecycle
234
259
  if (state.buttonRegistry?.addButton) {
235
260
  state.buttonRegistry.addButton(id, config)
@@ -240,7 +265,8 @@ const addButton = (state, payload) => {
240
265
  buttonConfig: newButtonConfig,
241
266
  hiddenButtons,
242
267
  disabledButtons,
243
- pressedButtons
268
+ pressedButtons,
269
+ expandedButtons
244
270
  }
245
271
  }
246
272
 
@@ -334,6 +360,7 @@ export const actionsMap = {
334
360
  TOGGLE_BUTTON_DISABLED: toggleButtonDisabled,
335
361
  TOGGLE_BUTTON_HIDDEN: toggleButtonHidden,
336
362
  TOGGLE_BUTTON_PRESSED: toggleButtonPressed,
363
+ TOGGLE_BUTTON_EXPANDED: toggleButtonExpanded,
337
364
  // Registry actions
338
365
  REGISTER_BUTTON: registerButton,
339
366
  ADD_BUTTON: addButton,
@@ -30,6 +30,7 @@ describe('actionsMap full coverage', () => {
30
30
  disabledButtons: new Set(['btn1']),
31
31
  hiddenButtons: new Set(['btn3']),
32
32
  pressedButtons: new Set(['btn5']),
33
+ expandedButtons: new Set(['btn7']),
33
34
  panelConfig: mockPanelConfig,
34
35
  panelRegistry: { addPanel: jest.fn(), removePanel: jest.fn(), getPanelConfig: jest.fn(() => mockPanelConfig) },
35
36
  buttonConfig: {},
@@ -82,6 +83,11 @@ describe('actionsMap full coverage', () => {
82
83
  expect(result.interfaceType).toBe('compact')
83
84
  })
84
85
 
86
+ test('SET_INTERFACE_TYPE returns same state reference when interfaceType unchanged', () => {
87
+ const result = actionsMap.SET_INTERFACE_TYPE(state, state.interfaceType)
88
+ expect(result).toBe(state)
89
+ })
90
+
85
91
  test('OPEN_PANEL adds a panel with props', () => {
86
92
  const result = actionsMap.OPEN_PANEL(state, { panelId: 'panel2', props: { foo: 'bar' } })
87
93
  expect(result.openPanels.panel2?.props).toEqual({ foo: 'bar' })
@@ -152,6 +158,13 @@ describe('actionsMap full coverage', () => {
152
158
  expect(r2.pressedButtons.has('btn5')).toBe(false)
153
159
  })
154
160
 
161
+ test('TOGGLE_BUTTON_EXPANDED adds/removes button', () => {
162
+ const r1 = actionsMap.TOGGLE_BUTTON_EXPANDED(state, { id: 'btn8', isExpanded: true })
163
+ expect(r1.expandedButtons.has('btn8')).toBe(true)
164
+ const r2 = actionsMap.TOGGLE_BUTTON_EXPANDED(state, { id: 'btn7', isExpanded: false })
165
+ expect(r2.expandedButtons.has('btn7')).toBe(false)
166
+ })
167
+
155
168
  test('REGISTER_PANEL updates panelConfig', () => {
156
169
  const payload = { id: 'panelX', config: { desktop: { slot: 'side' } } }
157
170
  const result = actionsMap.REGISTER_PANEL(state, payload)
@@ -204,6 +217,19 @@ describe('actionsMap full coverage', () => {
204
217
  expect(state.buttonRegistry.addButton).toHaveBeenCalled()
205
218
  })
206
219
 
220
+ test('ADD_BUTTON sets hidden, disabled, pressed and expanded state when config flags are true', () => {
221
+ const payload = {
222
+ id: 'btnSpecial',
223
+ config: { isHidden: true, isDisabled: true, isPressed: true, isExpanded: true }
224
+ }
225
+ const result = actionsMap.ADD_BUTTON(state, payload)
226
+ expect(result.buttonConfig.btnSpecial).toBeDefined()
227
+ expect(result.hiddenButtons.has('btnSpecial')).toBe(true)
228
+ expect(result.disabledButtons.has('btnSpecial')).toBe(true)
229
+ expect(result.pressedButtons.has('btnSpecial')).toBe(true)
230
+ expect(result.expandedButtons.has('btnSpecial')).toBe(true)
231
+ })
232
+
207
233
  // ---------------------- FALLBACK / OPTIONAL BRANCHES ----------------------
208
234
  test('SET_MODE uses panelRegistry.getPanelConfig() when panelConfig missing', () => {
209
235
  const tmp = { ...state, panelConfig: undefined }
@@ -41,6 +41,7 @@ export const initialState = (config) => {
41
41
  disabledButtons: new Set(),
42
42
  hiddenButtons: new Set(),
43
43
  pressedButtons: new Set(),
44
+ expandedButtons: new Set(),
44
45
  hasExclusiveControl: false,
45
46
  openPanels,
46
47
  previousOpenPanels: {},