@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,6 @@
1
- import { anchorToMaplibre, getSymbolImageId, registerSymbols } from './symbolImages.js'
1
+ import { anchorToMaplibre, addSymbolsToMap } from './symbolImages.js'
2
2
  import { symbolRegistry } from '../../../../src/services/symbolRegistry.js'
3
3
 
4
- const STYLE_ID = 'test'
5
- const mapStyle = { id: STYLE_ID }
6
-
7
4
  beforeAll(() => {
8
5
  globalThis.URL.createObjectURL = jest.fn(() => 'blob:mock')
9
6
  globalThis.URL.revokeObjectURL = jest.fn()
@@ -87,142 +84,100 @@ describe('anchorToMaplibre', () => {
87
84
  })
88
85
  })
89
86
 
90
- // ─── getSymbolImageId ─────────────────────────────────────────────────────────
91
-
92
- describe('getSymbolImageId', () => {
93
- it('returns null when dataset has no symbol', () => {
94
- expect(getSymbolImageId({}, mapStyle, symbolRegistry)).toBeNull()
95
- })
96
-
97
- it('returns null for an unregistered symbol id', () => {
98
- expect(getSymbolImageId({ symbol: 'does-not-exist' }, mapStyle, symbolRegistry)).toBeNull()
99
- })
100
-
101
- it('returns a string prefixed symbol- for normal state', () => {
102
- const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
103
- expect(typeof id).toBe('string')
104
- expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
105
- })
106
-
107
- it('returns a string prefixed symbol-sel- for selected state', () => {
108
- const id = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
109
- expect(typeof id).toBe('string')
110
- expect(id).toMatch(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/)
111
- })
112
-
113
- it('normal and selected ids differ for the same dataset', () => {
114
- const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
115
- const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
116
- expect(normalId).not.toBe(selectedId)
117
- })
118
-
119
- it('same dataset and style always produces the same id', () => {
120
- const id1 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
121
- const id2 = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
122
- expect(id1).toBe(id2)
123
- })
124
-
125
- it('different symbols produce different ids', () => {
126
- const pinId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry)
127
- const circleId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry)
128
- expect(pinId).not.toBe(circleId)
129
- })
130
-
131
- it('different backgrounds produce different ids', () => {
132
- const redId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#ff0000' }, mapStyle, symbolRegistry)
133
- const blueId = getSymbolImageId({ symbol: 'pin', symbolBackgroundColor: '#0000ff' }, mapStyle, symbolRegistry)
134
- expect(redId).not.toBe(blueId)
135
- })
136
-
137
- it('resolves inline symbolSvgContent', () => {
138
- const dataset = {
139
- symbolSvgContent: '<circle cx="19" cy="19" r="12" fill="{{backgroundColor}}"/>',
140
- symbolViewBox: '0 0 38 38',
141
- symbolAnchor: [0.5, 0.5]
142
- }
143
- const id = getSymbolImageId(dataset, mapStyle, symbolRegistry)
144
- expect(id).toMatch(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/)
145
- })
146
- })
147
-
148
- // ─── registerSymbols ──────────────────────────────────────────────────────────
87
+ // ─── addSymbolsToMap ──────────────────────────────────────────────────────────
149
88
 
150
89
  const makeMap = (existingIds = []) => ({
151
- _symbolImageMap: {},
90
+ _activeSymbolImageMap: {},
91
+ _selectedSymbolImageMap: {},
152
92
  hasImage: jest.fn((id) => existingIds.includes(id)),
153
93
  addImage: jest.fn()
154
94
  })
95
+ const STYLE_ID = 'test'
96
+ const mapStyle = { id: STYLE_ID }
155
97
 
156
- describe('registerSymbols — registration', () => {
98
+ describe('addSymbolsToMap — registration', () => {
157
99
  it('returns early and does not touch map for empty configs', async () => {
158
100
  const map = makeMap()
159
- await registerSymbols(map, [], mapStyle, symbolRegistry)
101
+ await addSymbolsToMap(map, [], mapStyle, symbolRegistry)
160
102
  expect(map.hasImage).not.toHaveBeenCalled()
161
103
  expect(map.addImage).not.toHaveBeenCalled()
162
104
  })
163
105
 
164
- it('resets _symbolImageMap before processing', async () => {
106
+ it('resets _activeSymbolImageMap and _selectedSymbolImageMap before processing', async () => {
165
107
  const map = makeMap()
166
- map._symbolImageMap = { stale: 'entry' }
167
- await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
168
- expect(map._symbolImageMap).not.toHaveProperty('stale')
108
+ map._activeSymbolImageMap = { stale: 'entry' }
109
+ map._selectedSymbolImageMap = { stale: 'entry' }
110
+ await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
111
+ expect(map._activeSymbolImageMap).not.toHaveProperty('stale')
112
+ expect(map._selectedSymbolImageMap).not.toHaveProperty('stale')
169
113
  })
170
114
 
171
- it('calls addImage for normal and selected variants', async () => {
115
+ it('calls addImage for normal, active and selected variants', async () => {
172
116
  const map = makeMap()
173
- await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
174
- expect(map.addImage).toHaveBeenCalledTimes(2)
117
+ await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
118
+ expect(map.addImage).toHaveBeenCalledTimes(3) // NOSONAR S109 — normal, active, selected
175
119
  expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
120
+ expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-act-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
176
121
  expect(map.addImage).toHaveBeenCalledWith(expect.stringMatching(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/), expect.any(Object), { pixelRatio: 2 })
177
122
  })
178
123
 
179
- it('populates _symbolImageMap with normal → selected id pairs', async () => {
124
+ it('populates _activeSymbolImageMap and _selectedSymbolImageMap with normal → variant id pairs', async () => {
180
125
  const map = makeMap()
181
- await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
182
- const normalId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, false)
183
- const selectedId = getSymbolImageId({ symbol: 'pin' }, mapStyle, symbolRegistry, true)
184
- expect(map._symbolImageMap[normalId]).toBe(selectedId)
185
- })
186
-
187
- it('skips addImage when image is already registered', async () => {
188
- const normalId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, false)
189
- const selectedId = getSymbolImageId({ symbol: 'circle' }, mapStyle, symbolRegistry, true)
190
- const map = makeMap([normalId, selectedId])
191
- await registerSymbols(map, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
126
+ await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
127
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, false)
128
+ const activeId = symbolRegistry.getSymbolImageId({ symbol: 'pin' }, mapStyle, true)
129
+ const selectedId = map._selectedSymbolImageMap[normalId]
130
+ expect(map._activeSymbolImageMap[normalId]).toBe(activeId)
131
+ expect(selectedId).toMatch(/^symbol-sel-[a-z0-9]+-\d+(\.\d+)?x$/)
132
+ })
133
+
134
+ it('skips addImage when all three variant images are already registered', async () => {
135
+ // Run once to discover the selected image ID (not derivable without rasterising)
136
+ const setupMap = makeMap()
137
+ await addSymbolsToMap(setupMap, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
138
+ const normalId = symbolRegistry.getSymbolImageId({ symbol: 'circle' }, mapStyle, false)
139
+ const activeId = symbolRegistry.getSymbolImageId({ symbol: 'circle' }, mapStyle, true)
140
+ const selectedId = setupMap._selectedSymbolImageMap[normalId]
141
+
142
+ const map = makeMap([normalId, activeId, selectedId])
143
+ await addSymbolsToMap(map, [{ symbol: 'circle' }], mapStyle, symbolRegistry)
192
144
  expect(map.addImage).not.toHaveBeenCalled()
193
145
  })
194
146
 
195
147
  it('processes multiple configs independently', async () => {
196
148
  const map = makeMap()
197
- await registerSymbols(map, [{ symbol: 'pin' }, { symbol: 'circle' }], mapStyle, symbolRegistry)
198
- expect(map.addImage).toHaveBeenCalledTimes(4)
199
- expect(Object.keys(map._symbolImageMap)).toHaveLength(2)
149
+ await addSymbolsToMap(map, [{ symbol: 'pin' }, { symbol: 'circle' }], mapStyle, symbolRegistry)
150
+ expect(map.addImage).toHaveBeenCalledTimes(6) // NOSONAR S109 — 2 configs × 3 variants each
151
+ expect(Object.keys(map._activeSymbolImageMap)).toHaveLength(2)
152
+ expect(Object.keys(map._selectedSymbolImageMap)).toHaveLength(2)
200
153
  })
201
154
  })
202
155
 
203
- describe('registerSymbols — null results and caching', () => {
156
+ describe('addSymbolsToMap — null results and caching', () => {
204
157
  it('does not call addImage when rasteriseSymbolImage returns null', async () => {
205
- // getSymbolImageId (called twice — normal + selected) needs a real symbolDef to produce imageIds,
158
+ // getSymbolImageId (called twice — normal + active) needs a real symbolDef to produce imageIds,
206
159
  // but rasteriseSymbolImage must get undefined from getSymbolDef so it returns null.
207
- // The registry.get call order: [1] getSymbolImageId normal, [2] getSymbolImageId selected,
208
- // [3] rasteriseSymbolImage normal, [4] rasteriseSymbolImage selected.
160
+ // The registry.get call order: [1] getSymbolImageId normal, [2] getSymbolImageId active,
161
+ // [3] rasteriseSymbolImage normal, [4] rasteriseSymbolImage active, [5] rasteriseSymbolImage selected.
209
162
  const pinDef = symbolRegistry.get('pin')
210
163
  const getSpy = jest.spyOn(symbolRegistry, 'get')
211
164
  .mockReturnValueOnce(pinDef)
212
165
  .mockReturnValueOnce(pinDef)
213
166
  .mockReturnValueOnce(undefined)
214
167
  .mockReturnValueOnce(undefined)
168
+ .mockReturnValueOnce(undefined)
215
169
  const map = makeMap()
216
- await registerSymbols(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
170
+ await addSymbolsToMap(map, [{ symbol: 'pin' }], mapStyle, symbolRegistry)
217
171
  expect(map.addImage).not.toHaveBeenCalled()
218
172
  getSpy.mockRestore()
219
173
  })
220
174
 
221
175
  it('skips config when symbolDef cannot be resolved', async () => {
222
176
  const map = makeMap()
223
- await registerSymbols(map, [{ symbol: 'no-such-symbol' }], mapStyle, symbolRegistry)
177
+ await addSymbolsToMap(map, [{ symbol: 'no-such-symbol' }], mapStyle, symbolRegistry)
224
178
  expect(map.addImage).not.toHaveBeenCalled()
225
- expect(map._symbolImageMap).toEqual({})
179
+ expect(map._activeSymbolImageMap).toEqual({})
180
+ expect(map._selectedSymbolImageMap).toEqual({})
226
181
  })
227
182
 
228
183
  it('reuses cached imageData when called again with the same pixelRatio', async () => {
@@ -230,19 +185,19 @@ describe('registerSymbols — null results and caching', () => {
230
185
  const uniqueRatio = 7
231
186
 
232
187
  const map1 = makeMap()
233
- const blobCallsBefore = globalThis.URL.createObjectURL.mock.calls.length
234
- await registerSymbols(map1, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
235
- const blobCallsAfterFirst = globalThis.URL.createObjectURL.mock.calls.length
236
- // Rasterisation ran — blob was created
237
- expect(blobCallsAfterFirst).toBeGreaterThan(blobCallsBefore)
188
+ const getContextCallsBefore = HTMLCanvasElement.prototype.getContext.mock.calls.length
189
+ await addSymbolsToMap(map1, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
190
+ const getContextCallsAfterFirst = HTMLCanvasElement.prototype.getContext.mock.calls.length
191
+ // Rasterisation ran — canvas was used
192
+ expect(getContextCallsAfterFirst).toBeGreaterThan(getContextCallsBefore)
238
193
 
239
194
  // Second call with a fresh map (hasImage → false) but same ratio → cache hit
240
195
  const map2 = makeMap()
241
- await registerSymbols(map2, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
242
- const blobCallsAfterSecond = globalThis.URL.createObjectURL.mock.calls.length
243
- // No new blob created — rasterisation was skipped via cache
244
- expect(blobCallsAfterSecond).toBe(blobCallsAfterFirst)
196
+ await addSymbolsToMap(map2, [{ symbol: 'pin' }], mapStyle, symbolRegistry, uniqueRatio)
197
+ const getContextCallsAfterSecond = HTMLCanvasElement.prototype.getContext.mock.calls.length
198
+ // No new canvas — rasterisation was skipped via cache
199
+ expect(getContextCallsAfterSecond).toBe(getContextCallsAfterFirst)
245
200
  // addImage still called because map2 has no pre-registered images
246
- expect(map2.addImage).toHaveBeenCalledTimes(2)
201
+ expect(map2.addImage).toHaveBeenCalledTimes(3) // NOSONAR S109 — normal, active, selected
247
202
  })
248
203
  })
package/rollup.esm.mjs CHANGED
@@ -82,7 +82,7 @@ const PREACT_EXTERNALS = [
82
82
  // dependency so it auto-installs for consumers and is resolved transparently.
83
83
  const BABEL_RUNTIME_EXTERNAL = /@babel\/runtime/
84
84
 
85
- const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null) => {
85
+ const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null, extraExternals = []) => {
86
86
  const esmDir = path.resolve(__dirname, outDir)
87
87
  // Use the parent dir as output.dir so CSS can be emitted to css/index.css
88
88
  // (a sibling subdir) without Rollup 4's ban on ".." in emitted file names.
@@ -108,7 +108,8 @@ const createESMConfig = (entryPath, outDir, isCore = false, manualChunks = null)
108
108
  // instance from their own node_modules rather than a 1 MB copy bundled
109
109
  // into the provider. (UMD keeps it bundled — no bundler available there.)
110
110
  'maplibre-gl',
111
- /^@arcgis\/core/
111
+ /^@arcgis\/core/,
112
+ ...extraExternals
112
113
  ],
113
114
 
114
115
  plugins: [
@@ -228,6 +229,16 @@ const ALL_BUILDS = [
228
229
  outDir: 'providers/beta/esri/dist/esm',
229
230
  manualChunks: (id) => { if (id.includes('/esriProvider')) return 'im-esri-provider' }
230
231
  },
232
+ {
233
+ entryPath: './providers/beta/openlayers/src/index.js',
234
+ outDir: 'providers/beta/openlayers/dist/esm',
235
+ extraExternals: [/^ol\//, 'proj4'],
236
+ manualChunks: (id) => {
237
+ if (id.includes('/openlayersProvider')) {
238
+ return 'im-openlayers-provider'
239
+ }
240
+ }
241
+ },
231
242
 
232
243
  // Plugins — each lazy-loads ./manifest.js; manualChunks names that split chunk
233
244
  {
@@ -293,5 +304,5 @@ const buildsToRun = BUILD_TARGET
293
304
  : ALL_BUILDS
294
305
 
295
306
  export default buildsToRun.map(b =>
296
- createESMConfig(b.entryPath, b.outDir, b.isCore || false, b.manualChunks || null)
307
+ createESMConfig(b.entryPath, b.outDir, b.isCore || false, b.manualChunks || null, b.extraExternals || [])
297
308
  )
@@ -1,4 +1,5 @@
1
1
  .im-c-actions {
2
+ position: relative;
2
3
  display: flex;
3
4
  flex-direction: row;
4
5
  justify-content: center;
@@ -6,6 +6,9 @@ import * as AppContext from '../../store/appContext' // import the module to moc
6
6
  // Simple child component to handle isHidden
7
7
  const TestChild = ({ isHidden, children, ...props }) => <div {...props}>{children}</div>
8
8
 
9
+ const ACTIONS_SELECTOR = '.im-c-actions'
10
+ const HIDDEN_CLASS = 'im-c-actions--hidden'
11
+
9
12
  describe('Actions component', () => {
10
13
  const mockUseApp = {
11
14
  openPanels: {},
@@ -53,8 +56,8 @@ describe('Actions component', () => {
53
56
  <TestChild isHidden data-testid='child2'>Child 2</TestChild>
54
57
  </Actions>
55
58
  )
56
- const container = screen.getByTestId('child1').closest('.im-c-actions')
57
- expect(container).toHaveClass('im-c-actions--hidden')
59
+ const container = screen.getByTestId('child1').closest(ACTIONS_SELECTOR)
60
+ expect(container).toHaveClass(HIDDEN_CLASS)
58
61
  })
59
62
 
60
63
  it('shows the container when at least one child is visible', () => {
@@ -64,7 +67,17 @@ describe('Actions component', () => {
64
67
  <TestChild isHidden data-testid='child2'>Child 2</TestChild>
65
68
  </Actions>
66
69
  )
67
- const container = screen.getByTestId('child1').closest('.im-c-actions')
68
- expect(container).not.toHaveClass('im-c-actions--hidden')
70
+ const container = screen.getByTestId('child1').closest(ACTIONS_SELECTOR)
71
+ expect(container).not.toHaveClass(HIDDEN_CLASS)
72
+ })
73
+
74
+ it('hides the container when all visible children have variant touch', () => {
75
+ render(
76
+ <Actions slot='actions'>
77
+ <TestChild isHidden={false} variant='touch' data-testid='child1'>Child 1</TestChild>
78
+ </Actions>
79
+ )
80
+ const container = screen.getByTestId('child1').closest(ACTIONS_SELECTOR)
81
+ expect(container).toHaveClass(HIDDEN_CLASS)
69
82
  })
70
83
  })
@@ -0,0 +1,52 @@
1
+ import React, { useState, useEffect } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { useConfig } from '../../store/configContext.js'
4
+ import { useService } from '../../store/serviceContext.js'
5
+ import { useApp } from '../../store/appContext.js'
6
+
7
+ /**
8
+ * Renders the active keyboard hint as a toast portaled into im-o-app__main.
9
+ * Positioned above the actions bar using --hint-bottom. All visual
10
+ * hints pass through here; screen reader announcements are handled internally
11
+ * by the hints service so callers only need hints.show().
12
+ *
13
+ * The container element (id="${mapId}-hints") is always in the DOM after mount
14
+ * so aria-describedby references remain valid even when no hint is showing.
15
+ */
16
+ // eslint-disable-next-line camelcase, react/jsx-pascal-case
17
+ // sonarjs/disable-next-line function-name
18
+ export const Hints = () => {
19
+ const { id, keyboardHintText } = useConfig()
20
+ const { hints } = useService()
21
+ const { layoutRefs } = useApp()
22
+ const [activeHint, setActiveHint] = useState(null)
23
+ const [mounted, setMounted] = useState(false)
24
+
25
+ useEffect(() => {
26
+ setMounted(true)
27
+ return hints.subscribe(setActiveHint)
28
+ }, [hints])
29
+
30
+ if (!mounted || !layoutRefs.mainRef?.current) {
31
+ return null
32
+ }
33
+
34
+ return createPortal(
35
+ <div className='im-o-hints'>
36
+ <div id={`${id}-hints`} className='im-c-hints'>
37
+ {activeHint && (
38
+ <div
39
+ className='im-c-hints__hint'
40
+ dangerouslySetInnerHTML={{ __html: activeHint.html }}
41
+ />
42
+ )}
43
+ </div>
44
+ <div
45
+ id={`${id}-keyboard-desc`}
46
+ className='im-u-visually-hidden'
47
+ dangerouslySetInnerHTML={{ __html: keyboardHintText }}
48
+ />
49
+ </div>,
50
+ layoutRefs.mainRef.current
51
+ )
52
+ }
@@ -0,0 +1,37 @@
1
+ // ===================================================
2
+ // Object: KeyboardHints (layout wrapper)
3
+ // ===================================================
4
+
5
+ .im-o-hints {
6
+ position: absolute;
7
+ bottom: var(--hint-bottom, var(--primary-gap));
8
+ left: 50%;
9
+ transform: translateX(-50%);
10
+ z-index: 1001;
11
+ }
12
+
13
+ // ===================================================
14
+ // Component: KeyboardHints
15
+ // ===================================================
16
+
17
+ .im-c-hints {
18
+ display: flex;
19
+ flex-direction: column;
20
+ align-items: center;
21
+ gap: var(--divider-gap);
22
+ }
23
+
24
+ .im-c-hints__hint {
25
+ text-wrap: nowrap;
26
+
27
+ color: var(--tooltip-foreground-color);
28
+ background-color: var(--tooltip-background-color);
29
+ border-radius: var(--tooltip-border-radius);
30
+ padding: var(--tooltip-padding);
31
+ font-size: var(--tooltip-font-size);
32
+ line-height: 1.2;
33
+ }
34
+
35
+ .im-c-hints__hint kbd {
36
+ border: var(--kbd-tooltip-border);
37
+ }
@@ -0,0 +1,104 @@
1
+ import React from 'react'
2
+ import { render, act } from '@testing-library/react'
3
+ import { Hints } from './Hints.jsx'
4
+ import { useConfig } from '../../store/configContext.js'
5
+ import { useService } from '../../store/serviceContext.js'
6
+ import { useApp } from '../../store/appContext.js'
7
+
8
+ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
9
+ jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
10
+ jest.mock('../../store/appContext.js', () => ({ useApp: jest.fn() }))
11
+
12
+ const CONTAINER_ID = '#test-map-hints'
13
+ const HINT_CLASS = '.im-c-hints__hint'
14
+
15
+ let capturedSubscriber
16
+ let mockHints
17
+
18
+ const setup = ({ mainEl = document.createElement('div') } = {}) => {
19
+ capturedSubscriber = null
20
+ mockHints = {
21
+ subscribe: jest.fn((fn) => {
22
+ capturedSubscriber = fn
23
+ return () => {}
24
+ })
25
+ }
26
+ useConfig.mockReturnValue({ id: 'test-map', keyboardHintText: '<kbd>Alt</kbd> + <kbd>K</kbd>' })
27
+ useService.mockReturnValue({ hints: mockHints })
28
+ useApp.mockReturnValue({ layoutRefs: { mainRef: { current: mainEl } } })
29
+ if (mainEl) document.body.appendChild(mainEl)
30
+ return mainEl
31
+ }
32
+
33
+ afterEach(() => {
34
+ document.body.innerHTML = ''
35
+ jest.clearAllMocks()
36
+ })
37
+
38
+ // ─── rendering ───────────────────────────────────────────────────────────────
39
+
40
+ describe('Hints — rendering', () => {
41
+ it('renders the container into mainRef after mount', () => {
42
+ const mainEl = setup()
43
+ render(<Hints />)
44
+ expect(mainEl.querySelector(CONTAINER_ID)).toBeTruthy()
45
+ })
46
+
47
+ it('renders a persistent keyboard-desc span with the hint text', () => {
48
+ const mainEl = setup()
49
+ render(<Hints />)
50
+ const desc = mainEl.querySelector('#test-map-keyboard-desc')
51
+ expect(desc).toBeTruthy()
52
+ expect(desc.innerHTML).toBe('<kbd>Alt</kbd> + <kbd>K</kbd>')
53
+ })
54
+
55
+ it('renders no hint content when there is no active hint', () => {
56
+ const mainEl = setup()
57
+ render(<Hints />)
58
+ expect(mainEl.querySelector(HINT_CLASS)).toBeNull()
59
+ })
60
+
61
+ it('renders hint content when subscriber fires with a hint', () => {
62
+ const mainEl = setup()
63
+ render(<Hints />)
64
+ act(() => capturedSubscriber({ html: '<kbd>Enter</kbd> to select' }))
65
+ const hint = mainEl.querySelector(HINT_CLASS)
66
+ expect(hint).toBeTruthy()
67
+ expect(hint.innerHTML).toBe('<kbd>Enter</kbd> to select')
68
+ })
69
+
70
+ it('removes hint content when subscriber fires with null', () => {
71
+ const mainEl = setup()
72
+ render(<Hints />)
73
+ act(() => capturedSubscriber({ html: 'hello' }))
74
+ act(() => capturedSubscriber(null))
75
+ expect(mainEl.querySelector(HINT_CLASS)).toBeNull()
76
+ })
77
+ })
78
+
79
+ // ─── lifecycle ───────────────────────────────────────────────────────────────
80
+
81
+ describe('Hints — lifecycle', () => {
82
+ it('subscribes to hints on mount', () => {
83
+ setup()
84
+ render(<Hints />)
85
+ expect(mockHints.subscribe).toHaveBeenCalled()
86
+ })
87
+
88
+ it('unsubscribes on unmount', () => {
89
+ const unsub = jest.fn()
90
+ setup()
91
+ mockHints = { subscribe: jest.fn(() => unsub) }
92
+ useService.mockReturnValue({ hints: mockHints })
93
+ const { unmount } = render(<Hints />)
94
+ unmount()
95
+ expect(unsub).toHaveBeenCalled()
96
+ })
97
+
98
+ it('renders nothing when mainRef is null', () => {
99
+ setup({ mainEl: null })
100
+ useApp.mockReturnValue({ layoutRefs: { mainRef: { current: null } } })
101
+ const { container } = render(<Hints />)
102
+ expect(container.innerHTML).toBe('')
103
+ })
104
+ })
@@ -1,33 +1,71 @@
1
1
  // src/components/KeyboardHelp.jsx
2
2
  import React from 'react'
3
3
  import { useConfig } from '../../store/configContext'
4
+ import { useApp } from '../../store/appContext.js'
4
5
  import { getKeyboardShortcuts } from '../../registry/keyboardShortcutRegistry.js'
6
+ import { Tabs } from '../Tabs/Tabs.jsx'
7
+
8
+ const ShortcutList = ({ items }) => (
9
+ <dl className='im-c-keyboard-help__list'>
10
+ {items.map((item) => (
11
+ <div key={item.id} className='im-c-keyboard-help__item'>
12
+ <dt className='im-c-keyboard-help__title'>{item.title}</dt>
13
+ <dd className='im-c-keyboard-help__description' dangerouslySetInnerHTML={{ __html: item.command }} />
14
+ </div>
15
+ ))}
16
+ </dl>
17
+ )
18
+
19
+ const buildGroupMap = (shortcuts) => shortcuts.reduce((acc, shortcut) => {
20
+ const group = shortcut.group || 'Navigate'
21
+ if (!acc[group]) {
22
+ acc[group] = []
23
+ }
24
+ acc[group].push(shortcut)
25
+ return acc
26
+ }, {})
27
+
28
+ const getDefaultTab = (groupEntries, context) => {
29
+ const exactMatch = groupEntries.find(([, items]) =>
30
+ items.some(s => (s.context ?? 'viewport') === context)
31
+ )
32
+ if (exactMatch) {
33
+ return exactMatch[0]
34
+ }
35
+ const globalMatch = groupEntries.find(([, items]) =>
36
+ items.some(s => s.context === 'global')
37
+ )
38
+ return globalMatch?.[0] ?? groupEntries[0][0]
39
+ }
5
40
 
6
41
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
7
42
  // sonarjs/disable-next-line function-name
8
- export const KeyboardHelp = () => {
43
+ export const KeyboardHelp = ({ context = 'viewport' }) => { // NOSONAR: project does not use PropTypes
9
44
  const appConfig = useConfig()
10
- const groups = getKeyboardShortcuts(appConfig).reduce((acc, shortcut) => {
11
- acc[shortcut.group] = acc[shortcut.group] || []
12
- acc[shortcut.group].push(shortcut)
13
- return acc
14
- }, {})
45
+ const { listboxIsActive } = useApp()
46
+ const allShortcuts = getKeyboardShortcuts(appConfig)
47
+ const shortcuts = listboxIsActive
48
+ ? allShortcuts
49
+ : allShortcuts.filter(s => !s.group && s.context !== 'listbox')
50
+ const groupMap = buildGroupMap(shortcuts)
51
+ const groupEntries = Object.entries(groupMap)
52
+
53
+ if (groupEntries.length <= 1) {
54
+ return (
55
+ <div className='im-c-keyboard-help'>
56
+ <ShortcutList items={shortcuts} />
57
+ </div>
58
+ )
59
+ }
60
+
61
+ const tabs = groupEntries.map(([name, items]) => ({
62
+ name,
63
+ content: <ShortcutList items={items} />
64
+ }))
15
65
 
16
66
  return (
17
67
  <div className='im-c-keyboard-help'>
18
- {Object.entries(groups).map(([groupName, items]) => (
19
- <div key={groupName} className='im-c-keyboard-help__group'>
20
- {/* <h3 className='im-c-keyboard-help__title'>{groupName}</h3> */}
21
- <dl className='im-c-keyboard-help__list'>
22
- {items.map((item) => (
23
- <div key={item.id} className='im-c-keyboard-help__item'>
24
- <dt className='im-c-keyboard-help__title'>{item.title}</dt>
25
- <dd className='im-c-keyboard-help__description' dangerouslySetInnerHTML={{ __html: item.command }} />
26
- </div>
27
- ))}
28
- </dl>
29
- </div>
30
- ))}
68
+ <Tabs tabs={tabs} defaultTab={getDefaultTab(groupEntries, context)} />
31
69
  </div>
32
70
  )
33
71
  }
@@ -2,6 +2,8 @@
2
2
  // Component: KeyboardHelp
3
3
  // ===================================================
4
4
 
5
+ @use '../../../scss/tools/index' as tools;
6
+
5
7
  // 1. Base styles
6
8
 
7
9
  // 2. Elements
@@ -15,6 +17,10 @@
15
17
  padding: 10px 0;
16
18
  width: 100%;
17
19
  border-top: 1px solid var(--content-border-color);
20
+
21
+ &:last-child {
22
+ padding-bottom: 0;
23
+ }
18
24
  }
19
25
 
20
26
  .im-c-keyboard-help__group:last-child .im-c-keyboard-help__item:last-child {
@@ -40,3 +46,7 @@
40
46
  display: block;
41
47
  margin-top: 5px;
42
48
  }
49
+
50
+ .im-c-tabs__panel .im-c-keyboard-help__item:first-child {
51
+ border-top: 0;
52
+ }