@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
@@ -18,13 +18,13 @@ const ZOOM_ACTIONS = [
18
18
  { id: 'nudgeZoomOut', label: 'Zoom out', announceLabel: 'Zoomed out', method: 'zoomOut' }
19
19
  ]
20
20
 
21
- export const MoveControl = () => {
21
+ export const MoveControls = () => {
22
22
  const { id: appId, mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta } = useConfig()
23
23
  const { dispatch, expandedButtons, nudgeStepSize, interfaceType, layoutRefs } = useApp()
24
24
  const { isAtMaxZoom, isAtMinZoom } = useMap()
25
25
  const { announce } = useService()
26
26
 
27
- const isOpen = expandedButtons.has('moveControl')
27
+ const isOpen = expandedButtons.has('moveControls')
28
28
  const firstDirectionButtonId = `${appId}-pan-up`
29
29
 
30
30
  useEffect(() => {
@@ -41,7 +41,7 @@ export const MoveControl = () => {
41
41
  // Registry buttons (declared via a plugin's manifest) auto-return focus to the map
42
42
  // viewport after a click (see createButtonClickHandler in mapButtons.js) unless
43
43
  // marked keepFocus — which is how e.g. draw's own toolbar buttons never leave
44
- // keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MoveControl
44
+ // keyboard shortcuts (like arrow-key vertex nudging) stranded afterward. MoveControls
45
45
  // renders its own buttons directly, bypassing that mechanism entirely, so it has to
46
46
  // replicate the same return-to-viewport behaviour itself. But unlike a one-off
47
47
  // action button, the D-pad is meant to be pressed repeatedly — a keyboard/switch
@@ -70,7 +70,7 @@ export const MoveControl = () => {
70
70
 
71
71
  // Any plugin can claim the D-pad for something other than panning the map (e.g.
72
72
  // moving a selected draw vertex) by setting mapProvider.activeMoveTarget — a
73
- // generic { move(dx, dy, isLargeStep), label? } contract. MoveControl doesn't
73
+ // generic { move(dx, dy, isLargeStep), label? } contract. MoveControls doesn't
74
74
  // know or care what's claiming it; it just checks for a claimant before
75
75
  // defaulting to its own panBy behaviour. Read fresh on every click rather than
76
76
  // subscribed to reactively, since it's a plain mapProvider property, not state.
@@ -107,7 +107,7 @@ export const MoveControl = () => {
107
107
  // first tabbing all the way back to the map. Handling arrow keys here — while
108
108
  // focus is anywhere within this control, not just on a direction button — covers
109
109
  // that directly, without needing draw's (or the app's own) keyboard-shortcut
110
- // guards to special-case MoveControl's buttons. Only arrow keys are handled;
110
+ // guards to special-case MoveControls' buttons. Only arrow keys are handled;
111
111
  // other shortcuts (delete/escape/undo) still require focus on the viewport,
112
112
  // which mouse/touch users already get back automatically after any click here.
113
113
  const handleContainerKeyDown = (e) => {
@@ -120,12 +120,12 @@ export const MoveControl = () => {
120
120
  }
121
121
 
122
122
  const containerClassName = [
123
- 'im-c-move-control',
124
- !isOpen && 'im-c-move-control--collapsed'
123
+ 'im-c-move-controls',
124
+ !isOpen && 'im-c-move-controls--collapsed'
125
125
  ].filter(Boolean).join(' ')
126
126
 
127
127
  const directionsGroup = (
128
- <div key='directions' role='group' aria-label='Direction controls' className='im-c-move-control__directions'>{/* NOSONAR - div with role="group" is correct for a button group */}
128
+ <div key='directions' role='group' aria-label='Direction controls' className='im-c-move-controls__directions'>{/* NOSONAR - div with role="group" is correct for a button group */}
129
129
  {DIRECTIONS.map(({ id, verb, dx, dy }) => (
130
130
  <MapButton
131
131
  key={id}
@@ -154,7 +154,7 @@ export const MoveControl = () => {
154
154
  )
155
155
 
156
156
  const zoomGroup = (
157
- <div key='zoom' role='group' aria-label='Zoom controls' className='im-c-move-control__zoom'>{/* NOSONAR - div with role="group" is correct for a button group */}
157
+ <div key='zoom' role='group' aria-label='Zoom controls' className='im-c-move-controls__zoom'>{/* NOSONAR - div with role="group" is correct for a button group */}
158
158
  {ZOOM_ACTIONS.map(({ id, label, announceLabel, method }) => (
159
159
  <MapButton
160
160
  key={id}
@@ -169,7 +169,7 @@ export const MoveControl = () => {
169
169
  )
170
170
 
171
171
  return (
172
- <div id={`${appId}-move-control-content`} className={containerClassName} onKeyDown={handleContainerKeyDown}>{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */}
172
+ <div id={`${appId}-move-controls-content`} className={containerClassName} onKeyDown={handleContainerKeyDown} data-map-keyboard-scope>{/* NOSONAR - only catches arrow-key bubbling from the real, already-focusable button descendants below; the div itself needs no role/tabIndex */}
173
173
  {directionsGroup}
174
174
  {zoomGroup}
175
175
  </div>
@@ -1,62 +1,62 @@
1
1
  @use '../../../scss/tools/index' as tools;
2
2
 
3
3
  // ===================================================
4
- // Component: MoveControl
4
+ // Component: MoveControls
5
5
  // ===================================================
6
6
 
7
7
  // 1. Base styles
8
- .im-c-move-control {
8
+ .im-c-move-controls {
9
9
  display: flex;
10
10
  flex-direction: column;
11
11
  align-items: center;
12
12
  gap: var(--divider-gap);
13
13
  }
14
14
 
15
- .im-c-move-control.im-c-move-control--collapsed {
15
+ .im-c-move-controls.im-c-move-controls--collapsed {
16
16
  display: none;
17
17
  }
18
18
 
19
19
  // 2. Elements
20
- .im-c-move-control__directions {
20
+ .im-c-move-controls__directions {
21
21
  display: grid;
22
22
  grid-template-columns: repeat(3, var(--button-size));
23
23
  grid-template-rows: repeat(3, var(--button-size));
24
24
  }
25
25
 
26
- .im-c-move-control__directions .im-c-button-wrapper--pan-up {
26
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-up {
27
27
  grid-column: 2;
28
28
  grid-row: 1;
29
29
  }
30
30
 
31
- .im-c-move-control__directions .im-c-button-wrapper--pan-left {
31
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-left {
32
32
  grid-column: 1;
33
33
  grid-row: 2;
34
34
  }
35
35
 
36
- .im-c-move-control__directions .im-c-button-wrapper--pan-right {
36
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-right {
37
37
  grid-column: 3;
38
38
  grid-row: 2;
39
39
  }
40
40
 
41
- .im-c-move-control__directions .im-c-button-wrapper--pan-down {
41
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-down {
42
42
  grid-column: 2;
43
43
  grid-row: 3;
44
44
  }
45
45
 
46
- .im-c-move-control__directions .im-c-button-wrapper--nudge-step-toggle {
46
+ .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle {
47
47
  grid-column: 2;
48
48
  grid-row: 2;
49
49
  }
50
50
 
51
- .im-c-move-control__directions .im-c-map-button--pan-up svg {
51
+ .im-c-move-controls__directions .im-c-map-button--pan-up svg {
52
52
  transform: rotate(180deg);
53
53
  }
54
54
 
55
- .im-c-move-control__directions .im-c-map-button--pan-left svg {
55
+ .im-c-move-controls__directions .im-c-map-button--pan-left svg {
56
56
  transform: rotate(90deg);
57
57
  }
58
58
 
59
- .im-c-move-control__directions .im-c-map-button--pan-right svg {
59
+ .im-c-move-controls__directions .im-c-map-button--pan-right svg {
60
60
  transform: rotate(-90deg);
61
61
  }
62
62
 
@@ -66,35 +66,35 @@
66
66
  // express a plus-shaped grid where the centre button touches all four neighbours —
67
67
  // clip-path is set directly per button instead, clipping only the side(s) where it
68
68
  // touches another button and leaving the exposed side(s) showing the shadow as normal.
69
- .im-c-move-control__directions .im-c-button-wrapper--pan-up .im-c-map-button::before {
69
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-up .im-c-map-button::before {
70
70
  clip-path: inset(-20px -20px 0 -20px); // touches nudge-step-toggle below
71
71
  }
72
72
 
73
- .im-c-move-control__directions .im-c-button-wrapper--pan-left .im-c-map-button::before {
73
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-left .im-c-map-button::before {
74
74
  clip-path: inset(-20px 0 -20px -20px); // touches nudge-step-toggle to the right
75
75
  }
76
76
 
77
- .im-c-move-control__directions .im-c-button-wrapper--pan-right .im-c-map-button::before {
77
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-right .im-c-map-button::before {
78
78
  clip-path: inset(-20px -20px -20px 0); // touches nudge-step-toggle to the left
79
79
  }
80
80
 
81
- .im-c-move-control__directions .im-c-button-wrapper--pan-down .im-c-map-button::before {
81
+ .im-c-move-controls__directions .im-c-button-wrapper--pan-down .im-c-map-button::before {
82
82
  clip-path: inset(0 -20px -20px -20px); // touches nudge-step-toggle above
83
83
  }
84
84
 
85
- .im-c-move-control__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button::before {
85
+ .im-c-move-controls__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button::before {
86
86
  clip-path: inset(0); // touches all four neighbours
87
87
  }
88
88
 
89
89
  // Same inverted "pressed" treatment already used for actions-slot toggle buttons
90
90
  // (e.g. draw's Snap toggle) — reused here so Nudge mode reads consistently active.
91
- .im-c-move-control .im-c-map-button[aria-pressed="true"] {
91
+ .im-c-move-controls .im-c-map-button[aria-pressed="true"] {
92
92
  color: var(--pressed-button-foreground-color);
93
93
  border-color: var(--pressed-button-border-color);
94
94
  background-color: var(--pressed-button-background-color);
95
95
  }
96
96
 
97
- // The trigger lives in the right-top slot, outside .im-c-move-control, so this is
97
+ // The trigger lives in the right-top slot, outside .im-c-move-controls, so this is
98
98
  // its own top-level rule. Same colour treatment as above, but keyed off
99
99
  // aria-expanded rather than aria-pressed — the trigger is correctly a disclosure
100
100
  // button (it reveals the control below), not a toggle button, so it doesn't carry
@@ -106,26 +106,26 @@
106
106
  // hovering the expanded trigger button was a coin-flip decided by cascade order —
107
107
  // one cssnano mergeRules reshuffle in production and hover started winning,
108
108
  // showing the hover tint instead of the pressed colours. The sibling
109
- // .im-c-move-control .im-c-map-button[aria-pressed="true"] rule above never had
109
+ // .im-c-move-controls .im-c-map-button[aria-pressed="true"] rule above never had
110
110
  // this problem (it's already (0,3,0), safely ahead of hover); this one needed the
111
111
  // same headroom.
112
- .im-c-map-button.im-c-map-button--move-control[aria-expanded="true"] {
112
+ .im-c-map-button.im-c-map-button--move-controls[aria-expanded="true"] {
113
113
  color: var(--pressed-button-foreground-color);
114
114
  border-color: var(--pressed-button-border-color);
115
115
  background-color: var(--pressed-button-background-color);
116
116
  }
117
117
 
118
- .im-c-move-control__zoom {
118
+ .im-c-move-controls__zoom {
119
119
  display: flex;
120
120
  flex-direction: row;
121
121
  }
122
122
 
123
123
  // A simple two-item linear row, so the standard button-group corner/shadow treatment
124
124
  // applies directly (matches .im-o-app__top's first/last-child pattern).
125
- .im-c-move-control__zoom .im-c-button-wrapper--nudge-zoom-in .im-c-map-button {
125
+ .im-c-move-controls__zoom .im-c-button-wrapper--nudge-zoom-in .im-c-map-button {
126
126
  @include tools.border-focus-corner-override($corners: 'left');
127
127
  }
128
128
 
129
- .im-c-move-control__zoom .im-c-button-wrapper--nudge-zoom-out .im-c-map-button {
129
+ .im-c-move-controls__zoom .im-c-button-wrapper--nudge-zoom-out .im-c-map-button {
130
130
  @include tools.border-focus-corner-override($corners: 'right');
131
131
  }
@@ -1,6 +1,6 @@
1
1
  import React from 'react'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
- import { MoveControl } from './MoveControl.jsx'
3
+ import { MoveControls } from './MoveControls.jsx'
4
4
  import { useApp } from '../../store/appContext.js'
5
5
  import { useConfig } from '../../store/configContext.js'
6
6
  import { useMap } from '../../store/mapContext.js'
@@ -11,19 +11,19 @@ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
11
11
  jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
12
12
  jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
13
13
 
14
- describe('MoveControl', () => {
14
+ describe('MoveControls', () => {
15
15
  let dispatch
16
16
  let mapProvider
17
17
  let announce
18
18
 
19
19
  // MapButton and Tooltip also read from useApp() (buttonRefs, interfaceType), so every
20
- // mockReturnValue needs to carry these alongside the fields MoveControl itself reads.
20
+ // mockReturnValue needs to carry these alongside the fields MoveControls itself reads.
21
21
  const buildAppState = (overrides) => ({
22
22
  buttonRefs: { current: {} },
23
23
  interfaceType: 'mouse',
24
24
  breakpoint: 'desktop',
25
25
  dispatch,
26
- expandedButtons: new Set(['moveControl']),
26
+ expandedButtons: new Set(['moveControls']),
27
27
  nudgeStepSize: 'large',
28
28
  layoutRefs: { viewportRef: { current: { focus: jest.fn() } } },
29
29
  ...overrides
@@ -50,29 +50,29 @@ describe('MoveControl', () => {
50
50
  afterEach(() => jest.clearAllMocks())
51
51
 
52
52
  it('renders with the id matching the trigger button aria-controls value', () => {
53
- const { container } = render(<MoveControl />)
54
- expect(container.querySelector('#im-move-control-content')).toBeInTheDocument()
53
+ const { container } = render(<MoveControls />)
54
+ expect(container.querySelector('#im-move-controls-content')).toBeInTheDocument()
55
55
  })
56
56
 
57
- it('is not visually collapsed when moveControl is expanded', () => {
58
- const { container } = render(<MoveControl />)
59
- expect(container.querySelector('.im-c-move-control--collapsed')).not.toBeInTheDocument()
57
+ it('is not visually collapsed when moveControls is expanded', () => {
58
+ const { container } = render(<MoveControls />)
59
+ expect(container.querySelector('.im-c-move-controls--collapsed')).not.toBeInTheDocument()
60
60
  })
61
61
 
62
62
  it('moves focus to the first direction button when the control opens', () => {
63
- const { rerender } = render(<MoveControl />)
63
+ const { rerender } = render(<MoveControls />)
64
64
  useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
65
- rerender(<MoveControl />)
65
+ rerender(<MoveControls />)
66
66
 
67
- useApp.mockReturnValue(buildAppState({ expandedButtons: new Set(['moveControl']) }))
68
- rerender(<MoveControl />)
67
+ useApp.mockReturnValue(buildAppState({ expandedButtons: new Set(['moveControls']) }))
68
+ rerender(<MoveControls />)
69
69
  expect(screen.getByRole('button', { name: 'Move up' })).toHaveFocus()
70
70
  })
71
71
 
72
72
  it('renders the directions group before the zoom group at every breakpoint', () => {
73
73
  ['mobile', 'tablet', 'desktop'].forEach(breakpoint => {
74
74
  useApp.mockReturnValue(buildAppState({ breakpoint }))
75
- const { container, unmount } = render(<MoveControl />)
75
+ const { container, unmount } = render(<MoveControls />)
76
76
  const groups = container.querySelectorAll('[role="group"]')
77
77
  expect(groups[0]).toHaveAttribute('aria-label', 'Direction controls')
78
78
  expect(groups[1]).toHaveAttribute('aria-label', 'Zoom controls')
@@ -80,14 +80,14 @@ describe('MoveControl', () => {
80
80
  })
81
81
  })
82
82
 
83
- it('is collapsed when moveControl is not expanded', () => {
83
+ it('is collapsed when moveControls is not expanded', () => {
84
84
  useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
85
- const { container } = render(<MoveControl />)
86
- expect(container.querySelector('.im-c-move-control--collapsed')).toBeInTheDocument()
85
+ const { container } = render(<MoveControls />)
86
+ expect(container.querySelector('.im-c-move-controls--collapsed')).toBeInTheDocument()
87
87
  })
88
88
 
89
89
  it('labels direction buttons "Move" and pans by the large delta by default', () => {
90
- render(<MoveControl />)
90
+ render(<MoveControls />)
91
91
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
92
92
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
93
93
  expect(announce).toHaveBeenCalledWith('Moved right')
@@ -95,7 +95,7 @@ describe('MoveControl', () => {
95
95
 
96
96
  it('labels direction buttons "Nudge" and pans by the small delta when nudgeStepSize is small', () => {
97
97
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
98
- render(<MoveControl />)
98
+ render(<MoveControls />)
99
99
  fireEvent.click(screen.getByRole('button', { name: 'Nudge up' }))
100
100
  expect(mapProvider.panBy).toHaveBeenCalledWith([0, -5])
101
101
  expect(announce).toHaveBeenCalledWith('Nudged up')
@@ -103,7 +103,7 @@ describe('MoveControl', () => {
103
103
 
104
104
  it('routes direction clicks to mapProvider.activeMoveTarget instead of panning, when a plugin has claimed it', () => {
105
105
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
106
- render(<MoveControl />)
106
+ render(<MoveControls />)
107
107
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
108
108
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(1, 0, true)
109
109
  expect(mapProvider.panBy).not.toHaveBeenCalled()
@@ -112,22 +112,22 @@ describe('MoveControl', () => {
112
112
 
113
113
  it('falls back to panning once activeMoveTarget is released', () => {
114
114
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
115
- const { rerender } = render(<MoveControl />)
115
+ const { rerender } = render(<MoveControls />)
116
116
  mapProvider.activeMoveTarget = null
117
- rerender(<MoveControl />)
117
+ rerender(<MoveControls />)
118
118
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
119
119
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
120
120
  })
121
121
 
122
122
  it('omits the target label from the announcement when activeMoveTarget has none', () => {
123
123
  mapProvider.activeMoveTarget = { move: jest.fn() }
124
- render(<MoveControl />)
124
+ render(<MoveControls />)
125
125
  fireEvent.click(screen.getByRole('button', { name: 'Move up' }))
126
126
  expect(announce).toHaveBeenCalledWith('Moved up')
127
127
  })
128
128
 
129
129
  it('zooms in and out by the large delta by default and announces the action', () => {
130
- render(<MoveControl />)
130
+ render(<MoveControls />)
131
131
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
132
132
  expect(mapProvider.zoomIn).toHaveBeenCalledWith(1)
133
133
  expect(announce).toHaveBeenCalledWith('Zoomed in')
@@ -139,26 +139,26 @@ describe('MoveControl', () => {
139
139
 
140
140
  it('zooms by the small delta when nudgeStepSize is small', () => {
141
141
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
142
- render(<MoveControl />)
142
+ render(<MoveControls />)
143
143
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
144
144
  expect(mapProvider.zoomIn).toHaveBeenCalledWith(0.1)
145
145
  })
146
146
 
147
147
  it('disables the zoom in button at max zoom, and zoom out at min zoom', () => {
148
148
  useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: false })
149
- const { rerender } = render(<MoveControl />)
149
+ const { rerender } = render(<MoveControls />)
150
150
  expect(screen.getByRole('button', { name: 'Zoom in' })).toHaveAttribute('aria-disabled', 'true')
151
151
  expect(screen.getByRole('button', { name: 'Zoom out' })).not.toHaveAttribute('aria-disabled')
152
152
 
153
153
  useMap.mockReturnValue({ isAtMaxZoom: false, isAtMinZoom: true })
154
- rerender(<MoveControl />)
154
+ rerender(<MoveControls />)
155
155
  expect(screen.getByRole('button', { name: 'Zoom in' })).not.toHaveAttribute('aria-disabled')
156
156
  expect(screen.getByRole('button', { name: 'Zoom out' })).toHaveAttribute('aria-disabled', 'true')
157
157
  })
158
158
 
159
159
  it('does not zoom when the relevant button is disabled at max/min zoom', () => {
160
160
  useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: true })
161
- render(<MoveControl />)
161
+ render(<MoveControls />)
162
162
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
163
163
  fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
164
164
  expect(mapProvider.zoomIn).not.toHaveBeenCalled()
@@ -177,7 +177,7 @@ describe('MoveControl', () => {
177
177
  it('returns focus to the viewport after panning on mouse/touch, so arrow-key shortcuts elsewhere are not left stranded on the D-pad button', () => {
178
178
  const appState = buildAppState({ interfaceType: 'mouse' })
179
179
  useApp.mockReturnValue(appState)
180
- render(<MoveControl />)
180
+ render(<MoveControls />)
181
181
 
182
182
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
183
183
  expect(appState.layoutRefs.viewportRef.current.focus).toHaveBeenCalled()
@@ -185,19 +185,19 @@ describe('MoveControl', () => {
185
185
 
186
186
  it('keeps focus on the button when driven by keyboard, so repeated Enter/Space presses do not require re-tabbing', () => {
187
187
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
188
- render(<MoveControl />)
188
+ render(<MoveControls />)
189
189
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
190
190
  expect(rafSpy).not.toHaveBeenCalled()
191
191
  })
192
192
 
193
193
  it('also returns focus after zooming on mouse/touch, but not on keyboard', () => {
194
194
  useApp.mockReturnValue(buildAppState({ interfaceType: 'mouse' }))
195
- const { rerender } = render(<MoveControl />)
195
+ const { rerender } = render(<MoveControls />)
196
196
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
197
197
  expect(rafSpy).toHaveBeenCalledTimes(1)
198
198
 
199
199
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
200
- rerender(<MoveControl />)
200
+ rerender(<MoveControls />)
201
201
  fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
202
202
  expect(rafSpy).toHaveBeenCalledTimes(1)
203
203
  })
@@ -205,7 +205,7 @@ describe('MoveControl', () => {
205
205
  it('also returns focus after a vertex nudge via activeMoveTarget on mouse/touch', () => {
206
206
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
207
207
  useApp.mockReturnValue(buildAppState({ interfaceType: 'touch' }))
208
- render(<MoveControl />)
208
+ render(<MoveControls />)
209
209
  fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
210
210
  expect(rafSpy).toHaveBeenCalledTimes(1)
211
211
  })
@@ -217,7 +217,7 @@ describe('MoveControl', () => {
217
217
  // arrow keys without first tabbing all the way back out to the map.
218
218
  it('pans the map on an arrow key, regardless of which button currently has focus', () => {
219
219
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
220
- render(<MoveControl />)
220
+ render(<MoveControls />)
221
221
  // Focus a button unrelated to the direction being pressed, to prove this
222
222
  // isn't just reading the focused button's own handler.
223
223
  fireEvent.focus(screen.getByRole('button', { name: 'Zoom in' }))
@@ -229,7 +229,7 @@ describe('MoveControl', () => {
229
229
  it('routes the arrow key through activeMoveTarget when a plugin has claimed the control', () => {
230
230
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
231
231
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
232
- render(<MoveControl />)
232
+ render(<MoveControls />)
233
233
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp' })
234
234
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, true)
235
235
  expect(mapProvider.panBy).not.toHaveBeenCalled()
@@ -237,14 +237,14 @@ describe('MoveControl', () => {
237
237
 
238
238
  it('ignores non-arrow keys, leaving default behaviour (e.g. Enter/Space activating the focused button) untouched', () => {
239
239
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard' }))
240
- render(<MoveControl />)
240
+ render(<MoveControls />)
241
241
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'Enter' })
242
242
  expect(mapProvider.panBy).not.toHaveBeenCalled()
243
243
  })
244
244
 
245
245
  it('shift+arrow overrides the Precision toggle to the small step, matching the map\'s own native keyboard shortcuts', () => {
246
246
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
247
- render(<MoveControl />)
247
+ render(<MoveControls />)
248
248
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight', shiftKey: true })
249
249
  expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
250
250
  expect(announce).toHaveBeenCalledWith('Nudged right')
@@ -252,7 +252,7 @@ describe('MoveControl', () => {
252
252
 
253
253
  it('shift+arrow still resolves to the small step when Precision is already on (idempotent, not a toggle-relative flip)', () => {
254
254
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'small' }))
255
- render(<MoveControl />)
255
+ render(<MoveControls />)
256
256
  fireEvent.keyDown(screen.getByRole('button', { name: 'Nudge right' }), { key: 'ArrowRight', shiftKey: true })
257
257
  expect(mapProvider.panBy).toHaveBeenCalledWith([5, 0])
258
258
  expect(announce).toHaveBeenCalledWith('Nudged right')
@@ -261,14 +261,14 @@ describe('MoveControl', () => {
261
261
  it('shift+arrow overrides activeMoveTarget.move to the small step too', () => {
262
262
  mapProvider.activeMoveTarget = { move: jest.fn(), label: 'vertex' }
263
263
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
264
- render(<MoveControl />)
264
+ render(<MoveControls />)
265
265
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move up' }), { key: 'ArrowUp', shiftKey: true })
266
266
  expect(mapProvider.activeMoveTarget.move).toHaveBeenCalledWith(0, -1, false)
267
267
  })
268
268
 
269
269
  it('arrow key without shift still follows the Precision toggle as before', () => {
270
270
  useApp.mockReturnValue(buildAppState({ interfaceType: 'keyboard', nudgeStepSize: 'large' }))
271
- render(<MoveControl />)
271
+ render(<MoveControls />)
272
272
  fireEvent.keyDown(screen.getByRole('button', { name: 'Move right' }), { key: 'ArrowRight' })
273
273
  expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
274
274
  expect(announce).toHaveBeenCalledWith('Moved right')
@@ -276,11 +276,11 @@ describe('MoveControl', () => {
276
276
  })
277
277
 
278
278
  it('has a stable "Precision" label regardless of state', () => {
279
- const { rerender } = render(<MoveControl />)
279
+ const { rerender } = render(<MoveControls />)
280
280
  expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
281
281
 
282
282
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
283
- rerender(<MoveControl />)
283
+ rerender(<MoveControls />)
284
284
  expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
285
285
  })
286
286
 
@@ -294,29 +294,29 @@ describe('MoveControl', () => {
294
294
  return document.getElementById(button.getAttribute('aria-labelledby'))
295
295
  }
296
296
 
297
- const { rerender } = render(<MoveControl />)
297
+ const { rerender } = render(<MoveControls />)
298
298
  let tooltip = getOwnTooltip()
299
299
  expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(Off)')
300
300
  expect(tooltip).toHaveTextContent('Precision (Off)')
301
301
 
302
302
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
303
- rerender(<MoveControl />)
303
+ rerender(<MoveControls />)
304
304
  tooltip = getOwnTooltip()
305
305
  expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(On)')
306
306
  expect(tooltip).toHaveTextContent('Precision (On)')
307
307
  })
308
308
 
309
309
  it('reflects precision mode via aria-pressed, not via label changes', () => {
310
- const { rerender } = render(<MoveControl />)
310
+ const { rerender } = render(<MoveControls />)
311
311
  expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'false')
312
312
 
313
313
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
314
- rerender(<MoveControl />)
314
+ rerender(<MoveControls />)
315
315
  expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'true')
316
316
  })
317
317
 
318
318
  it('toggles precision on and announces it when currently in large-step mode', () => {
319
- render(<MoveControl />)
319
+ render(<MoveControls />)
320
320
  fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
321
321
  expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
322
322
  expect(announce).toHaveBeenCalledWith('Precision on')
@@ -324,7 +324,7 @@ describe('MoveControl', () => {
324
324
 
325
325
  it('toggles precision off and announces it when currently in small-step mode', () => {
326
326
  useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
327
- render(<MoveControl />)
327
+ render(<MoveControls />)
328
328
  fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
329
329
  expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
330
330
  expect(announce).toHaveBeenCalledWith('Precision off')
@@ -18,7 +18,7 @@ import { Markers } from '../Markers/Markers'
18
18
  // eslint-disable-next-line camelcase, react/jsx-pascal-case
19
19
  // sonarjs/disable-next-line function-name
20
20
  export const Viewport = () => {
21
- const { id, mapProvider, mapLabel, keyboardHintText } = useConfig()
21
+ const { id, mapProvider, mapLabel, keyboardHintText, focusOnMount } = useConfig()
22
22
  const { mode, previousMode, layoutRefs, safeZoneInset, dispatch } = useApp()
23
23
  const { mapSize } = useMap()
24
24
  const { eventBus, hints } = useService()
@@ -62,6 +62,13 @@ export const Viewport = () => {
62
62
  }
63
63
  }, [mode])
64
64
 
65
+ // Focus the viewport on mount when opened by a genuine launcher click (see InteractiveMap.js's _handleButtonClick), never on an auto-load or resize.
66
+ useEffect(() => {
67
+ if (focusOnMount) {
68
+ layoutRefs.viewportRef?.current.focus()
69
+ }
70
+ }, [])
71
+
65
72
  return (
66
73
  <>
67
74
  <MapController mapContainerRef={mapContainerRef} />
@@ -71,6 +78,7 @@ export const Viewport = () => {
71
78
  aria-label={mapLabel}
72
79
  role='application'
73
80
  tabIndex='0' // nosonar
81
+ data-map-keyboard-scope
74
82
  onFocus={handleFocus}
75
83
  onBlur={handleBlur}
76
84
  ref={layoutRefs.viewportRef}
@@ -115,6 +115,17 @@ describe('Viewport rendering', () => {
115
115
  onViewportFocusChange(false)
116
116
  expect(hints.dismiss).toHaveBeenCalled()
117
117
  })
118
+
119
+ it('focuses the viewport on mount when focusOnMount is true', () => {
120
+ useConfig.mockReturnValueOnce({ ...useConfig(), focusOnMount: true })
121
+ const { viewport } = renderViewport()
122
+ expect(viewport).toHaveFocus()
123
+ })
124
+
125
+ it('does not focus the viewport on mount when focusOnMount is false', () => {
126
+ renderViewport()
127
+ expect(viewportEl).not.toHaveFocus()
128
+ })
118
129
  })
119
130
 
120
131
  describe('Viewport interactions', () => {
@@ -5,7 +5,7 @@ import { createButtonRegistry } from './registry/buttonRegistry.js'
5
5
  import { createPanelRegistry } from './registry/panelRegistry.js'
6
6
  import { createControlRegistry } from './registry/controlRegistry.js'
7
7
  import { createPluginRegistry } from './registry/pluginRegistry.js'
8
- import { setProviderSupportedShortcuts } from './registry/keyboardShortcutRegistry.js'
8
+ import { createKeyboardShortcutRegistry } from './registry/keyboardShortcutRegistry.js'
9
9
  import { mergeManifests } from './registry/mergeManifests.js'
10
10
  import { App } from './App.jsx'
11
11
 
@@ -19,13 +19,15 @@ const getOrCreateRegistries = (rootElement) => {
19
19
  const buttonRegistry = createButtonRegistry()
20
20
  const panelRegistry = createPanelRegistry()
21
21
  const controlRegistry = createControlRegistry()
22
+ const keyboardShortcutRegistry = createKeyboardShortcutRegistry()
22
23
  const pluginRegistry = createPluginRegistry({
23
24
  registerButton: buttonRegistry.registerButton,
24
25
  registerPanel: panelRegistry.registerPanel,
25
- registerControl: controlRegistry.registerControl
26
+ registerControl: controlRegistry.registerControl,
27
+ registerKeyboardShortcut: keyboardShortcutRegistry.registerKeyboardShortcut
26
28
  })
27
29
 
28
- registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry }
30
+ registries = { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry }
29
31
  registryMap.set(rootElement, registries)
30
32
  }
31
33
  return registries
@@ -70,15 +72,15 @@ export async function initialiseApp (rootElement, {
70
72
  mapProviderMap.set(rootElement, mapProvider)
71
73
  }
72
74
 
75
+ // Reuse or create registries (persist across app open/close cycles)
76
+ const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry, keyboardShortcutRegistry } = getOrCreateRegistries(rootElement)
77
+ const { registerPlugin } = pluginRegistry
78
+
73
79
  // Register provider-supported shortcuts
74
80
  if (mapProvider.capabilities?.supportedShortcuts) {
75
- setProviderSupportedShortcuts(mapProvider.capabilities.supportedShortcuts)
81
+ keyboardShortcutRegistry.setProviderSupportedShortcuts(mapProvider.capabilities.supportedShortcuts)
76
82
  }
77
83
 
78
- // Reuse or create registries (persist across app open/close cycles)
79
- const { buttonRegistry, panelRegistry, controlRegistry, pluginRegistry } = getOrCreateRegistries(rootElement)
80
- const { registerPlugin } = pluginRegistry
81
-
82
84
  // Clear previous plugins (but keep runtime additions)
83
85
  pluginRegistry.clear()
84
86
 
@@ -121,6 +123,7 @@ export async function initialiseApp (rootElement, {
121
123
  panelRegistry={panelRegistry}
122
124
  controlRegistry={controlRegistry}
123
125
  pluginRegistry={pluginRegistry}
126
+ keyboardShortcutRegistry={keyboardShortcutRegistry}
124
127
  mapProvider={mapProvider}
125
128
  />)
126
129