@defra/interactive-map 0.0.42-fmp-4 → 0.0.43-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 (138) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/keyboard-shortcut-definition.md +102 -0
  7. package/docs/api.md +2 -0
  8. package/docs/building-a-plugin.md +1 -0
  9. package/docs/plugins/draw.md +1 -1
  10. package/docs/plugins/plugin-manifest.md +9 -0
  11. package/package.json +1 -1
  12. package/plugins/beta/frame/dist/esm/im-frame-plugin.js +1 -1
  13. package/plugins/beta/frame/dist/umd/im-frame-plugin.js +1 -1
  14. package/plugins/beta/frame/src/Frame.jsx +28 -2
  15. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  16. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  17. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  19. package/plugins/datasets/src/registry/dataset.js +8 -12
  20. package/plugins/datasets/src/registry/dataset.test.js +17 -0
  21. package/plugins/datasets/src/registry/datasetRegistry.js +4 -5
  22. package/plugins/draw/dist/esm/im-draw-ml-adapter.js +1 -1
  23. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  24. package/plugins/draw/dist/esm/im-draw-plugin.js +1 -1
  25. package/plugins/draw/dist/umd/im-draw-ml-adapter.js +1 -1
  26. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  27. package/plugins/draw/dist/umd/im-draw-plugin.js +1 -1
  28. package/plugins/draw/src/DrawInit.jsx +23 -13
  29. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.js +15 -0
  30. package/plugins/draw/src/adapters/maplibre/modes/drawMode/lifecycle.test.js +18 -0
  31. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +9 -0
  32. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.js +12 -1
  33. package/plugins/draw/src/adapters/openlayers/point/drawPointMode.test.js +18 -0
  34. package/plugins/draw/src/manifest.js +4 -1
  35. package/plugins/draw/src/manifest.test.js +19 -7
  36. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  37. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  38. package/plugins/interact/dist/umd/index.js +1 -1
  39. package/plugins/interact/src/events.js +6 -2
  40. package/plugins/interact/src/events.test.js +32 -0
  41. package/plugins/interact/src/hooks/useCrossHairVisibility.js +8 -3
  42. package/plugins/interact/src/hooks/useMapItemList.js +41 -12
  43. package/plugins/interact/src/hooks/useMapItemList.test.js +68 -3
  44. package/plugins/interact/src/utils/spatial.js +24 -1
  45. package/plugins/interact/src/utils/spatial.test.js +26 -1
  46. package/plugins/map-key/dist/css/index.css +1 -1
  47. package/plugins/map-key/dist/esm/im-map-key-plugin.js +1 -1
  48. package/plugins/map-key/dist/umd/im-map-key-plugin.js +1 -1
  49. package/plugins/map-key/dist/umd/index.js +1 -1
  50. package/plugins/map-key/src/components/Key/Key.jsx +3 -4
  51. package/plugins/map-key/src/components/Key/Key.module.scss +10 -8
  52. package/plugins/map-key/src/components/Key/KeyGroupItem.test.jsx +2 -2
  53. package/plugins/map-key/src/components/Key/KeyItem.jsx +2 -2
  54. package/plugins/map-key/src/components/Key/KeySvg.jsx +9 -7
  55. package/plugins/map-key/src/components/Key/KeySvg.test.jsx +4 -4
  56. package/plugins/map-key/src/components/Key/MapKey.jsx +5 -3
  57. package/plugins/map-key/src/manifest.js +1 -1
  58. package/plugins/map-key/src/utils/groupStyles.js +7 -0
  59. package/plugins/map-key/src/utils/mergeKeyGroupItems.js +12 -0
  60. package/plugins/map-key/src/utils/mergeKeyGroupItems.test.js +53 -0
  61. package/plugins/menu/dist/esm/im-menu-plugin.js +1 -1
  62. package/plugins/menu/dist/umd/im-menu-plugin.js +1 -1
  63. package/plugins/menu/dist/umd/index.js +1 -1
  64. package/plugins/menu/src/initialise/MenuInit.jsx +15 -3
  65. package/plugins/menu/src/initialise/MenuInit.test.jsx +25 -0
  66. package/plugins/menu/src/initialise/createMenu.js +3 -1
  67. package/plugins/menu/src/initialise/createMenu.test.js +20 -5
  68. package/plugins/menu/src/initialise/updateUrl.js +44 -0
  69. package/plugins/menu/src/initialise/updateUrl.test.js +91 -0
  70. package/plugins/menu/src/reducers/menuStateReducer.js +19 -1
  71. package/plugins/menu/src/reducers/menuStateReducer.test.js +53 -20
  72. package/plugins/menu/src/reducers/pluginState.js +1 -6
  73. package/plugins/menu/src/reducers/pluginState.test.js +0 -25
  74. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  75. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  76. package/plugins/search/src/manifest.js +5 -9
  77. package/plugins/search/src/manifest.test.js +5 -0
  78. package/providers/beta/openlayers/dist/esm/im-openlayers-provider.js +1 -1
  79. package/providers/beta/openlayers/dist/umd/im-openlayers-provider.js +1 -1
  80. package/providers/beta/openlayers/src/openlayersProvider.js +3 -3
  81. package/providers/beta/openlayers/src/openlayersProvider.test.js +6 -3
  82. package/providers/beta/openlayers/src/utils/queryFeatures.js +72 -0
  83. package/providers/beta/openlayers/src/utils/queryFeatures.test.js +122 -1
  84. package/providers/maplibre/src/defaults.test.js +26 -0
  85. package/src/App/components/CrossHair/CrossHair.jsx +19 -11
  86. package/src/App/components/CrossHair/CrossHair.module.scss +33 -12
  87. package/src/App/components/CrossHair/CrossHair.test.jsx +35 -11
  88. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +4 -1
  89. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +17 -0
  90. package/src/App/components/MapButton/MapButton.jsx +19 -7
  91. package/src/App/components/MapButton/MapButton.test.jsx +42 -4
  92. package/src/App/components/Markers/SymbolMarker.jsx +2 -3
  93. package/src/App/components/Markers/SymbolMarker.test.jsx +8 -9
  94. package/src/App/components/Panel/Panel.jsx +19 -20
  95. package/src/App/components/Panel/Panel.module.scss +5 -0
  96. package/src/App/components/Panel/Panel.test.jsx +69 -4
  97. package/src/App/components/PopupMenu/PopupMenu.jsx +7 -3
  98. package/src/App/components/PopupMenu/PopupMenu.test.jsx +71 -0
  99. package/src/App/components/PopupMenu/usePopupMenu.js +10 -9
  100. package/src/App/components/Viewport/Features.jsx +18 -4
  101. package/src/App/components/Viewport/Features.module.scss +4 -4
  102. package/src/App/components/Viewport/Features.test.jsx +155 -11
  103. package/src/App/components/Viewport/Viewport.jsx +3 -3
  104. package/src/App/controls/keyboardMappings.js +7 -2
  105. package/src/App/controls/keyboardMappings.test.js +21 -0
  106. package/src/App/controls/keyboardShortcuts.js +16 -2
  107. package/src/App/controls/keyboardShortcuts.test.js +21 -0
  108. package/src/App/hooks/useCrossHairAPI.js +29 -1
  109. package/src/App/hooks/useCrossHairAPI.test.js +17 -1
  110. package/src/App/hooks/useFeatureFocus.js +86 -28
  111. package/src/App/hooks/useFeatureFocus.test.js +167 -0
  112. package/src/App/hooks/useFeatureItems.js +1 -1
  113. package/src/App/hooks/useInterfaceAPI.js +2 -1
  114. package/src/App/hooks/useInterfaceAPI.test.js +9 -0
  115. package/src/App/hooks/useKeyboardShortcuts.js +66 -35
  116. package/src/App/hooks/useKeyboardShortcuts.test.js +52 -0
  117. package/src/App/hooks/useModalPanelBehaviour.js +48 -34
  118. package/src/App/hooks/useModalPanelBehaviour.test.js +59 -0
  119. package/src/App/layout/Layout.jsx +4 -2
  120. package/src/App/layout/Layout.test.jsx +21 -0
  121. package/src/App/layout/layout.module.scss +7 -1
  122. package/src/App/registry/keyboardShortcutRegistry.js +2 -2
  123. package/src/App/registry/keyboardShortcutRegistry.test.js +16 -0
  124. package/src/App/renderer/HtmlElementHost.jsx +12 -32
  125. package/src/App/renderer/mapButtons.js +4 -0
  126. package/src/App/renderer/mapButtons.test.js +20 -0
  127. package/src/App/renderer/mapPanels.js +33 -53
  128. package/src/App/renderer/mapPanels.test.js +29 -4
  129. package/src/App/renderer/slotHelpers.js +33 -0
  130. package/src/App/renderer/slotHelpers.test.js +79 -2
  131. package/src/InteractiveMap/InteractiveMap.js +13 -2
  132. package/src/InteractiveMap/InteractiveMap.test.js +33 -0
  133. package/src/config/appConfig.js +2 -2
  134. package/src/types.js +40 -0
  135. package/src/utils/getPanelElementId.js +4 -0
  136. package/src/utils/getPanelElementId.test.js +11 -0
  137. package/src/utils/getPanelRole.js +22 -0
  138. package/src/utils/getPanelRole.test.js +56 -0
@@ -11,7 +11,7 @@ import { attachMapEvents } from './mapEvents.js'
11
11
  import { attachAppEvents, createMapStyleLayer } from './appEvents.js'
12
12
  import { getAreaDimensions, getCardinalMove, getExtentFromGeoJSON, getPaddedExtent, isGeometryObscured } from './utils/spatial.js'
13
13
  import { updateHighlightedFeatures } from './utils/highlightFeatures.js'
14
- import { queryFeatures } from './utils/queryFeatures.js'
14
+ import { queryFeatures, getVisibleFeatures } from './utils/queryFeatures.js'
15
15
  import { collectTileFragments } from './utils/vtTileFragments.js'
16
16
  import { setupHoverCursor } from './utils/hoverCursor.js'
17
17
  import { applyOpenLayersFixes } from './utils/openLayersFixes.js'
@@ -223,8 +223,8 @@ export default class OpenLayersProvider extends MapProvider {
223
223
  this._onHoverMove = setupHoverCursor(this.map, layerIds, this._onHoverMove)
224
224
  }
225
225
 
226
- getVisibleFeatures (_layerIds) {
227
- return []
226
+ getVisibleFeatures (layerIds) {
227
+ return getVisibleFeatures(this.map, layerIds)
228
228
  }
229
229
 
230
230
  updateHighlightedFeatures (selectedFeatures, activeFeatures, stylesMap) {
@@ -26,13 +26,15 @@ const mockMapGetSize = jest.fn(() => [800, 600])
26
26
  const mockMapSetTarget = jest.fn()
27
27
  const mockMapGetPixel = jest.fn(() => [100, 200])
28
28
  const mockMapGetCoord = jest.fn(() => [400000, 300000])
29
+ const mockMapGetLayers = jest.fn(() => ({ forEach: jest.fn() }))
29
30
  const mockMapInstance = {
30
31
  once: mockMapOnce,
31
32
  getSize: mockMapGetSize,
32
33
  setTarget: mockMapSetTarget,
33
34
  getPixelFromCoordinate: mockMapGetPixel,
34
35
  getCoordinateFromPixel: mockMapGetCoord,
35
- getView: jest.fn(() => mockViewInstance)
36
+ getView: jest.fn(() => mockViewInstance),
37
+ getLayers: mockMapGetLayers
36
38
  }
37
39
 
38
40
  const mockSource = {}
@@ -329,8 +331,9 @@ describe('OpenLayersProvider', () => {
329
331
  expect(provider.getBounds()).toEqual([1.13, 2.46, 3.99, 4])
330
332
  })
331
333
 
332
- it('getVisibleFeatures returns empty array', () => {
333
- expect(provider.getVisibleFeatures()).toEqual([])
334
+ it('getVisibleFeatures delegates to the map (no layers registered here — see queryFeatures.test.js)', () => {
335
+ expect(provider.getVisibleFeatures(['draw'])).toEqual([])
336
+ expect(mockMapGetLayers).toHaveBeenCalled()
334
337
  })
335
338
 
336
339
  it('getResolution delegates to view.getResolution', () => {
@@ -1,6 +1,7 @@
1
1
  import VectorTileLayer from 'ol/layer/VectorTile.js'
2
2
  import VectorLayer from 'ol/layer/Vector.js'
3
3
  import GeoJSON from 'ol/format/GeoJSON.js'
4
+ import TileState from 'ol/TileState.js'
4
5
  import { renderFeatureToGeoJSON } from './vtTileFragments.js'
5
6
 
6
7
  const CRS = 'EPSG:27700'
@@ -75,3 +76,74 @@ export const queryFeatures = (map, point, options = {}) => {
75
76
 
76
77
  return results
77
78
  }
79
+
80
+ /**
81
+ * Returns every currently visible feature for the given layer ids, in the same
82
+ * { id, layer: { id }, geometry, properties } shape queryFeatures() above uses — mirrors
83
+ * MapLibre's map.queryRenderedFeatures(undefined, { layers }). Used by the interact plugin's
84
+ * Features list (collectVisibleFeatures in useMapItemList.js) to find selectable/labelled
85
+ * features without requiring a click.
86
+ *
87
+ * VectorTileLayer features come from currently-loaded tiles, the same source
88
+ * collectTileFragments() reads (see vtTileFragments.js) — OL only loads tiles for the current
89
+ * viewport (plus a small buffer), so "loaded" is a reasonable proxy for "visible" without a
90
+ * separate extent check. A single logical feature can be split across tile boundaries, so
91
+ * fragments are deduplicated the same way queryFeatures() does above.
92
+ * VectorLayer features come directly from the source's current-viewport extent.
93
+ */
94
+ export const getVisibleFeatures = (map, layerIds) => {
95
+ const wanted = new Set(layerIds)
96
+ const results = []
97
+ const seenKeys = new Set()
98
+ const extent = map.getView().calculateExtent(map.getSize())
99
+
100
+ map.getLayers().forEach(mapLayer => {
101
+ if (mapLayer instanceof VectorTileLayer) {
102
+ const sourceTiles = mapLayer.getSource()?.sourceTiles_
103
+ if (!sourceTiles) {
104
+ return
105
+ }
106
+ Object.values(sourceTiles).forEach(tile => {
107
+ if (tile.getState() !== TileState.LOADED) {
108
+ return
109
+ }
110
+ tile.getFeatures().forEach(feature => {
111
+ const mapboxLayer = feature.get('mapbox-layer')
112
+ const styleLayerId = mapboxLayer?.id
113
+ // background-type layers have no features in MapLibre — skip to match behaviour
114
+ if (!styleLayerId || !wanted.has(styleLayerId) || mapboxLayer?.type === 'background') {
115
+ return
116
+ }
117
+ const key = `${styleLayerId}:${getVtFeatureId(feature)}`
118
+ if (seenKeys.has(key)) {
119
+ return
120
+ }
121
+ seenKeys.add(key)
122
+ results.push({
123
+ id: feature.getId(),
124
+ layer: { id: styleLayerId },
125
+ geometry: renderFeatureToGeoJSON(feature),
126
+ properties: feature.getProperties()
127
+ })
128
+ })
129
+ })
130
+ } else if (mapLayer instanceof VectorLayer) {
131
+ const layerId = mapLayer.get('layerId')
132
+ if (!layerId || !wanted.has(layerId) || mapLayer.get('_highlight')) {
133
+ return
134
+ }
135
+ mapLayer.getSource()?.getFeaturesInExtent(extent).forEach(feature => {
136
+ results.push({
137
+ id: feature.getId(),
138
+ layer: { id: layerId },
139
+ geometry: geoJsonFormat.writeGeometryObject(feature.getGeometry()),
140
+ properties: feature.getProperties()
141
+ })
142
+ })
143
+ } else {
144
+ // other layer types (e.g. TileLayer) — skip
145
+ }
146
+ })
147
+
148
+ return results
149
+ }
@@ -1,11 +1,13 @@
1
- import { queryFeatures } from './queryFeatures.js'
1
+ import { queryFeatures, getVisibleFeatures } from './queryFeatures.js'
2
2
 
3
3
  import VectorTileLayer from 'ol/layer/VectorTile.js'
4
4
  import VectorLayer from 'ol/layer/Vector.js'
5
+ import TileState from 'ol/TileState.js'
5
6
  import { renderFeatureToGeoJSON } from './vtTileFragments.js'
6
7
 
7
8
  jest.mock('ol/layer/VectorTile.js', () => ({ __esModule: true, default: class VectorTileLayer {} }))
8
9
  jest.mock('ol/layer/Vector.js', () => ({ __esModule: true, default: class VectorLayer {} }))
10
+ jest.mock('ol/TileState.js', () => ({ __esModule: true, default: { LOADED: 'loaded', LOADING: 'loading' } }))
9
11
  jest.mock('ol/format/GeoJSON.js', () => ({
10
12
  __esModule: true,
11
13
  default: jest.fn(() => ({
@@ -179,3 +181,122 @@ describe('queryFeatures', () => {
179
181
  expect(queryFeatures(map, { x: 0, y: 0 })).toHaveLength(2)
180
182
  })
181
183
  })
184
+
185
+ describe('getVisibleFeatures', () => {
186
+ beforeEach(() => jest.clearAllMocks())
187
+
188
+ const EXTENT = [0, 0, 100, 100]
189
+
190
+ const makeExtentMap = (layers = []) => ({
191
+ getView: () => ({ calculateExtent: () => EXTENT }),
192
+ getSize: () => [800, 600],
193
+ getLayers: () => ({ forEach: cb => layers.forEach(cb) })
194
+ })
195
+
196
+ const makeTile = (features, state = TileState.LOADED) => ({
197
+ getState: () => state,
198
+ getFeatures: () => features
199
+ })
200
+
201
+ const makeVTLayerWithTiles = (tiles) => Object.assign(new VectorTileLayer(), {
202
+ getSource: () => ({ sourceTiles_: tiles })
203
+ })
204
+
205
+ const makeVectorLayerWithFeatures = (layerId, features, isHighlight = false) => {
206
+ const source = { getFeaturesInExtent: jest.fn(() => features) }
207
+ return Object.assign(new VectorLayer(), {
208
+ get: (key) => {
209
+ if (key === 'layerId') return layerId
210
+ if (key === '_highlight') return isHighlight || undefined
211
+ return undefined
212
+ },
213
+ getSource: () => source
214
+ })
215
+ }
216
+
217
+ /* ------------------------------------------------------------------ */
218
+ /* VectorTile layer features */
219
+ /* ------------------------------------------------------------------ */
220
+
221
+ it('returns a loaded VT feature matching a requested layer id', () => {
222
+ const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
223
+ const layer = makeVTLayerWithTiles({ a: makeTile([feature]) })
224
+ const results = getVisibleFeatures(makeExtentMap([layer]), ['roads'])
225
+ expect(results).toHaveLength(1)
226
+ expect(results[0]).toMatchObject({ id: 1, layer: { id: 'roads' } })
227
+ })
228
+
229
+ it('skips VT features whose style layer id is not requested', () => {
230
+ const feature = makeVTFeature({ id: 1, styleLayerId: 'buildings' })
231
+ const layer = makeVTLayerWithTiles({ a: makeTile([feature]) })
232
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
233
+ })
234
+
235
+ it('skips tiles that are not loaded', () => {
236
+ const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
237
+ const layer = makeVTLayerWithTiles({ a: makeTile([feature], TileState.LOADING) })
238
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
239
+ })
240
+
241
+ it('skips background-type VT features', () => {
242
+ const feature = makeVTFeature({ styleLayerId: 'roads', type: 'background' })
243
+ const layer = makeVTLayerWithTiles({ a: makeTile([feature]) })
244
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
245
+ })
246
+
247
+ it('deduplicates the same feature split across multiple loaded tiles', () => {
248
+ const feature = makeVTFeature({ id: 1, styleLayerId: 'roads' })
249
+ const layer = makeVTLayerWithTiles({ a: makeTile([feature]), b: makeTile([feature]) })
250
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toHaveLength(1)
251
+ })
252
+
253
+ it('does nothing when the VectorTileLayer has no loaded tiles yet', () => {
254
+ const layer = makeVTLayerWithTiles(undefined)
255
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['roads'])).toEqual([])
256
+ })
257
+
258
+ /* ------------------------------------------------------------------ */
259
+ /* Vector layer features */
260
+ /* ------------------------------------------------------------------ */
261
+
262
+ it('returns Vector layer features in the current extent for a requested layer id', () => {
263
+ const feature = makeVectorFeature('f1', { type: 'Point' })
264
+ const layer = makeVectorLayerWithFeatures('draw', [feature])
265
+ const results = getVisibleFeatures(makeExtentMap([layer]), ['draw'])
266
+ expect(results).toHaveLength(1)
267
+ expect(results[0]).toMatchObject({ id: 'f1', layer: { id: 'draw' } })
268
+ })
269
+
270
+ it('queries the source with the current view extent', () => {
271
+ const feature = makeVectorFeature('f1')
272
+ const layer = makeVectorLayerWithFeatures('draw', [feature])
273
+ const source = layer.getSource()
274
+ getVisibleFeatures(makeExtentMap([layer]), ['draw'])
275
+ expect(source.getFeaturesInExtent).toHaveBeenCalledWith(EXTENT)
276
+ })
277
+
278
+ it('skips Vector layers whose layerId is not requested', () => {
279
+ const layer = makeVectorLayerWithFeatures('other', [makeVectorFeature('f1')])
280
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['draw'])).toEqual([])
281
+ })
282
+
283
+ it('skips highlight overlay Vector layers', () => {
284
+ const layer = makeVectorLayerWithFeatures('draw', [makeVectorFeature('f1')], true)
285
+ expect(getVisibleFeatures(makeExtentMap([layer]), ['draw'])).toEqual([])
286
+ })
287
+
288
+ /* ------------------------------------------------------------------ */
289
+ /* Mixed / other layers */
290
+ /* ------------------------------------------------------------------ */
291
+
292
+ it('skips layers that are neither VectorTileLayer nor VectorLayer', () => {
293
+ expect(getVisibleFeatures(makeExtentMap([{}]), ['draw'])).toEqual([])
294
+ })
295
+
296
+ it('returns results from both VT and Vector layers in one call', () => {
297
+ const vtLayer = makeVTLayerWithTiles({ a: makeTile([makeVTFeature({ id: 1, styleLayerId: 'roads' })]) })
298
+ const vecLayer = makeVectorLayerWithFeatures('draw', [makeVectorFeature('f1')])
299
+ const results = getVisibleFeatures(makeExtentMap([vtLayer, vecLayer]), ['roads', 'draw'])
300
+ expect(results).toHaveLength(2)
301
+ })
302
+ })
@@ -0,0 +1,26 @@
1
+ import { DEFAULTS, supportedShortcuts } from './defaults.js'
2
+
3
+ describe('DEFAULTS', () => {
4
+ it('keeps animationDuration below the 500ms core debounce time', () => {
5
+ expect(DEFAULTS.animationDuration).toBeLessThan(500)
6
+ })
7
+
8
+ it('exposes coordinatePrecision', () => {
9
+ expect(DEFAULTS.coordinatePrecision).toBe(7)
10
+ })
11
+ })
12
+
13
+ describe('supportedShortcuts', () => {
14
+ it('lists the keyboard shortcuts this provider supports', () => {
15
+ expect(supportedShortcuts).toEqual([
16
+ 'showKeyboardHelp',
17
+ 'selectControl',
18
+ 'moveLarge',
19
+ 'nudgeMap',
20
+ 'zoomLarge',
21
+ 'nudgeZoom',
22
+ 'highlightLabelAtCenter',
23
+ 'highlightNextLabel'
24
+ ])
25
+ })
26
+ })
@@ -16,24 +16,32 @@ export const CrossHair = () => {
16
16
  const { isVisible, isPinnedToMap, state } = crossHair
17
17
 
18
18
  return (
19
- <svg
20
- id={`${id}-cross-hair`}
19
+ <button
20
+ tabIndex='-1'
21
21
  ref={crossHairRef}
22
- className='im-c-cross-hair'
23
- width='38'
24
- height='38'
25
- viewBox='0 0 38 38'
26
- fillRule='evenodd'
27
- fill='currentColor'
22
+ id={`${id}-cross-hair`}
23
+ className='im-c-cross-hair-button'
24
+ onClick={() => crossHair.activate?.()}
28
25
  style={{
29
26
  position: 'absolute',
30
27
  left: isPinnedToMap ? 0 : '50%',
31
28
  top: isPinnedToMap ? 0 : '50%',
32
- pointerEvents: 'none',
33
29
  display: isVisible ? 'block' : 'none'
34
30
  }}
35
31
  >
36
- <path d={paths[state || 'active']} />
37
- </svg>
32
+ <svg
33
+ width='38'
34
+ height='38'
35
+ viewBox='0 0 38 38'
36
+ fillRule='evenodd'
37
+ fill='currentColor'
38
+ >
39
+ <path d={paths[state || 'active']} />
40
+ </svg>
41
+ {/* "Target" — shorter and easier for Voice Control users to say than "Cross hair"
42
+ ("Click Target"); a general-purpose map is unlikely to already have a feature/marker
43
+ named "Target" that this would collide with. */}
44
+ <span className='im-u-visually-hidden'>Target</span>
45
+ </button>
38
46
  )
39
47
  }
@@ -1,14 +1,35 @@
1
- .im-c-cross-hair {
2
- position: absolute;
3
- top: 0;
4
- left: 0;
5
- pointer-events: none;
1
+ // The crosshair is wrapped in a real <button> (tabIndex="-1") so it's a genuine, accessibility-
2
+ // focusable target for coordinate-based AT (e.g. macOS Voice Control's Show Numbers/Names) —
3
+ // see CrossHair.jsx — without being a Tab stop.
4
+ .im-c-cross-hair-button {
5
+ // Reset native chrome to zero: default padding/border/background otherwise shift and
6
+ // decorate the crosshair icon, since position/margin below assumes the content-box starts
7
+ // exactly at the border-box edge.
8
+ appearance: none;
9
+ border: 0;
10
+ background: none;
11
+ padding: 0;
12
+ font: inherit;
13
+ cursor: inherit;
14
+
15
+ // Explicit size, matching the SVG's own width/height, plus the negative margin that shifts
16
+ // the box by half its own size — so the button's *own* geometry, not just its child SVG's, is
17
+ // centred on wherever CrossHair.jsx's inline top/left positions it. This is what a coordinate-
18
+ // based AT overlay (e.g. Voice Control) reads to place its badge — without it, the button's
19
+ // content-box collapses to 0×0 (its only child is position:absolute, contributing nothing to
20
+ // normal-flow sizing), so the badge would land at the anchor point's corner, not on the icon.
21
+ width: 38px;
22
+ height: 38px;
6
23
  margin: -19px 0 0 -19px;
7
- }
8
24
 
9
- .im-c-cross-hair path {
10
- stroke-width: 3;
11
- stroke: var(--map-overlay-halo-color);
12
- fill: var(--map-overlay-foreground-color);
13
- paint-order: stroke fill;
14
- }
25
+ svg {
26
+ display: block;
27
+ }
28
+
29
+ path {
30
+ stroke-width: 3;
31
+ stroke: var(--map-overlay-halo-color);
32
+ fill: var(--map-overlay-foreground-color);
33
+ paint-order: stroke fill;
34
+ }
35
+ }
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render } from '@testing-library/react'
2
+ import { render, fireEvent } from '@testing-library/react'
3
3
  import { CrossHair } from './CrossHair.jsx'
4
4
  import { useCrossHair } from '../../hooks/useCrossHairAPI.js'
5
5
 
@@ -9,6 +9,9 @@ jest.mock('../../store/configContext', () => ({ useConfig: jest.fn(() => ({ id:
9
9
  describe('CrossHair', () => {
10
10
  const crossHairRef = React.createRef()
11
11
 
12
+ // Positioning/visibility/sizing all live on the outer <button> (tabIndex="-1" — a real,
13
+ // accessibility-focusable-but-not-Tab-stop element, so coordinate-based AT like Voice Control
14
+ // can find it, at the right geometry); the SVG itself is a plain, unstyled child.
12
15
  const renderWith = (overrides = {}) => {
13
16
  useCrossHair.mockReturnValue({
14
17
  crossHairRef,
@@ -19,43 +22,64 @@ describe('CrossHair', () => {
19
22
  ...overrides
20
23
  }
21
24
  })
22
- return render(<CrossHair />).container.querySelector('svg')
25
+ const { container } = render(<CrossHair />)
26
+ return { button: container.querySelector('button'), svg: container.querySelector('svg') }
23
27
  }
24
28
 
25
29
  it('renders visible active marker when pinned', () => {
26
- const svg = renderWith()
30
+ const { button, svg } = renderWith()
27
31
  const path = svg.querySelector('path')
28
32
 
29
- expect(svg).toHaveClass('im-c-cross-hair')
30
- expect(svg).toHaveStyle({
33
+ expect(button).toHaveClass('im-c-cross-hair-button')
34
+ expect(button).toHaveStyle({
31
35
  position: 'absolute',
32
36
  left: 0,
33
37
  top: 0,
34
- pointerEvents: 'none',
35
38
  display: 'block'
36
39
  })
37
40
  expect(path.getAttribute('d')).toContain('M5.035 20H1v-2h4.035C5.525') // active path
38
41
  })
39
42
 
40
43
  it('positions marker at center when not pinned', () => {
41
- const svg = renderWith({ isPinnedToMap: false })
44
+ const { button } = renderWith({ isPinnedToMap: false })
42
45
 
43
- expect(svg).toHaveStyle({
46
+ expect(button).toHaveStyle({
44
47
  left: '50%',
45
48
  top: '50%'
46
49
  })
47
50
  })
48
51
 
52
+ // Fully removed from the layout/accessibility tree when hidden, not just faded — so Voice
53
+ // Control's Show Names never puts a "Target" label over nothing. Its real route back is
54
+ // opening MoveControls (a normal, always-visible button), not re-discovering this element.
49
55
  it('hides marker when not visible', () => {
50
- const svg = renderWith({ isVisible: false })
51
- expect(svg).toHaveStyle({ display: 'none' })
56
+ const { button } = renderWith({ isVisible: false })
57
+ expect(button).toHaveStyle({ display: 'none' })
58
+ })
59
+
60
+ it('invokes crossHair.activate() on click, for Voice Control / mouse / touch activation', () => {
61
+ const activate = jest.fn()
62
+ const { button } = renderWith({ activate })
63
+ fireEvent.click(button)
64
+ expect(activate).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ it('does not throw when clicked with no activate() assigned', () => {
68
+ const { button } = renderWith()
69
+ expect(() => fireEvent.click(button)).not.toThrow()
70
+ })
71
+
72
+ it('is not a Tab stop, but is a real button for coordinate-based AT (e.g. Voice Control) to find', () => {
73
+ const { button } = renderWith()
74
+ expect(button.tabIndex).toBe(-1)
75
+ expect(button.tagName).toBe('BUTTON')
52
76
  })
53
77
 
54
78
  it.each([
55
79
  ['inactive', 'M5.035 20H1v-2h4.035a13.98'],
56
80
  [undefined, 'M5.035 20H1v-2h4.035C5.525'] // fallback to active
57
81
  ])('renders correct path for state=%s', (state, expected) => {
58
- const svg = renderWith({ state })
82
+ const { svg } = renderWith({ state })
59
83
  expect(svg.querySelector('path').getAttribute('d')).toContain(expected)
60
84
  })
61
85
  })
@@ -10,7 +10,10 @@ const DEFAULT_GROUP = 'Navigate'
10
10
  const ShortcutList = ({ items }) => (
11
11
  <dl className='im-c-keyboard-help__list'>
12
12
  {items.map((item) => (
13
- <div key={item.id} className='im-c-keyboard-help__item'>
13
+ <div
14
+ key={item.id}
15
+ className={`im-c-keyboard-help__item${item.visuallyHidden ? ' im-u-visually-hidden' : ''}`}
16
+ >
14
17
  <dt className='im-c-keyboard-help__title'>{item.title}</dt>
15
18
  <dd className='im-c-keyboard-help__description' dangerouslySetInnerHTML={{ __html: item.command }} />
16
19
  </div>
@@ -68,6 +68,23 @@ describe('KeyboardHelp — flat list', () => {
68
68
  render(<KeyboardHelp />)
69
69
  expect(screen.getByText('↑')).toBeInTheDocument()
70
70
  })
71
+
72
+ it('visually hides a shortcut flagged visuallyHidden but keeps it in the DOM for assistive tech', () => {
73
+ getKeyboardShortcuts.mockReturnValue([
74
+ ...VIEWPORT_SHORTCUTS,
75
+ { id: 'sr', context: 'viewport', title: 'Get info', command: '<kbd>Alt</kbd> + <kbd>I</kbd>', visuallyHidden: true }
76
+ ])
77
+ render(<KeyboardHelp />)
78
+ const item = screen.getByText('Get info').closest('.im-c-keyboard-help__item')
79
+ expect(item).toHaveClass('im-u-visually-hidden')
80
+ })
81
+
82
+ it('does not visually hide a regular shortcut', () => {
83
+ getKeyboardShortcuts.mockReturnValue(VIEWPORT_SHORTCUTS)
84
+ render(<KeyboardHelp />)
85
+ const item = screen.getByText('Move').closest('.im-c-keyboard-help__item')
86
+ expect(item).not.toHaveClass('im-u-visually-hidden')
87
+ })
71
88
  })
72
89
 
73
90
  // ─── tab UI (multiple groups) ─────────────────────────────────────────────────
@@ -1,6 +1,7 @@
1
1
  // components/MapButton.jsx
2
2
  import React, { useState, useRef, useCallback } from 'react'
3
3
  import { stringToKebab } from '../../../utils/stringToKebab'
4
+ import { getPanelElementId } from '../../../utils/getPanelElementId.js'
4
5
  import { Tooltip } from '../Tooltip/Tooltip'
5
6
  import { Icon } from '../Icon/Icon'
6
7
  import { SlotRenderer } from '../../renderer/SlotRenderer'
@@ -88,6 +89,10 @@ const makePopupKeyUpHandler = (hasMenu, buttonRefs, buttonId, setMenuStartPos, s
88
89
  const getButtonSlot = (panelId, buttonId) =>
89
90
  panelId ? `${stringToKebab(buttonId)}-button` : undefined
90
91
 
92
+ // aria-haspopup only accepts these role-shaped tokens (plus 'true', unused here) — any other
93
+ // controlled-element role (e.g. a panel's 'complementary'/'region') means no aria-haspopup.
94
+ const HASPOPUP_ROLES = new Set(['menu', 'listbox', 'tree', 'grid', 'dialog'])
95
+
91
96
  /**
92
97
  * Determines the controlled element (panel or popup menu) for ARIA attributes.
93
98
  * @param {Object} options - Configuration options
@@ -95,14 +100,16 @@ const getButtonSlot = (panelId, buttonId) =>
95
100
  * @param {string} options.panelId - ID of the controlled panel (if applicable)
96
101
  * @param {string} options.buttonId - Unique button identifier
97
102
  * @param {boolean} options.hasMenu - Whether the button has a popup menu
98
- * @returns {Object|null} Object with id and type ('panel' or 'popup'), or null if no controlled element
103
+ * @param {string} [options.panelRole] - The controlled panel's own ARIA role (see getPanelRole.js),
104
+ * used for aria-haspopup on the button — undefined when no panel is controlled.
105
+ * @returns {Object|null} Object with id, type ('panel' or 'popup') and role, or null if no controlled element
99
106
  */
100
- const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu }) => {
107
+ const getControlledElement = ({ idPrefix, panelId, buttonId, hasMenu, panelRole }) => {
101
108
  if (panelId) {
102
- return { id: `${idPrefix}-panel-${stringToKebab(panelId)}`, type: 'panel' }
109
+ return { id: getPanelElementId(idPrefix, panelId), type: 'panel', role: panelRole }
103
110
  }
104
111
  if (hasMenu) {
105
- return { id: `${idPrefix}-popup-${stringToKebab(buttonId)}`, type: 'popup' }
112
+ return { id: `${idPrefix}-popup-${stringToKebab(buttonId)}`, type: 'popup', role: 'menu' }
106
113
  }
107
114
  return null
108
115
  }
@@ -168,7 +175,7 @@ const buildButtonProps = ({
168
175
  'aria-expanded': ariaExpanded,
169
176
  'aria-pressed': typeof isPressed === 'boolean' ? isPressed : undefined,
170
177
  'aria-controls': controlledElement?.id ?? ariaControls,
171
- 'aria-haspopup': controlledElement?.type === 'popup' ? 'menu' : undefined,
178
+ 'aria-haspopup': HASPOPUP_ROLES.has(controlledElement?.role) ? controlledElement.role : undefined,
172
179
  ...(href
173
180
  ? { href, target: '_blank', onKeyUp: handleKeyUp, role: 'button' }
174
181
  : { type: 'button' })
@@ -191,6 +198,7 @@ const buildButtonProps = ({
191
198
  * @param {boolean} [props.isExpanded] - Whether content controlled by the button is expanded
192
199
  * @param {boolean} [props.isHidden=false] - Whether to hide the button (CSS display: none)
193
200
  * @param {boolean} [props.isPanelOpen=false] - Whether the controlled panel is open
201
+ * @param {string} [props.panelRole] - The controlled panel's own ARIA role, used for aria-haspopup
194
202
  * @param {string} [props.variant] - CSS variant class for styling (e.g., 'primary')
195
203
  * @param {Function} [props.onClick] - Custom click handler
196
204
  * @param {string} [props.panelId] - ID of the panel controlled by this button
@@ -212,6 +220,7 @@ export const MapButton = ({
212
220
  isExpanded,
213
221
  isHidden,
214
222
  isPanelOpen,
223
+ panelRole,
215
224
  variant,
216
225
  onClick,
217
226
  panelId,
@@ -239,7 +248,7 @@ export const MapButton = ({
239
248
  const hasMenu = menuItems?.length >= 1
240
249
  const showIcon = iconId || iconSvgContent || hasMenu
241
250
  const buttonSlot = getButtonSlot(panelId, buttonId)
242
- const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu })
251
+ const controlledElement = getControlledElement({ idPrefix, panelId, buttonId, hasMenu, panelRole })
243
252
 
244
253
  /**
245
254
  * Handles button click events.
@@ -301,7 +310,10 @@ export const MapButton = ({
301
310
  >
302
311
  {showLabel ? buttonEl : <Tooltip content={label}>{buttonEl}</Tooltip>}
303
312
  {buttonSlot && <SlotRenderer slot={buttonSlot} />}
304
- {isPopupOpen && <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} />}
313
+ {/* Mounted permanently, not just while open, so its aria-controls id stays a stable node. */}
314
+ {controlledElement?.type === 'popup' && (
315
+ <PopupMenu popupMenuId={controlledElement.id} buttonId={buttonId} startPos={menuStartPos} menuRef={menuRef} items={menuItems} setIsOpen={setIsPopupOpen} buttonRect={menuRect} isOpen={isPopupOpen} />
316
+ )}
305
317
  </div>
306
318
  )
307
319
  }