@defra/interactive-map 0.0.22-alpha → 0.0.23-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/dist/css/index.css +1 -1
  2. package/dist/esm/im-core.js +1 -1
  3. package/dist/esm/im-shell.js +1 -1
  4. package/dist/umd/im-core.js +1 -1
  5. package/dist/umd/index.js +1 -1
  6. package/docs/api/button-definition.md +39 -0
  7. package/docs/api/context.md +4 -4
  8. package/docs/api/map-style-config.md +11 -2
  9. package/docs/api/marker-config.md +38 -7
  10. package/docs/api/panel-definition.md +7 -3
  11. package/docs/api/symbol-config.md +10 -26
  12. package/docs/api/symbol-registry.md +28 -13
  13. package/docs/api.md +24 -2
  14. package/docs/assets/css/{docusaurus.css → docusaurus.scss} +18 -12
  15. package/docs/assets/images/toggle-marker-label.jpg +0 -0
  16. package/docs/examples/index.mdx +12 -0
  17. package/docs/examples/toggle-marker-label.mdx +89 -0
  18. package/docs/plugins/interact.md +4 -11
  19. package/docusaurus.config.cjs +2 -1
  20. package/package.json +8 -8
  21. package/plugins/beta/datasets/dist/adapters/maplibre/esm/im-datasets-ml-adapter.js +1 -1
  22. package/plugins/beta/datasets/dist/css/index.css +2 -2
  23. package/plugins/beta/datasets/dist/esm/im-datasets-plugin.js +1 -1
  24. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-maplibre-adapter.js +1 -1
  25. package/plugins/beta/datasets/dist/umd/maplibre/im-datasets-plugin.js +1 -1
  26. package/plugins/beta/datasets/dist/umd/maplibre/index.js +1 -1
  27. package/plugins/beta/datasets/src/adapters/maplibre/layerBuilders.js +5 -5
  28. package/plugins/beta/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +24 -29
  29. package/plugins/beta/datasets/src/adapters/maplibre/patternImages.js +2 -2
  30. package/plugins/beta/datasets/src/adapters/maplibre/symbolImages.js +3 -3
  31. package/plugins/beta/datasets/src/components/KeySvg.jsx +2 -2
  32. package/plugins/beta/datasets/src/components/KeySvgPattern.jsx +1 -2
  33. package/plugins/beta/datasets/src/components/svgProperties.js +2 -2
  34. package/plugins/beta/datasets/src/panels/Key.module.scss +2 -2
  35. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  36. package/plugins/beta/draw-ml/dist/esm/im-draw-ml-plugin.js +1 -1
  37. package/plugins/beta/draw-ml/dist/umd/im-draw-ml-plugin.js +1 -1
  38. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  39. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  40. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  41. package/plugins/beta/use-location/dist/esm/im-use-location-plugin.js +1 -1
  42. package/plugins/beta/use-location/dist/umd/im-use-location-plugin.js +1 -1
  43. package/plugins/beta/use-location/src/manifest.js +1 -0
  44. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  45. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  46. package/plugins/interact/dist/umd/index.js +1 -1
  47. package/plugins/interact/src/InteractInit.jsx +23 -55
  48. package/plugins/interact/src/InteractInit.test.js +58 -80
  49. package/plugins/interact/src/defaults.js +1 -3
  50. package/plugins/interact/src/hooks/useAttachEvents.js +46 -0
  51. package/plugins/interact/src/hooks/useAttachEvents.test.js +65 -0
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +47 -0
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +54 -0
  54. package/plugins/interact/src/hooks/useHighlightSync.js +30 -8
  55. package/plugins/interact/src/hooks/useHighlightSync.test.js +127 -80
  56. package/plugins/interact/src/hooks/useInteractionHandlers.js +18 -17
  57. package/plugins/interact/src/hooks/useInteractionHandlers.test.js +22 -61
  58. package/plugins/interact/src/hooks/useMapItemList.js +187 -0
  59. package/plugins/interact/src/hooks/useMapItemList.test.js +584 -0
  60. package/plugins/interact/src/index.js +0 -2
  61. package/plugins/interact/src/index.test.js +0 -2
  62. package/plugins/interact/src/manifest.js +37 -6
  63. package/plugins/interact/src/reducer.js +8 -4
  64. package/plugins/interact/src/reducer.test.js +18 -3
  65. package/plugins/interact/src/utils/buildStylesMap.js +21 -10
  66. package/plugins/interact/src/utils/buildStylesMap.test.js +29 -29
  67. package/plugins/interact/src/utils/spatial.js +0 -15
  68. package/plugins/interact/src/utils/spatial.test.js +1 -13
  69. package/plugins/search/dist/css/index.css +1 -1
  70. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  71. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  72. package/plugins/search/src/components/CloseButton/CloseButton.module.scss +2 -0
  73. package/plugins/search/src/components/OpenButton/OpenButton.jsx +1 -1
  74. package/plugins/search/src/components/OpenButton/OpenButton.module.scss +3 -0
  75. package/plugins/search/src/search.scss +1 -0
  76. package/providers/beta/esri/dist/esm/im-esri-provider.js +1 -1
  77. package/providers/beta/esri/src/esriProvider.js +4 -0
  78. package/providers/maplibre/dist/esm/im-maplibre-provider.js +1 -1
  79. package/providers/maplibre/dist/esm/index.js +1 -1
  80. package/providers/maplibre/dist/umd/im-maplibre-provider.js +1 -1
  81. package/providers/maplibre/dist/umd/index.js +1 -1
  82. package/providers/maplibre/src/index.js +1 -1
  83. package/providers/maplibre/src/index.test.js +9 -14
  84. package/providers/maplibre/src/mapEvents.js +11 -5
  85. package/providers/maplibre/src/mapEvents.test.js +14 -3
  86. package/providers/maplibre/src/maplibreProvider.js +18 -11
  87. package/providers/maplibre/src/maplibreProvider.test.js +43 -36
  88. package/providers/maplibre/src/utils/highlightFeatures.js +102 -57
  89. package/providers/maplibre/src/utils/highlightFeatures.test.js +162 -51
  90. package/providers/maplibre/src/utils/maplibreFixes.js +10 -0
  91. package/providers/maplibre/src/utils/maplibreFixes.test.js +31 -0
  92. package/providers/maplibre/src/utils/patternImages.js +10 -12
  93. package/providers/maplibre/src/utils/patternImages.test.js +145 -167
  94. package/providers/maplibre/src/utils/rasteriseToImageData.js +1 -4
  95. package/providers/maplibre/src/utils/rasteriseToImageData.test.js +23 -24
  96. package/providers/maplibre/src/utils/symbolImages.js +23 -86
  97. package/providers/maplibre/src/utils/symbolImages.test.js +60 -105
  98. package/src/App/components/Actions/Actions.module.scss +1 -0
  99. package/src/App/components/Actions/Actions.test.jsx +17 -4
  100. package/src/App/components/Hints/Hints.jsx +52 -0
  101. package/src/App/components/Hints/Hints.module.scss +37 -0
  102. package/src/App/components/Hints/Hints.test.jsx +104 -0
  103. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +57 -19
  104. package/src/App/components/KeyboardHelp/KeyboardHelp.module.scss +10 -0
  105. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +140 -31
  106. package/src/App/components/Markers/LabelMarker.jsx +14 -0
  107. package/src/App/components/Markers/LabelMarker.test.jsx +36 -0
  108. package/src/App/components/Markers/Markers.jsx +44 -75
  109. package/src/App/components/Markers/Markers.module.scss +58 -0
  110. package/src/App/components/Markers/Markers.test.jsx +90 -113
  111. package/src/App/components/Markers/SymbolLabelMarker.jsx +43 -0
  112. package/src/App/components/Markers/SymbolLabelMarker.test.jsx +56 -0
  113. package/src/App/components/Markers/SymbolMarker.jsx +31 -0
  114. package/src/App/components/Markers/SymbolMarker.test.jsx +72 -0
  115. package/src/App/components/Panel/Panel.jsx +1 -1
  116. package/src/App/components/Panel/Panel.module.scss +1 -0
  117. package/src/App/components/PopupMenu/PopupMenu.test.jsx +117 -1
  118. package/src/App/components/PopupMenu/usePopupMenu.js +132 -85
  119. package/src/App/components/Tabs/Tabs.jsx +68 -0
  120. package/src/App/components/Tabs/Tabs.module.scss +57 -0
  121. package/src/App/components/Tabs/Tabs.test.jsx +121 -0
  122. package/src/App/components/Tooltip/Tooltip.module.scss +1 -1
  123. package/src/App/components/Viewport/Features.jsx +35 -0
  124. package/src/App/components/Viewport/Features.module.scss +31 -0
  125. package/src/App/components/Viewport/Features.test.jsx +124 -0
  126. package/src/App/components/Viewport/MapStatus.jsx +0 -4
  127. package/src/App/components/Viewport/Viewport.jsx +35 -29
  128. package/src/App/components/Viewport/Viewport.module.scss +7 -21
  129. package/src/App/components/Viewport/Viewport.test.jsx +108 -65
  130. package/src/App/controls/keyboardActions.js +9 -3
  131. package/src/App/controls/keyboardActions.test.js +122 -87
  132. package/src/App/controls/keyboardShortcuts.js +1 -14
  133. package/src/App/hooks/useFeatureFocus.js +199 -0
  134. package/src/App/hooks/useFeatureFocus.test.js +635 -0
  135. package/src/App/hooks/useFeatureItems.js +30 -0
  136. package/src/App/hooks/useFeatureItems.test.js +97 -0
  137. package/src/App/hooks/useFocusVisible.js +4 -7
  138. package/src/App/hooks/useFocusVisible.test.js +6 -6
  139. package/src/App/hooks/useKeyboardHint.js +9 -34
  140. package/src/App/hooks/useKeyboardHint.test.js +34 -84
  141. package/src/App/hooks/useKeyboardShortcuts.js +8 -5
  142. package/src/App/hooks/useKeyboardShortcuts.test.js +16 -12
  143. package/src/App/hooks/useLayoutMeasurements.js +10 -4
  144. package/src/App/hooks/useLayoutMeasurements.test.js +30 -0
  145. package/src/App/hooks/useMarkerCursor.js +33 -0
  146. package/src/App/hooks/useMarkerCursor.test.js +87 -0
  147. package/src/App/hooks/useMarkersAPI.js +32 -9
  148. package/src/App/hooks/useMarkersAPI.test.js +162 -94
  149. package/src/App/layout/Layout.jsx +2 -0
  150. package/src/App/layout/Layout.test.jsx +3 -0
  151. package/src/App/registry/keyboardShortcutRegistry.js +3 -2
  152. package/src/App/registry/keyboardShortcutRegistry.test.js +6 -8
  153. package/src/App/renderer/mapButtons.js +5 -1
  154. package/src/App/renderer/mapButtons.test.js +37 -1
  155. package/src/App/store/ServiceProvider.jsx +4 -1
  156. package/src/App/store/ServiceProvider.test.jsx +11 -0
  157. package/src/App/store/appActionsMap.js +5 -1
  158. package/src/App/store/appActionsMap.test.js +13 -0
  159. package/src/App/store/appReducer.js +1 -0
  160. package/src/App/store/mapActionsMap.js +2 -1
  161. package/src/InteractiveMap/InteractiveMap.js +12 -0
  162. package/src/InteractiveMap/InteractiveMap.test.js +2 -0
  163. package/src/config/appConfig.js +3 -1
  164. package/src/config/appConfig.test.js +4 -0
  165. package/src/config/defaults.js +1 -1
  166. package/src/config/events.js +8 -0
  167. package/src/config/mapTheme.js +8 -4
  168. package/src/config/symbolConfig.js +23 -16
  169. package/src/scss/main.scss +3 -0
  170. package/src/scss/tools/_border-focus.scss +0 -1
  171. package/src/services/eventBus.js +15 -0
  172. package/src/services/eventBus.test.js +11 -0
  173. package/src/services/hints.js +47 -0
  174. package/src/services/hints.test.js +110 -0
  175. package/src/services/patternRegistry.js +69 -1
  176. package/src/services/patternRegistry.test.js +112 -1
  177. package/src/services/reverseGeocode.js +4 -0
  178. package/src/services/reverseGeocode.test.js +11 -1
  179. package/src/services/symbolRegistry.js +152 -17
  180. package/src/services/symbolRegistry.test.js +141 -31
  181. package/src/types.js +71 -14
  182. package/src/utils/constrainKeyboardFocus.js +5 -5
  183. package/src/utils/detectInterfaceType.js +27 -5
  184. package/src/utils/detectInterfaceType.test.js +29 -3
  185. package/src/utils/patternUtils.js +4 -67
  186. package/src/utils/patternUtils.test.js +1 -123
  187. package/src/utils/symbolUtils.js +11 -13
  188. package/src/utils/symbolUtils.test.js +18 -26
  189. package/plugins/interact/dist/css/index.css +0 -1
  190. package/plugins/interact/src/interact.scss +0 -24
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { render, cleanup } from '@testing-library/react'
2
+ import { render, cleanup, fireEvent } from '@testing-library/react'
3
3
  import { Viewport } from './Viewport.jsx'
4
4
  import { useConfig } from '../../store/configContext.js'
5
5
  import { useApp } from '../../store/appContext.js'
@@ -9,9 +9,6 @@ import { useKeyboardHint } from '../../hooks/useKeyboardHint.js'
9
9
  import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts.js'
10
10
  import { useMapEvents } from '../../hooks/useMapEvents.js'
11
11
 
12
- // ---------------------------
13
- // Mock dependencies
14
- // ---------------------------
15
12
  jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
16
13
  jest.mock('../../store/appContext.js', () => ({ useApp: jest.fn() }))
17
14
  jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
@@ -22,60 +19,60 @@ jest.mock('../../hooks/useMapEvents.js', () => ({ useMapEvents: jest.fn() }))
22
19
  jest.mock('../CrossHair/CrossHair', () => ({ CrossHair: jest.fn(() => <div data-testid='cross-hair' />) }))
23
20
  jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid='markers' />) }))
24
21
 
25
- describe('Viewport', () => {
26
- let viewportEl
27
- let mainEl
28
- const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
22
+ const KEYBOARD_HINT_TEXT = 'Test keyboad hint text'
23
+
24
+ const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }
25
+
26
+ function setupHookMocks (mainEl, viewportEl) {
27
+ useConfig.mockReturnValue({
28
+ id: 'test-map',
29
+ mapLabel: 'Test Map',
30
+ keyboardHintText: KEYBOARD_HINT_TEXT,
31
+ mapProvider: mockMapProvider
32
+ })
33
+ useApp.mockReturnValue({
34
+ interfaceType: 'desktop',
35
+ mode: 'default',
36
+ previousMode: 'default',
37
+ layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewportEl }, safeZoneRef: { current: null } },
38
+ safeZoneInset: {},
39
+ dispatch: jest.fn()
40
+ })
41
+ useMap.mockReturnValue({ mapSize: 'medium', dispatch: jest.fn() })
42
+ useService.mockReturnValue({
43
+ announce: jest.fn(),
44
+ hints: { show: jest.fn(), dismiss: jest.fn(), subscribe: jest.fn(() => jest.fn()) },
45
+ eventBus: { on: jest.fn(), off: jest.fn(), emit: jest.fn() }
46
+ })
47
+ useKeyboardHint.mockImplementation(({ onViewportFocusChange }) => ({
48
+ handleFocus: () => onViewportFocusChange(true),
49
+ handleBlur: () => onViewportFocusChange(false)
50
+ }))
51
+ useKeyboardShortcuts.mockImplementation(() => {})
52
+ useMapEvents.mockImplementation(() => {})
53
+ }
54
+
55
+ function renderViewport () {
56
+ const { container, rerender } = render(<Viewport />)
57
+ const viewport = container.querySelector('.im-c-viewport')
58
+ const mapContainer = container.querySelector('.im-c-viewport__map-container')
59
+ const safeZone = container.querySelector('.im-c-viewport__safezone')
60
+ const crossHair = container.querySelector('[data-testid="cross-hair"]')
61
+ const markers = container.querySelector('[data-testid="markers"]')
62
+ return { container, viewport, mapContainer, safeZone, crossHair, markers, rerender }
63
+ }
64
+
65
+ describe('Viewport rendering', () => {
66
+ let viewportEl, mainEl
29
67
 
30
68
  beforeEach(() => {
31
69
  cleanup()
32
70
  jest.clearAllMocks()
33
-
34
71
  viewportEl = document.createElement('div')
35
72
  mainEl = document.createElement('div')
36
73
  document.body.appendChild(viewportEl)
37
74
  document.body.appendChild(mainEl)
38
-
39
- // ---------------------------
40
- // Hook mocks
41
- // ---------------------------
42
- useConfig.mockReturnValue({
43
- id: 'test-map',
44
- mapLabel: 'Test Map',
45
- keyboardHintText: 'Press arrow keys',
46
- mapProvider: mockMapProvider
47
- })
48
-
49
- useApp.mockReturnValue({
50
- interfaceType: 'desktop',
51
- mode: 'default',
52
- previousMode: 'default',
53
- layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewportEl }, safeZoneRef: { current: null } },
54
- safeZoneInset: {}
55
- })
56
-
57
- useMap.mockReturnValue({
58
- mapSize: 'medium',
59
- dispatch: jest.fn()
60
- })
61
-
62
- useService.mockReturnValue({
63
- announce: jest.fn(),
64
- eventBus: {
65
- on: jest.fn(),
66
- off: jest.fn(),
67
- emit: jest.fn()
68
- }
69
- })
70
-
71
- useKeyboardHint.mockImplementation(({ onViewportFocusChange }) => ({
72
- showHint: true,
73
- handleFocus: () => onViewportFocusChange(true),
74
- handleBlur: () => onViewportFocusChange(false)
75
- }))
76
-
77
- useKeyboardShortcuts.mockImplementation(() => {})
78
- useMapEvents.mockImplementation(() => {})
75
+ setupHookMocks(mainEl, viewportEl)
79
76
  })
80
77
 
81
78
  afterEach(() => {
@@ -83,17 +80,6 @@ describe('Viewport', () => {
83
80
  mainEl.remove()
84
81
  })
85
82
 
86
- const renderViewport = () => {
87
- const { container, rerender, unmount } = render(<Viewport />)
88
- const viewport = container.querySelector('.im-c-viewport')
89
- const mapContainer = container.querySelector('.im-c-viewport__map-container')
90
- const safeZone = container.querySelector('.im-c-viewport__safezone')
91
- const keyboardHint = mainEl.querySelector('.im-c-viewport__keyboard-hint')
92
- const crossHair = container.querySelector('[data-testid="cross-hair"]')
93
- const markers = container.querySelector('[data-testid="markers"]')
94
- return { viewport, mapContainer, safeZone, keyboardHint, crossHair, markers, rerender, unmount }
95
- }
96
-
97
83
  it('renders viewport, map container, safe zone, CrossHair, and Markers', () => {
98
84
  const { viewport, mapContainer, safeZone, crossHair, markers } = renderViewport()
99
85
  expect(viewport).toBeInTheDocument()
@@ -109,10 +95,44 @@ describe('Viewport', () => {
109
95
  expect(viewport).toHaveClass('im-c-viewport--medium')
110
96
  })
111
97
 
112
- it('renders keyboard hint when showHint is true', () => {
113
- const { keyboardHint } = renderViewport()
114
- expect(keyboardHint).toBeInTheDocument()
115
- expect(keyboardHint.innerHTML).toBe('Press arrow keys')
98
+ it('sets aria-describedby to the shared hints container id', () => {
99
+ const { viewport } = renderViewport()
100
+ expect(viewport).toHaveAttribute('aria-describedby', 'test-map-keyboard-desc')
101
+ })
102
+
103
+ it('calls hints.show() with keyboardHintText when viewport gains keyboard focus', () => {
104
+ const { hints } = useService()
105
+ renderViewport()
106
+ const { onViewportFocusChange } = useKeyboardHint.mock.calls[0][0]
107
+ onViewportFocusChange(true)
108
+ expect(hints.show).toHaveBeenCalledWith(KEYBOARD_HINT_TEXT, { duration: 0 })
109
+ })
110
+
111
+ it('calls hints.dismiss() when viewport loses focus', () => {
112
+ const { hints } = useService()
113
+ renderViewport()
114
+ const { onViewportFocusChange } = useKeyboardHint.mock.calls[0][0]
115
+ onViewportFocusChange(false)
116
+ expect(hints.dismiss).toHaveBeenCalled()
117
+ })
118
+ })
119
+
120
+ describe('Viewport interactions', () => {
121
+ let viewportEl, mainEl
122
+
123
+ beforeEach(() => {
124
+ cleanup()
125
+ jest.clearAllMocks()
126
+ viewportEl = document.createElement('div')
127
+ mainEl = document.createElement('div')
128
+ document.body.appendChild(viewportEl)
129
+ document.body.appendChild(mainEl)
130
+ setupHookMocks(mainEl, viewportEl)
131
+ })
132
+
133
+ afterEach(() => {
134
+ viewportEl.remove()
135
+ mainEl.remove()
116
136
  })
117
137
 
118
138
  it('attaches keyboard shortcuts', () => {
@@ -120,6 +140,15 @@ describe('Viewport', () => {
120
140
  expect(useKeyboardShortcuts).toHaveBeenCalled()
121
141
  })
122
142
 
143
+ it('dispatches SET_LISTBOX_ACTIVE when interact:listboxcapable fires', () => {
144
+ renderViewport()
145
+ const { eventBus } = useService()
146
+ const [, handler] = eventBus.on.mock.calls.find(([event]) => event === 'interact:listboxcapable')
147
+ handler()
148
+ const { dispatch } = useApp()
149
+ expect(dispatch).toHaveBeenCalledWith({ type: 'SET_LISTBOX_ACTIVE' })
150
+ })
151
+
123
152
  it('calls mapProvider.clearHighlightedLabel on map:click', () => {
124
153
  const clearMock = jest.fn()
125
154
  useConfig.mockReturnValueOnce({ ...useConfig(), mapProvider: { ...mockMapProvider, clearHighlightedLabel: clearMock } })
@@ -128,6 +157,20 @@ describe('Viewport', () => {
128
157
  expect(clearMock).toHaveBeenCalled()
129
158
  })
130
159
 
160
+ it('calls hints.show() when the features listbox gains focus', () => {
161
+ const { hints } = useService()
162
+ const { container } = renderViewport()
163
+ fireEvent.focus(container.querySelector('[role="listbox"]'))
164
+ expect(hints.show).toHaveBeenCalledWith(KEYBOARD_HINT_TEXT, { duration: 0 })
165
+ })
166
+
167
+ it('calls hints.dismiss() when the features listbox loses focus', () => {
168
+ const { hints } = useService()
169
+ const { container } = renderViewport()
170
+ fireEvent.blur(container.querySelector('[role="listbox"]'))
171
+ expect(hints.dismiss).toHaveBeenCalled()
172
+ })
173
+
131
174
  it('focuses viewport when mode changes', () => {
132
175
  const { viewport, rerender } = renderViewport()
133
176
  const focusMock = jest.spyOn(viewport, 'focus')
@@ -1,4 +1,5 @@
1
- import { reverseGeocode } from '../../services/reverseGeocode.js'
1
+ import { reverseGeocode, hasReverseGeocode } from '../../services/reverseGeocode.js'
2
+ import { logger } from '../../services/logger.js'
2
3
 
3
4
  export const createKeyboardActions = (mapProvider, announce, {
4
5
  containerRef,
@@ -13,12 +14,13 @@ export const createKeyboardActions = (mapProvider, announce, {
13
14
  const getZoom = (shift) => (shift ? nudgeZoomDelta : zoomDelta)
14
15
 
15
16
  return {
16
- showKeyboardControls: () => {
17
+ showKeyboardControls: (e) => {
18
+ const context = e.target === containerRef.current ? 'viewport' : 'listbox'
17
19
  dispatch({
18
20
  type: 'OPEN_PANEL',
19
21
  payload: {
20
22
  panelId: 'keyboardHelp',
21
- props: { triggeringElement: containerRef.current }
23
+ props: { triggeringElement: e.target, context }
22
24
  }
23
25
  })
24
26
  },
@@ -36,6 +38,10 @@ export const createKeyboardActions = (mapProvider, announce, {
36
38
  zoomOut: (e) => mapProvider.zoomOut(getZoom(e.shiftKey)),
37
39
 
38
40
  getInfo: async (_e) => {
41
+ if (!hasReverseGeocode()) {
42
+ logger.warn('Alt+I pressed but reverseGeocode has not been configured')
43
+ return
44
+ }
39
45
  const coord = mapProvider.getCenter()
40
46
  const place = await reverseGeocode(mapProvider.getZoom(), coord)
41
47
  const area = mapProvider.getAreaDimensions?.()
@@ -1,139 +1,174 @@
1
1
  import { createKeyboardActions } from './keyboardActions.js'
2
- import { reverseGeocode } from '../../services/reverseGeocode.js'
2
+ import { reverseGeocode, hasReverseGeocode } from '../../services/reverseGeocode.js'
3
+ import { logger } from '../../services/logger.js'
3
4
 
4
5
  jest.mock('../../services/reverseGeocode.js', () => ({
5
- reverseGeocode: jest.fn()
6
+ reverseGeocode: jest.fn(),
7
+ hasReverseGeocode: jest.fn()
6
8
  }))
7
9
 
8
- describe('createKeyboardActions', () => {
9
- beforeAll(() => {
10
- Object.defineProperty(window, 'matchMedia', {
11
- writable: true,
12
- value: jest.fn().mockReturnValue({
13
- matches: false,
14
- addListener: jest.fn(),
15
- removeListener: jest.fn(),
16
- addEventListener: jest.fn(),
17
- removeEventListener: jest.fn()
18
- })
19
- })
10
+ jest.mock('../../services/logger.js', () => ({
11
+ logger: { warn: jest.fn() }
12
+ }))
13
+
14
+ const PAN_DELTA = 10
15
+ const NUDGE_PAN_DELTA = 5
16
+ const ZOOM_DELTA = 2
17
+ const NUDGE_ZOOM_DELTA = 1
18
+ const ZOOM_LEVEL = 12
19
+
20
+ const makeMapProvider = () => ({
21
+ panBy: jest.fn(),
22
+ zoomIn: jest.fn(),
23
+ zoomOut: jest.fn(),
24
+ getCenter: jest.fn().mockReturnValue({ lng: 1, lat: 2 }),
25
+ getZoom: jest.fn().mockReturnValue(ZOOM_LEVEL),
26
+ getAreaDimensions: jest.fn().mockReturnValue('5km²'),
27
+ highlightNextLabel: jest.fn(),
28
+ highlightLabelAtCenter: jest.fn(),
29
+ clearHighlightedLabel: jest.fn()
30
+ })
31
+
32
+ const makeActions = (overrides = {}) => {
33
+ const mapProvider = makeMapProvider()
34
+ const announce = jest.fn()
35
+ const dispatch = jest.fn()
36
+ const containerEl = document.createElement('div')
37
+ const containerRef = { current: containerEl }
38
+ const actions = createKeyboardActions(mapProvider, announce, {
39
+ containerRef,
40
+ dispatch,
41
+ panDelta: PAN_DELTA,
42
+ nudgePanDelta: NUDGE_PAN_DELTA,
43
+ zoomDelta: ZOOM_DELTA,
44
+ nudgeZoomDelta: NUDGE_ZOOM_DELTA,
45
+ readMapText: true,
46
+ ...overrides
20
47
  })
48
+ return { actions, mapProvider, announce, dispatch, containerRef }
49
+ }
50
+
51
+ afterEach(() => {
52
+ document.body.querySelectorAll('[role="listbox"]').forEach(el => el.remove())
53
+ })
21
54
 
22
- const panDelta = 10
23
- const nudgePanDelta = 5
24
- const zoomDelta = 2
25
- const nudgeZoomDelta = 1
26
-
27
- let mapProvider, announce, dispatch, containerRef, readMapText
28
-
29
- const create = () =>
30
- createKeyboardActions(mapProvider, announce, {
31
- containerRef,
32
- dispatch,
33
- panDelta,
34
- nudgePanDelta,
35
- zoomDelta,
36
- nudgeZoomDelta,
37
- readMapText
55
+ beforeAll(() => {
56
+ Object.defineProperty(window, 'matchMedia', { // NOSONAR: window required for broader browser support
57
+ writable: true,
58
+ value: jest.fn().mockReturnValue({
59
+ matches: false,
60
+ addListener: jest.fn(),
61
+ removeListener: jest.fn(),
62
+ addEventListener: jest.fn(),
63
+ removeEventListener: jest.fn()
38
64
  })
65
+ })
66
+ })
67
+
68
+ beforeEach(() => {
69
+ hasReverseGeocode.mockReturnValue(true)
70
+ })
39
71
 
40
- beforeEach(() => {
41
- mapProvider = {
42
- panBy: jest.fn(),
43
- zoomIn: jest.fn(),
44
- zoomOut: jest.fn(),
45
- getCenter: jest.fn().mockReturnValue({ lng: 1, lat: 2 }),
46
- getZoom: jest.fn().mockReturnValue(12),
47
- getAreaDimensions: jest.fn().mockReturnValue('5km²'),
48
- highlightNextLabel: jest.fn(),
49
- highlightLabelAtCenter: jest.fn(),
50
- clearHighlightedLabel: jest.fn()
51
- }
52
-
53
- announce = jest.fn()
54
- dispatch = jest.fn()
55
- containerRef = { current: {} }
56
- readMapText = true
72
+ describe('navigation actions', () => {
73
+ test('showKeyboardControls uses viewport context when target is the container', () => {
74
+ const { actions, dispatch, containerRef } = makeActions()
75
+ actions.showKeyboardControls({ target: containerRef.current })
76
+ expect(dispatch).toHaveBeenCalledWith({
77
+ type: 'OPEN_PANEL',
78
+ payload: { panelId: 'keyboardHelp', props: { triggeringElement: containerRef.current, context: 'viewport' } }
79
+ })
57
80
  })
58
81
 
59
- test('showKeyboardControls dispatches correct action', () => {
60
- create().showKeyboardControls()
82
+ test('showKeyboardControls uses listbox context when target is not the container', () => {
83
+ const { actions, dispatch } = makeActions()
84
+ const target = document.createElement('div')
85
+ actions.showKeyboardControls({ target })
61
86
  expect(dispatch).toHaveBeenCalledWith({
62
87
  type: 'OPEN_PANEL',
63
- payload: {
64
- panelId: 'keyboardHelp',
65
- props: { triggeringElement: containerRef.current }
66
- }
88
+ payload: { panelId: 'keyboardHelp', props: { triggeringElement: target, context: 'listbox' } }
67
89
  })
68
90
  })
69
91
 
70
92
  test.each([
71
- ['panUp', { shiftKey: false }, [0, -panDelta]],
72
- ['panDown', { shiftKey: true }, [0, nudgePanDelta]],
73
- ['panLeft', { shiftKey: false }, [-panDelta, 0]],
74
- ['panRight', { shiftKey: true }, [nudgePanDelta, 0]]
93
+ ['panUp', { shiftKey: false }, [0, -PAN_DELTA]],
94
+ ['panDown', { shiftKey: true }, [0, NUDGE_PAN_DELTA]],
95
+ ['panLeft', { shiftKey: false }, [-PAN_DELTA, 0]],
96
+ ['panRight', { shiftKey: true }, [NUDGE_PAN_DELTA, 0]]
75
97
  ])('%s uses correct deltas', (method, event, expected) => {
76
- create()[method](event)
98
+ const { actions, mapProvider } = makeActions()
99
+ actions[method](event)
77
100
  expect(mapProvider.panBy).toHaveBeenCalledWith(expected)
78
101
  })
79
102
 
80
103
  test.each([
81
- ['zoomIn', { shiftKey: false }, zoomDelta],
82
- ['zoomOut', { shiftKey: true }, nudgeZoomDelta]
104
+ ['zoomIn', { shiftKey: false }, ZOOM_DELTA],
105
+ ['zoomOut', { shiftKey: true }, NUDGE_ZOOM_DELTA]
83
106
  ])('%s uses correct deltas', (method, event, expected) => {
84
- create()[method](event)
107
+ const { actions, mapProvider } = makeActions()
108
+ actions[method](event)
85
109
  expect(mapProvider[method]).toHaveBeenCalledWith(expected)
86
110
  })
87
111
 
88
- test('getInfo performs reverse geocode, places marker, and announces', async () => {
89
- reverseGeocode.mockResolvedValue('London')
90
- await create().getInfo()
112
+ test('clearSelection calls clearHighlightedLabel', () => {
113
+ const { actions, mapProvider } = makeActions()
114
+ actions.clearSelection()
115
+ expect(mapProvider.clearHighlightedLabel).toHaveBeenCalled()
116
+ })
117
+ })
91
118
 
92
- expect(reverseGeocode).toHaveBeenCalledWith(12, { lng: 1, lat: 2 })
119
+ describe('getInfo', () => {
120
+ test('does nothing when reverseGeocode is not configured', async () => {
121
+ hasReverseGeocode.mockReturnValue(false)
122
+ const { actions, announce } = makeActions()
123
+ await actions.getInfo()
124
+ expect(reverseGeocode).not.toHaveBeenCalled()
125
+ expect(announce).not.toHaveBeenCalled()
126
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('reverseGeocode'))
127
+ })
93
128
 
129
+ test('performs reverse geocode and announces result with area', async () => {
130
+ reverseGeocode.mockResolvedValue('London')
131
+ const { actions, announce } = makeActions()
132
+ await actions.getInfo()
133
+ expect(reverseGeocode).toHaveBeenCalledWith(ZOOM_LEVEL, { lng: 1, lat: 2 })
94
134
  expect(announce).toHaveBeenCalledWith('London. Covering 5km².', 'core')
95
135
  })
96
136
 
97
- test('getInfo handles missing getAreaDimensions', async () => {
98
- delete mapProvider.getAreaDimensions
137
+ test('announces result without area when getAreaDimensions is absent', async () => {
99
138
  reverseGeocode.mockResolvedValue('Paris')
100
-
101
- await create().getInfo()
139
+ const { actions, mapProvider, announce } = makeActions()
140
+ delete mapProvider.getAreaDimensions
141
+ await actions.getInfo()
102
142
  expect(announce).toHaveBeenCalledWith('Paris.', 'core')
103
143
  })
144
+ })
104
145
 
146
+ describe('label actions', () => {
105
147
  test('highlightNextLabel announces returned label', () => {
148
+ const { actions, mapProvider, announce } = makeActions()
106
149
  mapProvider.highlightNextLabel.mockReturnValue('Label A')
107
- create().highlightNextLabel({ key: 'A' })
108
-
150
+ actions.highlightNextLabel({ key: 'A' })
109
151
  expect(announce).toHaveBeenCalledWith('Label A', 'core')
110
152
  })
111
153
 
112
- test('highlightNextLabel no-op when readMapText=false', () => {
113
- readMapText = false
114
- create().highlightNextLabel({ key: 'X' })
115
-
154
+ test('highlightNextLabel is no-op when readMapText is false', () => {
155
+ const { actions, mapProvider, announce } = makeActions({ readMapText: false })
156
+ actions.highlightNextLabel({ key: 'X' })
116
157
  expect(mapProvider.highlightNextLabel).not.toHaveBeenCalled()
117
158
  expect(announce).not.toHaveBeenCalled()
118
159
  })
119
160
 
120
- test('highlightLabelAtCenter announces label', () => {
161
+ test('highlightLabelAtCenter announces returned label', () => {
162
+ const { actions, mapProvider, announce } = makeActions()
121
163
  mapProvider.highlightLabelAtCenter.mockReturnValue('Center Label')
122
- create().highlightLabelAtCenter()
123
-
164
+ actions.highlightLabelAtCenter()
124
165
  expect(announce).toHaveBeenCalledWith('Center Label', 'core')
125
166
  })
126
167
 
127
- test('highlightLabelAtCenter no-op when readMapText=false', () => {
128
- readMapText = false
129
- create().highlightLabelAtCenter()
130
-
168
+ test('highlightLabelAtCenter is no-op when readMapText is false', () => {
169
+ const { actions, mapProvider, announce } = makeActions({ readMapText: false })
170
+ actions.highlightLabelAtCenter()
131
171
  expect(mapProvider.highlightLabelAtCenter).not.toHaveBeenCalled()
132
172
  expect(announce).not.toHaveBeenCalled()
133
173
  })
134
-
135
- test('clearSelection calls correct method', () => {
136
- create().clearSelection()
137
- expect(mapProvider.clearHighlightedLabel).toHaveBeenCalled()
138
- })
139
174
  })
@@ -3,49 +3,37 @@
3
3
  export const coreShortcuts = [
4
4
  {
5
5
  id: 'showKeyboardHelp',
6
- group: 'General',
7
6
  title: 'Show keyboard help',
8
7
  command: '<kbd>Alt</kbd> + <kbd>K</kbd>',
9
- enabled: true
10
- },
11
- {
12
- id: 'selectControl',
13
- group: 'General',
14
- title: 'Select a map control',
15
- command: '<kbd>Tab</kbd> or <kbd>Shift</kbd> + <kbd>Tab</kbd>',
8
+ context: 'global',
16
9
  enabled: true
17
10
  },
18
11
  {
19
12
  id: 'moveLarge',
20
- group: 'Navigation',
21
13
  title: 'Move in large steps',
22
14
  command: '<kbd>←</kbd>, <kbd>↑</kbd>, <kbd>→</kbd> or <kbd>↓</kbd>',
23
15
  enabled: true
24
16
  },
25
17
  {
26
18
  id: 'nudgeMap',
27
- group: 'Navigation',
28
19
  title: 'Nudge map',
29
20
  command: '<kbd>Shift</kbd> + <kbd>←</kbd>, <kbd>↑</kbd>, <kbd>→</kbd> or <kbd>↓</kbd>',
30
21
  enabled: false
31
22
  },
32
23
  {
33
24
  id: 'zoomLarge',
34
- group: 'Navigation',
35
25
  title: 'Zoom in large steps',
36
26
  command: '<kbd>+</kbd> or <kbd>-</kbd>',
37
27
  enabled: true
38
28
  },
39
29
  {
40
30
  id: 'nudgeZoom',
41
- group: 'Navigation',
42
31
  title: 'Nudge zoom',
43
32
  command: '<kbd>Shift</kbd> + <kbd>+</kbd> or <kbd>-</kbd>',
44
33
  enabled: false
45
34
  },
46
35
  {
47
36
  id: 'highlightLabelAtCenter',
48
- group: 'Labels',
49
37
  title: 'Highlight label at centre',
50
38
  command: '<kbd>Alt</kbd> + <kbd>Enter</kbd>',
51
39
  enabled: false,
@@ -53,7 +41,6 @@ export const coreShortcuts = [
53
41
  },
54
42
  {
55
43
  id: 'highlightNextLabel',
56
- group: 'Labels',
57
44
  title: 'Highlight nearby label',
58
45
  command: '<kbd>Alt</kbd> + <kbd>→</kbd>, <kbd>←</kbd>, <kbd>↑</kbd> or <kbd>↓</kbd>',
59
46
  enabled: false,