@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.
- package/dist/css/index.css +1 -1
- package/dist/esm/im-core.js +1 -1
- package/dist/esm/im-shell.js +1 -1
- package/dist/umd/im-core.js +1 -1
- package/dist/umd/index.js +1 -1
- package/docs/api.md +4 -4
- package/docs/plugins/draw.md +1 -1
- package/package.json +1 -1
- package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
- package/plugins/beta/map-styles/dist/umd/index.js +1 -1
- package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
- package/plugins/datasets/dist/esm/im-datasets-ml-adapter.js +1 -1
- package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-esri-adapter.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-ml-adapter.js +1 -1
- package/plugins/datasets/dist/umd/im-datasets-plugin.js +1 -1
- package/plugins/datasets/src/adapters/esri/esriLayerAdapter.js +2 -3
- package/plugins/datasets/src/adapters/esri/registry/esriDataset.js +1 -1
- package/plugins/datasets/src/adapters/loadLayerAdapter.js +1 -1
- package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.js +1 -3
- package/plugins/datasets/src/adapters/maplibre/maplibreLayerAdapter.test.js +3 -3
- package/plugins/datasets/src/initialise/initialiseDatasets.js +0 -1
- package/plugins/datasets/src/registry/dataset.js +5 -4
- package/plugins/datasets/src/registry/datasetRegistry.js +30 -27
- package/plugins/datasets/src/registry/datasetRegistry.test.js +26 -19
- package/plugins/datasets/src/registry/globalDataset.js +0 -3
- package/plugins/draw/dist/esm/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/dist/umd/im-draw-ol-adapter.js +1 -1
- package/plugins/draw/src/DrawInit.jsx +2 -2
- package/plugins/draw/src/adapters/maplibre/MaplibreDrawAdapter.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/drawMode/pointerHandlers.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/keyboardHandlers.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.js +3 -3
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode/vertexOperations.test.js +1 -1
- package/plugins/draw/src/adapters/maplibre/modes/editVertexMode.test.js +1 -1
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.js +1 -0
- package/plugins/draw/src/adapters/openlayers/draw/DrawMode.test.js +15 -1
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.js +17 -3
- package/plugins/draw/src/adapters/openlayers/draw/drawInput.test.js +47 -3
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.js +1 -1
- package/plugins/draw/src/adapters/openlayers/edit/EditMode.test.js +1 -1
- package/plugins/draw/src/adapters/openlayers/edit/keyboardHandler.test.js +1 -1
- package/plugins/draw/src/adapters/openlayers/edit/nudge.js +2 -2
- package/plugins/draw/src/adapters/openlayers/edit/nudge.test.js +2 -2
- package/plugins/draw/src/events.js +4 -4
- package/plugins/draw/src/events.test.js +1 -1
- package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
- package/plugins/interact/dist/umd/index.js +1 -1
- package/plugins/interact/src/hooks/useCrossHairVisibility.js +12 -4
- package/plugins/interact/src/hooks/useCrossHairVisibility.test.js +88 -1
- package/plugins/search/dist/esm/im-search-plugin.js +1 -1
- package/plugins/search/dist/umd/im-search-plugin.js +1 -1
- package/plugins/search/src/Search.jsx +5 -2
- package/sonar-project.properties +7 -1
- package/src/App/components/Attributions/Attributions.module.scss +10 -0
- package/src/App/components/Hints/Hints.jsx +7 -2
- package/src/App/components/KeyboardHelp/KeyboardHelp.jsx +2 -3
- package/src/App/components/KeyboardHelp/KeyboardHelp.test.jsx +3 -6
- package/src/App/components/{MoveControl/MoveControl.jsx → MoveControls/MoveControls.jsx} +10 -10
- package/src/App/components/{MoveControl/MoveControl.module.scss → MoveControls/MoveControls.module.scss} +24 -24
- package/src/App/components/{MoveControl/MoveControl.test.jsx → MoveControls/MoveControls.test.jsx} +48 -48
- package/src/App/components/Viewport/Viewport.jsx +9 -1
- package/src/App/components/Viewport/Viewport.test.jsx +11 -0
- package/src/App/initialiseApp.js +11 -8
- package/src/App/initialiseApp.test.js +15 -3
- package/src/App/layout/Layout.jsx +3 -1
- package/src/App/registry/keyboardShortcutRegistry.js +25 -13
- package/src/App/registry/keyboardShortcutRegistry.test.js +49 -32
- package/src/App/registry/pluginRegistry.js +1 -2
- package/src/App/registry/pluginRegistry.test.js +4 -3
- package/src/App/store/AppProvider.jsx +3 -2
- package/src/InteractiveMap/InteractiveMap.js +8 -2
- package/src/InteractiveMap/InteractiveMap.test.js +20 -3
- package/src/config/appConfig.js +17 -15
- package/src/config/appConfig.test.js +22 -22
- package/src/config/defaults.js +3 -1
- package/src/config/mergeConfig.js +7 -0
- package/src/config/mergeConfig.test.js +20 -0
- package/src/scss/main.scss +1 -1
- package/src/test-utils.js +5 -0
- 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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
8
|
-
const pluginShortcutIds =
|
|
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
|
-
|
|
22
|
-
providerSupportedIds = new Set(ids)
|
|
16
|
+
const _setProviderSupportedShortcuts = (state, ids = []) => {
|
|
17
|
+
state.providerSupportedIds = new Set(ids)
|
|
23
18
|
}
|
|
24
19
|
|
|
25
|
-
|
|
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('
|
|
3
|
-
let
|
|
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
|
-
|
|
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 })
|
|
28
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
expect(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
expect(
|
|
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
|
-
|
|
52
|
+
const registry = createKeyboardShortcutRegistry()
|
|
53
|
+
registry.setProviderSupportedShortcuts(['copy'])
|
|
57
54
|
const pluginShortcut = { id: 'plugin', description: 'Plugin' }
|
|
58
|
-
registerKeyboardShortcut({ shortcut: pluginShortcut })
|
|
59
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
expect(
|
|
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
|
|
81
|
-
|
|
76
|
+
const registry = require('./keyboardShortcutRegistry.js').createKeyboardShortcutRegistry()
|
|
77
|
+
registry.setProviderSupportedShortcuts(['always', 'conditional'])
|
|
82
78
|
|
|
83
79
|
// Without config, conditional shortcut is excluded
|
|
84
|
-
expect(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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).
|
|
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 () => {
|
package/src/config/appConfig.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { KeyboardHelp } from '../App/components/KeyboardHelp/KeyboardHelp.jsx'
|
|
2
|
-
import {
|
|
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
|
|
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.
|
|
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.
|
|
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: '
|
|
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-
|
|
105
|
+
ariaControls: ({ appConfig }) => `${appConfig.id}-move-controls-content`,
|
|
104
106
|
onClick: (_e, { appState }) => appState.dispatch({
|
|
105
107
|
type: 'TOGGLE_BUTTON_EXPANDED',
|
|
106
|
-
payload: { id: '
|
|
108
|
+
payload: { id: 'moveControls', isExpanded: !appState.expandedButtons.has('moveControls') }
|
|
107
109
|
}),
|
|
108
|
-
excludeWhen: ({ appConfig }) => !appConfig.
|
|
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: '
|
|
134
|
+
id: 'moveControls',
|
|
133
135
|
label: 'Move and zoom',
|
|
134
|
-
excludeWhen: ({ appConfig }) => !appConfig.
|
|
135
|
-
mobile:
|
|
136
|
-
tablet:
|
|
137
|
-
desktop:
|
|
138
|
-
render:
|
|
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
|
|
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 (
|
|
147
|
+
// Branch B: Second part of OR is true (enableMoveControls)
|
|
148
148
|
expect(btn.excludeWhen({
|
|
149
|
-
appConfig: { enableZoomControls: 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,
|
|
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,
|
|
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
|
|
186
|
-
it('covers all branches of
|
|
187
|
-
expect(
|
|
188
|
-
expect(
|
|
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('
|
|
192
|
-
expect(
|
|
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('
|
|
195
|
+
it('moveControls onClick toggles TOGGLE_BUTTON_EXPANDED based on current state', () => {
|
|
196
196
|
const dispatch = jest.fn()
|
|
197
197
|
|
|
198
|
-
|
|
198
|
+
moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set() } })
|
|
199
199
|
expect(dispatch).toHaveBeenCalledWith({
|
|
200
200
|
type: 'TOGGLE_BUTTON_EXPANDED',
|
|
201
|
-
payload: { id: '
|
|
201
|
+
payload: { id: 'moveControls', isExpanded: true }
|
|
202
202
|
})
|
|
203
203
|
|
|
204
204
|
dispatch.mockClear()
|
|
205
|
-
|
|
205
|
+
moveControlsBtn.onClick({}, { appState: { dispatch, expandedButtons: new Set(['moveControls']) } })
|
|
206
206
|
expect(dispatch).toHaveBeenCalledWith({
|
|
207
207
|
type: 'TOGGLE_BUTTON_EXPANDED',
|
|
208
|
-
payload: { id: '
|
|
208
|
+
payload: { id: 'moveControls', isExpanded: false }
|
|
209
209
|
})
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
-
// --- MOVE
|
|
213
|
-
it('covers all branches of
|
|
214
|
-
const control = defaultAppConfig.controls.find(c => c.id === '
|
|
215
|
-
expect(control.excludeWhen({ appConfig: {
|
|
216
|
-
expect(control.excludeWhen({ appConfig: {
|
|
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 ---
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|