@defra/interactive-map 0.0.38-alpha → 0.0.40-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 (84) 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.md +4 -4
  7. package/docs/plugins/draw.md +1 -1
  8. package/package.json +1 -1
  9. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  10. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  11. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  12. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  13. package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
  14. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  15. package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
  16. package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
  17. package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
  18. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.js +2 -3
  19. package/plugins/datasets/src/adapters/esri/registry/esriDataset.js +1 -1
  20. package/plugins/datasets/src/adapters/loadLayerAdapter.js +1 -1
  21. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +1 -3
  22. package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +3 -3
  23. package/plugins/datasets/src/initialise/initialiseDatasets.js +0 -1
  24. package/plugins/datasets/src/registry/dataset.js +5 -4
  25. package/plugins/datasets/src/registry/datasetRegistry.js +30 -27
  26. package/plugins/datasets/src/registry/datasetRegistry.test.js +26 -19
  27. package/plugins/datasets/src/registry/globalDataset.js +0 -3
  28. package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
  29. package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
  30. package/plugins/draw/src/DrawInit.jsx +2 -2
  31. package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +1 -1
  32. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +1 -1
  33. package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +1 -1
  34. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +1 -1
  35. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +3 -3
  36. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +1 -1
  37. package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +1 -1
  38. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +1 -0
  39. package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +15 -1
  40. package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +17 -3
  41. package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +47 -3
  42. package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +1 -1
  43. package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +1 -1
  44. package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +1 -1
  45. package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
  46. package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
  47. package/plugins/draw/src/events.js +4 -4
  48. package/plugins/draw/src/events.test.js +1 -1
  49. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  50. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  51. package/plugins/interact/dist/umd/index.js +1 -1
  52. package/plugins/interact/src/hooks/useCrossHairVisibility.js +12 -4
  53. package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +88 -1
  54. package/plugins/search/dist/esm/im-search-plugin.js +1 -1
  55. package/plugins/search/dist/umd/im-search-plugin.js +1 -1
  56. package/plugins/search/src/Search.jsx +5 -2
  57. package/sonar-project.properties +7 -1
  58. package/src/App/components/Attributions/Attributions.module.scss +10 -0
  59. package/src/App/components/Hints/Hints.jsx +7 -2
  60. package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +2 -3
  61. package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +3 -6
  62. package/src/App/components/{MoveControl/MoveControl.jsx → MoveControls/MoveControls.jsx} +10 -10
  63. package/src/App/components/{MoveControl/MoveControl.module.scss → MoveControls/MoveControls.module.scss} +24 -24
  64. package/src/App/components/{MoveControl/MoveControl.test.jsx → MoveControls/MoveControls.test.jsx} +48 -48
  65. package/src/App/components/Viewport/Viewport.jsx +9 -1
  66. package/src/App/components/Viewport/Viewport.test.jsx +11 -0
  67. package/src/App/initialiseApp.js +11 -8
  68. package/src/App/initialiseApp.test.js +15 -3
  69. package/src/App/layout/Layout.jsx +3 -1
  70. package/src/App/registry/keyboardShortcutRegistry.js +25 -13
  71. package/src/App/registry/keyboardShortcutRegistry.test.js +49 -32
  72. package/src/App/registry/pluginRegistry.js +1 -2
  73. package/src/App/registry/pluginRegistry.test.js +4 -3
  74. package/src/App/store/AppProvider.jsx +3 -2
  75. package/src/InteractiveMap/InteractiveMap.js +8 -2
  76. package/src/InteractiveMap/InteractiveMap.test.js +20 -3
  77. package/src/config/appConfig.js +17 -15
  78. package/src/config/appConfig.test.js +22 -22
  79. package/src/config/defaults.js +3 -1
  80. package/src/config/mergeConfig.js +7 -0
  81. package/src/config/mergeConfig.test.js +20 -0
  82. package/src/scss/main.scss +1 -1
  83. package/src/test-utils.js +5 -0
  84. package/src/types.js +14 -6
@@ -3,7 +3,6 @@ import { initialiseApp } from './initialiseApp.js'
3
3
  import { createRoot } from 'react-dom/client'
4
4
  import { EVENTS as events } from '../config/events.js'
5
5
  import { createMockRegistries } from '../test-utils.js'
6
- import { setProviderSupportedShortcuts } from './registry/keyboardShortcutRegistry.js'
7
6
 
8
7
  // --------------------------------------------------
9
8
  // Shared mocks
@@ -48,7 +47,7 @@ jest.mock('./registry/pluginRegistry.js', () => ({
48
47
  }))
49
48
 
50
49
  jest.mock('./registry/keyboardShortcutRegistry.js', () => ({
51
- setProviderSupportedShortcuts: jest.fn()
50
+ createKeyboardShortcutRegistry: jest.fn(() => mockRegistries.keyboardShortcutRegistry)
52
51
  }))
53
52
 
54
53
  jest.mock('./registry/mergeManifests.js', () => ({
@@ -179,6 +178,19 @@ describe('initialiseApp', () => {
179
178
 
180
179
  await initApp()
181
180
 
182
- expect(setProviderSupportedShortcuts).not.toHaveBeenCalled()
181
+ expect(mockRegistries.keyboardShortcutRegistry.setProviderSupportedShortcuts).not.toHaveBeenCalled()
182
+ })
183
+
184
+ test('registers provider-supported shortcuts on the per-instance registry', async () => {
185
+ await initApp()
186
+
187
+ expect(mockRegistries.keyboardShortcutRegistry.setProviderSupportedShortcuts).toHaveBeenCalledWith(['ctrl+a'])
188
+ })
189
+
190
+ test('passes keyboardShortcutRegistry through to the rendered App', async () => {
191
+ await initApp()
192
+
193
+ const rendered = createRoot.mock.results[0].value.render.mock.calls[0][0]
194
+ expect(rendered.props.keyboardShortcutRegistry).toBe(mockRegistries.keyboardShortcutRegistry)
183
195
  })
184
196
  })
@@ -16,7 +16,7 @@ import { getMapThemeVars } from '../../config/mapTheme.js'
16
16
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
17
17
  // sonarjs/disable-next-line function-name
18
18
  export const Layout = () => {
19
- const { id } = useConfig()
19
+ const { id, mapLabel, mapHintText } = useConfig()
20
20
  const { breakpoint, interfaceType, preferredColorScheme, layoutRefs, isLayoutReady, hasExclusiveControl, isFullscreen } = useApp()
21
21
  const { mapStyle } = useMap()
22
22
 
@@ -37,6 +37,8 @@ export const Layout = () => {
37
37
  style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
38
38
  ref={layoutRefs.appContainerRef}
39
39
  >
40
+ {/* Leads with mapLabel so screen reader users can tell multiple maps on a page apart. */}
41
+ <div className='im-u-visually-hidden'>{mapLabel}. {mapHintText}</div>
40
42
  <Viewport />
41
43
  <div className={`im-o-app__overlay${isLayoutReady ? '' : ' im-o-app__overlay--not-ready'}`}>
42
44
  <div className='im-o-app__side' ref={layoutRefs.sideRef}>
@@ -1,15 +1,10 @@
1
1
  // src/core/registry/keyboardShortcutRegistry.js
2
2
  import { coreShortcuts } from '../controls/keyboardShortcuts.js'
3
3
 
4
- // Stores the actual shortcut objects in insertion order
5
- const pluginShortcutHelp = []
4
+ // State is passed in explicitly, rather than held at module scope, so each map instance gets its own registry and can't leak shortcuts or provider support into another map's help panel.
6
5
 
7
- // Tracks only IDs for O(1) duplicate detection
8
- const pluginShortcutIds = new Set()
9
-
10
- let providerSupportedIds = new Set()
11
-
12
- export const registerKeyboardShortcut = ({ shortcut }) => {
6
+ const _registerKeyboardShortcut = (state, { shortcut }) => {
7
+ const { pluginShortcutHelp, pluginShortcutIds } = state
13
8
  if (pluginShortcutIds.has(shortcut.id)) {
14
9
  pluginShortcutHelp[pluginShortcutHelp.findIndex(s => s.id === shortcut.id)] = shortcut
15
10
  } else {
@@ -18,14 +13,14 @@ export const registerKeyboardShortcut = ({ shortcut }) => {
18
13
  }
19
14
  }
20
15
 
21
- export const setProviderSupportedShortcuts = (ids = []) => {
22
- providerSupportedIds = new Set(ids)
16
+ const _setProviderSupportedShortcuts = (state, ids = []) => {
17
+ state.providerSupportedIds = new Set(ids)
23
18
  }
24
19
 
25
- export const getKeyboardShortcuts = (appConfig = {}) => {
20
+ const _getKeyboardShortcuts = (state, appConfig = {}) => {
26
21
  const filteredCore = coreShortcuts.filter(s => {
27
22
  // Must be supported by provider
28
- if (!providerSupportedIds.has(s.id)) {
23
+ if (!state.providerSupportedIds.has(s.id)) {
29
24
  return false
30
25
  }
31
26
  // Check requiredConfig - all specified config values must be truthy
@@ -37,6 +32,23 @@ export const getKeyboardShortcuts = (appConfig = {}) => {
37
32
 
38
33
  return [
39
34
  ...filteredCore, // supported core shortcuts
40
- ...pluginShortcutHelp // plugin-defined shortcuts (deduped)
35
+ ...state.pluginShortcutHelp // plugin-defined shortcuts (deduped)
41
36
  ]
42
37
  }
38
+
39
+ // One instance per map — see initialiseApp.js's getOrCreateRegistries.
40
+ export function createKeyboardShortcutRegistry () {
41
+ const state = {
42
+ // Stores the actual shortcut objects in insertion order
43
+ pluginShortcutHelp: [],
44
+ // Tracks only IDs for O(1) duplicate detection
45
+ pluginShortcutIds: new Set(),
46
+ providerSupportedIds: new Set()
47
+ }
48
+
49
+ return {
50
+ registerKeyboardShortcut: (args) => _registerKeyboardShortcut(state, args),
51
+ setProviderSupportedShortcuts: (ids) => _setProviderSupportedShortcuts(state, ids),
52
+ getKeyboardShortcuts: (appConfig) => _getKeyboardShortcuts(state, appConfig)
53
+ }
54
+ }
@@ -1,8 +1,6 @@
1
1
  // src/core/registry/keyboardShortcutRegistry.test.js
2
- describe('keyboardShortcutRegistry', () => {
3
- let registerKeyboardShortcut
4
- let setProviderSupportedShortcuts
5
- let getKeyboardShortcuts
2
+ describe('createKeyboardShortcutRegistry', () => {
3
+ let createKeyboardShortcutRegistry
6
4
  let coreShortcutsMock
7
5
 
8
6
  beforeEach(() => {
@@ -16,57 +14,55 @@ describe('keyboardShortcutRegistry', () => {
16
14
  coreShortcuts: coreShortcutsMock
17
15
  }))
18
16
 
19
- const module = require('./keyboardShortcutRegistry.js')
20
- registerKeyboardShortcut = module.registerKeyboardShortcut
21
- setProviderSupportedShortcuts = module.setProviderSupportedShortcuts
22
- getKeyboardShortcuts = module.getKeyboardShortcuts
17
+ createKeyboardShortcutRegistry = require('./keyboardShortcutRegistry.js').createKeyboardShortcutRegistry
23
18
  })
24
19
 
25
20
  test('registerKeyboardShortcut should add a plugin shortcut', () => {
21
+ const registry = createKeyboardShortcutRegistry()
26
22
  const shortcut = { id: 'pluginShortcut', description: 'Plugin Shortcut' }
27
- registerKeyboardShortcut({ shortcut }) // Pass { shortcut }
28
- const shortcuts = getKeyboardShortcuts()
29
- expect(shortcuts).toContain(shortcut)
23
+ registry.registerKeyboardShortcut({ shortcut })
24
+ expect(registry.getKeyboardShortcuts()).toContain(shortcut)
30
25
  })
31
26
 
32
27
  test('registerKeyboardShortcut updates an existing shortcut when re-registered with the same id', () => {
28
+ const registry = createKeyboardShortcutRegistry()
33
29
  const shortcut = { id: 'duplicate', description: 'First' }
34
30
  const updatedShortcut = { id: 'duplicate', description: 'Second' }
35
- registerKeyboardShortcut({ shortcut })
36
- registerKeyboardShortcut({ shortcut: updatedShortcut })
37
- const shortcuts = getKeyboardShortcuts()
31
+ registry.registerKeyboardShortcut({ shortcut })
32
+ registry.registerKeyboardShortcut({ shortcut: updatedShortcut })
33
+ const shortcuts = registry.getKeyboardShortcuts()
38
34
  expect(shortcuts).toContain(updatedShortcut)
39
35
  expect(shortcuts).not.toContain(shortcut)
40
36
  expect(shortcuts.filter(s => s.id === 'duplicate')).toHaveLength(1)
41
37
  })
42
38
 
43
39
  test('setProviderSupportedShortcuts should filter core shortcuts', () => {
44
- setProviderSupportedShortcuts(['copy'])
45
- const shortcuts = getKeyboardShortcuts()
46
- expect(shortcuts).toEqual([{ id: 'copy', description: 'Copy' }])
40
+ const registry = createKeyboardShortcutRegistry()
41
+ registry.setProviderSupportedShortcuts(['copy'])
42
+ expect(registry.getKeyboardShortcuts()).toEqual([{ id: 'copy', description: 'Copy' }])
47
43
  })
48
44
 
49
45
  test('setProviderSupportedShortcuts with no argument defaults to empty set', () => {
50
- setProviderSupportedShortcuts() // no arguments
51
- const shortcuts = getKeyboardShortcuts()
52
- expect(shortcuts).toEqual([]) // default empty
46
+ const registry = createKeyboardShortcutRegistry()
47
+ registry.setProviderSupportedShortcuts() // no ids argument
48
+ expect(registry.getKeyboardShortcuts()).toEqual([]) // default empty
53
49
  })
54
50
 
55
51
  test('getKeyboardShortcuts should merge core and plugin shortcuts', () => {
56
- setProviderSupportedShortcuts(['copy'])
52
+ const registry = createKeyboardShortcutRegistry()
53
+ registry.setProviderSupportedShortcuts(['copy'])
57
54
  const pluginShortcut = { id: 'plugin', description: 'Plugin' }
58
- registerKeyboardShortcut({ shortcut: pluginShortcut })
59
- const shortcuts = getKeyboardShortcuts()
60
- expect(shortcuts).toEqual([
55
+ registry.registerKeyboardShortcut({ shortcut: pluginShortcut })
56
+ expect(registry.getKeyboardShortcuts()).toEqual([
61
57
  { id: 'copy', description: 'Copy' },
62
58
  pluginShortcut
63
59
  ])
64
60
  })
65
61
 
66
62
  test('setProviderSupportedShortcuts with empty array returns no core shortcuts', () => {
67
- setProviderSupportedShortcuts([])
68
- const shortcuts = getKeyboardShortcuts()
69
- expect(shortcuts).toEqual([])
63
+ const registry = createKeyboardShortcutRegistry()
64
+ registry.setProviderSupportedShortcuts([])
65
+ expect(registry.getKeyboardShortcuts()).toEqual([])
70
66
  })
71
67
 
72
68
  test('getKeyboardShortcuts filters by requiredConfig when appConfig provided', () => {
@@ -77,23 +73,44 @@ describe('keyboardShortcutRegistry', () => {
77
73
  { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
78
74
  ]
79
75
  }))
80
- const module = require('./keyboardShortcutRegistry.js')
81
- module.setProviderSupportedShortcuts(['always', 'conditional'])
76
+ const registry = require('./keyboardShortcutRegistry.js').createKeyboardShortcutRegistry()
77
+ registry.setProviderSupportedShortcuts(['always', 'conditional'])
82
78
 
83
79
  // Without config, conditional shortcut is excluded
84
- expect(module.getKeyboardShortcuts()).toEqual([
80
+ expect(registry.getKeyboardShortcuts()).toEqual([
85
81
  { id: 'always', description: 'Always shown' }
86
82
  ])
87
83
 
88
84
  // With config false, conditional shortcut is excluded
89
- expect(module.getKeyboardShortcuts({ featureEnabled: false })).toEqual([
85
+ expect(registry.getKeyboardShortcuts({ featureEnabled: false })).toEqual([
90
86
  { id: 'always', description: 'Always shown' }
91
87
  ])
92
88
 
93
89
  // With config true, conditional shortcut is included
94
- expect(module.getKeyboardShortcuts({ featureEnabled: true })).toEqual([
90
+ expect(registry.getKeyboardShortcuts({ featureEnabled: true })).toEqual([
95
91
  { id: 'always', description: 'Always shown' },
96
92
  { id: 'conditional', description: 'Conditional', requiredConfig: ['featureEnabled'] }
97
93
  ])
98
94
  })
95
+
96
+ test('two registry instances are fully isolated from each other', () => {
97
+ // This is the multi-map-on-a-page scenario: a shortcut registered by one map
98
+ // (e.g. a plugin) must never leak into another map's help panel, and each map's
99
+ // provider-supported-shortcuts filtering must stay independent.
100
+ const registryA = createKeyboardShortcutRegistry()
101
+ const registryB = createKeyboardShortcutRegistry()
102
+
103
+ registryA.setProviderSupportedShortcuts(['copy'])
104
+ registryA.registerKeyboardShortcut({ shortcut: { id: 'onlyOnA', description: 'Only on A' } })
105
+
106
+ registryB.setProviderSupportedShortcuts(['paste'])
107
+
108
+ expect(registryA.getKeyboardShortcuts()).toEqual([
109
+ { id: 'copy', description: 'Copy' },
110
+ { id: 'onlyOnA', description: 'Only on A' }
111
+ ])
112
+ expect(registryB.getKeyboardShortcuts()).toEqual([
113
+ { id: 'paste', description: 'Paste' }
114
+ ])
115
+ })
99
116
  })
@@ -1,6 +1,5 @@
1
1
  // src/core/registry/pluginRegistry.js
2
2
  import { registerIcon } from './iconRegistry.js'
3
- import { registerKeyboardShortcut } from './keyboardShortcutRegistry.js'
4
3
  import { allowedSlots } from '../renderer/slots.js'
5
4
  import { logger } from '../../services/logger.js'
6
5
 
@@ -18,7 +17,7 @@ function validateSlots (item, type) {
18
17
  })
19
18
  }
20
19
 
21
- export function createPluginRegistry ({ registerButton, registerPanel, registerControl }) {
20
+ export function createPluginRegistry ({ registerButton, registerPanel, registerControl, registerKeyboardShortcut }) {
22
21
  const registeredPlugins = []
23
22
 
24
23
  function registerPlugin (plugin) {
@@ -5,7 +5,6 @@ jest.mock('./buttonRegistry.js', () => ({ registerButton: jest.fn() }))
5
5
  jest.mock('./panelRegistry.js', () => ({ registerPanel: jest.fn() }))
6
6
  jest.mock('./controlRegistry.js', () => ({ registerControl: jest.fn() }))
7
7
  jest.mock('./iconRegistry.js', () => ({ registerIcon: jest.fn() }))
8
- jest.mock('./keyboardShortcutRegistry.js', () => ({ registerKeyboardShortcut: jest.fn() }))
9
8
 
10
9
  describe('pluginRegistry', () => {
11
10
  let pluginRegistry
@@ -18,9 +17,11 @@ describe('pluginRegistry', () => {
18
17
  registerPanel = require('./panelRegistry.js').registerPanel
19
18
  registerControl = require('./controlRegistry.js').registerControl
20
19
  registerIcon = require('./iconRegistry.js').registerIcon
21
- registerKeyboardShortcut = require('./keyboardShortcutRegistry.js').registerKeyboardShortcut
20
+ // registerKeyboardShortcut is injected (per-instance registry), not imported from a module —
21
+ // see keyboardShortcutRegistry.js's createKeyboardShortcutRegistry.
22
+ registerKeyboardShortcut = jest.fn()
22
23
 
23
- pluginRegistry = createPluginRegistry({ registerButton, registerPanel, registerControl })
24
+ pluginRegistry = createPluginRegistry({ registerButton, registerPanel, registerControl, registerKeyboardShortcut })
24
25
  })
25
26
 
26
27
  it('registers plugin and pushes to registeredPlugins', () => {
@@ -10,7 +10,7 @@ import { useMediaQueryDispatch } from '../hooks/useMediaQueryDispatch.js'
10
10
  export const AppContext = createContext(null)
11
11
 
12
12
  export const AppProvider = ({ options, children }) => {
13
- const { pluginRegistry, buttonRegistry, panelRegistry, controlRegistry, eventBus, breakpointDetector } = options
13
+ const { pluginRegistry, buttonRegistry, panelRegistry, controlRegistry, keyboardShortcutRegistry, eventBus, breakpointDetector } = options
14
14
 
15
15
  const layoutRefs = {
16
16
  appContainerRef: useRef(null),
@@ -94,7 +94,8 @@ export const AppProvider = ({ options, children }) => {
94
94
  pluginRegistry,
95
95
  buttonRegistry,
96
96
  panelRegistry,
97
- controlRegistry
97
+ controlRegistry,
98
+ keyboardShortcutRegistry
98
99
  }), [state, dispatch])
99
100
 
100
101
  return (
@@ -111,13 +111,16 @@ export default class InteractiveMap {
111
111
  history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
112
112
  }
113
113
  this.showApp()
114
+ // App is already mounted, so focus the viewport directly rather than via a mount effect.
115
+ this.rootEl.querySelector('[role="application"]')?.focus()
114
116
  } else if (this._root) {
115
117
  // app already open — no-op, no push
116
118
  } else {
117
119
  if (this.config.manageHistoryState) {
118
120
  history.pushState({ isBack: true }, '', e.currentTarget.getAttribute('href'))
119
121
  }
120
- this.loadApp()
122
+ // Only a genuine launcher click moves focus into the map — never a resize/breakpoint transition.
123
+ this.loadApp({ focusOnMount: true })
121
124
  }
122
125
  }
123
126
 
@@ -175,9 +178,11 @@ export default class InteractiveMap {
175
178
  * Load and initialize the map application.
176
179
  *
177
180
  * @internal Not intended for end-user use.
181
+ * @param {Object} [options]
182
+ * @param {boolean} [options.focusOnMount=false] - Focus the viewport once mounted; only set by a genuine launcher click.
178
183
  * @returns {Promise<void>}
179
184
  */
180
- async loadApp () {
185
+ async loadApp ({ focusOnMount = false } = {}) {
181
186
  if (this._root || this._isLoading) {
182
187
  return
183
188
  }
@@ -204,6 +209,7 @@ export default class InteractiveMap {
204
209
  id: this.id,
205
210
  initialBreakpoint: this._breakpointDetector.getBreakpoint(),
206
211
  initialInterfaceType: getInterfaceType(),
212
+ focusOnMount,
207
213
  ...this.config,
208
214
  MapProvider,
209
215
  mapProviderConfig,
@@ -185,6 +185,18 @@ describe('InteractiveMap — loadApp', () => {
185
185
  expect(typeof map.someMethod).toBe('function')
186
186
  expect(map.eventBus.emit).toHaveBeenCalledWith('app:opened', { statePreserved: false, isFullscreen: false })
187
187
  })
188
+
189
+ it('defaults focusOnMount to false when not passed', async () => {
190
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
191
+ await map.loadApp()
192
+ expect(initialiseApp).toHaveBeenCalledWith(rootEl, expect.objectContaining({ focusOnMount: false }))
193
+ })
194
+
195
+ it('passes focusOnMount through to initialiseApp when set', async () => {
196
+ const map = new InteractiveMap('map', { behaviour: 'buttonFirst', mapProvider: mapProviderMock })
197
+ await map.loadApp({ focusOnMount: true })
198
+ expect(initialiseApp).toHaveBeenCalledWith(rootEl, expect.objectContaining({ focusOnMount: true }))
199
+ })
188
200
  })
189
201
 
190
202
  // --- _handleButtonClick ---
@@ -193,25 +205,30 @@ describe('InteractiveMap — _handleButtonClick', () => {
193
205
  beforeEach(setupBeforeEach)
194
206
  afterEach(() => jest.restoreAllMocks())
195
207
 
196
- it('pushState and loadApp on first open', async () => {
208
+ it('pushState and loadApp (with focusOnMount) on first open', async () => {
197
209
  const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
198
210
  jest.spyOn(map, 'loadApp').mockResolvedValue()
199
211
  jest.spyOn(history, 'pushState').mockImplementation(() => {})
200
212
  await openButtonCallback({ currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } })
201
- expect(map.loadApp).toHaveBeenCalled()
213
+ expect(map.loadApp).toHaveBeenCalledWith({ focusOnMount: true })
202
214
  expect(history.pushState).toHaveBeenCalledWith({ isBack: true }, '', '/?mv=map')
203
215
  })
204
216
 
205
- it('calls showApp (not loadApp) when map is hidden', async () => {
217
+ it('calls showApp (not loadApp) and focuses the viewport when map is hidden', async () => {
206
218
  const map = new InteractiveMap('map', { behaviour: 'buttonFirst', manageHistoryState: true, mapProvider: mapProviderMock })
207
219
  map._isHidden = true
208
220
  jest.spyOn(map, 'showApp').mockImplementation(() => {})
209
221
  jest.spyOn(map, 'loadApp').mockResolvedValue()
210
222
  jest.spyOn(history, 'pushState').mockImplementation(() => {})
223
+ const viewport = document.createElement('div')
224
+ viewport.setAttribute('role', 'application')
225
+ viewport.setAttribute('tabindex', '0')
226
+ rootEl.appendChild(viewport)
211
227
  await openButtonCallback({ currentTarget: { getAttribute: jest.fn().mockReturnValue('/?mv=map') } })
212
228
  expect(map.showApp).toHaveBeenCalled()
213
229
  expect(map.loadApp).not.toHaveBeenCalled()
214
230
  expect(history.pushState).toHaveBeenCalled()
231
+ expect(viewport).toHaveFocus()
215
232
  })
216
233
 
217
234
  it('skips pushState when manageHistoryState is false', async () => {
@@ -1,5 +1,5 @@
1
1
  import { KeyboardHelp } from '../App/components/KeyboardHelp/KeyboardHelp.jsx'
2
- import { MoveControl } from '../App/components/MoveControl/MoveControl.jsx'
2
+ import { MoveControls } from '../App/components/MoveControls/MoveControls.jsx'
3
3
 
4
4
  const keyboardBasePanelSlots = {
5
5
  slot: 'middle',
@@ -13,13 +13,15 @@ const buttonSlots = {
13
13
  showLabel: false
14
14
  }
15
15
 
16
- const moveControlSlot = {
16
+ const moveControlsSlot = {
17
17
  slot: 'right-bottom'
18
18
  }
19
19
 
20
20
  const exitButtonSlots = {
21
21
  slot: 'top-left',
22
- showLabel: false
22
+ showLabel: false,
23
+ // Explicit order so exit always sorts first in its slot.
24
+ order: 1
23
25
  }
24
26
 
25
27
  const journeyBackSlots = { slot: 'actions', showLabel: true }
@@ -77,7 +79,7 @@ export const defaultAppConfig = {
77
79
  iconId: 'plus',
78
80
  keepFocus: true,
79
81
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomIn(appConfig.zoomDelta),
80
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControl || appState.interfaceType === 'touch',
82
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
81
83
  enableWhen: ({ mapState }) => !mapState.isAtMaxZoom,
82
84
  mobile: buttonSlots,
83
85
  tablet: buttonSlots,
@@ -89,23 +91,23 @@ export const defaultAppConfig = {
89
91
  iconId: 'minus',
90
92
  keepFocus: true,
91
93
  onClick: (_e, { mapProvider, appConfig }) => mapProvider.zoomOut(appConfig.zoomDelta),
92
- excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControl || appState.interfaceType === 'touch',
94
+ excludeWhen: ({ appState, appConfig }) => !appConfig.enableZoomControls || appConfig.enableMoveControls || appState.interfaceType === 'touch',
93
95
  enableWhen: ({ mapState }) => !mapState.isAtMinZoom,
94
96
  mobile: buttonSlots,
95
97
  tablet: buttonSlots,
96
98
  desktop: buttonSlots
97
99
  }, {
98
- id: 'moveControl',
100
+ id: 'moveControls',
99
101
  label: 'Move and zoom controls',
100
102
  iconId: 'move',
101
103
  keepFocus: true,
102
104
  isExpanded: false,
103
- ariaControls: ({ appConfig }) => `${appConfig.id}-move-control-content`,
105
+ ariaControls: ({ appConfig }) => `${appConfig.id}-move-controls-content`,
104
106
  onClick: (_e, { appState }) => appState.dispatch({
105
107
  type: 'TOGGLE_BUTTON_EXPANDED',
106
- payload: { id: 'moveControl', isExpanded: !appState.expandedButtons.has('moveControl') }
108
+ payload: { id: 'moveControls', isExpanded: !appState.expandedButtons.has('moveControls') }
107
109
  }),
108
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControl,
110
+ excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
109
111
  mobile: buttonSlots,
110
112
  tablet: buttonSlots,
111
113
  desktop: buttonSlots
@@ -129,13 +131,13 @@ export const defaultAppConfig = {
129
131
  }],
130
132
 
131
133
  controls: [{
132
- id: 'moveControl',
134
+ id: 'moveControls',
133
135
  label: 'Move and zoom',
134
- excludeWhen: ({ appConfig }) => !appConfig.enableMoveControl,
135
- mobile: moveControlSlot,
136
- tablet: moveControlSlot,
137
- desktop: moveControlSlot,
138
- render: MoveControl
136
+ excludeWhen: ({ appConfig }) => !appConfig.enableMoveControls,
137
+ mobile: moveControlsSlot,
138
+ tablet: moveControlsSlot,
139
+ desktop: moveControlsSlot,
140
+ render: MoveControls
139
141
  }],
140
142
 
141
143
  icons: [{
@@ -2,7 +2,7 @@ import { render } from '@testing-library/react'
2
2
  import { defaultAppConfig, defaultButtonConfig, scaleFactor } from './appConfig'
3
3
 
4
4
  jest.mock('../App/store/appContext.js', () => ({
5
- useApp: () => ({ listboxIsActive: false })
5
+ useApp: () => ({ listboxIsActive: false, keyboardShortcutRegistry: { getKeyboardShortcuts: () => [] } })
6
6
  }))
7
7
 
8
8
  describe('defaultAppConfig', () => {
@@ -19,7 +19,7 @@ describe('defaultAppConfig', () => {
19
19
  const journeyContinueBtn = buttons.find(b => b.id === 'journeyContinue')
20
20
  const zoomInBtn = buttons.find(b => b.id === 'zoomIn')
21
21
  const zoomOutBtn = buttons.find(b => b.id === 'zoomOut')
22
- const moveControlBtn = buttons.find(b => b.id === 'moveControl')
22
+ const moveControlsBtn = buttons.find(b => b.id === 'moveControls')
23
23
 
24
24
  // --- UI RENDER TESTS ---
25
25
  it('renders KeyboardHelp panel', () => {
@@ -144,21 +144,21 @@ describe('defaultAppConfig', () => {
144
144
  appState: { interfaceType: 'mouse' }
145
145
  })).toBe(true)
146
146
 
147
- // Branch B: Second part of OR is true (enableMoveControl)
147
+ // Branch B: Second part of OR is true (enableMoveControls)
148
148
  expect(btn.excludeWhen({
149
- appConfig: { enableZoomControls: true, enableMoveControl: true },
149
+ appConfig: { enableZoomControls: true, enableMoveControls: true },
150
150
  appState: { interfaceType: 'mouse' }
151
151
  })).toBe(true)
152
152
 
153
153
  // Branch C: Third part of OR is true (interfaceType === 'touch')
154
154
  expect(btn.excludeWhen({
155
- appConfig: { enableZoomControls: true, enableMoveControl: false },
155
+ appConfig: { enableZoomControls: true, enableMoveControls: false },
156
156
  appState: { interfaceType: 'touch' }
157
157
  })).toBe(true)
158
158
 
159
159
  // Branch D: All parts are false (Result: false)
160
160
  expect(btn.excludeWhen({
161
- appConfig: { enableZoomControls: true, enableMoveControl: false },
161
+ appConfig: { enableZoomControls: true, enableMoveControls: false },
162
162
  appState: { interfaceType: 'mouse' }
163
163
  })).toBe(false)
164
164
  })
@@ -182,38 +182,38 @@ describe('defaultAppConfig', () => {
182
182
  expect(mapProviderMock.zoomOut).toHaveBeenCalledWith(2)
183
183
  })
184
184
 
185
- // --- MOVE CONTROL TOGGLE BUTTON ---
186
- it('covers all branches of moveControl excludeWhen', () => {
187
- expect(moveControlBtn.excludeWhen({ appConfig: { enableMoveControl: false } })).toBe(true)
188
- expect(moveControlBtn.excludeWhen({ appConfig: { enableMoveControl: true } })).toBe(false)
185
+ // --- MOVE CONTROLS TOGGLE BUTTON ---
186
+ it('covers all branches of moveControls excludeWhen', () => {
187
+ expect(moveControlsBtn.excludeWhen({ appConfig: { enableMoveControls: false } })).toBe(true)
188
+ expect(moveControlsBtn.excludeWhen({ appConfig: { enableMoveControls: true } })).toBe(false)
189
189
  })
190
190
 
191
- it('moveControl ariaControls resolves the control element id', () => {
192
- expect(moveControlBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-move-control-content')
191
+ it('moveControls ariaControls resolves the control element id', () => {
192
+ expect(moveControlsBtn.ariaControls({ appConfig: { id: 'im' } })).toBe('im-move-controls-content')
193
193
  })
194
194
 
195
- it('moveControl onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
195
+ it('moveControls onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
196
196
  const dispatch = jest.fn()
197
197
 
198
- moveControlBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
198
+ moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
199
199
  expect(dispatch).toHaveBeenCalledWith({
200
200
  type: 'TOGGLE_BUTTON_EXPANDED',
201
- payload: { id: 'moveControl', isExpanded: true }
201
+ payload: { id: 'moveControls', isExpanded: true }
202
202
  })
203
203
 
204
204
  dispatch.mockClear()
205
- moveControlBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['moveControl']) } })
205
+ moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['moveControls']) } })
206
206
  expect(dispatch).toHaveBeenCalledWith({
207
207
  type: 'TOGGLE_BUTTON_EXPANDED',
208
- payload: { id: 'moveControl', isExpanded: false }
208
+ payload: { id: 'moveControls', isExpanded: false }
209
209
  })
210
210
  })
211
211
 
212
- // --- MOVE CONTROL ---
213
- it('covers all branches of moveControl control excludeWhen', () => {
214
- const control = defaultAppConfig.controls.find(c => c.id === 'moveControl')
215
- expect(control.excludeWhen({ appConfig: { enableMoveControl: false } })).toBe(true)
216
- expect(control.excludeWhen({ appConfig: { enableMoveControl: true } })).toBe(false)
212
+ // --- MOVE CONTROLS ---
213
+ it('covers all branches of moveControls control excludeWhen', () => {
214
+ const control = defaultAppConfig.controls.find(c => c.id === 'moveControls')
215
+ expect(control.excludeWhen({ appConfig: { enableMoveControls: false } })).toBe(true)
216
+ expect(control.excludeWhen({ appConfig: { enableMoveControls: true } })).toBe(false)
217
217
  })
218
218
 
219
219
  // --- SUPPLEMENTARY CONFIGS ---
@@ -16,13 +16,14 @@ const defaults = {
16
16
  containerHeight: '600px',
17
17
  deviceNotSupportedText: 'Your device is not supported. A map is available with a more up-to-date browser or device.',
18
18
  enableFullscreen: false,
19
- enableMoveControl: true,
19
+ enableMoveControls: true,
20
20
  enableZoomControls: true,
21
21
  genericErrorText: 'There was a problem loading the map. Please try again later.',
22
22
  hasExitButton: false,
23
23
  backAndContinue: null,
24
24
  hybridWidth: null, // Defaults to maxMobileWidth if not set
25
25
  keyboardHintText: '<span class="im-u-visually-hidden">Press </span><kbd>Shift</kbd> + <span class="im-u-visually-hidden">Question mark</span><kbd aria-hidden="true">?</kbd> <span class="im-u-visually-hidden">to view </span>keyboard shortcuts',
26
+ mapHintText: 'Press Tab to focus it. Keyboard commands are only available once it has focus.', // Visually hidden text for assitive technology
26
27
  mapLabel: 'Interactive map application',
27
28
  mapProvider: null,
28
29
  mapSize: 'small',
@@ -31,6 +32,7 @@ const defaults = {
31
32
  urlPosition: 'sync',
32
33
  maxMobileWidth: 640,
33
34
  minDesktopWidth: 835,
35
+ moveControlsHintText: 'A "Move and zoom controls" button is also available, opening further controls for moving and zooming.',
34
36
  nudgePanDelta: 5,
35
37
  nudgeZoomDelta: 0.1,
36
38
  panDelta: 100,
@@ -13,5 +13,12 @@ export const mergeConfig = (userConfig = {}) => {
13
13
  config.mapViewQueryParam = mapViewParamKey
14
14
  }
15
15
 
16
+ // mapOnly has no launcher button or history entry to exit back to, so an exit
17
+ // button never makes sense there regardless of hasExitButton.
18
+ if (config.behaviour === 'mapOnly' && config.hasExitButton) {
19
+ console.warn('[InteractiveMap] hasExitButton has no effect when behaviour is \'mapOnly\' — there is nothing to exit to.')
20
+ config.hasExitButton = false
21
+ }
22
+
16
23
  return config
17
24
  }