@defra/interactive-map 0.0.15-alpha → 0.0.17-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 (263) hide show
  1. package/assets/css/docusaurus.css +104 -0
  2. package/assets/images/favicon.svg +1 -0
  3. package/assets/images/hero.png +0 -0
  4. package/assets/images/slot-map.svg +264 -0
  5. package/dist/css/index.css +1 -1
  6. package/dist/esm/im-core.js +1 -1
  7. package/dist/esm/im-shell.js +1 -1
  8. package/dist/umd/im-core.js +1 -1
  9. package/dist/umd/index.js +1 -1
  10. package/docs/api/slots.md +90 -6
  11. package/docs/api.md +4 -4
  12. package/docs/architecture.md +3 -1
  13. package/docs/{demo.mdx → examples.mdx} +1 -1
  14. package/docs/getting-started.md +5 -4
  15. package/docs/index.mdx +42 -0
  16. package/docs/plugins/datasets.md +561 -0
  17. package/docs/plugins/interact.md +176 -55
  18. package/docs/plugins/map-styles.md +64 -7
  19. package/docs/plugins/search.md +207 -63
  20. package/docs/plugins.md +8 -16
  21. package/docusaurus.config.cjs +34 -34
  22. package/jest.setup.js +1 -1
  23. package/package.json +6 -5
  24. package/plugins/beta/datasets/dist/css/index.css +85 -15
  25. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/im-datasets-plugin.js +1 -1
  27. package/plugins/beta/datasets/dist/umd/index.js +1 -1
  28. package/plugins/beta/datasets/src/DatasetsInit.jsx +24 -9
  29. package/plugins/beta/datasets/src/adapters/maplibre/index.js +18 -0
  30. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +113 -0
  31. package/plugins/beta/datasets/src/adapters/maplibre/layerIds.js +69 -0
  32. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +338 -0
  33. package/plugins/beta/datasets/src/adapters/maplibre/patternRegistry.js +48 -0
  34. package/plugins/beta/datasets/src/api/addDataset.js +3 -9
  35. package/plugins/beta/datasets/src/api/getOpacity.js +17 -0
  36. package/plugins/beta/datasets/src/api/getStyle.js +13 -0
  37. package/plugins/beta/datasets/src/api/removeDataset.js +3 -45
  38. package/plugins/beta/datasets/src/api/setData.js +8 -0
  39. package/plugins/beta/datasets/src/api/setDatasetVisibility.js +37 -0
  40. package/plugins/beta/datasets/src/api/setFeatureVisibility.js +22 -0
  41. package/plugins/beta/datasets/src/api/setOpacity.js +29 -0
  42. package/plugins/beta/datasets/src/api/setStyle.js +22 -0
  43. package/plugins/beta/datasets/src/datasets.js +33 -59
  44. package/plugins/beta/datasets/src/defaults.js +43 -9
  45. package/plugins/beta/datasets/src/fetch/createDynamicSource.js +39 -30
  46. package/plugins/beta/datasets/src/fetch/fetchGeoJSON.js +2 -2
  47. package/plugins/beta/datasets/src/manifest.js +27 -19
  48. package/plugins/beta/datasets/src/panels/Key.jsx +129 -49
  49. package/plugins/beta/datasets/src/panels/Key.module.scss +48 -9
  50. package/plugins/beta/datasets/src/panels/Layers.jsx +131 -29
  51. package/plugins/beta/datasets/src/panels/Layers.module.scss +50 -8
  52. package/plugins/beta/datasets/src/reducer.js +128 -9
  53. package/plugins/beta/datasets/src/styles/patterns.js +157 -0
  54. package/plugins/beta/datasets/src/utils/bbox.js +8 -6
  55. package/plugins/beta/datasets/src/utils/filters.js +5 -2
  56. package/plugins/beta/datasets/src/utils/mergeSublayer.js +78 -0
  57. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  58. package/plugins/beta/draw-es/src/DrawInit.jsx +16 -16
  59. package/plugins/beta/draw-es/src/api/addFeature.js +3 -3
  60. package/plugins/beta/draw-es/src/api/deleteFeature.js +3 -3
  61. package/plugins/beta/draw-es/src/api/editFeature.js +3 -3
  62. package/plugins/beta/draw-es/src/api/newPolygon.js +3 -3
  63. package/plugins/beta/draw-es/src/events.js +52 -20
  64. package/plugins/beta/draw-es/src/events.test.js +301 -0
  65. package/plugins/beta/draw-es/src/graphic.js +1 -1
  66. package/plugins/beta/draw-es/src/manifest.js +4 -4
  67. package/plugins/beta/draw-es/src/reducer.js +1 -1
  68. package/plugins/beta/draw-es/src/sketchViewModel.js +1 -1
  69. package/plugins/beta/draw-ml/dist/css/index.css +1 -1
  70. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  71. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  72. package/plugins/beta/draw-ml/src/DrawInit.jsx +49 -52
  73. package/plugins/beta/draw-ml/src/api/deleteFeature.js +1 -1
  74. package/plugins/beta/draw-ml/src/api/editFeature.js +8 -5
  75. package/plugins/beta/draw-ml/src/api/newLine.js +0 -1
  76. package/plugins/beta/draw-ml/src/api/newPolygon.js +0 -1
  77. package/plugins/beta/draw-ml/src/api/split.js +4 -4
  78. package/plugins/beta/draw-ml/src/defaults.js +1 -1
  79. package/plugins/beta/draw-ml/src/draw.scss +0 -7
  80. package/plugins/beta/draw-ml/src/events.js +8 -6
  81. package/plugins/beta/draw-ml/src/manifest.js +29 -29
  82. package/plugins/beta/draw-ml/src/mapboxDraw.js +1 -1
  83. package/plugins/beta/draw-ml/src/mapboxSnap.js +17 -18
  84. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +31 -31
  85. package/plugins/beta/draw-ml/src/modes/disabledMode.js +1 -1
  86. package/plugins/beta/draw-ml/src/modes/editVertex/touchHandlers.js +11 -11
  87. package/plugins/beta/draw-ml/src/modes/editVertex/undoHandlers.js +7 -7
  88. package/plugins/beta/draw-ml/src/modes/editVertex/vertexOperations.js +8 -8
  89. package/plugins/beta/draw-ml/src/modes/editVertex/vertexQueries.js +7 -7
  90. package/plugins/beta/draw-ml/src/modes/editVertexMode.js +32 -24
  91. package/plugins/beta/draw-ml/src/reducer.js +1 -1
  92. package/plugins/beta/draw-ml/src/undoStack.js +4 -4
  93. package/plugins/beta/draw-ml/src/utils/snapHelpers.js +12 -12
  94. package/plugins/beta/draw-ml/src/utils/spatial.js +11 -11
  95. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  96. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  97. package/plugins/beta/frame/src/Frame.jsx +9 -9
  98. package/plugins/beta/frame/src/FrameInit.jsx +4 -4
  99. package/plugins/beta/frame/src/api/addFrame.js +1 -1
  100. package/plugins/beta/frame/src/api/editFeature.js +1 -1
  101. package/plugins/beta/frame/src/config.js +1 -1
  102. package/plugins/beta/frame/src/manifest.js +3 -3
  103. package/plugins/beta/frame/src/reducer.js +1 -1
  104. package/plugins/beta/frame/src/utils.js +1 -1
  105. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  106. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  107. package/plugins/beta/map-styles/src/MapStyles.jsx +18 -18
  108. package/plugins/beta/map-styles/src/manifest.js +1 -1
  109. package/plugins/beta/scale-bar/dist/css/index.css +1 -1
  110. package/plugins/beta/scale-bar/dist/esm/im-scale-bar-plugin.js +1 -1
  111. package/plugins/beta/scale-bar/dist/umd/im-scale-bar-plugin.js +1 -1
  112. package/plugins/beta/scale-bar/src/ScaleBar.jsx +5 -5
  113. package/plugins/beta/scale-bar/src/index.test.js +3 -3
  114. package/plugins/beta/scale-bar/src/manifest.js +3 -3
  115. package/plugins/beta/scale-bar/src/scaleBar.scss +2 -1
  116. package/plugins/beta/use-location/src/UseLocation.jsx +1 -1
  117. package/plugins/beta/use-location/src/defaults.js +1 -1
  118. package/plugins/beta/use-location/src/events.js +3 -3
  119. package/plugins/interact/dist/css/index.css +1 -1
  120. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  121. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  122. package/plugins/interact/src/InteractInit.jsx +1 -2
  123. package/plugins/interact/src/api/enable.js +8 -5
  124. package/plugins/interact/src/api/enable.test.js +2 -2
  125. package/plugins/interact/src/api/selectFeature.js +4 -4
  126. package/plugins/interact/src/api/unselectFeature.js +5 -5
  127. package/plugins/interact/src/defaults.js +0 -1
  128. package/plugins/interact/src/events.test.js +15 -15
  129. package/plugins/interact/src/hooks/useHighlightSync.js +1 -1
  130. package/plugins/interact/src/hooks/useInteractionHandlers.js +2 -2
  131. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +5 -5
  132. package/plugins/interact/src/interact.scss +0 -7
  133. package/plugins/interact/src/manifest.js +15 -19
  134. package/plugins/interact/src/manifest.test.js +6 -5
  135. package/plugins/interact/src/reducer.js +3 -3
  136. package/plugins/interact/src/reducer.test.js +0 -1
  137. package/plugins/interact/src/utils/spatial.js +10 -10
  138. package/plugins/interact/src/utils/spatial.test.js +14 -14
  139. package/plugins/search/dist/css/index.css +1 -1
  140. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  141. package/plugins/search/dist/esm/index.js +1 -1
  142. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  143. package/plugins/search/dist/umd/index.js +1 -1
  144. package/plugins/search/src/Search.jsx +7 -6
  145. package/plugins/search/src/Search.test.jsx +23 -23
  146. package/plugins/search/src/components/CloseButton/CloseButton.jsx +15 -15
  147. package/plugins/search/src/components/CloseButton/CloseButton.test.jsx +2 -2
  148. package/plugins/search/src/components/Form/Form.jsx +14 -14
  149. package/plugins/search/src/components/Form/Form.module.scss +2 -1
  150. package/plugins/search/src/components/Form/Form.test.jsx +11 -11
  151. package/plugins/search/src/components/OpenButton/OpenButton.jsx +16 -15
  152. package/plugins/search/src/components/OpenButton/OpenButton.test.jsx +6 -2
  153. package/plugins/search/src/components/SubmitButton/SubmitButton.jsx +15 -15
  154. package/plugins/search/src/components/Suggestions/Suggestions.jsx +6 -6
  155. package/plugins/search/src/components/Suggestions/Suggestions.test.jsx +4 -4
  156. package/plugins/search/src/datasets.js +12 -13
  157. package/plugins/search/src/datasets.test.js +1 -1
  158. package/plugins/search/src/defaults.js +1 -1
  159. package/plugins/search/src/events/fetchSuggestions.js +3 -3
  160. package/plugins/search/src/events/fetchSuggestions.test.js +1 -1
  161. package/plugins/search/src/events/formHandlers.js +3 -3
  162. package/plugins/search/src/events/formHandlers.test.js +1 -1
  163. package/plugins/search/src/events/index.js +2 -2
  164. package/plugins/search/src/events/index.test.js +2 -2
  165. package/plugins/search/src/events/inputHandlers.js +4 -4
  166. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  167. package/plugins/search/src/events/suggestionHandlers.js +2 -2
  168. package/plugins/search/src/events/suggestionHandlers.test.js +1 -1
  169. package/plugins/search/src/index.js +2 -1
  170. package/plugins/search/src/index.test.js +3 -3
  171. package/plugins/search/src/manifest.js +6 -4
  172. package/plugins/search/src/reducer.js +1 -2
  173. package/plugins/search/src/reducer.test.js +2 -2
  174. package/plugins/search/src/search.scss +10 -3
  175. package/plugins/search/src/utils/parseOsNamesResults.js +1 -2
  176. package/plugins/search/src/utils/parseOsNamesResults.test.js +2 -2
  177. package/plugins/search/src/utils/updateMap.js +1 -1
  178. package/plugins/search/src/utils/updateMap.test.js +5 -5
  179. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  180. package/providers/beta/esri/src/esriProvider.js +5 -5
  181. package/providers/beta/esri/src/utils/coords.js +1 -1
  182. package/providers/beta/esri/src/utils/esriFixes.js +1 -1
  183. package/providers/beta/esri/src/utils/query.js +4 -4
  184. package/providers/beta/esri/src/utils/spatial.js +1 -2
  185. package/providers/beta/esri/src/utils/spatial.test.js +4 -1
  186. package/providers/beta/open-names/src/utils/mapToLocationModel.test.js +1 -1
  187. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  188. package/providers/maplibre/dist/umd/im-maplibre-framework.js +1 -1
  189. package/providers/maplibre/dist/umd/im-maplibre-framework.js.LICENSE.txt +1 -1
  190. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  191. package/providers/maplibre/src/appEvents.test.js +1 -1
  192. package/providers/maplibre/src/index.js +1 -1
  193. package/providers/maplibre/src/index.test.js +3 -5
  194. package/providers/maplibre/src/mapEvents.test.js +15 -5
  195. package/providers/maplibre/src/maplibreProvider.test.js +6 -2
  196. package/providers/maplibre/src/utils/calculateLinearTextSize.js +4 -4
  197. package/providers/maplibre/src/utils/calculateLinearTextSize.test.js +3 -3
  198. package/providers/maplibre/src/utils/detectWebgl.test.js +1 -1
  199. package/providers/maplibre/src/utils/highlightFeatures.js +3 -2
  200. package/providers/maplibre/src/utils/highlightFeatures.test.js +13 -6
  201. package/providers/maplibre/src/utils/labels.js +19 -20
  202. package/providers/maplibre/src/utils/labels.test.js +15 -13
  203. package/providers/maplibre/src/utils/maplibreFixes.test.js +1 -1
  204. package/providers/maplibre/src/utils/queryFeatures.js +6 -6
  205. package/providers/maplibre/src/utils/queryFeatures.test.js +13 -13
  206. package/providers/maplibre/src/utils/spatial.js +0 -1
  207. package/providers/maplibre/src/utils/spatial.test.js +26 -27
  208. package/src/App/components/Actions/Actions.jsx +2 -2
  209. package/src/App/components/Actions/Actions.module.scss +0 -7
  210. package/src/App/components/Actions/Actions.test.jsx +1 -1
  211. package/src/App/components/Icon/Icon.jsx +3 -2
  212. package/src/App/components/Icon/Icon.module.scss +4 -0
  213. package/src/App/components/Icon/Icon.test.jsx +43 -4
  214. package/src/App/components/MapButton/MapButton.jsx +42 -17
  215. package/src/App/components/MapButton/MapButton.module.scss +4 -13
  216. package/src/App/components/MapButton/MapButton.test.jsx +27 -3
  217. package/src/App/components/PopupMenu/PopupMenu.jsx +51 -274
  218. package/src/App/components/PopupMenu/PopupMenu.module.scss +14 -7
  219. package/src/App/components/PopupMenu/PopupMenu.test.jsx +70 -1
  220. package/src/App/components/PopupMenu/usePopupMenu.js +258 -0
  221. package/src/App/hooks/useButtonStateEvaluator.js +12 -2
  222. package/src/App/hooks/useButtonStateEvaluator.test.js +38 -4
  223. package/src/App/hooks/useInterfaceAPI.js +6 -0
  224. package/src/App/hooks/useLayoutMeasurements.js +84 -18
  225. package/src/App/hooks/useLayoutMeasurements.test.js +124 -17
  226. package/src/App/layout/Layout.jsx +12 -7
  227. package/src/App/layout/Layout.test.jsx +2 -2
  228. package/src/App/layout/layout.module.scss +67 -29
  229. package/src/App/registry/pluginRegistry.js +17 -0
  230. package/src/App/registry/pluginRegistry.test.js +33 -0
  231. package/src/App/renderer/HtmlElementHost.jsx +2 -1
  232. package/src/App/renderer/HtmlElementHost.test.jsx +7 -7
  233. package/src/App/renderer/mapButtons.js +3 -2
  234. package/src/App/renderer/mapPanels.test.js +2 -2
  235. package/src/App/renderer/slotHelpers.js +2 -2
  236. package/src/App/renderer/slotHelpers.test.js +5 -5
  237. package/src/App/renderer/slots.js +9 -5
  238. package/src/App/store/AppProvider.jsx +3 -1
  239. package/src/App/store/AppProvider.test.jsx +1 -1
  240. package/src/App/store/ServiceProvider.jsx +3 -1
  241. package/src/App/store/appActionsMap.js +16 -0
  242. package/src/App/store/appActionsMap.test.js +27 -0
  243. package/src/App/store/appDispatchMiddleware.js +33 -1
  244. package/src/App/store/appDispatchMiddleware.test.js +250 -222
  245. package/src/App/store/appReducer.js +2 -0
  246. package/src/InteractiveMap/InteractiveMap.js +4 -0
  247. package/src/config/appConfig.js +7 -4
  248. package/src/config/events.js +28 -0
  249. package/src/scss/main.scss +1 -0
  250. package/src/scss/settings/_dimensions.scss +0 -1
  251. package/src/services/logger.js +6 -0
  252. package/src/services/logger.test.js +32 -0
  253. package/src/utils/getSafeZoneInset.js +9 -7
  254. package/src/utils/getSafeZoneInset.test.js +10 -10
  255. package/webpack.dev.mjs +23 -19
  256. package/docs/govuk-prototype.md +0 -23
  257. package/docs/index.md +0 -19
  258. package/plugins/beta/datasets/src/api/hideDataset.js +0 -14
  259. package/plugins/beta/datasets/src/api/hideFeatures.js +0 -41
  260. package/plugins/beta/datasets/src/api/showDataset.js +0 -14
  261. package/plugins/beta/datasets/src/api/showFeatures.js +0 -44
  262. package/plugins/beta/datasets/src/handleSetMapStyle.js +0 -54
  263. package/plugins/beta/datasets/src/mapLayers.js +0 -165
@@ -1,280 +1,59 @@
1
- import React, { useEffect, useState, useMemo } from 'react'
1
+ import { createPortal } from 'react-dom'
2
2
  import { stringToKebab } from '../../../utils/stringToKebab'
3
3
  import { useConfig } from '../../store/configContext'
4
4
  import { useApp } from '../../store/appContext'
5
5
  import { Icon } from '../Icon/Icon'
6
6
  import { useEvaluateProp } from '../../hooks/useEvaluateProp.js'
7
-
8
- /**
9
- * PopupMenu accessible keyboard-navigable dropdown menu component.
10
- * Renders a role=menu list with selected item highlighting, keyboard nav (arrows/Home/End/Enter),
11
- * and closes-on-outside-click/focus behavior. Skips hidden items during navigation.
12
- *
13
- * @component
14
- * @param {string} popupMenuId
15
- * Unique identifier for the menu element and item IDs (items get id={id}-item-{i})
16
- * @param {string} pluginId
17
- * Identifier for the owning plugin, passed to evaluateProp for context
18
- * @param {string} buttonId
19
- * Ref key of the button that triggered this menu; used to manage focus and detect outside clicks
20
- * @param {string} [startPos]
21
- * Initial selection strategy: 'first' (first visible), 'last' (last visible), or null/undefined (no selection)
22
- * @param {number} [startIndex]
23
- * Exact index to select on mount; takes precedence over startPos
24
- * @param {MutableRefObject} menuRef
25
- * Ref to the menu UL element; used for focus management and click-outside detection
26
- * @param {Array} items
27
- * Array of menu items {id, label, onClick, iconId?, iconSvgContent?, pressedWhen?} to render
28
- * @param {Function} setIsOpen
29
- * Callback to close the menu (called with false when user presses Escape/Tab or clicks outside)
30
- * @returns {JSX.Element}
31
- * A role=menu UL with keyboard handlers and visible=filtered LI children
32
- */
33
- // eslint-disable-next-line camelcase, react/jsx-pascal-case
34
- // sonarjs/disable-next-line function-name
35
- export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen }) => {
7
+ import { usePopupMenu } from './usePopupMenu'
8
+
9
+ const MenuItem = ({ item, isSelected, hiddenButtons, disabledButtons, pressedButtons, id, onItemClick }) => (
10
+ <li // NOSONAR
11
+ id={`${id}-${stringToKebab(item.id)}`}
12
+ className={`im-c-popup-menu__item${isSelected ? ' im-c-popup-menu__item--selected' : ''}`}
13
+ role={item.isPressed !== undefined || item.pressedWhen ? 'menuitemcheckbox' : 'menuitem'} // NOSONAR
14
+ aria-disabled={disabledButtons.has(item.id) || undefined} // NOSONAR
15
+ aria-checked={(item.isPressed !== undefined || item.pressedWhen) ? pressedButtons.has(item.id) : undefined} // NOSONAR
16
+ style={hiddenButtons.has(item.id) ? { display: 'none' } : undefined}
17
+ onClick={(e) => onItemClick(e, item)} // NOSONAR
18
+ >
19
+ {(item.iconId || item.iconSvgContent) && <Icon id={item.iconId} svgContent={item.iconSvgContent} />}
20
+ <span className='im-c-popup-menu__item-label'>{item.label}</span>
21
+ </li>
22
+ )
23
+
24
+ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, startPos, startIndex, menuRef, items, setIsOpen, buttonRect }) => {
36
25
  const { id } = useConfig()
37
- const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons } = useApp()
26
+ const { buttonRefs, buttonConfig, hiddenButtons, disabledButtons, pressedButtons, layoutRefs } = useApp()
38
27
  const instigatorKey = buttonId ?? instigatorId
39
28
  const instigator = buttonRefs.current[instigatorKey]
40
29
  const evaluateProp = useEvaluateProp()
41
30
 
42
- /**
43
- * Compute visible item indices by filtering out items in hiddenButtons.
44
- * Memoized on items and hiddenButtons to avoid recomputation.
45
- */
46
- const visibleIndices = useMemo(() => {
47
- const visible = []
48
- items.forEach((item, idx) => {
49
- if (!hiddenButtons.has(item.id)) {
50
- visible.push(idx)
51
- }
52
- })
53
- return visible
54
- }, [items, hiddenButtons])
55
-
56
- /**
57
- * Initialize selected index from startIndex (exact value) or startPos ('first'/'last')
58
- * Falls back to -1 (no selection) if no initial value provided or all items are hidden.
59
- */
60
- const [index, setIndex] = useState(() => {
61
- if (typeof startIndex === 'number') {
62
- return startIndex
63
- }
64
- if (startPos === 'first') {
65
- return visibleIndices[0] ?? -1
66
- }
67
- if (startPos === 'last') {
68
- return visibleIndices[visibleIndices.length - 1] ?? -1
69
- }
70
- return -1
31
+ const { index, handleMenuKeyDown, handleItemClick, menuStyle, menuDirection, menuHAlign } = usePopupMenu({
32
+ items,
33
+ hiddenButtons,
34
+ startIndex,
35
+ startPos,
36
+ instigator,
37
+ instigatorKey,
38
+ buttonRefs,
39
+ buttonConfig,
40
+ disabledButtons,
41
+ pluginId,
42
+ evaluateProp,
43
+ id,
44
+ menuRef,
45
+ setIsOpen,
46
+ buttonRect
71
47
  })
72
48
 
73
- /**
74
- * Helper: Close menu and return focus to instigator button.
75
- * @param {Event} e
76
- * The event that triggered the close (may have preventDefault called)
77
- * @param {boolean} [preventDefault=false]
78
- * Whether to call e.preventDefault() (true for Escape, false for Tab)
79
- */
80
- const closeAndFocus = (e, preventDefault = false) => {
81
- if (preventDefault && e?.preventDefault) {
82
- e.preventDefault()
83
- }
84
- instigator.focus()
85
- setIsOpen(false)
86
- }
87
-
88
- /**
89
- * Helper: Navigate visible items via ArrowDown/ArrowUp.
90
- * ArrowDown moves forward (wraps at end); ArrowUp moves backward (wraps at start).
91
- * When no selection (-1), ArrowDown picks first, ArrowUp picks last.
92
- * @param {KeyboardEvent} e
93
- * Keyboard event (checked for ArrowDown/ArrowUp)
94
- */
95
- const navigateVisible = (e) => {
96
- e.preventDefault()
97
- const vis = visibleIndices
98
- const n = vis.length
99
- if (n === 0) {
100
- return
101
- }
102
- const pos = vis.indexOf(index)
103
- let nextPos
104
- if (e.key === 'ArrowDown') {
105
- nextPos = pos === -1 ? 0 : (pos + 1) % n
106
- } else if (pos === -1) {
107
- nextPos = n - 1
108
- } else {
109
- nextPos = (pos - 1 + n) % n
110
- }
111
- setIndex(vis[nextPos])
112
- }
113
-
114
- /**
115
- * Helper: Invoke a menu item's action via buttonConfig or item.onClick.
116
- * @param {Event} e - The triggering event
117
- * @param {Object} item - The item to activate
118
- */
119
- const activateItem = (e, item) => {
120
- const menuItemConfig = buttonConfig[item.id]
121
- if (typeof menuItemConfig?.onClick === 'function') {
122
- menuItemConfig.onClick(e, evaluateProp(ctx => ctx, pluginId))
123
- } else if (typeof item.onClick === 'function') {
124
- item.onClick(e.nativeEvent)
125
- } else {
126
- // No action
127
- }
128
- // For keyboard events, also dispatch a synthetic click so native window listeners fire
129
- // (e.g. editVertexMode.onButtonClick for delete/undo in edit_vertex mode).
130
- // Marked with _fromKeyboardActivation so handleItemClick ignores it and only the
131
- // window listener handles it — preventing double-activation.
132
- if (e.nativeEvent instanceof KeyboardEvent) {
133
- const el = document.getElementById(`${id}-${stringToKebab(item.id)}`)
134
- if (el) {
135
- const click = new MouseEvent('click', { bubbles: true, cancelable: true })
136
- click._fromKeyboardActivation = true
137
- el.dispatchEvent(click)
138
- }
139
- }
140
- }
141
-
142
- /**
143
- * Helper: Handle Enter key press.
144
- * Closes menu, returns focus to instigator, then activates the selected item if enabled.
145
- * @param {KeyboardEvent} e
146
- * The Enter keydown event
147
- */
148
- const handleEnter = (e) => {
149
- e.preventDefault()
150
- const item = items[index]
151
- if (item && !disabledButtons.has(item.id)) {
152
- activateItem(e, item)
153
- }
154
- instigator.focus()
155
- setIsOpen(false)
156
- }
157
-
158
- /**
159
- * Helper: Handle Space key press.
160
- * For menuitemcheckbox: activates item only (menu stays open).
161
- * For menuitem: closes menu, returns focus to instigator, then activates item.
162
- * @param {KeyboardEvent} e
163
- * The Space keydown event
164
- */
165
- const handleSpace = (e) => {
166
- e.preventDefault()
167
- const item = items[index]
168
- if (!item || disabledButtons.has(item.id)) {
169
- return
170
- }
171
- const isCheckbox = item.isPressed !== undefined || item.pressedWhen
172
- activateItem(e, item)
173
- if (!isCheckbox) {
174
- instigator.focus()
175
- setIsOpen(false)
176
- }
177
- }
178
-
179
- /**
180
- * Main keyboard handler for the menu.
181
- * Dispatches to helpers or directly handles:
182
- * - Escape/Esc: close & focus instigator
183
- * - Tab: close & focus instigator (without preventDefault)
184
- * - ArrowDown/ArrowUp: navigate visible items
185
- * - Home: select first visible
186
- * - End: select last visible
187
- * - Enter: call selected item's onClick & close
188
- * @param {KeyboardEvent} e
189
- * Keyboard event from menu onKeyDown
190
- */
191
- const handleMenuKeyDown = (e) => {
192
- if (['Escape', 'Esc'].includes(e.key)) {
193
- closeAndFocus(e, true)
194
- return
195
- }
196
- if (e.key === 'Tab') {
197
- closeAndFocus(e)
198
- return
199
- }
200
- if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
201
- navigateVisible(e)
202
- return
203
- }
204
- if (e.key === 'Home' && visibleIndices.length) {
205
- setIndex(visibleIndices[0])
206
- return
207
- }
208
- if (e.key === 'End' && visibleIndices.length) {
209
- setIndex(visibleIndices[visibleIndices.length - 1])
210
- return
211
- }
212
- if (e.key === 'Enter') {
213
- handleEnter(e)
214
- }
215
- if (e.key === ' ') {
216
- handleSpace(e)
217
- }
218
- }
219
-
220
- /**
221
- * Helper: Close menu if click/focus originated outside menu and instigator.
222
- * Registered on document focusin and pointerdown events to detect outside interactions.
223
- * @param {Event} e
224
- * The focusin or pointerdown event
225
- */
226
- const handleOutside = (e) => {
227
- if (menuRef.current?.contains(e.target) || buttonRefs.current[instigatorKey]?.contains(e.target)) {
228
- return
229
- }
230
- setIsOpen(false)
231
- }
232
-
233
- /**
234
- * Helper: Handle item click.
235
- * Closes menu and activates the item; does nothing if the item is disabled.
236
- * @param {React.MouseEvent} e
237
- * React synthetic event from the LI click
238
- * @param {Object} item
239
- * The clicked item object with {id, label, onClick, ...}
240
- */
241
- const handleItemClick = (e, item) => {
242
- if (e.nativeEvent._fromKeyboardActivation) {
243
- return
244
- }
245
- if (disabledButtons.has(item.id)) {
246
- return
247
- }
248
- setIsOpen(false)
249
- activateItem(e, item)
250
- }
251
-
252
- useEffect(() => {
253
- menuRef.current?.focus()
254
-
255
- // If startPos changes on mount, ensure selection respects visible items.
256
- if (startPos === 'first') {
257
- setIndex(visibleIndices[0] ?? -1)
258
- } else if (startPos === 'last') {
259
- setIndex(visibleIndices[visibleIndices.length - 1] ?? -1)
260
- } else {
261
- // No action
262
- }
263
-
264
- document.addEventListener('focusin', handleOutside)
265
- document.addEventListener('pointerdown', handleOutside)
266
-
267
- return () => {
268
- document.removeEventListener('focusin', handleOutside)
269
- document.removeEventListener('pointerdown', handleOutside)
270
- }
271
- }, [])
272
-
273
- return (
49
+ return createPortal(
274
50
  <ul // NOSONAR
275
51
  ref={menuRef}
276
52
  id={popupMenuId}
277
53
  className='im-c-popup-menu'
54
+ data-direction={menuDirection}
55
+ data-halign={menuHAlign}
56
+ style={menuStyle}
278
57
  role='menu' // NOSONAR
279
58
  tabIndex='-1'
280
59
  aria-labelledby={instigatorKey}
@@ -282,20 +61,18 @@ export const PopupMenu = ({ popupMenuId, buttonId, instigatorId, pluginId, start
282
61
  onKeyDown={handleMenuKeyDown}
283
62
  >
284
63
  {items.map((item, i) => (
285
- <li // NOSONAR
64
+ <MenuItem
286
65
  key={item.id}
287
- id={`${id}-${stringToKebab(items[i].id)}`}
288
- className={`im-c-popup-menu__item${index === i ? ' im-c-popup-menu__item--selected' : ''}`}
289
- role={items[i].isPressed !== undefined || items[i].pressedWhen ? 'menuitemcheckbox' : 'menuitem'} // NOSONAR
290
- aria-disabled={disabledButtons.has(items[i].id) || undefined} // NOSONAR
291
- aria-checked={(items[i].isPressed !== undefined || items[i].pressedWhen) ? pressedButtons.has(items[i].id) : undefined} // NOSONAR
292
- style={hiddenButtons.has(items[i].id) ? { display: 'none' } : undefined}
293
- onClick={(e) => handleItemClick(e, items[i])} // NOSONAR
294
- >
295
- {(item.iconId || item.iconSvgContent) && <Icon id={item.iconId} svgContent={item.iconSvgContent} />}
296
- <span className='im-c-popup-menu__item-label'>{item.label}</span>
297
- </li>
66
+ item={item}
67
+ isSelected={index === i}
68
+ hiddenButtons={hiddenButtons}
69
+ disabledButtons={disabledButtons}
70
+ pressedButtons={pressedButtons}
71
+ id={id}
72
+ onItemClick={handleItemClick}
73
+ />
298
74
  ))}
299
- </ul>
75
+ </ul>,
76
+ layoutRefs?.appContainerRef?.current ?? document.body
300
77
  )
301
78
  }
@@ -13,10 +13,23 @@
13
13
  );
14
14
 
15
15
  background-color: var(--background-color);
16
- position: absolute;
16
+ position: fixed;
17
+ z-index: 9999;
17
18
  list-style: none;
18
19
  margin: 0;
19
20
  padding: 0;
21
+
22
+ &[data-direction="below"] {
23
+ margin-top: var(--divider-gap);
24
+ }
25
+
26
+ &[data-direction="above"] {
27
+ margin-bottom: var(--divider-gap);
28
+ }
29
+
30
+ &[data-halign="center"] {
31
+ transform: translateX(-50%);
32
+ }
20
33
  }
21
34
 
22
35
  // 2. Elements
@@ -125,9 +138,3 @@
125
138
  opacity: 1;
126
139
  }
127
140
 
128
- // 3. Modifiers
129
- .im-o-app__actions .im-c-popup-menu {
130
- left: 50%;
131
- transform: translateX(-50%);
132
- bottom: calc(100% + (var(--primary-gap) * 2));
133
- }
@@ -22,7 +22,8 @@ const mockUseApp = {
22
22
  buttonConfig: {},
23
23
  hiddenButtons: new Set(),
24
24
  disabledButtons: new Set(),
25
- pressedButtons: new Set()
25
+ pressedButtons: new Set(),
26
+ layoutRefs: { appContainerRef: { current: document.body } }
26
27
  }
27
28
  jest.mock('../../store/appContext', () => ({
28
29
  useApp: jest.fn(() => mockUseApp)
@@ -387,4 +388,72 @@ describe('PopupMenu', () => {
387
388
  expect(mockSetIsOpen).not.toHaveBeenCalled()
388
389
  })
389
390
  })
391
+
392
+ describe('buttonRect positioning', () => {
393
+ beforeEach(() => {
394
+ Object.defineProperty(window, 'innerWidth', { value: 1024, configurable: true })
395
+ Object.defineProperty(window, 'innerHeight', { value: 768, configurable: true })
396
+ })
397
+
398
+ it('uses top and data-direction=below when button is in top half', () => {
399
+ renderMenu({ buttonRect: { top: 100, bottom: 140, left: 50, right: 100 } })
400
+ const menu = screen.getByRole('menu')
401
+ expect(menu).toHaveStyle({ top: '140px' })
402
+ expect(menu).toHaveAttribute('data-direction', 'below')
403
+ })
404
+
405
+ it('uses bottom and data-direction=above when button is in bottom half', () => {
406
+ renderMenu({ buttonRect: { top: 500, bottom: 540, left: 50, right: 100 } })
407
+ const menu = screen.getByRole('menu')
408
+ expect(menu).toHaveStyle({ bottom: '268px' }) // 768 - 500
409
+ expect(menu).toHaveAttribute('data-direction', 'above')
410
+ })
411
+
412
+ it('uses left and data-halign=left when button center is in left third', () => {
413
+ // centerX = (50+100)/2 = 75 < 1024/3 ≈ 341
414
+ renderMenu({ buttonRect: { top: 100, bottom: 140, left: 50, right: 100 } })
415
+ const menu = screen.getByRole('menu')
416
+ expect(menu).toHaveStyle({ left: '50px' })
417
+ expect(menu).toHaveAttribute('data-halign', 'left')
418
+ })
419
+
420
+ it('uses right and data-halign=right when button center is in right third', () => {
421
+ // centerX = (700+750)/2 = 725 > 1024*2/3 ≈ 683
422
+ renderMenu({ buttonRect: { top: 100, bottom: 140, left: 700, right: 750 } })
423
+ const menu = screen.getByRole('menu')
424
+ expect(menu).toHaveStyle({ right: '274px' }) // 1024 - 750
425
+ expect(menu).toHaveAttribute('data-halign', 'right')
426
+ })
427
+
428
+ it('uses button centerX and data-halign=center when button center is in middle third', () => {
429
+ // centerX = (400+450)/2 = 425, between 341 and 683
430
+ renderMenu({ buttonRect: { top: 100, bottom: 140, left: 400, right: 450 } })
431
+ const menu = screen.getByRole('menu')
432
+ expect(menu).toHaveStyle({ left: '425px' })
433
+ expect(menu).toHaveAttribute('data-halign', 'center')
434
+ })
435
+
436
+ it('applies no inline style when buttonRect is not provided', () => {
437
+ renderMenu()
438
+ const style = screen.getByRole('menu').style
439
+ expect(style.top).toBe('')
440
+ expect(style.bottom).toBe('')
441
+ expect(style.left).toBe('')
442
+ expect(style.right).toBe('')
443
+ })
444
+ })
445
+
446
+ it('falls back to document.body when appContainerRef.current is null', () => {
447
+ const saved = mockUseApp.layoutRefs
448
+ mockUseApp.layoutRefs = { appContainerRef: { current: null } }
449
+ renderMenu()
450
+ expect(screen.getByRole('menu')).toBeInTheDocument()
451
+ mockUseApp.layoutRefs = saved
452
+ })
453
+
454
+ it('closes menu on window resize', () => {
455
+ renderMenu()
456
+ window.dispatchEvent(new Event('resize'))
457
+ expect(mockSetIsOpen).toHaveBeenCalledWith(false)
458
+ })
390
459
  })