@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
@@ -11,6 +11,12 @@ jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
11
11
  jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
12
12
  jest.mock('../../../config/appConfig.js', () => ({ scaleFactor: { small: 1, medium: 1.5, large: 2 } }))
13
13
 
14
+ const MARKER_ID = 'marker-1'
15
+ const SEL_CHANGE = 'interact:selectionchange'
16
+ const INTERACT_ACTIVE = 'interact:active'
17
+ const SELECTED_CLASS = 'im-c-marker--selected'
18
+ const SVG_SEL = 'svg'
19
+
14
20
  const makeEventBus = () => {
15
21
  const listeners = {}
16
22
  return {
@@ -29,7 +35,7 @@ const makeSymbolRegistry = (overrides = {}) => ({
29
35
  })
30
36
 
31
37
  const makeMarker = (overrides = {}) => ({
32
- id: 'marker-1', isVisible: true, symbol: 'pin', ...overrides
38
+ id: MARKER_ID, isVisible: true, symbol: 'pin', ...overrides
33
39
  })
34
40
 
35
41
  const setup = ({ markers = [], mapSize = 'small', eventBus, symbolRegistry, mapStyle = 'outdoor' } = {}) => {
@@ -41,36 +47,62 @@ const setup = ({ markers = [], mapSize = 'small', eventBus, symbolRegistry, mapS
41
47
  useService.mockReturnValue({ symbolRegistry: sr, eventBus: eb })
42
48
  useMarkers.mockReturnValue({
43
49
  markers: { items: markers, markerRefs },
44
- markerRef: (id) => (el) => { if (el) markerRefs.set(id, el) }
50
+ markerRef: (id) => (el) => { if (el) { markerRefs.set(id, el) } }
45
51
  })
46
52
  return { eb, sr, result: render(<Markers />) }
47
53
  }
48
54
 
49
- describe('Markers', () => {
55
+ // ─── Markers basic rendering ────────────────────────────────────────────────
56
+
57
+ describe('Markers — basic rendering', () => {
50
58
  it('renders nothing when mapStyle is not set', () => {
51
59
  expect(setup({ mapStyle: null }).result.container.firstChild).toBeNull()
52
60
  })
53
61
 
54
62
  it('renders nothing when there are no markers', () => {
55
- expect(setup().result.container.querySelectorAll('svg')).toHaveLength(0)
63
+ expect(setup().result.container.querySelectorAll(SVG_SEL)).toHaveLength(0)
56
64
  })
57
65
 
58
66
  it('renders one svg per marker with correct id and classes', () => {
59
- const { result } = setup({ markers: [makeMarker(), makeMarker({ id: 'b', symbol: undefined })] })
60
- const [svg1, svg2] = result.container.querySelectorAll('svg')
67
+ const { result } = setup({ markers: [makeMarker(), makeMarker({ id: 'b', symbol: null })] })
68
+ const [svg1, svg2] = result.container.querySelectorAll(SVG_SEL)
61
69
  expect(svg1.getAttribute('id')).toBe('test-app-marker-marker-1')
62
70
  expect(svg1).toHaveClass('im-c-marker', 'im-c-marker--pin')
63
71
  expect(svg2).toHaveClass('im-c-marker--pin')
64
72
  })
73
+ })
65
74
 
66
- it.each([
67
- [true, 'block'],
68
- [false, 'none']
69
- ])('display is %s when isVisible=%s', (isVisible, display) => {
70
- const svg = setup({ markers: [makeMarker({ isVisible })] }).result.container.querySelector('svg')
71
- expect(svg).toHaveStyle({ display })
75
+ // ─── Markers — routing ────────────────────────────────────────────────────────
76
+
77
+ describe('Markers — routing', () => {
78
+ it('renders a LabelMarker for a marker with symbol: null', () => {
79
+ const marker = makeMarker({ id: 'lbl', label: 'My label', symbol: null, symbolSvgContent: null })
80
+ const { result } = setup({ markers: [marker] })
81
+ const wrapper = result.container.querySelector('.im-c-marker-wrapper--label')
82
+ expect(wrapper).toBeTruthy()
83
+ expect(wrapper.querySelector('.im-c-marker__label--standalone').textContent).toBe('My label')
84
+ expect(result.container.querySelector(SVG_SEL)).toBeNull()
72
85
  })
73
86
 
87
+ it('renders a SymbolLabelMarker when showLabel is true', () => {
88
+ const marker = makeMarker({ label: 'Tooltip', showLabel: true })
89
+ const { result } = setup({ markers: [marker] })
90
+ const wrapper = result.container.querySelector('.im-c-marker-wrapper')
91
+ expect(wrapper).toBeTruthy()
92
+ expect(wrapper.querySelector('.im-c-marker__label').textContent).toBe('Tooltip')
93
+ expect(wrapper.querySelector(SVG_SEL)).toBeTruthy()
94
+ })
95
+
96
+ it('renders an svg without label when showLabel is false or absent', () => {
97
+ const { result } = setup({ markers: [makeMarker({ label: 'hidden', showLabel: false })] })
98
+ expect(result.container.querySelector(SVG_SEL)).toBeTruthy()
99
+ expect(result.container.querySelector('.im-c-marker__label')).toBeNull()
100
+ })
101
+ })
102
+
103
+ // ─── Markers — symbol resolution ─────────────────────────────────────────────
104
+
105
+ describe('Markers — symbol resolution', () => {
74
106
  it('uses inline symbolSvgContent over the symbol registry', () => {
75
107
  const sr = makeSymbolRegistry()
76
108
  setup({ markers: [makeMarker({ symbolSvgContent: '<rect/>' })], symbolRegistry: sr })
@@ -81,23 +113,23 @@ describe('Markers', () => {
81
113
  const sr = makeSymbolRegistry({
82
114
  getDefaults: jest.fn(() => ({ symbolSvgContent: '<default-svg/>', viewBox: '0 0 38 38', anchor: [0.5, 1] }))
83
115
  })
84
- setup({ markers: [makeMarker({ symbol: undefined })], symbolRegistry: sr })
116
+ setup({ markers: [makeMarker({ symbol: null })], symbolRegistry: sr })
85
117
  expect(sr.get).not.toHaveBeenCalled()
86
118
  })
87
119
 
88
120
  it('uses marker.viewBox when provided', () => {
89
- const svg = setup({ markers: [makeMarker({ viewBox: '0 0 50 60' })] }).result.container.querySelector('svg')
121
+ const svg = setup({ markers: [makeMarker({ viewBox: '0 0 50 60' })] }).result.container.querySelector(SVG_SEL)
90
122
  expect(svg.getAttribute('viewBox')).toBe('0 0 50 60')
91
123
  expect(svg.getAttribute('width')).toBe('50')
92
124
  expect(svg.getAttribute('height')).toBe('60')
93
125
  })
94
126
 
95
- it("falls back to '0 0 38 38' viewBox when none is provided", () => {
127
+ it("falls back to '0 0 44 44' viewBox when none is provided", () => {
96
128
  const sr = makeSymbolRegistry({
97
129
  get: jest.fn(() => ({ svg: '<circle/>' })),
98
130
  getDefaults: jest.fn(() => ({ symbol: 'pin' }))
99
131
  })
100
- expect(setup({ markers: [makeMarker()], symbolRegistry: sr }).result.container.querySelector('svg').getAttribute('viewBox')).toBe('0 0 38 38')
132
+ expect(setup({ markers: [makeMarker()], symbolRegistry: sr }).result.container.querySelector(SVG_SEL).getAttribute('viewBox')).toBe('0 0 44 44')
101
133
  })
102
134
 
103
135
  it.each([
@@ -105,8 +137,8 @@ describe('Markers', () => {
105
137
  ['symbolDef.anchor', makeMarker(), { get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38', anchor: [0, 0.5] })), getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38' })) }, '0px', '-19px'],
106
138
  ['[0.5, 0.5] fallback', makeMarker(), { get: jest.fn(() => ({ svg: '<circle/>', viewBox: '0 0 38 38' })), getDefaults: jest.fn(() => ({ symbol: 'pin', viewBox: '0 0 38 38' })) }, '-19px', '-19px']
107
139
  ])('resolveAnchor uses %s', (_, marker, srOverrides, left, top) => {
108
- const sr = srOverrides ? makeSymbolRegistry(srOverrides) : undefined
109
- expect(setup({ markers: [marker], symbolRegistry: sr }).result.container.querySelector('svg')).toHaveStyle({ marginLeft: left, marginTop: top })
140
+ const sr = srOverrides ? makeSymbolRegistry(srOverrides) : null
141
+ expect(setup({ markers: [marker], symbolRegistry: sr }).result.container.querySelector(SVG_SEL)).toHaveStyle({ marginLeft: left, marginTop: top })
110
142
  })
111
143
 
112
144
  it.each([
@@ -115,24 +147,37 @@ describe('Markers', () => {
115
147
  ['large', '76', '76'],
116
148
  ['huge', '38', '38']
117
149
  ])('scales svg dimensions for mapSize=%s', (mapSize, width, height) => {
118
- const svg = setup({ markers: [makeMarker()], mapSize }).result.container.querySelector('svg')
150
+ const svg = setup({ markers: [makeMarker()], mapSize }).result.container.querySelector(SVG_SEL)
119
151
  expect(svg.getAttribute('width')).toBe(width)
120
152
  expect(svg.getAttribute('height')).toBe(height)
121
153
  })
122
154
 
123
155
  it('scales anchor offsets for medium mapSize', () => {
124
- expect(setup({ markers: [makeMarker()], mapSize: 'medium' }).result.container.querySelector('svg'))
156
+ expect(setup({ markers: [makeMarker()], mapSize: 'medium' }).result.container.querySelector(SVG_SEL))
125
157
  .toHaveStyle({ marginLeft: '-28.5px', marginTop: '-57px' })
126
158
  })
159
+ })
160
+
161
+ // ─── Markers — selection ──────────────────────────────────────────────────────
127
162
 
163
+ describe('Markers — selection', () => {
128
164
  it('adds selected class and calls resolveSelected when marker is selected', () => {
129
165
  const { eb, sr, result } = setup({ markers: [makeMarker()] })
130
- act(() => eb.emit('interact:selectionchange', { selectedMarkers: ['marker-1'] }))
131
- expect(result.container.querySelector('svg')).toHaveClass('im-c-marker--selected')
166
+ act(() => eb.emit(SEL_CHANGE, { selectedMarkers: [MARKER_ID] }))
167
+ expect(result.container.querySelector(SVG_SEL)).toHaveClass(SELECTED_CLASS)
132
168
  expect(sr.resolveSelected).toHaveBeenCalled()
133
169
  expect(sr.resolve).not.toHaveBeenCalledAfter?.(sr.resolveSelected)
134
170
  })
135
171
 
172
+ it('calls resolveActive when the marker is the active listbox item', () => {
173
+ const SET_ACTIVE = 'map:setactivefeature'
174
+ const sr = makeSymbolRegistry({ resolveActive: jest.fn(() => '<circle class="active"/>') })
175
+ const { eb } = setup({ markers: [makeMarker()], symbolRegistry: sr })
176
+ act(() => eb.emit(SET_ACTIVE, { id: MARKER_ID }))
177
+ expect(sr.resolveActive).toHaveBeenCalled()
178
+ expect(sr.resolveSelected).not.toHaveBeenCalled()
179
+ })
180
+
136
181
  it('uses resolve (not resolveSelected) for unselected markers', () => {
137
182
  const { sr } = setup({ markers: [makeMarker()] })
138
183
  expect(sr.resolve).toHaveBeenCalled()
@@ -144,103 +189,35 @@ describe('Markers', () => {
144
189
  ['missing selectedMarkers key', {}]
145
190
  ])('deselects when selectionchange has %s', (_, payload) => {
146
191
  const { eb, result } = setup({ markers: [makeMarker()] })
147
- act(() => eb.emit('interact:selectionchange', { selectedMarkers: ['marker-1'] }))
148
- act(() => eb.emit('interact:selectionchange', payload))
149
- expect(result.container.querySelector('svg')).not.toHaveClass('im-c-marker--selected')
192
+ act(() => eb.emit(SEL_CHANGE, { selectedMarkers: [MARKER_ID] }))
193
+ act(() => eb.emit(SEL_CHANGE, payload))
194
+ expect(result.container.querySelector(SVG_SEL)).not.toHaveClass(SELECTED_CLASS)
195
+ })
196
+
197
+ it('handles interact:active with selectMarker in interactionModes', () => {
198
+ const { eb } = setup()
199
+ expect(() => act(() => eb.emit(INTERACT_ACTIVE, { active: true, interactionModes: ['selectMarker'] }))).not.toThrow()
200
+ })
201
+
202
+ it('handles interact:active with no interactionModes (uses default [])', () => {
203
+ const { eb } = setup()
204
+ expect(() => act(() => eb.emit(INTERACT_ACTIVE, { active: true }))).not.toThrow()
150
205
  })
151
206
 
152
207
  it('wires interact:active and interact:selectionchange on mount and removes them on unmount', () => {
153
208
  const { eb, result } = setup()
154
- expect(eb.on).toHaveBeenCalledWith('interact:active', expect.any(Function))
155
- expect(eb.on).toHaveBeenCalledWith('interact:selectionchange', expect.any(Function))
209
+ expect(eb.on).toHaveBeenCalledWith(INTERACT_ACTIVE, expect.any(Function))
210
+ expect(eb.on).toHaveBeenCalledWith(SEL_CHANGE, expect.any(Function))
156
211
  result.unmount()
157
- expect(eb.off).toHaveBeenCalledWith('interact:active', expect.any(Function))
158
- expect(eb.off).toHaveBeenCalledWith('interact:selectionchange', expect.any(Function))
212
+ expect(eb.off).toHaveBeenCalledWith(INTERACT_ACTIVE, expect.any(Function))
213
+ expect(eb.off).toHaveBeenCalledWith(SEL_CHANGE, expect.any(Function))
159
214
  })
160
215
 
161
- describe('useMarkerCursor', () => {
162
- let viewport
163
-
164
- beforeEach(() => {
165
- viewport = document.createElement('div')
166
- viewport.className = 'im-c-viewport'
167
- document.body.appendChild(viewport)
168
- })
169
-
170
- afterEach(() => {
171
- if (viewport.parentNode) document.body.removeChild(viewport)
172
- })
173
-
174
- const activate = (eb) => act(() => eb.emit('interact:active', { active: true, interactionModes: ['selectMarker'] }))
175
- const deactivate = (eb) => act(() => eb.emit('interact:active', { active: false, interactionModes: ['selectMarker'] }))
176
- const fireMove = (clientX, clientY) => act(() => {
177
- viewport.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX, clientY }))
178
- })
179
-
180
- const setupCursor = (markerBounds) => {
181
- const eb = makeEventBus()
182
- const markerRefs = new Map()
183
- if (markerBounds) markerRefs.set('marker-1', { getBoundingClientRect: () => markerBounds })
184
- useConfig.mockReturnValue({ id: 'test-app' })
185
- useMap.mockReturnValue({ mapStyle: 'outdoor', mapSize: 'small' })
186
- useService.mockReturnValue({ symbolRegistry: makeSymbolRegistry(), eventBus: eb })
187
- useMarkers.mockReturnValue({ markers: { items: [makeMarker()], markerRefs }, markerRef: () => () => {} })
188
- render(<Markers />)
189
- return eb
190
- }
191
-
192
- it('does not track mousemove when interact is not active', () => {
193
- setupCursor({ left: 0, top: 0, right: 50, bottom: 50 })
194
- fireMove(20, 20)
195
- expect(viewport.style.cursor).toBe('')
196
- })
197
-
198
- it('does not track mousemove when selectMarker is not in interactionModes', () => {
199
- const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
200
- act(() => eb.emit('interact:active', { active: true, interactionModes: ['selectFeature'] }))
201
- fireMove(20, 20)
202
- expect(viewport.style.cursor).toBe('')
203
- })
204
-
205
- it('does not track mousemove when interactionModes is absent from payload', () => {
206
- const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
207
- act(() => eb.emit('interact:active', { active: true }))
208
- fireMove(20, 20)
209
- expect(viewport.style.cursor).toBe('')
210
- })
211
-
212
- it('does not track mousemove when viewport element is absent', () => {
213
- document.body.removeChild(viewport)
214
- const eb = setupCursor({ left: 0, top: 0, right: 50, bottom: 50 })
215
- activate(eb)
216
- expect(viewport.style.cursor).toBe('')
217
- })
218
-
219
- it('sets cursor to pointer when mousemove lands inside a marker', () => {
220
- const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
221
- activate(eb)
222
- fireMove(20, 20)
223
- expect(viewport.style.cursor).toBe('pointer')
224
- })
225
-
226
- it.each([
227
- ['outside all markers', 100, 100],
228
- ['marker has no ref element', 20, 20]
229
- ])('cursor stays empty when %s', (label, x, y) => {
230
- const bounds = label.includes('no ref') ? null : { left: 10, top: 10, right: 50, bottom: 50 }
231
- const eb = setupCursor(bounds)
232
- activate(eb)
233
- fireMove(x, y)
234
- expect(viewport.style.cursor).toBe('')
235
- })
236
-
237
- it('clears cursor and stops tracking when interact becomes inactive', () => {
238
- const eb = setupCursor({ left: 10, top: 10, right: 50, bottom: 50 })
239
- activate(eb)
240
- fireMove(20, 20)
241
- expect(viewport.style.cursor).toBe('pointer')
242
- deactivate(eb)
243
- expect(viewport.style.cursor).toBe('')
244
- })
216
+ it('adds selected class to SymbolLabelMarker wrapper and svg when selected', () => {
217
+ const { eb, result } = setup({ markers: [makeMarker({ label: 'Test', showLabel: true })] })
218
+ act(() => eb.emit(SEL_CHANGE, { selectedMarkers: [MARKER_ID] }))
219
+ const wrapper = result.container.querySelector('.im-c-marker-wrapper')
220
+ expect(wrapper).toHaveClass('im-c-marker-wrapper--selected')
221
+ expect(wrapper.querySelector(SVG_SEL)).toHaveClass(SELECTED_CLASS)
245
222
  })
246
223
  })
@@ -0,0 +1,43 @@
1
+ import { stringToKebab } from '../../../utils/stringToKebab.js'
2
+
3
+ const SymbolLabelMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) => { // NOSONAR: project does not use PropTypes
4
+ const { resolvedSvg, viewBox, anchor, shapeId, scaledWidth, scaledHeight } = symbolProps
5
+ return (
6
+ <div
7
+ ref={markerRef(marker.id)}
8
+ id={`${mapId}-marker-${marker.id}`}
9
+ className={[
10
+ 'im-c-marker-wrapper',
11
+ `im-c-marker-wrapper--${stringToKebab(shapeId)}`,
12
+ isSelected && 'im-c-marker-wrapper--selected'
13
+ ].filter(Boolean).join(' ')}
14
+ style={{
15
+ display: marker.isVisible ? 'block' : 'none',
16
+ marginLeft: `${-anchor[0] * scaledWidth}px`,
17
+ marginTop: `${-anchor[1] * scaledHeight}px`,
18
+ width: `${scaledWidth}px`,
19
+ height: `${scaledHeight}px`
20
+ }}
21
+ >
22
+ <svg
23
+ className={[
24
+ 'im-c-marker__svg',
25
+ `im-c-marker--${stringToKebab(shapeId)}`,
26
+ isSelected && 'im-c-marker--selected'
27
+ ].filter(Boolean).join(' ')}
28
+ width={scaledWidth}
29
+ height={scaledHeight}
30
+ viewBox={viewBox}
31
+ overflow='visible'
32
+ aria-hidden='true'
33
+ >
34
+ <g dangerouslySetInnerHTML={{ __html: resolvedSvg }} />
35
+ </svg>
36
+ <div role='note' className='im-c-marker__label'>
37
+ {marker.label}
38
+ </div>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ export default SymbolLabelMarker
@@ -0,0 +1,56 @@
1
+ import { render } from '@testing-library/react'
2
+ import SymbolLabelMarker from './SymbolLabelMarker.jsx'
3
+
4
+ const makeMarker = (overrides = {}) => ({ id: 'marker-1', isVisible: true, symbol: 'pin', label: 'Test', ...overrides })
5
+ const markerRef = () => () => {}
6
+ const makeSymbolProps = (overrides = {}) => ({
7
+ resolvedSvg: '<circle/>',
8
+ viewBox: '0 0 38 38',
9
+ anchor: [0.5, 0.5],
10
+ shapeId: 'pin',
11
+ scaledWidth: 38,
12
+ scaledHeight: 38,
13
+ ...overrides
14
+ })
15
+
16
+ describe('SymbolLabelMarker', () => {
17
+ it('renders the label text', () => {
18
+ const { getByText } = render(<SymbolLabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
19
+ expect(getByText('Test')).toBeTruthy()
20
+ })
21
+
22
+ it('has wrapper id of mapId-marker-markerId', () => {
23
+ const { container } = render(<SymbolLabelMarker marker={makeMarker()} mapId='my-map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
24
+ expect(container.querySelector('.im-c-marker-wrapper').getAttribute('id')).toBe('my-map-marker-marker-1')
25
+ })
26
+
27
+ it('applies shapeId to wrapper and svg class names', () => {
28
+ const { container } = render(<SymbolLabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps({ shapeId: 'flag' })} />)
29
+ expect(container.querySelector('.im-c-marker-wrapper')).toHaveClass('im-c-marker-wrapper--flag')
30
+ expect(container.querySelector('svg')).toHaveClass('im-c-marker--flag')
31
+ })
32
+
33
+ it('adds selected classes to wrapper and svg when isSelected', () => {
34
+ const { container } = render(<SymbolLabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected symbolProps={makeSymbolProps()} />)
35
+ expect(container.querySelector('.im-c-marker-wrapper')).toHaveClass('im-c-marker-wrapper--selected')
36
+ expect(container.querySelector('svg')).toHaveClass('im-c-marker--selected')
37
+ })
38
+
39
+ it('svg is aria-hidden', () => {
40
+ const { container } = render(<SymbolLabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
41
+ expect(container.querySelector('svg').getAttribute('aria-hidden')).toBe('true')
42
+ })
43
+
44
+ it('label element has role note', () => {
45
+ const { container } = render(<SymbolLabelMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
46
+ expect(container.querySelector('.im-c-marker__label').getAttribute('role')).toBe('note')
47
+ })
48
+
49
+ it.each([
50
+ [true, 'block'],
51
+ [false, 'none']
52
+ ])('display is %s when isVisible=%s', (isVisible, display) => {
53
+ const { container } = render(<SymbolLabelMarker marker={makeMarker({ isVisible })} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
54
+ expect(container.querySelector('.im-c-marker-wrapper')).toHaveStyle({ display })
55
+ })
56
+ })
@@ -0,0 +1,31 @@
1
+ import { stringToKebab } from '../../../utils/stringToKebab.js'
2
+
3
+ const SymbolMarker = ({ marker, mapId, markerRef, isSelected, symbolProps }) => { // NOSONAR: project does not use PropTypes
4
+ const { resolvedSvg, viewBox, anchor, shapeId, scaledWidth, scaledHeight } = symbolProps
5
+ return (
6
+ <svg // NOSONAR: role='img' with aria-label is the correct ARIA pattern for SVG icons; <img> is not appropriate here
7
+ ref={markerRef(marker.id)}
8
+ id={`${mapId}-marker-${marker.id}`}
9
+ className={[
10
+ 'im-c-marker',
11
+ `im-c-marker--${stringToKebab(shapeId)}`,
12
+ isSelected && 'im-c-marker--selected'
13
+ ].filter(Boolean).join(' ')}
14
+ width={scaledWidth}
15
+ height={scaledHeight}
16
+ viewBox={viewBox}
17
+ overflow='visible'
18
+ role='img'
19
+ aria-label={marker.label || 'Map marker'}
20
+ style={{
21
+ display: marker.isVisible ? 'block' : 'none',
22
+ marginLeft: `${-anchor[0] * scaledWidth}px`,
23
+ marginTop: `${-anchor[1] * scaledHeight}px`
24
+ }}
25
+ >
26
+ <g dangerouslySetInnerHTML={{ __html: resolvedSvg }} />
27
+ </svg>
28
+ )
29
+ }
30
+
31
+ export default SymbolMarker
@@ -0,0 +1,72 @@
1
+ import { render } from '@testing-library/react'
2
+ import SymbolMarker from './SymbolMarker.jsx'
3
+
4
+ const makeMarker = (overrides = {}) => ({ id: 'marker-1', isVisible: true, symbol: 'pin', ...overrides })
5
+ const markerRef = () => () => {}
6
+ const makeSymbolProps = (overrides = {}) => ({
7
+ resolvedSvg: '<circle/>',
8
+ viewBox: '0 0 38 38',
9
+ anchor: [0.5, 0.5],
10
+ shapeId: 'pin',
11
+ scaledWidth: 38,
12
+ scaledHeight: 38,
13
+ ...overrides
14
+ })
15
+
16
+ describe('SymbolMarker', () => {
17
+ it('has id of mapId-marker-markerId', () => {
18
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='my-map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
19
+ expect(container.querySelector('svg').getAttribute('id')).toBe('my-map-marker-marker-1')
20
+ })
21
+
22
+ it('has role img', () => {
23
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
24
+ expect(container.querySelector('svg').getAttribute('role')).toBe('img')
25
+ })
26
+
27
+ it('aria-label uses marker.label', () => {
28
+ const { container } = render(<SymbolMarker marker={makeMarker({ label: 'My location' })} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
29
+ expect(container.querySelector('svg').getAttribute('aria-label')).toBe('My location')
30
+ })
31
+
32
+ it('aria-label falls back to Map marker when no label', () => {
33
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
34
+ expect(container.querySelector('svg').getAttribute('aria-label')).toBe('Map marker')
35
+ })
36
+
37
+ it('applies shapeId class', () => {
38
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps({ shapeId: 'flag' })} />)
39
+ expect(container.querySelector('svg')).toHaveClass('im-c-marker--flag')
40
+ })
41
+
42
+ it('adds selected class when isSelected', () => {
43
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected symbolProps={makeSymbolProps()} />)
44
+ expect(container.querySelector('svg')).toHaveClass('im-c-marker--selected')
45
+ })
46
+
47
+ it('does not add selected class when not selected', () => {
48
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
49
+ expect(container.querySelector('svg')).not.toHaveClass('im-c-marker--selected')
50
+ })
51
+
52
+ it.each([
53
+ [true, 'block'],
54
+ [false, 'none']
55
+ ])('display is %s when isVisible=%s', (isVisible, display) => {
56
+ const { container } = render(<SymbolMarker marker={makeMarker({ isVisible })} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps()} />)
57
+ expect(container.querySelector('svg')).toHaveStyle({ display })
58
+ })
59
+
60
+ it('sets width, height and viewBox from symbolProps', () => {
61
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps({ scaledWidth: 50, scaledHeight: 60, viewBox: '0 0 50 60' })} />)
62
+ const svg = container.querySelector('svg')
63
+ expect(svg.getAttribute('width')).toBe('50')
64
+ expect(svg.getAttribute('height')).toBe('60')
65
+ expect(svg.getAttribute('viewBox')).toBe('0 0 50 60')
66
+ })
67
+
68
+ it('sets marginLeft and marginTop from anchor and dimensions', () => {
69
+ const { container } = render(<SymbolMarker marker={makeMarker()} mapId='map' markerRef={markerRef} isSelected={false} symbolProps={makeSymbolProps({ anchor: [0.5, 1], scaledWidth: 38, scaledHeight: 38 })} />)
70
+ expect(container.querySelector('svg')).toHaveStyle({ marginLeft: '-19px', marginTop: '-38px' })
71
+ })
72
+ })
@@ -11,7 +11,7 @@ const computePanelState = (bpConfig, triggeringElement, focus, focusOnOpen) => {
11
11
  const isDialog = !isAside && bpConfig.dismissible
12
12
  const isModal = bpConfig.modal === true
13
13
  const isDismissible = bpConfig.dismissible !== false
14
- const shouldFocus = isModal || (focusOnOpen !== false && (focusOnOpen === true || focus === true || Boolean(triggeringElement)))
14
+ const shouldFocus = isModal || focusOnOpen === true || (focusOnOpen !== false && focus !== false && (focus === true || Boolean(triggeringElement)))
15
15
  const buttonContainerEl = bpConfig.slot.endsWith('button') ? triggeringElement?.parentNode : undefined
16
16
  return { isAside, isDialog, isModal, isDismissible, shouldFocus, buttonContainerEl }
17
17
  }
@@ -10,6 +10,7 @@
10
10
  $is-inset: false,
11
11
  $type: panel
12
12
  );
13
+ position: relative;
13
14
 
14
15
  display: flex;
15
16
  flex-direction: column;