@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.
- 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 +15 -1
- package/package.json +1 -1
- package/plugins/beta/draw-es/dist/esm/im-draw-es-plugin.js +1 -1
- package/plugins/beta/draw-es/src/events.js +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/css/index.css +1 -1
- package/plugins/datasets/dist/esm/esriLayerAdapter.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-plugin.js +1 -1
- package/plugins/datasets/src/adapters/esri/esriLayerAdapter.js +20 -1
- package/plugins/datasets/src/adapters/esri/esriLayerAdapter.test.js +1 -0
- package/plugins/datasets/src/components/LayersMenu/Layers.module.scss +3 -0
- package/plugins/datasets/src/components/LayersMenu/LayersMenuGroupWrapper.jsx +3 -1
- package/plugins/datasets/src/components/LayersMenu/LayersRadioGroupWrapper.jsx +3 -1
- package/plugins/datasets/src/initialise/initialiseDatasets.js +2 -2
- package/plugins/datasets/src/reducers/pluginState.js +2 -0
- package/plugins/datasets/src/registry/datasetRegistry.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/useMapItemList.js +2 -2
- package/providers/beta/openlayers/src/utils/tileLayers.test.js +1 -1
- package/providers/maplibre/src/utils/queryFeatures.test.js +6 -6
- package/src/App/components/Actions/Actions.jsx +2 -2
- package/src/App/components/MoveControl/MoveControl.jsx +113 -0
- package/src/App/components/MoveControl/MoveControl.module.scss +121 -0
- package/src/App/components/MoveControl/MoveControl.test.jsx +196 -0
- package/src/App/components/Tabs/Tabs.jsx +1 -0
- package/src/App/components/Tabs/Tabs.test.jsx +11 -34
- package/src/App/controls/keyboardActions.js +4 -2
- package/src/App/hooks/useMarkersAPI.test.js +1 -1
- package/src/App/registry/pluginRegistry.test.js +2 -2
- package/src/App/renderer/mapButtons.test.js +8 -8
- package/src/App/renderer/mapControls.js +6 -1
- package/src/App/renderer/mapControls.test.js +24 -0
- package/src/App/renderer/slots.js +1 -0
- package/src/App/store/appActionsMap.js +8 -0
- package/src/App/store/appActionsMap.test.js +12 -0
- package/src/App/store/appReducer.js +1 -0
- package/src/App/store/appReducer.test.js +1 -0
- package/src/config/appConfig.js +41 -2
- package/src/config/appConfig.test.js +45 -4
- package/src/config/defaults.js +1 -0
- package/src/scss/main.scss +1 -0
- package/src/types.js +9 -1
- package/src/utils/resolveNudgeStep.js +2 -0
- 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
|
+
})
|
|
@@ -77,40 +77,17 @@ describe('Tabs — WCAG attributes', () => {
|
|
|
77
77
|
// ─── WCAG keyboard navigation ─────────────────────────────────────────────────
|
|
78
78
|
|
|
79
79
|
describe('Tabs — WCAG keyboard navigation', () => {
|
|
80
|
-
it(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
14
|
-
const
|
|
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
|
-
|
|
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
|
|
181
|
+
expect(pluginRegistry.registeredPlugins).toHaveLength(2)
|
|
182
182
|
|
|
183
183
|
pluginRegistry.clear()
|
|
184
|
-
expect(pluginRegistry.registeredPlugins
|
|
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 })
|
|
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 })
|
|
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 })
|
|
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 })
|
|
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 })
|
|
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 })
|
|
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 })
|
|
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 })
|
|
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'] }
|