@defra/interactive-map 0.0.22-alpha → 0.0.24-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 (242) hide show
  1. package/assets/images/light-raster-thumb.jpg +0 -0
  2. package/assets/images/outdoor-raster-thumb.jpg +0 -0
  3. package/assets/images/road-raster-thumb.jpg +0 -0
  4. package/dist/css/index.css +1 -1
  5. package/dist/esm/im-core.js +1 -1
  6. package/dist/esm/im-shell.js +1 -1
  7. package/dist/umd/im-core.js +1 -1
  8. package/dist/umd/index.js +1 -1
  9. package/docs/api/button-definition.md +39 -0
  10. package/docs/api/context.md +4 -4
  11. package/docs/api/map-style-config.md +11 -2
  12. package/docs/api/marker-config.md +38 -7
  13. package/docs/api/panel-definition.md +7 -3
  14. package/docs/api/symbol-config.md +10 -26
  15. package/docs/api/symbol-registry.md +28 -13
  16. package/docs/api.md +24 -2
  17. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  18. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  19. package/docs/examples/index.mdx +12 -0
  20. package/docs/examples/toggle-marker-label.mdx +89 -0
  21. package/docs/plugins/interact.md +4 -11
  22. package/docs/plugins/search.md +70 -0
  23. package/docusaurus.config.cjs +2 -1
  24. package/package.json +23 -9
  25. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  26. package/plugins/beta/datasets/dist/css/index.css +2 -2
  27. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  28. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  29. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  30. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  31. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  32. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  33. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  34. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  35. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  36. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  37. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  38. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  39. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  40. package/plugins/beta/draw-ml/dist/css/index.css +1 -21
  41. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  42. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  43. package/plugins/beta/draw-ml/src/api/editFeature.js +9 -4
  44. package/plugins/beta/draw-ml/src/modes/createDrawMode.js +11 -3
  45. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.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/use-location/dist/esm/im-use-location-plugin.js +1 -1
  49. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  50. package/plugins/beta/use-location/src/manifest.js +1 -0
  51. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  52. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  53. package/plugins/interact/dist/umd/index.js +1 -1
  54. package/plugins/interact/src/InteractInit.jsx +23 -55
  55. package/plugins/interact/src/InteractInit.test.js +58 -80
  56. package/plugins/interact/src/defaults.js +1 -3
  57. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  58. package/plugins/interact/src/hooks/useAttachEvents.test.js +68 -0
  59. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  60. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  61. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  62. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  63. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  64. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  65. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  66. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  67. package/plugins/interact/src/index.js +0 -2
  68. package/plugins/interact/src/index.test.js +0 -2
  69. package/plugins/interact/src/manifest.js +37 -6
  70. package/plugins/interact/src/reducer.js +8 -4
  71. package/plugins/interact/src/reducer.test.js +18 -3
  72. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  73. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  74. package/plugins/interact/src/utils/spatial.js +0 -15
  75. package/plugins/interact/src/utils/spatial.test.js +1 -13
  76. package/plugins/search/dist/css/index.css +1 -1
  77. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  78. package/plugins/search/dist/esm/index.js +1 -1
  79. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  80. package/plugins/search/dist/umd/index.js +1 -1
  81. package/plugins/search/src/Search.jsx +2 -1
  82. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  83. package/plugins/search/src/components/Form/Form.jsx +11 -7
  84. package/plugins/search/src/components/Form/Form.test.jsx +23 -1
  85. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  86. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  87. package/plugins/search/src/datasets.js +2 -2
  88. package/plugins/search/src/datasets.test.js +1 -1
  89. package/plugins/search/src/defaults.js +7 -1
  90. package/plugins/search/src/events/fetchSuggestions.js +13 -10
  91. package/plugins/search/src/events/fetchSuggestions.test.js +14 -1
  92. package/plugins/search/src/events/formHandlers.js +2 -4
  93. package/plugins/search/src/events/formHandlers.test.js +3 -3
  94. package/plugins/search/src/events/inputHandlers.js +1 -1
  95. package/plugins/search/src/events/inputHandlers.test.js +1 -1
  96. package/plugins/search/src/events/suggestionHandlers.js +2 -1
  97. package/plugins/search/src/events/suggestionHandlers.test.js +7 -1
  98. package/plugins/search/src/index.js +3 -7
  99. package/plugins/search/src/reducer.js +10 -5
  100. package/plugins/search/src/reducer.test.js +2 -1
  101. package/plugins/search/src/search.scss +1 -0
  102. package/plugins/search/src/utils/parseOsNamesResults.js +3 -3
  103. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  104. package/providers/beta/esri/src/esriProvider.js +4 -0
  105. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -0
  106. package/providers/beta/openlayers/dist/esm/index.js +1 -0
  107. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +2 -0
  108. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js.LICENSE.txt +1 -0
  109. package/providers/beta/openlayers/dist/umd/index.js +2 -0
  110. package/providers/beta/openlayers/dist/umd/index.js.LICENSE.txt +1 -0
  111. package/providers/beta/openlayers/src/appEvents.js +50 -0
  112. package/providers/beta/openlayers/src/appEvents.test.js +140 -0
  113. package/providers/beta/openlayers/src/defaults.js +36 -0
  114. package/providers/beta/openlayers/src/index.js +21 -0
  115. package/providers/beta/openlayers/src/mapEvents.js +132 -0
  116. package/providers/beta/openlayers/src/mapEvents.test.js +197 -0
  117. package/providers/beta/openlayers/src/openlayersProvider.js +234 -0
  118. package/providers/beta/openlayers/src/openlayersProvider.test.js +331 -0
  119. package/providers/beta/openlayers/src/utils/spatial.js +142 -0
  120. package/providers/beta/openlayers/src/utils/spatial.test.js +117 -0
  121. package/providers/beta/openlayers/src/utils/zoom.js +26 -0
  122. package/providers/beta/openlayers/src/utils/zoom.test.js +23 -0
  123. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  124. package/providers/maplibre/dist/esm/index.js +1 -1
  125. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  126. package/providers/maplibre/dist/umd/index.js +1 -1
  127. package/providers/maplibre/src/index.js +1 -1
  128. package/providers/maplibre/src/index.test.js +9 -14
  129. package/providers/maplibre/src/mapEvents.js +11 -5
  130. package/providers/maplibre/src/mapEvents.test.js +14 -3
  131. package/providers/maplibre/src/maplibreProvider.js +18 -11
  132. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  133. package/providers/maplibre/src/utils/highlightFeatures.js +112 -57
  134. package/providers/maplibre/src/utils/highlightFeatures.test.js +179 -51
  135. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  136. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  137. package/providers/maplibre/src/utils/patternImages.js +22 -19
  138. package/providers/maplibre/src/utils/patternImages.test.js +146 -167
  139. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  140. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  141. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  142. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  143. package/rollup.esm.mjs +14 -3
  144. package/src/App/components/Actions/Actions.module.scss +1 -0
  145. package/src/App/components/Actions/Actions.test.jsx +17 -4
  146. package/src/App/components/Hints/Hints.jsx +52 -0
  147. package/src/App/components/Hints/Hints.module.scss +37 -0
  148. package/src/App/components/Hints/Hints.test.jsx +104 -0
  149. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  150. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  151. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  152. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  153. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  154. package/src/App/components/Markers/Markers.jsx +44 -75
  155. package/src/App/components/Markers/Markers.module.scss +58 -0
  156. package/src/App/components/Markers/Markers.test.jsx +90 -113
  157. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  158. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  159. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  160. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  161. package/src/App/components/Panel/Panel.jsx +1 -1
  162. package/src/App/components/Panel/Panel.module.scss +1 -0
  163. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  164. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  165. package/src/App/components/Tabs/Tabs.jsx +68 -0
  166. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  167. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  168. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  169. package/src/App/components/Viewport/Features.jsx +35 -0
  170. package/src/App/components/Viewport/Features.module.scss +31 -0
  171. package/src/App/components/Viewport/Features.test.jsx +124 -0
  172. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  173. package/src/App/components/Viewport/Viewport.jsx +35 -29
  174. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  175. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  176. package/src/App/controls/keyboardActions.js +9 -3
  177. package/src/App/controls/keyboardActions.test.js +122 -87
  178. package/src/App/controls/keyboardShortcuts.js +1 -14
  179. package/src/App/hooks/useFeatureFocus.js +199 -0
  180. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  181. package/src/App/hooks/useFeatureItems.js +30 -0
  182. package/src/App/hooks/useFeatureItems.test.js +98 -0
  183. package/src/App/hooks/useFocusVisible.js +4 -7
  184. package/src/App/hooks/useFocusVisible.test.js +6 -6
  185. package/src/App/hooks/useKeyboardHint.js +9 -34
  186. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  187. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  188. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  189. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  190. package/src/App/hooks/useLayoutMeasurements.test.js +30 -1
  191. package/src/App/hooks/useMarkerCursor.js +33 -0
  192. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  193. package/src/App/hooks/useMarkersAPI.js +32 -9
  194. package/src/App/hooks/useMarkersAPI.test.js +163 -94
  195. package/src/App/hooks/useModalPanelBehaviour.test.js +2 -1
  196. package/src/App/layout/Layout.jsx +2 -0
  197. package/src/App/layout/Layout.test.jsx +3 -0
  198. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  199. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  200. package/src/App/renderer/mapButtons.js +5 -1
  201. package/src/App/renderer/mapButtons.test.js +37 -1
  202. package/src/App/store/AppProvider.test.jsx +5 -0
  203. package/src/App/store/MapProvider.test.jsx +8 -1
  204. package/src/App/store/ServiceProvider.jsx +4 -1
  205. package/src/App/store/ServiceProvider.test.jsx +11 -0
  206. package/src/App/store/appActionsMap.js +5 -1
  207. package/src/App/store/appActionsMap.test.js +13 -0
  208. package/src/App/store/appReducer.js +1 -0
  209. package/src/App/store/mapActionsMap.js +2 -1
  210. package/src/InteractiveMap/InteractiveMap.js +12 -0
  211. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  212. package/src/config/appConfig.js +3 -1
  213. package/src/config/appConfig.test.js +4 -0
  214. package/src/config/defaults.js +1 -1
  215. package/src/config/events.js +8 -0
  216. package/src/config/mapTheme.js +8 -4
  217. package/src/config/symbolConfig.js +23 -16
  218. package/src/scss/main.scss +3 -0
  219. package/src/scss/tools/_border-focus.scss +0 -1
  220. package/src/services/announcer.test.js +5 -7
  221. package/src/services/eventBus.js +15 -0
  222. package/src/services/eventBus.test.js +11 -0
  223. package/src/services/hints.js +47 -0
  224. package/src/services/hints.test.js +110 -0
  225. package/src/services/patternRegistry.js +69 -1
  226. package/src/services/patternRegistry.test.js +112 -1
  227. package/src/services/reverseGeocode.js +4 -0
  228. package/src/services/reverseGeocode.test.js +11 -1
  229. package/src/services/symbolRegistry.js +152 -17
  230. package/src/services/symbolRegistry.test.js +141 -31
  231. package/src/types.js +71 -14
  232. package/src/utils/constrainKeyboardFocus.js +5 -5
  233. package/src/utils/detectInterfaceType.js +27 -5
  234. package/src/utils/detectInterfaceType.test.js +29 -3
  235. package/src/utils/patternUtils.js +4 -67
  236. package/src/utils/patternUtils.test.js +1 -123
  237. package/src/utils/symbolUtils.js +11 -13
  238. package/src/utils/symbolUtils.test.js +18 -26
  239. package/webpack.dev.mjs +6 -1
  240. package/webpack.umd.mjs +1 -0
  241. package/plugins/interact/dist/css/index.css +0 -1
  242. package/plugins/interact/src/interact.scss +0 -24
@@ -1,9 +1,10 @@
1
1
  // src/components/KeyboardHelp.test.jsx
2
2
  import React from 'react'
3
- import { render, screen, within } from '@testing-library/react'
3
+ import { render, screen, fireEvent } from '@testing-library/react'
4
4
  import { KeyboardHelp } from './KeyboardHelp'
5
5
  import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js'
6
6
  import { useConfig } from '../../store/configContext'
7
+ import { useApp } from '../../store/appContext.js'
7
8
 
8
9
  jest.mock('../../registry/keyboardShortcutRegistry.js', () => ({
9
10
  getKeyboardShortcuts: jest.fn()
@@ -13,51 +14,159 @@ jest.mock('../../store/configContext', () => ({
13
14
  useConfig: jest.fn()
14
15
  }))
15
16
 
16
- describe('KeyboardHelp', () => {
17
+ jest.mock('../../store/appContext.js', () => ({
18
+ useApp: jest.fn()
19
+ }))
20
+
21
+ jest.mock('../../hooks/useResizeObserver.js', () => ({ useResizeObserver: jest.fn() }))
22
+
23
+ const SELECT_FEATURES_GROUP = 'Select features'
24
+ const DEFAULT_GROUP = 'Navigate'
25
+
26
+ // Ungrouped — mirrors real core shortcuts (no group, plain context)
27
+ const VIEWPORT_SHORTCUTS = [
28
+ { id: '1', context: 'viewport', title: 'Move', command: '<kbd>↑</kbd>' },
29
+ { id: '2', context: 'viewport', title: 'Zoom', command: '<kbd>+</kbd>' }
30
+ ]
31
+
32
+ const LISTBOX_SHORTCUTS = [
33
+ { id: '3', group: SELECT_FEATURES_GROUP, context: 'listbox', title: 'Navigate', command: '<kbd>↓</kbd>' },
34
+ { id: '4', group: SELECT_FEATURES_GROUP, context: 'listbox', title: 'Select', command: '<kbd>Enter</kbd>' }
35
+ ]
36
+
37
+ const GLOBAL_SHORTCUTS = [
38
+ { id: '5', context: 'global', title: 'Help', command: '<kbd>Alt</kbd>' }
39
+ ]
40
+
41
+ beforeEach(() => {
42
+ useConfig.mockReturnValue({})
43
+ useApp.mockReturnValue({ listboxIsActive: false })
44
+ })
45
+
46
+ afterEach(() => {
47
+ jest.clearAllMocks()
48
+ })
49
+
50
+ // ─── flat list (single group) ─────────────────────────────────────────────────
51
+
52
+ describe('KeyboardHelp — flat list', () => {
53
+ it('renders a flat list with no tabs when all shortcuts share one group', () => {
54
+ getKeyboardShortcuts.mockReturnValue(VIEWPORT_SHORTCUTS)
55
+ render(<KeyboardHelp />)
56
+ expect(document.querySelector('.im-c-keyboard-help')).toBeInTheDocument()
57
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
58
+ expect(screen.getByText('Move')).toBeInTheDocument()
59
+ expect(screen.getByText('Zoom')).toBeInTheDocument()
60
+ })
61
+
62
+ it('renders a flat list with no tabs when there are no shortcuts', () => {
63
+ getKeyboardShortcuts.mockReturnValue([])
64
+ render(<KeyboardHelp />)
65
+ expect(document.querySelector('.im-c-keyboard-help')).toBeInTheDocument()
66
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
67
+ })
68
+
69
+ it('renders command HTML', () => {
70
+ getKeyboardShortcuts.mockReturnValue(VIEWPORT_SHORTCUTS)
71
+ render(<KeyboardHelp />)
72
+ expect(screen.getByText('↑')).toBeInTheDocument()
73
+ })
74
+ })
75
+
76
+ // ─── tab UI (multiple groups) ─────────────────────────────────────────────────
77
+
78
+ describe('KeyboardHelp — tabs', () => {
79
+ const allShortcuts = [...GLOBAL_SHORTCUTS, ...VIEWPORT_SHORTCUTS, ...LISTBOX_SHORTCUTS]
80
+
17
81
  beforeEach(() => {
18
- useConfig.mockReturnValue({})
82
+ useApp.mockReturnValue({ listboxIsActive: true })
83
+ })
84
+
85
+ it('renders a tab for each group when listboxIsActive is true', () => {
86
+ getKeyboardShortcuts.mockReturnValue(allShortcuts)
87
+ render(<KeyboardHelp />)
88
+ expect(screen.getByRole('tablist')).toBeInTheDocument()
89
+ expect(screen.getByRole('tab', { name: DEFAULT_GROUP })).toBeInTheDocument()
90
+ expect(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP })).toBeInTheDocument()
91
+ })
92
+
93
+ it('defaults to first viewport-context tab when context is viewport', () => {
94
+ getKeyboardShortcuts.mockReturnValue(allShortcuts)
95
+ render(<KeyboardHelp context='viewport' />)
96
+ expect(screen.getByRole('tab', { name: DEFAULT_GROUP })).toHaveAttribute('aria-selected', 'true')
97
+ expect(screen.getByText('Move')).toBeInTheDocument()
19
98
  })
20
99
 
21
- afterEach(() => {
22
- jest.clearAllMocks()
100
+ it('defaults to first listbox-context tab when context is listbox', () => {
101
+ getKeyboardShortcuts.mockReturnValue(allShortcuts)
102
+ render(<KeyboardHelp context='listbox' />)
103
+ expect(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP })).toHaveAttribute('aria-selected', 'true')
104
+ expect(screen.getByText('Select')).toBeInTheDocument()
23
105
  })
24
106
 
25
- it('renders grouped keyboard shortcuts correctly', () => {
107
+ it('falls back to first global-context tab when no exact match exists', () => {
108
+ getKeyboardShortcuts.mockReturnValue([...GLOBAL_SHORTCUTS, ...LISTBOX_SHORTCUTS])
109
+ render(<KeyboardHelp context='viewport' />)
110
+ expect(screen.getByRole('tab', { name: DEFAULT_GROUP })).toHaveAttribute('aria-selected', 'true')
111
+ })
112
+
113
+ it('falls back to first tab when no exact or global-context match exists', () => {
26
114
  getKeyboardShortcuts.mockReturnValue([
27
- { id: '1', group: 'Navigation', title: 'Go Home', command: '<kbd>H</kbd>' },
28
- { id: '2', group: 'Navigation', title: 'Search', command: '<kbd>/</kbd>' },
29
- { id: '3', group: 'Editing', title: 'Copy', command: '<kbd>Ctrl+C</kbd>' }
115
+ { id: 'a', group: 'Group A', context: 'listbox', title: 'A', command: '<kbd>A</kbd>' },
116
+ { id: 'b', group: 'Group B', context: 'listbox', title: 'B', command: '<kbd>B</kbd>' }
30
117
  ])
118
+ render(<KeyboardHelp context='viewport' />)
119
+ expect(screen.getByRole('tab', { name: 'Group A' })).toHaveAttribute('aria-selected', 'true')
120
+ })
31
121
 
32
- render(<KeyboardHelp />)
122
+ it('shows only the active tab panel content', () => {
123
+ getKeyboardShortcuts.mockReturnValue(allShortcuts)
124
+ render(<KeyboardHelp context='viewport' />)
125
+ expect(screen.getByText('Move')).toBeInTheDocument()
126
+ expect(screen.queryByText('Select')).not.toBeInTheDocument()
127
+ })
33
128
 
34
- // outer container
35
- const container = document.querySelector('.im-c-keyboard-help')
36
- expect(container).toBeInTheDocument()
129
+ it('switches tab and content on click', () => {
130
+ getKeyboardShortcuts.mockReturnValue(allShortcuts)
131
+ render(<KeyboardHelp context='viewport' />)
132
+ fireEvent.click(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP }))
133
+ expect(screen.getByRole('tab', { name: SELECT_FEATURES_GROUP })).toHaveAttribute('aria-selected', 'true')
134
+ expect(screen.getByText('Select')).toBeInTheDocument()
135
+ expect(screen.queryByText('Move')).not.toBeInTheDocument()
136
+ })
37
137
 
38
- // Navigation group contains its shortcuts
39
- const navGroup = screen.getByText('Go Home').closest('.im-c-keyboard-help__group')
40
- expect(navGroup).toBeInTheDocument()
41
- expect(within(navGroup).getByText('Search')).toBeInTheDocument()
42
- expect(within(navGroup).getByText('Go Home')).toBeInTheDocument()
138
+ it('ungrouped shortcuts default to Navigate group', () => {
139
+ getKeyboardShortcuts.mockReturnValue([
140
+ { id: 'x', title: 'No group', command: '<kbd>X</kbd>' },
141
+ ...LISTBOX_SHORTCUTS
142
+ ])
143
+ render(<KeyboardHelp />)
144
+ fireEvent.click(screen.getByRole('tab', { name: DEFAULT_GROUP }))
145
+ expect(screen.getByText('No group')).toBeInTheDocument()
146
+ })
147
+ })
43
148
 
44
- // Editing group contains its shortcut
45
- const editGroup = screen.getByText('Copy').closest('.im-c-keyboard-help__group')
46
- expect(editGroup).toBeInTheDocument()
149
+ // ─── listboxIsActive filtering ────────────────────────────────────────────────
47
150
 
48
- // command HTML is injected
49
- expect(screen.getByText('H')).toBeInTheDocument()
50
- expect(screen.getByText('/')).toBeInTheDocument()
51
- expect(screen.getByText('Ctrl+C')).toBeInTheDocument()
52
- })
151
+ describe('KeyboardHelp listboxIsActive filtering', () => {
152
+ const allShortcuts = [...GLOBAL_SHORTCUTS, ...VIEWPORT_SHORTCUTS, ...LISTBOX_SHORTCUTS]
53
153
 
54
- it('renders nothing if there are no shortcuts', () => {
55
- getKeyboardShortcuts.mockReturnValue([])
154
+ it('hides the Select features tab when listboxIsActive is false', () => {
155
+ getKeyboardShortcuts.mockReturnValue(allShortcuts)
156
+ render(<KeyboardHelp />)
157
+ expect(screen.queryByRole('tab', { name: SELECT_FEATURES_GROUP })).not.toBeInTheDocument()
158
+ })
56
159
 
160
+ it('still shows ungrouped shortcuts when listboxIsActive is false', () => {
161
+ getKeyboardShortcuts.mockReturnValue(allShortcuts)
57
162
  render(<KeyboardHelp />)
163
+ expect(screen.getByText('Move')).toBeInTheDocument()
164
+ })
58
165
 
59
- const container = document.querySelector('.im-c-keyboard-help')
60
- expect(container).toBeInTheDocument()
61
- expect(container.querySelectorAll('.im-c-keyboard-help__group')).toHaveLength(0)
166
+ it('renders as a flat list when only ungrouped shortcuts remain after filtering', () => {
167
+ getKeyboardShortcuts.mockReturnValue([...VIEWPORT_SHORTCUTS, ...LISTBOX_SHORTCUTS])
168
+ render(<KeyboardHelp />)
169
+ expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
170
+ expect(screen.getByText('Move')).toBeInTheDocument()
62
171
  })
63
172
  })
@@ -0,0 +1,14 @@
1
+ const LabelMarker = ({ marker, mapId, markerRef }) => ( // NOSONAR: project does not use PropTypes
2
+ <div
3
+ ref={markerRef(marker.id)}
4
+ id={`${mapId}-marker-${marker.id}`}
5
+ className='im-c-marker-wrapper im-c-marker-wrapper--label'
6
+ style={{ display: marker.isVisible ? 'block' : 'none' }}
7
+ >
8
+ <div role='note' className='im-c-marker__label im-c-marker__label--standalone'>
9
+ {marker.label}
10
+ </div>
11
+ </div>
12
+ )
13
+
14
+ export default LabelMarker
@@ -0,0 +1,36 @@
1
+ import { render } from '@testing-library/react'
2
+ import LabelMarker from './LabelMarker.jsx'
3
+
4
+ const makeMarker = (overrides = {}) => ({ id: 'lbl-1', isVisible: true, label: 'Test Label', ...overrides })
5
+ const markerRef = () => () => {}
6
+
7
+ describe('LabelMarker', () => {
8
+ it('renders the label text', () => {
9
+ const { getByText } = render(<LabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} />)
10
+ expect(getByText('Test Label')).toBeTruthy()
11
+ })
12
+
13
+ it('has wrapper id of mapId-marker-markerId', () => {
14
+ const { container } = render(<LabelMarker marker={makeMarker()} mapId='my-map' markerRef={markerRef} />)
15
+ expect(container.querySelector('.im-c-marker-wrapper').getAttribute('id')).toBe('my-map-marker-lbl-1')
16
+ })
17
+
18
+ it('has wrapper class im-c-marker-wrapper--label', () => {
19
+ const { container } = render(<LabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} />)
20
+ expect(container.querySelector('.im-c-marker-wrapper')).toHaveClass('im-c-marker-wrapper--label')
21
+ })
22
+
23
+ it('label element has role note and standalone class', () => {
24
+ const { container } = render(<LabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} />)
25
+ const label = container.querySelector('.im-c-marker__label--standalone')
26
+ expect(label.getAttribute('role')).toBe('note')
27
+ })
28
+
29
+ it.each([
30
+ [true, 'block'],
31
+ [false, 'none']
32
+ ])('display is %s when isVisible=%s', (isVisible, display) => {
33
+ const { container } = render(<LabelMarker marker={makeMarker({ isVisible })} mapId='map' markerRef={markerRef} />)
34
+ expect(container.querySelector('.im-c-marker-wrapper')).toHaveStyle({ display })
35
+ })
36
+ })
@@ -1,59 +1,51 @@
1
1
  import { useEffect, useRef, useState } from 'react'
2
2
  import { useMarkers } from '../../hooks/useMarkersAPI.js'
3
+ import { useMarkerCursor } from '../../hooks/useMarkerCursor.js'
3
4
  import { useConfig } from '../../store/configContext.js'
4
5
  import { useMap } from '../../store/mapContext.js'
5
6
  import { useService } from '../../store/serviceContext.js'
6
- import { stringToKebab } from '../../../utils/stringToKebab.js'
7
7
  import { scaleFactor } from '../../../config/appConfig.js'
8
+ import { isStandaloneLabel } from '../../../utils/symbolUtils.js'
9
+ import { EVENTS } from '../../../config/events.js'
10
+ import LabelMarker from './LabelMarker.jsx'
11
+ import SymbolLabelMarker from './SymbolLabelMarker.jsx'
12
+ import SymbolMarker from './SymbolMarker.jsx'
8
13
 
9
14
  // Marker properties handled internally — excluded from style value resolution
10
- const INTERNAL_KEYS = new Set(['id', 'coords', 'x', 'y', 'isVisible', 'symbol', 'symbolSvgContent', 'viewBox', 'anchor', 'selectedColor', 'selectedWidth'])
15
+ const INTERNAL_KEYS = new Set(['id', 'coords', 'x', 'y', 'isVisible', 'symbol', 'symbolSvgContent', 'viewBox', 'anchor', 'selectedColor', 'label', 'showLabel'])
11
16
 
12
17
  const resolveSymbolDef = (marker, defaults, symbolRegistry) => {
13
18
  const svgContent = marker.symbolSvgContent || defaults.symbolSvgContent
14
- // Inline symbolSvgContent takes precedence over a registered symbol,
15
- // cascading through marker → constructor defaults
16
19
  return svgContent
17
20
  ? { svg: svgContent }
18
21
  : symbolRegistry.get(marker.symbol || defaults.symbol)
19
22
  }
20
23
 
21
24
  const resolveViewBox = (marker, defaults, symbolDef) =>
22
- marker.viewBox || defaults.viewBox || symbolDef?.viewBox || '0 0 38 38'
25
+ marker.viewBox || defaults.viewBox || symbolDef?.viewBox || '0 0 44 44'
23
26
 
24
27
  const resolveAnchor = (marker, defaults, symbolDef) =>
25
28
  marker.anchor ?? defaults.anchor ?? symbolDef?.anchor ?? [0.5, 0.5]
26
29
 
27
- /**
28
- * When the interact plugin is active, watch mousemove to set cursor:pointer whenever
29
- * the mouse is over one of the marker SVG elements (which are pointer-events:none).
30
- */
31
- const useMarkerCursor = (markers, interactActive, viewportRef) => {
32
- useEffect(() => {
33
- if (!interactActive) {
34
- return undefined
35
- }
36
- const viewport = viewportRef.current
37
- if (!viewport) {
38
- return undefined
39
- }
40
- const onMove = (e) => {
41
- const hit = markers.items.some(marker => {
42
- const el = markers.markerRefs?.get(marker.id)
43
- if (!el) {
44
- return false
45
- }
46
- const { left, top, right, bottom } = el.getBoundingClientRect()
47
- return e.clientX >= left && e.clientX <= right && e.clientY >= top && e.clientY <= bottom
48
- })
49
- viewport.style.cursor = hit ? 'pointer' : ''
50
- }
51
- viewport.addEventListener('mousemove', onMove)
52
- return () => {
53
- viewport.removeEventListener('mousemove', onMove)
54
- viewport.style.cursor = ''
55
- }
56
- }, [markers, interactActive, viewportRef])
30
+ const resolveSymbolProps = (marker, defaults, symbolRegistry, mapStyle, mapSize, isSelected, isActive) => {
31
+ const symbolDef = resolveSymbolDef(marker, defaults, symbolRegistry)
32
+ const styleValues = Object.fromEntries(
33
+ Object.entries(marker).filter(([k]) => !INTERNAL_KEYS.has(k))
34
+ )
35
+ let resolvedSvg
36
+ if (isActive) {
37
+ resolvedSvg = symbolRegistry.resolveActive(symbolDef, styleValues, mapStyle)
38
+ } else if (isSelected) {
39
+ resolvedSvg = symbolRegistry.resolveSelected(symbolDef, styleValues, mapStyle)
40
+ } else {
41
+ resolvedSvg = symbolRegistry.resolve(symbolDef, styleValues, mapStyle)
42
+ }
43
+ const viewBox = resolveViewBox(marker, defaults, symbolDef)
44
+ const [,, svgWidth, svgHeight] = viewBox.split(' ').map(Number)
45
+ const anchor = resolveAnchor(marker, defaults, symbolDef)
46
+ const shapeId = marker.symbol || defaults.symbol
47
+ const scale = scaleFactor[mapSize] ?? 1
48
+ return { resolvedSvg, viewBox, anchor, shapeId, scaledWidth: svgWidth * scale, scaledHeight: svgHeight * scale }
57
49
  }
58
50
 
59
51
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
@@ -66,16 +58,20 @@ export const Markers = () => {
66
58
 
67
59
  const [canSelectMarker, setCanSelectMarker] = useState(false)
68
60
  const [selectedMarkers, setSelectedMarkers] = useState([])
61
+ const [activeMarkerId, setActiveMarkerId] = useState(null)
69
62
  const viewportRef = useRef(null)
70
63
 
71
64
  useEffect(() => {
72
65
  const handleActive = ({ active, interactionModes = [] }) => setCanSelectMarker(active && interactionModes.includes('selectMarker'))
73
66
  const handleSelectionChange = ({ selectedMarkers: next = [] }) => setSelectedMarkers(next)
67
+ const handleSetActive = ({ id: markerId }) => setActiveMarkerId(markerId)
74
68
  eventBus.on('interact:active', handleActive)
75
69
  eventBus.on('interact:selectionchange', handleSelectionChange)
70
+ eventBus.on(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
76
71
  return () => {
77
72
  eventBus.off('interact:active', handleActive)
78
73
  eventBus.off('interact:selectionchange', handleSelectionChange)
74
+ eventBus.off(EVENTS.MAP_SET_ACTIVE_FEATURE, handleSetActive)
79
75
  }
80
76
  }, [eventBus])
81
77
 
@@ -95,47 +91,20 @@ export const Markers = () => {
95
91
  return (
96
92
  <>
97
93
  {markers.items.map(marker => {
98
- const symbolDef = resolveSymbolDef(marker, defaults, symbolRegistry)
99
- // selectedColor comes from mapStyle — not per-marker; selectedWidth stays in cascade
100
- const styleValues = Object.fromEntries(
101
- Object.entries(marker).filter(([k]) => !INTERNAL_KEYS.has(k))
102
- )
103
94
  const isSelected = selectedMarkers.includes(marker.id)
104
- const resolvedSvg = isSelected
105
- ? symbolRegistry.resolveSelected(symbolDef, styleValues, mapStyle)
106
- : symbolRegistry.resolve(symbolDef, styleValues, mapStyle)
107
-
108
- const viewBox = resolveViewBox(marker, defaults, symbolDef)
109
- const [,, svgWidth, svgHeight] = viewBox.split(' ').map(Number)
110
- const anchor = resolveAnchor(marker, defaults, symbolDef)
111
- const shapeId = marker.symbol || defaults.symbol
112
- const scale = scaleFactor[mapSize] ?? 1
113
- const scaledWidth = svgWidth * scale
114
- const scaledHeight = svgHeight * scale
115
-
116
- return (
117
- <svg
118
- key={marker.id}
119
- ref={markerRef(marker.id)}
120
- id={`${id}-marker-${marker.id}`}
121
- className={[
122
- 'im-c-marker',
123
- `im-c-marker--${stringToKebab(shapeId)}`,
124
- isSelected && 'im-c-marker--selected'
125
- ].filter(Boolean).join(' ')}
126
- width={scaledWidth}
127
- height={scaledHeight}
128
- viewBox={viewBox}
129
- overflow='visible'
130
- style={{
131
- display: marker.isVisible ? 'block' : 'none',
132
- marginLeft: `${-anchor[0] * scaledWidth}px`,
133
- marginTop: `${-anchor[1] * scaledHeight}px`
134
- }}
135
- >
136
- <g dangerouslySetInnerHTML={{ __html: resolvedSvg }} />
137
- </svg>
138
- )
95
+ const isActive = marker.id === activeMarkerId
96
+
97
+ if (isStandaloneLabel(marker)) {
98
+ return <LabelMarker key={marker.id} marker={marker} mapId={id} markerRef={markerRef} />
99
+ }
100
+
101
+ const symbolProps = resolveSymbolProps(marker, defaults, symbolRegistry, mapStyle, mapSize, isSelected, isActive)
102
+
103
+ if (marker.showLabel && marker.label) {
104
+ return <SymbolLabelMarker key={marker.id} marker={marker} mapId={id} markerRef={markerRef} isSelected={isSelected} symbolProps={symbolProps} />
105
+ }
106
+
107
+ return <SymbolMarker key={marker.id} marker={marker} mapId={id} markerRef={markerRef} isSelected={isSelected} symbolProps={symbolProps} />
139
108
  })}
140
109
  </>
141
110
  )
@@ -3,6 +3,16 @@
3
3
  // ===================================================
4
4
 
5
5
  // 1. Base styles
6
+
7
+ // Wrapper for all markers — absolutely positioned at origin so marker children
8
+ // position correctly and parentElement.getBoundingClientRect() matches the viewport origin
9
+ .im-c-markers {
10
+ position: absolute;
11
+ top: 0;
12
+ left: 0;
13
+ pointer-events: none;
14
+ }
15
+
6
16
  .im-c-marker {
7
17
  position: absolute;
8
18
  left: 0;
@@ -10,3 +20,51 @@
10
20
  pointer-events: none;
11
21
  }
12
22
 
23
+ // Wrapper used when a marker has an html label, or for standalone labels.
24
+ // Takes the same absolute positioning as .im-c-marker.
25
+ .im-c-marker-wrapper {
26
+ position: absolute;
27
+ left: 0;
28
+ top: 0;
29
+ pointer-events: none;
30
+ }
31
+
32
+ // SVG inside a wrapper — fills wrapper dimensions, no independent positioning
33
+ .im-c-marker__svg {
34
+ display: block;
35
+ pointer-events: none;
36
+ }
37
+
38
+ // Label rendered above a marker symbol
39
+ .im-c-marker__label {
40
+ position: absolute;
41
+ bottom: 100%;
42
+ left: 50%;
43
+ transform: translateX(-50%);
44
+ white-space: nowrap;
45
+ pointer-events: none;
46
+
47
+ color: var(--tooltip-foreground-color);
48
+ background-color: var(--tooltip-background-color);
49
+ border-radius: var(--tooltip-border-radius);
50
+ padding: var(--tooltip-padding);
51
+ font-size: var(--tooltip-font-size);
52
+ line-height: 1.2;
53
+ }
54
+
55
+ // Standalone label — no symbol, coordinate is where the arrow points.
56
+ // Label body sits above (x, y); arrow points down to (x, y).
57
+ .im-c-marker__label--standalone {
58
+ bottom: 0;
59
+ }
60
+
61
+ .im-c-marker__label--standalone::after {
62
+ content: '';
63
+ position: absolute;
64
+ top: calc(100% - 1px);
65
+ left: 50%;
66
+ transform: translateX(-50%);
67
+ border: calc(var(--tooltip-arrow-size) - 2px) solid transparent;
68
+ border-top-color: var(--tooltip-background-color);
69
+ }
70
+