@defra/interactive-map 0.0.35-alpha → 0.0.37-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 (55) 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 +15 -1
  7. package/package.json +1 -1
  8. package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
  9. package/plugins/beta/draw-es/src/events.js +1 -1
  10. package/plugins/beta/map-styles/dist/esm/im-map-styles-plugin.js +1 -1
  11. package/plugins/beta/map-styles/dist/umd/im-map-styles-plugin.js +1 -1
  12. package/plugins/beta/map-styles/dist/umd/index.js +1 -1
  13. package/plugins/datasets/dist/css/index.css +1 -1
  14. package/plugins/datasets/dist/esm/esriLayerAdapter.js +1 -1
  15. package/plugins/datasets/dist/esm/im-datasets-plugin.js +1 -1
  16. package/plugins/datasets/dist/umd/im-datasets-esri-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 +20 -1
  19. package/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js +1 -0
  20. package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +3 -0
  21. package/plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx +3 -1
  22. package/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +3 -1
  23. package/plugins/datasets/src/initialise/initialiseDatasets.js +2 -2
  24. package/plugins/datasets/src/reducers/pluginState.js +2 -0
  25. package/plugins/datasets/src/registry/datasetRegistry.js +1 -1
  26. package/plugins/interact/dist/esm/im-interact-plugin.js +1 -1
  27. package/plugins/interact/dist/umd/im-interact-plugin.js +1 -1
  28. package/plugins/interact/dist/umd/index.js +1 -1
  29. package/plugins/interact/src/hooks/useMapItemList.js +2 -2
  30. package/providers/beta/openlayers/src/utils/tileLayers.test.js +1 -1
  31. package/providers/maplibre/src/utils/queryFeatures.test.js +6 -6
  32. package/src/App/components/Actions/Actions.jsx +2 -2
  33. package/src/App/components/MoveControl/MoveControl.jsx +113 -0
  34. package/src/App/components/MoveControl/MoveControl.module.scss +121 -0
  35. package/src/App/components/MoveControl/MoveControl.test.jsx +196 -0
  36. package/src/App/components/Tabs/Tabs.jsx +1 -0
  37. package/src/App/components/Tabs/Tabs.test.jsx +11 -34
  38. package/src/App/controls/keyboardActions.js +4 -2
  39. package/src/App/hooks/useMarkersAPI.test.js +1 -1
  40. package/src/App/registry/pluginRegistry.test.js +2 -2
  41. package/src/App/renderer/mapButtons.test.js +8 -8
  42. package/src/App/renderer/mapControls.js +6 -1
  43. package/src/App/renderer/mapControls.test.js +24 -0
  44. package/src/App/renderer/slots.js +1 -0
  45. package/src/App/store/appActionsMap.js +8 -0
  46. package/src/App/store/appActionsMap.test.js +12 -0
  47. package/src/App/store/appReducer.js +1 -0
  48. package/src/App/store/appReducer.test.js +1 -0
  49. package/src/config/appConfig.js +41 -2
  50. package/src/config/appConfig.test.js +45 -4
  51. package/src/config/defaults.js +1 -0
  52. package/src/scss/main.scss +1 -0
  53. package/src/types.js +9 -1
  54. package/src/utils/resolveNudgeStep.js +2 -0
  55. package/src/utils/resolveNudgeStep.test.js +11 -0
@@ -0,0 +1,113 @@
1
+ import React, { useEffect } from 'react'
2
+ import { MapButton } from '../MapButton/MapButton.jsx'
3
+ import { useApp } from '../../store/appContext.js'
4
+ import { useConfig } from '../../store/configContext.js'
5
+ import { useMap } from '../../store/mapContext.js'
6
+ import { useService } from '../../store/serviceContext.js'
7
+ import { resolveStepAmount } from '../../../utils/resolveNudgeStep.js'
8
+
9
+ const DIRECTIONS = [
10
+ { id: 'panUp', verb: 'up', dx: 0, dy: -1 },
11
+ { id: 'panDown', verb: 'down', dx: 0, dy: 1 },
12
+ { id: 'panLeft', verb: 'left', dx: -1, dy: 0 },
13
+ { id: 'panRight', verb: 'right', dx: 1, dy: 0 }
14
+ ]
15
+
16
+ const ZOOM_ACTIONS = [
17
+ { id: 'nudgeZoomIn', label: 'Zoom in', announceLabel: 'Zoomed in', method: 'zoomIn' },
18
+ { id: 'nudgeZoomOut', label: 'Zoom out', announceLabel: 'Zoomed out', method: 'zoomOut' }
19
+ ]
20
+
21
+ export const MoveControl = () => {
22
+ const { id: appId, mapProvider, panDelta, nudgePanDelta, zoomDelta, nudgeZoomDelta } = useConfig()
23
+ const { dispatch, expandedButtons, nudgeStepSize } = useApp()
24
+ const { isAtMaxZoom, isAtMinZoom } = useMap()
25
+ const { announce } = useService()
26
+
27
+ const isOpen = expandedButtons.has('moveControl')
28
+ const firstDirectionButtonId = `${appId}-pan-up`
29
+
30
+ useEffect(() => {
31
+ if (isOpen) {
32
+ document.getElementById(firstDirectionButtonId)?.focus()
33
+ }
34
+ }, [isOpen, firstDirectionButtonId])
35
+
36
+ const isLargeStep = nudgeStepSize === 'large'
37
+ // Matches the draw plugin's existing "Move point" (default)/"Nudge point" (Shift, small)
38
+ // keyboard-shortcut vocabulary, so the label always describes the step size in effect.
39
+ const actionWord = isLargeStep ? 'Move' : 'Nudge'
40
+
41
+ const handlePan = (dx, dy, verb) => {
42
+ const amount = resolveStepAmount(isLargeStep, nudgePanDelta, panDelta)
43
+ mapProvider.panBy([dx * amount, dy * amount])
44
+ announce(`${actionWord}d ${verb}`)
45
+ }
46
+
47
+ const handleZoom = (method, label) => {
48
+ const amount = resolveStepAmount(isLargeStep, nudgeZoomDelta, zoomDelta)
49
+ mapProvider[method](amount)
50
+ announce(label)
51
+ }
52
+
53
+ const handleToggleStep = () => {
54
+ dispatch({ type: 'TOGGLE_NUDGE_STEP' })
55
+ announce(isLargeStep ? 'Precision on' : 'Precision off')
56
+ }
57
+
58
+ const containerClassName = [
59
+ 'im-c-move-control',
60
+ !isOpen && 'im-c-move-control--collapsed'
61
+ ].filter(Boolean).join(' ')
62
+
63
+ const directionsGroup = (
64
+ <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 */}
65
+ {DIRECTIONS.map(({ id, verb, dx, dy }) => (
66
+ <MapButton
67
+ key={id}
68
+ buttonId={id}
69
+ label={`${actionWord} ${verb}`}
70
+ iconId='chevron'
71
+ onClick={() => handlePan(dx, dy, verb)}
72
+ />
73
+ ))}
74
+
75
+ {/* Stable accessible name regardless of state (WAI-ARIA toggle-button pattern) —
76
+ aria-pressed alone conveys state to assistive tech. The (On)/(Off) suffix is
77
+ aria-hidden so it's excluded from the computed name (avoiding a duplicate
78
+ announcement alongside aria-pressed) but still visible in the tooltip for
79
+ sighted users. The icon itself is a decorative refinement of the same shape
80
+ (longer ticks + a centre dot when active), not a different icon/concept, so
81
+ it doesn't carry any of the meaning aria-pressed already conveys on its own. */}
82
+ <MapButton
83
+ buttonId='nudgeStepToggle'
84
+ label={<>Precision <span aria-hidden='true'>({isLargeStep ? 'Off' : 'On'})</span></>}
85
+ iconId={isLargeStep ? 'precision' : 'precision-active'}
86
+ isPressed={!isLargeStep}
87
+ onClick={handleToggleStep}
88
+ />
89
+ </div>
90
+ )
91
+
92
+ const zoomGroup = (
93
+ <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 */}
94
+ {ZOOM_ACTIONS.map(({ id, label, announceLabel, method }) => (
95
+ <MapButton
96
+ key={id}
97
+ buttonId={id}
98
+ label={label}
99
+ iconId={method === 'zoomIn' ? 'plus' : 'minus'}
100
+ isDisabled={method === 'zoomIn' ? isAtMaxZoom : isAtMinZoom}
101
+ onClick={() => handleZoom(method, announceLabel)}
102
+ />
103
+ ))}
104
+ </div>
105
+ )
106
+
107
+ return (
108
+ <div id={`${appId}-move-control-content`} className={containerClassName}>
109
+ {directionsGroup}
110
+ {zoomGroup}
111
+ </div>
112
+ )
113
+ }
@@ -0,0 +1,121 @@
1
+ @use '../../../scss/tools/index' as tools;
2
+
3
+ // ===================================================
4
+ // Component: MoveControl
5
+ // ===================================================
6
+
7
+ // 1. Base styles
8
+ .im-c-move-control {
9
+ display: flex;
10
+ flex-direction: column;
11
+ align-items: center;
12
+ gap: var(--divider-gap);
13
+ }
14
+
15
+ .im-c-move-control--collapsed {
16
+ display: none;
17
+ }
18
+
19
+ // 2. Elements
20
+ .im-c-move-control__directions {
21
+ display: grid;
22
+ grid-template-columns: repeat(3, var(--button-size));
23
+ grid-template-rows: repeat(3, var(--button-size));
24
+ }
25
+
26
+ .im-c-move-control__directions .im-c-button-wrapper--pan-up {
27
+ grid-column: 2;
28
+ grid-row: 1;
29
+ }
30
+
31
+ .im-c-move-control__directions .im-c-button-wrapper--pan-left {
32
+ grid-column: 1;
33
+ grid-row: 2;
34
+ }
35
+
36
+ .im-c-move-control__directions .im-c-button-wrapper--pan-right {
37
+ grid-column: 3;
38
+ grid-row: 2;
39
+ }
40
+
41
+ .im-c-move-control__directions .im-c-button-wrapper--pan-down {
42
+ grid-column: 2;
43
+ grid-row: 3;
44
+ }
45
+
46
+ .im-c-move-control__directions .im-c-button-wrapper--nudge-step-toggle {
47
+ grid-column: 2;
48
+ grid-row: 2;
49
+ }
50
+
51
+ .im-c-move-control__directions .im-c-map-button--pan-up svg {
52
+ transform: rotate(180deg);
53
+ }
54
+
55
+ .im-c-move-control__directions .im-c-map-button--pan-left svg {
56
+ transform: rotate(90deg);
57
+ }
58
+
59
+ .im-c-move-control__directions .im-c-map-button--pan-right svg {
60
+ transform: rotate(-90deg);
61
+ }
62
+
63
+ // Each button's own drop shadow (tools.border-focus-base's ::before) otherwise bleeds
64
+ // into whichever neighbour it butts up against. The standard button-group treatment
65
+ // (border-focus-corner-override) only handles a single-axis linear strip, so it can't
66
+ // express a plus-shaped grid where the centre button touches all four neighbours —
67
+ // clip-path is set directly per button instead, clipping only the side(s) where it
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 {
70
+ clip-path: inset(-20px -20px 0 -20px); // touches nudge-step-toggle below
71
+ }
72
+
73
+ .im-c-move-control__directions .im-c-button-wrapper--pan-left .im-c-map-button::before {
74
+ clip-path: inset(-20px 0 -20px -20px); // touches nudge-step-toggle to the right
75
+ }
76
+
77
+ .im-c-move-control__directions .im-c-button-wrapper--pan-right .im-c-map-button::before {
78
+ clip-path: inset(-20px -20px -20px 0); // touches nudge-step-toggle to the left
79
+ }
80
+
81
+ .im-c-move-control__directions .im-c-button-wrapper--pan-down .im-c-map-button::before {
82
+ clip-path: inset(0 -20px -20px -20px); // touches nudge-step-toggle above
83
+ }
84
+
85
+ .im-c-move-control__directions .im-c-button-wrapper--nudge-step-toggle .im-c-map-button::before {
86
+ clip-path: inset(0); // touches all four neighbours
87
+ }
88
+
89
+ // Same inverted "pressed" treatment already used for actions-slot toggle buttons
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"] {
92
+ color: var(--pressed-button-foreground-color);
93
+ border-color: var(--pressed-button-border-color);
94
+ background-color: var(--pressed-button-background-color);
95
+ }
96
+
97
+ // The trigger lives in the right-top slot, outside .im-c-move-control, so this is
98
+ // its own top-level rule. Same colour treatment as above, but keyed off
99
+ // aria-expanded rather than aria-pressed — the trigger is correctly a disclosure
100
+ // button (it reveals the control below), not a toggle button, so it doesn't carry
101
+ // aria-pressed itself; the colour is purely a visual echo of its expanded state.
102
+ .im-c-map-button--move-control[aria-expanded="true"] {
103
+ color: var(--pressed-button-foreground-color);
104
+ border-color: var(--pressed-button-border-color);
105
+ background-color: var(--pressed-button-background-color);
106
+ }
107
+
108
+ .im-c-move-control__zoom {
109
+ display: flex;
110
+ flex-direction: row;
111
+ }
112
+
113
+ // A simple two-item linear row, so the standard button-group corner/shadow treatment
114
+ // applies directly (matches .im-o-app__top's first/last-child pattern).
115
+ .im-c-move-control__zoom .im-c-button-wrapper--nudge-zoom-in .im-c-map-button {
116
+ @include tools.border-focus-corner-override($corners: 'left');
117
+ }
118
+
119
+ .im-c-move-control__zoom .im-c-button-wrapper--nudge-zoom-out .im-c-map-button {
120
+ @include tools.border-focus-corner-override($corners: 'right');
121
+ }
@@ -0,0 +1,196 @@
1
+ import React from 'react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { MoveControl } from './MoveControl.jsx'
4
+ import { useApp } from '../../store/appContext.js'
5
+ import { useConfig } from '../../store/configContext.js'
6
+ import { useMap } from '../../store/mapContext.js'
7
+ import { useService } from '../../store/serviceContext.js'
8
+
9
+ jest.mock('../../store/appContext.js', () => ({ useApp: jest.fn() }))
10
+ jest.mock('../../store/configContext.js', () => ({ useConfig: jest.fn() }))
11
+ jest.mock('../../store/mapContext.js', () => ({ useMap: jest.fn() }))
12
+ jest.mock('../../store/serviceContext.js', () => ({ useService: jest.fn() }))
13
+
14
+ describe('MoveControl', () => {
15
+ let dispatch
16
+ let mapProvider
17
+ let announce
18
+
19
+ // MapButton and Tooltip also read from useApp() (buttonRefs, interfaceType), so every
20
+ // mockReturnValue needs to carry these alongside the fields MoveControl itself reads.
21
+ const buildAppState = (overrides) => ({
22
+ buttonRefs: { current: {} },
23
+ interfaceType: 'mouse',
24
+ breakpoint: 'desktop',
25
+ dispatch,
26
+ expandedButtons: new Set(['moveControl']),
27
+ nudgeStepSize: 'large',
28
+ ...overrides
29
+ })
30
+
31
+ beforeEach(() => {
32
+ dispatch = jest.fn()
33
+ mapProvider = { panBy: jest.fn(), zoomIn: jest.fn(), zoomOut: jest.fn() }
34
+ announce = jest.fn()
35
+
36
+ useConfig.mockReturnValue({
37
+ id: 'im',
38
+ mapProvider,
39
+ panDelta: 100,
40
+ nudgePanDelta: 5,
41
+ zoomDelta: 1,
42
+ nudgeZoomDelta: 0.1
43
+ })
44
+ useApp.mockReturnValue(buildAppState())
45
+ useMap.mockReturnValue({ isAtMaxZoom: false, isAtMinZoom: false })
46
+ useService.mockReturnValue({ announce })
47
+ })
48
+
49
+ afterEach(() => jest.clearAllMocks())
50
+
51
+ it('renders with the id matching the trigger button aria-controls value', () => {
52
+ const { container } = render(<MoveControl />)
53
+ expect(container.querySelector('#im-move-control-content')).toBeInTheDocument()
54
+ })
55
+
56
+ it('is not visually collapsed when moveControl is expanded', () => {
57
+ const { container } = render(<MoveControl />)
58
+ expect(container.querySelector('.im-c-move-control--collapsed')).not.toBeInTheDocument()
59
+ })
60
+
61
+ it('moves focus to the first direction button when the control opens', () => {
62
+ const { rerender } = render(<MoveControl />)
63
+ useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
64
+ rerender(<MoveControl />)
65
+
66
+ useApp.mockReturnValue(buildAppState({ expandedButtons: new Set(['moveControl']) }))
67
+ rerender(<MoveControl />)
68
+ expect(screen.getByRole('button', { name: 'Move up' })).toHaveFocus()
69
+ })
70
+
71
+ it('renders the directions group before the zoom group at every breakpoint', () => {
72
+ ['mobile', 'tablet', 'desktop'].forEach(breakpoint => {
73
+ useApp.mockReturnValue(buildAppState({ breakpoint }))
74
+ const { container, unmount } = render(<MoveControl />)
75
+ const groups = container.querySelectorAll('[role="group"]')
76
+ expect(groups[0]).toHaveAttribute('aria-label', 'Direction controls')
77
+ expect(groups[1]).toHaveAttribute('aria-label', 'Zoom controls')
78
+ unmount()
79
+ })
80
+ })
81
+
82
+ it('is collapsed when moveControl is not expanded', () => {
83
+ useApp.mockReturnValue(buildAppState({ expandedButtons: new Set() }))
84
+ const { container } = render(<MoveControl />)
85
+ expect(container.querySelector('.im-c-move-control--collapsed')).toBeInTheDocument()
86
+ })
87
+
88
+ it('labels direction buttons "Move" and pans by the large delta by default', () => {
89
+ render(<MoveControl />)
90
+ fireEvent.click(screen.getByRole('button', { name: 'Move right' }))
91
+ expect(mapProvider.panBy).toHaveBeenCalledWith([100, 0])
92
+ expect(announce).toHaveBeenCalledWith('Moved right')
93
+ })
94
+
95
+ it('labels direction buttons "Nudge" and pans by the small delta when nudgeStepSize is small', () => {
96
+ useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
97
+ render(<MoveControl />)
98
+ fireEvent.click(screen.getByRole('button', { name: 'Nudge up' }))
99
+ expect(mapProvider.panBy).toHaveBeenCalledWith([0, -5])
100
+ expect(announce).toHaveBeenCalledWith('Nudged up')
101
+ })
102
+
103
+ it('zooms in and out by the large delta by default and announces the action', () => {
104
+ render(<MoveControl />)
105
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
106
+ expect(mapProvider.zoomIn).toHaveBeenCalledWith(1)
107
+ expect(announce).toHaveBeenCalledWith('Zoomed in')
108
+
109
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
110
+ expect(mapProvider.zoomOut).toHaveBeenCalledWith(1)
111
+ expect(announce).toHaveBeenCalledWith('Zoomed out')
112
+ })
113
+
114
+ it('zooms by the small delta when nudgeStepSize is small', () => {
115
+ useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
116
+ render(<MoveControl />)
117
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
118
+ expect(mapProvider.zoomIn).toHaveBeenCalledWith(0.1)
119
+ })
120
+
121
+ it('disables the zoom in button at max zoom, and zoom out at min zoom', () => {
122
+ useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: false })
123
+ const { rerender } = render(<MoveControl />)
124
+ expect(screen.getByRole('button', { name: 'Zoom in' })).toHaveAttribute('aria-disabled', 'true')
125
+ expect(screen.getByRole('button', { name: 'Zoom out' })).not.toHaveAttribute('aria-disabled')
126
+
127
+ useMap.mockReturnValue({ isAtMaxZoom: false, isAtMinZoom: true })
128
+ rerender(<MoveControl />)
129
+ expect(screen.getByRole('button', { name: 'Zoom in' })).not.toHaveAttribute('aria-disabled')
130
+ expect(screen.getByRole('button', { name: 'Zoom out' })).toHaveAttribute('aria-disabled', 'true')
131
+ })
132
+
133
+ it('does not zoom when the relevant button is disabled at max/min zoom', () => {
134
+ useMap.mockReturnValue({ isAtMaxZoom: true, isAtMinZoom: true })
135
+ render(<MoveControl />)
136
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom in' }))
137
+ fireEvent.click(screen.getByRole('button', { name: 'Zoom out' }))
138
+ expect(mapProvider.zoomIn).not.toHaveBeenCalled()
139
+ expect(mapProvider.zoomOut).not.toHaveBeenCalled()
140
+ })
141
+
142
+ it('has a stable "Precision" label regardless of state', () => {
143
+ const { rerender } = render(<MoveControl />)
144
+ expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
145
+
146
+ useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
147
+ rerender(<MoveControl />)
148
+ expect(screen.getByRole('button', { name: 'Precision' })).toBeInTheDocument()
149
+ })
150
+
151
+ it('shows an aria-hidden (On)/(Off) suffix in the tooltip without affecting the accessible name', () => {
152
+ // The (On)/(Off) suffix lives in the Tooltip's content div (a sibling of the
153
+ // <button>, referenced via aria-labelledby), not inside the button itself. Several
154
+ // tooltips exist in the DOM (one per icon-only button), so resolve this button's
155
+ // own tooltip via its aria-labelledby id rather than grabbing the first one.
156
+ const getOwnTooltip = () => {
157
+ const button = screen.getByRole('button', { name: 'Precision' })
158
+ return document.getElementById(button.getAttribute('aria-labelledby'))
159
+ }
160
+
161
+ const { rerender } = render(<MoveControl />)
162
+ let tooltip = getOwnTooltip()
163
+ expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(Off)')
164
+ expect(tooltip).toHaveTextContent('Precision (Off)')
165
+
166
+ useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
167
+ rerender(<MoveControl />)
168
+ tooltip = getOwnTooltip()
169
+ expect(tooltip.querySelector('[aria-hidden="true"]')).toHaveTextContent('(On)')
170
+ expect(tooltip).toHaveTextContent('Precision (On)')
171
+ })
172
+
173
+ it('reflects precision mode via aria-pressed, not via label changes', () => {
174
+ const { rerender } = render(<MoveControl />)
175
+ expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'false')
176
+
177
+ useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
178
+ rerender(<MoveControl />)
179
+ expect(screen.getByRole('button', { name: 'Precision' })).toHaveAttribute('aria-pressed', 'true')
180
+ })
181
+
182
+ it('toggles precision on and announces it when currently in large-step mode', () => {
183
+ render(<MoveControl />)
184
+ fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
185
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
186
+ expect(announce).toHaveBeenCalledWith('Precision on')
187
+ })
188
+
189
+ it('toggles precision off and announces it when currently in small-step mode', () => {
190
+ useApp.mockReturnValue(buildAppState({ nudgeStepSize: 'small' }))
191
+ render(<MoveControl />)
192
+ fireEvent.click(screen.getByRole('button', { name: 'Precision' }))
193
+ expect(dispatch).toHaveBeenCalledWith({ type: 'TOGGLE_NUDGE_STEP' })
194
+ expect(announce).toHaveBeenCalledWith('Precision off')
195
+ })
196
+ })
@@ -40,6 +40,7 @@ export const Tabs = ({ tabs, defaultTab }) => { // NOSONAR: project does not use
40
40
  <div role='tablist' className='im-c-tabs__list'>
41
41
  {tabs.map(({ name }) => (
42
42
  <button
43
+ type='button'
43
44
  key={name}
44
45
  id={toTabId(name)}
45
46
  role='tab'
@@ -77,40 +77,17 @@ describe('Tabs — WCAG attributes', () => {
77
77
  // ─── WCAG keyboard navigation ─────────────────────────────────────────────────
78
78
 
79
79
  describe('Tabs — WCAG keyboard navigation', () => {
80
- it('ArrowRight moves to next tab', () => {
81
- render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} />)
82
- fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'ArrowRight' })
83
- expect(screen.getByRole('tab', { name: 'Beta' })).toHaveAttribute('aria-selected', 'true')
84
- })
85
-
86
- it('ArrowLeft moves to previous tab', () => {
87
- render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} defaultTab='Beta' />)
88
- fireEvent.keyDown(screen.getByRole('tab', { name: 'Beta' }), { key: 'ArrowLeft' })
89
- expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
90
- })
91
-
92
- it('ArrowRight wraps from last tab to first', () => {
93
- render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} defaultTab='Gamma' />)
94
- fireEvent.keyDown(screen.getByRole('tab', { name: 'Gamma' }), { key: 'ArrowRight' })
95
- expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
96
- })
97
-
98
- it('ArrowLeft wraps from first tab to last', () => {
99
- render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} />)
100
- fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'ArrowLeft' })
101
- expect(screen.getByRole('tab', { name: 'Gamma' })).toHaveAttribute('aria-selected', 'true')
102
- })
103
-
104
- it('Home moves to first tab', () => {
105
- render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} defaultTab='Gamma' />)
106
- fireEvent.keyDown(screen.getByRole('tab', { name: 'Gamma' }), { key: 'Home' })
107
- expect(screen.getByRole('tab', { name: 'Alpha' })).toHaveAttribute('aria-selected', 'true')
108
- })
109
-
110
- it('End moves to last tab', () => {
111
- render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} />)
112
- fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'End' })
113
- expect(screen.getByRole('tab', { name: 'Gamma' })).toHaveAttribute('aria-selected', 'true')
80
+ it.each([
81
+ ['ArrowRight moves to next tab', undefined, 'Alpha', 'ArrowRight', 'Beta'],
82
+ ['ArrowLeft moves to previous tab', 'Beta', 'Beta', 'ArrowLeft', 'Alpha'],
83
+ ['ArrowRight wraps from last tab to first', 'Gamma', 'Gamma', 'ArrowRight', 'Alpha'],
84
+ ['ArrowLeft wraps from first tab to last', undefined, 'Alpha', 'ArrowLeft', 'Gamma'],
85
+ ['Home moves to first tab', 'Gamma', 'Gamma', 'Home', 'Alpha'],
86
+ ['End moves to last tab', undefined, 'Alpha', 'End', 'Gamma']
87
+ ])('%s', (_description, defaultTab, focusedTab, key, expectedTab) => {
88
+ render(<Tabs tabs={[TAB_A, TAB_B, TAB_C]} defaultTab={defaultTab} />)
89
+ fireEvent.keyDown(screen.getByRole('tab', { name: focusedTab }), { key })
90
+ expect(screen.getByRole('tab', { name: expectedTab })).toHaveAttribute('aria-selected', 'true')
114
91
  })
115
92
 
116
93
  it('unhandled keys do not change the active tab', () => {
@@ -1,5 +1,6 @@
1
1
  import { reverseGeocode, hasReverseGeocode } from '../../services/reverseGeocode.js'
2
2
  import { logger } from '../../services/logger.js'
3
+ import { resolveStepAmount } from '../../utils/resolveNudgeStep.js'
3
4
 
4
5
  export const createKeyboardActions = (mapProvider, announce, {
5
6
  containerRef,
@@ -10,8 +11,9 @@ export const createKeyboardActions = (mapProvider, announce, {
10
11
  nudgeZoomDelta,
11
12
  readMapText
12
13
  }) => {
13
- const getPan = (shift) => (shift ? nudgePanDelta : panDelta)
14
- const getZoom = (shift) => (shift ? nudgeZoomDelta : zoomDelta)
14
+ // Shift held selects the fine nudge amount (the smaller value), not a "large step".
15
+ const getPan = (shift) => resolveStepAmount(!shift, nudgePanDelta, panDelta)
16
+ const getZoom = (shift) => resolveStepAmount(!shift, nudgeZoomDelta, zoomDelta)
15
17
 
16
18
  return {
17
19
  showKeyboardControls: (e) => {
@@ -244,7 +244,7 @@ describe('useMarkers — cleanup', () => {
244
244
  let cleanup
245
245
  act(() => { cleanup = result.current.markerRef('m1')(ctx.mockElement) })
246
246
  const updateCallback = ctx.mockEventBus.on.mock.calls.find(call => call[0] === 'map:render')[1]
247
- act(() => { if (cleanup) cleanup() })
247
+ if (cleanup) cleanup()
248
248
  expect(ctx.mockEventBus.off).toHaveBeenCalledWith('map:render', updateCallback)
249
249
  })
250
250
 
@@ -178,10 +178,10 @@ describe('pluginRegistry', () => {
178
178
 
179
179
  pluginRegistry.registerPlugin(pluginA)
180
180
  pluginRegistry.registerPlugin(pluginB)
181
- expect(pluginRegistry.registeredPlugins.length).toBe(2)
181
+ expect(pluginRegistry.registeredPlugins).toHaveLength(2)
182
182
 
183
183
  pluginRegistry.clear()
184
- expect(pluginRegistry.registeredPlugins.length).toBe(0)
184
+ expect(pluginRegistry.registeredPlugins).toHaveLength(0)
185
185
  expect(pluginRegistry.registeredPlugins).toEqual([])
186
186
  })
187
187
  })
@@ -98,7 +98,7 @@ describe('mapButtons module', () => {
98
98
  // -------------------------
99
99
  describe('getMatchingButtons', () => {
100
100
  const testFilter = (config, expected) => {
101
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp }).length).toBe(expected)
101
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp })).toHaveLength(expected)
102
102
  }
103
103
 
104
104
  it('returns empty array when buttonConfig is null', () => testFilter(null, 0))
@@ -117,41 +117,41 @@ describe('mapButtons module', () => {
117
117
  it('filters out buttons with inline:false when not in fullscreen', () => {
118
118
  const config = { b1: { ...baseBtn, inline: false } }
119
119
  const state = { ...appState, isFullscreen: false }
120
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(0)
120
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp })).toHaveLength(0)
121
121
  })
122
122
 
123
123
  it('includes buttons with inline:false when in fullscreen', () => {
124
124
  const config = { b1: { ...baseBtn, inline: false } }
125
125
  const state = { ...appState, isFullscreen: true }
126
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(1)
126
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp })).toHaveLength(1)
127
127
  })
128
128
 
129
129
  it('includes buttons without inline property regardless of fullscreen state', () => {
130
130
  const config = { b1: baseBtn }
131
131
  const state = { ...appState, isFullscreen: false }
132
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(1)
132
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp })).toHaveLength(1)
133
133
  })
134
134
 
135
135
  it('filters out buttons with isMenuItem:true', () => {
136
136
  const config = { b1: { ...baseBtn, isMenuItem: true } }
137
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp }).length).toBe(0)
137
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp })).toHaveLength(0)
138
138
  })
139
139
 
140
140
  it('does not filter out buttons without isMenuItem', () => {
141
141
  const config = { b1: baseBtn, b2: { ...baseBtn, isMenuItem: false } }
142
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp }).length).toBe(2)
142
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState, evaluateProp })).toHaveLength(2)
143
143
  })
144
144
 
145
145
  it('filters out panel-toggle button when panel is open and non-dismissible at current breakpoint', () => {
146
146
  const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissible: false } } } }
147
147
  const config = { b1: { ...baseBtn, panelId: 'myPanel' } }
148
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(0)
148
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp })).toHaveLength(0)
149
149
  })
150
150
 
151
151
  it('includes panel-toggle button when panel is dismissible at current breakpoint', () => {
152
152
  const state = { ...appState, panelConfig: { myPanel: { desktop: { open: true, dismissible: true } } } }
153
153
  const config = { b1: { ...baseBtn, panelId: 'myPanel' } }
154
- expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp }).length).toBe(1)
154
+ expect(getMatchingButtons({ buttonConfig: config, slot: 'header', appState: state, evaluateProp })).toHaveLength(1)
155
155
  })
156
156
  })
157
157
 
@@ -12,7 +12,7 @@ export function mapControls ({ slot, appState, evaluateProp }) {
12
12
  const { breakpoint, mode, pluginRegistry, controlConfig } = appState
13
13
 
14
14
  return Object.values(controlConfig)
15
- .filter(control => {
15
+ .filter(control => { // NOSONAR, extracting to a helper wouldn't necessarily improve readability
16
16
  // Consumer HTML controls are managed by HtmlElementHost
17
17
  if (isConsumerHtml(control)) {
18
18
  return false
@@ -23,6 +23,11 @@ export function mapControls ({ slot, appState, evaluateProp }) {
23
23
  return false
24
24
  }
25
25
 
26
+ // Dynamic exclusion
27
+ if (typeof control.excludeWhen === 'function' && evaluateProp(control.excludeWhen, control.pluginId)) {
28
+ return false
29
+ }
30
+
26
31
  const slotAllowed = allowedSlots.control.includes(bpConfig.slot)
27
32
  const inModeWhitelist = control.includeModes?.includes(mode) ?? true
28
33
  const inExcludeModes = control.excludeModes?.includes(mode) ?? false
@@ -63,6 +63,30 @@ describe('mapControls', () => {
63
63
  expect(result).toEqual([])
64
64
  })
65
65
 
66
+ it('filters out controls when excludeWhen evaluates truthy', () => {
67
+ defaultAppState.controlConfig = ({
68
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, excludeWhen: () => true }
69
+ })
70
+ const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p() })
71
+ expect(result).toEqual([])
72
+ })
73
+
74
+ it('includes controls when excludeWhen evaluates falsy', () => {
75
+ defaultAppState.controlConfig = ({
76
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, excludeWhen: () => false }
77
+ })
78
+ const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p() })
79
+ expect(result.map(c => c.id)).toEqual(['ctrl1'])
80
+ })
81
+
82
+ it('ignores excludeWhen when it is not a function', () => {
83
+ defaultAppState.controlConfig = ({
84
+ ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, excludeWhen: true }
85
+ })
86
+ const result = mapControls({ slot: 'header', appState: defaultAppState, evaluateProp: (p) => p() })
87
+ expect(result.map(c => c.id)).toEqual(['ctrl1'])
88
+ })
89
+
66
90
  it('filters by excludeModes', () => {
67
91
  defaultAppState.controlConfig = ({
68
92
  ctrl1: { id: 'ctrl1', desktop: { slot: 'header', order: 1 }, excludeModes: ['view'] }
@@ -23,6 +23,7 @@ export const allowedSlots = Object.freeze({
23
23
  layoutSlots.TOP_LEFT,
24
24
  layoutSlots.TOP_RIGHT,
25
25
  layoutSlots.MIDDLE,
26
+ layoutSlots.RIGHT_TOP,
26
27
  layoutSlots.RIGHT_BOTTOM,
27
28
  layoutSlots.BOTTOM_RIGHT,
28
29
  layoutSlots.DRAWER,