@defra/interactive-map 0.0.22-alpha → 0.0.23-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 (190) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/button-definition.md +39 -0
  7. package/docs/api/context.md +4 -4
  8. package/docs/api/map-style-config.md +11 -2
  9. package/docs/api/marker-config.md +38 -7
  10. package/docs/api/panel-definition.md +7 -3
  11. package/docs/api/symbol-config.md +10 -26
  12. package/docs/api/symbol-registry.md +28 -13
  13. package/docs/api.md +24 -2
  14. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  15. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  16. package/docs/examples/index.mdx +12 -0
  17. package/docs/examples/toggle-marker-label.mdx +89 -0
  18. package/docs/plugins/interact.md +4 -11
  19. package/docusaurus.config.cjs +2 -1
  20. package/package.json +8 -8
  21. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  22. package/plugins/beta/datasets/dist/css/index.css +2 -2
  23. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  25. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  28. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  29. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  30. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  31. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  32. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  33. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  34. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  35. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  37. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  38. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  39. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  40. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  41. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  42. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  43. package/plugins/beta/use-location/src/manifest.js +1 -0
  44. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  45. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  46. package/plugins/interact/dist/umd/index.js +1 -1
  47. package/plugins/interact/src/InteractInit.jsx +23 -55
  48. package/plugins/interact/src/InteractInit.test.js +58 -80
  49. package/plugins/interact/src/defaults.js +1 -3
  50. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  51. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  54. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  55. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  56. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  57. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  58. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  59. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  60. package/plugins/interact/src/index.js +0 -2
  61. package/plugins/interact/src/index.test.js +0 -2
  62. package/plugins/interact/src/manifest.js +37 -6
  63. package/plugins/interact/src/reducer.js +8 -4
  64. package/plugins/interact/src/reducer.test.js +18 -3
  65. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  66. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  67. package/plugins/interact/src/utils/spatial.js +0 -15
  68. package/plugins/interact/src/utils/spatial.test.js +1 -13
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  71. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  72. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  73. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  74. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  75. package/plugins/search/src/search.scss +1 -0
  76. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  77. package/providers/beta/esri/src/esriProvider.js +4 -0
  78. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/esm/index.js +1 -1
  80. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  81. package/providers/maplibre/dist/umd/index.js +1 -1
  82. package/providers/maplibre/src/index.js +1 -1
  83. package/providers/maplibre/src/index.test.js +9 -14
  84. package/providers/maplibre/src/mapEvents.js +11 -5
  85. package/providers/maplibre/src/mapEvents.test.js +14 -3
  86. package/providers/maplibre/src/maplibreProvider.js +18 -11
  87. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  88. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  89. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  90. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  91. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  92. package/providers/maplibre/src/utils/patternImages.js +10 -12
  93. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  94. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  95. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  96. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  97. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  98. package/src/App/components/Actions/Actions.module.scss +1 -0
  99. package/src/App/components/Actions/Actions.test.jsx +17 -4
  100. package/src/App/components/Hints/Hints.jsx +52 -0
  101. package/src/App/components/Hints/Hints.module.scss +37 -0
  102. package/src/App/components/Hints/Hints.test.jsx +104 -0
  103. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  104. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  105. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  106. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  107. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  108. package/src/App/components/Markers/Markers.jsx +44 -75
  109. package/src/App/components/Markers/Markers.module.scss +58 -0
  110. package/src/App/components/Markers/Markers.test.jsx +90 -113
  111. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  112. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  113. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  114. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  115. package/src/App/components/Panel/Panel.jsx +1 -1
  116. package/src/App/components/Panel/Panel.module.scss +1 -0
  117. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  118. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  119. package/src/App/components/Tabs/Tabs.jsx +68 -0
  120. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  121. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  122. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  123. package/src/App/components/Viewport/Features.jsx +35 -0
  124. package/src/App/components/Viewport/Features.module.scss +31 -0
  125. package/src/App/components/Viewport/Features.test.jsx +124 -0
  126. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  127. package/src/App/components/Viewport/Viewport.jsx +35 -29
  128. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  129. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  130. package/src/App/controls/keyboardActions.js +9 -3
  131. package/src/App/controls/keyboardActions.test.js +122 -87
  132. package/src/App/controls/keyboardShortcuts.js +1 -14
  133. package/src/App/hooks/useFeatureFocus.js +199 -0
  134. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  135. package/src/App/hooks/useFeatureItems.js +30 -0
  136. package/src/App/hooks/useFeatureItems.test.js +97 -0
  137. package/src/App/hooks/useFocusVisible.js +4 -7
  138. package/src/App/hooks/useFocusVisible.test.js +6 -6
  139. package/src/App/hooks/useKeyboardHint.js +9 -34
  140. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  141. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  142. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  143. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  144. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  145. package/src/App/hooks/useMarkerCursor.js +33 -0
  146. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  147. package/src/App/hooks/useMarkersAPI.js +32 -9
  148. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  149. package/src/App/layout/Layout.jsx +2 -0
  150. package/src/App/layout/Layout.test.jsx +3 -0
  151. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  152. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  153. package/src/App/renderer/mapButtons.js +5 -1
  154. package/src/App/renderer/mapButtons.test.js +37 -1
  155. package/src/App/store/ServiceProvider.jsx +4 -1
  156. package/src/App/store/ServiceProvider.test.jsx +11 -0
  157. package/src/App/store/appActionsMap.js +5 -1
  158. package/src/App/store/appActionsMap.test.js +13 -0
  159. package/src/App/store/appReducer.js +1 -0
  160. package/src/App/store/mapActionsMap.js +2 -1
  161. package/src/InteractiveMap/InteractiveMap.js +12 -0
  162. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  163. package/src/config/appConfig.js +3 -1
  164. package/src/config/appConfig.test.js +4 -0
  165. package/src/config/defaults.js +1 -1
  166. package/src/config/events.js +8 -0
  167. package/src/config/mapTheme.js +8 -4
  168. package/src/config/symbolConfig.js +23 -16
  169. package/src/scss/main.scss +3 -0
  170. package/src/scss/tools/_border-focus.scss +0 -1
  171. package/src/services/eventBus.js +15 -0
  172. package/src/services/eventBus.test.js +11 -0
  173. package/src/services/hints.js +47 -0
  174. package/src/services/hints.test.js +110 -0
  175. package/src/services/patternRegistry.js +69 -1
  176. package/src/services/patternRegistry.test.js +112 -1
  177. package/src/services/reverseGeocode.js +4 -0
  178. package/src/services/reverseGeocode.test.js +11 -1
  179. package/src/services/symbolRegistry.js +152 -17
  180. package/src/services/symbolRegistry.test.js +141 -31
  181. package/src/types.js +71 -14
  182. package/src/utils/constrainKeyboardFocus.js +5 -5
  183. package/src/utils/detectInterfaceType.js +27 -5
  184. package/src/utils/detectInterfaceType.test.js +29 -3
  185. package/src/utils/patternUtils.js +4 -67
  186. package/src/utils/patternUtils.test.js +1 -123
  187. package/src/utils/symbolUtils.js +11 -13
  188. package/src/utils/symbolUtils.test.js +18 -26
  189. package/plugins/interact/dist/css/index.css +0 -1
  190. package/plugins/interact/src/interact.scss +0 -24
@@ -23,7 +23,11 @@ const mockUseApp = {
23
23
  hiddenButtons: new Set(),
24
24
  disabledButtons: new Set(),
25
25
  pressedButtons: new Set(),
26
- layoutRefs: { appContainerRef: { current: document.body } }
26
+ dispatch: jest.fn(),
27
+ layoutRefs: {
28
+ appContainerRef: { current: document.body },
29
+ viewportRef: { current: { focus: jest.fn() } }
30
+ }
27
31
  }
28
32
  jest.mock('../../store/appContext', () => ({
29
33
  useApp: jest.fn(() => mockUseApp)
@@ -148,6 +152,36 @@ describe('PopupMenu', () => {
148
152
  expect(mockSetIsOpen).toHaveBeenCalled()
149
153
  })
150
154
 
155
+ it('click on item with panelId dispatches OPEN_PANEL and closes menu', () => {
156
+ mockUseApp.buttonConfig = { item1: { panelId: 'myPanel' } }
157
+ renderMenu()
158
+ fireEvent.click(screen.getByText('Item 1'))
159
+ expect(mockUseApp.dispatch).toHaveBeenCalledWith({
160
+ type: 'OPEN_PANEL',
161
+ payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator } }
162
+ })
163
+ expect(mockSetIsOpen).toHaveBeenCalledWith(false)
164
+ })
165
+
166
+ it('click on item with panelId and keepFocus includes focusOnOpen: false in dispatch', () => {
167
+ mockUseApp.buttonConfig = { item1: { panelId: 'myPanel', keepFocus: true } }
168
+ renderMenu()
169
+ fireEvent.click(screen.getByText('Item 1'))
170
+ expect(mockUseApp.dispatch).toHaveBeenCalledWith({
171
+ type: 'OPEN_PANEL',
172
+ payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator }, focusOnOpen: false }
173
+ })
174
+ })
175
+
176
+ it('click on item with keepFocus returns focus to instigator instead of viewport', () => {
177
+ const focusSpy = jest.fn()
178
+ mockUseApp.buttonRefs.current.instigator.focus = focusSpy
179
+ mockUseApp.buttonConfig = { item1: { keepFocus: true } }
180
+ renderMenu()
181
+ fireEvent.click(screen.getByText('Item 1'))
182
+ expect(focusSpy).toHaveBeenCalled()
183
+ })
184
+
151
185
  it('calls buttonConfig.onClick with evaluateProp if defined', () => {
152
186
  const mockOnClick = jest.fn()
153
187
  mockUseApp.buttonConfig = { item2: { onClick: mockOnClick } }
@@ -186,6 +220,47 @@ describe('PopupMenu', () => {
186
220
  expect(mockSetIsOpen).toHaveBeenCalled()
187
221
  })
188
222
 
223
+ it('Enter on item with panelId dispatches OPEN_PANEL and closes menu', () => {
224
+ mockUseApp.buttonConfig = { item1: { panelId: 'myPanel' } }
225
+ renderMenu({ startIndex: 0 })
226
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' })
227
+ expect(mockUseApp.dispatch).toHaveBeenCalledWith({
228
+ type: 'OPEN_PANEL',
229
+ payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator } }
230
+ })
231
+ expect(mockSetIsOpen).toHaveBeenCalledWith(false)
232
+ })
233
+
234
+ it('Enter on item with panelId and keepFocus includes focusOnOpen: false in dispatch', () => {
235
+ mockUseApp.buttonConfig = { item1: { panelId: 'myPanel', keepFocus: true } }
236
+ renderMenu({ startIndex: 0 })
237
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' })
238
+ expect(mockUseApp.dispatch).toHaveBeenCalledWith({
239
+ type: 'OPEN_PANEL',
240
+ payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator }, focusOnOpen: false }
241
+ })
242
+ })
243
+
244
+ it('Enter on item with keepFocus returns focus to instigator instead of viewport', () => {
245
+ const focusSpy = jest.fn()
246
+ mockUseApp.buttonRefs.current.instigator.focus = focusSpy
247
+ mockUseApp.buttonConfig = { item1: { keepFocus: true } }
248
+ renderMenu({ startIndex: 0 })
249
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' })
250
+ expect(focusSpy).toHaveBeenCalled()
251
+ expect(mockSetIsOpen).toHaveBeenCalledWith(false)
252
+ })
253
+
254
+ it('Enter on regular item focuses viewport via requestAnimationFrame', () => {
255
+ const rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 })
256
+ const focusSpy = jest.spyOn(mockUseApp.layoutRefs.viewportRef.current, 'focus')
257
+ renderMenu({ startIndex: 0 })
258
+ fireEvent.keyDown(screen.getByRole('menu'), { key: 'Enter' })
259
+ expect(focusSpy).toHaveBeenCalled()
260
+ rafSpy.mockRestore()
261
+ focusSpy.mockRestore()
262
+ })
263
+
189
264
  it('does nothing if click is inside menu', () => {
190
265
  renderMenu()
191
266
  const element = document.createElement('div')
@@ -387,6 +462,47 @@ describe('PopupMenu', () => {
387
462
  expect(items[1].onClick).not.toHaveBeenCalled()
388
463
  expect(mockSetIsOpen).not.toHaveBeenCalled()
389
464
  })
465
+
466
+ it('Space on item with panelId dispatches OPEN_PANEL and closes menu', () => {
467
+ mockUseApp.buttonConfig = { item1: { panelId: 'myPanel' } }
468
+ renderMenu({ startIndex: 0 })
469
+ fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' })
470
+ expect(mockUseApp.dispatch).toHaveBeenCalledWith({
471
+ type: 'OPEN_PANEL',
472
+ payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator } }
473
+ })
474
+ expect(mockSetIsOpen).toHaveBeenCalledWith(false)
475
+ })
476
+
477
+ it('Space on item with panelId and keepFocus includes focusOnOpen: false in dispatch', () => {
478
+ mockUseApp.buttonConfig = { item1: { panelId: 'myPanel', keepFocus: true } }
479
+ renderMenu({ startIndex: 0 })
480
+ fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' })
481
+ expect(mockUseApp.dispatch).toHaveBeenCalledWith({
482
+ type: 'OPEN_PANEL',
483
+ payload: { panelId: 'myPanel', props: { triggeringElement: mockUseApp.buttonRefs.current.instigator }, focusOnOpen: false }
484
+ })
485
+ })
486
+
487
+ it('Space on item with keepFocus returns focus to instigator instead of viewport', () => {
488
+ const focusSpy = jest.fn()
489
+ mockUseApp.buttonRefs.current.instigator.focus = focusSpy
490
+ mockUseApp.buttonConfig = { item1: { keepFocus: true } }
491
+ renderMenu({ startIndex: 0 })
492
+ fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' })
493
+ expect(focusSpy).toHaveBeenCalled()
494
+ expect(mockSetIsOpen).toHaveBeenCalledWith(false)
495
+ })
496
+
497
+ it('Space on regular item focuses viewport via requestAnimationFrame', () => {
498
+ const rafSpy = jest.spyOn(global, 'requestAnimationFrame').mockImplementation(cb => { cb(); return 1 })
499
+ const focusSpy = jest.spyOn(mockUseApp.layoutRefs.viewportRef.current, 'focus')
500
+ renderMenu({ startIndex: 0 })
501
+ fireEvent.keyDown(screen.getByRole('menu'), { key: ' ' })
502
+ expect(focusSpy).toHaveBeenCalled()
503
+ rafSpy.mockRestore()
504
+ focusSpy.mockRestore()
505
+ })
390
506
  })
391
507
 
392
508
  describe('buttonRect positioning', () => {
@@ -1,5 +1,6 @@
1
1
  import { useState, useMemo, useEffect } from 'react'
2
2
  import { stringToKebab } from '../../../utils/stringToKebab.js'
3
+ import { useApp } from '../../store/appContext.js'
3
4
 
4
5
  /**
5
6
  * Computes the position and alignment style for the popup menu based on the
@@ -44,121 +45,164 @@ const getMenuStyle = (buttonRect) => {
44
45
  * The synthetic event is marked _fromKeyboardActivation so handleItemClick can
45
46
  * ignore it and avoid double-activation.
46
47
  *
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 }.
48
+ * @param {React.SyntheticEvent} event - The triggering React event.
49
+ * @param {object} item - The item being activated.
50
+ * @param {object} ctx - Dependencies: { buttonConfig, evaluateProp, pluginId, id }.
50
51
  */
51
- const activateItem = (e, item, { buttonConfig, evaluateProp, pluginId, id }) => {
52
+ const activateItem = (event, item, { buttonConfig, evaluateProp, pluginId, id }) => {
52
53
  const menuItemConfig = buttonConfig[item.id]
53
54
  if (typeof menuItemConfig?.onClick === 'function') {
54
- menuItemConfig.onClick(e, evaluateProp(ctx => ctx, pluginId))
55
+ menuItemConfig.onClick(event, evaluateProp(ctx => ctx, pluginId))
55
56
  } else if (typeof item.onClick === 'function') {
56
- item.onClick(e.nativeEvent)
57
+ item.onClick(event.nativeEvent)
57
58
  } else {
58
59
  // No action
59
60
  }
60
- if (e.nativeEvent instanceof KeyboardEvent) {
61
- const el = document.getElementById(`${id}-${stringToKebab(item.id)}`)
62
- if (el) {
61
+ if (event.nativeEvent instanceof KeyboardEvent) {
62
+ const menuItemEl = document.getElementById(`${id}-${stringToKebab(item.id)}`)
63
+ if (menuItemEl) {
63
64
  const click = new MouseEvent('click', { bubbles: true, cancelable: true })
64
65
  click._fromKeyboardActivation = true
65
- el.dispatchEvent(click)
66
+ menuItemEl.dispatchEvent(click)
66
67
  }
67
68
  }
68
69
  }
69
70
 
71
+ const resolveInitialIndex = (startIndex, startPos, visibleIndices) => {
72
+ if (typeof startIndex === 'number') {
73
+ return startIndex
74
+ }
75
+ if (startPos === 'first') {
76
+ return visibleIndices[0] ?? -1
77
+ }
78
+ if (startPos === 'last') {
79
+ return visibleIndices[visibleIndices.length - 1] ?? -1
80
+ }
81
+ return -1
82
+ }
83
+
84
+ const resolveItemFocus = (item, buttonConfig) => ({
85
+ panelId: buttonConfig[item.id]?.panelId ?? item.panelId,
86
+ keepFocus: buttonConfig[item.id]?.keepFocus ?? item.keepFocus
87
+ })
88
+
89
+ const handleMenuEnter = (event, { items, index, disabledButtons, activateCtx, instigator, dispatch, viewportRef, setIsOpen }) => {
90
+ event.preventDefault()
91
+ const item = items[index]
92
+ if (item && !disabledButtons.has(item.id)) {
93
+ const { panelId, keepFocus } = resolveItemFocus(item, activateCtx.buttonConfig)
94
+ if (panelId) {
95
+ dispatch({ type: 'OPEN_PANEL', payload: { panelId, props: { triggeringElement: instigator }, ...(keepFocus && { focusOnOpen: false }) } })
96
+ setIsOpen(false)
97
+ return
98
+ }
99
+ activateItem(event, item, activateCtx)
100
+ if (keepFocus) {
101
+ instigator.focus()
102
+ setIsOpen(false)
103
+ return
104
+ }
105
+ }
106
+ requestAnimationFrame(() => viewportRef.current?.focus())
107
+ setIsOpen(false)
108
+ }
109
+
110
+ const handleMenuSpace = (event, { items, index, disabledButtons, activateCtx, instigator, dispatch, viewportRef, setIsOpen }) => {
111
+ event.preventDefault()
112
+ const item = items[index]
113
+ if (!item || disabledButtons.has(item.id)) {
114
+ return
115
+ }
116
+ const { panelId, keepFocus } = resolveItemFocus(item, activateCtx.buttonConfig)
117
+ if (panelId) {
118
+ dispatch({ type: 'OPEN_PANEL', payload: { panelId, props: { triggeringElement: instigator }, ...(keepFocus && { focusOnOpen: false }) } })
119
+ setIsOpen(false)
120
+ return
121
+ }
122
+ activateItem(event, item, activateCtx)
123
+ if (!(item.isPressed !== undefined || item.pressedWhen)) {
124
+ if (keepFocus) {
125
+ instigator.focus()
126
+ } else {
127
+ requestAnimationFrame(() => viewportRef.current?.focus())
128
+ }
129
+ setIsOpen(false)
130
+ }
131
+ }
132
+
70
133
  /**
71
134
  * Builds the keydown handler for the menu UL. Handles Escape/Tab (close & focus),
72
135
  * ArrowDown/Up (navigate visible items), Home/End (jump to ends),
73
136
  * Enter (activate and close), Space (activate; close only for non-checkbox items).
74
137
  *
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.
138
+ * @param {object} params
139
+ * @param {Array} params.items - All menu item descriptors.
140
+ * @param {number[]} params.visibleIndices - Indices of non-hidden items.
141
+ * @param {number} params.index - Currently highlighted index.
142
+ * @param {Function} params.setIndex - State setter for highlighted index.
143
+ * @param {Set} params.disabledButtons - IDs of disabled items.
144
+ * @param {object} params.instigator - DOM node of the trigger button.
145
+ * @param {Function} params.setIsOpen - Callback to close the menu.
146
+ * @param {object} params.activateCtx - Context passed through to activateItem.
147
+ * @param {Function} params.dispatch - App dispatch for OPEN_PANEL actions.
148
+ * @param {object} params.viewportRef - Ref to the map viewport element.
84
149
  * @returns {Function} onKeyDown handler for the menu element.
85
150
  */
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()
151
+ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx, dispatch, viewportRef }) => {
152
+ const closeAndFocus = (event, preventDefault = false) => {
153
+ if (preventDefault && event?.preventDefault) {
154
+ event.preventDefault()
90
155
  }
91
156
  instigator.focus()
92
157
  setIsOpen(false)
93
158
  }
94
159
 
95
- const navigateVisible = (e) => {
96
- e.preventDefault()
97
- const n = visibleIndices.length
98
- if (n === 0) {
160
+ const navigateVisible = (event) => {
161
+ event.preventDefault()
162
+ const visibleCount = visibleIndices.length
163
+ if (visibleCount === 0) {
99
164
  return
100
165
  }
101
166
  const pos = visibleIndices.indexOf(index)
102
167
  let nextPos
103
- if (e.key === 'ArrowDown') {
104
- nextPos = pos === -1 ? 0 : (pos + 1) % n
168
+ if (event.key === 'ArrowDown') {
169
+ nextPos = pos === -1 ? 0 : (pos + 1) % visibleCount
105
170
  } else if (pos === -1) {
106
- nextPos = n - 1
171
+ nextPos = visibleCount - 1
107
172
  } else {
108
- nextPos = (pos - 1 + n) % n
173
+ nextPos = (pos - 1 + visibleCount) % visibleCount
109
174
  }
110
175
  setIndex(visibleIndices[nextPos])
111
176
  }
112
177
 
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
- }
178
+ const actionCtx = { items, index, disabledButtons, activateCtx, instigator, dispatch, viewportRef, setIsOpen }
135
179
 
136
- return (e) => {
137
- if (['Escape', 'Esc'].includes(e.key)) {
138
- closeAndFocus(e, true)
180
+ return (event) => {
181
+ if (['Escape', 'Esc'].includes(event.key)) {
182
+ closeAndFocus(event, true)
139
183
  return
140
184
  }
141
- if (e.key === 'Tab') {
142
- closeAndFocus(e)
185
+ if (event.key === 'Tab') {
186
+ closeAndFocus(event)
143
187
  return
144
188
  }
145
- if (['ArrowDown', 'ArrowUp'].includes(e.key)) {
146
- navigateVisible(e)
189
+ if (['ArrowDown', 'ArrowUp'].includes(event.key)) {
190
+ navigateVisible(event)
147
191
  return
148
192
  }
149
- if (e.key === 'Home' && visibleIndices.length) {
193
+ if (event.key === 'Home' && visibleIndices.length) {
150
194
  setIndex(visibleIndices[0])
151
195
  return
152
196
  }
153
- if (e.key === 'End' && visibleIndices.length) {
197
+ if (event.key === 'End' && visibleIndices.length) {
154
198
  setIndex(visibleIndices[visibleIndices.length - 1])
155
199
  return
156
200
  }
157
- if (e.key === 'Enter') {
158
- handleEnter(e)
201
+ if (event.key === 'Enter') {
202
+ handleMenuEnter(event, actionCtx)
159
203
  }
160
- if (e.key === ' ') {
161
- handleSpace(e)
204
+ if (event.key === ' ') {
205
+ handleMenuSpace(event, actionCtx)
162
206
  }
163
207
  }
164
208
  }
@@ -174,7 +218,7 @@ const createMenuKeyDownHandler = ({ items, visibleIndices, index, setIndex, disa
174
218
  * @param {object} params.instigator - DOM node of the button that opened the menu.
175
219
  * @param {string} params.instigatorKey - Key used to look up instigator in buttonRefs.
176
220
  * @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.
221
+ * @param {object} params.buttonConfig - Config map that may override item onClick, panelId, and keepFocus.
178
222
  * @param {Set} params.disabledButtons - IDs of currently disabled items.
179
223
  * @param {string} params.pluginId - Plugin context passed to evaluateProp.
180
224
  * @param {Function} params.evaluateProp - Context evaluator from useEvaluateProp.
@@ -189,6 +233,9 @@ export const usePopupMenu = ({
189
233
  items, hiddenButtons, startIndex, startPos, instigator, instigatorKey,
190
234
  buttonRefs, buttonConfig, disabledButtons, pluginId, evaluateProp, id, menuRef, setIsOpen, buttonRect
191
235
  }) => {
236
+ const { dispatch, layoutRefs } = useApp()
237
+ const viewportRef = layoutRefs.viewportRef
238
+
192
239
  const visibleIndices = useMemo(() => {
193
240
  const visible = []
194
241
  items.forEach((item, idx) => {
@@ -199,38 +246,38 @@ export const usePopupMenu = ({
199
246
  return visible
200
247
  }, [items, hiddenButtons])
201
248
 
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
- })
249
+ const [index, setIndex] = useState(() => resolveInitialIndex(startIndex, startPos, visibleIndices))
214
250
 
215
251
  const activateCtx = { buttonConfig, evaluateProp, pluginId, id }
216
252
 
217
253
  const handleMenuKeyDown = createMenuKeyDownHandler({
218
- items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx
254
+ items, visibleIndices, index, setIndex, disabledButtons, instigator, setIsOpen, activateCtx, dispatch, viewportRef
219
255
  })
220
256
 
221
- const handleOutside = (e) => {
222
- if (menuRef.current?.contains(e.target) || buttonRefs.current[instigatorKey]?.contains(e.target)) {
257
+ const handleOutside = (event) => {
258
+ if (menuRef.current?.contains(event.target) || buttonRefs.current[instigatorKey]?.contains(event.target)) {
223
259
  return
224
260
  }
225
261
  setIsOpen(false)
226
262
  }
227
263
 
228
- const handleItemClick = (e, item) => {
229
- if (e.nativeEvent._fromKeyboardActivation || disabledButtons.has(item.id)) {
264
+ const handleItemClick = (event, item) => {
265
+ if (event.nativeEvent._fromKeyboardActivation || disabledButtons.has(item.id)) {
266
+ return
267
+ }
268
+ const { panelId, keepFocus } = resolveItemFocus(item, buttonConfig)
269
+ if (panelId) {
270
+ dispatch({ type: 'OPEN_PANEL', payload: { panelId, props: { triggeringElement: instigator }, ...(keepFocus && { focusOnOpen: false }) } })
271
+ setIsOpen(false)
230
272
  return
231
273
  }
232
274
  setIsOpen(false)
233
- activateItem(e, item, activateCtx)
275
+ activateItem(event, item, activateCtx)
276
+ if (keepFocus) {
277
+ instigator.focus()
278
+ } else {
279
+ viewportRef.current?.focus()
280
+ }
234
281
  }
235
282
 
236
283
  useEffect(() => {
@@ -0,0 +1,68 @@
1
+ import React, { useState, useCallback } from 'react'
2
+
3
+ const toTabId = (name) => `im-c-tabs-tab-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
4
+ const toPanelId = (name) => `im-c-tabs-panel-${name.toLowerCase().replaceAll(/\s+/g, '-')}`
5
+
6
+ // eslint-disable-next-line camelcase, react/jsx-pascal-case
7
+ // sonarjs/disable-next-line function-name
8
+ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use PropTypes
9
+ const names = tabs.map(t => t.name)
10
+ const [activeTab, setActiveTab] = useState(defaultTab ?? names[0])
11
+
12
+ const activateTab = useCallback((name) => {
13
+ setActiveTab(name)
14
+ document.getElementById(toTabId(name))?.focus()
15
+ }, [])
16
+
17
+ const handleKeyDown = useCallback((e) => {
18
+ const idx = names.indexOf(activeTab)
19
+ if (e.key === 'ArrowRight') {
20
+ e.preventDefault()
21
+ activateTab(names[(idx + 1) % names.length])
22
+ } else if (e.key === 'ArrowLeft') {
23
+ e.preventDefault()
24
+ activateTab(names[(idx - 1 + names.length) % names.length])
25
+ } else if (e.key === 'Home') {
26
+ e.preventDefault()
27
+ activateTab(names[0])
28
+ } else if (e.key === 'End') {
29
+ e.preventDefault()
30
+ activateTab(names.at(-1))
31
+ } else {
32
+ // no action
33
+ }
34
+ }, [names, activeTab, activateTab])
35
+
36
+ const activeContent = tabs.find(t => t.name === activeTab)?.content
37
+
38
+ return (
39
+ <div className='im-c-tabs'>
40
+ <div role='tablist' className='im-c-tabs__list'>
41
+ {tabs.map(({ name }) => (
42
+ <button
43
+ key={name}
44
+ id={toTabId(name)}
45
+ role='tab'
46
+ aria-selected={activeTab === name}
47
+ aria-controls={toPanelId(name)}
48
+ tabIndex={activeTab === name ? 0 : -1}
49
+ className='im-c-tabs__tab'
50
+ onClick={() => activateTab(name)}
51
+ onKeyDown={handleKeyDown}
52
+ >
53
+ <span className='im-c-tabs__label'>{name}</span>
54
+ </button>
55
+ ))}
56
+ </div>
57
+ <div
58
+ id={toPanelId(activeTab)}
59
+ role='tabpanel'
60
+ aria-labelledby={toTabId(activeTab)}
61
+ tabIndex={-1} // nosonar — panel has no focusable children; -1 per ARIA tabs pattern (AT reads via role/aria-labelledby)
62
+ className='im-c-tabs__panel'
63
+ >
64
+ {activeContent}
65
+ </div>
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,57 @@
1
+ @use '../../../scss/tools/index' as tools;
2
+
3
+ // ===================================================
4
+ // Component: Tabs
5
+ // ===================================================
6
+
7
+ .im-c-tabs__list {
8
+ position: relative;
9
+ display: flex;
10
+ flex-wrap: wrap;
11
+ padding: 0;
12
+ margin: 0;
13
+ border-bottom: 1px solid var(--app-border-color);
14
+ z-index: 1;
15
+ }
16
+
17
+ .im-c-tabs__tab {
18
+ position: relative;
19
+ appearance: none;
20
+ background: var(--button-hover-color);
21
+ border: 1px solid transparent;
22
+ border-bottom: none;
23
+ padding: 7.5px 15px;
24
+ margin: 5px 5px 5px 0;
25
+ cursor: pointer;
26
+ color: var(--foreground-color);
27
+ font-size: inherit;
28
+ font-weight: normal;
29
+ outline: 3px solid transparent;
30
+
31
+ &[aria-selected='true'] {
32
+ background: var(--background-color);
33
+ border-color: var(--app-border-color);
34
+ border-bottom-color: var(--background-color);
35
+ margin-top: 0;
36
+ margin-bottom: -1px;
37
+ }
38
+
39
+ &:focus .im-c-tabs__label {
40
+ @include tools.link-focus;
41
+ }
42
+ }
43
+
44
+ .im-c-tabs__tab:not([aria-selected='true']) {
45
+ &:hover {
46
+ background-color: var(--content-border-color);
47
+ }
48
+
49
+ .im-c-tabs__label {
50
+ text-decoration: underline;
51
+ text-underline-offset: 3px;
52
+ }
53
+
54
+ &:hover .im-c-tabs__label {
55
+ text-decoration-thickness: 3px;
56
+ }
57
+ }