@defra/interactive-map 0.0.4-alpha → 0.0.5-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 (202) 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 +95 -0
  65. package/plugins/interact/src/utils/spatial.test.js +93 -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 +3 -0
  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 +25 -1
  164. package/src/App/store/appActionsMap.test.js +21 -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/_dimensions.scss +6 -2
  184. package/src/services/eventBus.test.js +24 -1
  185. package/src/test-utils.js +1 -0
  186. package/src/types.js +5 -0
  187. package/src/utils/detectBreakpoint.js +78 -77
  188. package/src/utils/detectBreakpoint.test.js +50 -4
  189. package/src/utils/detectInterfaceType.js +4 -4
  190. package/src/utils/getIsFullscreen.js +3 -1
  191. package/src/utils/getSafeZoneInset.js +23 -7
  192. package/src/utils/getSafeZoneInset.test.js +18 -0
  193. package/src/utils/mapStateSync.js +3 -3
  194. package/src/utils/queryString.js +1 -1
  195. package/src/utils/stringToKebab.js +1 -1
  196. package/src/utils/toggleInertElements.js +37 -12
  197. package/webpack.dev.mjs +3 -4
  198. package/plugins/interact/src/utils/turfHelpers.js +0 -30
  199. package/plugins/interact/src/utils/turfHelpers.test.js +0 -30
  200. package/plugins/search/src/utils/fetchDataset.js +0 -23
  201. package/providers/maplibre/dist/esm/im-maplibre-legacy-framework.js +0 -1
  202. package/providers/maplibre/dist/umd/im-maplibre-legacy-framework.js +0 -1
@@ -1,11 +1,13 @@
1
1
  // src/components/KeyboardHelp.jsx
2
2
  import React from 'react'
3
+ import { useConfig } from '../../store/configContext'
3
4
  import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js'
4
5
 
5
6
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
6
7
  // sonarjs/disable-next-line function-name
7
8
  export const KeyboardHelp = () => {
8
- const groups = getKeyboardShortcuts().reduce((acc, shortcut) => {
9
+ const appConfig = useConfig()
10
+ const groups = getKeyboardShortcuts(appConfig).reduce((acc, shortcut) => {
9
11
  acc[shortcut.group] = acc[shortcut.group] || []
10
12
  acc[shortcut.group].push(shortcut)
11
13
  return acc
@@ -3,12 +3,21 @@ import React from 'react'
3
3
  import { render, screen, within } from '@testing-library/react'
4
4
  import { KeyboardHelp } from './KeyboardHelp'
5
5
  import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js'
6
+ import { useConfig } from '../../store/configContext'
6
7
 
7
8
  jest.mock('../../registry/keyboardShortcutRegistry.js', () => ({
8
9
  getKeyboardShortcuts: jest.fn()
9
10
  }))
10
11
 
12
+ jest.mock('../../store/configContext', () => ({
13
+ useConfig: jest.fn()
14
+ }))
15
+
11
16
  describe('KeyboardHelp', () => {
17
+ beforeEach(() => {
18
+ useConfig.mockReturnValue({})
19
+ })
20
+
12
21
  afterEach(() => {
13
22
  jest.clearAllMocks()
14
23
  })
@@ -7,7 +7,7 @@ export const Logo = () => {
7
7
  const { mapStyle } = useMap()
8
8
 
9
9
  if (!mapStyle?.logo) {
10
- return
10
+ return undefined
11
11
  }
12
12
 
13
13
  return (
@@ -1,13 +1,169 @@
1
1
  // components/MapButton.jsx
2
+ import React, { useState, useRef } from 'react'
2
3
  import { stringToKebab } from '../../../utils/stringToKebab'
3
4
  import { Tooltip } from '../Tooltip/Tooltip'
4
5
  import { Icon } from '../Icon/Icon'
5
6
  import { SlotRenderer } from '../../renderer/SlotRenderer'
7
+ import { PopupMenu } from '../PopupMenu/PopupMenu'
6
8
  import { useConfig } from '../../store/configContext'
7
9
  import { useApp } from '../../store/appContext'
8
10
 
9
- // eslint-disable-next-line camelcase, react/jsx-pascal-case
10
- // sonarjs/disable-next-line function-name
11
+ /**
12
+ * Builds CSS class names for the map button element.
13
+ * @param {string} buttonId - Unique identifier for the button (kebab-cased)
14
+ * @param {string} variant - Visual variant of the button (e.g., 'primary', 'secondary')
15
+ * @param {boolean} showLabel - Whether the button label is displayed
16
+ * @returns {string} Space-separated CSS class names
17
+ */
18
+ const buildButtonClassNames = (buttonId, variant, showLabel) => [
19
+ 'im-c-map-button',
20
+ buttonId && `im-c-map-button--${stringToKebab(buttonId)}`,
21
+ variant && `im-c-map-button--${variant}`,
22
+ showLabel && 'im-c-map-button--with-label'
23
+ ].filter(Boolean).join(' ')
24
+
25
+ /**
26
+ * Builds CSS class names for the wrapper div that contains the button.
27
+ * @param {string} buttonId - Unique identifier for the button
28
+ * @param {boolean} showLabel - Whether the button label is displayed
29
+ * @param {boolean} groupStart - Whether this button is at the start of a button group
30
+ * @param {boolean} groupMiddle - Whether this button is in the middle of a button group
31
+ * @param {boolean} groupEnd - Whether this button is at the end of a button group
32
+ * @returns {string} Space-separated CSS class names for the wrapper
33
+ */
34
+ const buildWrapperClassNames = (buttonId, showLabel, groupStart, groupMiddle, groupEnd) => [
35
+ 'im-c-button-wrapper',
36
+ buttonId && `im-c-button-wrapper--${stringToKebab(buttonId)}`,
37
+ showLabel && 'im-c-button-wrapper--wide',
38
+ groupStart && 'im-c-button-wrapper--group-start',
39
+ groupMiddle && 'im-c-button-wrapper--group-middle',
40
+ groupEnd && 'im-c-button-wrapper--group-end'
41
+ ].filter(Boolean).join(' ')
42
+
43
+ /**
44
+ * Handles spacebar key presses on anchor links by triggering a click event.
45
+ * This ensures anchor buttons behave like standard buttons when spacebar is pressed.
46
+ * @param {KeyboardEvent} e - The keyboard event
47
+ */
48
+ const handleKeyUp = (e) => {
49
+ if (e.key === ' ' || e.key === 'Spacebar') {
50
+ e.preventDefault()
51
+ e.currentTarget.click()
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Determines the controlled element (panel or popup menu) for ARIA attributes.
57
+ * @param {Object} options - Configuration options
58
+ * @param {string} options.idPrefix - Prefix for generated IDs
59
+ * @param {string} options.panelId - ID of the controlled panel (if applicable)
60
+ * @param {string} options.buttonId - Unique button identifier
61
+ * @param {boolean} options.hasMenu - Whether the button has a popup menu
62
+ * @returns {Object|null} Object with id and type ('panel' or 'popup'), or null if no controlled element
63
+ */
64
+ const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu }) => {
65
+ if (panelId) {
66
+ return { id: `${idPrefix}-panel-${stringToKebab(panelId)}`, type: 'panel' }
67
+ }
68
+ if (hasMenu) {
69
+ return { id: `${idPrefix}-popup-${stringToKebab(buttonId)}`, type: 'popup' }
70
+ }
71
+ return null
72
+ }
73
+
74
+ /**
75
+ * Builds the complete props object for a button or anchor element.
76
+ * Handles ARIA attributes, event handlers, refs, and conditional styling.
77
+ * @param {Object} options - Configuration options
78
+ * @param {string} options.appId - Application identifier
79
+ * @param {string} options.buttonId - Unique button identifier
80
+ * @param {string} options.className - CSS classes for the button
81
+ * @param {Function} options.onClick - Click event handler
82
+ * @param {Function} options.onKeyUp - Key up event handler
83
+ * @param {Object} options.buttonRefs - React ref object for storing button references
84
+ * @param {boolean} options.isDisabled - Whether the button is disabled
85
+ * @param {boolean} options.isPressed - Whether the button is in pressed state
86
+ * @param {boolean} options.isExpanded - Whether content controlled by button is expanded
87
+ * @param {boolean} options.isPanelOpen - Whether the controlled panel is open
88
+ * @param {boolean} options.isPopupOpen - Whether the popup menu is open
89
+ * @param {Object|null} options.controlledElement - The element controlled by this button
90
+ * @param {string} options.href - URL for anchor element (if provided, renders as <a> instead of <button>)
91
+ * @returns {Object} Props object suitable for button or anchor element
92
+ */
93
+ const buildButtonProps = ({
94
+ appId,
95
+ buttonId,
96
+ className,
97
+ onClick,
98
+ onKeyUp,
99
+ buttonRefs,
100
+ isDisabled,
101
+ isPressed,
102
+ isExpanded,
103
+ isPanelOpen,
104
+ isPopupOpen,
105
+ controlledElement,
106
+ href
107
+ }) => {
108
+ let ariaExpanded
109
+ if (controlledElement?.type === 'panel') {
110
+ ariaExpanded = String(isPanelOpen)
111
+ } else if (controlledElement?.type === 'popup') {
112
+ ariaExpanded = isPopupOpen
113
+ } else if (typeof isExpanded === 'boolean') {
114
+ ariaExpanded = isExpanded
115
+ } else {
116
+ // No action
117
+ }
118
+
119
+ return {
120
+ id: `${appId}-${stringToKebab(buttonId)}`,
121
+ className,
122
+ onClick,
123
+ onKeyUp,
124
+ ref: (el) => {
125
+ if (buttonRefs.current && buttonId) {
126
+ buttonRefs.current[buttonId] = el
127
+ }
128
+ },
129
+ 'aria-disabled': isDisabled || undefined,
130
+ 'aria-expanded': ariaExpanded,
131
+ 'aria-pressed': typeof isPressed === 'boolean' ? isPressed : undefined,
132
+ 'aria-controls': controlledElement?.id,
133
+ 'aria-haspopup': controlledElement?.type === 'popup' || undefined,
134
+ ...(href
135
+ ? { href, target: '_blank', onKeyUp: handleKeyUp, role: 'button' }
136
+ : { type: 'button' })
137
+ }
138
+ }
139
+
140
+ /**
141
+ * MapButton component - A versatile button for map applications.
142
+ * Supports icons, labels, popups, panels, tooltips, and button grouping.
143
+ * Renders as either a <button> or <a> element depending on props.
144
+ * @component
145
+ * @param {Object} props - Component props
146
+ * @param {string} props.buttonId - Unique identifier for the button
147
+ * @param {string} [props.iconId] - Icon identifier to display
148
+ * @param {string} [props.iconSvgContent] - SVG content as an alternative to iconId
149
+ * @param {string} props.label - Button label/tooltip text
150
+ * @param {boolean} [props.showLabel=false] - Whether to display the label visually
151
+ * @param {boolean} [props.isDisabled=false] - Whether the button is disabled
152
+ * @param {boolean} [props.isPressed] - Whether the button is in pressed state (aria-pressed)
153
+ * @param {boolean} [props.isExpanded] - Whether content controlled by the button is expanded
154
+ * @param {boolean} [props.isHidden=false] - Whether to hide the button (CSS display: none)
155
+ * @param {boolean} [props.isPanelOpen=false] - Whether the controlled panel is open
156
+ * @param {string} [props.variant] - CSS variant class for styling (e.g., 'primary')
157
+ * @param {Function} [props.onClick] - Custom click handler
158
+ * @param {string} [props.panelId] - ID of the panel controlled by this button
159
+ * @param {Array<Object>} [props.menuItems] - Array of items for popup menu
160
+ * @param {string} [props.idPrefix=''] - Prefix for generated panel/popup IDs
161
+ * @param {string} [props.href] - URL for anchor element; if provided, renders as <a> instead of <button>
162
+ * @param {boolean} [props.groupStart=false] - Whether button is at start of button group
163
+ * @param {boolean} [props.groupMiddle=false] - Whether button is in middle of button group
164
+ * @param {boolean} [props.groupEnd=false] - Whether button is at end of button group
165
+ * @returns {JSX.Element} The rendered button component
166
+ */
11
167
  export const MapButton = ({
12
168
  buttonId,
13
169
  iconId,
@@ -15,13 +171,14 @@ export const MapButton = ({
15
171
  label,
16
172
  showLabel,
17
173
  isDisabled,
18
- isExpanded,
19
174
  isPressed,
175
+ isExpanded,
20
176
  isHidden,
21
- isOpen,
177
+ isPanelOpen,
22
178
  variant,
23
179
  onClick,
24
180
  panelId,
181
+ menuItems,
25
182
  idPrefix,
26
183
  href,
27
184
  groupMiddle,
@@ -30,42 +187,65 @@ export const MapButton = ({
30
187
  }) => {
31
188
  const { id: appId } = useConfig()
32
189
  const { buttonRefs } = useApp()
33
-
34
- const buttonClassNames = [
35
- 'im-c-map-button',
36
- buttonId && `im-c-map-button--${stringToKebab(buttonId)}`,
37
- variant && `im-c-map-button--${variant}`,
38
- showLabel && 'im-c-map-button--with-label'
39
- ].filter(Boolean).join(' ')
190
+ const [isPopupOpen, setIsPopupOpen] = useState(false)
191
+ const [menuStartPos, setMenuStartPos] = useState(null)
192
+ const menuRef = useRef(null)
40
193
 
41
194
  const Element = href ? 'a' : 'button'
195
+ const hasMenu = menuItems?.length >= 1
196
+ const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu })
42
197
 
43
- const handleKeyUp = (e) => {
44
- if (e.key === ' ' || e.key === 'Spacebar') {
45
- e.preventDefault() // prevent page scrolling
46
- e.currentTarget.click() // trigger click
198
+ /**
199
+ * Handles button click events.
200
+ * Toggles popup menu visibility if the button controls a popup.
201
+ * Calls the custom onClick handler if provided.
202
+ * @param {React.MouseEvent} e - The click event
203
+ */
204
+ const handleButtonClick = (e) => {
205
+ if (isDisabled) {
206
+ return
207
+ }
208
+ if (controlledElement?.type === 'popup') {
209
+ const isKeyboard = e.nativeEvent.pointerType === ''
210
+ /* istanbul ignore next as pointerType can't be tested in jest */
211
+ setMenuStartPos(isKeyboard ? 'first' : null)
212
+ setIsPopupOpen((prev) => !prev)
213
+ }
214
+ if (onClick) {
215
+ onClick(e)
47
216
  }
48
217
  }
49
218
 
50
- const buttonProps = {
51
- id: `${appId}-${stringToKebab(buttonId)}`,
52
- className: buttonClassNames,
53
- onClick,
54
- ref: (el) => {
55
- if (buttonRefs.current && buttonId) {
56
- buttonRefs.current[buttonId] = el
57
- }
58
- },
59
- 'aria-disabled': isDisabled || undefined,
60
- 'aria-expanded': typeof isExpanded === 'boolean' ? isExpanded : undefined,
61
- 'aria-pressed': panelId ? String(isOpen) : isPressed,
62
- 'aria-controls': panelId ? `${idPrefix}-panel-${stringToKebab(panelId)}` : undefined,
63
- ...(href
64
- ? { href, target: '_blank', onKeyUp: handleKeyUp, role: 'button' } // only <a>
65
- : { type: 'button' } // only <button>
66
- )
219
+ /**
220
+ * Handles key up events on buttons that control popup menus.
221
+ * ArrowDown opens the menu at the first item.
222
+ * ArrowUp opens the menu at the last item.
223
+ * @param {React.KeyboardEvent} e - The keyboard event
224
+ */
225
+ const handleButtonKeyUp = e => {
226
+ if (hasMenu && ['ArrowDown', 'ArrowUp'].includes(e.key)) {
227
+ e.preventDefault()
228
+ setMenuStartPos(e.key === 'ArrowUp' ? 'last' : 'first')
229
+ setIsPopupOpen(true)
230
+ }
67
231
  }
68
232
 
233
+ const buttonProps = buildButtonProps({
234
+ appId,
235
+ buttonId,
236
+ className: buildButtonClassNames(buttonId, variant, showLabel),
237
+ onClick: handleButtonClick,
238
+ onKeyUp: handleButtonKeyUp,
239
+ buttonRefs,
240
+ isDisabled,
241
+ isPressed,
242
+ isExpanded,
243
+ isPanelOpen,
244
+ isPopupOpen,
245
+ controlledElement,
246
+ href
247
+ })
248
+
69
249
  const buttonEl = (
70
250
  <Element {...buttonProps}>
71
251
  {(iconId || iconSvgContent) && <Icon id={iconId} svgContent={iconSvgContent} />}
@@ -73,18 +253,14 @@ export const MapButton = ({
73
253
  </Element>
74
254
  )
75
255
 
76
- const wrapperClassNames = [
77
- 'im-c-button-wrapper',
78
- showLabel && ' im-c-button-wrapper--wide',
79
- groupStart && 'im-c-button-wrapper--group-start',
80
- groupMiddle && 'im-c-button-wrapper--group-middle',
81
- groupEnd && 'im-c-button-wrapper--group-end'
82
- ].filter(Boolean).join(' ')
83
-
84
256
  return (
85
- <div className={wrapperClassNames} style={isHidden ? { display: 'none' } : undefined}>
257
+ <div
258
+ className={buildWrapperClassNames(buttonId, showLabel, groupStart, groupMiddle, groupEnd)}
259
+ style={isHidden ? { display: 'none' } : undefined}
260
+ >
86
261
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
87
262
  {panelId && <SlotRenderer slot={`${stringToKebab(buttonId)}-button`} />}
263
+ {isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} />}
88
264
  </div>
89
265
  )
90
266
  }
@@ -26,23 +26,25 @@
26
26
  $type: button
27
27
  );
28
28
 
29
- & {
30
- display: flex;
31
- justify-content: center;
32
- align-items: center;
29
+ display: flex;
30
+ justify-content: center;
31
+ align-items: center;
33
32
 
34
- padding-left: var(--button-padding);
35
- padding-right: var(--button-padding);
33
+ padding-left: var(--button-padding);
34
+ padding-right: var(--button-padding);
36
35
 
37
- pointer-events: auto;
38
- color: var(--foreground-color);
39
- background-color: var(--button-background-color);
36
+ pointer-events: auto;
37
+ color: var(--foreground-color);
38
+ background-color: var(--button-background-color);
40
39
 
41
- min-height: var(--button-size);
42
- min-width: var(--button-size);
40
+ min-height: var(--button-size);
41
+ min-width: var(--button-size);
43
42
 
44
- user-select: none;
45
- cursor: pointer;
43
+ user-select: none;
44
+ cursor: pointer;
45
+
46
+ &--primary[aria-disabled="true"], &--tertiary[aria-disabled="true"] {
47
+ opacity: var(--disabled-button-opacity);
46
48
  }
47
49
  }
48
50
 
@@ -66,9 +68,7 @@
66
68
  transition: background-color var(--duration) ease;
67
69
  }
68
70
 
69
- & {
70
- background-color: var(--button-hover-color);
71
- }
71
+ background-color: var(--button-hover-color);
72
72
  }
73
73
  }
74
74
 
@@ -134,6 +134,14 @@
134
134
  @include tools.border-focus-corner-override($corners: 'top');
135
135
  }
136
136
 
137
+ .im-c-button-wrapper--group-middle .im-c-map-button {
138
+ @include tools.border-focus-corner-override($corners: 'none');
139
+
140
+ &::before {
141
+ clip-path: inset(0 -20px 0 -20px);
142
+ }
143
+ }
144
+
137
145
  .im-c-button-wrapper--group-end .im-c-map-button {
138
146
  margin-top: 0;
139
147
  @include tools.border-focus-corner-override($corners: 'bottom');
@@ -150,6 +158,14 @@
150
158
  @include tools.border-focus-corner-override($corners: 'left');
151
159
  }
152
160
 
161
+ .im-c-button-wrapper--group-middle .im-c-map-button {
162
+ @include tools.border-focus-corner-override($corners: 'none');
163
+
164
+ &::before {
165
+ clip-path: inset(-20px 0 -20px 0);
166
+ }
167
+ }
168
+
153
169
  .im-c-button-wrapper--group-end .im-c-map-button {
154
170
  @include tools.border-focus-corner-override($corners: 'right');
155
171
  }
@@ -162,11 +178,6 @@
162
178
  }
163
179
  }
164
180
 
165
- .im-c-map-button {
166
- &--primary[aria-disabled="true"], &--tertiary[aria-disabled="true"] {
167
- opacity: var(--disabled-button-opacity);
168
- }
169
- }
170
181
 
171
182
  .im-o-app__actions .im-c-map-button[aria-pressed="true"] {
172
183
  color: var(--pressed-button-foreground-color);