@defra/interactive-map 0.0.16-alpha → 0.0.18-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 (224) hide show
  1. package/assets/images/slot-map.svg +264 -0
  2. package/dist/css/index.css +1 -1
  3. package/dist/esm/im-core.js +1 -1
  4. package/dist/esm/im-shell.js +1 -1
  5. package/dist/umd/im-core.js +1 -1
  6. package/dist/umd/index.js +1 -1
  7. package/docs/api/context.md +53 -7
  8. package/docs/api/map-style-config.md +41 -2
  9. package/docs/api/marker-config.md +53 -11
  10. package/docs/api/slots.md +16 -15
  11. package/docs/api/symbol-config.md +160 -0
  12. package/docs/api/symbol-registry.md +115 -0
  13. package/docs/api.md +25 -22
  14. package/docs/getting-started.md +4 -1
  15. package/docs/plugins/datasets.md +657 -0
  16. package/docs/plugins/interact.md +68 -43
  17. package/docs/plugins/search.md +15 -3
  18. package/docs/plugins.md +1 -1
  19. package/package.json +2 -2
  20. package/plugins/beta/datasets/dist/css/index.css +103 -15
  21. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  22. package/plugins/beta/datasets/dist/esm/index.js +1 -1
  23. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  25. package/plugins/beta/datasets/src/DatasetsInit.jsx +29 -9
  26. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +159 -0
  28. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +75 -0
  29. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +440 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +27 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +31 -0
  32. package/plugins/beta/datasets/src/api/addDataset.js +2 -8
  33. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  34. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  35. package/plugins/beta/datasets/src/api/removeDataset.js +2 -44
  36. package/plugins/beta/datasets/src/api/setData.js +10 -0
  37. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  38. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  39. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  40. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  41. package/plugins/beta/datasets/src/components/EmptyKey.jsx +7 -0
  42. package/plugins/beta/datasets/src/components/EmptyKey.test.jsx +21 -0
  43. package/plugins/beta/datasets/src/components/KeySvg.jsx +24 -0
  44. package/plugins/beta/datasets/src/components/KeySvgLine.jsx +19 -0
  45. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +15 -0
  46. package/plugins/beta/datasets/src/components/KeySvgRect.jsx +22 -0
  47. package/plugins/beta/datasets/src/components/KeySvgSymbol.jsx +16 -0
  48. package/plugins/beta/datasets/src/components/svgProperties.js +20 -0
  49. package/plugins/beta/datasets/src/datasets.js +39 -56
  50. package/plugins/beta/datasets/src/defaults.js +44 -8
  51. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +34 -25
  52. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  53. package/plugins/beta/datasets/src/index.js +2 -1
  54. package/plugins/beta/datasets/src/manifest.js +25 -17
  55. package/plugins/beta/datasets/src/panels/Key.jsx +51 -51
  56. package/plugins/beta/datasets/src/panels/Key.module.scss +59 -9
  57. package/plugins/beta/datasets/src/panels/Layers.jsx +132 -29
  58. package/plugins/beta/datasets/src/panels/Layers.module.scss +56 -8
  59. package/plugins/beta/datasets/src/reducer.js +134 -9
  60. package/plugins/beta/datasets/src/reducers/keyReducer.js +34 -0
  61. package/plugins/beta/datasets/src/utils/bbox.js +7 -5
  62. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  63. package/plugins/beta/datasets/src/utils/mergeSublayer.js +86 -0
  64. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  65. package/plugins/beta/draw-es/src/DrawInit.jsx +3 -2
  66. package/plugins/beta/draw-ml/dist/css/index.css +21 -1
  67. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  68. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  69. package/plugins/beta/draw-ml/dist/umd/index.js +1 -1
  70. package/plugins/beta/draw-ml/src/DrawInit.jsx +4 -3
  71. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  72. package/plugins/beta/draw-ml/src/manifest.js +16 -16
  73. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  74. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  75. package/plugins/beta/frame/src/Frame.jsx +5 -5
  76. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  77. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  78. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  79. package/plugins/beta/map-styles/src/MapStyles.jsx +5 -4
  80. package/plugins/beta/map-styles/src/MapStylesInit.jsx +5 -4
  81. package/plugins/beta/map-styles/src/manifest.js +1 -1
  82. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  83. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  84. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  85. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  86. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  87. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  88. package/plugins/interact/dist/css/index.css +1 -1
  89. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  90. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  91. package/plugins/interact/dist/umd/index.js +1 -1
  92. package/plugins/interact/src/InteractInit.jsx +14 -5
  93. package/plugins/interact/src/InteractInit.test.js +26 -6
  94. package/plugins/interact/src/api/enable.test.js +7 -7
  95. package/plugins/interact/src/defaults.js +4 -6
  96. package/plugins/interact/src/events.js +9 -6
  97. package/plugins/interact/src/events.test.js +28 -4
  98. package/plugins/interact/src/hooks/useHighlightSync.js +3 -3
  99. package/plugins/interact/src/hooks/useHighlightSync.test.js +6 -6
  100. package/plugins/interact/src/hooks/useHoverCursor.js +10 -0
  101. package/plugins/interact/src/hooks/useHoverCursor.test.js +44 -0
  102. package/plugins/interact/src/hooks/useInteractionHandlers.js +111 -69
  103. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +147 -32
  104. package/plugins/interact/src/interact.scss +0 -7
  105. package/plugins/interact/src/manifest.js +14 -18
  106. package/plugins/interact/src/manifest.test.js +3 -1
  107. package/plugins/interact/src/reducer.js +23 -4
  108. package/plugins/interact/src/reducer.test.js +60 -11
  109. package/plugins/interact/src/utils/buildStylesMap.js +17 -4
  110. package/plugins/interact/src/utils/buildStylesMap.test.js +16 -2
  111. package/plugins/interact/src/utils/featureQueries.js +11 -6
  112. package/plugins/interact/src/utils/featureQueries.test.js +8 -1
  113. package/plugins/search/dist/css/index.css +1 -1
  114. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  115. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  116. package/plugins/search/src/Search.jsx +3 -1
  117. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  118. package/plugins/search/src/events/fetchSuggestions.js +6 -4
  119. package/plugins/search/src/events/fetchSuggestions.test.js +26 -4
  120. package/plugins/search/src/events/formHandlers.js +3 -3
  121. package/plugins/search/src/events/formHandlers.test.js +1 -1
  122. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  123. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  124. package/plugins/search/src/utils/updateMap.js +3 -3
  125. package/plugins/search/src/utils/updateMap.test.js +3 -3
  126. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  127. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  128. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  129. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  130. package/providers/maplibre/dist/umd/index.js +1 -1
  131. package/providers/maplibre/src/appEvents.js +7 -0
  132. package/providers/maplibre/src/appEvents.test.js +18 -4
  133. package/providers/maplibre/src/maplibreProvider.js +52 -0
  134. package/providers/maplibre/src/maplibreProvider.test.js +105 -1
  135. package/providers/maplibre/src/utils/highlightFeatures.js +37 -7
  136. package/providers/maplibre/src/utils/highlightFeatures.test.js +153 -95
  137. package/providers/maplibre/src/utils/hoverCursor.js +61 -0
  138. package/providers/maplibre/src/utils/hoverCursor.test.js +130 -0
  139. package/providers/maplibre/src/utils/patternImages.js +70 -0
  140. package/providers/maplibre/src/utils/patternImages.test.js +180 -0
  141. package/providers/maplibre/src/utils/queryFeatures.js +38 -16
  142. package/providers/maplibre/src/utils/queryFeatures.test.js +20 -3
  143. package/providers/maplibre/src/utils/rasteriseToImageData.js +30 -0
  144. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +69 -0
  145. package/providers/maplibre/src/utils/symbolImages.js +147 -0
  146. package/providers/maplibre/src/utils/symbolImages.test.js +248 -0
  147. package/src/App/components/Actions/Actions.jsx +2 -2
  148. package/src/App/components/Actions/Actions.module.scss +0 -7
  149. package/src/App/components/Actions/Actions.test.jsx +1 -1
  150. package/src/App/components/Icon/Icon.jsx +3 -2
  151. package/src/App/components/Icon/Icon.module.scss +4 -0
  152. package/src/App/components/Icon/Icon.test.jsx +43 -4
  153. package/src/App/components/MapButton/MapButton.jsx +42 -17
  154. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  155. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  156. package/src/App/components/Markers/Markers.jsx +122 -27
  157. package/src/App/components/Markers/Markers.module.scss +0 -10
  158. package/src/App/components/Markers/Markers.test.jsx +246 -0
  159. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  160. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  161. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  162. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  163. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  164. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  165. package/src/App/hooks/useInterfaceAPI.js +6 -0
  166. package/src/App/hooks/useInterfaceAPI.test.js +156 -0
  167. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  168. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  169. package/src/App/hooks/useMarkersAPI.js +2 -5
  170. package/src/App/hooks/useMarkersAPI.test.js +4 -4
  171. package/src/App/layout/Layout.jsx +14 -9
  172. package/src/App/layout/Layout.test.jsx +6 -4
  173. package/src/App/layout/layout.module.scss +67 -29
  174. package/src/App/registry/pluginRegistry.js +1 -1
  175. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  176. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  177. package/src/App/renderer/mapButtons.js +1 -1
  178. package/src/App/renderer/mapPanels.test.js +2 -2
  179. package/src/App/renderer/slotHelpers.js +2 -2
  180. package/src/App/renderer/slotHelpers.test.js +5 -5
  181. package/src/App/renderer/slots.js +9 -5
  182. package/src/App/store/AppProvider.jsx +3 -1
  183. package/src/App/store/AppProvider.test.jsx +1 -1
  184. package/src/App/store/ServiceProvider.jsx +8 -4
  185. package/src/App/store/appActionsMap.js +16 -0
  186. package/src/App/store/appActionsMap.test.js +27 -0
  187. package/src/App/store/appDispatchMiddleware.js +1 -1
  188. package/src/App/store/appDispatchMiddleware.test.js +2 -2
  189. package/src/App/store/appReducer.js +2 -0
  190. package/src/App/store/mapActionsMap.js +4 -6
  191. package/src/App/store/mapActionsMap.test.js +3 -2
  192. package/src/App/store/mapReducer.js +2 -1
  193. package/src/InteractiveMap/InteractiveMap.js +4 -0
  194. package/src/config/appConfig.js +5 -8
  195. package/src/config/appConfig.test.js +1 -2
  196. package/src/config/defaults.js +0 -2
  197. package/src/config/events.js +28 -0
  198. package/src/config/mapTheme.js +56 -0
  199. package/src/config/patternConfig.js +16 -0
  200. package/src/config/symbolConfig.js +80 -0
  201. package/src/scss/main.scss +1 -0
  202. package/src/scss/settings/_colors.scss +0 -9
  203. package/src/scss/settings/_dimensions.scss +0 -1
  204. package/src/services/patternRegistry.js +40 -0
  205. package/src/services/patternRegistry.test.js +48 -0
  206. package/src/services/symbolRegistry.js +113 -0
  207. package/src/services/symbolRegistry.test.js +262 -0
  208. package/src/types.js +93 -11
  209. package/src/utils/getSafeZoneInset.js +9 -7
  210. package/src/utils/getSafeZoneInset.test.js +10 -10
  211. package/src/utils/patternUtils.js +94 -0
  212. package/src/utils/patternUtils.test.js +160 -0
  213. package/src/utils/symbolUtils.js +85 -0
  214. package/src/utils/symbolUtils.test.js +156 -0
  215. package/webpack.dev.mjs +1 -1
  216. package/docs/api/slot-map.svg +0 -1
  217. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  218. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  219. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  220. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  221. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  222. package/plugins/beta/datasets/src/mapLayers.js +0 -164
  223. /package/src/{utils → services}/logger.js +0 -0
  224. /package/src/{utils → services}/logger.test.js +0 -0
@@ -0,0 +1,258 @@
1
+ import { useState, useMemo, useEffect } from 'react'
2
+ import { stringToKebab } from '../../../utils/stringToKebab.js'
3
+
4
+ /**
5
+ * Computes the position and alignment style for the popup menu based on the
6
+ * triggering button's bounding rect. Positions above/below and left/center/right
7
+ * depending on which third of the screen the button centre falls in.
8
+ *
9
+ * @param {DOMRect|null} buttonRect - Bounding rect of the trigger button, or null.
10
+ * @returns {{ style: object, direction: string, halign: string }}
11
+ */
12
+ const getMenuStyle = (buttonRect) => {
13
+ if (!buttonRect) {
14
+ return { style: {}, direction: 'below' }
15
+ }
16
+ const style = {}
17
+ let direction
18
+ if (buttonRect.top >= window.innerHeight / 2) {
19
+ style.bottom = `${window.innerHeight - buttonRect.top}px`
20
+ direction = 'above'
21
+ } else {
22
+ style.top = `${buttonRect.bottom}px`
23
+ direction = 'below'
24
+ }
25
+ const buttonCenterX = (buttonRect.left + buttonRect.right) / 2
26
+ let halign
27
+ if (buttonCenterX > (window.innerWidth * 2) / 3) { // NOSONAR, third of a page width
28
+ style.right = `${window.innerWidth - buttonRect.right}px`
29
+ halign = 'right'
30
+ } else if (buttonCenterX < window.innerWidth / 3) { // NOSONAR, third of a page width
31
+ style.left = `${buttonRect.left}px`
32
+ halign = 'left'
33
+ } else {
34
+ style.left = `${buttonCenterX}px`
35
+ halign = 'center'
36
+ }
37
+ return { style, direction, halign }
38
+ }
39
+
40
+ /**
41
+ * Invokes an item's action via buttonConfig.onClick (if configured) or item.onClick.
42
+ * For keyboard-triggered activations also dispatches a synthetic MouseEvent so that
43
+ * any window-level click listeners (e.g. editVertexMode) fire as expected.
44
+ * The synthetic event is marked _fromKeyboardActivation so handleItemClick can
45
+ * ignore it and avoid double-activation.
46
+ *
47
+ * @param {React.SyntheticEvent} e - The triggering React event.
48
+ * @param {object} item - The item being activated.
49
+ * @param {object} ctx - Dependencies: { buttonConfig, evaluateProp, pluginId, id }.
50
+ */
51
+ const activateItem = (e, item, { buttonConfig, evaluateProp, pluginId, id }) => {
52
+ const menuItemConfig = buttonConfig[item.id]
53
+ if (typeof menuItemConfig?.onClick === 'function') {
54
+ menuItemConfig.onClick(e, evaluateProp(ctx => ctx, pluginId))
55
+ } else if (typeof item.onClick === 'function') {
56
+ item.onClick(e.nativeEvent)
57
+ } else {
58
+ // No action
59
+ }
60
+ if (e.nativeEvent instanceof KeyboardEvent) {
61
+ const el = document.getElementById(`${id}-${stringToKebab(item.id)}`)
62
+ if (el) {
63
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true })
64
+ click._fromKeyboardActivation = true
65
+ el.dispatchEvent(click)
66
+ }
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Builds the keydown handler for the menu UL. Handles Escape/Tab (close & focus),
72
+ * ArrowDown/Up (navigate visible items), Home/End (jump to ends),
73
+ * Enter (activate and close), Space (activate; close only for non-checkbox items).
74
+ *
75
+ * @param {object} p
76
+ * @param {Array} p.items - All menu item descriptors.
77
+ * @param {number[]} p.visibleIndices - Indices of non-hidden items.
78
+ * @param {number} p.index - Currently highlighted index.
79
+ * @param {Function} p.setIndex - State setter for highlighted index.
80
+ * @param {Set} p.disabledButtons - IDs of disabled items.
81
+ * @param {object} p.instigator - DOM node of the trigger button.
82
+ * @param {Function} p.setIsOpen - Callback to close the menu.
83
+ * @param {object} p.activateCtx - Context passed through to activateItem.
84
+ * @returns {Function} onKeyDown handler for the menu element.
85
+ */
86
+ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx }) => {
87
+ const closeAndFocus = (e, preventDefault = false) => {
88
+ if (preventDefault && e?.preventDefault) {
89
+ e.preventDefault()
90
+ }
91
+ instigator.focus()
92
+ setIsOpen(false)
93
+ }
94
+
95
+ const navigateVisible = (e) => {
96
+ e.preventDefault()
97
+ const n = visibleIndices.length
98
+ if (n === 0) {
99
+ return
100
+ }
101
+ const pos = visibleIndices.indexOf(index)
102
+ let nextPos
103
+ if (e.key === 'ArrowDown') {
104
+ nextPos = pos === -1 ? 0 : (pos + 1) % n
105
+ } else if (pos === -1) {
106
+ nextPos = n - 1
107
+ } else {
108
+ nextPos = (pos - 1 + n) % n
109
+ }
110
+ setIndex(visibleIndices[nextPos])
111
+ }
112
+
113
+ const handleEnter = (e) => {
114
+ e.preventDefault()
115
+ const item = items[index]
116
+ if (item && !disabledButtons.has(item.id)) {
117
+ activateItem(e, item, activateCtx)
118
+ }
119
+ instigator.focus()
120
+ setIsOpen(false)
121
+ }
122
+
123
+ const handleSpace = (e) => {
124
+ e.preventDefault()
125
+ const item = items[index]
126
+ if (!item || disabledButtons.has(item.id)) {
127
+ return
128
+ }
129
+ activateItem(e, item, activateCtx)
130
+ if (!(item.isPressed !== undefined || item.pressedWhen)) {
131
+ instigator.focus()
132
+ setIsOpen(false)
133
+ }
134
+ }
135
+
136
+ return (e) => {
137
+ if (['Escape', 'Esc'].includes(e.key)) {
138
+ closeAndFocus(e, true)
139
+ return
140
+ }
141
+ if (e.key === 'Tab') {
142
+ closeAndFocus(e)
143
+ return
144
+ }
145
+ if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
146
+ navigateVisible(e)
147
+ return
148
+ }
149
+ if (e.key === 'Home' && visibleIndices.length) {
150
+ setIndex(visibleIndices[0])
151
+ return
152
+ }
153
+ if (e.key === 'End' && visibleIndices.length) {
154
+ setIndex(visibleIndices[visibleIndices.length - 1])
155
+ return
156
+ }
157
+ if (e.key === 'Enter') {
158
+ handleEnter(e)
159
+ }
160
+ if (e.key === ' ') {
161
+ handleSpace(e)
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Custom hook encapsulating all state and event-handler logic for PopupMenu.
168
+ *
169
+ * @param {object} params
170
+ * @param {Array} params.items - Menu item descriptors.
171
+ * @param {Set} params.hiddenButtons - IDs of items that should not be visible.
172
+ * @param {number} [params.startIndex] - Exact index to select on mount; takes precedence over startPos.
173
+ * @param {string} [params.startPos] - 'first' | 'last' — initial selection strategy.
174
+ * @param {object} params.instigator - DOM node of the button that opened the menu.
175
+ * @param {string} params.instigatorKey - Key used to look up instigator in buttonRefs.
176
+ * @param {object} params.buttonRefs - Ref map of all registered button DOM nodes.
177
+ * @param {object} params.buttonConfig - Config map that may override item onClick handlers.
178
+ * @param {Set} params.disabledButtons - IDs of currently disabled items.
179
+ * @param {string} params.pluginId - Plugin context passed to evaluateProp.
180
+ * @param {Function} params.evaluateProp - Context evaluator from useEvaluateProp.
181
+ * @param {string} params.id - App-level ID prefix for DOM element IDs.
182
+ * @param {object} params.menuRef - Ref to the menu UL element.
183
+ * @param {Function} params.setIsOpen - Callback to open/close the menu.
184
+ * @param {DOMRect} params.buttonRect - Bounding rect of the trigger button for positioning.
185
+ * @returns {{ index: number, handleMenuKeyDown: Function, handleItemClick: Function,
186
+ * menuStyle: object, menuDirection: string, menuHAlign: string }}
187
+ */
188
+ export const usePopupMenu = ({
189
+ items, hiddenButtons, startIndex, startPos, instigator, instigatorKey,
190
+ buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect
191
+ }) => {
192
+ const visibleIndices = useMemo(() => {
193
+ const visible = []
194
+ items.forEach((item, idx) => {
195
+ if (!hiddenButtons.has(item.id)) {
196
+ visible.push(idx)
197
+ }
198
+ })
199
+ return visible
200
+ }, [items, hiddenButtons])
201
+
202
+ const [index, setIndex] = useState(() => {
203
+ if (typeof startIndex === 'number') {
204
+ return startIndex
205
+ }
206
+ if (startPos === 'first') {
207
+ return visibleIndices[0] ?? -1
208
+ }
209
+ if (startPos === 'last') {
210
+ return visibleIndices[visibleIndices.length - 1] ?? -1
211
+ }
212
+ return -1
213
+ })
214
+
215
+ const activateCtx = { buttonConfig, evaluateProp, pluginId, id }
216
+
217
+ const handleMenuKeyDown = createMenuKeyDownHandler({
218
+ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx
219
+ })
220
+
221
+ const handleOutside = (e) => {
222
+ if (menuRef.current?.contains(e.target) || buttonRefs.current[instigatorKey]?.contains(e.target)) {
223
+ return
224
+ }
225
+ setIsOpen(false)
226
+ }
227
+
228
+ const handleItemClick = (e, item) => {
229
+ if (e.nativeEvent._fromKeyboardActivation || disabledButtons.has(item.id)) {
230
+ return
231
+ }
232
+ setIsOpen(false)
233
+ activateItem(e, item, activateCtx)
234
+ }
235
+
236
+ useEffect(() => {
237
+ menuRef.current?.focus()
238
+ if (startPos === 'first') {
239
+ setIndex(visibleIndices[0] ?? -1)
240
+ } else if (startPos === 'last') {
241
+ setIndex(visibleIndices[visibleIndices.length - 1] ?? -1)
242
+ } else {
243
+ // No action
244
+ }
245
+ const handleResize = () => setIsOpen(false)
246
+ document.addEventListener('focusin', handleOutside)
247
+ document.addEventListener('pointerdown', handleOutside)
248
+ window.addEventListener('resize', handleResize)
249
+ return () => {
250
+ document.removeEventListener('focusin', handleOutside)
251
+ document.removeEventListener('pointerdown', handleOutside)
252
+ window.removeEventListener('resize', handleResize)
253
+ }
254
+ }, [])
255
+
256
+ const { style: menuStyle, direction: menuDirection, halign: menuHAlign } = getMenuStyle(buttonRect)
257
+ return { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign }
258
+ }
@@ -1,4 +1,3 @@
1
- // src/core/hooks/useButtonStateEvaluator.js
2
1
  import { useLayoutEffect, useContext } from 'react'
3
2
  import { useApp } from '../store/appContext.js'
4
3
  import { useConfig } from '../store/configContext.js'
@@ -61,6 +60,12 @@ export function useButtonStateEvaluator (evaluateProp) {
61
60
  }
62
61
 
63
62
  const { dispatch } = appState
63
+ let dispatchCount = 0
64
+
65
+ const trackingDispatch = (action) => {
66
+ dispatchCount++
67
+ dispatch(action)
68
+ }
64
69
 
65
70
  pluginRegistry.registeredPlugins.forEach(plugin => {
66
71
  const buttons = (plugin?.manifest?.buttons ?? []).flatMap(b => [b, ...(b.menuItems ?? [])])
@@ -70,10 +75,15 @@ export function useButtonStateEvaluator (evaluateProp) {
70
75
  btn,
71
76
  pluginId: plugin.id,
72
77
  appState,
73
- dispatch,
78
+ dispatch: trackingDispatch,
74
79
  evaluateProp
75
80
  })
76
81
  )
77
82
  })
83
+
84
+ if (dispatchCount === 0 && !appState.arePluginsEvaluated) {
85
+ // No changes and flag not yet set — all button states have settled.
86
+ dispatch({ type: 'PLUGINS_EVALUATED' })
87
+ }
78
88
  }, [appState, pluginContext, evaluateProp])
79
89
  }
@@ -22,6 +22,7 @@ describe('useButtonStateEvaluator', () => {
22
22
  hiddenButtons: new Set(),
23
23
  pressedButtons: new Set(),
24
24
  expandedButtons: new Set(),
25
+ arePluginsEvaluated: true, // stable by default; override in settlement tests
25
26
  dispatch: mockDispatch
26
27
  }
27
28
  useApp.mockReturnValue(mockAppState)
@@ -149,8 +150,6 @@ describe('useButtonStateEvaluator', () => {
149
150
  })
150
151
 
151
152
  it('covers fallback to empty array when manifest or buttons is missing', () => {
152
- // Branch 1: Plugin exists but manifest is missing
153
- // Branch 2: Manifest exists but buttons is missing
154
153
  mockPluginRegistry.registeredPlugins = [
155
154
  { id: 'p1' },
156
155
  { id: 'p2', manifest: {} },
@@ -158,9 +157,44 @@ describe('useButtonStateEvaluator', () => {
158
157
  ]
159
158
 
160
159
  renderHook(() => useButtonStateEvaluator((fn) => fn()))
160
+ expect(mockDispatch).not.toHaveBeenCalled()
161
+ })
162
+
163
+ // --- Plugin evaluation settlement ---
164
+
165
+ it('dispatches PLUGINS_EVALUATED when no button states changed and arePluginsEvaluated is false', () => {
166
+ mockAppState.arePluginsEvaluated = false
167
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
168
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'PLUGINS_EVALUATED' })
169
+ })
161
170
 
162
- // If the fallback (|| []) works, the code continues to the next plugin
163
- // without throwing a "cannot read property forEach of undefined" error.
171
+ it('does not dispatch PLUGINS_EVALUATED when arePluginsEvaluated is already true', () => {
172
+ mockAppState.arePluginsEvaluated = true
173
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
164
174
  expect(mockDispatch).not.toHaveBeenCalled()
165
175
  })
176
+
177
+ it('does not dispatch CLEAR_PLUGINS_EVALUATED or PLUGINS_EVALUATED when button states change', () => {
178
+ mockAppState.arePluginsEvaluated = false
179
+ mockPluginRegistry.registeredPlugins = [{
180
+ id: 'p1',
181
+ manifest: { buttons: [{ id: 'btn1', hiddenWhen: () => true }] }
182
+ }]
183
+
184
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
185
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_BUTTON_HIDDEN', payload: { id: 'btn1', isHidden: true } })
186
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
187
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'PLUGINS_EVALUATED' })
188
+ })
189
+
190
+ it('does not dispatch CLEAR_PLUGINS_EVALUATED when button states change and already evaluated', () => {
191
+ mockAppState.arePluginsEvaluated = true
192
+ mockPluginRegistry.registeredPlugins = [{
193
+ id: 'p1',
194
+ manifest: { buttons: [{ id: 'btn1', hiddenWhen: () => true }] }
195
+ }]
196
+
197
+ renderHook(() => useButtonStateEvaluator((fn) => fn()))
198
+ expect(mockDispatch).not.toHaveBeenCalledWith({ type: 'CLEAR_PLUGINS_EVALUATED' })
199
+ })
166
200
  })
@@ -54,12 +54,16 @@ export const useInterfaceAPI = () => {
54
54
  }
55
55
  }
56
56
 
57
+ const handleAppVisible = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: true })
58
+ const handleAppHidden = () => dispatchRef.current({ type: 'TOGGLE_APP_VISIBLE', payload: false })
57
59
  const handleAddPanel = ({ id, config }) => dispatchRef.current({ type: 'ADD_PANEL', payload: { id, config } })
58
60
  const handleRemovePanel = (id) => dispatchRef.current({ type: 'REMOVE_PANEL', payload: id })
59
61
  const handleShowPanel = (id) => dispatchRef.current({ type: 'OPEN_PANEL', payload: { panelId: id } })
60
62
  const handleHidePanel = (id) => dispatchRef.current({ type: 'CLOSE_PANEL', payload: id })
61
63
  const handleAddControl = ({ id, config }) => dispatchRef.current({ type: 'ADD_CONTROL', payload: { id, config } })
62
64
 
65
+ eventBus.on(events.APP_VISIBLE, handleAppVisible)
66
+ eventBus.on(events.APP_HIDDEN, handleAppHidden)
63
67
  eventBus.on(events.APP_ADD_BUTTON, handleAddButton)
64
68
  eventBus.on(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
65
69
  eventBus.on(events.APP_ADD_PANEL, handleAddPanel)
@@ -69,6 +73,8 @@ export const useInterfaceAPI = () => {
69
73
  eventBus.on(events.APP_ADD_CONTROL, handleAddControl)
70
74
 
71
75
  return () => {
76
+ eventBus.off(events.APP_VISIBLE, handleAppVisible)
77
+ eventBus.off(events.APP_HIDDEN, handleAppHidden)
72
78
  eventBus.off(events.APP_ADD_BUTTON, handleAddButton)
73
79
  eventBus.off(events.APP_TOGGLE_BUTTON_STATE, handleToggleButtonState)
74
80
  eventBus.off(events.APP_ADD_PANEL, handleAddPanel)
@@ -0,0 +1,156 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useInterfaceAPI } from './useInterfaceAPI.js'
3
+ import { useApp } from '../store/appContext.js'
4
+ import { useService } from '../store/serviceContext.js'
5
+
6
+ jest.mock('../store/appContext.js')
7
+ jest.mock('../store/serviceContext.js')
8
+
9
+ const makeEventBus = () => {
10
+ const handlers = {}
11
+ return {
12
+ on: jest.fn((event, handler) => { handlers[event] = handler }),
13
+ off: jest.fn(),
14
+ emit: (event, payload) => handlers[event]?.(payload),
15
+ _handlers: handlers
16
+ }
17
+ }
18
+
19
+ describe('useInterfaceAPI', () => {
20
+ let mockDispatch, mockEventBus, mockState
21
+
22
+ beforeEach(() => {
23
+ mockDispatch = jest.fn()
24
+ mockEventBus = makeEventBus()
25
+ mockState = {
26
+ hiddenButtons: new Set(),
27
+ disabledButtons: new Set(),
28
+ pressedButtons: new Set(),
29
+ expandedButtons: new Set()
30
+ }
31
+
32
+ useApp.mockReturnValue({ dispatch: mockDispatch, ...mockState })
33
+ useService.mockReturnValue({ eventBus: mockEventBus })
34
+ })
35
+
36
+ it('dispatches ADD_BUTTON on app:addbutton', () => {
37
+ renderHook(() => useInterfaceAPI())
38
+ act(() => mockEventBus.emit('app:addbutton', { id: 'btn1', config: { label: 'Test' } }))
39
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'btn1', config: { label: 'Test' } } })
40
+ })
41
+
42
+ it('also dispatches ADD_BUTTON for each menuItem', () => {
43
+ renderHook(() => useInterfaceAPI())
44
+ act(() => mockEventBus.emit('app:addbutton', {
45
+ id: 'btn1',
46
+ config: {
47
+ label: 'Parent',
48
+ menuItems: [
49
+ { id: 'item1', label: 'Item 1' },
50
+ { id: 'item2', label: 'Item 2' }
51
+ ]
52
+ }
53
+ }))
54
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'btn1', config: expect.objectContaining({ label: 'Parent' }) } })
55
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item1', config: { id: 'item1', label: 'Item 1', isMenuItem: true } } })
56
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_BUTTON', payload: { id: 'item2', config: { id: 'item2', label: 'Item 2', isMenuItem: true } } })
57
+ })
58
+
59
+ it('dispatches TOGGLE_APP_VISIBLE true on app:visible', () => {
60
+ renderHook(() => useInterfaceAPI())
61
+ act(() => mockEventBus.emit('app:visible'))
62
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: true })
63
+ })
64
+
65
+ it('dispatches TOGGLE_APP_VISIBLE false on app:hidden', () => {
66
+ renderHook(() => useInterfaceAPI())
67
+ act(() => mockEventBus.emit('app:hidden'))
68
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'TOGGLE_APP_VISIBLE', payload: false })
69
+ })
70
+
71
+ it('dispatches ADD_PANEL on app:addpanel', () => {
72
+ renderHook(() => useInterfaceAPI())
73
+ act(() => mockEventBus.emit('app:addpanel', { id: 'panel1', config: { title: 'My Panel' } }))
74
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_PANEL', payload: { id: 'panel1', config: { title: 'My Panel' } } })
75
+ })
76
+
77
+ it('dispatches REMOVE_PANEL on app:removepanel', () => {
78
+ renderHook(() => useInterfaceAPI())
79
+ act(() => mockEventBus.emit('app:removepanel', 'panel1'))
80
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'REMOVE_PANEL', payload: 'panel1' })
81
+ })
82
+
83
+ it('dispatches OPEN_PANEL on app:showpanel', () => {
84
+ renderHook(() => useInterfaceAPI())
85
+ act(() => mockEventBus.emit('app:showpanel', 'panel1'))
86
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'OPEN_PANEL', payload: { panelId: 'panel1' } })
87
+ })
88
+
89
+ it('dispatches CLOSE_PANEL on app:hidepanel', () => {
90
+ renderHook(() => useInterfaceAPI())
91
+ act(() => mockEventBus.emit('app:hidepanel', 'panel1'))
92
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'CLOSE_PANEL', payload: 'panel1' })
93
+ })
94
+
95
+ it('dispatches ADD_CONTROL on app:addcontrol', () => {
96
+ renderHook(() => useInterfaceAPI())
97
+ act(() => mockEventBus.emit('app:addcontrol', { id: 'ctrl1', config: { position: 'top-left' } }))
98
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'ADD_CONTROL', payload: { id: 'ctrl1', config: { position: 'top-left' } } })
99
+ })
100
+
101
+ describe('handleToggleButtonState', () => {
102
+ it.each([
103
+ ['hidden', 'TOGGLE_BUTTON_HIDDEN', 'isHidden'],
104
+ ['disabled', 'TOGGLE_BUTTON_DISABLED', 'isDisabled'],
105
+ ['pressed', 'TOGGLE_BUTTON_PRESSED', 'isPressed'],
106
+ ['expanded', 'TOGGLE_BUTTON_EXPANDED', 'isExpanded']
107
+ ])('sets %s to explicit boolean value when provided', (prop, actionType, payloadKey) => {
108
+ renderHook(() => useInterfaceAPI())
109
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop, value: true }))
110
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: true } })
111
+
112
+ mockDispatch.mockClear()
113
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop, value: false }))
114
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: false } })
115
+ })
116
+
117
+ it.each([
118
+ ['hidden', 'TOGGLE_BUTTON_HIDDEN', 'isHidden', 'hiddenButtons'],
119
+ ['disabled', 'TOGGLE_BUTTON_DISABLED', 'isDisabled', 'disabledButtons'],
120
+ ['pressed', 'TOGGLE_BUTTON_PRESSED', 'isPressed', 'pressedButtons'],
121
+ ['expanded', 'TOGGLE_BUTTON_EXPANDED', 'isExpanded', 'expandedButtons']
122
+ ])('toggles %s when no boolean value provided', (prop, actionType, payloadKey, stateKey) => {
123
+ renderHook(() => useInterfaceAPI())
124
+
125
+ // Not in set → toggles to true
126
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop }))
127
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: true } })
128
+
129
+ // Already in set → toggles to false
130
+ mockDispatch.mockClear()
131
+ mockState[stateKey].add('btn1')
132
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop }))
133
+ expect(mockDispatch).toHaveBeenCalledWith({ type: actionType, payload: { id: 'btn1', [payloadKey]: false } })
134
+ })
135
+
136
+ it('does nothing for unknown prop', () => {
137
+ renderHook(() => useInterfaceAPI())
138
+ act(() => mockEventBus.emit('app:togglebuttonstate', { id: 'btn1', prop: 'unknown', value: true }))
139
+ expect(mockDispatch).not.toHaveBeenCalled()
140
+ })
141
+ })
142
+
143
+ it('removes all event listeners on unmount', () => {
144
+ const { unmount } = renderHook(() => useInterfaceAPI())
145
+ unmount()
146
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:visible', expect.any(Function))
147
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:hidden', expect.any(Function))
148
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addbutton', expect.any(Function))
149
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:togglebuttonstate', expect.any(Function))
150
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addpanel', expect.any(Function))
151
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:removepanel', expect.any(Function))
152
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:showpanel', expect.any(Function))
153
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:hidepanel', expect.any(Function))
154
+ expect(mockEventBus.off).toHaveBeenCalledWith('app:addcontrol', expect.any(Function))
155
+ })
156
+ })